diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 17da0c518..bf74d8142 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -97,6 +97,7 @@ struct AppScene: View { if UserDefaults.standard.bool(forKey: "pinOnLaunch") && settings.pinEnabled { isPinVerified = false } + if migrations.needsPostMigrationSync { app.toast( type: .warning, diff --git a/Bitkit/Assets.xcassets/Illustrations/shield-check-figure.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/shield-check-figure.imageset/Contents.json new file mode 100644 index 000000000..61dfde1ad --- /dev/null +++ b/Bitkit/Assets.xcassets/Illustrations/shield-check-figure.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "shield-check-figure.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Bitkit/Assets.xcassets/Illustrations/shield-check-figure.imageset/shield-check-figure.png b/Bitkit/Assets.xcassets/Illustrations/shield-check-figure.imageset/shield-check-figure.png new file mode 100644 index 000000000..1ba767e79 Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/shield-check-figure.imageset/shield-check-figure.png differ diff --git a/Bitkit/Assets.xcassets/icons/arrow-counter-clockwise.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/arrow-counter-clockwise.imageset/Contents.json new file mode 100644 index 000000000..e84178605 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/arrow-counter-clockwise.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "arrow-counter-clockwise.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/arrow-counter-clockwise.imageset/arrow-counter-clockwise.pdf b/Bitkit/Assets.xcassets/icons/arrow-counter-clockwise.imageset/arrow-counter-clockwise.pdf new file mode 100644 index 000000000..6c28c8d72 Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/arrow-counter-clockwise.imageset/arrow-counter-clockwise.pdf differ diff --git a/Bitkit/Assets.xcassets/icons/barcode.imageset /Contents.json b/Bitkit/Assets.xcassets/icons/barcode.imageset /Contents.json new file mode 100644 index 000000000..6e7a24260 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/barcode.imageset /Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "x-mark.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/barcode.imageset /barcode.pdf b/Bitkit/Assets.xcassets/icons/barcode.imageset /barcode.pdf new file mode 100644 index 000000000..5004953a3 Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/barcode.imageset /barcode.pdf differ diff --git a/Bitkit/Assets.xcassets/icons/broadcast.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/broadcast.imageset/Contents.json new file mode 100644 index 000000000..8e5a73516 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/broadcast.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "broadcast.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/broadcast.imageset/broadcast.pdf b/Bitkit/Assets.xcassets/icons/broadcast.imageset/broadcast.pdf new file mode 100644 index 000000000..2817a1137 Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/broadcast.imageset/broadcast.pdf differ diff --git a/Bitkit/Assets.xcassets/icons/caret-double-right.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/caret-double-right.imageset/Contents.json new file mode 100644 index 000000000..528c5fb61 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/caret-double-right.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "caret-double-right.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/caret-double-right.imageset/caret-double-right.pdf b/Bitkit/Assets.xcassets/icons/caret-double-right.imageset/caret-double-right.pdf new file mode 100644 index 000000000..154d89b67 Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/caret-double-right.imageset/caret-double-right.pdf differ diff --git a/Bitkit/Assets.xcassets/icons/cube.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/cube.imageset/Contents.json new file mode 100644 index 000000000..9c36c08b7 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/cube.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "cube.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/cube.imageset/cube.pdf b/Bitkit/Assets.xcassets/icons/cube.imageset/cube.pdf new file mode 100644 index 000000000..75afbf287 Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/cube.imageset/cube.pdf differ diff --git a/Bitkit/Assets.xcassets/icons/database.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/database.imageset/Contents.json new file mode 100644 index 000000000..d11044b66 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/database.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "database.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/database.imageset/database.pdf b/Bitkit/Assets.xcassets/icons/database.imageset/database.pdf new file mode 100644 index 000000000..a7325b39e Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/database.imageset/database.pdf differ diff --git a/Bitkit/Assets.xcassets/icons/device-mobile-speaker.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/device-mobile-speaker.imageset/Contents.json new file mode 100644 index 000000000..1486dd2e4 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/device-mobile-speaker.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "device-mobile-speaker.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/device-mobile-speaker.imageset/device-mobile-speaker.pdf b/Bitkit/Assets.xcassets/icons/device-mobile-speaker.imageset/device-mobile-speaker.pdf new file mode 100644 index 000000000..94677a2ab Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/device-mobile-speaker.imageset/device-mobile-speaker.pdf differ diff --git a/Bitkit/Assets.xcassets/icons/eye-slash.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/eye-slash.imageset/Contents.json new file mode 100644 index 000000000..969759f92 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/eye-slash.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "eye-slash.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/eye-slash.imageset/eye-slash.pdf b/Bitkit/Assets.xcassets/icons/eye-slash.imageset/eye-slash.pdf new file mode 100644 index 000000000..3690f6ae0 Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/eye-slash.imageset/eye-slash.pdf differ diff --git a/Bitkit/Assets.xcassets/icons/file-text.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/file-text.imageset/Contents.json new file mode 100644 index 000000000..5064d8d33 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/file-text.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "file-text.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/file-text.imageset/file-text.pdf b/Bitkit/Assets.xcassets/icons/file-text.imageset/file-text.pdf new file mode 100644 index 000000000..3d68de81a Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/file-text.imageset/file-text.pdf differ diff --git a/Bitkit/Assets.xcassets/icons/git-branch.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/git-branch.imageset/Contents.json new file mode 100644 index 000000000..2941a65ec --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/git-branch.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "git-branch.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/git-branch.imageset/git-branch.pdf b/Bitkit/Assets.xcassets/icons/git-branch.imageset/git-branch.pdf new file mode 100644 index 000000000..05bf87c9f Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/git-branch.imageset/git-branch.pdf differ diff --git a/Bitkit/Assets.xcassets/icons/git-diff.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/git-diff.imageset/Contents.json new file mode 100644 index 000000000..416ad0367 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/git-diff.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "git-diff.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/git-diff.imageset/git-diff.pdf b/Bitkit/Assets.xcassets/icons/git-diff.imageset/git-diff.pdf new file mode 100644 index 000000000..79e7a522a Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/git-diff.imageset/git-diff.pdf differ diff --git a/Bitkit/Assets.xcassets/icons/hand-pointing.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/hand-pointing.imageset/Contents.json new file mode 100644 index 000000000..798be8db4 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/hand-pointing.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "hand-pointing.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/hand-pointing.imageset/hand-pointing.pdf b/Bitkit/Assets.xcassets/icons/hand-pointing.imageset/hand-pointing.pdf new file mode 100644 index 000000000..edbe95255 Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/hand-pointing.imageset/hand-pointing.pdf differ diff --git a/Bitkit/Assets.xcassets/icons/hard-drives.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/hard-drives.imageset/Contents.json new file mode 100644 index 000000000..b320970d6 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/hard-drives.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "hard-drives.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/hard-drives.imageset/hard-drives.pdf b/Bitkit/Assets.xcassets/icons/hard-drives.imageset/hard-drives.pdf new file mode 100644 index 000000000..0939363b4 Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/hard-drives.imageset/hard-drives.pdf differ diff --git a/Bitkit/Assets.xcassets/icons/list-dashes.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/list-dashes.imageset/Contents.json new file mode 100644 index 000000000..7da1884ca --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/list-dashes.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "list-dashes.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/list-dashes.imageset/list-dashes.pdf b/Bitkit/Assets.xcassets/icons/list-dashes.imageset/list-dashes.pdf new file mode 100644 index 000000000..4e7f8e65a Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/list-dashes.imageset/list-dashes.pdf differ diff --git a/Bitkit/Assets.xcassets/icons/lock-key.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/lock-key.imageset/Contents.json new file mode 100644 index 000000000..0100d4a72 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/lock-key.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "lock-key.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/lock-key.imageset/lock-key.pdf b/Bitkit/Assets.xcassets/icons/lock-key.imageset/lock-key.pdf new file mode 100644 index 000000000..ce72eab32 Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/lock-key.imageset/lock-key.pdf differ diff --git a/Bitkit/Assets.xcassets/icons/question.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/question.imageset/Contents.json new file mode 100644 index 000000000..fd3293f15 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/question.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "question.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/question.imageset/question.pdf b/Bitkit/Assets.xcassets/icons/question.imageset/question.pdf new file mode 100644 index 000000000..9aac37522 Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/question.imageset/question.pdf differ diff --git a/Bitkit/Assets.xcassets/icons/smiley.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/smiley.imageset/Contents.json new file mode 100644 index 000000000..824138d01 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/smiley.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "smiley.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/smiley.imageset/smiley.pdf b/Bitkit/Assets.xcassets/icons/smiley.imageset/smiley.pdf new file mode 100644 index 000000000..19344ec43 Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/smiley.imageset/smiley.pdf differ diff --git a/Bitkit/Assets.xcassets/icons/stop-circle.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/stop-circle.imageset/Contents.json new file mode 100644 index 000000000..0d8d7715c --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/stop-circle.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "stop-circle.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/stop-circle.imageset/stop-circle.pdf b/Bitkit/Assets.xcassets/icons/stop-circle.imageset/stop-circle.pdf new file mode 100644 index 000000000..59047f7b6 Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/stop-circle.imageset/stop-circle.pdf differ diff --git a/Bitkit/Assets.xcassets/icons/translate.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/translate.imageset/Contents.json new file mode 100644 index 000000000..e4a49f0d5 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/translate.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "translate.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/translate.imageset/translate.pdf b/Bitkit/Assets.xcassets/icons/translate.imageset/translate.pdf new file mode 100644 index 000000000..d775d9d9e Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/translate.imageset/translate.pdf differ diff --git a/Bitkit/Assets.xcassets/synonym-logo.imageset/Contents.json b/Bitkit/Assets.xcassets/synonym-logo.imageset/Contents.json new file mode 100644 index 000000000..3336f23a9 --- /dev/null +++ b/Bitkit/Assets.xcassets/synonym-logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "synonym-logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Bitkit/Assets.xcassets/synonym-logo.imageset/synonym-logo.png b/Bitkit/Assets.xcassets/synonym-logo.imageset/synonym-logo.png new file mode 100644 index 000000000..1d871d71c Binary files /dev/null and b/Bitkit/Assets.xcassets/synonym-logo.imageset/synonym-logo.png differ diff --git a/Bitkit/Assets.xcassets/tether-logo.imageset/Contents.json b/Bitkit/Assets.xcassets/tether-logo.imageset/Contents.json new file mode 100644 index 000000000..2f6041ed0 --- /dev/null +++ b/Bitkit/Assets.xcassets/tether-logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tether-logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Bitkit/Assets.xcassets/tether-logo.imageset/tether-logo.png b/Bitkit/Assets.xcassets/tether-logo.imageset/tether-logo.png new file mode 100644 index 000000000..efbb0d23b Binary files /dev/null and b/Bitkit/Assets.xcassets/tether-logo.imageset/tether-logo.png differ diff --git a/Bitkit/Components/DrawerView.swift b/Bitkit/Components/DrawerView.swift index 28a0f6a28..bdbb41df2 100644 --- a/Bitkit/Components/DrawerView.swift +++ b/Bitkit/Components/DrawerView.swift @@ -7,6 +7,7 @@ enum DrawerMenuItem: Int, CaseIterable, Identifiable, Hashable { case profile case widgets case shop + case support case settings case appStatus @@ -22,6 +23,7 @@ enum DrawerMenuItem: Int, CaseIterable, Identifiable, Hashable { case .profile: return "user-square" case .widgets: return "stack" case .shop: return "storefront" + case .support: return "chat" case .settings: return "gear-six" case .appStatus: return "status-circle" } @@ -35,6 +37,7 @@ enum DrawerMenuItem: Int, CaseIterable, Identifiable, Hashable { case .profile: return t("wallet__drawer__profile") case .widgets: return t("wallet__drawer__widgets") case .shop: return t("wallet__drawer__shop") + case .support: return t("wallet__drawer__support") case .settings: return t("wallet__drawer__settings") case .appStatus: return t("settings__status__title") } @@ -57,6 +60,7 @@ enum DrawerMenuItem: Int, CaseIterable, Identifiable, Hashable { case .profile: return "DrawerProfile" case .widgets: return "DrawerWidgets" case .shop: return "DrawerShop" + case .support: return "DrawerSupport" case .settings: return "DrawerSettings" case .appStatus: return "DrawerAppStatus" } @@ -99,9 +103,10 @@ struct DrawerView: View { case .activity: return .activityList case .contacts: return .contacts case .profile: return .profile - case .settings: return .settings - case .shop: return app.hasSeenShopIntro ? .shopDiscover : .shopIntro case .widgets: return app.hasSeenWidgetsIntro ? .widgetsList : .widgetsIntro + case .shop: return app.hasSeenShopIntro ? .shopDiscover : .shopIntro + case .support: return .support + case .settings: return .settings case .appStatus: return .appStatus } } diff --git a/Bitkit/Components/Home/Suggestions.swift b/Bitkit/Components/Home/Suggestions.swift index ece52f8ad..00355e741 100644 --- a/Bitkit/Components/Home/Suggestions.swift +++ b/Bitkit/Components/Home/Suggestions.swift @@ -259,7 +259,7 @@ struct Suggestions: View { switch card.action { case .backup: - sheets.showSheet(.backup, data: BackupConfig()) + sheets.showSheet(.backup) case .buyBitcoin: route = .buyBitcoin case .invite: @@ -271,7 +271,7 @@ struct Suggestions: View { case .notifications: route = app.hasSeenNotificationsIntro ? .notifications : .notificationsIntro case .secure: - sheets.showSheet(.security, data: SecurityConfig(showLaterButton: true)) + sheets.showSheet(.security) case .shop: route = app.hasSeenShopIntro ? .shopDiscover : .shopIntro case .support: diff --git a/Bitkit/Components/PinInput.swift b/Bitkit/Components/PinInput.swift index c009c5fc6..3829feaf2 100644 --- a/Bitkit/Components/PinInput.swift +++ b/Bitkit/Components/PinInput.swift @@ -28,7 +28,6 @@ struct PinInput: View { NumberPad { key in handleNumberPadInput(key) } - .background(Color.black) } .accessibilityIdentifier("PinPad") } diff --git a/Bitkit/Components/SegmentedControl.swift b/Bitkit/Components/SegmentedControl.swift index fcf4c67a9..99260e1d4 100644 --- a/Bitkit/Components/SegmentedControl.swift +++ b/Bitkit/Components/SegmentedControl.swift @@ -16,13 +16,13 @@ struct SegmentedControl: View { private let defaultActiveColor: Color @Namespace private var underlineNamespace - init(selectedTab: Binding, tabs: [T], activeColor: Color = .brandAccent) { + init(selectedTab: Binding, tabs: [T], activeColor: Color = .textPrimary) { _selectedTab = selectedTab tabItems = tabs.map { TabItem($0) } defaultActiveColor = activeColor } - init(selectedTab: Binding, tabItems: [TabItem], defaultActiveColor: Color = .brandAccent) { + init(selectedTab: Binding, tabItems: [TabItem], defaultActiveColor: Color = .textPrimary) { _selectedTab = selectedTab self.tabItems = tabItems self.defaultActiveColor = defaultActiveColor @@ -32,7 +32,7 @@ struct SegmentedControl: View { HStack(spacing: 8) { ForEach(tabItems, id: \.tab) { tabItem in Button(action: { - withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + withAnimation(.easeInOut(duration: 0.2)) { selectedTab = tabItem.tab } }) { @@ -43,6 +43,7 @@ struct SegmentedControl: View { Rectangle() .frame(height: 2) .foregroundColor(Color.white64) + if selectedTab == tabItem.tab { Rectangle() .frame(height: 2) @@ -51,7 +52,7 @@ struct SegmentedControl: View { } } } - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) .contentShape(Rectangle()) } .buttonStyle(PlainButtonStyle()) diff --git a/Bitkit/Components/SettingsListLabel.swift b/Bitkit/Components/SettingsRow.swift similarity index 69% rename from Bitkit/Components/SettingsListLabel.swift rename to Bitkit/Components/SettingsRow.swift index 38328a2bf..f3a11e3f6 100644 --- a/Bitkit/Components/SettingsListLabel.swift +++ b/Bitkit/Components/SettingsRow.swift @@ -1,15 +1,32 @@ import SwiftUI -enum SettingsListRightIcon { +/// Section header for settings screens +struct SettingsSectionHeader: View { + let title: String + + init(_ title: String) { + self.title = title + } + + var body: some View { + CaptionMText(title) + .frame(height: 50) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityAddTraits(.isHeader) + } +} + +enum SettingsRowRightIcon { case chevron case checkmark } -struct SettingsListLabel: View { +struct SettingsRow: View { let title: String let iconName: String? + let iconColor: Color? let rightText: String? - let rightIcon: SettingsListRightIcon? + let rightIcon: SettingsRowRightIcon? let toggle: Binding? let disabled: Bool? let testIdentifier: String? @@ -17,14 +34,16 @@ struct SettingsListLabel: View { init( title: String, iconName: String? = nil, + iconColor: Color? = .brandAccent, rightText: String? = nil, - rightIcon: SettingsListRightIcon? = .chevron, + rightIcon: SettingsRowRightIcon? = .chevron, toggle: Binding? = nil, disabled: Bool? = nil, testIdentifier: String? = nil ) { self.title = title self.iconName = iconName + self.iconColor = iconColor self.rightText = rightText self.rightIcon = rightIcon self.toggle = toggle @@ -36,16 +55,12 @@ struct SettingsListLabel: View { VStack(spacing: 0) { HStack(alignment: .center, spacing: 0) { if let iconName { - Label { - BodyMText(title, textColor: .textPrimary) - } icon: { - CircularIcon(icon: iconName, iconColor: .textPrimary) - .padding(.trailing, 8) - } - } else { - BodyMText(title, textColor: .textPrimary) + CircularIcon(icon: iconName, iconColor: iconColor ?? .brandAccent, backgroundColor: .black) + .padding(.trailing, 8) } + BodyMText(title, textColor: .textPrimary) + Spacer() if let toggle { @@ -57,7 +72,7 @@ struct SettingsListLabel: View { } else { if let rightText { - BodyMText(rightText, textColor: .textPrimary) + BodyMText(rightText, textColor: .textSecondary) .padding(.trailing, 5) .accessibilityIdentifier("Value") } @@ -80,10 +95,7 @@ struct SettingsListLabel: View { } .frame(height: 50) - // Bottom border - Rectangle() - .fill(Color.white10) - .frame(height: 1) + CustomDivider() } } } diff --git a/Bitkit/Components/Social.swift b/Bitkit/Components/Social.swift index 63b873cf8..09f9082ab 100644 --- a/Bitkit/Components/Social.swift +++ b/Bitkit/Components/Social.swift @@ -3,11 +3,7 @@ import SwiftUI struct Social: View { @Environment(\.openURL) private var openURL - let backgroundColor: Color - - init(backgroundColor: Color = .white16) { - self.backgroundColor = backgroundColor - } + let backgroundColor: Color = .clear var body: some View { HStack { diff --git a/Bitkit/Extensions/String+Utilities.swift b/Bitkit/Extensions/String+Utilities.swift index 454f6efdf..2c14c562f 100644 --- a/Bitkit/Extensions/String+Utilities.swift +++ b/Bitkit/Extensions/String+Utilities.swift @@ -1,15 +1,31 @@ import Foundation extension String { - /// Truncates a string to a maximum length and adds ellipsis in the middle - /// - Parameter maxLength: The maximum length of the string - /// - Returns: The truncated string with ellipsis in the middle - func ellipsis(maxLength: Int) -> String { + enum EllipsisStyle { + /// Ellipsis in the middle: "ab...de" + case middle + /// Ellipsis at the end: "abcde..." + case end + } + + /// Truncates a string to a maximum length and adds ellipsis. + /// - Parameters: + /// - maxLength: The maximum length of the string + /// - style: `.middle` (default) keeps start and end with "..." in between; `.end` keeps prefix and "..." at the end + /// - Returns: The truncated string with ellipsis + func ellipsis(maxLength: Int, style: EllipsisStyle = .middle) -> String { if count <= maxLength { return self } - let start = prefix(maxLength / 2) - let end = suffix(maxLength / 2) - return "\(start)...\(end)" + + switch style { + case .middle: + let half = maxLength / 2 + let start = prefix(half) + let end = suffix(half) + return "\(start)...\(end)" + case .end: + return String(prefix(maxLength)) + "..." + } } } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 1829d883a..f7471edc1 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -320,14 +320,8 @@ struct MainNavView: View { case let .widgetEdit(widgetType): WidgetEditView(id: widgetType) // Settings - case .settings: MainSettings() - case .generalSettings: GeneralSettingsView() - case .securitySettings: SecurityPrivacySettingsView() - case .backupSettings: BackupSettings() - case .advancedSettings: AdvancedSettingsView() - case .support: SupportView() - case .about: AboutView() - case .devSettings: DevSettingsView() + case .settings: MainSettingsScreen() + case .support: SupportScreen() // General settings case .languageSettings: LanguageSettingsScreen() @@ -338,16 +332,16 @@ struct MainNavView: View { case .quickpayIntro: QuickpayIntroView() case .customSpeedSettings: CustomSpeedView() case .tagSettings: TagSettingsView() - case .widgetsSettings: WidgetsSettingsView() + case .widgetsSettings: WidgetsSettingsScreen() case .notifications: NotificationsSettings() case .notificationsIntro: NotificationsIntro() // Security settings - case .disablePin: DisablePinView() - case .changePin: PinChangeView() + case .changePin: ChangePinScreen() // Backup settings - case .resetAndRestore: ResetAndRestore() + case .dataBackups: DataBackupsScreen() + case .reset: ResetScreen() // Support settings case .reportIssue: ReportIssue() @@ -363,9 +357,10 @@ struct MainNavView: View { case .electrumSettings: ElectrumSettingsScreen() case .rgsSettings: RgsSettingsScreen() case .addressViewer: AddressViewer() + case .devSettings: DevSettingsView() // Dev settings - case .blocktankRegtest: BlocktankRegtestView() + case .blocktankRegtest: BlocktankRegtestScreen() case .ldkDebug: LdkDebugScreen() case .vssDebug: VssDebugScreen() case .probingTool: ProbingToolScreen() diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index a508858dc..0c40f2ad7 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -529,10 +529,12 @@ "security__pin_choose_text" = "Please use a PIN you will remember. If you forget your PIN you can reset it, but that will require restoring your wallet."; "security__pin_retype_header" = "Retype 4-Digit PIN"; "security__pin_retype_text" = "Please retype your 4-digit PIN to complete the setup process."; -"security__pin_not_match" = "Try again, this is not the same PIN."; -"security__pin_disable_title" = "Disable PIN"; -"security__pin_disable_text" = "PIN code is currently enabled. If you want to disable your PIN, you need to enter your current PIN code first."; +"security__pin_not_match" = "Not the same PIN. Try again."; +"security__pin_change_title" = "PIN Enabled"; +"security__pin_change_text" = "PIN code is currently enabled. If you want to disable your PIN, you need to enter your current PIN code first."; +"security__pin_enable_button" = "Enable PIN"; "security__pin_disable_button" = "Disable PIN"; +"security__pin_disable_text" = "Enter your PIN to disable it."; "security__pin_enter" = "Please enter your PIN code"; "security__pin_last_attempt" = "Last attempt. Entering the wrong PIN again will reset your wallet."; "security__pin_attempts" = "{attemptsRemaining} attempts remaining. Forgot your PIN?"; @@ -583,12 +585,12 @@ "security__export_error_msg" = "Bitkit could not export the backup file to your phone."; "security__export_error_file" = "Bitkit could not create the backup file."; "security__cp_title" = "Change PIN"; -"security__cp_text" = "You can change your PIN code to a new\n4-digit combination. Please enter your current PIN code first."; +"security__cp_text" = "Enter your current PIN to change it."; "security__cp_retype_title" = "Retype New PIN"; "security__cp_retype_text" = "Please retype your 4-digit PIN to complete the setup process."; "security__cp_setnew_title" = "Set New PIN"; "security__cp_setnew_text" = "Please use a PIN you will remember. If you forget your PIN you can reset it, but that will require restoring your wallet."; -"security__cp_try_again" = "Try again, this is not the same PIN."; +"security__cp_try_again" = "Not the same PIN. Try again."; "security__cp_changed_title" = "PIN changed"; "security__cp_changed_text" = "You have successfully changed your PIN to a new 4-digit combination."; "security__use_pin" = "Use PIN code"; @@ -612,13 +614,10 @@ "settings__dev_disabled_title" = "Dev Options Disabled"; "settings__dev_disabled_message" = "Developer options are now disabled throughout the app."; "settings__general_title" = "General"; -"settings__security_title" = "Security and Privacy"; -"settings__backup_title" = "Back up or Restore"; +"settings__security_title" = "Security"; "settings__advanced_title" = "Advanced"; -"settings__about_title" = "About"; +"settings__data_backups_nav_title" = "Data Backups"; "settings__support_title" = "Support"; -"settings__about__title" = "About Bitkit"; -"settings__about__text" = "Thank you for being a responsible Bitcoiner.\nChange your wallet, change the world.\n\nBitkit hands you the keys to your money, profile, contacts, and web accounts.\n\nBitkit was crafted by Synonym Software Ltd."; "settings__about__legal" = "Legal"; "settings__about__share" = "Share"; "settings__about__version" = "Version"; @@ -637,7 +636,7 @@ "settings__general__denomination_label" = "Bitcoin denomination"; "settings__general__denomination_modern" = "Modern (₿ 10 000)"; "settings__general__denomination_classic" = "Classic (₿ 0.00010000)"; -"settings__general__speed" = "Transaction speed"; +"settings__general__speed" = "Transaction Speed"; "settings__general__speed_title" = "Transaction Speed"; "settings__general__speed_default" = "Default Transaction Speed"; "settings__general__speed_fee_custom" = "Set Custom Fee"; @@ -648,9 +647,17 @@ "settings__general__language" = "Language"; "settings__general__language_title" = "Language"; "settings__general__language_other" = "Interface language"; -"settings__widgets__nav_title" = "Widgets and Suggestions"; -"settings__widgets__showWidgets" = "Widgets and Suggestions"; +"settings__general__section_interface" = "Interface"; +"settings__general__section_payments" = "Payments"; +"settings__widgets__nav_title" = "Widgets"; +"settings__widgets__section_display" = "Display"; +"settings__widgets__section_reset" = "Reset To Defaults"; +"settings__widgets__showWidgets" = "Show Widgets"; "settings__widgets__showWidgetTitles" = "Show Widget Titles"; +"settings__widgets__reset_widgets" = "Reset Widgets"; +"settings__widgets__reset_widgets_dialog_title" = "Reset Widgets?"; +"settings__widgets__reset_widgets_dialog_description" = "Are you sure you want to reset the widgets? The default widget set with default configurations will be displayed."; +"settings__widgets__reset_suggestions" = "Reset Suggestions Cards"; "settings__notifications__nav_title" = "Background Payments"; "settings__notifications__intro__title" = "Get Paid\nPassively"; "settings__notifications__intro__text" = "Turn on notifications to get paid, even when your Bitkit app is closed."; @@ -682,17 +689,22 @@ "settings__security__warn_100" = "Warn when sending over $100"; "settings__security__pin" = "PIN Code"; "settings__security__pin_change" = "Change PIN Code"; +"settings__security__section_backup" = "Back up or reset"; +"settings__security__section_privacy" = "Privacy"; +"settings__security__section_safety" = "Safety"; +"settings__security__section_pin" = "PIN Code"; "settings__security__pin_launch" = "Require PIN on launch"; "settings__security__pin_idle" = "Require PIN when idle"; "settings__security__pin_payments" = "Require PIN for payments"; "settings__security__pin_enabled" = "Enabled"; "settings__security__pin_disabled" = "Disabled"; -"settings__security__use_bio" = "Use {biometryTypeName} instead"; +"settings__security__use_bio" = "{biometryTypeName} instead of PIN"; "settings__security__footer" = "When enabled, you can use {biometryTypeName} instead of your PIN code to unlock your wallet or send payments."; -"settings__backup__title" = "Back Up Or Restore"; +"settings__reset_nav_title" = "Reset"; "settings__backup__wallet" = "Back up your wallet"; -"settings__backup__export" = "Export wallet data to phone"; -"settings__backup__reset" = "Reset and restore wallet"; +"settings__backup__data" = "Data backups"; +"settings__backup__export" = "Export wallet data"; +"settings__backup__reset" = "Reset wallet"; "settings__backup__failed_title" = "Data Backup Failure"; "settings__backup__failed_message" = "Bitkit failed to back up wallet data. Retrying in {interval, plural, one {# minute} other {# minutes}}."; "settings__backup__latest" = "latest data backups"; @@ -708,7 +720,7 @@ "settings__backup__category_profile" = "Profile"; "settings__backup__category_contacts" = "Contacts"; "settings__support__title" = "Support"; -"settings__support__text" = "Need help? Report your issue from within Bitkit, visit the help center, check the status, or reach out to us on our social channels."; +"settings__support__text" = "Need help? Report your issue from within Bitkit or visit our help center."; "settings__support__report" = "Report Issue"; "settings__support__help" = "Help Center"; "settings__support__status" = "App Status"; @@ -745,9 +757,10 @@ "settings__status__backup__ready" = "Backed up"; "settings__status__backup__pending" = "Backing up..."; "settings__status__backup__error" = "Failed to complete a full backup"; +"settings__adv__section_debug" = "Debug"; "settings__adv__section_payments" = "Payments"; "settings__adv__section_networks" = "Networks"; -"settings__adv__section_other" = "Other"; +"settings__adv__address_type_title" = "Address Type"; "settings__adv__address_type" = "Bitcoin Address Type"; "settings__adv__monitored_address_types" = "Monitored Address Types"; "settings__adv__addr_type_failed_title" = "Failed"; @@ -967,6 +980,7 @@ "wallet__drawer__widgets" = "Widgets"; "wallet__drawer__shop" = "Shop"; "wallet__drawer__settings" = "Settings"; +"wallet__drawer__support" = "Support"; "wallet__drawer__status" = "App Status"; "wallet__send" = "Send"; "wallet__receive" = "Receive"; diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index b43a79bc0..942182348 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -33,6 +33,7 @@ enum Route: Hashable { case savingsAdvanced case savingsProgress case scanner + case support // Shop case shopIntro @@ -46,37 +47,33 @@ enum Route: Hashable { case widgetDetail(WidgetType) case widgetEdit(WidgetType) - // Main Settings - case settings - case generalSettings - case securitySettings - case backupSettings - case advancedSettings - case support - case about - case devSettings + // Support + case reportIssue + case appStatus - // General settings + // Settings + // General/Interface + case settings case languageSettings case currencySettings case unitSettings - case transactionSpeedSettings - case customSpeedSettings case tagSettings case widgetsSettings + + // General/Payments + case transactionSpeedSettings + case customSpeedSettings case quickpay case quickpayIntro case notifications case notificationsIntro - // Security settings - case disablePin + // Security + case dataBackups + case reset case changePin - /// Backup settings - case resetAndRestore - - // Advanced settings + // Advanced/Payments case coinSelection case addressTypePreference case connections @@ -86,10 +83,7 @@ enum Route: Hashable { case electrumSettings case rgsSettings case addressViewer - - // Support settings - case reportIssue - case appStatus + case devSettings // Dev settings case blocktankRegtest diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index e811dd6cc..4efe4519a 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -300,8 +300,8 @@ class SheetViewModel: ObservableObject { get { guard let config = activeSheetConfiguration, config.id == .security else { return nil } let securityConfig = config.data as? SecurityConfig - let showLaterButton = securityConfig?.showLaterButton ?? false - return SecuritySheetItem(showLaterButton: showLaterButton) + let initialRoute = securityConfig?.initialRoute ?? .intro + return SecuritySheetItem(initialRoute: initialRoute) } set { if newValue == nil { diff --git a/Bitkit/Views/Security/AuthCheck.swift b/Bitkit/Views/Security/AuthCheck.swift index 59e7271cf..466250333 100644 --- a/Bitkit/Views/Security/AuthCheck.swift +++ b/Bitkit/Views/Security/AuthCheck.swift @@ -152,10 +152,10 @@ struct AuthCheck: View { if !errorMessage.isEmpty { BodySText(errorMessage, textColor: .brandAccent) .frame(maxWidth: .infinity, alignment: .center) + .accessibilityIdentifier(errorIdentifier ?? "WrongPIN") .onTapGesture { sheets.showSheet(.forgotPin) } - .accessibilityIdentifier(errorIdentifier ?? "WrongPIN") } } .frame(maxWidth: .infinity) diff --git a/Bitkit/Views/Security/PinCheckView.swift b/Bitkit/Views/Security/PinCheckView.swift index a4805f469..1425c563b 100644 --- a/Bitkit/Views/Security/PinCheckView.swift +++ b/Bitkit/Views/Security/PinCheckView.swift @@ -1,6 +1,13 @@ import SwiftUI struct PinCheckView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var settings: SettingsViewModel + @EnvironmentObject private var sheets: SheetViewModel + @EnvironmentObject private var wallet: WalletViewModel + @EnvironmentObject private var session: SessionManager + let title: String let explanation: String let onCancel: () -> Void @@ -9,8 +16,6 @@ struct PinCheckView: View { @State private var pinInput: String = "" @State private var errorMessage: String = "" @State private var errorIdentifier: String? - @EnvironmentObject private var settings: SettingsViewModel - @Environment(\.dismiss) private var dismiss private func handlePinChange(_ pin: String) { if pin.count == 4 { @@ -39,9 +44,7 @@ struct PinCheckView: View { Haptics.notify(.error) if settings.hasExceededPinAttempts() { - // Exceeded maximum attempts - this should be handled by the app level - // TODO: wipe app - errorIdentifier = "WrongPIN" + wipeWallet() return } @@ -58,6 +61,23 @@ struct PinCheckView: View { } } + private func wipeWallet() { + Task { + do { + try await AppReset.wipe( + app: app, + wallet: wallet, + session: session, + toastType: .warning + ) + sheets.hideSheet() + } catch { + Logger.error("Failed to wipe wallet after PIN attempts exceeded: \(error)", context: "PinCheckView") + app.toast(error) + } + } + } + var body: some View { VStack(spacing: 0) { NavigationBar(title: title, showMenuButton: false) @@ -71,6 +91,9 @@ struct PinCheckView: View { CaptionText(errorMessage, textColor: .brandAccent) .padding(.bottom, 16) .accessibilityIdentifier(errorIdentifier ?? "WrongPIN") + .onTapGesture { + sheets.showSheet(.forgotPin) + } } // PIN input component @@ -110,7 +133,11 @@ struct PinCheckView: View { print("PIN verified!") } ) + .environmentObject(AppViewModel()) .environmentObject(SettingsViewModel.shared) + .environmentObject(SheetViewModel()) + .environmentObject(WalletViewModel()) + .environmentObject(SessionManager()) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Settings/AboutView.swift b/Bitkit/Views/Settings/AboutView.swift deleted file mode 100644 index a8d5c8766..000000000 --- a/Bitkit/Views/Settings/AboutView.swift +++ /dev/null @@ -1,104 +0,0 @@ -import SwiftUI - -struct DiagonalCut: Shape { - func path(in rect: CGRect) -> Path { - var path = Path() - - let leftCutX = rect.maxX * 0.15 - path.move(to: CGPoint(x: leftCutX, y: rect.maxY)) - - let topCutY = rect.maxY * 0.63 - path.addLine(to: CGPoint(x: rect.maxX, y: topCutY)) - - // Line to the top-right corner - path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) - // Line to the bottom-right corner - path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) - // Line to the bottom-left corner - path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) - // Close the path back to the starting point - path.closeSubpath() - - return path - } -} - -struct AboutView: View { - @Environment(\.openURL) private var openURL - - private var appVersion: String { - let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" - let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" - return "\(version) (\(build))" - } - - private var shareText: String { - return t( - "settings__about__shareText", - variables: ["appStoreUrl": Env.appStoreUrl, "playStoreUrl": Env.playStoreUrl] - ) - } - - var body: some View { - ZStack { - // Orange diagonal background - Color.brandAccent - .clipShape(DiagonalCut()) - .ignoresSafeArea() - - VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: t("settings__about__title")) - .padding(.bottom, 16) - - BodyMText(t("settings__about__text")) - .padding(.vertical, 16) - - VStack(spacing: 0) { - Button(action: { - openURL(URL(string: Env.termsOfServiceUrl)!) - }) { - SettingsListLabel(title: t("settings__about__legal")) - } - - ShareLink(item: shareText, message: Text(shareText)) { - SettingsListLabel(title: t("settings__about__share")) - } - - Button(action: { - openURL(URL(string: Env.githubReleasesUrl)!) - }) { - SettingsListLabel( - title: t("settings__about__version"), - rightText: appVersion, - rightIcon: nil - ) - } - } - - Spacer(minLength: 32) - - VStack(alignment: .center, spacing: 0) { - Image("logo") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxHeight: 82) - .accessibilityIdentifier("AboutLogo") - } - .frame(maxWidth: .infinity) - .padding(.bottom, 32) - - Social(backgroundColor: .clear) - } - .navigationBarHidden(true) - .padding(.horizontal, 16) - .bottomSafeAreaPadding() - } - } -} - -#Preview { - NavigationView { - AboutView() - } - .preferredColorScheme(.dark) -} diff --git a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift index c693806d4..5d24873fd 100644 --- a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift +++ b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift @@ -1,113 +1,100 @@ import SwiftUI struct AdvancedSettingsView: View { - @EnvironmentObject var navigation: NavigationViewModel - @EnvironmentObject var suggestionsManager: SuggestionsManager - @EnvironmentObject var settings: SettingsViewModel - @State private var showingResetAlert = false + @EnvironmentObject private var settings: SettingsViewModel + @EnvironmentObject private var wallet: WalletViewModel + + @AppStorage("showDevSettings") private var showDevSettings = Env.isDebug var body: some View { VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: t("settings__advanced_title")) - .padding(.bottom, 16) - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 16) { - // PAYMENTS Section - VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("settings__adv__section_payments")) - .padding(.bottom, 8) - - NavigationLink(value: Route.addressTypePreference) { - SettingsListLabel( - title: t("settings__adv__address_type"), - rightText: settings.selectedAddressType.localizedTitle + VStack(alignment: .leading, spacing: 0) { + // Debug Section + if showDevSettings { + SettingsSectionHeader(t("settings__adv__section_debug")) + + NavigationLink(value: Route.devSettings) { + SettingsRow( + title: t("settings__dev_title"), + iconName: "game-controller" ) } - .accessibilityIdentifier("AddressTypePreference") - - NavigationLink(value: Route.coinSelection) { - SettingsListLabel(title: t("settings__adv__coin_selection")) - } - .accessibilityIdentifier("CoinSelectPreference") + .padding(.bottom, 16) + .accessibilityIdentifier("DevSettings") + } - // NavigationLink(destination: Text("Coming soon")) { - // SettingsListLabel(title: t("settings__adv__payment_preference")) - // } + // Payments section + SettingsSectionHeader(t("settings__adv__section_payments")) - // NavigationLink(destination: Text("Coming soon")) { - // SettingsListLabel(title: t("settings__adv__gap_limit")) - // } + NavigationLink(value: Route.addressTypePreference) { + SettingsRow( + title: t("settings__adv__address_type_title"), + iconName: "list-dashes", + rightText: settings.selectedAddressType.localizedTitle + ) } + .accessibilityIdentifier("AddressTypePreference") - // NETWORKS Section - VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("settings__adv__section_networks")) - .padding(.top, 24) - .padding(.bottom, 8) - - NavigationLink(value: Route.connections) { - SettingsListLabel(title: t("settings__adv__lightning_connections")) - } - .accessibilityIdentifier("Channels") + NavigationLink(value: Route.coinSelection) { + SettingsRow( + title: t("settings__adv__coin_selection"), + iconName: "coins", + rightText: settings.coinSelectionMethod.localizedTitle + ) + } + .accessibilityIdentifier("CoinSelectPreference") - NavigationLink(value: Route.node) { - SettingsListLabel(title: t("settings__adv__lightning_node")) - } - .accessibilityIdentifier("LightningNodeInfo") + NavigationLink(value: Route.addressViewer) { + SettingsRow( + title: t("settings__adv__address_viewer"), + iconName: "eye" + ) + } + .accessibilityIdentifier("AddressViewer") - NavigationLink(value: Route.electrumSettings) { - SettingsListLabel(title: t("settings__adv__electrum_server")) - } - .accessibilityIdentifier("ElectrumConfig") + // Networks section + SettingsSectionHeader(t("settings__adv__section_networks")) + .padding(.top, 16) - NavigationLink(value: Route.rgsSettings) { - SettingsListLabel(title: t("settings__adv__rgs_server")) - } - .accessibilityIdentifier("RGSServer") + NavigationLink(value: Route.connections) { + SettingsRow( + title: t("settings__adv__lightning_connections"), + iconName: "bolt-hollow", + rightText: String(wallet.channels?.count ?? 0) + ) } + .accessibilityIdentifier("Channels") - // OTHER Section - VStack(alignment: .leading, spacing: 0) { - CaptionMText( - t("settings__adv__section_other") + NavigationLink(value: Route.node) { + SettingsRow( + title: t("settings__adv__lightning_node"), + iconName: "git-branch", + rightText: wallet.nodeId?.ellipsis(maxLength: 5, style: .end) ) - .padding(.top, 24) - .padding(.bottom, 8) - - NavigationLink(value: Route.addressViewer) { - SettingsListLabel(title: t("settings__adv__address_viewer")) - } - .accessibilityIdentifier("AddressViewer") - - // SettingsListLabel(title: t("settings__adv__rescan"), rightIcon: nil) + } + .accessibilityIdentifier("LightningNodeInfo") - Button(action: { - showingResetAlert = true - }) { - SettingsListLabel(title: t("settings__adv__suggestions_reset")) - } - .accessibilityIdentifier("ResetSuggestions") + NavigationLink(value: Route.electrumSettings) { + SettingsRow( + title: t("settings__adv__electrum_server"), + iconName: "hard-drives" + ) + } + .accessibilityIdentifier("ElectrumConfig") - Spacer() + NavigationLink(value: Route.rgsSettings) { + SettingsRow( + title: t("settings__adv__rgs_server"), + iconName: "broadcast" + ) } + .accessibilityIdentifier("RGSServer") } + .padding(.top, 16) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() } } - .navigationBarHidden(true) - .padding(.horizontal, 16) - .bottomSafeAreaPadding() - .alert(t("settings__adv__reset_title"), isPresented: $showingResetAlert) { - Button(t("settings__adv__reset_confirm"), role: .destructive) { - suggestionsManager.resetDismissed() - navigation.reset() - } - .accessibilityIdentifier("DialogConfirm") - - Button(t("common__dialog_cancel"), role: .cancel) {} - .accessibilityIdentifier("DialogCancel") - } message: { - Text(t("settings__adv__reset_desc")) - } } } diff --git a/Bitkit/Views/Settings/Advanced/CoinSelectionSettingsView.swift b/Bitkit/Views/Settings/Advanced/CoinSelectionSettingsView.swift index b2587c31a..7a32a9b59 100644 --- a/Bitkit/Views/Settings/Advanced/CoinSelectionSettingsView.swift +++ b/Bitkit/Views/Settings/Advanced/CoinSelectionSettingsView.swift @@ -117,14 +117,12 @@ struct CoinSelectionSettingsView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { NavigationBar(title: t("settings__adv__coin_selection")) - .padding(.bottom, 16) ScrollView(showsIndicators: false) { - VStack(spacing: 0) { + VStack(spacing: 32) { // COIN SELECTION METHOD Section VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("settings__adv__cs_method")) - .padding(.bottom, 8) + SettingsSectionHeader(t("settings__adv__cs_method")) VStack(spacing: 0) { ForEach(CoinSelectionMethod.allCases, id: \.self) { method in @@ -136,9 +134,7 @@ struct CoinSelectionSettingsView: View { settingsViewModel.coinSelectionMethod = method } - if method != CoinSelectionMethod.allCases.last { - Divider() - } + CustomDivider() } } } @@ -147,9 +143,7 @@ struct CoinSelectionSettingsView: View { // AUTOPILOT MODE Section (only show if Autopilot is selected) if settingsViewModel.coinSelectionMethod == .autopilot { VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("settings__adv__cs_auto_mode")) - .padding(.top, 24) - .padding(.bottom, 8) + SettingsSectionHeader(t("settings__adv__cs_auto_mode")) VStack(spacing: 0) { ForEach(CoinSelectionAlgorithm.supportedAlgorithms, id: \.self) { algorithm in @@ -165,16 +159,12 @@ struct CoinSelectionSettingsView: View { } } } - - // Add spacing at the bottom - Spacer() - .frame(height: 32) } + .padding(.horizontal, 16) + .bottomSafeAreaPadding() } } .navigationBarHidden(true) - .padding(.horizontal, 16) - .bottomSafeAreaPadding() } } diff --git a/Bitkit/Views/Settings/Advanced/LightningConnectionDetailView.swift b/Bitkit/Views/Settings/Advanced/LightningConnectionDetailView.swift index 661cce2f3..f2b7aff4c 100644 --- a/Bitkit/Views/Settings/Advanced/LightningConnectionDetailView.swift +++ b/Bitkit/Views/Settings/Advanced/LightningConnectionDetailView.swift @@ -66,12 +66,12 @@ struct LightningConnectionDetailView: View { ) .padding(.bottom, 28) - VStack(alignment: .leading, spacing: 32) { + VStack(alignment: .leading, spacing: 16) { // STATUS Section - VStack(alignment: .leading, spacing: 16) { - Divider() + VStack(alignment: .leading, spacing: 0) { + CustomDivider() - CaptionMText(t("lightning__status")) + SettingsSectionHeader(t("lightning__status")) HStack(alignment: .center, spacing: 8) { let status = detailedStatus(for: channel) @@ -84,15 +84,15 @@ struct LightningConnectionDetailView: View { BodyMSBText(status.text, textColor: status.color) } + .padding(.bottom, 16) - Divider() + CustomDivider() } // ORDER DETAILS Section if let order = channelDetails.linkedOrder { VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("lightning__order_details")) - .padding(.bottom, 16) + SettingsSectionHeader(t("lightning__order_details")) DetailRow(label: t("lightning__order"), value: order.id) @@ -116,8 +116,7 @@ struct LightningConnectionDetailView: View { // BALANCE Section VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("lightning__balance")) - .padding(.bottom, 16) + SettingsSectionHeader(t("lightning__balance")) DetailRowWithAmount( label: t("lightning__receiving_label"), @@ -140,49 +139,45 @@ struct LightningConnectionDetailView: View { // FEES Section VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("lightning__fees")) - .padding(.bottom, 16) - + SettingsSectionHeader(t("lightning__fees")) DetailRowWithAmount(label: t("lightning__base_fee"), amount: UInt64(channel.forwardingFeeBaseMsat / 1000)) DetailRow(label: t("lightning__fee_rate"), value: "\(channel.forwardingFeeProportionalMillionths) ppm") } // OTHER Section - VStack(alignment: .leading, spacing: 16) { - CaptionMText(t("lightning__other")) - - VStack(spacing: 0) { - DetailRow( - label: t("lightning__is_usable"), - value: channel.isUsable ? t("common__yes") : t("common__no"), - valueTestId: channel.isUsable ? "IsUsableYes" : "IsUsableNo" - ) + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader(t("lightning__other")) - // TODO: Add channel opening date - // if let formattedDate = formatDate(channel.fundingTxo) { - // DetailRow(label: t("lightning__opened_on"), value: formattedDate) - // } + DetailRow( + label: t("lightning__is_usable"), + value: channel.isUsable ? t("common__yes") : t("common__no"), + valueTestId: channel.isUsable ? "IsUsableYes" : "IsUsableNo" + ) - if let closedAt = channel.displayedClosedAt { - if let formattedCloseDate = formatDate(closedAt) { - DetailRow(label: t("lightning__closed_on"), value: formattedCloseDate) - } + // TODO: Add channel opening date + // if let formattedDate = formatDate(channel.fundingTxo) { + // DetailRow(label: t("lightning__opened_on"), value: formattedDate) + // } + + if let closedAt = channel.displayedClosedAt { + if let formattedCloseDate = formatDate(closedAt) { + DetailRow(label: t("lightning__closed_on"), value: formattedCloseDate) } + } - DetailRow(label: t("lightning__channel_id"), value: channel.channelIdString) + DetailRow(label: t("lightning__channel_id"), value: channel.channelIdString) - if let txid = channel.displayedFundingTxoTxid, let vout = channel.fundingTxoVout { - DetailRow(label: t("lightning__channel_point"), value: "\(txid):\(vout)") - } + if let txid = channel.displayedFundingTxoTxid, let vout = channel.fundingTxoVout { + DetailRow(label: t("lightning__channel_point"), value: "\(txid):\(vout)") + } - DetailRow( - label: t("lightning__channel_node_id"), - value: channel.counterpartyNodeIdString - ) + DetailRow( + label: t("lightning__channel_node_id"), + value: channel.counterpartyNodeIdString + ) - if let reason = channel.closureReason { - DetailRow(label: t("lightning__closure_reason"), value: reason) - } + if let reason = channel.closureReason { + DetailRow(label: t("lightning__closure_reason"), value: reason) } } } @@ -352,7 +347,7 @@ struct LightningConnectionDetailView: View { .frame(height: 50) } - Divider() + CustomDivider() } .frame(height: 51) } @@ -371,7 +366,7 @@ struct LightningConnectionDetailView: View { .frame(height: 50) } - Divider() + CustomDivider() } .frame(height: 51) } diff --git a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift index 318783829..1cd390ad2 100644 --- a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift +++ b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift @@ -61,14 +61,13 @@ struct LightningConnectionsView: View { } .padding(.bottom, 16) - Divider() + CustomDivider() // Pending Connections section if !pendingConnections.isEmpty { - VStack(alignment: .leading, spacing: 16) { - CaptionMText(t("lightning__conn_pending")) - .padding(.top, 16) + SettingsSectionHeader(t("lightning__conn_pending")) + VStack(alignment: .leading, spacing: 16) { ForEach(Array(pendingConnections.enumerated()), id: \.element.channelId) { index, channel in let labelIndex = pendingConnections.count - index Button { @@ -93,7 +92,7 @@ struct LightningConnectionsView: View { ) .padding(.bottom, 16) - Divider() + CustomDivider() } } .buttonStyle(PlainButtonStyle()) @@ -105,10 +104,9 @@ struct LightningConnectionsView: View { // Open Connections section if !openChannels.isEmpty { - VStack(alignment: .leading, spacing: 16) { - CaptionMText(t("lightning__conn_open")) - .padding(.top, 16) + SettingsSectionHeader(t("lightning__conn_open")) + VStack(alignment: .leading, spacing: 16) { ForEach(Array(openChannels.enumerated()), id: \.element.channelId) { index, channel in let labelIndex = openChannels.count - index Button { @@ -133,7 +131,7 @@ struct LightningConnectionsView: View { ) .padding(.bottom, 16) - Divider() + CustomDivider() } .opacity((!channel.isChannelReady || !channel.isUsable) ? 0.64 : 1.0) } @@ -144,10 +142,9 @@ struct LightningConnectionsView: View { // Closed Connections section if showClosedConnections && !closedChannels.isEmpty { - VStack(alignment: .leading, spacing: 16) { - CaptionMText(t("lightning__conn_closed")) - .padding(.top, 16) + SettingsSectionHeader(t("lightning__conn_closed")) + VStack(alignment: .leading, spacing: 16) { ForEach(Array(closedChannels.enumerated()), id: \.element.channelId) { index, channel in let labelIndex = closedChannels.count - index Button { @@ -172,7 +169,7 @@ struct LightningConnectionsView: View { ) .padding(.bottom, 16) - Divider() + CustomDivider() } .opacity(0.64) } @@ -196,7 +193,6 @@ struct LightningConnectionsView: View { } Spacer() - // .frame(height: 32) HStack(spacing: 16) { CustomButton(title: t("lightning__conn_button_export_logs"), variant: .secondary) { diff --git a/Bitkit/Views/Settings/AppStatusView.swift b/Bitkit/Views/Settings/AppStatusView.swift index 7cc129760..daa2e0699 100644 --- a/Bitkit/Views/Settings/AppStatusView.swift +++ b/Bitkit/Views/Settings/AppStatusView.swift @@ -159,7 +159,7 @@ struct AppStatusView: View { description: description, status: status, onTap: { - navigation.navigate(.backupSettings) + navigation.navigate(.dataBackups) } ) .accessibilityIdentifier("Status-backup") diff --git a/Bitkit/Views/Settings/BlocktankRegtestView.swift b/Bitkit/Views/Settings/BlocktankRegtestView.swift index 53176c339..130ed77b3 100644 --- a/Bitkit/Views/Settings/BlocktankRegtestView.swift +++ b/Bitkit/Views/Settings/BlocktankRegtestView.swift @@ -1,57 +1,41 @@ import SwiftUI -struct RegtestButton: View { - let title: String - let action: () async throws -> Void - - @EnvironmentObject var app: AppViewModel - @State private var isLoading = false - - var body: some View { - Button(isLoading ? "Loading..." : title) { - isLoading = true - Task { - do { - try await action() - } catch { - Logger.error("Regtest action failed: \(error.localizedDescription)", context: "BlocktankRegtestView") - app.toast(type: .error, title: "Regtest action failed: \(error.localizedDescription)") - } - isLoading = false - } - } - .disabled(isLoading) - .opacity(isLoading ? 0.5 : 1) - } -} - -struct BlocktankRegtestView: View { +struct BlocktankRegtestScreen: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var wallet: WalletViewModel + @State private var result: String = "" - @State private var mineBlockCount: String = "1" + @State private var selectedMineBlockCount: Int = 1 @State private var depositAmount: String = "100000" @State private var depositAddress: String = "" @State private var paymentInvoice: String = "" @State private var paymentAmount: String = "" @State private var forceCloseAfterSeconds: String = "" @State private var showingResult = false + @State private var isDepositLoading = false + @State private var isMiningLoading = false + @State private var isPayInvoiceLoading = false + @State private var isClosingChannelLoading = false + + private let mineBlockOptions = [1, 3, 20, 144] var body: some View { VStack(alignment: .leading, spacing: 0) { NavigationBar(title: "Blocktank Regtest") .padding(.horizontal, 16) - List { - serverInfoSection - depositSection - miningSection - lightningPaymentSection - channelCloseSection + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 16) { + depositSection + miningSection + lightningPaymentSection + channelCloseSection + } + .padding(.horizontal, 16) + .bottomSafeAreaPadding() } } .navigationBarHidden(true) - .bottomSafeAreaPadding() .onAppear { // Generate a fresh address when the view appears Task { @@ -66,190 +50,207 @@ struct BlocktankRegtestView: View { } } - var serverInfoSection: some View { - Section { - Text(Env.blocktankClientServer) - } footer: { - Text("These actions are executed on the staging Blocktank server node.") - } - } - var depositSection: some View { - Section { - HStack { - TextField("Address", text: $depositAddress) - .lineLimit(1) - .truncationMode(.middle) + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader("Deposit") - Button { - if let string = UIPasteboard.general.string { - depositAddress = string - } - } label: { - Image(systemName: "doc.on.clipboard") - } + VStack(alignment: .leading, spacing: 8) { + HStack { + TextField("Address", text: $depositAddress) + .lineLimit(1) + .truncationMode(.middle) - Button { - Task { - do { - let newAddress = try await LightningService.shared.newAddress() - depositAddress = newAddress - } catch { - app.toast(type: .error, title: "Failed to generate address", description: error.localizedDescription) + Button { + if let string = UIPasteboard.general.string { + depositAddress = string } + } label: { + Image(systemName: "doc.on.clipboard") } - } label: { - Image(systemName: "arrow.clockwise") - } - } - - TextField("Amount (sats)", text: $depositAmount) - .keyboardType(.numberPad) - RegtestButton(title: "Make Deposit") { - Logger.debug("Initiating regtest deposit with amount: \(depositAmount)", context: "BlocktankRegtestView") - guard let amount = UInt64(depositAmount) else { - Logger.error("Invalid deposit amount: \(depositAmount)", context: "BlocktankRegtestView") - throw ValidationError("Invalid amount") + Button { + Task { + do { + let newAddress = try await LightningService.shared.newAddress() + depositAddress = newAddress + } catch { + app.toast(type: .error, title: "Failed to generate address", description: error.localizedDescription) + } + } + } label: { + Image(systemName: "arrow.clockwise") + } } - // Generate a new address for each deposit - let newAddress = try await LightningService.shared.newAddress() - Logger.debug("Generated new address for deposit: \(newAddress)", context: "BlocktankRegtestView") - - let txId = try await CoreService.shared.blocktank.regtestDepositFunds( - address: newAddress, - amountSat: amount - ) - Logger.debug("Deposit successful with txId: \(txId)", context: "BlocktankRegtestView") - app.toast(type: .success, title: "Success", description: "Deposit successful. TxID: \(txId)") + TextField("Amount (sats)", text: $depositAmount) + .keyboardType(.numberPad) - // Update the displayed address to the new one - depositAddress = newAddress + CustomButton(title: "Deposit", size: .small, isDisabled: depositAmount.isEmpty, isLoading: isDepositLoading) { + isDepositLoading = true + defer { isDepositLoading = false } + do { + Logger.debug("Initiating regtest deposit with amount: \(depositAmount)", context: "BlocktankRegtestScreen") + guard let amount = UInt64(depositAmount) else { + Logger.error("Invalid deposit amount: \(depositAmount)", context: "BlocktankRegtestScreen") + throw ValidationError("Invalid amount") + } - // Sync wallet after deposit without waiting - Task { - try? await wallet.sync() + let newAddress = try await LightningService.shared.newAddress() + Logger.debug("Generated new address for deposit: \(newAddress)", context: "BlocktankRegtestScreen") + + let txId = try await CoreService.shared.blocktank.regtestDepositFunds( + address: newAddress, + amountSat: amount + ) + Logger.debug("Deposit successful with txId: \(txId)", context: "BlocktankRegtestScreen") + app.toast(type: .success, title: "Success", description: "Deposit successful. TxID: \(txId)") + depositAddress = newAddress + Task { try? await wallet.sync() } + } catch { + Logger.error("Regtest action failed: \(error.localizedDescription)", context: "BlocktankRegtestScreen") + app.toast(type: .error, title: "Regtest action failed: \(error.localizedDescription)") + } } } - .disabled(depositAmount.isEmpty) - .tint(.orange) - } header: { - Text("Deposit") } } var miningSection: some View { - Section { - HStack { - TextField("Block count", text: $mineBlockCount) - .keyboardType(.numberPad) - - RegtestButton(title: "Mine Blocks") { - Logger.debug("Starting regtest mining with block count: \(mineBlockCount)", context: "BlocktankRegtestView") - guard let count = UInt32(mineBlockCount) else { - Logger.error("Invalid block count: \(mineBlockCount)", context: "BlocktankRegtestView") - throw ValidationError("Invalid block count") + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader("Mining") + + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + ForEach(mineBlockOptions, id: \.self) { count in + Button { + selectedMineBlockCount = count + } label: { + BodyMSBText("\(count)", textColor: selectedMineBlockCount == count ? .white : .textSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(selectedMineBlockCount == count ? Color.brandAccent : Color.white10) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) } - try await CoreService.shared.blocktank.regtestMineBlocks(count) - Logger.debug("Successfully mined \(count) blocks", context: "BlocktankRegtestView") - app.toast(type: .success, title: "Success", description: "Successfully mined \(count) blocks") + } - // Sync wallet after mining blocks without waiting - Task { - try? await wallet.sync() + CustomButton(title: "Mine Blocks", size: .small, isDisabled: selectedMineBlockCount == 0, isLoading: isMiningLoading) { + isMiningLoading = true + defer { isMiningLoading = false } + do { + let count = UInt32(selectedMineBlockCount) + Logger.debug("Starting regtest mining with block count: \(count)", context: "BlocktankRegtestScreen") + try await CoreService.shared.blocktank.regtestMineBlocks(count) + Logger.debug("Successfully mined \(count) blocks", context: "BlocktankRegtestScreen") + app.toast(type: .success, title: "Success", description: "Successfully mined \(count) blocks") + Task { try? await wallet.sync() } + } catch { + Logger.error("Regtest action failed: \(error.localizedDescription)", context: "BlocktankRegtestScreen") + app.toast(type: .error, title: "Regtest action failed: \(error.localizedDescription)") } } - .tint(.orange) } - } header: { - Text("Mining") } } var lightningPaymentSection: some View { - Section { - HStack { - TextField("Invoice", text: $paymentInvoice) + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader("Lightning Payment") + + VStack(alignment: .leading, spacing: 8) { + HStack { + TextField("Invoice", text: $paymentInvoice) - Button { - if let string = UIPasteboard.general.string { - paymentInvoice = string + Button { + if let string = UIPasteboard.general.string { + paymentInvoice = string + } + } label: { + Image(systemName: "doc.on.clipboard") } - } label: { - Image(systemName: "doc.on.clipboard") } - } - TextField("Amount (optional, sats)", text: $paymentAmount) - .keyboardType(.numberPad) + TextField("Amount (optional, sats)", text: $paymentAmount) + .keyboardType(.numberPad) - RegtestButton(title: "Pay Invoice") { - Logger.debug("Initiating regtest payment with invoice: \(paymentInvoice), amount: \(paymentAmount)", context: "BlocktankRegtestView") - let amount = paymentAmount.isEmpty ? nil : UInt64(paymentAmount) ?? 0 - let paymentId = try await CoreService.shared.blocktank.regtestPayInvoice(paymentInvoice, amountSat: amount) - Logger.debug("Payment successful with ID: \(paymentId)", context: "BlocktankRegtestView") - app.toast(type: .success, title: "Success", description: "Payment successful. ID: \(paymentId)") + CustomButton(title: "Pay Invoice", size: .small, isDisabled: paymentInvoice.isEmpty, isLoading: isPayInvoiceLoading) { + isPayInvoiceLoading = true + defer { isPayInvoiceLoading = false } + do { + Logger.debug( + "Initiating regtest payment with invoice: \(paymentInvoice), amount: \(paymentAmount)", + context: "BlocktankRegtestScreen" + ) + let amount = paymentAmount.isEmpty ? nil : UInt64(paymentAmount) ?? 0 + let paymentId = try await CoreService.shared.blocktank.regtestPayInvoice(paymentInvoice, amountSat: amount) + Logger.debug("Payment successful with ID: \(paymentId)", context: "BlocktankRegtestScreen") + app.toast(type: .success, title: "Success", description: "Payment successful. ID: \(paymentId)") + } catch { + Logger.error("Regtest action failed: \(error.localizedDescription)", context: "BlocktankRegtestScreen") + app.toast(type: .error, title: "Regtest action failed: \(error.localizedDescription)") + } + } } - .disabled(paymentInvoice.isEmpty) - .tint(.orange) - } header: { - Text("Lightning Payment") } } var channelCloseSection: some View { - Section { - TextField("Force close after (seconds)", text: $forceCloseAfterSeconds) - .keyboardType(.numberPad) - - if let channels = wallet.channels, !channels.isEmpty { - ForEach(channels, id: \.channelId) { channel in - VStack(alignment: .leading) { - Text(channel.channelId) - .font(.caption) - - Text("Ready: \(channel.isChannelReady ? "✅" : "❌")") - Text("Usable: \(channel.isUsable ? "✅" : "❌")") - - RegtestButton(title: "Close This Channel") { - Logger.debug("Closing channel: \(channel.channelId)", context: "BlocktankRegtestView") - - let closeAfter = forceCloseAfterSeconds.isEmpty ? nil : UInt64(forceCloseAfterSeconds) + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader("Channel close from Blocktank side") - let closingTxId = try await CoreService.shared.blocktank.regtestRemoteCloseChannel( - channel: channel, - forceCloseAfterSeconds: closeAfter - ) + VStack(alignment: .leading, spacing: 8) { + TextField("Force close after (seconds)", text: $forceCloseAfterSeconds) + .keyboardType(.numberPad) - Logger.debug("Channel closed successfully with txId: \(closingTxId)", context: "BlocktankRegtestView") - app.toast(type: .success, title: "Success", description: "Channel closed. Closing TxID: \(closingTxId)") + if let channels = wallet.channels, !channels.isEmpty { + ForEach(channels, id: \.channelId) { channel in + VStack(alignment: .leading) { + CaptionMText(channel.channelId, textColor: .textSecondary) + + HStack(spacing: 6) { + BodyMText("Ready:", textColor: .textPrimary) + Image(systemName: channel.isChannelReady ? "checkmark.circle.fill" : "xmark.circle.fill") + .font(.system(size: 17)) + .foregroundColor(channel.isChannelReady ? .greenAccent : .redAccent) + } + HStack(spacing: 6) { + BodyMText("Usable:", textColor: .textPrimary) + Image(systemName: channel.isUsable ? "checkmark.circle.fill" : "xmark.circle.fill") + .font(.system(size: 17)) + .foregroundColor(channel.isUsable ? .greenAccent : .redAccent) + } + + CustomButton(title: "Close This Channel", size: .small, isDisabled: false, isLoading: isClosingChannelLoading) { + isClosingChannelLoading = true + defer { isClosingChannelLoading = false } + do { + Logger.debug("Closing channel: \(channel.channelId)", context: "BlocktankRegtestScreen") + let closeAfter = forceCloseAfterSeconds.isEmpty ? nil : UInt64(forceCloseAfterSeconds) + let closingTxId = try await CoreService.shared.blocktank.regtestRemoteCloseChannel( + channel: channel, + forceCloseAfterSeconds: closeAfter + ) + Logger.debug("Channel closed successfully with txId: \(closingTxId)", context: "BlocktankRegtestScreen") + app.toast(type: .success, title: "Success", description: "Channel closed. Closing TxID: \(closingTxId)") + } catch { + Logger.error("Regtest action failed: \(error.localizedDescription)", context: "BlocktankRegtestScreen") + app.toast(type: .error, title: "Regtest action failed: \(error.localizedDescription)") + } + } + .padding(.top, 8) } - .tint(.red) - .padding(.top, 8) + .padding(.vertical, 4) } - .padding(.vertical, 4) + } else { + BodyMText("No channels available") + .padding(.vertical, 8) } - } else { - Text("No channels available") - .foregroundColor(.secondary) - .padding(.vertical, 8) } - } header: { - Text("Channel close from Blocktank side") } } } -#Preview { - NavigationStack { - BlocktankRegtestView() - .environmentObject(AppViewModel()) - .environmentObject(WalletViewModel()) - } - .preferredColorScheme(.dark) -} - private struct ValidationError: LocalizedError { let message: String diff --git a/Bitkit/Views/Settings/DevSettingsView.swift b/Bitkit/Views/Settings/DevSettingsView.swift index 73e4d2756..0e3544b1d 100644 --- a/Bitkit/Views/Settings/DevSettingsView.swift +++ b/Bitkit/Views/Settings/DevSettingsView.swift @@ -18,12 +18,12 @@ struct DevSettingsView: View { VStack(alignment: .leading, spacing: 0) { if Env.network == .regtest { NavigationLink(value: Route.blocktankRegtest) { - SettingsListLabel(title: "Blocktank Regtest") + SettingsRow(title: "Blocktank Regtest") } } if Env.network == .regtest { - SettingsListLabel( + SettingsRow( title: "Override Fees", rightIcon: nil, toggle: $feeEstimatesManager.devOverrideFeeEstimates @@ -31,19 +31,19 @@ struct DevSettingsView: View { } NavigationLink(value: Route.ldkDebug) { - SettingsListLabel(title: "LDK") + SettingsRow(title: "LDK") } NavigationLink(value: Route.vssDebug) { - SettingsListLabel(title: "VSS") + SettingsRow(title: "VSS") } NavigationLink(value: Route.probingTool) { - SettingsListLabel(title: "Probing Tool") + SettingsRow(title: "Probing Tool") } NavigationLink(value: Route.orders) { - SettingsListLabel(title: "Orders") + SettingsRow(title: "Orders") } Button { @@ -57,7 +57,7 @@ struct DevSettingsView: View { } } } label: { - SettingsListLabel(title: "Generate Test Activities", rightIcon: nil) + SettingsRow(title: "Generate Test Activities", rightIcon: nil) } Button { @@ -71,11 +71,11 @@ struct DevSettingsView: View { } } } label: { - SettingsListLabel(title: "Reset All Activities", rightIcon: nil) + SettingsRow(title: "Reset All Activities", rightIcon: nil) } NavigationLink(value: Route.logs) { - SettingsListLabel(title: "Show Logs") + SettingsRow(title: "Show Logs") } Button { @@ -100,7 +100,7 @@ struct DevSettingsView: View { } } } label: { - SettingsListLabel(title: "Export Logs", rightIcon: nil) + SettingsRow(title: "Export Logs", rightIcon: nil) } Button { @@ -117,13 +117,13 @@ struct DevSettingsView: View { } } } label: { - SettingsListLabel(title: "Test Push Notification", rightIcon: nil) + SettingsRow(title: "Test Push Notification", rightIcon: nil) } Button { fatalError("Simulate Crash") } label: { - SettingsListLabel(title: "Simulate Crash", rightIcon: nil) + SettingsRow(title: "Simulate Crash", rightIcon: nil) } Button { @@ -139,7 +139,7 @@ struct DevSettingsView: View { } } } label: { - SettingsListLabel(title: "Wipe Wallet", rightIcon: nil) + SettingsRow(title: "Wipe Wallet", rightIcon: nil) } } .padding(.horizontal, 16) diff --git a/Bitkit/Views/Settings/General/DefaultUnitSettingsView.swift b/Bitkit/Views/Settings/General/DefaultUnitSettingsView.swift index e5d2b53e4..6a8efe7f0 100644 --- a/Bitkit/Views/Settings/General/DefaultUnitSettingsView.swift +++ b/Bitkit/Views/Settings/General/DefaultUnitSettingsView.swift @@ -6,64 +6,61 @@ struct DefaultUnitSettingsView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { NavigationBar(title: t("settings__general__unit_title")) + .padding(.horizontal, 16) ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 8) { - CaptionMText(t("settings__general__unit_display")) - .padding(.vertical, 16) - .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader(t("settings__general__unit_display")) Button(action: { currency.primaryDisplay = .bitcoin }) { - SettingsListLabel( + SettingsRow( title: t("settings__general__unit_bitcoin"), iconName: "b-unit", + iconColor: .textPrimary, rightIcon: currency.primaryDisplay == .bitcoin ? .checkmark : nil ) } - .buttonStyle(PlainButtonStyle()) if let rate = currency.convert(sats: 1)?.currency { Button(action: { currency.primaryDisplay = .fiat }) { - SettingsListLabel( + SettingsRow( title: rate, iconName: "globe", + iconColor: .textPrimary, rightIcon: currency.primaryDisplay == .fiat ? .checkmark : nil ) } - .buttonStyle(PlainButtonStyle()) } BodyMText(t("settings__general__unit_note", variables: ["currency": currency.selectedCurrency])) .padding(.vertical, 16) - } - VStack(alignment: .leading, spacing: 8) { - CaptionMText(t("settings__general__denomination_label")) - .padding(.vertical, 16) - .frame(maxWidth: .infinity, alignment: .leading) + CustomDivider() + + SettingsSectionHeader(t("settings__general__denomination_label")) + .padding(.top, 16) ForEach(BitcoinDisplayUnit.allCases, id: \.self) { unit in Button(action: { currency.displayUnit = unit }) { - SettingsListLabel( + SettingsRow( title: t("settings__general__denomination_\(unit.rawValue)"), rightIcon: currency.displayUnit == unit ? .checkmark : nil ) } - .buttonStyle(PlainButtonStyle()) .accessibilityIdentifier(unit.testIdentifier) } } + .padding(.horizontal, 16) + .bottomSafeAreaPadding() } } .navigationBarHidden(true) - .padding(.horizontal, 16) - .bottomSafeAreaPadding() } } diff --git a/Bitkit/Views/Settings/General/LanguageSettingsScreen.swift b/Bitkit/Views/Settings/General/LanguageSettingsScreen.swift index d6c0a4957..97d58429d 100644 --- a/Bitkit/Views/Settings/General/LanguageSettingsScreen.swift +++ b/Bitkit/Views/Settings/General/LanguageSettingsScreen.swift @@ -8,7 +8,7 @@ struct LanguageSettingsScreen: View { Button(action: { selectLanguage(language) }) { - SettingsListLabel( + SettingsRow( title: language.name, rightIcon: languageManager.currentLanguage.code == language.code ? .checkmark : nil ) @@ -26,22 +26,21 @@ struct LanguageSettingsScreen: View { var body: some View { VStack(alignment: .leading, spacing: 0) { NavigationBar(title: t("settings__general__language_title")) + .padding(.horizontal, 16) ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("settings__general__language_other")) - .padding(.vertical, 16) - .frame(maxWidth: .infinity, alignment: .leading) + SettingsSectionHeader(t("settings__general__language_other")) ForEach(SupportedLanguage.allLanguages, id: \.id) { language in languageRow(language) } } + .padding(.horizontal, 16) + .bottomSafeAreaPadding() } } .navigationBarHidden(true) - .padding(.horizontal, 16) - .bottomSafeAreaPadding() .alert("Language Changed", isPresented: $showAlert) { Button("OK", role: .cancel) {} } message: { diff --git a/Bitkit/Views/Settings/General/LocalCurrencySettingsView.swift b/Bitkit/Views/Settings/General/LocalCurrencySettingsView.swift index d92f79e3f..a6144fa5a 100644 --- a/Bitkit/Views/Settings/General/LocalCurrencySettingsView.swift +++ b/Bitkit/Views/Settings/General/LocalCurrencySettingsView.swift @@ -2,6 +2,8 @@ import SwiftUI struct LocalCurrencySettingsView: View { @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var navigation: NavigationViewModel + @State private var searchText = "" private let mostUsedCurrencies = ["USD", "GBP", "CAD", "CNY", "EUR"] @@ -25,7 +27,7 @@ struct LocalCurrencySettingsView: View { } private func currencyRow(_ rate: FxRate) -> some View { - SettingsListLabel( + SettingsRow( title: "\(rate.quote) (\(rate.currencySymbol))", rightIcon: currency.selectedCurrency == rate.quote ? .checkmark : nil, testIdentifier: "Currency-\(rate.quote)" @@ -35,6 +37,7 @@ struct LocalCurrencySettingsView: View { currency.selectedCurrency = rate.quote Task { await currency.refresh() + navigation.navigateBack() } } .accessibilityAddTraits(.isButton) @@ -64,10 +67,7 @@ struct LocalCurrencySettingsView: View { ScrollView(showsIndicators: false) { if !availableMostUsed.isEmpty { VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("settings__general__currency_most_used")) - .padding(.top, 16) - .padding(.bottom, 8) - .frame(maxWidth: .infinity, alignment: .leading) + SettingsSectionHeader(t("settings__general__currency_most_used")) ForEach(availableMostUsed, id: \.quote) { rate in currencyRow(rate) @@ -76,15 +76,13 @@ struct LocalCurrencySettingsView: View { } VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("settings__general__currency_other")) - .padding(.top, 24) - .padding(.bottom, 8) - .frame(maxWidth: .infinity, alignment: .leading) + SettingsSectionHeader(t("settings__general__currency_other")) ForEach(otherCurrencies, id: \.quote) { rate in currencyRow(rate) } } + .padding(.top, 16) CaptionText(t("settings__general__currency_footer")) .padding(.top, 16) diff --git a/Bitkit/Views/Settings/General/TagSettingsView.swift b/Bitkit/Views/Settings/General/TagSettingsView.swift index e9601854e..61ec7bc61 100644 --- a/Bitkit/Views/Settings/General/TagSettingsView.swift +++ b/Bitkit/Views/Settings/General/TagSettingsView.swift @@ -1,18 +1,16 @@ import SwiftUI struct TagSettingsView: View { - @EnvironmentObject var app: AppViewModel @EnvironmentObject var tagManager: TagManager var body: some View { VStack(alignment: .leading, spacing: 0) { NavigationBar(title: t("settings__general__tags")) + .padding(.horizontal, 16) ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("settings__general__tags_previously")) - .padding(.top, 24) - .padding(.bottom, 16) + SettingsSectionHeader(t("settings__general__tags_previously")) TagsListView( tags: tagManager.lastUsedTags, @@ -22,10 +20,10 @@ struct TagSettingsView: View { } ) } + .padding(.horizontal, 16) + .bottomSafeAreaPadding() } } .navigationBarHidden(true) - .padding(.horizontal, 16) - .bottomSafeAreaPadding() } } diff --git a/Bitkit/Views/Settings/General/WidgetsSettingsScreen.swift b/Bitkit/Views/Settings/General/WidgetsSettingsScreen.swift new file mode 100644 index 000000000..c60e42ca1 --- /dev/null +++ b/Bitkit/Views/Settings/General/WidgetsSettingsScreen.swift @@ -0,0 +1,82 @@ +import SwiftUI + +struct WidgetsSettingsScreen: View { + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var settings: SettingsViewModel + @EnvironmentObject var suggestionsManager: SuggestionsManager + @EnvironmentObject var widgets: WidgetsViewModel + + @State private var showWidgetsResetAlert = false + @State private var showSuggestionsResetAlert = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("settings__widgets__nav_title")) + .padding(.horizontal, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader(t("settings__widgets__section_display")) + + SettingsRow( + title: t("settings__widgets__showWidgets"), + toggle: $settings.showWidgets + ) + + SettingsRow( + title: t("settings__widgets__showWidgetTitles"), + toggle: $settings.showWidgetTitles + ) + + SettingsSectionHeader(t("settings__widgets__section_reset")) + .padding(.top, 16) + + Button(action: { + showWidgetsResetAlert = true + }) { + SettingsRow( + title: t("settings__widgets__reset_widgets"), + iconName: "arrow-counter-clockwise" + ) + } + + Button(action: { + showSuggestionsResetAlert = true + }) { + SettingsRow( + title: t("settings__widgets__reset_suggestions"), + iconName: "arrow-counter-clockwise" + ) + } + } + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + } + } + .navigationBarHidden(true) + .alert(t("settings__widgets__reset_widgets_dialog_title"), isPresented: $showWidgetsResetAlert) { + Button(t("settings__adv__reset_confirm"), role: .destructive) { + widgets.clearWidgets() + navigation.reset() + } + .accessibilityIdentifier("DialogConfirm") + + Button(t("common__dialog_cancel"), role: .cancel) {} + .accessibilityIdentifier("DialogCancel") + } message: { + Text(t("settings__widgets__reset_widgets_dialog_description")) + } + .alert(t("settings__adv__reset_title"), isPresented: $showSuggestionsResetAlert) { + Button(t("settings__adv__reset_confirm"), role: .destructive) { + suggestionsManager.resetDismissed() + navigation.reset() + } + .accessibilityIdentifier("DialogConfirm") + + Button(t("common__dialog_cancel"), role: .cancel) {} + .accessibilityIdentifier("DialogCancel") + } message: { + Text(t("settings__adv__reset_desc")) + } + } +} diff --git a/Bitkit/Views/Settings/General/WidgetsSettingsView.swift b/Bitkit/Views/Settings/General/WidgetsSettingsView.swift deleted file mode 100644 index 30e5a99b2..000000000 --- a/Bitkit/Views/Settings/General/WidgetsSettingsView.swift +++ /dev/null @@ -1,37 +0,0 @@ -import SwiftUI - -struct WidgetsSettingsView: View { - @EnvironmentObject var settings: SettingsViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: t("settings__widgets__nav_title")) - .padding(.horizontal, 16) - - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - SettingsListLabel( - title: t("settings__widgets__showWidgets"), - toggle: $settings.showWidgets - ) - - SettingsListLabel( - title: t("settings__widgets__showWidgetTitles"), - toggle: $settings.showWidgetTitles - ) - } - .padding(.horizontal, 16) - .bottomSafeAreaPadding() - } - } - .navigationBarHidden(true) - } -} - -#Preview { - NavigationView { - WidgetsSettingsView() - .environmentObject(SettingsViewModel.shared) - } - .preferredColorScheme(.dark) -} diff --git a/Bitkit/Views/Settings/GeneralSettingsView.swift b/Bitkit/Views/Settings/GeneralSettingsView.swift index 454d14e84..ccbc84f53 100644 --- a/Bitkit/Views/Settings/GeneralSettingsView.swift +++ b/Bitkit/Views/Settings/GeneralSettingsView.swift @@ -8,82 +8,93 @@ struct GeneralSettingsView: View { @StateObject private var languageManager = LanguageManager.shared var body: some View { - VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: t("settings__general_title")) + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + // Interface section + SettingsSectionHeader(t("settings__general__section_interface")) - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - NavigationLink(value: Route.languageSettings) { - SettingsListLabel( - title: t("settings__general__language"), - rightText: languageManager.currentLanguageDisplayName - ) - } + NavigationLink(value: Route.languageSettings) { + SettingsRow( + title: t("settings__general__language"), + iconName: "translate", + rightText: languageManager.currentLanguageDisplayName + ) + } - NavigationLink(value: Route.currencySettings) { - SettingsListLabel( - title: t("settings__general__currency_local"), - rightText: currency.selectedCurrency - ) - } - .accessibilityIdentifier("CurrenciesSettings") + NavigationLink(value: Route.currencySettings) { + SettingsRow( + title: t("settings__general__currency_local"), + iconName: "coins", + rightText: "\(currency.selectedCurrency) (\(currency.symbol))" + ) + } + .accessibilityIdentifier("CurrenciesSettings") - NavigationLink(value: Route.unitSettings) { - SettingsListLabel( - title: t("settings__general__unit"), - rightText: currency.primaryDisplay == .bitcoin ? currency.primaryDisplay.rawValue : currency.selectedCurrency - ) - } - .accessibilityIdentifier("UnitSettings") + NavigationLink(value: Route.unitSettings) { + SettingsRow( + title: t("settings__general__unit"), + iconName: currency.primaryDisplay == .bitcoin ? "b-unit" : "globe", + rightText: currency.primaryDisplay == .bitcoin ? currency.primaryDisplay.rawValue : currency.selectedCurrency + ) + } + .accessibilityIdentifier("UnitSettings") + + NavigationLink(value: Route.widgetsSettings) { + SettingsRow( + title: t("settings__widgets__nav_title"), + iconName: "cube", + rightText: settings.showWidgets ? t("common__on") : t("common__off") + ) + } + .accessibilityIdentifier("WidgetsSettings") - NavigationLink(value: Route.transactionSpeedSettings) { - SettingsListLabel( - title: t("settings__general__speed"), - rightText: settings.defaultTransactionSpeed.title + if !tagManager.lastUsedTags.isEmpty { + NavigationLink(value: Route.tagSettings) { + SettingsRow( + title: t("settings__general__tags"), + iconName: "tag", + rightText: String(tagManager.lastUsedTags.count) ) } - .accessibilityElement(children: .contain) - .accessibilityIdentifier("TransactionSpeedSettings") + .accessibilityIdentifier("TagsSettings") + } - if !tagManager.lastUsedTags.isEmpty { - NavigationLink(value: Route.tagSettings) { - SettingsListLabel( - title: t("settings__general__tags"), - rightText: String(tagManager.lastUsedTags.count) - ) - } - .accessibilityIdentifier("TagsSettings") - } + // Payments section + SettingsSectionHeader(t("settings__general__section_payments")) + .padding(.top, 16) - NavigationLink(value: Route.widgetsSettings) { - SettingsListLabel( - title: t("settings__widgets__nav_title"), - rightText: settings.showWidgets ? t("common__on") : t("common__off") - ) - } - .accessibilityIdentifier("WidgetsSettings") + NavigationLink(value: Route.transactionSpeedSettings) { + SettingsRow( + title: t("settings__general__speed"), + iconName: settings.defaultTransactionSpeed.iconName, + rightText: settings.defaultTransactionSpeed.title + ) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("TransactionSpeedSettings") - NavigationLink(value: app.hasSeenQuickpayIntro ? Route.quickpay : Route.quickpayIntro) { - SettingsListLabel( - title: t("settings__quickpay__nav_title"), - rightText: settings.enableQuickpay ? t("common__on") : t("common__off") - ) - } - .accessibilityIdentifier("QuickpaySettings") + NavigationLink(value: app.hasSeenQuickpayIntro ? Route.quickpay : Route.quickpayIntro) { + SettingsRow( + title: t("settings__quickpay__nav_title"), + iconName: "caret-double-right", + rightText: settings.enableQuickpay ? t("common__on") : t("common__off") + ) + } + .accessibilityIdentifier("QuickpaySettings") - NavigationLink(value: app.hasSeenNotificationsIntro ? Route.notifications : Route.notificationsIntro) { - SettingsListLabel( - title: t("settings__notifications__nav_title"), - rightText: settings.enableNotifications ? t("common__on") : t("common__off") - ) - } - .accessibilityIdentifier("NotificationsSettings") + NavigationLink(value: app.hasSeenNotificationsIntro ? Route.notifications : Route.notificationsIntro) { + SettingsRow( + title: t("settings__notifications__nav_title"), + iconName: "bell", + rightText: settings.enableNotifications ? t("common__on") : t("common__off") + ) } + .accessibilityIdentifier("NotificationsSettings") } + .padding(.top, 16) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() } - .navigationBarHidden(true) - .padding(.horizontal, 16) - .bottomSafeAreaPadding() } } diff --git a/Bitkit/Views/Settings/LdkDebugScreen.swift b/Bitkit/Views/Settings/LdkDebugScreen.swift index 69d1556cb..49439a570 100644 --- a/Bitkit/Views/Settings/LdkDebugScreen.swift +++ b/Bitkit/Views/Settings/LdkDebugScreen.swift @@ -17,7 +17,7 @@ struct LdkDebugScreen: View { VStack(alignment: .leading, spacing: 32) { // Add Peer VStack(alignment: .leading, spacing: 8) { - CaptionMText("Add Peer") + SettingsSectionHeader("Add Peer") TextField("039b8d4d...a8f3eae3@127.0.0.1:9735", text: $nodeUri) @@ -37,7 +37,7 @@ struct LdkDebugScreen: View { // Network Graph Storage VStack(alignment: .leading, spacing: 8) { - CaptionMText("Network Graph Storage") + SettingsSectionHeader("Network Graph Storage") HStack(spacing: 8) { CustomButton(title: "Log Graph Info", size: .small) { @@ -56,7 +56,7 @@ struct LdkDebugScreen: View { // Node VStack(alignment: .leading, spacing: 8) { - CaptionMText("Node") + SettingsSectionHeader("Node") HStack(spacing: 8) { CustomButton(title: "Restart", size: .small, isLoading: isRestartingNode) { @@ -69,7 +69,7 @@ struct LdkDebugScreen: View { // Peer Simulation VStack(alignment: .leading, spacing: 8) { - CaptionMText("Peer Simulation") + SettingsSectionHeader("Peer Simulation") Picker("Peer Simulation", selection: Binding( get: { WalletViewModel.peerSimulation }, diff --git a/Bitkit/Views/Settings/MainSettings.swift b/Bitkit/Views/Settings/MainSettings.swift deleted file mode 100644 index d558b1119..000000000 --- a/Bitkit/Views/Settings/MainSettings.swift +++ /dev/null @@ -1,107 +0,0 @@ -import SwiftUI - -struct MainSettings: View { - @EnvironmentObject private var app: AppViewModel - - @AppStorage("showDevSettings") private var showDevSettings = Env.isDebug - @State private var cogTapCount = 0 - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: t("settings__settings")) - - GeometryReader { geometry in - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - NavigationLink(value: Route.generalSettings) { - SettingsListLabel( - title: t("settings__general_title"), - iconName: "gear-six" - ) - } - .accessibilityIdentifier("GeneralSettings") - - NavigationLink(value: Route.securitySettings) { - SettingsListLabel( - title: t("settings__security_title"), - iconName: "shield" - ) - } - .accessibilityIdentifier("SecuritySettings") - - NavigationLink(value: Route.backupSettings) { - SettingsListLabel( - title: t("settings__backup_title"), - iconName: "rewind" - ) - } - .accessibilityIdentifier("BackupSettings") - - NavigationLink(value: Route.advancedSettings) { - SettingsListLabel( - title: t("settings__advanced_title"), - iconName: "sliders" - ) - } - .accessibilityIdentifier("AdvancedSettings") - - NavigationLink(value: Route.support) { - SettingsListLabel( - title: t("settings__support_title"), - iconName: "chat" - ) - } - .accessibilityIdentifier("Support") - - NavigationLink(value: Route.about) { - SettingsListLabel( - title: t("settings__about_title"), - iconName: "info" - ) - } - .accessibilityIdentifier("About") - - if showDevSettings { - NavigationLink(value: Route.devSettings) { - SettingsListLabel( - title: t("settings__dev_title"), - iconName: "game-controller" - ) - } - .accessibilityIdentifier("DevSettings") - } - - Spacer() - - Image("cog") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 256, height: 256) - .frame(maxWidth: .infinity, alignment: .center) - .onTapGesture { - cogTapCount += 1 - - // Toggle dev settings every 5 taps - if cogTapCount >= 5 { - showDevSettings.toggle() - cogTapCount = 0 - - app.toast( - type: .success, - title: t(showDevSettings ? "settings__dev_enabled_title" : "settings__dev_disabled_title"), - description: t(showDevSettings ? "settings__dev_enabled_message" : "settings__dev_disabled_message") - ) - } - } - .accessibilityIdentifier("DevOptions") - - Spacer() - } - .frame(minHeight: geometry.size.height) - } - } - } - .navigationBarHidden(true) - .padding(.horizontal, 16) - } -} diff --git a/Bitkit/Views/Settings/MainSettingsScreen.swift b/Bitkit/Views/Settings/MainSettingsScreen.swift new file mode 100644 index 000000000..93088c337 --- /dev/null +++ b/Bitkit/Views/Settings/MainSettingsScreen.swift @@ -0,0 +1,43 @@ +import SwiftUI + +enum SettingsTab: String, CaseIterable, CustomStringConvertible { + case general + case security + case advanced + + var description: String { + switch self { + case .general: return t("settings__general_title") + case .security: return t("settings__security_title") + case .advanced: return t("settings__advanced_title") + } + } +} + +struct MainSettingsScreen: View { + @State private var selectedTab: SettingsTab = .general + + private var settingsTabItems: [TabItem] { + SettingsTab.allCases.map { TabItem($0) } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("settings__settings")) + .padding(.horizontal, 16) + + SegmentedControl(selectedTab: $selectedTab, tabItems: settingsTabItems) + .padding(.horizontal, 16) + + Group { + switch selectedTab { + case .general: GeneralSettingsView() + case .security: SecuritySettingsView() + case .advanced: AdvancedSettingsView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .navigationBarHidden(true) + } +} diff --git a/Bitkit/Views/Settings/Notifications/NotificationsSettings.swift b/Bitkit/Views/Settings/Notifications/NotificationsSettings.swift index 0ef7fbe1e..eeae11de8 100644 --- a/Bitkit/Views/Settings/Notifications/NotificationsSettings.swift +++ b/Bitkit/Views/Settings/Notifications/NotificationsSettings.swift @@ -29,7 +29,7 @@ struct NotificationsSettings: View { VStack(alignment: .leading, spacing: 0) { NavigationBar(title: t("settings__notifications__nav_title")) - SettingsListLabel( + SettingsRow( title: t("settings__notifications__settings__toggle"), toggle: $settings.enableNotifications, disabled: isDenied @@ -51,7 +51,7 @@ struct NotificationsSettings: View { CaptionMText(t("settings__notifications__settings__privacy__label")) .padding(.top, 32) - SettingsListLabel( + SettingsRow( title: t("settings__notifications__settings__privacy__text"), toggle: $settings.enableNotificationsAmount ) diff --git a/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift b/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift index d19182eb1..ca920ec57 100644 --- a/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift +++ b/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift @@ -13,7 +13,7 @@ struct QuickpaySettings: View { GeometryReader { geometry in ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { - SettingsListLabel( + SettingsRow( title: t("settings__quickpay__settings__toggle"), toggle: $settings.enableQuickpay, testIdentifier: "QuickpayToggle" @@ -24,8 +24,8 @@ struct QuickpaySettings: View { ) .padding(.top, 16) - VStack(alignment: .leading, spacing: 16) { - CaptionMText(t("settings__quickpay__settings__label")) + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader(t("settings__quickpay__settings__label")) CustomSlider(value: $settings.quickpayAmount, steps: sliderSteps) } .padding(.top, 32) diff --git a/Bitkit/Views/Settings/Security/ChangePinScreen.swift b/Bitkit/Views/Settings/Security/ChangePinScreen.swift new file mode 100644 index 000000000..bdaec4a6f --- /dev/null +++ b/Bitkit/Views/Settings/Security/ChangePinScreen.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct ChangePinScreen: View { + @EnvironmentObject private var settings: SettingsViewModel + @EnvironmentObject private var sheets: SheetViewModel + + var navTitle: String { + settings.pinEnabled ? t("security__pin_change_title") : t("settings__security__pin") + } + + var description: String { + settings.pinEnabled ? t("security__pin_change_text") : t("security__pin_security_text") + } + + var image: String { + settings.pinEnabled ? "shield-check-figure" : "shield-figure" + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: navTitle) + .padding(.bottom, 16) + + BodyMText(description) + + Spacer() + + Image(image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + .frame(maxWidth: .infinity) + + Spacer() + + HStack(alignment: .center, spacing: 16) { + if settings.pinEnabled { + CustomButton(title: t("security__cp_title"), variant: .secondary) { + sheets.showSheet(.security, data: SecurityConfig(initialRoute: .changePin)) + } + .accessibilityIdentifier("ChangePin") + + CustomButton(title: t("security__pin_disable_button")) { + sheets.showSheet(.security, data: SecurityConfig(initialRoute: .disablePin)) + } + .accessibilityIdentifier("DisablePin") + } else { + CustomButton(title: t("security__pin_enable_button")) { + sheets.showSheet(.security, data: SecurityConfig(initialRoute: .setupPin)) + } + .accessibilityIdentifier("EnablePin") + } + } + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + } +} diff --git a/Bitkit/Views/Settings/Backup/BackupSettings.swift b/Bitkit/Views/Settings/Security/DataBackupsScreen.swift similarity index 76% rename from Bitkit/Views/Settings/Backup/BackupSettings.swift rename to Bitkit/Views/Settings/Security/DataBackupsScreen.swift index de0909a96..31b84c1a2 100644 --- a/Bitkit/Views/Settings/Backup/BackupSettings.swift +++ b/Bitkit/Views/Settings/Security/DataBackupsScreen.swift @@ -1,19 +1,5 @@ import SwiftUI -private struct SettingsLabel: View { - let text: String - - init(_ text: String) { - self.text = text - } - - var body: some View { - CaptionMText(text) - .frame(height: 40) - .frame(maxWidth: .infinity, alignment: .leading) - } -} - private struct StatusItemView: View { let imageName: String let title: String @@ -51,8 +37,7 @@ private struct StatusItemView: View { } } -struct BackupSettings: View { - @EnvironmentObject var sheets: SheetViewModel +struct DataBackupsScreen: View { @StateObject private var viewModel = BackupViewModel() private var allSynced: Bool { @@ -64,26 +49,12 @@ struct BackupSettings: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: t("settings__backup_title")) + NavigationBar(title: t("settings__data_backups_nav_title")) GeometryReader { geometry in ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { - Button(action: { - sheets.showSheet(.backup, data: BackupConfig(view: .mnemonic)) - }) { - SettingsListLabel(title: t("settings__backup__wallet")) - } - .accessibilityIdentifier("BackupWallet") - - NavigationLink(value: Route.resetAndRestore) { - SettingsListLabel(title: t("settings__backup__reset")) - } - .accessibilityIdentifier("ResetAndRestore") - HStack(alignment: .center, spacing: 8) { - SettingsLabel(t("settings__backup__latest")) - if Env.isE2E, allSynced { Image("check") .resizable() @@ -112,6 +83,8 @@ struct BackupSettings: View { viewModel.triggerBackup(for: category) } ) + + Divider() } Spacer() diff --git a/Bitkit/Views/Settings/Security/DisablePinView.swift b/Bitkit/Views/Settings/Security/DisablePinView.swift deleted file mode 100644 index f285e0425..000000000 --- a/Bitkit/Views/Settings/Security/DisablePinView.swift +++ /dev/null @@ -1,57 +0,0 @@ -import SwiftUI - -struct DisablePinView: View { - @Environment(\.dismiss) private var dismiss - @EnvironmentObject var settings: SettingsViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: t("security__pin_disable_title")) - .padding(.bottom, 16) - - BodyMText(t("security__pin_disable_text")) - - Spacer() - - Image("shield-figure") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 256, height: 256) - .frame(maxWidth: .infinity) - .padding(.top, 32) - - Spacer() - - CustomButton( - title: t("security__pin_disable_button"), - destination: PinCheckView( - title: t("security__pin_enter"), - explanation: "", - onCancel: {}, - onPinVerified: { pin in - do { - try settings.removePin(pin: pin) - dismiss() - } catch { - Logger.error("Failed to remove PIN: \(error)", context: "DisablePinView") - // Still dismiss even if there's an error, as the PIN was verified - dismiss() - } - } - ) - ) - .accessibilityIdentifier("DisablePin") - } - .navigationBarHidden(true) - .padding(.horizontal, 16) - .bottomSafeAreaPadding() - } -} - -#Preview { - NavigationStack { - DisablePinView() - } - .preferredColorScheme(.dark) - .environmentObject(SettingsViewModel.shared) -} diff --git a/Bitkit/Views/Settings/Security/PinChangeView.swift b/Bitkit/Views/Settings/Security/PinChangeView.swift deleted file mode 100644 index 63d7e204e..000000000 --- a/Bitkit/Views/Settings/Security/PinChangeView.swift +++ /dev/null @@ -1,259 +0,0 @@ -import SwiftUI - -/// View for changing the PIN or disabling it -struct PinChangeView: View { - @Environment(\.dismiss) private var dismiss - @EnvironmentObject private var app: AppViewModel - @EnvironmentObject private var session: SessionManager - @EnvironmentObject private var settings: SettingsViewModel - @EnvironmentObject private var sheets: SheetViewModel - @EnvironmentObject private var wallet: WalletViewModel - - @State private var pinInput: String = "" - @State private var currentPin: String = "" - @State private var newPin: String = "" - @State private var step: PinChangeStep = .verifyCurrentPin - @State private var errorMessage: String = "" - @State private var errorIdentifier: String? - - enum PinChangeStep { - case verifyCurrentPin - case enterNewPin - case confirmNewPin - case success - } - - /// Computed properties for title and description - var navTitle: String { - switch step { - case .verifyCurrentPin: - return t("security__cp_title", comment: "Change PIN") - case .enterNewPin: - return t("security__cp_setnew_title", comment: "Set new PIN title") - case .confirmNewPin: - return t("security__cp_retype_title", comment: "Retype New PIN") - case .success: - return t("security__cp_changed_title", comment: "PIN changed title") - } - } - - var description: String { - switch step { - case .verifyCurrentPin: - return t("security__cp_text", comment: "Change PIN description") - case .enterNewPin: - return t("security__cp_setnew_text", comment: "Set new PIN description") - case .confirmNewPin: - return t("security__cp_retype_text", comment: "Retype PIN description") - case .success: - return t("security__cp_changed_text", comment: "PIN changed description") - } - } - - private func handlePinComplete(_ pin: String) { - switch step { - case .verifyCurrentPin: - handleCurrentPinVerification(pin) - case .enterNewPin: - handleNewPinEntry(pin) - case .confirmNewPin: - handleNewPinConfirmation(pin) - case .success: - // Should not reach here as PIN input is hidden in success state - break - } - } - - private func handleCurrentPinVerification(_ pin: String) { - if settings.pinCheck(pin: pin) { - // Current PIN is correct - proceed to new PIN entry - currentPin = pin - step = .enterNewPin - resetPinInput() - Haptics.notify(.success) - } else { - handleIncorrectCurrentPin() - } - } - - private func handleNewPinEntry(_ pin: String) { - // Store the new PIN and move to confirmation - newPin = pin - step = .confirmNewPin - resetPinInput() - Haptics.notify(.success) - } - - private func handleNewPinConfirmation(_ pin: String) { - if pin == newPin { - // PINs match - update the PIN - updatePin() - } else { - // PINs don't match - go back to enter new PIN - handlePinMismatch() - } - } - - private func updatePin() { - do { - try settings.removePin(pin: currentPin, resetSettings: false) - try settings.setPin(newPin) - step = .success - resetPinInput() - Haptics.notify(.success) - } catch { - Logger.error("Failed to change PIN: \(error)", context: "PinChangeView") - errorMessage = t("security__cp_try_again", comment: "Try again, this is not the same PIN") - errorIdentifier = "WrongPIN" - pinInput = "" - Haptics.notify(.error) - } - } - - private func handlePinMismatch() { - errorMessage = t("security__cp_try_again", comment: "Try again, this is not the same PIN") - errorIdentifier = "WrongPIN" - pinInput = "" - Haptics.notify(.error) - } - - private func resetPinInput() { - pinInput = "" - errorMessage = "" - errorIdentifier = nil - } - - private func handleIncorrectCurrentPin() { - pinInput = "" - Haptics.notify(.error) - - if settings.hasExceededPinAttempts() { - handleWalletWipe() - return - } - - updateErrorMessageForRemainingAttempts() - } - - private func handleWalletWipe() { - Task { - do { - try await AppReset.wipe( - app: app, - wallet: wallet, - session: session, - toastType: .warning - ) - } catch { - Logger.error("Failed to wipe wallet after PIN attempts exceeded: \(error)", context: "PinChangeView") - app.toast(error) - } - } - } - - private func updateErrorMessageForRemainingAttempts() { - let remainingAttempts = settings.getRemainingPinAttempts() - - if remainingAttempts == 1 { - // Last attempt warning - errorMessage = t( - "security__pin_last_attempt", - comment: "Last attempt. Entering the wrong PIN again will reset your wallet." - ) - errorIdentifier = "LastAttempt" - } else { - // Show remaining attempts - errorMessage = t( - "security__pin_attempts", - comment: "%d attempts remaining. Forgot your PIN?", - variables: ["attemptsRemaining": "\(remainingAttempts)"] - ) - errorIdentifier = "AttemptsRemaining" - } - } - - private func handlePinChange(_ pin: String) { - if pin.count == 4 { - handlePinComplete(pin) - } else if pin.count == 1 { - // Clear error message when user starts typing - errorMessage = "" - } - } - - var body: some View { - VStack(spacing: 0) { - NavigationBar(title: navTitle, showBackButton: step != .success) - - if step == .success { - successScreen - } else { - descriptionSection - errorSection - pinInputSection - } - } - .navigationBarHidden(true) - .padding(.horizontal, 16) - .bottomSafeAreaPadding() - } - - private var descriptionSection: some View { - BodyMText(description) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 32) - .padding(.bottom, 49) - } - - private var successScreen: some View { - VStack(spacing: 0) { - BodyMText(t("security__cp_changed_text")) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 32) - - Spacer() - - Image("check") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 274, height: 274) - - Spacer() - - CustomButton(title: t("common__ok")) { - dismiss() - } - } - } - - private var errorSection: some View { - Group { - if !errorMessage.isEmpty { - BodySText(errorMessage, textColor: .brandAccent) - .frame(maxWidth: .infinity, alignment: .center) - .onTapGesture { - sheets.showSheet(.forgotPin) - } - .accessibilityIdentifier(errorIdentifier ?? "WrongPIN") - } - } - } - - private var pinInputSection: some View { - PinInput(pinInput: $pinInput, verticalSpace: true) { pin in - handlePinChange(pin) - } - .padding(.top, 16) - } -} - -#Preview { - NavigationStack { - PinChangeView() - } - .preferredColorScheme(.dark) - .environmentObject(AppViewModel()) - .environmentObject(SettingsViewModel.shared) - .environmentObject(SheetViewModel()) - .environmentObject(WalletViewModel()) -} diff --git a/Bitkit/Views/Settings/Backup/ResetAndRestore.swift b/Bitkit/Views/Settings/Security/ResetScreen.swift similarity index 96% rename from Bitkit/Views/Settings/Backup/ResetAndRestore.swift rename to Bitkit/Views/Settings/Security/ResetScreen.swift index 97bcd23ab..89e55da30 100644 --- a/Bitkit/Views/Settings/Backup/ResetAndRestore.swift +++ b/Bitkit/Views/Settings/Security/ResetScreen.swift @@ -1,6 +1,6 @@ import SwiftUI -struct ResetAndRestore: View { +struct ResetScreen: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var sheets: SheetViewModel @EnvironmentObject var wallet: WalletViewModel @@ -10,7 +10,7 @@ struct ResetAndRestore: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: t("settings__backup__title")) + NavigationBar(title: t("settings__reset_nav_title")) VStack(spacing: 0) { BodyMText(t("security__reset_text")) diff --git a/Bitkit/Views/Settings/Security/SecuritySheet/SecurityChangePin.swift b/Bitkit/Views/Settings/Security/SecuritySheet/SecurityChangePin.swift new file mode 100644 index 000000000..b448d9f0e --- /dev/null +++ b/Bitkit/Views/Settings/Security/SecuritySheet/SecurityChangePin.swift @@ -0,0 +1,177 @@ +import SwiftUI + +private enum Step: Hashable { + case verifyCurrentPin + case enterNewPin + case confirmNewPin +} + +struct SecurityChangePin: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var settings: SettingsViewModel + @EnvironmentObject private var sheets: SheetViewModel + @EnvironmentObject private var session: SessionManager + @EnvironmentObject private var wallet: WalletViewModel + + @Binding var navigationPath: [SecurityRoute] + + @State private var pinInput: String = "" + @State private var currentPin: String = "" + @State private var newPin: String = "" + @State private var errorMessage: String = "" + @State private var errorIdentifier: String? + @State private var step: Step = .verifyCurrentPin + + private var navTitle: String { + switch step { + case .verifyCurrentPin: t("security__cp_title") + case .enterNewPin: t("security__cp_setnew_title") + case .confirmNewPin: t("security__cp_retype_title") + } + } + + private var text: String { + switch step { + case .verifyCurrentPin: t("security__cp_text") + case .enterNewPin: t("security__cp_setnew_text") + case .confirmNewPin: t("security__cp_retype_text") + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: navTitle) + .padding(.horizontal, 16) + + VStack(spacing: 0) { + BodyMText(text, accentFont: Fonts.bold) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 32) + + Spacer() + + if !errorMessage.isEmpty { + BodySText(errorMessage, textColor: .brandAccent) + .padding(.bottom, 16) + .accessibilityIdentifier(errorIdentifier ?? "WrongPIN") + } + } + .padding(.horizontal, 32) + + PinInput(pinInput: $pinInput) { pin in + if pin.count == 4 { + handlePinComplete(pin) + } else if pin.count == 1 { + errorMessage = "" + errorIdentifier = nil + } + } + } + .navigationBarHidden(true) + .allowSwipeBack(false) + .sheetBackground() + } + + private func handlePinComplete(_ pin: String) { + switch step { + case .verifyCurrentPin: + handleVerifyCurrentPin(pin) + case .enterNewPin: + handleEnterNewPin(pin) + case .confirmNewPin: + handleConfirmNewPin(pin) + } + } + + private func handleVerifyCurrentPin(_ pin: String) { + if settings.pinCheck(pin: pin) { + currentPin = pin + step = .enterNewPin + resetPinInput() + Haptics.notify(.success) + } else { + handleIncorrectCurrentPin() + } + } + + private func handleEnterNewPin(_ pin: String) { + newPin = pin + step = .confirmNewPin + resetPinInput() + Haptics.notify(.success) + } + + private func handleConfirmNewPin(_ pin: String) { + guard pin == newPin else { + errorMessage = t("security__cp_try_again") + errorIdentifier = "WrongPIN" + pinInput = "" + return + } + + // PINs match - update PIN + do { + try settings.removePin(pin: currentPin, resetSettings: false) + try settings.setPin(newPin) + pinInput = "" + errorMessage = "" + errorIdentifier = nil + navigationPath.append(.changePinSuccess) + } catch { + Logger.error("Failed to change PIN: \(error)", context: "SecurityChangePin") + errorMessage = t("security__cp_try_again") + errorIdentifier = "WrongPIN" + pinInput = "" + } + } + + private func handleIncorrectCurrentPin() { + pinInput = "" + + if settings.hasExceededPinAttempts() { + handleWalletWipe() + return + } + + let remainingAttempts = settings.getRemainingPinAttempts() + if remainingAttempts == 1 { + errorMessage = t( + "security__pin_last_attempt", + comment: "Last attempt. Entering the wrong PIN again will reset your wallet." + ) + errorIdentifier = "LastAttempt" + } else { + errorMessage = t( + "security__pin_attempts", + comment: "%d attempts remaining. Forgot your PIN?", + variables: ["attemptsRemaining": "\(remainingAttempts)"] + ) + errorIdentifier = "AttemptsRemaining" + } + + Haptics.notify(.error) + } + + private func handleWalletWipe() { + Task { + do { + try await AppReset.wipe( + app: app, + wallet: wallet, + session: session, + toastType: .warning + ) + sheets.hideSheet() + } catch { + Logger.error("Failed to wipe wallet after PIN attempts exceeded: \(error)", context: "SecurityChangePin") + app.toast(error) + } + } + } + + private func resetPinInput() { + pinInput = "" + errorMessage = "" + errorIdentifier = nil + } +} diff --git a/Bitkit/Views/Settings/Security/SecuritySheet/SecurityChangePinSuccess.swift b/Bitkit/Views/Settings/Security/SecuritySheet/SecurityChangePinSuccess.swift new file mode 100644 index 000000000..d8372d693 --- /dev/null +++ b/Bitkit/Views/Settings/Security/SecuritySheet/SecurityChangePinSuccess.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct SecurityChangePinSuccess: View { + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var sheets: SheetViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: t("security__cp_changed_title")) + + VStack(spacing: 0) { + BodyMText(t("security__cp_changed_text")) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 32) + + Spacer() + + Image("check") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + + Spacer() + + CustomButton(title: t("common__ok")) { + sheets.hideSheet() + navigation.navigateBack() + } + } + .padding(.horizontal, 16) + } + .navigationBarHidden(true) + .allowSwipeBack(false) + .padding(.horizontal, 16) + .sheetBackground() + } +} diff --git a/Bitkit/Views/Settings/Security/SecuritySheet/SecurityDisablePin.swift b/Bitkit/Views/Settings/Security/SecuritySheet/SecurityDisablePin.swift new file mode 100644 index 000000000..6b8ed18be --- /dev/null +++ b/Bitkit/Views/Settings/Security/SecuritySheet/SecurityDisablePin.swift @@ -0,0 +1,102 @@ +import SwiftUI + +struct SecurityDisablePin: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var session: SessionManager + @EnvironmentObject private var settings: SettingsViewModel + @EnvironmentObject private var sheets: SheetViewModel + @EnvironmentObject private var wallet: WalletViewModel + + @State private var pinInput: String = "" + @State private var errorMessage: String = "" + @State private var errorIdentifier: String? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: t("security__pin_disable_button")) + .padding(.horizontal, 16) + + VStack(spacing: 0) { + BodyMText(t("security__pin_disable_text")) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 32) + + Spacer() + + if !errorMessage.isEmpty { + BodySText(errorMessage, textColor: .brandAccent) + .padding(.bottom, 16) + .accessibilityIdentifier(errorIdentifier ?? "WrongPIN") + .onTapGesture { + sheets.showSheet(.forgotPin) + } + } + } + .padding(.horizontal, 32) + + PinInput(pinInput: $pinInput) { pin in + if pin.count == 4 { + onPinEntered(pin) + } else if pin.count == 1 { + errorMessage = "" + errorIdentifier = nil + } + } + } + .navigationBarHidden(true) + .allowSwipeBack(false) + .sheetBackground() + } + + private func onPinEntered(_ pin: String) { + if settings.pinCheck(pin: pin) { + do { + try settings.removePin(pin: pin) + navigation.navigateBack() + sheets.hideSheet() + } catch { + Logger.error("Failed to disable PIN: \(error)", context: "SecurityDisablePin") + errorMessage = t("security__cp_try_again") + errorIdentifier = "WrongPIN" + pinInput = "" + } + return + } + + pinInput = "" + + if settings.hasExceededPinAttempts() { + handleWalletWipe() + return + } + + let remainingAttempts = settings.getRemainingPinAttempts() + if remainingAttempts == 1 { + errorMessage = t("security__pin_last_attempt") + errorIdentifier = "LastAttempt" + } else { + errorMessage = t("security__pin_attempts", variables: ["attemptsRemaining": "\(remainingAttempts)"]) + errorIdentifier = "AttemptsRemaining" + } + + Haptics.notify(.error) + } + + private func handleWalletWipe() { + Task { + do { + try await AppReset.wipe( + app: app, + wallet: wallet, + session: session, + toastType: .warning + ) + sheets.hideSheet() + } catch { + Logger.error("Failed to wipe wallet after PIN attempts exceeded: \(error)", context: "SecurityDisablePin") + app.toast(error) + } + } + } +} diff --git a/Bitkit/Views/Settings/Security/SecuritySheet/SecurityIntro.swift b/Bitkit/Views/Settings/Security/SecuritySheet/SecurityIntro.swift index b477a007f..e89af3994 100644 --- a/Bitkit/Views/Settings/Security/SecuritySheet/SecurityIntro.swift +++ b/Bitkit/Views/Settings/Security/SecuritySheet/SecurityIntro.swift @@ -3,7 +3,6 @@ import SwiftUI struct SecurityIntro: View { @EnvironmentObject private var sheets: SheetViewModel @Binding var navigationPath: [SecurityRoute] - let showLaterButton: Bool var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -13,14 +12,14 @@ struct SecurityIntro: View { description: t("security__pin_security_text"), image: "shield-figure", continueText: t("security__pin_security_button"), - cancelText: showLaterButton ? t("common__later") : nil, + cancelText: t("common__later"), accentColor: .greenAccent, testID: "SecureWallet", onCancel: { sheets.hideSheet() }, onContinue: { - navigationPath.append(.pin) + navigationPath.append(.setupPin) } ) } @@ -28,6 +27,6 @@ struct SecurityIntro: View { } #Preview { - SecurityIntro(navigationPath: .constant([.intro]), showLaterButton: true) + SecurityIntro(navigationPath: .constant([.intro])) .environmentObject(SheetViewModel()) } diff --git a/Bitkit/Views/Settings/Security/SecuritySheet/SecurityPin.swift b/Bitkit/Views/Settings/Security/SecuritySheet/SecuritySetupPin.swift similarity index 85% rename from Bitkit/Views/Settings/Security/SecuritySheet/SecurityPin.swift rename to Bitkit/Views/Settings/Security/SecuritySheet/SecuritySetupPin.swift index 4497c1cca..6a30f4da8 100644 --- a/Bitkit/Views/Settings/Security/SecuritySheet/SecurityPin.swift +++ b/Bitkit/Views/Settings/Security/SecuritySheet/SecuritySetupPin.swift @@ -1,6 +1,6 @@ import SwiftUI -struct SecurityPin: View { +struct SecuritySetupPin: View { @EnvironmentObject private var settings: SettingsViewModel @Binding var navigationPath: [SecurityRoute] @State private var pinInput: String = "" @@ -40,14 +40,12 @@ struct SecurityPin: View { } .padding(.horizontal, 32) - VStack(spacing: 0) { - PinInput(pinInput: $pinInput) { pin in - if pin.count == 4 { - handlePinComplete(pin) - } else if pin.count == 1 { - errorMessage = "" - errorIdentifier = nil - } + PinInput(pinInput: $pinInput) { pin in + if pin.count == 4 { + handlePinComplete(pin) + } else if pin.count == 1 { + errorMessage = "" + errorIdentifier = nil } } } @@ -91,8 +89,3 @@ struct SecurityPin: View { } } } - -#Preview { - SecurityPin(navigationPath: .constant([.pin])) - .environmentObject(SettingsViewModel.shared) -} diff --git a/Bitkit/Views/Settings/Security/SecuritySheet/SecuritySheet.swift b/Bitkit/Views/Settings/Security/SecuritySheet/SecuritySheet.swift index 4b1a90054..532611a2b 100644 --- a/Bitkit/Views/Settings/Security/SecuritySheet/SecuritySheet.swift +++ b/Bitkit/Views/Settings/Security/SecuritySheet/SecuritySheet.swift @@ -2,27 +2,31 @@ import SwiftUI enum SecurityRoute: Hashable { case intro - case pin + case setupPin case biometrics case noBiometrics case success + case changePin + case changePinSuccess + case disablePin } struct SecurityConfig { - let showLaterButton: Bool + let initialRoute: SecurityRoute - init(showLaterButton: Bool = false) { - self.showLaterButton = showLaterButton + init(initialRoute: SecurityRoute = .intro) { + self.initialRoute = initialRoute } } struct SecuritySheetItem: SheetItem { let id: SheetID = .security - let showLaterButton: Bool let size: SheetSize = .medium + let initialRoute: SecurityRoute - static let withLaterButton = SecuritySheetItem(showLaterButton: true) - static let withoutLaterButton = SecuritySheetItem(showLaterButton: false) + init(initialRoute: SecurityRoute = .intro) { + self.initialRoute = initialRoute + } } struct SecuritySheet: View { @@ -32,7 +36,7 @@ struct SecuritySheet: View { var body: some View { Sheet(id: .security, data: config) { NavigationStack(path: $navigationPath) { - viewForRoute(.intro) + viewForRoute(config.initialRoute) .navigationDestination(for: SecurityRoute.self) { route in viewForRoute(route) } @@ -44,15 +48,21 @@ struct SecuritySheet: View { private func viewForRoute(_ route: SecurityRoute) -> some View { switch route { case .intro: - SecurityIntro(navigationPath: $navigationPath, showLaterButton: config.showLaterButton) - case .pin: - SecurityPin(navigationPath: $navigationPath) + SecurityIntro(navigationPath: $navigationPath) + case .setupPin: + SecuritySetupPin(navigationPath: $navigationPath) case .biometrics: SecurityBiometrics(navigationPath: $navigationPath) case .noBiometrics: SecurityNoBiometrics(navigationPath: $navigationPath) case .success: - SecuritySuccess(navigationPath: $navigationPath) + SecuritySuccess() + case .changePin: + SecurityChangePin(navigationPath: $navigationPath) + case .changePinSuccess: + SecurityChangePinSuccess() + case .disablePin: + SecurityDisablePin() } } } diff --git a/Bitkit/Views/Settings/Security/SecuritySheet/SecuritySuccess.swift b/Bitkit/Views/Settings/Security/SecuritySheet/SecuritySuccess.swift index 7a57c1bbc..cc153e6a5 100644 --- a/Bitkit/Views/Settings/Security/SecuritySheet/SecuritySuccess.swift +++ b/Bitkit/Views/Settings/Security/SecuritySheet/SecuritySuccess.swift @@ -3,23 +3,17 @@ import SwiftUI struct SecuritySuccess: View { @EnvironmentObject private var sheets: SheetViewModel @EnvironmentObject private var settings: SettingsViewModel - @Binding var navigationPath: [SecurityRoute] private var biometryTypeName: String { switch Env.biometryType { - case .touchID: - return t("security__bio_touch_id") - case .faceID: - return t("security__bio_face_id") - default: - return t("security__bio_face_id") // Default to Face ID + case .touchID: t("security__bio_touch_id") + default: t("security__bio_face_id") } } var body: some View { VStack(alignment: .leading, spacing: 0) { SheetHeader(title: t("security__success_title")) - .padding(.horizontal, 16) VStack(spacing: 0) { BodyMText( @@ -67,7 +61,7 @@ struct SecuritySuccess: View { } #Preview { - SecuritySuccess(navigationPath: .constant([.success])) + SecuritySuccess() .environmentObject(SheetViewModel()) .environmentObject(SettingsViewModel.shared) } diff --git a/Bitkit/Views/Settings/SecurityPrivacySettingsView.swift b/Bitkit/Views/Settings/SecuritySettingsView.swift similarity index 70% rename from Bitkit/Views/Settings/SecurityPrivacySettingsView.swift rename to Bitkit/Views/Settings/SecuritySettingsView.swift index 8cff031b8..70a2c4653 100644 --- a/Bitkit/Views/Settings/SecurityPrivacySettingsView.swift +++ b/Bitkit/Views/Settings/SecuritySettingsView.swift @@ -1,7 +1,7 @@ import LocalAuthentication import SwiftUI -struct SecurityPrivacySettingsView: View { +struct SecuritySettingsView: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var sheets: SheetViewModel @EnvironmentObject var settings: SettingsViewModel @@ -12,12 +12,9 @@ struct SecurityPrivacySettingsView: View { private var biometryTypeName: String { switch Env.biometryType { - case .touchID: - return t("security__bio_touch_id") - case .faceID: - return t("security__bio_face_id") - default: - return t("security__bio_face_id") // Default to Face ID + case .touchID: return t("security__bio_touch_id") + case .faceID: return t("security__bio_face_id") + default: return t("security__bio_face_id") // Default to Face ID } } @@ -29,71 +26,57 @@ struct SecurityPrivacySettingsView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: t("settings__security__title")) - .padding(.bottom, 16) - .padding(.horizontal, 16) - ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { - // Privacy Settings Section - SettingsListLabel( - title: t("settings__security__swipe_balance_to_hide"), - toggle: $settings.swipeBalanceToHide, - testIdentifier: "SwipeBalanceToHide" - ) - - SettingsListLabel( - title: t("settings__security__hide_balance_on_open"), - toggle: $settings.hideBalanceOnOpen, - testIdentifier: "HideBalanceOnOpen" - ) - - SettingsListLabel( - title: t("settings__security__clipboard"), - toggle: $settings.readClipboard, - testIdentifier: "AutoReadClipboard" - ) + // Backup section + SettingsSectionHeader(t("settings__security__section_backup")) + + Button(action: { + sheets.showSheet(.backup, data: BackupConfig(view: .mnemonic)) + }) { + SettingsRow( + title: t("settings__backup__wallet"), + iconName: "lock-key" + ) + } + .accessibilityIdentifier("BackupWallet") - SettingsListLabel( - title: t("settings__security__warn_100"), - toggle: $settings.warnWhenSendingOver100, - testIdentifier: "SendAmountWarning" - ) + NavigationLink(value: Route.dataBackups) { + SettingsRow( + title: t("settings__backup__data"), + iconName: "database" + ) + } + .accessibilityIdentifier("BackupSettings") - // PIN Code Section - if !settings.pinEnabled { - Button { - sheets.showSheet(.security, data: SecurityConfig(showLaterButton: false)) - } label: { - SettingsListLabel( - title: t("settings__security__pin"), - rightText: t("settings__security__pin_disabled") - ) - } - .accessibilityIdentifier("PINCode") - } else { - NavigationLink(value: Route.disablePin) { - SettingsListLabel( - title: t("settings__security__pin"), - rightText: t("settings__security__pin_enabled") - ) - } - .accessibilityIdentifier("PINCode") + NavigationLink(value: Route.reset) { + SettingsRow( + title: t("settings__backup__reset"), + iconName: "arrow-counter-clockwise" + ) } + .accessibilityIdentifier("ResetAndRestore") + + // Safety section + SettingsSectionHeader(t("settings__security__section_safety")) + .padding(.top, 16) + + NavigationLink(value: Route.changePin) { + SettingsRow( + title: t("settings__security__pin"), + iconName: "shield", + rightText: settings.pinEnabled ? t("settings__security__pin_enabled") : t("settings__security__pin_disabled") + ) + } + .accessibilityIdentifier("PINCode") if settings.pinEnabled { - NavigationLink(value: Route.changePin) { - SettingsListLabel( - title: t("settings__security__pin_change") - ) - } - .accessibilityIdentifier("PINChange") - Button { showPinCheckForPayments = true } label: { - SettingsListLabel( + SettingsRow( title: t("settings__security__pin_payments"), + iconName: "coins", rightIcon: nil, toggle: Binding( get: { settings.requirePinForPayments }, @@ -104,9 +87,9 @@ struct SecurityPrivacySettingsView: View { .accessibilityIdentifier("EnablePinForPayments") if isBiometricAvailable { - // Biometrics toggle with custom handling - SettingsListLabel( + SettingsRow( title: t("settings__security__use_bio", variables: ["biometryTypeName": biometryTypeName]), + iconName: "smiley", toggle: Binding( get: { settings.useBiometrics }, set: { newValue in @@ -115,18 +98,46 @@ struct SecurityPrivacySettingsView: View { ), testIdentifier: "UseBiometryInstead" ) - - // Footer text for Biometrics - BodySText(t("settings__security__footer", variables: ["biometryTypeName": biometryTypeName])) - .padding(.top, 16) } } + + SettingsRow( + title: t("settings__security__warn_100"), + iconName: "warning", + toggle: $settings.warnWhenSendingOver100, + testIdentifier: "SendAmountWarning" + ) + + // Privacy section + SettingsSectionHeader(t("settings__security__section_privacy")) + .padding(.top, 16) + + SettingsRow( + title: t("settings__security__swipe_balance_to_hide"), + iconName: "hand-pointing", + toggle: $settings.swipeBalanceToHide, + testIdentifier: "SwipeBalanceToHide" + ) + + SettingsRow( + title: t("settings__security__hide_balance_on_open"), + iconName: "eye-slash", + toggle: $settings.hideBalanceOnOpen, + testIdentifier: "HideBalanceOnOpen" + ) + + SettingsRow( + title: t("settings__security__clipboard"), + iconName: "clipboard", + toggle: $settings.readClipboard, + testIdentifier: "AutoReadClipboard" + ) } + .padding(.top, 16) .padding(.horizontal, 16) .bottomSafeAreaPadding() } } - .navigationBarHidden(true) .navigationDestination(isPresented: $showPinCheckForPayments) { PinCheckView( title: t("security__pin_enter"), @@ -137,10 +148,7 @@ struct SecurityPrivacySettingsView: View { } ) } - .alert( - t("security__bio_error_title"), - isPresented: $showingBiometricError - ) { + .alert(t("security__bio_error_title"), isPresented: $showingBiometricError) { Button(t("common__ok")) { // Error handled, user acknowledged } @@ -155,7 +163,7 @@ struct SecurityPrivacySettingsView: View { requestBiometricPermission { success in if success { settings.useBiometrics = true - Logger.debug("Biometric authentication enabled", context: "SecurityPrivacySettingsView") + Logger.debug("Biometric authentication enabled", context: "SecuritySettingsView") } else { // Authentication failed - keep toggle off // The toggle will automatically revert since we're not setting the value @@ -167,7 +175,7 @@ struct SecurityPrivacySettingsView: View { requestBiometricPermission { success in if success { settings.useBiometrics = false - Logger.debug("Biometric authentication disabled", context: "SecurityPrivacySettingsView") + Logger.debug("Biometric authentication disabled", context: "SecuritySettingsView") } else { // Authentication failed - keep toggle on // The toggle will automatically revert since we're not setting the value @@ -234,12 +242,12 @@ struct SecurityPrivacySettingsView: View { showingBiometricError = true } - Logger.error("Biometric authentication error: \(error)", context: "SecurityPrivacySettingsView") + Logger.error("Biometric authentication error: \(error)", context: "SecuritySettingsView") } } #Preview { - SecurityPrivacySettingsView() + SecuritySettingsView() .environmentObject(SheetViewModel()) .environmentObject(SettingsViewModel.shared) .preferredColorScheme(.dark) diff --git a/Bitkit/Views/Settings/SupportScreen.swift b/Bitkit/Views/Settings/SupportScreen.swift new file mode 100644 index 000000000..8b5c97061 --- /dev/null +++ b/Bitkit/Views/Settings/SupportScreen.swift @@ -0,0 +1,178 @@ +import SwiftUI + +private struct DiagonalCut: Shape { + var cornerRadius: CGFloat = 36 + + func path(in rect: CGRect) -> Path { + var path = Path() + let r = min(cornerRadius, rect.width / 4, rect.height / 4) + + let leftCutY = rect.maxY - 210 + path.move(to: CGPoint(x: rect.minX, y: leftCutY)) + + let rightCutY = rect.maxY - 300 + path.addLine(to: CGPoint(x: rect.maxX, y: rightCutY)) + + // Right edge to just above bottom-right corner + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - r)) + // Rounded bottom-right corner + path.addArc( + center: CGPoint(x: rect.maxX - r, y: rect.maxY - r), + radius: r, + startAngle: Angle(radians: 0), + endAngle: Angle(radians: .pi / 2), + clockwise: false + ) + // Bottom edge to just before bottom-left corner + path.addLine(to: CGPoint(x: rect.minX + r, y: rect.maxY)) + // Rounded bottom-left corner + path.addArc( + center: CGPoint(x: rect.minX + r, y: rect.maxY - r), + radius: r, + startAngle: Angle(radians: .pi / 2), + endAngle: Angle(radians: .pi), + clockwise: false + ) + path.closeSubpath() + + return path + } +} + +struct SupportScreen: View { + @EnvironmentObject private var app: AppViewModel + @Environment(\.openURL) private var openURL + + @State private var versionTapCount = 0 + + @AppStorage("showDevSettings") private var showDevSettings = Env.isDebug + + private var appVersion: String { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" + let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" + return "\(version) (\(build))" + } + + private var shareText: String { + return t( + "settings__about__shareText", + variables: ["appStoreUrl": Env.appStoreUrl, "playStoreUrl": Env.playStoreUrl] + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("settings__support__title")) + .padding(.horizontal, 16) + .padding(.bottom, 16) + + GeometryReader { geometry in + ScrollView(showsIndicators: false) { + ZStack { + // Orange diagonal background (scrolls with content) + Color.brandAccent + .clipShape(DiagonalCut()) + .ignoresSafeArea() + + VStack(alignment: .leading, spacing: 0) { + BodyMText(t("settings__support__text")) + .padding(.bottom, 16) + + VStack(spacing: 0) { + NavigationLink(value: Route.reportIssue) { + SettingsRow(title: t("settings__support__report"), iconName: "warning") + } + + Button(action: { + openURL(URL(string: Env.helpUrl)!) + }) { + SettingsRow(title: t("settings__support__help"), iconName: "question") + } + + NavigationLink(value: Route.appStatus) { + SettingsRow(title: t("settings__support__status"), iconName: "power") + } + .accessibilityIdentifier("AppStatus") + + Button(action: { + openURL(URL(string: Env.termsOfServiceUrl)!) + }) { + SettingsRow(title: t("settings__about__legal"), iconName: "file-text") + } + + ShareLink(item: shareText, message: Text(shareText)) { + SettingsRow(title: t("settings__about__share"), iconName: "share") + } + + Button(action: { + onVersionTap() + }) { + SettingsRow( + title: t("settings__about__version"), + iconName: "stack", + rightText: appVersion, + rightIcon: nil + ) + } + } + + Spacer(minLength: 32) + + VStack(alignment: .center, spacing: 0) { + Image("logo") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxHeight: 100) + .accessibilityIdentifier("AboutLogo") + } + .frame(maxWidth: .infinity) + .padding(.bottom, 16) + + Social() + .padding(.bottom, 16) + + BodyMText("Bitkit was crafted by Synonym Software, S.A. DE C.V. ©2025. All rights reserved.") + .padding(.bottom, 16) + + HStack(alignment: .center, spacing: 10) { + Image("synonym-logo") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 24) + + Image("tether-logo") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 16) + } + .frame(maxWidth: .infinity, alignment: .center) + .frame(height: 24) + .padding(.bottom, 32) + } + .frame(minHeight: geometry.size.height) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + } + } + } + .ignoresSafeArea() + } + .navigationBarHidden(true) + } + + private func onVersionTap() { + versionTapCount += 1 + + // When tapped 5 times, toggle developer mode + if versionTapCount >= 5 { + versionTapCount = 0 + showDevSettings.toggle() + + app.toast( + type: .info, + title: t(showDevSettings ? "settings__dev_enabled_title" : "settings__dev_disabled_title"), + description: t(showDevSettings ? "settings__dev_enabled_message" : "settings__dev_disabled_message") + ) + } + } +} diff --git a/Bitkit/Views/Settings/SupportView.swift b/Bitkit/Views/Settings/SupportView.swift deleted file mode 100644 index af614d099..000000000 --- a/Bitkit/Views/Settings/SupportView.swift +++ /dev/null @@ -1,55 +0,0 @@ -import SwiftUI - -struct SupportView: View { - @Environment(\.openURL) private var openURL - - var body: some View { - VStack(spacing: 0) { - NavigationBar(title: t("settings__support__title")) - .padding(.bottom, 16) - - BodyMText(t("settings__support__text")) - .padding(.bottom, 16) - - VStack(spacing: 0) { - NavigationLink(value: Route.reportIssue) { - SettingsListLabel(title: t("settings__support__report")) - } - - Button(action: { - openURL(URL(string: Env.helpUrl)!) - }) { - SettingsListLabel(title: t("settings__support__help")) - } - - NavigationLink(value: Route.appStatus) { - SettingsListLabel(title: t("settings__support__status")) - } - .accessibilityIdentifier("AppStatus") - } - - Spacer(minLength: 32) - - VStack { - Image("question-mark") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxHeight: 256) - } - - Spacer(minLength: 32) - - Social() - } - .navigationBarHidden(true) - .padding(.horizontal, 16) - .bottomSafeAreaPadding() - } -} - -#Preview { - NavigationView { - SupportView() - } - .preferredColorScheme(.dark) -} diff --git a/Bitkit/Views/Settings/TransactionSpeed/TransactionSpeedSettingsView.swift b/Bitkit/Views/Settings/TransactionSpeed/TransactionSpeedSettingsView.swift index e95b42aff..ed9c0183a 100644 --- a/Bitkit/Views/Settings/TransactionSpeed/TransactionSpeedSettingsView.swift +++ b/Bitkit/Views/Settings/TransactionSpeed/TransactionSpeedSettingsView.swift @@ -66,8 +66,7 @@ struct TransactionSpeedSettingsView: View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("settings__general__speed_default")) - .frame(height: 50) + SettingsSectionHeader(t("settings__general__speed_default")) VStack(spacing: 0) { TransactionSpeedSettingsRow( @@ -79,7 +78,7 @@ struct TransactionSpeedSettingsView: View { } ) - Divider() + CustomDivider() TransactionSpeedSettingsRow( speed: .normal, @@ -90,7 +89,7 @@ struct TransactionSpeedSettingsView: View { } ) - Divider() + CustomDivider() TransactionSpeedSettingsRow( speed: .slow, @@ -101,7 +100,7 @@ struct TransactionSpeedSettingsView: View { } ) - Divider() + CustomDivider() TransactionSpeedSettingsRow( speed: .custom(satsPerVByte: 1), // Placeholder diff --git a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift index 9e7e8ddfa..1ff1238e0 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift @@ -46,13 +46,13 @@ struct ReceiveQr: View { // Show unified tab when we have a Lightning invoice (even if channels not yet usable) if !wallet.bolt11.isEmpty { return [ - TabItem(.savings), - TabItem(.unified, activeColor: .white), + TabItem(.savings, activeColor: .brandAccent), + TabItem(.unified, activeColor: .textPrimary), TabItem(.spending, activeColor: .purpleAccent), ] } else { return [ - TabItem(.savings), + TabItem(.savings, activeColor: .textPrimary), TabItem(.spending, activeColor: .purpleAccent), ] } diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index 5c823477c..5999401b8 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -8,11 +8,12 @@ struct LnurlPayConfirm: View { @EnvironmentObject var wallet: WalletViewModel @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var settings: SettingsViewModel + @Binding var navigationPath: [SendRoute] + let requestPinCheck: () async -> Bool + @State private var showWarningAlert = false @State private var alertContinuation: CheckedContinuation? - @State private var showPinCheck = false - @State private var pinCheckContinuation: CheckedContinuation? @State private var showingBiometricError = false @State private var biometricErrorMessage = "" @State private var comment = "" @@ -137,9 +138,8 @@ struct LnurlPayConfirm: View { throw CancellationError() } } else { - showPinCheck = true - let shouldProceed = try await waitForPinCheck() - if !shouldProceed { + let shouldProceed = await requestPinCheck() + guard shouldProceed else { throw CancellationError() } } @@ -173,20 +173,6 @@ struct LnurlPayConfirm: View { } message: { Text(biometricErrorMessage) } - .navigationDestination(isPresented: $showPinCheck) { - PinCheckView( - title: t("security__pin_send_title"), - explanation: t("security__pin_send"), - onCancel: { - pinCheckContinuation?.resume(returning: false) - pinCheckContinuation = nil - }, - onPinVerified: { _ in - pinCheckContinuation?.resume(returning: true) - pinCheckContinuation = nil - } - ) - } } private func waitForAlertDismissal() async throws -> Bool { @@ -195,12 +181,6 @@ struct LnurlPayConfirm: View { } } - private func waitForPinCheck() async throws -> Bool { - return try await withCheckedThrowingContinuation { continuation in - pinCheckContinuation = continuation - } - } - private func performPayment() async throws { guard let lnurlPayData = app.lnurlPayData else { throw NSError(domain: "LNURL", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing LNURL pay data"]) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index e6da1d454..1e5e23e37 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -11,8 +11,8 @@ struct SendConfirmationView: View { @EnvironmentObject var tagManager: TagManager @Binding var navigationPath: [SendRoute] - @State private var showPinCheck = false - @State private var pinCheckContinuation: CheckedContinuation? + let requestPinCheck: () async -> Bool + @State private var showingBiometricError = false @State private var biometricErrorMessage = "" @State private var transactionFee: Int = 0 @@ -142,9 +142,8 @@ struct SendConfirmationView: View { throw CancellationError() } } else { - showPinCheck = true - let shouldProceed = try await waitForPinCheck() - if !shouldProceed { + let shouldProceed = await requestPinCheck() + guard shouldProceed else { throw CancellationError() } } @@ -195,26 +194,6 @@ struct SendConfirmationView: View { Text(warning.message) } } - .navigationDestination(isPresented: $showPinCheck) { - PinCheckView( - title: t("security__pin_send_title"), - explanation: t("security__pin_send"), - onCancel: { - pinCheckContinuation?.resume(returning: false) - pinCheckContinuation = nil - }, - onPinVerified: { _ in - pinCheckContinuation?.resume(returning: true) - pinCheckContinuation = nil - } - ) - } - } - - private func waitForPinCheck() async throws -> Bool { - return try await withCheckedThrowingContinuation { continuation in - pinCheckContinuation = continuation - } } private func performPayment() async throws { diff --git a/Bitkit/Views/Wallets/Send/SendPinScreen.swift b/Bitkit/Views/Wallets/Send/SendPinScreen.swift new file mode 100644 index 000000000..3be9610e3 --- /dev/null +++ b/Bitkit/Views/Wallets/Send/SendPinScreen.swift @@ -0,0 +1,103 @@ +import SwiftUI + +struct SendPinScreen: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var session: SessionManager + @EnvironmentObject private var settings: SettingsViewModel + @EnvironmentObject private var sheets: SheetViewModel + @EnvironmentObject private var wallet: WalletViewModel + + let onCancel: () -> Void + let onPinVerified: () -> Void + + @State private var pinInput: String = "" + @State private var errorMessage: String = "" + @State private var errorIdentifier: String? + @State private var hasResolvedPinCheck = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: t("security__pin_send_title"), showBackButton: true) + .padding(.horizontal, 16) + + VStack(spacing: 0) { + BodyMText(t("security__pin_send")) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 32) + + Spacer() + + if !errorMessage.isEmpty { + BodySText(errorMessage, textColor: .brandAccent) + .padding(.bottom, 16) + .accessibilityIdentifier(errorIdentifier ?? "WrongPIN") + .onTapGesture { + sheets.showSheet(.forgotPin) + } + } + } + .padding(.horizontal, 32) + + PinInput(pinInput: $pinInput) { pin in + if pin.count == 4 { + onPinEntered(pin) + } else if pin.count == 1 { + errorMessage = "" + errorIdentifier = nil + } + } + } + .navigationBarHidden(true) + .allowSwipeBack(false) + .sheetBackground() + .onDisappear { + if !hasResolvedPinCheck { + hasResolvedPinCheck = true + onCancel() + } + } + } + + private func onPinEntered(_ pin: String) { + if settings.pinCheck(pin: pin) { + hasResolvedPinCheck = true + onPinVerified() + return + } + + pinInput = "" + + if settings.hasExceededPinAttempts() { + handleWalletWipe() + return + } + + let remainingAttempts = settings.getRemainingPinAttempts() + if remainingAttempts == 1 { + errorMessage = t("security__pin_last_attempt") + errorIdentifier = "LastAttempt" + } else { + errorMessage = t("security__pin_attempts", variables: ["attemptsRemaining": "\(remainingAttempts)"]) + errorIdentifier = "AttemptsRemaining" + } + + Haptics.notify(.error) + } + + private func handleWalletWipe() { + Task { + do { + try await AppReset.wipe( + app: app, + wallet: wallet, + session: session, + toastType: .warning + ) + sheets.hideSheet() + } catch { + Logger.error("Failed to wipe wallet after PIN attempts exceeded: \(error)", context: "SendPinScreen") + app.toast(error) + } + } + } +} diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 8318f8e10..23dc08175 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -10,6 +10,7 @@ enum SendRoute: Hashable { case feeCustom case tag case quickpay + case pin case pending(paymentHash: String) case success(paymentId: String) case failure @@ -49,6 +50,7 @@ struct SendSheet: View { @State private var navigationPath: [SendRoute] = [] @State private var hasValidatedAfterSync = false + @State private var pinCheckContinuation: CheckedContinuation? /// Show sync overlay when node is not ready for payments /// For lightning: need node running AND at least one usable channel (peer connected). @@ -257,6 +259,26 @@ struct SendSheet: View { hasValidatedAfterSync = true } + private func requestPinCheck() async -> Bool { + // Prevent stacking multiple PIN screens if already presented. + if navigationPath.last != .pin { + navigationPath.append(.pin) + } + + return await withCheckedContinuation { continuation in + pinCheckContinuation = continuation + } + } + + private func resolvePinCheck(_ approved: Bool) { + pinCheckContinuation?.resume(returning: approved) + pinCheckContinuation = nil + + if navigationPath.last == .pin { + navigationPath.removeLast() + } + } + @ViewBuilder private func viewForRoute(_ route: SendRoute) -> some View { switch route { @@ -269,7 +291,7 @@ struct SendSheet: View { case .utxoSelection: SendUtxoSelectionView(navigationPath: $navigationPath) case .confirm: - SendConfirmationView(navigationPath: $navigationPath) + SendConfirmationView(navigationPath: $navigationPath, requestPinCheck: requestPinCheck) case .feeRate: SendFeeRate(navigationPath: $navigationPath) case .feeCustom: @@ -278,6 +300,8 @@ struct SendSheet: View { SendTagScreen(navigationPath: $navigationPath) case .quickpay: SendQuickpay(navigationPath: $navigationPath) + case .pin: + SendPinScreen(onCancel: { resolvePinCheck(false) }, onPinVerified: { resolvePinCheck(true) }) case let .pending(paymentHash): SendPendingScreen(paymentHash: paymentHash, navigationPath: $navigationPath) case let .success(paymentId): @@ -287,7 +311,7 @@ struct SendSheet: View { case .lnurlPayAmount: LnurlPayAmount(navigationPath: $navigationPath) case .lnurlPayConfirm: - LnurlPayConfirm(navigationPath: $navigationPath) + LnurlPayConfirm(navigationPath: $navigationPath, requestPinCheck: requestPinCheck) case .lnurlWithdrawAmount: LnurlWithdrawAmount { navigationPath.append(.lnurlWithdrawConfirm)