Implement bank transfer app flows

This commit is contained in:
2026-06-08 00:03:57 +05:00
parent ef877217ad
commit 9b281d48a7
127 changed files with 8008 additions and 90 deletions
+196
View File
@@ -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())
}
+253
View File
@@ -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())
}
+59
View File
@@ -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"
)) {}
}
+819
View File
@@ -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 48 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) }
}
}
}