Implement bank transfer app flows
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
import SwiftUI
|
||||
|
||||
// Mirrors fragment_accounts.xml (RecyclerView) + item_account.xml layout:
|
||||
// [40dp circle logo] [Name / Number(mono) / Type] [Balance + blocked + send button]
|
||||
// Divider between items, grouped by bank with section headers.
|
||||
struct AccountsView: View {
|
||||
@Environment(HomeViewModel.self) private var vm
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if vm.accounts.isEmpty && !vm.isRefreshing {
|
||||
emptyState
|
||||
} else {
|
||||
accountList
|
||||
}
|
||||
}
|
||||
.navigationTitle("Accounts")
|
||||
.toolbar {
|
||||
if vm.isRefreshing {
|
||||
ToolbarItem(placement: .topBarTrailing) { ProgressView() }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
withAnimation { vm.hideAmounts.toggle() }
|
||||
} label: {
|
||||
Image(systemName: vm.hideAmounts ? "eye.slash" : "eye")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Account list
|
||||
|
||||
private var accountList: some View {
|
||||
List {
|
||||
bankSection(title: "MIB Faisanet", accounts: vm.mibAccounts, color: bankColor("MIB"))
|
||||
bankSection(title: "Bank of Maldives", accounts: vm.bmlAccounts, color: bankColor("BML"))
|
||||
bankSection(title: "Fahipay", accounts: vm.fahipayAccounts, color: bankColor("FAHIPAY"))
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.refreshable { await vm.refresh() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func bankSection(title: String, accounts: [BankAccount], color: Color) -> some View {
|
||||
if !accounts.isEmpty {
|
||||
Section {
|
||||
ForEach(accounts) { account in
|
||||
AccountRow(account: account, hideAmounts: vm.hideAmounts)
|
||||
.listRowInsets(EdgeInsets()) // remove default insets — row provides its own
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
} header: {
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(color).frame(width: 8, height: 8)
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(nil)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty state
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "building.columns")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("No accounts yet")
|
||||
.font(.headline)
|
||||
Text("Your accounts will appear here after logging in to a bank.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Account row (item_account.xml)
|
||||
// Layout: [40dp circle] [Name / Number(mono) / Type] → [Balance / Blocked / SendBtn(40dp)]
|
||||
// Padding: 16dp horizontal, 14dp vertical. Divider below.
|
||||
|
||||
private struct AccountRow: View {
|
||||
let account: BankAccount
|
||||
let hideAmounts: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
// ── Bank logo circle (ShapeableImageView 40dp) ──────────────────
|
||||
bankLogo
|
||||
.padding(.trailing, 12)
|
||||
|
||||
// ── Left: name / number / type ──────────────────────────────────
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(displayName)
|
||||
.font(.subheadline.weight(.medium)) // textAppearanceTitleMedium
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(account.accountNumber)
|
||||
.font(.callout) // textAppearanceTitleSmall
|
||||
.foregroundStyle(.secondary)
|
||||
.fontDesign(.monospaced) // android:fontFamily="monospace"
|
||||
.lineLimit(1)
|
||||
|
||||
if !account.accountTypeName.isEmpty {
|
||||
Text(account.accountTypeName)
|
||||
.font(.caption) // textAppearanceBodySmall
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 16)
|
||||
|
||||
// ── Right: balance / blocked / send button ──────────────────────
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(hideAmounts ? "••••••" : account.formattedAvailableBalance)
|
||||
.font(.callout.weight(.medium)) // textAppearanceTitleSmall
|
||||
.foregroundStyle(.primary)
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
|
||||
if account.blockedAmount > 0 && !hideAmounts {
|
||||
Text("Blocked: \(account.currencyName) \(String(format: "%,.2f", account.blockedAmount))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
// Transfer button (40x40dp, borderless, tinted primary)
|
||||
Button { /* Transfer — Phase 7 */ } label: {
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(.tint)
|
||||
.frame(width: 40, height: 40)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
|
||||
// Divider (1dp, 16dp horizontal margin, colorOutlineVariant)
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private var displayName: String {
|
||||
account.accountBriefName.isEmpty ? account.accountTypeName : account.accountBriefName
|
||||
}
|
||||
|
||||
private var bankLogo: some View {
|
||||
Circle()
|
||||
.fill(color.opacity(0.12))
|
||||
.frame(width: 40, height: 40)
|
||||
.overlay {
|
||||
Text(String(account.bank.prefix(1)))
|
||||
.font(.headline)
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
|
||||
private var color: Color { bankColor(account.bank) }
|
||||
}
|
||||
|
||||
// MARK: - Shared bank color
|
||||
|
||||
private func bankColor(_ bank: String) -> Color {
|
||||
switch bank {
|
||||
case "MIB": return Color(red: 0.247, green: 0.396, blue: 0.678) // #3F65AD primary
|
||||
case "BML": return Color(red: 0.0, green: 0.47, blue: 0.80)
|
||||
case "FAHIPAY": return .purple
|
||||
default: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AccountsView()
|
||||
.environment(HomeViewModel())
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContactsView: View {
|
||||
@Environment(HomeViewModel.self) private var homeVM
|
||||
@State private var searchText = ""
|
||||
|
||||
private let primary = Color(red: 0.247, green: 0.396, blue: 0.678)
|
||||
|
||||
private var filtered: [BankContact] {
|
||||
homeVM.contacts(matching: searchText)
|
||||
}
|
||||
|
||||
private var grouped: [(String, [BankContact])] {
|
||||
let order = ["MIB", "BML", "FAHIPAY"]
|
||||
return order.compactMap { source in
|
||||
let group = filtered.filter { $0.source == source }
|
||||
return group.isEmpty ? nil : (source, group)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if homeVM.isLoadingContacts && homeVM.contacts.isEmpty {
|
||||
loadingView
|
||||
} else if filtered.isEmpty && homeVM.contactsError == nil && !homeVM.isLoadingContacts {
|
||||
emptyView
|
||||
} else {
|
||||
contactsList
|
||||
}
|
||||
}
|
||||
.navigationTitle("Contacts")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.searchable(text: $searchText, prompt: "Search name or account")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if homeVM.isLoadingContacts {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Button {
|
||||
Task { await homeVM.fetchContacts() }
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await homeVM.fetchContactsIfNeeded()
|
||||
}
|
||||
.refreshable {
|
||||
await homeVM.fetchContacts()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Contact list
|
||||
|
||||
private var contactsList: some View {
|
||||
List {
|
||||
if let err = homeVM.contactsError {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("Fetch error", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.orange)
|
||||
Text(err)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
ForEach(grouped, id: \.0) { source, contacts in
|
||||
Section(header: sectionHeader(source)) {
|
||||
ForEach(contacts) { contact in
|
||||
ContactRow(contact: contact) {
|
||||
homeVM.pendingTransferContact = contact
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
|
||||
// MARK: - Section header
|
||||
|
||||
private func sectionHeader(_ source: String) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(bankColor(source))
|
||||
.frame(width: 8, height: 8)
|
||||
Text(bankFullName(source))
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(bankColor(source))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty / loading
|
||||
|
||||
private var emptyView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "person.badge.plus")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(searchText.isEmpty ? "No saved contacts" : "No results for \"\(searchText)\"")
|
||||
.font(.title3.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
if searchText.isEmpty {
|
||||
Text("After a successful transfer, tap \"Save Contact\" on the receipt to add the recipient here.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var loadingView: some View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
Text("Loading contacts…")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func bankColor(_ source: String) -> Color {
|
||||
switch source {
|
||||
case "MIB": return Color(red: 0.247, green: 0.396, blue: 0.678)
|
||||
case "BML": return .blue
|
||||
case "FAHIPAY": return .purple
|
||||
default: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
private func bankFullName(_ source: String) -> String {
|
||||
switch source {
|
||||
case "MIB": return "Maldives Islamic Bank"
|
||||
case "BML": return "Bank of Maldives"
|
||||
case "FAHIPAY": return "Fahipay"
|
||||
default: return source
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Contact row
|
||||
|
||||
struct ContactRow: View {
|
||||
let contact: BankContact
|
||||
var onSend: () -> Void
|
||||
|
||||
private let primary = Color(red: 0.247, green: 0.396, blue: 0.678)
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
avatarCircle
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(contact.displayName)
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 6) {
|
||||
Text(contact.benefAccount)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
if let bank = contact.benefBankName, bank != "Maldives Islamic Bank", bank != "Bank of Maldives" {
|
||||
Text("·")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(bank)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
typeBadge
|
||||
}
|
||||
Spacer()
|
||||
Button(action: onSend) {
|
||||
Image(systemName: "paperplane.fill")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(primary)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private var avatarCircle: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(avatarColor.opacity(0.15))
|
||||
.frame(width: 40, height: 40)
|
||||
Text(initials)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(avatarColor)
|
||||
}
|
||||
}
|
||||
|
||||
private var typeBadge: some View {
|
||||
Text(typeLabel)
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(typeColor.opacity(0.12))
|
||||
.foregroundStyle(typeColor)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
private var initials: String {
|
||||
let words = contact.displayName.split(separator: " ").prefix(2)
|
||||
return words.compactMap { $0.first }.map(String.init).joined()
|
||||
}
|
||||
|
||||
private var avatarColor: Color {
|
||||
switch contact.source {
|
||||
case "MIB": return Color(red: 0.247, green: 0.396, blue: 0.678)
|
||||
case "BML": return .blue
|
||||
case "FAHIPAY": return .purple
|
||||
default: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
private var typeLabel: String {
|
||||
switch contact.benefType {
|
||||
case "MIB": return "MIB"
|
||||
case "LOCAL": return "IPS"
|
||||
case "SWIFT": return "SWIFT"
|
||||
case "BML": return "BML"
|
||||
default: return contact.benefType
|
||||
}
|
||||
}
|
||||
|
||||
private var typeColor: Color {
|
||||
switch contact.benefType {
|
||||
case "SWIFT": return .orange
|
||||
case "LOCAL": return .green
|
||||
default: return primary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContactsView()
|
||||
.environment(HomeViewModel())
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
import SwiftUI
|
||||
|
||||
// Mirrors fragment_dashboard.xml:
|
||||
// • 2-column balance cards (MVR + USD) — always shown
|
||||
// • Credit row — visible only when credit/prepaid cards exist
|
||||
// • Blocked funds row — visible only when blockedAmount > 0
|
||||
// • Card carousel — visible only when card accounts exist
|
||||
// • Error banners
|
||||
// • Fixed bottom quick-action bar (2 outlined buttons, outside scroll)
|
||||
struct DashboardView: View {
|
||||
@Environment(HomeViewModel.self) private var vm
|
||||
@Environment(AppViewModel.self) private var app
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// Indeterminate progress bar while loading (mirrors LinearProgressIndicator)
|
||||
if vm.isRefreshing {
|
||||
ProgressView(value: nil as Double?)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(.accentColor)
|
||||
.frame(height: 3)
|
||||
}
|
||||
|
||||
// Scrollable content area
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
|
||||
// ── 1. Balance summary row (MVR + USD) ───────────────────
|
||||
HStack(spacing: 8) {
|
||||
BalanceCard(label: "MVR Balance",
|
||||
value: vm.fmt(vm.totalMvrBalance, currency: "MVR"),
|
||||
hidden: vm.hideAmounts,
|
||||
style: .normal)
|
||||
BalanceCard(label: "USD Balance",
|
||||
value: vm.fmt(vm.totalUsdBalance, currency: "USD"),
|
||||
hidden: vm.hideAmounts,
|
||||
style: .normal)
|
||||
}
|
||||
|
||||
// ── 2. Available credit row (conditional) ────────────────
|
||||
if vm.hasCreditAccounts {
|
||||
HStack(spacing: 8) {
|
||||
BalanceCard(label: "MVR Available Credit",
|
||||
value: vm.fmt(vm.totalMvrCredit, currency: "MVR"),
|
||||
hidden: vm.hideAmounts,
|
||||
style: .normal)
|
||||
BalanceCard(label: "USD Available Credit",
|
||||
value: vm.fmt(vm.totalUsdCredit, currency: "USD"),
|
||||
hidden: vm.hideAmounts,
|
||||
style: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Blocked funds row (conditional) ───────────────────
|
||||
if vm.totalBlockedMvr > 0 || vm.totalBlockedUsd > 0 {
|
||||
HStack(spacing: 8) {
|
||||
if vm.totalBlockedMvr > 0 {
|
||||
BalanceCard(label: "Blocked MVR",
|
||||
value: vm.fmt(vm.totalBlockedMvr, currency: "MVR"),
|
||||
hidden: vm.hideAmounts,
|
||||
style: .error)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
if vm.totalBlockedUsd > 0 {
|
||||
BalanceCard(label: "Blocked USD",
|
||||
value: vm.fmt(vm.totalBlockedUsd, currency: "USD"),
|
||||
hidden: vm.hideAmounts,
|
||||
style: .error)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
// Pad when only one side has a value
|
||||
if vm.totalBlockedMvr == 0 || vm.totalBlockedUsd == 0 {
|
||||
Spacer().frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Pending finances (MIB) ────────────────────────────
|
||||
if vm.totalPendingFinances > 0 {
|
||||
BalanceCard(label: "Pending Finances",
|
||||
value: vm.fmt(vm.totalPendingFinances, currency: "MVR"),
|
||||
hidden: vm.hideAmounts,
|
||||
style: .normal)
|
||||
}
|
||||
|
||||
// ── 5. Foreign transaction limits (BML) ──────────────────
|
||||
ForEach(vm.foreignLimits, id: \.0) { userName, limits in
|
||||
ForeignLimitsCard(userName: userName, limits: limits, hidden: vm.hideAmounts)
|
||||
}
|
||||
|
||||
// ── 6. Card carousel ─────────────────────────────────────
|
||||
if !vm.cardAccounts.isEmpty {
|
||||
cardCarousel
|
||||
}
|
||||
|
||||
// ── 7. Error banners ─────────────────────────────────────
|
||||
ForEach(vm.bankErrors.sorted(by: { $0.key < $1.key }), id: \.key) { bank, msg in
|
||||
ErrorBanner(bank: bank, message: msg)
|
||||
}
|
||||
|
||||
// ── 8. Loading skeletons (no data yet) ───────────────────
|
||||
if vm.isRefreshing && vm.accounts.isEmpty {
|
||||
ForEach(0..<3, id: \.self) { _ in SkeletonCard() }
|
||||
}
|
||||
|
||||
// ── 9. Empty state ───────────────────────────────────────
|
||||
if !vm.isRefreshing && vm.accounts.isEmpty && vm.bankErrors.isEmpty {
|
||||
emptyState
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.refreshable { await vm.refresh() }
|
||||
|
||||
Divider()
|
||||
|
||||
// ── Fixed bottom quick-action bar ────────────────────────────────
|
||||
HStack(spacing: 8) {
|
||||
QuickActionButton(icon: "arrow.up.right", label: "Transfer") {}
|
||||
QuickActionButton(icon: "qrcode", label: "PayMV QR") {}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, max(16, 0))
|
||||
}
|
||||
.background(Color(.systemBackground))
|
||||
.navigationTitle("Thijooree")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbarItems }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Card Carousel
|
||||
|
||||
private var cardCarousel: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(vm.cardAccounts) { card in
|
||||
CardCarouselItem(account: card, hide: vm.hideAmounts)
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Extend carousel to bleed past the 16dp parent padding
|
||||
.padding(.horizontal, -16)
|
||||
.padding(.leading, 16)
|
||||
}
|
||||
|
||||
// MARK: - Toolbar (matches toolbar_menu.xml: bell + visibility + lock)
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbarItems: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack(spacing: 4) {
|
||||
Button { /* notifications — Phase 12 */ } label: {
|
||||
Image(systemName: "bell")
|
||||
}
|
||||
Button {
|
||||
withAnimation { vm.hideAmounts.toggle() }
|
||||
} label: {
|
||||
Image(systemName: vm.hideAmounts ? "eye.slash" : "eye")
|
||||
}
|
||||
Button { app.lock() } label: {
|
||||
Image(systemName: "lock")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty state
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "building.columns")
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("No accounts")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Pull to refresh or log in to a bank.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.tertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Balance card (mirrors MaterialCardView with 12dp radius, 1dp elevation, 16dp padding)
|
||||
|
||||
private struct BalanceCard: View {
|
||||
enum Style { case normal, error }
|
||||
|
||||
let label: String
|
||||
let value: String
|
||||
let hidden: Bool
|
||||
let style: Style
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(style == .error ? errorLabelColor : Color.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
Text(hidden ? hiddenValue : value)
|
||||
.font(.headline)
|
||||
.foregroundStyle(style == .error ? errorValueColor : Color.primary)
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(style == .error ? Color(.systemRed).opacity(0.12) : Color(.secondarySystemGroupedBackground),
|
||||
in: RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(color: .black.opacity(0.06), radius: 1, x: 0, y: 1)
|
||||
}
|
||||
|
||||
private var hiddenValue: String {
|
||||
String(value.prefix(4)) + " ••••••"
|
||||
}
|
||||
|
||||
private var errorLabelColor: Color { Color(.systemRed).opacity(0.8) }
|
||||
private var errorValueColor: Color { Color(.systemRed) }
|
||||
}
|
||||
|
||||
// MARK: - Card carousel item
|
||||
|
||||
struct CardCarouselItem: View {
|
||||
let account: BankAccount
|
||||
let hide: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
// Real card art — fall back to colour gradient if no asset
|
||||
if let name = cardImageName, UIImage(named: name) != nil {
|
||||
Image(name)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 300, height: 180)
|
||||
.clipped()
|
||||
} else {
|
||||
Rectangle()
|
||||
.fill(cardGradient)
|
||||
.frame(width: 300, height: 180)
|
||||
}
|
||||
|
||||
// Scrim for text legibility
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .clear, location: 0.35),
|
||||
.init(color: .black.opacity(0.65), location: 1.0)
|
||||
],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
)
|
||||
|
||||
// Name + masked number
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(account.profileName.uppercased())
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.shadow(color: .black.opacity(0.5), radius: 3, x: 1, y: 1)
|
||||
Text(maskedNumber)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.fontDesign(.monospaced)
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
HStack(spacing: 4) {
|
||||
CardActionButton(icon: "qrcode", label: "Scan to Pay") {}
|
||||
CardActionButton(icon: "wave.3.right", label: "Tap to Pay") {}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
.frame(width: 300)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.shadow(color: .black.opacity(0.12), radius: 4, x: 0, y: 2)
|
||||
}
|
||||
|
||||
// MARK: - Image name resolution
|
||||
|
||||
private var cardImageName: String? {
|
||||
switch account.bank {
|
||||
case "BML": return "card_bml_\(bmlAsset(account.productCode))"
|
||||
case "MIB": return mibCardImageName(account.productCode)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors BmlCardParser.productCodeToAsset() exactly
|
||||
private func bmlAsset(_ code: String) -> String {
|
||||
switch code {
|
||||
case "C8201","C8001","C8009": return "master_prepaid"
|
||||
case "C8205","C8005","C8008": return "master_prepaid_travel"
|
||||
case "C3007","C3017","C3097","C3095","C3077","C3177": return "amex_debit_green"
|
||||
case "C3003","C3013","C3053","C3023","C3033","C3052": return "amex_debit_gold"
|
||||
case "C3009","C3019","C3029","C3099","C3088","C3188": return "amex_credit_gold"
|
||||
case "C3001","C3011","C3050","C3051","C3031": return "amex_credit_green"
|
||||
case "C3005","C3015","C3055","C3054": return "amex_platinum"
|
||||
case "C1003","C1013","C1083","C1084","C1103","C1113","C1183","C1184": return "visa_gold"
|
||||
case "C1007","C1027","C1097","C1107","C1197","C1077","C1177": return "visa_debit"
|
||||
case "C1020","C1021": return "visa_debit_platinum"
|
||||
case "C8020","C8022": return "master_gold"
|
||||
case "C8902","C8907","C8909","C8912","C8992","C8996","C8997","C8982","C8983": return "master_islamic"
|
||||
case "C8101": return "master_masveriyaa"
|
||||
case "C8102": return "master_odiveriyaa"
|
||||
case "C8010","C8011": return "master_platinum"
|
||||
case "C8040","C8044": return "master_world"
|
||||
case "C8030","C8033": return "master_business_debit"
|
||||
case "C8901","C8991","C8980","C8981": return "master_passport"
|
||||
case "C1090","C1130","C1033","C1133": return "visa_corporate"
|
||||
case "C8905","C8995": return "visa_credit"
|
||||
case "C1001","C1011","C1082","C1081","C1101","C1111","C1181","C1182": return "visa_debit_generic"
|
||||
case "C1005","C1006","C1030","C1089": return "visa_debit_islamic"
|
||||
case "C1017": return "visa_infinite"
|
||||
case "C1009","C1019","C1085","C1086","C1109","C1119","C1185","C1186": return "visa_platinum"
|
||||
case "C1050","C1051","C1087","C1088","C1150","C1151","C1187","C1188",
|
||||
"C1040","C1041","C1047","C1048","C1140","C1141","C1147","C1148": return "visa_student_black"
|
||||
case "C8925","C8926": return "visa_student_blue"
|
||||
case "C1071","C1073","C1061","C1063","C1161","C1163": return "master"
|
||||
case "C1070","C1072","C1059","C1062","C1159","C1162": return "master_prepaid_business"
|
||||
default: return "defaultcard"
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors CardsFragment.cardImageAsset(MibCard)
|
||||
private func mibCardImageName(_ cardType: String) -> String? {
|
||||
switch cardType {
|
||||
case "51": return "card_mib_faisa_card"
|
||||
case "53": return "card_mib_visa_black_platinum"
|
||||
case "57": return "card_mib_visa_blue_everyday"
|
||||
case "70": return "card_mib_visa_business"
|
||||
case "701": return "card_mib_visa_bingaa_mvr"
|
||||
case "702": return "card_mib_visa_bingaa_usd"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var maskedNumber: String {
|
||||
let raw = account.accountNumber
|
||||
guard raw.count >= 4 else { return raw }
|
||||
if hide { return "•••• •••• •••• ••••" }
|
||||
return "•••• \(String(raw.suffix(4)))"
|
||||
}
|
||||
|
||||
private var cardGradient: LinearGradient {
|
||||
switch account.bank {
|
||||
case "BML": return LinearGradient(colors: [Color(red: 0.1, green: 0.3, blue: 0.7),
|
||||
Color(red: 0.05, green: 0.15, blue: 0.4)],
|
||||
startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
default: return LinearGradient(colors: [Color(red: 0.15, green: 0.5, blue: 0.25),
|
||||
Color(red: 0.05, green: 0.3, blue: 0.15)],
|
||||
startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Card action button (matches TonalButton style, 11sp text, 16dp icon)
|
||||
|
||||
private struct CardActionButton: View {
|
||||
let icon: String
|
||||
let label: String
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Label(label, systemImage: icon)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.tint.opacity(0.15), in: RoundedRectangle(cornerRadius: 8))
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Quick action button (matches Widget.Material3.Button.OutlinedButton)
|
||||
|
||||
private struct QuickActionButton: View {
|
||||
let icon: String
|
||||
let label: String
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Label(label, systemImage: icon)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(.tint, lineWidth: 1))
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error banner
|
||||
|
||||
private struct ErrorBanner: View {
|
||||
let bank: String
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(bank) — could not refresh")
|
||||
.font(.caption.bold())
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(12)
|
||||
.background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Skeleton placeholder card
|
||||
|
||||
private struct SkeletonCard: View {
|
||||
@State private var shimmer = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
SkeletonRect(width: nil, height: 60).frame(maxWidth: .infinity)
|
||||
SkeletonRect(width: nil, height: 60).frame(maxWidth: .infinity)
|
||||
}
|
||||
.opacity(shimmer ? 0.4 : 0.9)
|
||||
.animation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true), value: shimmer)
|
||||
.onAppear { shimmer = true }
|
||||
}
|
||||
}
|
||||
|
||||
private struct SkeletonRect: View {
|
||||
let width: CGFloat?
|
||||
let height: CGFloat
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.systemFill))
|
||||
.frame(width: width, height: height)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Foreign Limits Card
|
||||
|
||||
private struct ForeignLimitsCard: View {
|
||||
let userName: String
|
||||
let limits: [BmlForeignLimit]
|
||||
let hidden: Bool
|
||||
|
||||
@State private var expandedIndices: Set<Int> = []
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// Header: name + card-type badge
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(userName.isEmpty ? "BML" : userName)
|
||||
.font(.subheadline.bold())
|
||||
Text("USD Foreign Transaction Limits")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(limits.first?.type ?? "Debit")
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(.green.opacity(0.2))
|
||||
.foregroundStyle(.green)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
ForEach(Array(limits.enumerated()), id: \.offset) { idx, limit in
|
||||
Divider()
|
||||
LimitCardSection(
|
||||
limit: limit,
|
||||
hidden: hidden,
|
||||
isExpanded: expandedIndices.contains(idx)
|
||||
) {
|
||||
withAnimation(.easeInOut(duration: 0.22)) {
|
||||
if expandedIndices.contains(idx) { expandedIndices.remove(idx) }
|
||||
else { expandedIndices.insert(idx) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(color: .black.opacity(0.06), radius: 1, x: 0, y: 1)
|
||||
}
|
||||
}
|
||||
|
||||
// One limit entry (ECOM + General always; ATM/POS/Medical when expanded)
|
||||
private struct LimitCardSection: View {
|
||||
let limit: BmlForeignLimit
|
||||
let hidden: Bool
|
||||
let isExpanded: Bool
|
||||
let onToggle: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
// Always visible
|
||||
limitRow("Online (ECOM)", remaining: limit.ecomRemaining, total: limit.ecomLimit)
|
||||
limitRow("General", remaining: limit.generalRemaining, total: limit.generalCap)
|
||||
|
||||
// Expand / collapse toggle
|
||||
Button(action: onToggle) {
|
||||
HStack(spacing: 4) {
|
||||
Text(isExpanded ? "Show less" : "Show more")
|
||||
.font(.caption)
|
||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Expanded section: ATM, POS, Medical
|
||||
if isExpanded {
|
||||
Divider()
|
||||
limitRow(
|
||||
limit.isAtmEnabled ? "ATM" : "ATM (Disabled)",
|
||||
remaining: limit.atmRemaining, total: limit.atmLimit
|
||||
)
|
||||
limitRow(
|
||||
limit.isPosEnabled ? "POS" : "POS (Disabled)",
|
||||
remaining: limit.posRemaining, total: limit.posLimit
|
||||
)
|
||||
limitRow("Medical", remaining: limit.medicalRemaining, total: limit.totalLimit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func limitRow(_ label: String, remaining: Double, total: Double) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(label.contains("Disabled") ? Color.secondary.opacity(0.5) : Color.secondary)
|
||||
Spacer()
|
||||
Text(hidden ? "USD ••••••" : fmtLimit(remaining, total))
|
||||
.font(.subheadline.bold())
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(label.contains("Disabled") ? .secondary : .primary)
|
||||
}
|
||||
ProgressView(value: (hidden || total <= 0) ? 0.0 : min(remaining / total, 1.0))
|
||||
.progressViewStyle(.linear)
|
||||
.tint(progressTint(remaining, total))
|
||||
}
|
||||
}
|
||||
|
||||
// "USD 1,234.56 / 5,000" — mirrors Kotlin format
|
||||
private func fmtLimit(_ remaining: Double, _ total: Double) -> String {
|
||||
let rf = NumberFormatter(); rf.numberStyle = .decimal
|
||||
rf.minimumFractionDigits = 2; rf.maximumFractionDigits = 2; rf.usesGroupingSeparator = true
|
||||
let tf = NumberFormatter(); tf.numberStyle = .decimal
|
||||
tf.minimumFractionDigits = 0; tf.maximumFractionDigits = 0; tf.usesGroupingSeparator = true
|
||||
let r = rf.string(from: NSNumber(value: remaining)) ?? "0.00"
|
||||
let t = tf.string(from: NSNumber(value: total)) ?? "0"
|
||||
return "USD \(r) / \(t)"
|
||||
}
|
||||
|
||||
private func progressTint(_ remaining: Double, _ total: Double) -> Color {
|
||||
guard total > 0 else { return .secondary }
|
||||
let ratio = remaining / total
|
||||
if ratio > 0.5 { return .green }
|
||||
if ratio > 0.25 { return .orange }
|
||||
return .red
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
DashboardView()
|
||||
.environment(HomeViewModel())
|
||||
.environment(AppViewModel())
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import SwiftUI
|
||||
|
||||
// Root home container — 5-tab bottom bar matching Android bottom_nav_menu.xml
|
||||
struct HomeView: View {
|
||||
@State private var vm = HomeViewModel()
|
||||
@State private var selectedTab = 0
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
DashboardView()
|
||||
.tabItem { Label("Dashboard", systemImage: "house.fill") }
|
||||
.tag(0)
|
||||
|
||||
AccountsView()
|
||||
.tabItem { Label("Accounts", systemImage: "building.columns.fill") }
|
||||
.tag(1)
|
||||
|
||||
ContactsView()
|
||||
.tabItem { Label("Contacts", systemImage: "person.2.fill") }
|
||||
.tag(2)
|
||||
|
||||
TransferView()
|
||||
.tabItem { Label("Transfer", systemImage: "arrow.up.right") }
|
||||
.tag(3)
|
||||
|
||||
placeholderTab("More", icon: "ellipsis")
|
||||
.tabItem { Label("More", systemImage: "ellipsis") }
|
||||
.tag(4)
|
||||
}
|
||||
.environment(vm)
|
||||
.task { await vm.refreshIfNeeded() }
|
||||
// When a contact's "Send" button is tapped, jump to the Transfer tab
|
||||
.onChange(of: vm.pendingTransferContact) { _, contact in
|
||||
if contact != nil { selectedTab = 3 }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func placeholderTab(_ title: String, icon: String) -> some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(title)
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.systemBackground))
|
||||
.navigationTitle(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
HomeView()
|
||||
.environment(AppViewModel())
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TransferReceiptView: View {
|
||||
let receipt: TransferReceiptData
|
||||
var onSaveContact: ((BankContact) -> Void)? = nil
|
||||
var onDone: () -> Void
|
||||
|
||||
@State private var contactSaved = false
|
||||
|
||||
private let primary = Color(red: 0.247, green: 0.396, blue: 0.678)
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
successHeader
|
||||
detailsCard
|
||||
Spacer(minLength: 32)
|
||||
doneButton
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle("Receipt")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") { onDone() }
|
||||
.foregroundStyle(primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sub-views
|
||||
|
||||
private var successHeader: some View {
|
||||
VStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.green.opacity(0.15))
|
||||
.frame(width: 80, height: 80)
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
.padding(.top, 32)
|
||||
|
||||
Text("Transfer Successful")
|
||||
.font(.title2.bold())
|
||||
|
||||
Text(amountText)
|
||||
.font(.system(size: 36, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(primary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
|
||||
private var detailsCard: some View {
|
||||
VStack(spacing: 0) {
|
||||
row(label: "From", value: receipt.fromAccountNumber)
|
||||
Divider().padding(.leading, 16)
|
||||
row(label: "Bank", value: receipt.fromBankName)
|
||||
Divider().padding(.leading, 16)
|
||||
row(label: "To Account", value: receipt.toAccountNumber)
|
||||
Divider().padding(.leading, 16)
|
||||
row(label: "Beneficiary", value: receipt.toAccountName.isEmpty ? "—" : receipt.toAccountName)
|
||||
if !receipt.reference.isEmpty {
|
||||
Divider().padding(.leading, 16)
|
||||
row(label: "Reference", value: receipt.reference)
|
||||
}
|
||||
if !receipt.date.isEmpty {
|
||||
Divider().padding(.leading, 16)
|
||||
row(label: "Date", value: receipt.date)
|
||||
}
|
||||
if !receipt.message.isEmpty && receipt.message != "Transfer successful" {
|
||||
Divider().padding(.leading, 16)
|
||||
row(label: "Note", value: receipt.message)
|
||||
}
|
||||
}
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private var doneButton: some View {
|
||||
VStack(spacing: 12) {
|
||||
if let onSave = onSaveContact {
|
||||
Button {
|
||||
onSave(makeContact())
|
||||
contactSaved = true
|
||||
} label: {
|
||||
Label(contactSaved ? "Contact Saved" : "Save Contact",
|
||||
systemImage: contactSaved ? "checkmark.circle.fill" : "person.badge.plus")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(contactSaved ? Color.green.opacity(0.15) : Color(.secondarySystemGroupedBackground))
|
||||
.foregroundStyle(contactSaved ? .green : primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.disabled(contactSaved)
|
||||
}
|
||||
Button(action: onDone) {
|
||||
Text("Done")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(primary)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeContact() -> BankContact {
|
||||
let source = receipt.fromBankName.lowercased().contains("islamic") ? "MIB" : "BML"
|
||||
let displayName = receipt.toAccountName.isEmpty ? receipt.toAccountNumber : receipt.toAccountName
|
||||
return BankContact(
|
||||
id: "LOCAL_\(source)_\(receipt.toAccountNumber)",
|
||||
benefNo: receipt.toAccountNumber,
|
||||
benefName: displayName,
|
||||
benefNickName: nil,
|
||||
benefAccount: receipt.toAccountNumber,
|
||||
benefType: source,
|
||||
bankColor: nil,
|
||||
benefBankName: nil,
|
||||
bankCode: nil,
|
||||
benefStatus: "Active",
|
||||
transferCyDesc: receipt.currency,
|
||||
customerImgHash: nil,
|
||||
benefCategoryId: nil,
|
||||
profileId: nil,
|
||||
source: source
|
||||
)
|
||||
}
|
||||
|
||||
private func row(label: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
Text(value)
|
||||
.font(.subheadline)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
private var amountText: String {
|
||||
let f = NumberFormatter()
|
||||
f.numberStyle = .decimal
|
||||
f.minimumFractionDigits = 2
|
||||
f.maximumFractionDigits = 2
|
||||
f.usesGroupingSeparator = true
|
||||
return "\(receipt.currency) \(f.string(from: NSNumber(value: receipt.amount)) ?? "0.00")"
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TransferReceiptView(receipt: TransferReceiptData(
|
||||
fromAccountNumber: "7701234567890",
|
||||
fromBankName: "Bank of Maldives",
|
||||
toAccountNumber: "7709876543210",
|
||||
toAccountName: "Ahmed Mohamed",
|
||||
amount: 1500.00,
|
||||
currency: "MVR",
|
||||
reference: "TXN20260607001",
|
||||
date: "2026-06-07 14:32:00",
|
||||
message: "Transfer successful"
|
||||
)) {}
|
||||
}
|
||||
@@ -0,0 +1,819 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TransferView: View {
|
||||
private enum FocusedField {
|
||||
case toAccount
|
||||
case amount
|
||||
case remarks
|
||||
}
|
||||
|
||||
@Environment(HomeViewModel.self) private var homeVM
|
||||
@State private var vm = TransferViewModel()
|
||||
@State private var showAccountPicker = false
|
||||
@State private var showContactPicker = false
|
||||
@State private var showBmlOtp = false
|
||||
@State private var showQRScanner = false
|
||||
@State private var accountSearchText = ""
|
||||
@State private var contactSearchText = ""
|
||||
@State private var toInputText = ""
|
||||
@State private var amountInputText = ""
|
||||
@State private var purposeInputText = ""
|
||||
@State private var completedReceipt: ReceiptPresentation? = nil
|
||||
@State private var pendingReceiptAfterOtp: TransferReceiptData? = nil
|
||||
@State private var isProgrammaticToInputChange = false
|
||||
@FocusState private var focusedField: FocusedField?
|
||||
|
||||
private let primary = Color(red: 0.247, green: 0.396, blue: 0.678)
|
||||
|
||||
private struct ReceiptPresentation: Identifiable {
|
||||
let id = UUID()
|
||||
let receipt: TransferReceiptData
|
||||
}
|
||||
|
||||
// Accounts eligible to send from
|
||||
private var sendableAccounts: [BankAccount] {
|
||||
homeVM.accounts.filter {
|
||||
($0.bank == "MIB" || $0.bank == "BML") &&
|
||||
!["BML_CREDIT", "BML_DEBIT", "BML_PREPAID", "BML_LOAN", "MIB_CARD"].contains($0.profileType) &&
|
||||
$0.isActive
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredSendableAccounts: [BankAccount] {
|
||||
guard !accountSearchText.isEmpty else { return sendableAccounts }
|
||||
let query = accountSearchText.lowercased()
|
||||
return sendableAccounts.filter {
|
||||
$0.bank.lowercased().contains(query) ||
|
||||
$0.accountNumber.contains(query) ||
|
||||
$0.accountBriefName.lowercased().contains(query) ||
|
||||
$0.accountTypeName.lowercased().contains(query)
|
||||
}
|
||||
}
|
||||
|
||||
private var destinationAccounts: [BankAccount] {
|
||||
homeVM.accounts.filter {
|
||||
($0.bank == "MIB" || $0.bank == "BML") &&
|
||||
$0.profileType != "BML_LOAN" &&
|
||||
$0.id != vm.fromAccount?.id
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredDestinationAccounts: [BankAccount] {
|
||||
guard !contactSearchText.isEmpty else { return destinationAccounts }
|
||||
let query = contactSearchText.lowercased()
|
||||
return destinationAccounts.filter {
|
||||
$0.bank.lowercased().contains(query) ||
|
||||
$0.accountNumber.contains(query) ||
|
||||
$0.accountBriefName.lowercased().contains(query) ||
|
||||
$0.accountTypeName.lowercased().contains(query)
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredContacts: [BankContact] {
|
||||
guard !contactSearchText.isEmpty else { return homeVM.contacts }
|
||||
let query = contactSearchText.lowercased()
|
||||
return homeVM.contacts.filter {
|
||||
$0.displayName.lowercased().contains(query) ||
|
||||
$0.benefAccount.contains(query) ||
|
||||
($0.benefBankName?.lowercased().contains(query) ?? false) ||
|
||||
($0.benefCategoryId?.lowercased().contains(query) ?? false)
|
||||
}
|
||||
}
|
||||
|
||||
private var canSubmitTransfer: Bool {
|
||||
guard let from = vm.fromAccount else { return false }
|
||||
let amount = Double(amountInputText.replacingOccurrences(of: ",", with: "")) ?? 0
|
||||
guard amount > 0 else { return false }
|
||||
switch vm.lookupState {
|
||||
case .found, .bmlReady:
|
||||
return true
|
||||
default:
|
||||
return from.bank == "BML" && !toInputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
private var groupedContacts: [(String, [BankContact])] {
|
||||
let groups = Dictionary(grouping: filteredContacts) { contactCategoryTitle($0) }
|
||||
return groups.keys.sorted().compactMap { key in
|
||||
guard let contacts = groups[key], !contacts.isEmpty else { return nil }
|
||||
return (key, contacts.sorted { $0.displayName < $1.displayName })
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
fromAccountCard
|
||||
toAccountCard
|
||||
amountCard
|
||||
remarksCard
|
||||
transferButton
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { focusedField = nil }
|
||||
.navigationTitle("Transfer")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.sheet(isPresented: $showAccountPicker) {
|
||||
accountPickerSheet
|
||||
.onDisappear { accountSearchText = "" }
|
||||
}
|
||||
.sheet(isPresented: $showBmlOtp, onDismiss: presentPendingReceiptAfterOtp) {
|
||||
bmlOtpSheet
|
||||
}
|
||||
.sheet(isPresented: $showContactPicker) {
|
||||
contactPickerSheet
|
||||
.onDisappear { contactSearchText = "" }
|
||||
}
|
||||
.sheet(item: $completedReceipt, onDismiss: finishCompletedTransfer) { presentation in
|
||||
TransferReceiptView(
|
||||
receipt: presentation.receipt,
|
||||
onSaveContact: { contact in
|
||||
homeVM.saveContact(contact)
|
||||
}
|
||||
) {
|
||||
completedReceipt = nil
|
||||
finishCompletedTransfer()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showQRScanner) {
|
||||
QRScannerSheet { qrContent in
|
||||
handleQR(qrContent)
|
||||
}
|
||||
}
|
||||
.onChange(of: vm.transferState) { _, state in
|
||||
switch state {
|
||||
case .success(let receipt):
|
||||
focusedField = nil
|
||||
if showBmlOtp {
|
||||
pendingReceiptAfterOtp = receipt
|
||||
showBmlOtp = false
|
||||
} else {
|
||||
completedReceipt = ReceiptPresentation(receipt: receipt)
|
||||
}
|
||||
case .bmlStep2:
|
||||
focusedField = nil
|
||||
showBmlOtp = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.onChange(of: homeVM.pendingTransferContact) { _, contact in
|
||||
guard let contact else { return }
|
||||
selectContact(contact)
|
||||
homeVM.pendingTransferContact = nil
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .keyboard) {
|
||||
Spacer()
|
||||
Button("Done") { focusedField = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - From account card
|
||||
|
||||
private var fromAccountCard: some View {
|
||||
Button { showAccountPicker = true } label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("From Account")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let acc = vm.fromAccount {
|
||||
Text(acc.accountBriefName.isEmpty ? acc.accountTypeName : acc.accountBriefName)
|
||||
.font(.subheadline.bold())
|
||||
Text(acc.accountNumber)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(acc.formattedAvailableBalance)
|
||||
.font(.caption)
|
||||
.foregroundStyle(primary)
|
||||
} else {
|
||||
Text("Select account")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.down")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - To account card
|
||||
|
||||
private var toAccountCard: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("To Account")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
// Text field with lookup icon inside its border
|
||||
HStack(spacing: 8) {
|
||||
TextField("Account Number or Favara ID", text: $toInputText)
|
||||
.focused($focusedField, equals: .toAccount)
|
||||
.keyboardType(.asciiCapable)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.submitLabel(.next)
|
||||
.onSubmit { focusedField = .amount }
|
||||
.font(.subheadline)
|
||||
.onChange(of: toInputText) { _, _ in
|
||||
if isProgrammaticToInputChange {
|
||||
isProgrammaticToInputChange = false
|
||||
return
|
||||
}
|
||||
vm.lookupState = .idle
|
||||
vm.bmlTrnType = "IAT"
|
||||
vm.bmlBank = nil
|
||||
vm.bmlCreditAccountOverride = nil
|
||||
}
|
||||
|
||||
if vm.fromAccount?.bank == "MIB" || vm.fromAccount?.bank == "BML" {
|
||||
lookupButton
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color(.systemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color(.separator), lineWidth: 0.5)
|
||||
)
|
||||
|
||||
// Contact picker
|
||||
Button { showContactPicker = true } label: {
|
||||
Image(systemName: "person.fill")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Color.green)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// QR scanner
|
||||
Button { showQRScanner = true } label: {
|
||||
Image(systemName: "qrcode.viewfinder")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Color.green)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
lookupResultRow
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var lookupButton: some View {
|
||||
switch vm.lookupState {
|
||||
case .loading:
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
default:
|
||||
Button {
|
||||
performBeneficiaryLookup()
|
||||
} label: {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(toInputText.isEmpty ? Color.secondary : primary)
|
||||
}
|
||||
.disabled(toInputText.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var lookupResultRow: some View {
|
||||
switch vm.lookupState {
|
||||
case .found(let r):
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(r.accountName).font(.caption.bold())
|
||||
Text(r.bankName).font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
case .bmlReady(let name) where !name.isEmpty:
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||||
Text(name).font(.caption.bold())
|
||||
}
|
||||
case .error(let msg):
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "xmark.circle.fill").foregroundStyle(.red)
|
||||
Text(msg).font(.caption).foregroundStyle(.red)
|
||||
}
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Amount card
|
||||
|
||||
private var amountCard: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Amount")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
HStack {
|
||||
Text(vm.fromAccount?.currencyName ?? "MVR")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(primary)
|
||||
.frame(width: 44)
|
||||
TextField("0.00", text: $amountInputText)
|
||||
.focused($focusedField, equals: .amount)
|
||||
.keyboardType(.decimalPad)
|
||||
.submitLabel(.next)
|
||||
.onSubmit { focusedField = .remarks }
|
||||
.font(.title3.bold())
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
// MARK: - Remarks card
|
||||
|
||||
private var remarksCard: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Remarks")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
TextField("Fund Transfer", text: $purposeInputText)
|
||||
.focused($focusedField, equals: .remarks)
|
||||
.submitLabel(.done)
|
||||
.onSubmit { focusedField = nil }
|
||||
.font(.subheadline)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
// MARK: - Transfer button
|
||||
|
||||
@ViewBuilder
|
||||
private var transferButton: some View {
|
||||
if case .processing = vm.transferState {
|
||||
ProgressView("Processing transfer…")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
} else {
|
||||
VStack(spacing: 8) {
|
||||
if case .failure(let msg) = vm.transferState {
|
||||
Text(msg)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
Button {
|
||||
syncFormToViewModel()
|
||||
focusedField = nil
|
||||
Task { await vm.submitTransfer() }
|
||||
} label: {
|
||||
Text("Transfer")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(canSubmitTransfer ? primary : Color.gray.opacity(0.4))
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.disabled(!canSubmitTransfer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Account picker sheet
|
||||
|
||||
private var accountPickerSheet: some View {
|
||||
NavigationStack {
|
||||
List(filteredSendableAccounts) { acc in
|
||||
Button {
|
||||
vm.fromAccount = acc
|
||||
vm.lookupState = .idle
|
||||
vm.bmlTrnType = "IAT"
|
||||
vm.bmlBank = nil
|
||||
vm.bmlCreditAccountOverride = nil
|
||||
showAccountPicker = false
|
||||
} label: {
|
||||
HStack {
|
||||
bankDot(acc.bank)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(acc.accountBriefName.isEmpty ? acc.accountTypeName : acc.accountBriefName)
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(.primary)
|
||||
Text(acc.accountNumber)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(acc.formattedAvailableBalance)
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("From Account")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.searchable(text: $accountSearchText, prompt: "Search account")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Cancel") { showAccountPicker = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BML OTP sheet
|
||||
|
||||
private var bmlOtpSheet: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "lock.shield.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(primary)
|
||||
Text("Confirm Transfer")
|
||||
.font(.title2.bold())
|
||||
Text("The OTP will be generated automatically from your stored authenticator seed.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
if case .processing = vm.transferState {
|
||||
ProgressView("Confirming…")
|
||||
} else {
|
||||
Button {
|
||||
guard let pid = vm.fromAccount?.profileId
|
||||
?? vm.fromAccount.flatMap({ CredentialStore.shared.loadStringArray(forKey: CredentialStore.Keys.bmlProfiles($0.loginTag.replacingOccurrences(of: "bml_", with: ""))).first }),
|
||||
let seed = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlOtpSeed(
|
||||
vm.fromAccount?.loginTag.replacingOccurrences(of: "bml_", with: "") ?? ""
|
||||
)) else { return }
|
||||
let _ = pid // suppress unused warning
|
||||
Task { await vm.confirmBmlOtp(otpSeed: seed) }
|
||||
} label: {
|
||||
Text("Confirm with Authenticator")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(primary)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
if case .failure(let msg) = vm.transferState {
|
||||
Text(msg)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(24)
|
||||
.navigationTitle("OTP Confirmation")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
vm.transferState = .idle
|
||||
showBmlOtp = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
.onChange(of: vm.transferState) { _, state in
|
||||
if case .success = state {
|
||||
showBmlOtp = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Contact picker sheet
|
||||
|
||||
private var contactPickerSheet: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if !filteredDestinationAccounts.isEmpty {
|
||||
Section("My Accounts") {
|
||||
ForEach(filteredDestinationAccounts) { account in
|
||||
Button {
|
||||
selectDestinationAccount(account)
|
||||
showContactPicker = false
|
||||
} label: {
|
||||
destinationAccountRow(account)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(groupedContacts, id: \.0) { title, contacts in
|
||||
Section(title) {
|
||||
ForEach(contacts) { contact in
|
||||
Button {
|
||||
selectContact(contact)
|
||||
showContactPicker = false
|
||||
} label: {
|
||||
contactPickerRow(contact)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select Contact")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.searchable(text: $contactSearchText, prompt: "Search contacts")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Cancel") { showContactPicker = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transfer completion
|
||||
|
||||
private func finishCompletedTransfer() {
|
||||
completedReceipt = nil
|
||||
pendingReceiptAfterOtp = nil
|
||||
focusedField = nil
|
||||
showBmlOtp = false
|
||||
vm.reset()
|
||||
toInputText = ""
|
||||
amountInputText = ""
|
||||
purposeInputText = ""
|
||||
}
|
||||
|
||||
private func presentPendingReceiptAfterOtp() {
|
||||
guard let receipt = pendingReceiptAfterOtp else { return }
|
||||
pendingReceiptAfterOtp = nil
|
||||
completedReceipt = ReceiptPresentation(receipt: receipt)
|
||||
}
|
||||
|
||||
private func syncFormToViewModel() {
|
||||
vm.toInput = toInputText
|
||||
vm.amountInput = amountInputText
|
||||
vm.purposeInput = purposeInputText
|
||||
}
|
||||
|
||||
private func setToInput(_ value: String) {
|
||||
guard toInputText != value else {
|
||||
vm.toInput = value
|
||||
isProgrammaticToInputChange = false
|
||||
return
|
||||
}
|
||||
isProgrammaticToInputChange = true
|
||||
toInputText = value
|
||||
vm.toInput = value
|
||||
}
|
||||
|
||||
// MARK: - QR handler
|
||||
|
||||
private func performBeneficiaryLookup() {
|
||||
syncFormToViewModel()
|
||||
if resolveCachedRecipient() { return }
|
||||
Task { await vm.lookupBeneficiary() }
|
||||
}
|
||||
|
||||
private func resolveCachedRecipient() -> Bool {
|
||||
let query = toInputText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !query.isEmpty else { return false }
|
||||
|
||||
if let account = destinationAccounts.first(where: { $0.accountNumber == query }) {
|
||||
selectDestinationAccount(account)
|
||||
return true
|
||||
}
|
||||
if let contact = homeVM.contacts.first(where: {
|
||||
$0.benefAccount == query ||
|
||||
$0.benefNo == query ||
|
||||
$0.displayName.caseInsensitiveCompare(query) == .orderedSame
|
||||
}) {
|
||||
selectContact(contact)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func handleQR(_ content: String) {
|
||||
if let result = PaymvQrParser.parse(content) {
|
||||
setToInput(result.account)
|
||||
vm.lookupState = .idle
|
||||
if let amount = result.amount, !amount.isEmpty {
|
||||
amountInputText = amount
|
||||
vm.amountInput = amount
|
||||
}
|
||||
if let purpose = result.purpose, !purpose.isEmpty {
|
||||
purposeInputText = purpose
|
||||
vm.purposeInput = purpose
|
||||
} else if let name = result.merchantName, !name.isEmpty {
|
||||
purposeInputText = "Pay \(name)"
|
||||
vm.purposeInput = purposeInputText
|
||||
}
|
||||
// Trigger lookup for the filled account
|
||||
if vm.fromAccount?.bank == "MIB" {
|
||||
Task { await vm.lookupBeneficiary() }
|
||||
} else if vm.fromAccount?.bank == "BML" {
|
||||
Task { await vm.lookupBeneficiary() }
|
||||
}
|
||||
} else {
|
||||
// Fallback: treat raw content as an account number
|
||||
setToInput(content)
|
||||
vm.lookupState = .idle
|
||||
}
|
||||
}
|
||||
|
||||
private func destinationAccountRow(_ account: BankAccount) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(contactColor(account.bank).opacity(0.15))
|
||||
.frame(width: 36, height: 36)
|
||||
Text(accountInitials(account))
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(contactColor(account.bank))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(account.accountBriefName.isEmpty ? account.accountTypeName : account.accountBriefName)
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(.primary)
|
||||
Text("\(account.bank) · \(account.accountNumber)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
Text(account.formattedAvailableBalance)
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(primary)
|
||||
}
|
||||
}
|
||||
|
||||
private func contactPickerRow(_ contact: BankContact) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(contactColor(contact.source).opacity(0.15))
|
||||
.frame(width: 36, height: 36)
|
||||
Text(contactInitials(contact.displayName))
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(contactColor(contact.source))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(contact.displayName)
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(.primary)
|
||||
Text(contactSubtitle(contact))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
Text(contact.source)
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(contactColor(contact.source).opacity(0.12), in: Capsule())
|
||||
.foregroundStyle(contactColor(contact.source))
|
||||
}
|
||||
}
|
||||
|
||||
private func selectDestinationAccount(_ account: BankAccount) {
|
||||
setToInput(account.accountNumber)
|
||||
guard let from = vm.fromAccount else { return }
|
||||
|
||||
if from.bank == "MIB" {
|
||||
let isMibInternal = account.bank == "MIB"
|
||||
vm.lookupState = .found(MibAccountLookupResult(
|
||||
accountNumber: account.accountNumber,
|
||||
accountName: account.accountBriefName.isEmpty ? account.accountTypeName : account.accountBriefName,
|
||||
bankName: isMibInternal ? "Maldives Islamic Bank" : "Bank of Maldives",
|
||||
bankCode: isMibInternal ? "2" : "3",
|
||||
aliasId: nil,
|
||||
network: isMibInternal ? .mibInternal : .local
|
||||
))
|
||||
} else if from.bank == "BML" {
|
||||
if account.bank == "MIB" {
|
||||
vm.bmlTrnType = "DOT"
|
||||
vm.bmlBank = "MIB"
|
||||
vm.bmlCreditAccountOverride = nil
|
||||
} else if isBmlCard(account) {
|
||||
vm.bmlTrnType = "CPA"
|
||||
vm.bmlBank = nil
|
||||
vm.bmlCreditAccountOverride = (account.internalId ?? "").isEmpty ? account.accountNumber : account.internalId
|
||||
} else {
|
||||
vm.bmlTrnType = "IAT"
|
||||
vm.bmlBank = nil
|
||||
vm.bmlCreditAccountOverride = nil
|
||||
}
|
||||
vm.lookupState = .bmlReady(account.accountBriefName.isEmpty ? account.accountTypeName : account.accountBriefName)
|
||||
}
|
||||
}
|
||||
|
||||
private func selectContact(_ contact: BankContact) {
|
||||
setToInput(contact.benefAccount)
|
||||
guard let fromBank = vm.fromAccount?.bank else { return }
|
||||
|
||||
if fromBank == "MIB" {
|
||||
let isMibInternal = contact.source == "MIB" && contact.benefType != "LOCAL"
|
||||
vm.lookupState = .found(MibAccountLookupResult(
|
||||
accountNumber: contact.benefAccount,
|
||||
accountName: contact.displayName,
|
||||
bankName: contact.benefBankName ?? (isMibInternal ? "Maldives Islamic Bank" : "Local Bank"),
|
||||
bankCode: contact.bankCode ?? (isMibInternal ? "2" : "3"),
|
||||
aliasId: contact.benefNo,
|
||||
network: isMibInternal ? .mibInternal : .local
|
||||
))
|
||||
} else if fromBank == "BML" {
|
||||
vm.bmlTrnType = contact.source == "MIB" ? "DOT" : "IAT"
|
||||
vm.bmlBank = contact.source == "MIB" ? "MIB" : nil
|
||||
vm.bmlCreditAccountOverride = nil
|
||||
vm.lookupState = .bmlReady(contact.displayName)
|
||||
}
|
||||
}
|
||||
|
||||
private func contactCategoryTitle(_ contact: BankContact) -> String {
|
||||
if let category = contact.benefCategoryId, !category.isEmpty, category != "BML" {
|
||||
return category
|
||||
}
|
||||
switch contact.source {
|
||||
case "MIB": return "MIB Contacts"
|
||||
case "BML": return "BML Contacts"
|
||||
default: return "Other Contacts"
|
||||
}
|
||||
}
|
||||
|
||||
private func contactSubtitle(_ contact: BankContact) -> String {
|
||||
let bank = contact.benefBankName ?? contact.source
|
||||
return "\(bank) · \(contact.benefAccount)"
|
||||
}
|
||||
|
||||
private func accountInitials(_ account: BankAccount) -> String {
|
||||
let label = account.accountBriefName.isEmpty ? account.accountTypeName : account.accountBriefName
|
||||
let words = label.split(separator: " ").prefix(2)
|
||||
let initials = words.compactMap { $0.first }.map(String.init).joined()
|
||||
return initials.isEmpty ? account.bank : initials
|
||||
}
|
||||
|
||||
private func isBmlCard(_ account: BankAccount) -> Bool {
|
||||
account.profileType == "BML_CREDIT" ||
|
||||
account.profileType == "BML_DEBIT" ||
|
||||
account.profileType == "BML_PREPAID"
|
||||
}
|
||||
|
||||
private func contactInitials(_ name: String) -> String {
|
||||
let words = name.split(separator: " ").prefix(2)
|
||||
return words.compactMap { $0.first }.map(String.init).joined()
|
||||
}
|
||||
|
||||
private func contactColor(_ source: String) -> Color {
|
||||
switch source {
|
||||
case "MIB": return primary
|
||||
case "BML": return .blue
|
||||
default: return .purple
|
||||
}
|
||||
}
|
||||
|
||||
private func bankDot(_ bank: String) -> some View {
|
||||
Circle()
|
||||
.fill(bank == "MIB" ? Color(red: 0.247, green: 0.396, blue: 0.678) : Color.blue)
|
||||
.frame(width: 10, height: 10)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TransferView()
|
||||
.environment(HomeViewModel())
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BankSelectionView: View {
|
||||
@State private var selectedBank: String? = nil
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "building.columns.fill")
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.tint)
|
||||
Text("Add a Bank Account")
|
||||
.font(.title2.bold())
|
||||
Text("Choose which bank to connect.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 40)
|
||||
.padding(.bottom, 32)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
BankCard(
|
||||
bank: "MIB",
|
||||
name: "Maldives Islamic Bank",
|
||||
subtitle: "Faisanet personal & business",
|
||||
icon: "building.2.fill",
|
||||
color: .green,
|
||||
destination: { CredentialsView(bank: "MIB") }
|
||||
)
|
||||
BankCard(
|
||||
bank: "BML",
|
||||
name: "Bank of Maldives",
|
||||
subtitle: "Internet banking · all profiles",
|
||||
icon: "creditcard.fill",
|
||||
color: .blue,
|
||||
destination: { CredentialsView(bank: "BML") }
|
||||
)
|
||||
BankCard(
|
||||
bank: "FAHIPAY",
|
||||
name: "Fahipay Wallet",
|
||||
subtitle: "Mobile wallet · Ooredoo · Dhiraagu",
|
||||
icon: "wallet.pass.fill",
|
||||
color: .orange,
|
||||
destination: { CredentialsView(bank: "FAHIPAY") }
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct BankCard<Dest: View>: View {
|
||||
let bank: String
|
||||
let name: String
|
||||
let subtitle: String
|
||||
let icon: String
|
||||
let color: Color
|
||||
@ViewBuilder let destination: () -> Dest
|
||||
|
||||
var body: some View {
|
||||
NavigationLink(destination: destination()) {
|
||||
HStack(spacing: 16) {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(color.opacity(0.15))
|
||||
.frame(width: 52, height: 52)
|
||||
.overlay(
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(color)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(name)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(16)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
BankSelectionView()
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CredentialsView: View {
|
||||
let bank: String
|
||||
|
||||
@Environment(AppViewModel.self) private var app
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var vm = LoginViewModel()
|
||||
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var otpSeed = ""
|
||||
@State private var totpCode = "" // Fahipay 2-step verification code
|
||||
|
||||
@State private var isLoading = false
|
||||
@State private var errorText = ""
|
||||
@State private var showOtpPreview = false
|
||||
|
||||
private var isFahipay: Bool { bank == "FAHIPAY" }
|
||||
private var awaitingFahipayTotp: Bool {
|
||||
if case .fahipayNeedTotp = vm.state { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private var resolvedSeed: String { LoginViewModel.resolveOtpSeed(otpSeed) }
|
||||
|
||||
private var canSubmit: Bool {
|
||||
let u = username.trimmingCharacters(in: .whitespaces)
|
||||
let p = password
|
||||
if awaitingFahipayTotp { return totpCode.count == 6 }
|
||||
if isFahipay { return !u.isEmpty && !p.isEmpty }
|
||||
return !u.isEmpty && !p.isEmpty && LoginViewModel.isValidOtpSeed(otpSeed)
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
bankHeader
|
||||
|
||||
credentialFields
|
||||
|
||||
if !isFahipay && !resolvedSeed.isEmpty && LoginViewModel.isValidOtpSeed(otpSeed) {
|
||||
OtpPreviewView(seed: resolvedSeed)
|
||||
.padding(.horizontal, 4)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
if !errorText.isEmpty {
|
||||
Text(errorText)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.red)
|
||||
.multilineTextAlignment(.center)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
Button(submitLabel) { attemptLogin() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.disabled(!canSubmit || isLoading)
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.navigationTitle(bankTitle)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.animation(.easeInOut(duration: 0.2), value: resolvedSeed)
|
||||
.animation(.easeInOut(duration: 0.2), value: errorText)
|
||||
}
|
||||
|
||||
// MARK: - Bank header
|
||||
|
||||
@ViewBuilder
|
||||
private var bankHeader: some View {
|
||||
VStack(spacing: 6) {
|
||||
Text(bankDescription)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fields
|
||||
|
||||
@ViewBuilder
|
||||
private var credentialFields: some View {
|
||||
VStack(spacing: 14) {
|
||||
if awaitingFahipayTotp {
|
||||
// Fahipay 2-step: only show the TOTP field
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Authenticator Code")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("6-digit code", text: $totpCode)
|
||||
.keyboardType(.numberPad)
|
||||
.textContentType(.oneTimeCode)
|
||||
.padding(12)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
Text("Open your authenticator app and enter the 6-digit code for Fahipay.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
} else {
|
||||
// Username
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(isFahipay ? "ID Card Number" : "Username")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
TextField(isFahipay ? "A000000" : "Enter username", text: $username)
|
||||
.textContentType(.username)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.padding(12)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
|
||||
.disabled(awaitingFahipayTotp)
|
||||
}
|
||||
|
||||
// Password
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Password")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
SecureField("Enter password", text: $password)
|
||||
.textContentType(.password)
|
||||
.padding(12)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
|
||||
.disabled(awaitingFahipayTotp)
|
||||
}
|
||||
|
||||
// OTP seed (MIB + BML only)
|
||||
if !isFahipay {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Authenticator Secret")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
TextField("Paste TOTP secret or otpauth:// URI", text: $otpSeed)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.textContentType(.none)
|
||||
.padding(12)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
Text("This is the secret from your authenticator app — not the 6-digit code.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Login logic
|
||||
|
||||
private func attemptLogin() {
|
||||
withAnimation { errorText = "" }
|
||||
isLoading = true
|
||||
|
||||
Task {
|
||||
do {
|
||||
switch bank {
|
||||
case "MIB":
|
||||
let accounts = try await vm.loginMib(
|
||||
username: username.trimmingCharacters(in: .whitespaces),
|
||||
password: password,
|
||||
otpSeed: resolvedSeed
|
||||
)
|
||||
await MainActor.run { finish(accounts: accounts) }
|
||||
|
||||
case "BML":
|
||||
let accounts = try await vm.loginBml(
|
||||
username: username.trimmingCharacters(in: .whitespaces),
|
||||
password: password,
|
||||
otpSeed: resolvedSeed
|
||||
)
|
||||
await MainActor.run { finish(accounts: accounts) }
|
||||
|
||||
case "FAHIPAY":
|
||||
if awaitingFahipayTotp {
|
||||
try await vm.verifyFahipayTotp(totpCode)
|
||||
await MainActor.run { finish(accounts: []) }
|
||||
} else {
|
||||
let step = try await vm.loginFahipay(
|
||||
idCard: username.trimmingCharacters(in: .whitespaces),
|
||||
password: password
|
||||
)
|
||||
await MainActor.run {
|
||||
if step.twoFactorRequired {
|
||||
withAnimation { vm.state = .fahipayNeedTotp }
|
||||
} else if let authId = step.authId {
|
||||
vm.completeFahipayLogin(authId: authId)
|
||||
finish(accounts: [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default: break
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
withAnimation { errorText = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
await MainActor.run { isLoading = false }
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(accounts: [BankAccount]) {
|
||||
app.loginSucceeded()
|
||||
}
|
||||
|
||||
// MARK: - Computed strings
|
||||
|
||||
private var bankTitle: String {
|
||||
switch bank {
|
||||
case "MIB": return "MIB Faisanet"
|
||||
case "BML": return "Bank of Maldives"
|
||||
case "FAHIPAY": return "Fahipay Wallet"
|
||||
default: return bank
|
||||
}
|
||||
}
|
||||
|
||||
private var bankDescription: String {
|
||||
switch bank {
|
||||
case "MIB": return "Sign in with your Faisanet username and password.\nPaste your TOTP secret from your authenticator app."
|
||||
case "BML": return "Sign in with your BML Internet Banking credentials.\nAll personal profiles will be activated automatically."
|
||||
case "FAHIPAY": return "Sign in with your Fahipay ID card number and password."
|
||||
default: return ""
|
||||
}
|
||||
}
|
||||
|
||||
private var submitLabel: String {
|
||||
if awaitingFahipayTotp { return "Verify Code" }
|
||||
return "Sign In"
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
CredentialsView(bank: "MIB")
|
||||
.environment(AppViewModel())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OnboardingView: View {
|
||||
@Environment(AppViewModel.self) private var app
|
||||
@State private var page = 0
|
||||
@State private var pinDone = false
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $page) {
|
||||
WelcomePage(onNext: { page = 1 })
|
||||
.tag(0)
|
||||
|
||||
SecuritySetupView(onComplete: {
|
||||
pinDone = true
|
||||
withAnimation { page = 2 }
|
||||
})
|
||||
.tag(1)
|
||||
|
||||
FinishPage(onGetStarted: { app.completeOnboarding() })
|
||||
.tag(2)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
// Prevent swiping forward past the PIN page until it's configured
|
||||
.gesture(
|
||||
DragGesture().onEnded { v in
|
||||
let forward = v.translation.width < -40
|
||||
if forward && page == 1 && !pinDone { return }
|
||||
}
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Page 1: Welcome
|
||||
|
||||
private struct WelcomePage: View {
|
||||
let onNext: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(.systemBackground).ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "building.columns.fill")
|
||||
.font(.system(size: 72))
|
||||
.foregroundStyle(.tint)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Thijooree")
|
||||
.font(.largeTitle.bold())
|
||||
Text("MIB · BML · Fahipay — one app.")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
FeatureRow(icon: "lock.shield.fill", text: "Your credentials stay on your device — no backend, no middleman.")
|
||||
FeatureRow(icon: "faceid", text: "Face ID & Touch ID unlock in an instant.")
|
||||
FeatureRow(icon: "arrow.triangle.2.circlepath", text: "Balances from all your banks in one tap.")
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Get Started") { onNext() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.padding(.horizontal, 40)
|
||||
.padding(.bottom, 48)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct FeatureRow: View {
|
||||
let icon: String
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(.tint)
|
||||
.frame(width: 28)
|
||||
Text(text)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Page 3: Finish
|
||||
|
||||
private struct FinishPage: View {
|
||||
let onGetStarted: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(.systemBackground).ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 72))
|
||||
.foregroundStyle(.green)
|
||||
|
||||
Text("You're all set!")
|
||||
.font(.title.bold())
|
||||
|
||||
Text("Add your first bank account to get started.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Continue to Thijooree") { onGetStarted() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.padding(.horizontal, 40)
|
||||
.padding(.bottom, 48)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
OnboardingView()
|
||||
.environment(AppViewModel())
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import LocalAuthentication
|
||||
import SwiftUI
|
||||
|
||||
struct LockScreenView: View {
|
||||
var onUnlocked: () -> Void
|
||||
|
||||
@State private var vm = LockViewModel()
|
||||
@State private var hintText = ""
|
||||
@State private var lockoutSeconds = 0
|
||||
@State private var lockoutTimer: Timer?
|
||||
|
||||
private let biometricsEnabled = UserDefaults.standard.bool(forKey: "biometrics_enabled")
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 28) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("Enter PIN")
|
||||
.font(.title2).bold()
|
||||
}
|
||||
|
||||
Text(vm.dotsDisplay)
|
||||
.font(.system(size: 30, design: .monospaced))
|
||||
.kerning(6)
|
||||
.frame(height: 44)
|
||||
.animation(.spring(response: 0.2), value: vm.pinDigits.count)
|
||||
|
||||
Group {
|
||||
if lockoutSeconds > 0 {
|
||||
Text("Try again in \(lockoutSeconds)s")
|
||||
.foregroundStyle(.red)
|
||||
} else if !hintText.isEmpty {
|
||||
Text(hintText)
|
||||
.foregroundStyle(.red)
|
||||
} else {
|
||||
Text(" ") // placeholder to keep layout stable
|
||||
}
|
||||
}
|
||||
.font(.callout)
|
||||
|
||||
NumpadView { key in handleKey(key) }
|
||||
.disabled(lockoutSeconds > 0 || vm.isVerifying)
|
||||
.opacity(lockoutSeconds > 0 ? 0.4 : 1)
|
||||
|
||||
if biometricsEnabled {
|
||||
Button { triggerBiometric() } label: {
|
||||
Label(biometricLabel(), systemImage: biometricIcon())
|
||||
.font(.callout)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.task {
|
||||
if biometricsEnabled { triggerBiometric() }
|
||||
// Resume any active lockout on re-appearance
|
||||
if vm.isLockedOut { startLockoutTimer() }
|
||||
}
|
||||
.onDisappear {
|
||||
lockoutTimer?.invalidate()
|
||||
lockoutTimer = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Key handling
|
||||
|
||||
private func handleKey(_ key: String) {
|
||||
guard !vm.isLockedOut else { startLockoutTimer(); return }
|
||||
hintText = ""
|
||||
if vm.handleKey(key) { verifyPin() }
|
||||
}
|
||||
|
||||
// MARK: - Verification
|
||||
|
||||
private func verifyPin() {
|
||||
guard !vm.isVerifying else { return }
|
||||
vm.isVerifying = true
|
||||
let entered = vm.currentPin()
|
||||
vm.clearPin()
|
||||
|
||||
Task {
|
||||
let ok = await verifyInBackground(entered)
|
||||
await MainActor.run {
|
||||
vm.isVerifying = false
|
||||
if ok {
|
||||
vm.resetFailures()
|
||||
onUnlocked()
|
||||
} else {
|
||||
showFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func verifyInBackground(_ input: String) async -> Bool {
|
||||
await Task.detached(priority: .userInitiated) {
|
||||
guard
|
||||
let saltB64 = CredentialStore.shared.load(forKey: CredentialStore.Keys.securityHashSalt),
|
||||
let salt = Data(base64Encoded: saltB64),
|
||||
let stored = CredentialStore.shared.load(forKey: CredentialStore.Keys.securityHash)
|
||||
else { return false }
|
||||
return PinHash.verify(input, against: stored, salt: salt)
|
||||
}.value
|
||||
}
|
||||
|
||||
private func showFailure() {
|
||||
withAnimation { hintText = vm.failureHint() }
|
||||
if vm.isLockedOut {
|
||||
startLockoutTimer()
|
||||
} else {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||
withAnimation { hintText = "" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lockout timer
|
||||
|
||||
private func startLockoutTimer() {
|
||||
lockoutSeconds = Int(ceil(vm.lockoutRemaining))
|
||||
guard lockoutSeconds > 0 else { return }
|
||||
hintText = ""
|
||||
lockoutTimer?.invalidate()
|
||||
lockoutTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
|
||||
lockoutSeconds = Int(ceil(vm.lockoutRemaining))
|
||||
if lockoutSeconds <= 0 {
|
||||
lockoutTimer?.invalidate()
|
||||
lockoutTimer = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Biometric
|
||||
|
||||
private func triggerBiometric() {
|
||||
let ctx = LAContext()
|
||||
var err: NSError?
|
||||
guard ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &err) else { return }
|
||||
ctx.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: "Unlock Thijooree") { success, _ in
|
||||
guard success else { return }
|
||||
Task { @MainActor in
|
||||
vm.resetFailures()
|
||||
onUnlocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func biometricLabel() -> String {
|
||||
let ctx = LAContext()
|
||||
_ = ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
|
||||
return ctx.biometryType == .faceID ? "Face ID" : "Touch ID"
|
||||
}
|
||||
|
||||
private func biometricIcon() -> String {
|
||||
let ctx = LAContext()
|
||||
_ = ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
|
||||
return ctx.biometryType == .faceID ? "faceid" : "touchid"
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
LockScreenView(onUnlocked: {})
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import LocalAuthentication
|
||||
import SwiftUI
|
||||
|
||||
struct SecuritySetupView: View {
|
||||
var onComplete: () -> Void
|
||||
var changeMode: Bool = false
|
||||
|
||||
private enum Step { case enterPin, confirmPin, done }
|
||||
|
||||
@State private var step: Step = .enterPin
|
||||
@State private var pinDigits: [Int] = []
|
||||
@State private var firstPin = ""
|
||||
@State private var hintText = ""
|
||||
@State private var biometricsAvailable = false
|
||||
@State private var biometricsEnabled = UserDefaults.standard.bool(forKey: "biometrics_enabled")
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 28) {
|
||||
Spacer()
|
||||
|
||||
switch step {
|
||||
case .enterPin, .confirmPin:
|
||||
pinSetupContent
|
||||
case .done:
|
||||
doneContent
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: step)
|
||||
.task {
|
||||
let ctx = LAContext()
|
||||
biometricsAvailable = ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PIN setup
|
||||
|
||||
@ViewBuilder
|
||||
private var pinSetupContent: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(step == .enterPin ? "Create a PIN" : "Confirm PIN")
|
||||
.font(.title2).bold()
|
||||
|
||||
Text("Use 4–8 digits")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(dotsDisplay)
|
||||
.font(.system(size: 30, design: .monospaced))
|
||||
.kerning(6)
|
||||
.frame(height: 44)
|
||||
.animation(.spring(response: 0.2), value: pinDigits.count)
|
||||
|
||||
if !hintText.isEmpty {
|
||||
Text(hintText)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.red)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
NumpadView { key in handleKey(key) }
|
||||
}
|
||||
|
||||
// MARK: - Done screen
|
||||
|
||||
@ViewBuilder
|
||||
private var doneContent: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.shield.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.green)
|
||||
|
||||
Text("PIN Created")
|
||||
.font(.title2).bold()
|
||||
|
||||
Text("Your app is now protected.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if biometricsAvailable {
|
||||
VStack(spacing: 4) {
|
||||
let label = biometricLabel()
|
||||
Toggle(isOn: $biometricsEnabled) {
|
||||
Label("Use \(label) to unlock", systemImage: biometricIcon())
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.tint(.accentColor)
|
||||
.onChange(of: biometricsEnabled) {
|
||||
UserDefaults.standard.set(biometricsEnabled, forKey: "biometrics_enabled")
|
||||
}
|
||||
|
||||
Text("Recommended for quick access")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Button("Continue") { onComplete() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
|
||||
// MARK: - Logic
|
||||
|
||||
private var dotsDisplay: String {
|
||||
let n = pinDigits.count
|
||||
let total = max(n, 4)
|
||||
return String(repeating: "●", count: n)
|
||||
+ String(repeating: "○", count: total - n)
|
||||
}
|
||||
|
||||
private func handleKey(_ key: String) {
|
||||
withAnimation { hintText = "" }
|
||||
switch key {
|
||||
case "⌫":
|
||||
if !pinDigits.isEmpty { pinDigits.removeLast() }
|
||||
case "✓":
|
||||
if pinDigits.count >= 4 { submitPin() }
|
||||
default:
|
||||
if let d = Int(key), pinDigits.count < 8 {
|
||||
pinDigits.append(d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func submitPin() {
|
||||
let entered = pinDigits.map(String.init).joined()
|
||||
switch step {
|
||||
case .enterPin:
|
||||
firstPin = entered
|
||||
pinDigits = []
|
||||
step = .confirmPin
|
||||
|
||||
case .confirmPin:
|
||||
if entered == firstPin {
|
||||
savePin(entered)
|
||||
withAnimation { step = .done }
|
||||
} else {
|
||||
withAnimation { hintText = "PINs don't match — try again" }
|
||||
pinDigits = []
|
||||
}
|
||||
|
||||
case .done:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func savePin(_ pin: String) {
|
||||
let salt = PinHash.generateSalt()
|
||||
let hash = PinHash.hash(pin, salt: salt)
|
||||
try? CredentialStore.shared.save(salt.base64EncodedString(), forKey: CredentialStore.Keys.securityHashSalt)
|
||||
try? CredentialStore.shared.save(hash, forKey: CredentialStore.Keys.securityHash)
|
||||
UserDefaults.standard.set("pin", forKey: "security_method")
|
||||
UserDefaults.standard.set(pin.count, forKey: "pin_length")
|
||||
UserDefaults.standard.set(true, forKey: "auto_unlock_pin")
|
||||
}
|
||||
|
||||
private func biometricLabel() -> String {
|
||||
let ctx = LAContext()
|
||||
_ = ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
|
||||
return ctx.biometryType == .faceID ? "Face ID" : "Touch ID"
|
||||
}
|
||||
|
||||
private func biometricIcon() -> String {
|
||||
let ctx = LAContext()
|
||||
_ = ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
|
||||
return ctx.biometryType == .faceID ? "faceid" : "touchid"
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SecuritySetupView(onComplete: {})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NumpadView: View {
|
||||
let onKey: (String) -> Void
|
||||
|
||||
private let rows: [[String]] = [
|
||||
["1", "2", "3"],
|
||||
["4", "5", "6"],
|
||||
["7", "8", "9"],
|
||||
["⌫", "0", "✓"]
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(rows, id: \.self) { row in
|
||||
HStack(spacing: 20) {
|
||||
ForEach(row, id: \.self) { key in
|
||||
NumpadKey(label: key) { onKey(key) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NumpadKey: View {
|
||||
let label: String
|
||||
let action: () -> Void
|
||||
|
||||
var isConfirm: Bool { label == "✓" }
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Group {
|
||||
switch label {
|
||||
case "⌫":
|
||||
Image(systemName: "delete.left")
|
||||
.font(.system(size: 22))
|
||||
case "✓":
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
default:
|
||||
Text(label)
|
||||
.font(.system(size: 28, weight: .light))
|
||||
}
|
||||
}
|
||||
.frame(width: 72, height: 72)
|
||||
.background(isConfirm ? Color.accentColor : Color(.systemFill))
|
||||
.foregroundStyle(isConfirm ? .white : .primary)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NumpadView { _ in }
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OtpPreviewView: View {
|
||||
let seed: String
|
||||
|
||||
@State private var code = ""
|
||||
@State private var progress = 0.0 // 0.0 – 1.0 within the current 30s window
|
||||
@State private var timer: Timer?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Current OTP")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(formatted(code))
|
||||
.font(.system(size: 22, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color(.systemFill), lineWidth: 3)
|
||||
Circle()
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(progressColor, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.linear(duration: 0.8), value: progress)
|
||||
Text("\(Int(progress * 30))")
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { UIPasteboard.general.string = code }
|
||||
.onAppear { refresh(); startTimer() }
|
||||
.onDisappear { timer?.invalidate(); timer = nil }
|
||||
}
|
||||
|
||||
private var progressColor: Color {
|
||||
progress < 0.33 ? .red : progress < 0.6 ? .orange : .green
|
||||
}
|
||||
|
||||
private func formatted(_ otp: String) -> String {
|
||||
guard otp.count == 6 else { return otp }
|
||||
return "\(otp.prefix(3)) \(otp.suffix(3))"
|
||||
}
|
||||
|
||||
private func refresh() {
|
||||
code = (try? Totp.generate(seed)) ?? Totp.generate(seed)
|
||||
let seconds = Int(Date().timeIntervalSince1970) % 30
|
||||
progress = Double(30 - seconds) / 30.0
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
timer?.invalidate()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
OtpPreviewView(seed: "JBSWY3DPEHPK3PXP")
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
|
||||
struct QRScannerSheet: View {
|
||||
var onResult: (String) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
QRCameraView(onResult: { result in
|
||||
dismiss()
|
||||
onResult(result)
|
||||
})
|
||||
.ignoresSafeArea()
|
||||
.navigationTitle("Scan QR Code")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct QRCameraView: UIViewRepresentable {
|
||||
var onResult: (String) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
let view = UIView()
|
||||
view.backgroundColor = .black
|
||||
|
||||
let session = AVCaptureSession()
|
||||
guard let device = AVCaptureDevice.default(for: .video),
|
||||
let input = try? AVCaptureDeviceInput(device: device) else {
|
||||
return view
|
||||
}
|
||||
if session.canAddInput(input) { session.addInput(input) }
|
||||
|
||||
let output = AVCaptureMetadataOutput()
|
||||
if session.canAddOutput(output) {
|
||||
session.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(context.coordinator, queue: .main)
|
||||
output.metadataObjectTypes = [.qr]
|
||||
}
|
||||
|
||||
let preview = AVCaptureVideoPreviewLayer(session: session)
|
||||
preview.videoGravity = .resizeAspectFill
|
||||
preview.frame = view.bounds
|
||||
view.layer.addSublayer(preview)
|
||||
context.coordinator.previewLayer = preview
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { session.startRunning() }
|
||||
context.coordinator.session = session
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: Context) {
|
||||
context.coordinator.previewLayer?.frame = uiView.bounds
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(onResult: onResult) }
|
||||
|
||||
final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
|
||||
var onResult: (String) -> Void
|
||||
var session: AVCaptureSession?
|
||||
var previewLayer: AVCaptureVideoPreviewLayer?
|
||||
private var didCapture = false
|
||||
|
||||
init(onResult: @escaping (String) -> Void) {
|
||||
self.onResult = onResult
|
||||
}
|
||||
|
||||
func metadataOutput(_ output: AVCaptureMetadataOutput,
|
||||
didOutput objects: [AVMetadataObject],
|
||||
from connection: AVCaptureConnection) {
|
||||
guard !didCapture,
|
||||
let obj = objects.first as? AVMetadataMachineReadableCodeObject,
|
||||
let str = obj.stringValue else { return }
|
||||
didCapture = true
|
||||
session?.stopRunning()
|
||||
DispatchQueue.main.async { self.onResult(str) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user