From 32e691a0eae258b0ee1f4c12d5d396fc436df75d Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Mon, 2 Feb 2026 17:31:48 +0100 Subject: [PATCH 1/4] fix: rename checkmark icon --- .../Contents.json | 0 .../checkmark.pdf | Bin Bitkit/Components/CheckboxRow.swift | 2 +- Bitkit/Components/RadioGroup.swift | 2 +- Bitkit/Components/SettingsListLabel.swift | 2 +- Bitkit/Components/SwipeButton.swift | 2 +- .../Advanced/LightningConnectionDetailView.swift | 2 +- .../TransactionSpeedSettingsView.swift | 2 +- Bitkit/Views/Transfer/SettingUpView.swift | 2 +- Bitkit/Views/Widgets/WidgetEditItemView.swift | 2 +- 10 files changed, 8 insertions(+), 8 deletions(-) rename Bitkit/Assets.xcassets/icons/{checkmark.imageset => check-mark.imageset}/Contents.json (100%) rename Bitkit/Assets.xcassets/icons/{checkmark.imageset => check-mark.imageset}/checkmark.pdf (100%) diff --git a/Bitkit/Assets.xcassets/icons/checkmark.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/check-mark.imageset/Contents.json similarity index 100% rename from Bitkit/Assets.xcassets/icons/checkmark.imageset/Contents.json rename to Bitkit/Assets.xcassets/icons/check-mark.imageset/Contents.json diff --git a/Bitkit/Assets.xcassets/icons/checkmark.imageset/checkmark.pdf b/Bitkit/Assets.xcassets/icons/check-mark.imageset/checkmark.pdf similarity index 100% rename from Bitkit/Assets.xcassets/icons/checkmark.imageset/checkmark.pdf rename to Bitkit/Assets.xcassets/icons/check-mark.imageset/checkmark.pdf diff --git a/Bitkit/Components/CheckboxRow.swift b/Bitkit/Components/CheckboxRow.swift index 00874bb25..31ab20f6c 100644 --- a/Bitkit/Components/CheckboxRow.swift +++ b/Bitkit/Components/CheckboxRow.swift @@ -34,7 +34,7 @@ struct CheckboxRow: View { ) if isChecked { - Image("checkmark") + Image("check-mark") .resizable() .frame(width: 22, height: 22) .foregroundColor(.brandAccent) diff --git a/Bitkit/Components/RadioGroup.swift b/Bitkit/Components/RadioGroup.swift index 927642560..6e8ae1a40 100644 --- a/Bitkit/Components/RadioGroup.swift +++ b/Bitkit/Components/RadioGroup.swift @@ -50,7 +50,7 @@ private struct RadioButton: View { Spacer() if isSelected { - Image("checkmark") + Image("check-mark") .resizable() .foregroundColor(.brandAccent) .frame(width: 32, height: 32) diff --git a/Bitkit/Components/SettingsListLabel.swift b/Bitkit/Components/SettingsListLabel.swift index 47e70289f..07047db5b 100644 --- a/Bitkit/Components/SettingsListLabel.swift +++ b/Bitkit/Components/SettingsListLabel.swift @@ -70,7 +70,7 @@ struct SettingsListLabel: View { .foregroundColor(.textSecondary) .frame(width: 24, height: 24) case .checkmark: - Image("checkmark") + Image("check") .resizable() .foregroundColor(.brandAccent) .frame(width: 32, height: 32) diff --git a/Bitkit/Components/SwipeButton.swift b/Bitkit/Components/SwipeButton.swift index 4663ad984..3d39e8a07 100644 --- a/Bitkit/Components/SwipeButton.swift +++ b/Bitkit/Components/SwipeButton.swift @@ -52,7 +52,7 @@ struct SwipeButton: View { .foregroundColor(.gray7) .opacity(Double(1.0 - (offset / (geometry.size.width / 2)))) - Image("checkmark") + Image("check-mark") .resizable() .frame(width: 32, height: 32) .foregroundColor(.gray7) diff --git a/Bitkit/Views/Settings/Advanced/LightningConnectionDetailView.swift b/Bitkit/Views/Settings/Advanced/LightningConnectionDetailView.swift index f260fb488..99ab776d7 100644 --- a/Bitkit/Views/Settings/Advanced/LightningConnectionDetailView.swift +++ b/Bitkit/Views/Settings/Advanced/LightningConnectionDetailView.swift @@ -322,7 +322,7 @@ struct LightningConnectionDetailView: View { return ( text: t("lightning__order_state__paid"), color: .purpleAccent, - icon: "checkmark" + icon: "check-mark" ) } } diff --git a/Bitkit/Views/Settings/TransactionSpeed/TransactionSpeedSettingsView.swift b/Bitkit/Views/Settings/TransactionSpeed/TransactionSpeedSettingsView.swift index add5c6cb4..752b3e41c 100644 --- a/Bitkit/Views/Settings/TransactionSpeed/TransactionSpeedSettingsView.swift +++ b/Bitkit/Views/Settings/TransactionSpeed/TransactionSpeedSettingsView.swift @@ -39,7 +39,7 @@ struct TransactionSpeedSettingsRow: View { } if isSelected { - Image("checkmark") + Image("check-mark") .resizable() .frame(width: 32, height: 32) .foregroundColor(.brandAccent) diff --git a/Bitkit/Views/Transfer/SettingUpView.swift b/Bitkit/Views/Transfer/SettingUpView.swift index 81326603f..a9d9fe3b7 100644 --- a/Bitkit/Views/Transfer/SettingUpView.swift +++ b/Bitkit/Views/Transfer/SettingUpView.swift @@ -86,7 +86,7 @@ struct ProgressSteps: View { if index < currentStep { // Checkmark for completed steps - Image("checkmark") + Image("check-mark") .foregroundColor(.black) } else { // Number for current and upcoming steps diff --git a/Bitkit/Views/Widgets/WidgetEditItemView.swift b/Bitkit/Views/Widgets/WidgetEditItemView.swift index 49ebd1ec3..423477dbe 100644 --- a/Bitkit/Views/Widgets/WidgetEditItemView.swift +++ b/Bitkit/Views/Widgets/WidgetEditItemView.swift @@ -17,7 +17,7 @@ struct WidgetEditItemView: View { .frame(maxWidth: .infinity, alignment: .trailing) } - Image("checkmark") + Image("check-mark") .resizable() .foregroundColor(item.isChecked ? .brandAccent : .gray3) .frame(width: 32, height: 32) From 3ad5ded526b5cf968cd2b1e591942a3ee18c4648 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Fri, 13 Feb 2026 18:09:34 +0100 Subject: [PATCH 2/4] feat(home): redesign home screen --- Bitkit.xcodeproj/project.pbxproj | 16 +- .../trezor-card.imageset/Contents.json | 21 ++ .../trezor-card.imageset/trezor-card.png | Bin 0 -> 66046 bytes .../suggestions-widget.imageset/Contents.json | 12 ++ .../suggestions-widget.pdf | Bin 0 -> 11437 bytes .../icons/swipe-hint.imageset/Contents.json | 12 ++ .../icons/swipe-hint.imageset/swipe-hint.pdf | Bin 0 -> 10527 bytes .../Core/ButtonDetectionModifier.swift | 47 ---- .../Core/ButtonLocationTracking.swift | 68 ------ .../Components/Core/DragHandleTracking.swift | 35 +++ Bitkit/Components/Core/DraggableItem.swift | 204 +++++++++++------- Bitkit/Components/Core/DraggableList.swift | 125 +++-------- Bitkit/Components/Divider.swift | 17 +- Bitkit/Components/EmptyStateView.swift | 73 ++----- Bitkit/Components/Header.swift | 28 ++- Bitkit/Components/Home/Suggestions.swift | 158 +++++++------- Bitkit/Components/Home/SuggestionsCard.swift | 2 +- Bitkit/Components/Home/Widgets.swift | 81 ------- Bitkit/Components/MoneyStack.swift | 73 +++---- Bitkit/Components/SettingsListLabel.swift | 2 +- Bitkit/Components/TabViewDots.swift | 22 ++ Bitkit/Components/Widgets/BaseWidget.swift | 10 +- Bitkit/Components/WidgetsOnboardingView.swift | 37 ++++ Bitkit/Extensions/View+SafeArea.swift | 15 +- Bitkit/MainNavView.swift | 2 +- Bitkit/Models/BackupPayloads.swift | 66 +++++- Bitkit/Models/SettingsBackupConfig.swift | 2 +- .../Localization/en.lproj/Localizable.strings | 9 +- Bitkit/ViewModels/ActivityListViewModel.swift | 2 +- Bitkit/ViewModels/AppViewModel.swift | 10 +- Bitkit/ViewModels/SettingsViewModel.swift | 4 +- Bitkit/ViewModels/WidgetsViewModel.swift | 133 +++++++++++- Bitkit/Views/Home/HomeWalletView.swift | 59 +++++ Bitkit/Views/Home/HomeWidgetsView.swift | 123 +++++++++++ Bitkit/Views/HomeScreen.swift | 85 ++++++++ .../Views/Onboarding/CreateWalletView.swift | 1 - .../CreateWalletWithPassphraseView.swift | 1 - .../Views/Onboarding/OnboardingSlider.swift | 20 +- .../Views/Onboarding/RestoreWalletView.swift | 1 - .../Advanced/CoinSelectionSettingsView.swift | 4 +- .../General/WidgetsSettingsView.swift | 5 +- Bitkit/Views/Sheets/Sheet.swift | 14 +- .../Wallets/Activity/ActivityLatest.swift | 24 +-- .../Wallets/Activity/EmptyActivityRow.swift | 26 --- Bitkit/Views/Wallets/HomeView.swift | 168 --------------- Bitkit/Views/Wallets/SavingsWalletView.swift | 20 +- Bitkit/Views/Wallets/SpendingWalletView.swift | 18 +- 47 files changed, 993 insertions(+), 862 deletions(-) create mode 100644 Bitkit/Assets.xcassets/Illustrations/trezor-card.imageset/Contents.json create mode 100644 Bitkit/Assets.xcassets/Illustrations/trezor-card.imageset/trezor-card.png create mode 100644 Bitkit/Assets.xcassets/icons/suggestions-widget.imageset/Contents.json create mode 100644 Bitkit/Assets.xcassets/icons/suggestions-widget.imageset/suggestions-widget.pdf create mode 100644 Bitkit/Assets.xcassets/icons/swipe-hint.imageset/Contents.json create mode 100644 Bitkit/Assets.xcassets/icons/swipe-hint.imageset/swipe-hint.pdf delete mode 100644 Bitkit/Components/Core/ButtonDetectionModifier.swift delete mode 100644 Bitkit/Components/Core/ButtonLocationTracking.swift create mode 100644 Bitkit/Components/Core/DragHandleTracking.swift delete mode 100644 Bitkit/Components/Home/Widgets.swift create mode 100644 Bitkit/Components/TabViewDots.swift create mode 100644 Bitkit/Components/WidgetsOnboardingView.swift create mode 100644 Bitkit/Views/Home/HomeWalletView.swift create mode 100644 Bitkit/Views/Home/HomeWidgetsView.swift create mode 100644 Bitkit/Views/HomeScreen.swift delete mode 100644 Bitkit/Views/Wallets/Activity/EmptyActivityRow.swift delete mode 100644 Bitkit/Views/Wallets/HomeView.swift diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 352a71c11..fa5125179 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -504,7 +504,7 @@ INFOPLIST_FILE = BitkitNotification/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BitkitNotification; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.6; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -532,7 +532,7 @@ INFOPLIST_FILE = BitkitNotification/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BitkitNotification; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.6; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -693,7 +693,7 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 16.6; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; @@ -736,7 +736,7 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 16.6; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; @@ -759,7 +759,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.5; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 14.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = to.BitkitTests; @@ -782,7 +782,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.5; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 14.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = to.BitkitTests; @@ -803,7 +803,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.5; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 14.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = to.BitkitUITests; @@ -824,7 +824,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.5; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 14.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = to.BitkitUITests; diff --git a/Bitkit/Assets.xcassets/Illustrations/trezor-card.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/trezor-card.imageset/Contents.json new file mode 100644 index 000000000..00f74f3d7 --- /dev/null +++ b/Bitkit/Assets.xcassets/Illustrations/trezor-card.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "trezor-card.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/trezor-card.imageset/trezor-card.png b/Bitkit/Assets.xcassets/Illustrations/trezor-card.imageset/trezor-card.png new file mode 100644 index 0000000000000000000000000000000000000000..fb8b31dcaa8fb85959880377f25e9de285c416e3 GIT binary patch literal 66046 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 z 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 literal 0 HcmV?d00001 diff --git a/Bitkit/Assets.xcassets/icons/swipe-hint.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/swipe-hint.imageset/Contents.json new file mode 100644 index 000000000..d7569dd1e --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/swipe-hint.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "swipe-hint.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Bitkit/Assets.xcassets/icons/swipe-hint.imageset/swipe-hint.pdf b/Bitkit/Assets.xcassets/icons/swipe-hint.imageset/swipe-hint.pdf new file mode 100644 index 0000000000000000000000000000000000000000..a9b995c17b08a5b11fc804e65a32b9222886b9ff GIT binary patch literal 10527 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 literal 0 HcmV?d00001 diff --git a/Bitkit/Components/Core/ButtonDetectionModifier.swift b/Bitkit/Components/Core/ButtonDetectionModifier.swift deleted file mode 100644 index 85c5c919c..000000000 --- a/Bitkit/Components/Core/ButtonDetectionModifier.swift +++ /dev/null @@ -1,47 +0,0 @@ -import SwiftUI - -/// A view modifier that detects when a user interacts with a UIButton or UIControl -/// This is useful for determining if a tap occurred on a native iOS button or control -private struct ButtonDetectionModifier: ViewModifier { - /// Callback that is triggered when a button is detected - /// - Parameter isButton: Boolean indicating whether the tap occurred on a button/control - let onButtonDetected: (Bool) -> Void - - func body(content: Content) -> some View { - content - .background( - GeometryReader { _ in - Color.clear - .contentShape(Rectangle()) - .gesture( - // Using DragGesture with minimumDistance: 0 to detect taps - DragGesture(minimumDistance: 0) - .onChanged { gesture in - // Get the tap location - let location = gesture.startLocation - - // Perform hit testing to determine what was tapped - let hitTest = UIApplication.shared.connectedScenes - .first(where: { $0 is UIWindowScene }) - .flatMap { $0 as? UIWindowScene }?.windows - .first? - .hitTest(location, with: nil) - - // Check if the tapped view is a UIButton or UIControl - let isButton = hitTest is UIButton || hitTest is UIControl - onButtonDetected(isButton) - } - ) - } - ) - } -} - -extension View { - /// Adds button detection capability to any SwiftUI view - /// - Parameter onDetected: Closure that is called when a button is detected - /// - Returns: A modified view with button detection - func detectButton(onDetected: @escaping (Bool) -> Void) -> some View { - modifier(ButtonDetectionModifier(onButtonDetected: onDetected)) - } -} diff --git a/Bitkit/Components/Core/ButtonLocationTracking.swift b/Bitkit/Components/Core/ButtonLocationTracking.swift deleted file mode 100644 index d899ab6bb..000000000 --- a/Bitkit/Components/Core/ButtonLocationTracking.swift +++ /dev/null @@ -1,68 +0,0 @@ -import SwiftUI - -/// Preference key for tracking button locations in a coordinate space -struct ButtonLocationPreferenceKey: PreferenceKey { - static var defaultValue: [CGRect] = [] - - static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) { - value.append(contentsOf: nextValue()) - } -} - -/// Modifier that tracks a view's location in a specified coordinate space -private struct ButtonLocationModifier: ViewModifier { - /// The name of the coordinate space to track the view in - let coordinateSpace: String - - /// Callback when the view's location changes - let onLocationChanged: (CGRect) -> Void - - init(coordinateSpace: String, onLocationChanged: @escaping (CGRect) -> Void) { - self.coordinateSpace = coordinateSpace - self.onLocationChanged = onLocationChanged - } - - func body(content: Content) -> some View { - content - .background( - GeometryReader { geometry in - Color.clear - .preference( - key: ButtonLocationPreferenceKey.self, - value: [geometry.frame(in: .named(coordinateSpace))] - ) - .onPreferenceChange(ButtonLocationPreferenceKey.self) { frames in - if let frame = frames.first { - onLocationChanged(frame) - } - } - } - ) - } -} - -public extension View { - /// Tracks the location of a view in a specified coordinate space - /// - Parameters: - /// - coordinateSpace: The name of the coordinate space to track the view in - /// - onLocationChanged: Callback when the view's location changes - /// - Returns: A view that tracks its location in the specified coordinate space - func trackButtonLocation( - in coordinateSpace: String, - onLocationChanged: @escaping (CGRect) -> Void - ) -> some View { - modifier( - ButtonLocationModifier( - coordinateSpace: coordinateSpace, - onLocationChanged: onLocationChanged - ) - ) - } - - /// Tracks the location of a view in the default "dragSpace" coordinate space - /// - Parameter onLocationChanged: Callback when the view's location changes - /// - Returns: A view that tracks its location in the drag space - func trackButtonLocation(onLocationChanged: @escaping (CGRect) -> Void) -> some View { - trackButtonLocation(in: "dragSpace", onLocationChanged: onLocationChanged) - } -} diff --git a/Bitkit/Components/Core/DragHandleTracking.swift b/Bitkit/Components/Core/DragHandleTracking.swift new file mode 100644 index 000000000..4de2ce76a --- /dev/null +++ b/Bitkit/Components/Core/DragHandleTracking.swift @@ -0,0 +1,35 @@ +import SwiftUI + +/// Preference key for tracking drag handle location in a coordinate space. +/// Used so only the drag handle (e.g. burger icon) starts a reorder drag. +struct DragHandlePreferenceKey: PreferenceKey { + static var defaultValue: [CGRect] = [] + static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) { + value.append(contentsOf: nextValue()) + } +} + +private struct DragHandleLocationModifier: ViewModifier { + let coordinateSpace: String + + func body(content: Content) -> some View { + content + .background( + GeometryReader { geometry in + Color.clear + .preference( + key: DragHandlePreferenceKey.self, + value: [geometry.frame(in: .named(coordinateSpace))] + ) + } + ) + } +} + +public extension View { + /// Reports this view's frame as the drag handle in the given coordinate space. + /// Only touches that start inside this frame will begin a reorder drag. + func trackDragHandle(in coordinateSpace: String = "dragSpace") -> some View { + modifier(DragHandleLocationModifier(coordinateSpace: coordinateSpace)) + } +} diff --git a/Bitkit/Components/Core/DraggableItem.swift b/Bitkit/Components/Core/DraggableItem.swift index 396fc88c6..c33ffa460 100644 --- a/Bitkit/Components/Core/DraggableItem.swift +++ b/Bitkit/Components/Core/DraggableItem.swift @@ -23,8 +23,8 @@ struct DraggableItem: View { /// Height of each item including spacing private let itemHeight: CGFloat - /// Minimum drag distance before reordering starts - private let minDragDistance: CGFloat = 10 + /// Long-press duration on burger before drag activates (avoids scroll conflict) + private let longPressDuration: Double = 0.3 /// Called when a drag operation begins let onDragBegan: () -> Void @@ -41,21 +41,20 @@ struct DraggableItem: View { /// Track if we should handle the drag @State private var shouldHandleDrag = false - /// Track button locations - @State private var buttonFrames: [CGRect] = [] + /// Track drag handle locations (e.g. burger icon); only drag starts from these + @State private var dragHandleFrames: [CGRect] = [] - /// Track if the gesture started on a button - @State private var startedOnButton = false + /// Frozen overlay frame during drag so overlay position doesn't change and cause jitter + @State private var overlayFrameDuringDrag: CGRect? - /// Namespace for coordinate space - @Namespace private var dragSpace + /// Coordinate space name used for preference (must match content) + private let dragSpaceName = "dragSpace" - /// Track the item's frame - @State private var itemFrame: CGRect = .zero - - private var windowScene: UIWindowScene? { - UIApplication.shared.connectedScenes - .first(where: { $0 is UIWindowScene }) as? UIWindowScene + /// Clamp vertical drag to list bounds (can't drag above first or below last item). + private func constrainVerticalOffset(_ vertical: CGFloat) -> CGFloat { + let maxUp = -CGFloat(originalIndex) * itemHeight + let maxDown = CGFloat(itemCount - 1 - originalIndex) * itemHeight + return max(maxUp, min(maxDown, vertical)) } init( @@ -86,8 +85,6 @@ struct DraggableItem: View { content .opacity(isDragging ? 0.9 : 1.0) .offset(x: 0, y: isDragging ? dragOffset.height : 0) - .animation(.spring(response: 0.3, dampingFraction: 0.7), value: dragOffset) - .simultaneousGesture(enableDrag ? dragGesture : nil) .shadow( color: Color.black.opacity(isDragging ? 0.3 : 0), radius: isDragging ? 10 : 0, @@ -95,74 +92,131 @@ struct DraggableItem: View { y: isDragging ? 5 : 0 ) .zIndex(isDragging ? 10 : 0) - .coordinateSpace(name: dragSpace) - .background( - GeometryReader { geometry in - Color.clear - .onAppear { - itemFrame = geometry.frame(in: .named(dragSpace)) - } - .onChange(of: geometry.frame(in: .named(dragSpace))) { newFrame in - itemFrame = newFrame + .coordinateSpace(name: dragSpaceName) + .onPreferenceChange(DragHandlePreferenceKey.self) { frames in + dragHandleFrames = frames + } + // Handle overlay: long-press on burger then drag. UIKit view so it reliably receives touches (Color.clear often doesn't). + // Use frozen frame during drag so overlay position doesn't change and cause jitter. + .overlay(alignment: .topLeading) { + if enableDrag, let frame = overlayFrameDuringDrag ?? dragHandleFrames.first, frame.width > 0, frame.height > 0 { + LongPressDragHandleView( + itemHeight: itemHeight, + originalIndex: originalIndex, + itemCount: itemCount, + longPressDuration: longPressDuration, + onDragBegan: { + shouldHandleDrag = true + overlayFrameDuringDrag = dragHandleFrames.first + onDragBegan() + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + }, + onDragChanged: { translation in + dragOffset = CGSize(width: 0, height: constrainVerticalOffset(translation)) + onDragChanged(dragOffset) + }, + onDragEnded: { + onDragEnded(dragOffset) + overlayFrameDuringDrag = nil + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + dragOffset = .zero + shouldHandleDrag = false + } } + ) + .frame(width: frame.width, height: frame.height) + .offset(x: frame.minX, y: frame.minY) } - ) - .onPreferenceChange(ButtonLocationPreferenceKey.self) { frames in - buttonFrames = frames - } - .detectButton { isButton in - startedOnButton = isButton } } +} - private var dragGesture: some Gesture { - DragGesture(minimumDistance: minDragDistance) - .onChanged { gesture in - let verticalMovement = abs(gesture.translation.height) - let startLocation = gesture.startLocation - - // Check if we started on a button - let isOnButton = buttonFrames.contains { frame in - // Convert button frame to be relative to the item - let relativeFrame = CGRect( - x: frame.origin.x - itemFrame.origin.x, - y: frame.origin.y - itemFrame.origin.y, - width: frame.width, - height: frame.height - ) - return relativeFrame.contains(startLocation) - } - - // Only start dragging if we're not over a button and have enough movement - if !isDragging && verticalMovement > minDragDistance && !isOnButton { - shouldHandleDrag = true - onDragBegan() +// MARK: - UIKit long-press handle (reliably receives touches; Color.clear often doesn't) - // Give haptic feedback when drag begins - let impactFeedback = UIImpactFeedbackGenerator(style: .medium) - impactFeedback.impactOccurred() - } +private struct LongPressDragHandleView: UIViewRepresentable { + let itemHeight: CGFloat + let originalIndex: Int + let itemCount: Int + let longPressDuration: Double + let onDragBegan: () -> Void + let onDragChanged: (CGFloat) -> Void + let onDragEnded: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator( + itemHeight: itemHeight, + originalIndex: originalIndex, + itemCount: itemCount, + onDragBegan: onDragBegan, + onDragChanged: onDragChanged, + onDragEnded: onDragEnded + ) + } - if isDragging && shouldHandleDrag { - // Calculate the maximum allowed offset based on the item's position - let maxUpOffset = -CGFloat(originalIndex) * itemHeight - let maxDownOffset = CGFloat(itemCount - 1 - originalIndex) * itemHeight + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.backgroundColor = .clear + let recognizer = UILongPressGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleLongPress(_:)) + ) + recognizer.minimumPressDuration = longPressDuration + view.addGestureRecognizer(recognizer) + return view + } - // Constrain the vertical movement - let proposedOffset = gesture.translation.height - let constrainedOffset = max(maxUpOffset, min(maxDownOffset, proposedOffset)) + func updateUIView(_ uiView: UIView, context: Context) { + context.coordinator.itemHeight = itemHeight + context.coordinator.originalIndex = originalIndex + context.coordinator.itemCount = itemCount + } - dragOffset = CGSize(width: 0, height: constrainedOffset) - onDragChanged(dragOffset) - } - } - .onEnded { _ in - if isDragging && shouldHandleDrag { - let verticalOffset = CGSize(width: 0, height: dragOffset.height) - onDragEnded(verticalOffset) - dragOffset = .zero - shouldHandleDrag = false - } + final class Coordinator: NSObject { + var itemHeight: CGFloat + var originalIndex: Int + var itemCount: Int + var onDragBegan: () -> Void + var onDragChanged: (CGFloat) -> Void + var onDragEnded: () -> Void + /// Use window coordinates so translation isn't affected by the overlay moving with the dragged content (reduces lag/jitter). + var initialLocationInWindow: CGPoint = .zero + + init( + itemHeight: CGFloat, + originalIndex: Int, + itemCount: Int, + onDragBegan: @escaping () -> Void, + onDragChanged: @escaping (CGFloat) -> Void, + onDragEnded: @escaping () -> Void + ) { + self.itemHeight = itemHeight + self.originalIndex = originalIndex + self.itemCount = itemCount + self.onDragBegan = onDragBegan + self.onDragChanged = onDragChanged + self.onDragEnded = onDragEnded + } + + @objc func handleLongPress(_ recognizer: UILongPressGestureRecognizer) { + guard let window = recognizer.view?.window else { return } + let locationInWindow = recognizer.location(in: window) + switch recognizer.state { + case .began: + initialLocationInWindow = locationInWindow + onDragBegan() + case .changed: + let translation = locationInWindow.y - initialLocationInWindow.y + let maxUp = -CGFloat(originalIndex) * itemHeight + let maxDown = CGFloat(itemCount - 1 - originalIndex) * itemHeight + let constrained = max(maxUp, min(maxDown, translation)) + onDragChanged(constrained) + case .ended, .cancelled: + onDragEnded() + default: + break } + } } } diff --git a/Bitkit/Components/Core/DraggableList.swift b/Bitkit/Components/Core/DraggableList.swift index 20934bf18..c5cf047f9 100644 --- a/Bitkit/Components/Core/DraggableList.swift +++ b/Bitkit/Components/Core/DraggableList.swift @@ -23,13 +23,10 @@ struct DraggableList: View let content: (Data.Element) -> Content /// ID of the currently dragged item - @State private var draggedItemID: ID? = nil - - /// Current drag amount of the dragged item - @State private var dragAmount = CGSize.zero + @State private var draggedItemID: ID? /// Track the predicted destination during drag - @State private var predictedDestinationIndex: Int? = nil + @State private var predictedDestinationIndex: Int? /// Initialize a reorderable list component /// - Parameters: @@ -70,67 +67,52 @@ struct DraggableList: View itemHeight: itemHeight, onDragBegan: { if enableDrag { - // Start dragging with animation - withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + // Only set draggedItemID on long-press; leave predictedDestinationIndex nil until + // onDragChanged so no other row gets an offset (fixes "teleport below" on long-press) + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { draggedItemID = itemID - // Initially, the item is at its original position - predictedDestinationIndex = index + predictedDestinationIndex = nil } } }, onDragChanged: { amount in - dragAmount = amount - - // Only calculate predicted position if we have a dragged item - if draggedItemID != nil, let sourceIndex = getIndexForID(draggedItemID!) { - // Calculate how many positions to move based on vertical translation - let verticalChange = amount.height - let moveCount = Int(round(verticalChange / itemHeight)) - - // Calculate predicted destination with bounds checking - let newDestination = max(0, min(data.count - 1, sourceIndex + moveCount)) - - // Only update if the predicted destination changed - if newDestination != predictedDestinationIndex { - withAnimation(.spring(response: 0.4, dampingFraction: 0.9)) { - predictedDestinationIndex = newDestination - } - - // Very light impact feedback when crossing item boundaries - let impactFeedback = UIImpactFeedbackGenerator(style: .soft) - impactFeedback.impactOccurred(intensity: 0.7) - } + guard let draggedID = draggedItemID, let sourceIndex = getIndexForID(draggedID) else { return } + let verticalChange = amount.height + let moveCount = Int(round(verticalChange / itemHeight)) + let newDestination = max(0, min(data.count - 1, sourceIndex + moveCount)) + if newDestination != predictedDestinationIndex { + predictedDestinationIndex = newDestination + let impactFeedback = UIImpactFeedbackGenerator(style: .soft) + impactFeedback.impactOccurred(intensity: 0.7) } }, onDragEnded: { _ in - if draggedItemID == nil { return } - - // Find the source index for the dragged item - guard let sourceIndex = getIndexForID(draggedItemID!) else { return } + guard let draggedID = draggedItemID, let sourceIndex = getIndexForID(draggedID) else { return } // Use the calculated predicted destination as our target let targetIndex = predictedDestinationIndex ?? sourceIndex - // Call the reorder handler if the position changed + // Reset drag state first so when the parent re-renders with new order we don't + // apply wrong offsets (e.g. "cleared" space at index 0 when dragging index 2) + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + draggedItemID = nil + predictedDestinationIndex = nil + } + if sourceIndex != targetIndex { onReorder(sourceIndex, targetIndex) - // Success haptic feedback let notificationFeedback = UINotificationFeedbackGenerator() notificationFeedback.notificationOccurred(.success) } - - // Reset drag state with smooth animation - withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { - draggedItemID = nil - dragAmount = .zero - predictedDestinationIndex = nil - } }, content: { content(item) .offset(getOffsetForItem(index: index, id: itemID)) - .animation(.spring(response: 0.45, dampingFraction: 0.9), value: predictedDestinationIndex) } ) } @@ -149,8 +131,8 @@ struct DraggableList: View return .zero } - // If we're not dragging or don't have a predicted destination, no offset - guard let draggedIndex = getIndexForID(draggedItemID ?? id), + guard let draggedID = draggedItemID, + let draggedIndex = getIndexForID(draggedID), let predictedIndex = predictedDestinationIndex else { return .zero @@ -193,54 +175,3 @@ extension DraggableList where ID == Data.Element.ID { self.init(data, id: \.id, enableDrag: enableDrag, itemHeight: itemHeight, onReorder: onReorder, content: content) } } - -struct PreviewItem: Identifiable { - let id = UUID() - let name: String -} - -struct PreviewItemView: View { - let item: PreviewItem - - var body: some View { - Text(item.name) - .frame(maxWidth: .infinity) - .frame(height: 60) - .background(Color.blue.opacity(0.3)) - .cornerRadius(8) - .padding(.horizontal) - } -} - -struct DraggableListPreview: View { - @State private var items = [ - PreviewItem(name: "Item 1"), - PreviewItem(name: "Item 2"), - PreviewItem(name: "Item 3"), - PreviewItem(name: "Item 4"), - PreviewItem(name: "Item 5"), - ] - - var body: some View { - ScrollView { - DraggableList( - items, - enableDrag: true, - itemHeight: 76, // 60 for content + 16 for spacing - onReorder: { sourceIndex, destinationIndex in - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - let item = items.remove(at: sourceIndex) - items.insert(item, at: destinationIndex) - } - } - ) { item in - PreviewItemView(item: item) - } - .padding(.vertical) - } - } -} - -#Preview("DraggableList") { - DraggableListPreview() -} diff --git a/Bitkit/Components/Divider.swift b/Bitkit/Components/Divider.swift index 9f17e5de9..54343e55d 100644 --- a/Bitkit/Components/Divider.swift +++ b/Bitkit/Components/Divider.swift @@ -1,9 +1,22 @@ import SwiftUI +enum DividerType { + case horizontal + case vertical +} + struct CustomDivider: View { + let color: Color + let type: DividerType + + init(color: Color = .white.opacity(0.1), type: DividerType = .horizontal) { + self.color = color + self.type = type + } + var body: some View { Rectangle() - .fill(Color.white.opacity(0.1)) - .frame(height: 1) + .fill(color) + .frame(width: type == .horizontal ? nil : 1, height: type == .horizontal ? 1 : nil) } } diff --git a/Bitkit/Components/EmptyStateView.swift b/Bitkit/Components/EmptyStateView.swift index 3053eda35..b19a76a46 100644 --- a/Bitkit/Components/EmptyStateView.swift +++ b/Bitkit/Components/EmptyStateView.swift @@ -1,89 +1,56 @@ import SwiftUI -enum EmptyStateType { +enum WalletOnboardingType { case home case savings case spending var localizationKey: String { switch self { - case .home: - return "onboarding__empty_wallet" - case .savings: - return "wallet__savings__onboarding" - case .spending: - return "wallet__spending__onboarding" + case .home: return "onboarding__empty_wallet" + case .savings: return "wallet__savings__onboarding" + case .spending: return "wallet__spending__onboarding" } } var accentColor: Color { switch self { - case .spending: - return .purpleAccent - case .home, .savings: - return .brandAccent + case .spending: return .purpleAccent + case .home, .savings: return .brandAccent } } } -struct EmptyStateView: View { - let type: EmptyStateType - var onClose: (() -> Void)? +struct WalletOnboardingView: View { + let type: WalletOnboardingType var body: some View { - VStack { - Spacer() - - HStack(alignment: .bottom, spacing: 0) { - DisplayText(t(type.localizationKey), accentColor: type.accentColor) - .frame(width: 240) + HStack(alignment: .bottom, spacing: 0) { + DisplayText(t(type.localizationKey), accentColor: type.accentColor) + .frame(width: 240) - Image("empty-state-arrow") - .resizable() - .scaledToFit() - .frame(maxHeight: 144) + Image("empty-state-arrow") + .resizable() + .scaledToFit() + .frame(maxHeight: 144) - Spacer() - } - .frame(maxWidth: .infinity) - .padding(.bottom, 100) - .overlay { - if let onClose { - VStack { - Button(action: { - Haptics.play(.buttonTap) - onClose() - }) { - Image("x-mark") - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundColor(.textSecondary) - .frame(width: 16, height: 16) - .frame(width: 44, height: 44) // Increase hit area - } - .offset(x: 16, y: -16) - .accessibilityIdentifier("WalletOnboardingClose") - - Spacer() - } - .frame(maxWidth: .infinity, alignment: .topTrailing) - } - } + Spacer() } + .frame(maxWidth: .infinity) } } #Preview { VStack(spacing: 20) { - EmptyStateView(type: .home, onClose: {}) + WalletOnboardingView(type: .home) .frame(height: 300) .background(Color.gray.opacity(0.1)) - EmptyStateView(type: .savings, onClose: {}) + WalletOnboardingView(type: .savings) .frame(height: 300) .background(Color.gray.opacity(0.1)) - EmptyStateView(type: .spending, onClose: {}) + WalletOnboardingView(type: .spending) .frame(height: 300) .background(Color.gray.opacity(0.1)) } diff --git a/Bitkit/Components/Header.swift b/Bitkit/Components/Header.swift index a3771e1ff..7190d060c 100644 --- a/Bitkit/Components/Header.swift +++ b/Bitkit/Components/Header.swift @@ -4,6 +4,16 @@ struct Header: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var navigation: NavigationViewModel + /// When true, shows the widget edit button (only on the widgets tab). + var showWidgetEditButton: Bool = false + /// Binding to widgets edit state; used when showWidgetEditButton is true. + @Binding var isEditingWidgets: Bool + + init(showWidgetEditButton: Bool = false, isEditingWidgets: Binding = .constant(false)) { + self.showWidgetEditButton = showWidgetEditButton + _isEditingWidgets = isEditingWidgets + } + var body: some View { HStack(alignment: .center, spacing: 0) { // Button { @@ -26,7 +36,7 @@ struct Header: View { Spacer() - HStack(alignment: .center, spacing: 12) { + HStack(alignment: .center, spacing: 8) { AppStatus( testID: "HeaderAppStatus", onPress: { @@ -34,6 +44,21 @@ struct Header: View { } ) + if showWidgetEditButton { + Button(action: { + isEditingWidgets.toggle() + }) { + Image(isEditingWidgets ? "check-mark" : "pencil") + .resizable() + .foregroundColor(.textPrimary) + .frame(width: 24, height: 24) + .frame(width: 32, height: 32) + .padding(.leading, 16) + .contentShape(Rectangle()) + } + .accessibilityIdentifier("HeaderWidgetEdit") + } + Button { withAnimation { app.showDrawer = true @@ -44,7 +69,6 @@ struct Header: View { .foregroundColor(.textPrimary) .frame(width: 24, height: 24) .frame(width: 32, height: 32) - .padding(.leading, 16) .contentShape(Rectangle()) } .accessibilityIdentifier("HeaderMenu") diff --git a/Bitkit/Components/Home/Suggestions.swift b/Bitkit/Components/Home/Suggestions.swift index fc467bf78..a4af1e6dd 100644 --- a/Bitkit/Components/Home/Suggestions.swift +++ b/Bitkit/Components/Home/Suggestions.swift @@ -12,16 +12,32 @@ struct SuggestionCardData: Identifiable, Hashable { enum SuggestionAction: Hashable { case backup case buyBitcoin + // case hardware case invite + case notifications case profile case quickpay - case notifications case secure case shop case support case transferToSpending } +/// Wallet state used to choose which suggestion cards to show and in what order. +enum WalletSuggestionState { + case empty + case onchain + case spending +} + +/// Ordered suggestion card IDs per wallet state (priority: first = highest). +/// Max 4 cards are shown; when one is dismissed or completed, the next in this list is shown. +private let suggestionOrderByState: [WalletSuggestionState: [String]] = [ + .empty: ["buyBitcoin", "transferToSpending", "support", "backupSeedPhrase", "pin", "profile", "invite"], + .onchain: ["backupSeedPhrase", "pin", "transferToSpending", "support", "profile", "invite", "buyBitcoin"], + .spending: ["quickpay", "notifications", "shop", "profile", "support", "invite", "buyBitcoin"], +] + let cards: [SuggestionCardData] = [ SuggestionCardData( id: "backupSeedPhrase", @@ -103,8 +119,18 @@ let cards: [SuggestionCardData] = [ color: .brand24, action: .profile ), + // SuggestionCardData( + // id: "hardware", + // title: t("cards__hardware__title"), + // description: t("cards__hardware__description"), + // imageName: "trezor-card", + // color: .blue24, + // action: .hardware + // ), ] +private let cardsById: [String: SuggestionCardData] = Dictionary(uniqueKeysWithValues: cards.map { ($0.id, $0) }) + extension SuggestionCardData { var accessibilityId: String { switch action { @@ -112,14 +138,16 @@ extension SuggestionCardData { return "back_up" case .buyBitcoin: return "buy" + // case .hardware: + // return "hardware" case .invite: return "invite" + case .notifications: + return "notifications" case .profile: return "profile" case .quickpay: return "quick_pay" - case .notifications: - return "notifications" case .secure: return "secure" case .shop: @@ -138,36 +166,46 @@ struct Suggestions: View { @EnvironmentObject var sheets: SheetViewModel @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var suggestionsManager: SuggestionsManager + @EnvironmentObject var wallet: WalletViewModel @State private var showShareSheet = false - // Prevent duplicate item taps when the card is dismissed - @State private var ignoringCardTaps = false - let cardSize: CGFloat = 152 - let cardSpacing: CGFloat = 16 + private var walletSuggestionState: WalletSuggestionState { + if wallet.totalBalanceSats == 0 { + return .empty + } + if wallet.totalLightningSats > 0 { + return .spending + } + return .onchain + } - // Filter out cards that have already been completed or dismissed + /// Up to 4 cards for the current wallet state, in priority order; completed and dismissed cards are skipped and the next in the set is shown. private var filteredCards: [SuggestionCardData] { - cards.filter { card in - // Filter out completed actions - if card.action == .backup && app.backupVerified { - return false - } - - if card.action == .secure && settings.pinEnabled { - return false - } - - if card.action == .notifications && settings.enableNotifications { - return false - } - - // Filter out dismissed cards - if suggestionsManager.isDismissed(card.id) { - return false - } + let orderedIds = suggestionOrderByState[walletSuggestionState] ?? [] + var result: [SuggestionCardData] = [] + for id in orderedIds { + guard let card = cardsById[id] else { continue } + if isCardCompleted(card) { continue } + if suggestionsManager.isDismissed(card.id) { continue } + result.append(card) + if result.count >= 4 { break } + } + return result + } - return true + private func isCardCompleted(_ card: SuggestionCardData) -> Bool { + switch card.action { + case .backup: + return app.backupVerified + case .notifications: + return settings.enableNotifications + case .quickpay: + return settings.enableQuickpay + case .secure: + return settings.pinEnabled + default: + return false } } @@ -175,44 +213,20 @@ struct Suggestions: View { if filteredCards.isEmpty { EmptyView() } else { - VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("cards__suggestions")) - .padding(.horizontal) - .padding(.bottom, 16) - - SnapCarousel( - items: filteredCards, - itemSize: cardSize, - itemSpacing: cardSpacing, - onItemTap: { card in - if !ignoringCardTaps { - onItemTap(card) - } - } - ) { card in - SuggestionCard( - data: card, - onDismiss: { dismissCard(card) } - ) - .accessibilityElement(children: .contain) - .accessibilityIdentifier("Suggestion-\(card.accessibilityId)") + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16), + ], + spacing: 16 + ) { + ForEach(filteredCards) { card in + SuggestionCard(data: card, onDismiss: { dismissCard(card) }) + .onTapGesture { onItemTap(card) } + .accessibilityIdentifier("Suggestion-\(card.accessibilityId)") } + .accessibilityElement(children: .contain) .accessibilityIdentifier("Suggestions") - .id("suggestions-\(filteredCards.count)-\(suggestionsManager.dismissedIds.count)") - .frame(height: cardSize) - .padding(.bottom, 16) - } - .padding(.top, 32) - .sheet(isPresented: $showShareSheet) { - ShareSheet(activityItems: [ - t( - "settings__about__shareText", - variables: [ - "appStoreUrl": Env.appStoreUrl, - "playStoreUrl": Env.playStoreUrl, - ] - ), - ]) } } } @@ -239,6 +253,8 @@ struct Suggestions: View { route = app.hasSeenShopIntro ? .shopDiscover : .shopIntro case .support: route = .support + // case .hardware: + // route = .support case .transferToSpending: route = app.hasSeenTransferIntro ? .fundingOptions : .transferIntro } @@ -249,24 +265,8 @@ struct Suggestions: View { } private func dismissCard(_ card: SuggestionCardData) { - ignoringCardTaps = true - - // Force UI update by using withAnimation withAnimation(.easeInOut(duration: 0.3)) { suggestionsManager.dismiss(card.id) } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - ignoringCardTaps = false - } - } -} - -#Preview { - VStack { - Suggestions() } - .environmentObject(SheetViewModel()) - .environmentObject(SettingsViewModel.shared) - .preferredColorScheme(.dark) } diff --git a/Bitkit/Components/Home/SuggestionsCard.swift b/Bitkit/Components/Home/SuggestionsCard.swift index 50fe25446..a44f53f74 100644 --- a/Bitkit/Components/Home/SuggestionsCard.swift +++ b/Bitkit/Components/Home/SuggestionsCard.swift @@ -25,7 +25,7 @@ struct SuggestionCard: View { CaptionBText(data.description) } .padding() - .frame(width: 152, height: 152, alignment: .topLeading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background( RoundedRectangle(cornerRadius: 16) .fill( diff --git a/Bitkit/Components/Home/Widgets.swift b/Bitkit/Components/Home/Widgets.swift deleted file mode 100644 index 38fb6057e..000000000 --- a/Bitkit/Components/Home/Widgets.swift +++ /dev/null @@ -1,81 +0,0 @@ -import SwiftUI - -struct WidgetViewWrapper: View { - let widget: Widget - let isEditing: Bool - let onEditingEnd: (() -> Void)? - - @EnvironmentObject private var widgets: WidgetsViewModel - - var body: some View { - widget.view(widgetsViewModel: widgets, isEditing: isEditing, onEditingEnd: onEditingEnd) - } -} - -struct Widgets: View { - @EnvironmentObject var app: AppViewModel - @EnvironmentObject var navigation: NavigationViewModel - @EnvironmentObject var widgets: WidgetsViewModel - - @Binding var isEditing: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack { - CaptionMText(t("widgets__widgets")) - - Spacer() - - Button(action: { - isEditing.toggle() - }) { - Image(isEditing ? "checkmark" : "sort-ascending") - .resizable() - .foregroundColor(.textSecondary) - .frame(width: 24, height: 24) - .accessibilityIdentifier("WidgetsEdit") - } - } - .padding(.bottom, 16) - - DraggableList( - widgets.savedWidgets, - id: \.id, - enableDrag: isEditing, - itemHeight: 80, - onReorder: { sourceIndex, destinationIndex in - withAnimation { - widgets.reorderWidgets(from: sourceIndex, to: destinationIndex) - } - } - ) { widget in - WidgetViewWrapper(widget: widget, isEditing: isEditing) { - withAnimation { - isEditing = false - } - } - } - - CustomButton(title: t("widgets__add"), variant: .tertiary) { - if app.hasSeenWidgetsIntro { - navigation.navigate(.widgetsList) - } else { - navigation.navigate(.widgetsIntro) - } - } - .padding(.top, 16) - .accessibilityIdentifier("WidgetsAdd") - } - } -} - -#Preview { - VStack { - Widgets(isEditing: .constant(false)) - .environmentObject(AppViewModel()) - .environmentObject(NavigationViewModel()) - .environmentObject(WidgetsViewModel()) - .environmentObject(WalletViewModel()) - } - .preferredColorScheme(.dark) -} diff --git a/Bitkit/Components/MoneyStack.swift b/Bitkit/Components/MoneyStack.swift index 8d7ffd564..aa4e9250f 100644 --- a/Bitkit/Components/MoneyStack.swift +++ b/Bitkit/Components/MoneyStack.swift @@ -19,6 +19,36 @@ struct MoneyStack: View { private let springAnimation = Animation.spring(response: 0.3, dampingFraction: 0.8) + var hideGesture: some Gesture { + DragGesture(minimumDistance: 50, coordinateSpace: .local) + .onEnded { value in + let horizontalAmount = value.translation.width + let verticalAmount = value.translation.height + + // Only trigger if horizontal swipe is more significant than vertical + if abs(horizontalAmount) > abs(verticalAmount) { + let wasHidden = settings.hideBalance + withAnimation(springAnimation) { + settings.hideBalance.toggle() + } + Haptics.play(.medium) + + // Show toast on first hide (when balance becomes hidden) + if !wasHidden && settings.hideBalance && !settings.ignoresHideBalanceToast { + app.toast( + type: .info, + title: t("wallet__balance_hidden_title"), + description: t("wallet__balance_hidden_message"), + visibilityTime: 5.0, + accessibilityIdentifier: "BalanceHiddenToast" + ) + + settings.ignoresHideBalanceToast = true + } + } + } + } + var body: some View { VStack(alignment: .leading, spacing: 16) { if currency.primaryDisplay == .bitcoin { @@ -147,35 +177,7 @@ struct MoneyStack: View { } } .animation(springAnimation, value: currency.primaryDisplay) - .conditionalGesture(enableSwipeGesture) { - DragGesture(minimumDistance: 50, coordinateSpace: .local) - .onEnded { value in - let horizontalAmount = value.translation.width - let verticalAmount = value.translation.height - - // Only trigger if horizontal swipe is more significant than vertical - if abs(horizontalAmount) > abs(verticalAmount) { - let wasHidden = settings.hideBalance - withAnimation(springAnimation) { - settings.hideBalance.toggle() - } - Haptics.play(.medium) - - // Show toast on first hide (when balance becomes hidden) - if !wasHidden && settings.hideBalance && !settings.ignoresHideBalanceToast { - app.toast( - type: .info, - title: t("wallet__balance_hidden_title"), - description: t("wallet__balance_hidden_message"), - visibilityTime: 5.0, - accessibilityIdentifier: "BalanceHiddenToast" - ) - - settings.ignoresHideBalanceToast = true - } - } - } - } + .highPriorityGesture(enableSwipeGesture ? hideGesture : nil) .animation(enableSwipeGesture ? springAnimation : nil, value: settings.hideBalance) } } @@ -215,19 +217,6 @@ private extension MoneyStack { } } -// MARK: - Helper View Modifier - -extension View { - @ViewBuilder - func conditionalGesture(_ condition: Bool, gesture: () -> some Gesture) -> some View { - if condition { - self.gesture(gesture()) - } else { - self - } - } -} - // MARK: - Preview Helpers private extension MoneyStack { diff --git a/Bitkit/Components/SettingsListLabel.swift b/Bitkit/Components/SettingsListLabel.swift index 07047db5b..38328a2bf 100644 --- a/Bitkit/Components/SettingsListLabel.swift +++ b/Bitkit/Components/SettingsListLabel.swift @@ -70,7 +70,7 @@ struct SettingsListLabel: View { .foregroundColor(.textSecondary) .frame(width: 24, height: 24) case .checkmark: - Image("check") + Image("check-mark") .resizable() .foregroundColor(.brandAccent) .frame(width: 32, height: 32) diff --git a/Bitkit/Components/TabViewDots.swift b/Bitkit/Components/TabViewDots.swift new file mode 100644 index 000000000..bd6a13052 --- /dev/null +++ b/Bitkit/Components/TabViewDots.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct TabViewDots: View { + let numberOfTabs: Int + var currentTab: Int + + var body: some View { + VStack { + Spacer() + + HStack(spacing: 8) { + ForEach(Array(0 ..< numberOfTabs), id: \.self) { index in + Circle() + .fill(currentTab == index ? Color.textPrimary : Color.white32) + .frame(width: 8, height: 8) + } + } + .animation(.easeInOut(duration: 0.3), value: currentTab) + } + .zIndex(.infinity) + } +} diff --git a/Bitkit/Components/Widgets/BaseWidget.swift b/Bitkit/Components/Widgets/BaseWidget.swift index 9445ca86d..43f54cf9d 100644 --- a/Bitkit/Components/Widgets/BaseWidget.swift +++ b/Bitkit/Components/Widgets/BaseWidget.swift @@ -149,7 +149,6 @@ struct BaseWidget: View { } .frame(width: 32, height: 32) .contentShape(Rectangle()) - .trackButtonLocation { _ in } .accessibilityIdentifier("\(metadata.name)_WidgetActionDelete") // Edit button @@ -163,13 +162,20 @@ struct BaseWidget: View { } .frame(width: 32, height: 32) .contentShape(Rectangle()) - .trackButtonLocation { _ in } .accessibilityIdentifier("\(metadata.name)_WidgetActionEdit") Image("burger") .resizable() .foregroundColor(.textPrimary) .frame(width: 24, height: 24) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + .overlay { + Color.clear + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + .trackDragHandle() + } .accessibilityIdentifier("\(metadata.name)_WidgetActionReorder") } } diff --git a/Bitkit/Components/WidgetsOnboardingView.swift b/Bitkit/Components/WidgetsOnboardingView.swift new file mode 100644 index 000000000..50aa784d7 --- /dev/null +++ b/Bitkit/Components/WidgetsOnboardingView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +private struct WidgetsOnboardingText: View { + let text: String + private let fontSize: CGFloat = 24 + + var body: some View { + AccentedText( + text, + font: Fonts.black(size: fontSize), + fontColor: .textPrimary, + accentColor: .brandAccent, + accentFont: Fonts.black(size: fontSize) + ) + .kerning(-1) + .environment(\._lineHeightMultiple, 0.83) + .textCase(.uppercase) + .padding(.bottom, -9) + .frame(maxWidth: .infinity, alignment: .leading) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + } +} + +struct WidgetsOnboardingView: View { + var body: some View { + HStack(alignment: .bottom, spacing: 0) { + WidgetsOnboardingText(text: t("widgets__onboarding__swipe")) + + Image("swipe-hint") + .resizable() + .scaledToFit() + .frame(maxWidth: 100) + } + .frame(height: 72) + .frame(maxWidth: .infinity) + } +} diff --git a/Bitkit/Extensions/View+SafeArea.swift b/Bitkit/Extensions/View+SafeArea.swift index 371368478..6a335a7b6 100644 --- a/Bitkit/Extensions/View+SafeArea.swift +++ b/Bitkit/Extensions/View+SafeArea.swift @@ -1,20 +1,25 @@ import SwiftUI import UIKit -var hasHomeIndicator: Bool { +private var hasBottomSafeArea: Bool { + windowSafeAreaInsets.bottom > 0 +} + +/// Key window's safe area insets, or `.zero` if no window is available. +var windowSafeAreaInsets: UIEdgeInsets { guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = windowScene.windows.first else { - return false + return .zero } - return window.safeAreaInsets.bottom > 0 + return window.safeAreaInsets } // For phones without a home indicator, we add padding to the bottom of the view struct BottomSafeAreaPadding: ViewModifier { func body(content: Content) -> some View { content - .padding(.bottom, hasHomeIndicator ? 0 : 16) + .padding(.bottom, hasBottomSafeArea ? 0 : 16) } } @@ -24,7 +29,7 @@ extension View { } func buttonBottomPadding(isFocused: Bool = false) -> some View { - padding(.bottom, isFocused && hasHomeIndicator ? 16 : 0) + padding(.bottom, isFocused && hasBottomSafeArea ? 16 : 0) } } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index fad633e43..ae6bebd95 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -283,7 +283,7 @@ struct MainNavView: View { Group { switch navigation.activeDrawerMenuItem { case .wallet: - HomeView() + HomeScreen() case .activity: AllActivityView() case .contacts: diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index 45f4d8a40..df471171d 100644 --- a/Bitkit/Models/BackupPayloads.swift +++ b/Bitkit/Models/BackupPayloads.swift @@ -26,13 +26,77 @@ struct AppCacheData: Codable { let hasSeenTransferToSpendingIntro: Bool let hasSeenTransferToSavingsIntro: Bool let hasSeenWidgetsIntro: Bool - let showHomeViewEmptyState: Bool + let hasDismissedWidgetsOnboardingHint: Bool let appUpdateIgnoreTimestamp: TimeInterval let backupIgnoreTimestamp: TimeInterval let highBalanceIgnoreCount: Int let highBalanceIgnoreTimestamp: TimeInterval let dismissedSuggestions: [String] let lastUsedTags: [String] + + init( + hasSeenContactsIntro: Bool, + hasSeenProfileIntro: Bool, + hasSeenNotificationsIntro: Bool, + hasSeenQuickpayIntro: Bool, + hasSeenShopIntro: Bool, + hasSeenTransferIntro: Bool, + hasSeenTransferToSpendingIntro: Bool, + hasSeenTransferToSavingsIntro: Bool, + hasSeenWidgetsIntro: Bool, + hasDismissedWidgetsOnboardingHint: Bool, + appUpdateIgnoreTimestamp: TimeInterval, + backupIgnoreTimestamp: TimeInterval, + highBalanceIgnoreCount: Int, + highBalanceIgnoreTimestamp: TimeInterval, + dismissedSuggestions: [String], + lastUsedTags: [String] + ) { + self.hasSeenContactsIntro = hasSeenContactsIntro + self.hasSeenProfileIntro = hasSeenProfileIntro + self.hasSeenNotificationsIntro = hasSeenNotificationsIntro + self.hasSeenQuickpayIntro = hasSeenQuickpayIntro + self.hasSeenShopIntro = hasSeenShopIntro + self.hasSeenTransferIntro = hasSeenTransferIntro + self.hasSeenTransferToSpendingIntro = hasSeenTransferToSpendingIntro + self.hasSeenTransferToSavingsIntro = hasSeenTransferToSavingsIntro + self.hasSeenWidgetsIntro = hasSeenWidgetsIntro + self.hasDismissedWidgetsOnboardingHint = hasDismissedWidgetsOnboardingHint + self.appUpdateIgnoreTimestamp = appUpdateIgnoreTimestamp + self.backupIgnoreTimestamp = backupIgnoreTimestamp + self.highBalanceIgnoreCount = highBalanceIgnoreCount + self.highBalanceIgnoreTimestamp = highBalanceIgnoreTimestamp + self.dismissedSuggestions = dismissedSuggestions + self.lastUsedTags = lastUsedTags + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + hasSeenContactsIntro = try c.decode(Bool.self, forKey: .hasSeenContactsIntro) + hasSeenProfileIntro = try c.decode(Bool.self, forKey: .hasSeenProfileIntro) + hasSeenNotificationsIntro = try c.decode(Bool.self, forKey: .hasSeenNotificationsIntro) + hasSeenQuickpayIntro = try c.decode(Bool.self, forKey: .hasSeenQuickpayIntro) + hasSeenShopIntro = try c.decode(Bool.self, forKey: .hasSeenShopIntro) + hasSeenTransferIntro = try c.decode(Bool.self, forKey: .hasSeenTransferIntro) + hasSeenTransferToSpendingIntro = try c.decode(Bool.self, forKey: .hasSeenTransferToSpendingIntro) + hasSeenTransferToSavingsIntro = try c.decode(Bool.self, forKey: .hasSeenTransferToSavingsIntro) + hasSeenWidgetsIntro = try c.decode(Bool.self, forKey: .hasSeenWidgetsIntro) + hasDismissedWidgetsOnboardingHint = try c.decodeIfPresent(Bool.self, forKey: .hasDismissedWidgetsOnboardingHint) ?? false + appUpdateIgnoreTimestamp = try c.decode(TimeInterval.self, forKey: .appUpdateIgnoreTimestamp) + backupIgnoreTimestamp = try c.decode(TimeInterval.self, forKey: .backupIgnoreTimestamp) + highBalanceIgnoreCount = try c.decode(Int.self, forKey: .highBalanceIgnoreCount) + highBalanceIgnoreTimestamp = try c.decode(TimeInterval.self, forKey: .highBalanceIgnoreTimestamp) + dismissedSuggestions = try c.decode([String].self, forKey: .dismissedSuggestions) + lastUsedTags = try c.decode([String].self, forKey: .lastUsedTags) + } + + private enum CodingKeys: String, CodingKey { + case hasSeenContactsIntro, hasSeenProfileIntro, hasSeenNotificationsIntro, hasSeenQuickpayIntro + case hasSeenShopIntro, hasSeenTransferIntro, hasSeenTransferToSpendingIntro, hasSeenTransferToSavingsIntro + case hasSeenWidgetsIntro, hasDismissedWidgetsOnboardingHint + case appUpdateIgnoreTimestamp, backupIgnoreTimestamp, highBalanceIgnoreCount, highBalanceIgnoreTimestamp + case dismissedSuggestions, lastUsedTags + } } struct BlocktankBackupV1: Codable { diff --git a/Bitkit/Models/SettingsBackupConfig.swift b/Bitkit/Models/SettingsBackupConfig.swift index c02423215..ee7239ed7 100644 --- a/Bitkit/Models/SettingsBackupConfig.swift +++ b/Bitkit/Models/SettingsBackupConfig.swift @@ -25,7 +25,7 @@ enum SettingsBackupConfig { "hasSeenTransferToSpendingIntro", "hasSeenTransferToSavingsIntro", "hasSeenWidgetsIntro", - "showHomeViewEmptyState", + "hasDismissedWidgetsOnboardingHint", "appUpdateIgnoreTimestamp", "backupIgnoreTimestamp", "highBalanceIgnoreCount", diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index c297be4f6..3249eaad0 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -29,6 +29,8 @@ "cards__slashtagsProfile__description" = "Add your details"; "cards__support__title" = "Support"; "cards__support__description" = "Get assistance"; +"cards__hardware__title" = "Hardware"; +"cards__hardware__description" = "Connect device"; "cards__buyBitcoin__title" = "Buy"; "cards__buyBitcoin__description" = "Buy some bitcoin"; "cards__btFailed__title" = "Failed"; @@ -37,7 +39,7 @@ "coming_soon__nav_title" = "Coming Soon"; "coming_soon__headline" = "Coming\nsoon"; "coming_soon__description" = "This feature is currently in development and will be available soon."; -"coming_soon__button" = "Wallet overview"; +"coming_soon__button" = "Wallet Overview"; "common__advanced" = "Advanced"; "common__continue" = "Continue"; "common__cancel" = "Cancel"; @@ -636,8 +638,8 @@ "settings__general__language" = "Language"; "settings__general__language_title" = "Language"; "settings__general__language_other" = "Interface language"; -"settings__widgets__nav_title" = "Widgets"; -"settings__widgets__showWidgets" = "Widgets"; +"settings__widgets__nav_title" = "Widgets and Suggestions"; +"settings__widgets__showWidgets" = "Widgets and Suggestions"; "settings__widgets__showWidgetTitles" = "Show Widget Titles"; "settings__notifications__nav_title" = "Background Payments"; "settings__notifications__intro__title" = "Get Paid\nPassively"; @@ -1211,6 +1213,7 @@ "wallet__receive_foreground_title" = "Keep Bitkit In Foreground"; "wallet__receive_foreground_msg" = "Payments to your spending balance might fail if you switch between apps."; "widgets__widgets" = "Widgets"; +"widgets__onboarding__swipe" = "Swipe down\nto find your\nwidgets"; "widgets__onboarding__title" = "Hello,\nWidgets"; "widgets__onboarding__description" = "Enjoy decentralized feeds from your favorite web services, by adding fun and useful widgets to your Bitkit wallet."; "widgets__nav_title" = "Widgets"; diff --git a/Bitkit/ViewModels/ActivityListViewModel.swift b/Bitkit/ViewModels/ActivityListViewModel.swift index 8a9c6c614..a47d6580c 100644 --- a/Bitkit/ViewModels/ActivityListViewModel.swift +++ b/Bitkit/ViewModels/ActivityListViewModel.swift @@ -143,7 +143,7 @@ class ActivityListViewModel: ObservableObject { func syncState() async { do { // Get latest activities first as that's displayed on the home view - let limitLatest: UInt32 = 3 + let limitLatest: UInt32 = UIScreen.main.isSmall ? 2 : 3 // Fetch extra to account for potential filtering of replaced transactions let latest = try await coreService.activity.get(filter: .all, limit: limitLatest * 3) let filtered = await filterOutReplacedSentTransactions(latest) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 0eb93f71a..6131e1d31 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -27,6 +27,7 @@ class AppViewModel: ObservableObject { @Published var lnurlWithdrawData: LnurlWithdrawData? // Onboarding + @AppStorage("hasDismissedWidgetsOnboardingHint") var hasDismissedWidgetsOnboardingHint: Bool = false @AppStorage("hasSeenContactsIntro") var hasSeenContactsIntro: Bool = false @AppStorage("hasSeenProfileIntro") var hasSeenProfileIntro: Bool = false @AppStorage("hasSeenNotificationsIntro") var hasSeenNotificationsIntro: Bool = false @@ -37,9 +38,6 @@ class AppViewModel: ObservableObject { @AppStorage("hasSeenTransferToSavingsIntro") var hasSeenTransferToSavingsIntro: Bool = false @AppStorage("hasSeenWidgetsIntro") var hasSeenWidgetsIntro: Bool = false - // When to show empty state UI - @AppStorage("showHomeViewEmptyState") var showHomeViewEmptyState: Bool = false - // App update tracking @AppStorage("appUpdateIgnoreTimestamp") var appUpdateIgnoreTimestamp: TimeInterval = 0 @@ -58,10 +56,6 @@ class AppViewModel: ObservableObject { // This prevents flashing error status during startup/background transitions @Published var appStatusInit: Bool = false - func showAllEmptyStates(_ show: Bool) { - showHomeViewEmptyState = show - } - /// Called when node reaches running state func markAppStatusInit() { appStatusInit = true @@ -220,7 +214,7 @@ class AppViewModel: ObservableObject { hasSeenTransferToSpendingIntro = false hasSeenTransferToSavingsIntro = false hasSeenWidgetsIntro = false - showHomeViewEmptyState = false + hasDismissedWidgetsOnboardingHint = false appUpdateIgnoreTimestamp = 0 backupVerified = false backupIgnoreTimestamp = 0 diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index 22b6e9b83..9ec8119f3 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -471,7 +471,7 @@ class SettingsViewModel: NSObject, ObservableObject { hasSeenTransferToSpendingIntro: defaults.bool(forKey: "hasSeenTransferToSpendingIntro"), hasSeenTransferToSavingsIntro: defaults.bool(forKey: "hasSeenTransferToSavingsIntro"), hasSeenWidgetsIntro: defaults.bool(forKey: "hasSeenWidgetsIntro"), - showHomeViewEmptyState: defaults.bool(forKey: "showHomeViewEmptyState"), + hasDismissedWidgetsOnboardingHint: defaults.bool(forKey: "hasDismissedWidgetsOnboardingHint"), appUpdateIgnoreTimestamp: defaults.double(forKey: "appUpdateIgnoreTimestamp"), backupIgnoreTimestamp: defaults.double(forKey: "backupIgnoreTimestamp"), highBalanceIgnoreCount: defaults.integer(forKey: "highBalanceIgnoreCount"), @@ -492,7 +492,7 @@ class SettingsViewModel: NSObject, ObservableObject { defaults.set(cache.hasSeenTransferToSpendingIntro, forKey: "hasSeenTransferToSpendingIntro") defaults.set(cache.hasSeenTransferToSavingsIntro, forKey: "hasSeenTransferToSavingsIntro") defaults.set(cache.hasSeenWidgetsIntro, forKey: "hasSeenWidgetsIntro") - defaults.set(cache.showHomeViewEmptyState, forKey: "showHomeViewEmptyState") + defaults.set(cache.hasDismissedWidgetsOnboardingHint, forKey: "hasDismissedWidgetsOnboardingHint") defaults.set(cache.appUpdateIgnoreTimestamp, forKey: "appUpdateIgnoreTimestamp") defaults.set(cache.backupIgnoreTimestamp, forKey: "backupIgnoreTimestamp") defaults.set(cache.highBalanceIgnoreCount, forKey: "highBalanceIgnoreCount") diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index 3c80b1f07..5a1da4669 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -151,8 +151,27 @@ enum WidgetType: String, CaseIterable, Codable { case weather } +// MARK: - Widgets tab row (suggestions section or a widget) + +/// A single row in the widgets tab: either the suggestions section or a widget. +enum WidgetsTabRow: Identifiable { + case suggestions + case widget(Widget) + + var id: String { + switch self { + case .suggestions: + return "suggestions" + case let .widget(widget): + return widget.type.rawValue + } + } +} + // MARK: - WidgetsViewModel +private let widgetsTabLayoutOrderKey = "widgetsTabLayoutOrder" + @MainActor class WidgetsViewModel: ObservableObject { @Published var savedWidgets: [Widget] = [] @@ -163,17 +182,49 @@ class WidgetsViewModel: ObservableObject { // In-memory storage for saved widgets with options private var savedWidgetsWithOptions: [SavedWidget] = [] + /// Order of widgets tab rows: "suggestions" or WidgetType.rawValue. Persisted separately so suggestions can sit between widgets. + @Published private(set) var layoutOrder: [String] = [] + // Default widgets for new installs and resets private static let defaultSavedWidgets: [SavedWidget] = [ SavedWidget(type: .price), - SavedWidget(type: .news), SavedWidget(type: .blocks), ] + /// Default layout: suggestions first, then default widget types. + private static var defaultLayoutOrder: [String] { + ["suggestions"] + defaultSavedWidgets.map(\.type.rawValue) + } + init() { + loadLayoutOrder() loadSavedWidgets() } + /// Rows to display in the widgets tab (suggestions + widgets) in the user's order. + var orderedRows: [WidgetsTabRow] { + let widgetTypesInOrder = layoutOrder.compactMap { id -> WidgetType? in + id == "suggestions" ? nil : WidgetType(rawValue: id) + } + let validWidgetTypes = widgetTypesInOrder.filter { type in savedWidgets.contains { $0.type == type } } + let orderedWidgets = validWidgetTypes.compactMap { type in savedWidgets.first { $0.type == type } } + var result: [WidgetsTabRow] = [] + for id in layoutOrder { + if id == "suggestions" { + result.append(.suggestions) + } else if let widget = orderedWidgets.first(where: { $0.type.rawValue == id }) { + result.append(.widget(widget)) + } + } + for widget in savedWidgets where !layoutOrder.contains(widget.type.rawValue) { + result.append(.widget(widget)) + } + if !layoutOrder.contains("suggestions") { + result.insert(.suggestions, at: 0) + } + return result + } + // MARK: - Public Methods /// Check if a widget type is already saved @@ -189,6 +240,10 @@ class WidgetsViewModel: ObservableObject { let newSavedWidget = SavedWidget(type: type) savedWidgetsWithOptions.append(newSavedWidget) savedWidgets.append(newSavedWidget.toWidget()) + if !layoutOrder.contains(type.rawValue) { + layoutOrder.append(type.rawValue) + persistLayoutOrder() + } persistSavedWidgets() } @@ -196,29 +251,37 @@ class WidgetsViewModel: ObservableObject { func deleteWidget(_ type: WidgetType) { savedWidgetsWithOptions.removeAll { $0.type == type } savedWidgets.removeAll { $0.type == type } + layoutOrder.removeAll { $0 == type.rawValue } + persistLayoutOrder() persistSavedWidgets() } - /// Reorder widgets - func reorderWidgets(from sourceIndex: Int, to destinationIndex: Int) { + /// Reorder the widgets tab list (suggestions + widgets). Updates layout order and, when a widget is moved, savedWidgets order. + func reorderWidgetsTab(from sourceIndex: Int, to destinationIndex: Int) { + let rows = orderedRows guard sourceIndex != destinationIndex, - sourceIndex >= 0, sourceIndex < savedWidgets.count, - destinationIndex >= 0, destinationIndex < savedWidgets.count + sourceIndex >= 0, sourceIndex < rows.count, + destinationIndex >= 0, destinationIndex < rows.count else { return } - let savedWidget = savedWidgetsWithOptions.remove(at: sourceIndex) - savedWidgetsWithOptions.insert(savedWidget, at: destinationIndex) - - let widget = savedWidgets.remove(at: sourceIndex) - savedWidgets.insert(widget, at: destinationIndex) + let moved = rows[sourceIndex] + var newOrder = rows.map(\.id) + newOrder.remove(at: sourceIndex) + newOrder.insert(moved.id, at: destinationIndex) + layoutOrder = newOrder + persistLayoutOrder() - persistSavedWidgets() + if case .widget = moved { + syncSavedWidgetsOrderFromLayoutOrder() + } } /// Clear all persisted widgets and restore defaults func clearWidgets() { savedWidgetsWithOptions = WidgetsViewModel.defaultSavedWidgets savedWidgets = savedWidgetsWithOptions.map { $0.toWidget() } + layoutOrder = Self.defaultLayoutOrder + persistLayoutOrder() persistSavedWidgets() } @@ -304,6 +367,54 @@ class WidgetsViewModel: ObservableObject { savedWidgets = savedWidgetsWithOptions.map { $0.toWidget() } persistSavedWidgets() } + syncLayoutOrderFromSavedWidgets() + } + + private func loadLayoutOrder() { + guard let data = UserDefaults.standard.data(forKey: widgetsTabLayoutOrderKey), + let decoded = try? JSONDecoder().decode([String].self, from: data) + else { + layoutOrder = Self.defaultLayoutOrder + persistLayoutOrder() + return + } + layoutOrder = decoded + } + + private func persistLayoutOrder() { + guard let data = try? JSONEncoder().encode(layoutOrder) else { return } + UserDefaults.standard.set(data, forKey: widgetsTabLayoutOrderKey) + } + + /// Ensure layoutOrder contains all current saved widget types and "suggestions"; append missing ids. + private func syncLayoutOrderFromSavedWidgets() { + let currentIds = Set(layoutOrder) + let widgetIds = Set(savedWidgets.map(\.type.rawValue)) + var needSync = false + if !currentIds.contains("suggestions") { + layoutOrder.insert("suggestions", at: 0) + needSync = true + } + for type in savedWidgets.map(\.type) { + if !currentIds.contains(type.rawValue) { + layoutOrder.append(type.rawValue) + needSync = true + } + } + layoutOrder = layoutOrder.filter { $0 == "suggestions" || widgetIds.contains($0) } + if needSync { persistLayoutOrder() } + } + + /// Update savedWidgets order to match the order of widget ids in layoutOrder. + private func syncSavedWidgetsOrderFromLayoutOrder() { + let widgetIdsInOrder = layoutOrder.compactMap { id -> WidgetType? in + id == "suggestions" ? nil : WidgetType(rawValue: id) + } + let ordered = widgetIdsInOrder.compactMap { type in savedWidgetsWithOptions.first(where: { $0.type == type }) } + let remaining = savedWidgetsWithOptions.filter { w in !widgetIdsInOrder.contains(w.type) } + savedWidgetsWithOptions = ordered + remaining + savedWidgets = savedWidgetsWithOptions.map { $0.toWidget() } + persistSavedWidgets() } private func persistSavedWidgets() { diff --git a/Bitkit/Views/Home/HomeWalletView.swift b/Bitkit/Views/Home/HomeWalletView.swift new file mode 100644 index 000000000..b102022b2 --- /dev/null +++ b/Bitkit/Views/Home/HomeWalletView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct HomeWalletView: View { + @EnvironmentObject var activity: ActivityListViewModel + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var settings: SettingsViewModel + @EnvironmentObject var wallet: WalletViewModel + + var hasActivity: Bool { + return activity.latestActivities?.isEmpty == false + } + + private var topPadding: CGFloat { windowSafeAreaInsets.top + 48 + 16 } // safe area + header + spacing + private var bottomPadding: CGFloat { windowSafeAreaInsets.bottom + 64 + 32 } // safe area + tab bar + spacing + + var body: some View { + VStack(spacing: 0) { + MoneyStack( + sats: wallet.totalBalanceSats, + showSymbol: true, + showEyeIcon: true, + enableSwipeGesture: settings.swipeBalanceToHide, + enableHide: true + ) + .padding(.bottom, 32) + + HStack(spacing: 16) { + NavigationLink(value: Route.savingsWallet) { + WalletBalanceView(type: .onchain, sats: UInt64(wallet.totalOnchainSats)) + } + + CustomDivider(color: .gray4, type: .vertical) + + NavigationLink(value: Route.spendingWallet) { + WalletBalanceView(type: .lightning, sats: UInt64(wallet.totalLightningSats)) + } + } + .frame(height: 50) + .padding(.bottom, 32) + + if hasActivity { + ActivityLatest() + + Spacer() + + if settings.showWidgets, !app.hasDismissedWidgetsOnboardingHint { + WidgetsOnboardingView() + } + } else { + Spacer() + WalletOnboardingView(type: .home) + } + } + .padding(.top, topPadding) + .padding(.bottom, bottomPadding) + .padding(.horizontal) + .animation(.spring(response: 0.3), value: hasActivity) + } +} diff --git a/Bitkit/Views/Home/HomeWidgetsView.swift b/Bitkit/Views/Home/HomeWidgetsView.swift new file mode 100644 index 000000000..aba0bcc7f --- /dev/null +++ b/Bitkit/Views/Home/HomeWidgetsView.swift @@ -0,0 +1,123 @@ +import SwiftUI + +struct HomeWidgetsView: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var widgets: WidgetsViewModel + @Binding var isEditingWidgets: Bool + + private var topPadding: CGFloat { windowSafeAreaInsets.top + 48 + 16 } // safe area + header + spacing + private var bottomPadding: CGFloat { windowSafeAreaInsets.bottom + 64 + 32 } // safe area + tab bar + spacing + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + DraggableList( + widgets.orderedRows, + id: \.id, + enableDrag: isEditingWidgets, + itemHeight: 80, + onReorder: { sourceIndex, destinationIndex in + widgets.reorderWidgetsTab(from: sourceIndex, to: destinationIndex) + } + ) { row in + rowContent(row) + } + .id(widgets.orderedRows.map(\.id)) + + CustomButton(title: t("widgets__add"), variant: .tertiary) { + if app.hasSeenWidgetsIntro { + navigation.navigate(.widgetsList) + } else { + navigation.navigate(.widgetsIntro) + } + } + .padding(.top, 16) + .accessibilityIdentifier("WidgetsAdd") + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.top, topPadding) + .padding(.bottom, bottomPadding) + .padding(.horizontal) + } + + @ViewBuilder + private func rowContent(_ row: WidgetsTabRow) -> some View { + switch row { + case .suggestions: + if isEditingWidgets { + SuggestionsEditRow() + } else { + Suggestions() + } + case let .widget(widget): + WidgetViewWrapper(widget: widget, isEditing: isEditingWidgets) { + withAnimation { + isEditingWidgets = false + } + } + } + } +} + +/// Wraps a widget and forwards view model + edit state to the widget's view builder. +private struct WidgetViewWrapper: View { + let widget: Widget + let isEditing: Bool + let onEditingEnd: (() -> Void)? + + @EnvironmentObject private var widgets: WidgetsViewModel + + var body: some View { + widget.view(widgetsViewModel: widgets, isEditing: isEditing, onEditingEnd: onEditingEnd) + } +} + +/// Collapsed suggestions row shown in edit mode. Matches widget edit layout with delete/edit disabled. +private struct SuggestionsEditRow: View { + var body: some View { + Button {} label: { + HStack(spacing: 16) { + Image("suggestions-widget") + .resizable() + .frame(width: 32, height: 32) + + BodyMSBText(t("cards__suggestions")) + .lineLimit(1) + + Spacer() + + HStack(spacing: 8) { + Image("trash") + .resizable() + .foregroundColor(.textPrimary) + .frame(width: 24, height: 24) + .frame(width: 32, height: 32) + Image("gear-six") + .resizable() + .foregroundColor(.textPrimary) + .frame(width: 24, height: 24) + .frame(width: 32, height: 32) + Image("burger") + .resizable() + .foregroundColor(.textPrimary) + .frame(width: 24, height: 24) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + .overlay { + Color.clear + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + .trackDragHandle() + } + } + } + .contentShape(Rectangle()) + } + .buttonStyle(WidgetButtonStyle()) + .frame(maxWidth: .infinity) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + .accessibilityIdentifier("SuggestionsWidget") + } +} diff --git a/Bitkit/Views/HomeScreen.swift b/Bitkit/Views/HomeScreen.swift new file mode 100644 index 000000000..81267a327 --- /dev/null +++ b/Bitkit/Views/HomeScreen.swift @@ -0,0 +1,85 @@ +import SwiftUI + +struct HomeScreen: View { + @EnvironmentObject var activity: ActivityListViewModel + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var settings: SettingsViewModel + @EnvironmentObject var wallet: WalletViewModel + + @State private var scrollPosition: Int? = 0 + @State private var isEditingWidgets = false + + private var currentPage: Int { scrollPosition ?? 0 } + private var topPadding: CGFloat { windowSafeAreaInsets.top + 48 + 16 } // safe area + header + spacing + private var bottomPadding: CGFloat { windowSafeAreaInsets.bottom + 64 + 32 } // safe area + tab bar + spacing + + var body: some View { + ZStack(alignment: .top) { + Header(showWidgetEditButton: currentPage == 1, isEditingWidgets: $isEditingWidgets) + + GeometryReader { geometry in + ScrollView(.vertical, showsIndicators: false) { + LazyVStack { + HomeWalletView() + .containerRelativeFrame(.vertical) + .id(0) + + if settings.showWidgets { + HomeWidgetsView(isEditingWidgets: $isEditingWidgets) + .frame(minHeight: geometry.size.height) + .id(1) + } + } + .scrollTargetLayout() + } + .scrollTargetBehavior(.paging) + .scrollPosition(id: $scrollPosition) + .onChange(of: scrollPosition) { _, newValue in + // Dismiss widgets onboarding hint the first time user scrolls to widgets + if newValue == 1 { + app.hasDismissedWidgetsOnboardingHint = true + } + } + .refreshable { + guard currentPage == 0 else { return } + guard wallet.nodeLifecycleState == .running else { return } + do { + try await wallet.sync() + try await activity.syncLdkNodePayments() + } catch { + app.toast(error) + } + } + } + .ignoresSafeArea() + + // Top and bottom gradients + VStack(spacing: 0) { + LinearGradient( + colors: [.black, .black.opacity(0)], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: topPadding) + + Spacer() + + LinearGradient( + colors: [.black.opacity(0), .black], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: bottomPadding) + } + .ignoresSafeArea() + .allowsHitTesting(false) + } + .navigationBarHidden(true) + .onAppear { + TimedSheetManager.shared.onHomeScreenEntered() + } + .onDisappear { + TimedSheetManager.shared.onHomeScreenExited() + } + } +} diff --git a/Bitkit/Views/Onboarding/CreateWalletView.swift b/Bitkit/Views/Onboarding/CreateWalletView.swift index 4444f45e2..4f73a5ed0 100644 --- a/Bitkit/Views/Onboarding/CreateWalletView.swift +++ b/Bitkit/Views/Onboarding/CreateWalletView.swift @@ -32,7 +32,6 @@ struct CreateWalletView: View { CustomButton(title: t("onboarding__new_wallet")) { do { wallet.nodeLifecycleState = .initializing - app.showAllEmptyStates(true) _ = try StartupHandler.createNewWallet(bip39Passphrase: nil) try wallet.setWalletExistsState() } catch { diff --git a/Bitkit/Views/Onboarding/CreateWalletWithPassphraseView.swift b/Bitkit/Views/Onboarding/CreateWalletWithPassphraseView.swift index 253ac8241..d04ec8bbf 100644 --- a/Bitkit/Views/Onboarding/CreateWalletWithPassphraseView.swift +++ b/Bitkit/Views/Onboarding/CreateWalletWithPassphraseView.swift @@ -62,7 +62,6 @@ struct CreateWalletWithPassphraseView: View { private func createWallet() { do { wallet.nodeLifecycleState = .initializing - app.showAllEmptyStates(true) _ = try StartupHandler.createNewWallet(bip39Passphrase: bip39Passphrase) try wallet.setWalletExistsState() } catch { diff --git a/Bitkit/Views/Onboarding/OnboardingSlider.swift b/Bitkit/Views/Onboarding/OnboardingSlider.swift index c7c310f96..718fdfa16 100644 --- a/Bitkit/Views/Onboarding/OnboardingSlider.swift +++ b/Bitkit/Views/Onboarding/OnboardingSlider.swift @@ -31,24 +31,6 @@ private struct OnboardingToolbar: View { } } -private struct Dots: View { - var currentTab: Int - - var body: some View { - VStack { - Spacer() - HStack(spacing: 8) { - ForEach(0 ..< 4) { index in - Circle() - .fill(currentTab == index ? Color.textPrimary : Color.white32) - .frame(width: 8, height: 8) - } - } - .animation(.easeInOut(duration: 0.3), value: currentTab) - } - } -} - struct OnboardingSlider: View { @EnvironmentObject var app: AppViewModel @State var currentTab = 0 @@ -99,7 +81,7 @@ struct OnboardingSlider: View { } if currentTab != 3 { - Dots(currentTab: currentTab) + TabViewDots(numberOfTabs: 4, currentTab: currentTab) } } .navigationBarHidden(true) diff --git a/Bitkit/Views/Onboarding/RestoreWalletView.swift b/Bitkit/Views/Onboarding/RestoreWalletView.swift index 86051f633..dfa2aec91 100644 --- a/Bitkit/Views/Onboarding/RestoreWalletView.swift +++ b/Bitkit/Views/Onboarding/RestoreWalletView.swift @@ -256,7 +256,6 @@ struct RestoreWalletView: View { do { wallet.nodeLifecycleState = .initializing wallet.isRestoringWallet = true - app.showAllEmptyStates(false) _ = try StartupHandler.restoreWallet(mnemonic: bip39Mnemonic, bip39Passphrase: bip39Passphrase) try wallet.setWalletExistsState() } catch { diff --git a/Bitkit/Views/Settings/Advanced/CoinSelectionSettingsView.swift b/Bitkit/Views/Settings/Advanced/CoinSelectionSettingsView.swift index 94c45eec5..6ec2849b3 100644 --- a/Bitkit/Views/Settings/Advanced/CoinSelectionSettingsView.swift +++ b/Bitkit/Views/Settings/Advanced/CoinSelectionSettingsView.swift @@ -66,7 +66,7 @@ struct CoinSelectionMethodOption: View { BodyMText(method.localizedTitle, textColor: .textPrimary) Spacer() if isSelected { - Image("checkmark") + Image("check-mark") .resizable() .frame(width: 32, height: 32) .foregroundColor(.brandAccent) @@ -91,7 +91,7 @@ struct CoinSelectionAlgorithmOption: View { BodyMText(algorithm.localizedTitle, textColor: .textPrimary) Spacer() if isSelected { - Image("checkmark") + Image("check-mark") .resizable() .frame(width: 32, height: 32) .foregroundColor(.brandAccent) diff --git a/Bitkit/Views/Settings/General/WidgetsSettingsView.swift b/Bitkit/Views/Settings/General/WidgetsSettingsView.swift index 1ff458be9..30e5a99b2 100644 --- a/Bitkit/Views/Settings/General/WidgetsSettingsView.swift +++ b/Bitkit/Views/Settings/General/WidgetsSettingsView.swift @@ -6,6 +6,7 @@ struct WidgetsSettingsView: View { 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) { @@ -19,11 +20,11 @@ struct WidgetsSettingsView: View { toggle: $settings.showWidgetTitles ) } + .padding(.horizontal, 16) + .bottomSafeAreaPadding() } } .navigationBarHidden(true) - .padding(.horizontal, 16) - .bottomSafeAreaPadding() } } diff --git a/Bitkit/Views/Sheets/Sheet.swift b/Bitkit/Views/Sheets/Sheet.swift index 45c1ba8fa..c6225f08b 100644 --- a/Bitkit/Views/Sheets/Sheet.swift +++ b/Bitkit/Views/Sheets/Sheet.swift @@ -5,23 +5,19 @@ enum SheetSize { var height: CGFloat { let screenHeight = UIScreen.screenHeight - let safeAreaInsets = - UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .first?.windows.first?.safeAreaInsets ?? .zero + let safeAreaInsets = windowSafeAreaInsets let headerHeight: CGFloat = 48 let balanceHeight: CGFloat = 70 let spacing: CGFloat = 16 - let safeArea = safeAreaInsets.top + safeAreaInsets.bottom - let headerSpacing = safeArea + headerHeight - spacing + let headerSpacing = safeAreaInsets.top + headerHeight + spacing let balanceSpacing = headerSpacing + balanceHeight + spacing * 2 switch self { case .small: return 400 case .medium: - let minHeight: CGFloat = 600 + let minHeight: CGFloat = 616 // Header + Balance visible let preferredHeight = screenHeight - balanceSpacing if preferredHeight < minHeight { @@ -31,7 +27,7 @@ enum SheetSize { } return preferredHeight case .calendar: - let minHeight: CGFloat = 600 + let minHeight: CGFloat = 616 // same as medium + 40px, to be just under search input let preferredHeight = screenHeight - balanceSpacing + 40 if preferredHeight < minHeight { @@ -41,7 +37,7 @@ enum SheetSize { } return preferredHeight case .large: - let minHeight: CGFloat = 600 + let minHeight: CGFloat = 616 // Only Header visible let preferredHeight = screenHeight - headerSpacing return max(minHeight, preferredHeight) diff --git a/Bitkit/Views/Wallets/Activity/ActivityLatest.swift b/Bitkit/Views/Wallets/Activity/ActivityLatest.swift index 85e9a29aa..49d5be6a0 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityLatest.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityLatest.swift @@ -3,7 +3,6 @@ import SwiftUI struct ActivityLatest: View { @EnvironmentObject private var activity: ActivityListViewModel @EnvironmentObject private var navigation: NavigationViewModel - @EnvironmentObject private var sheets: SheetViewModel @EnvironmentObject private var wallet: WalletViewModel private var shouldShowBanner: Bool { @@ -32,10 +31,6 @@ struct ActivityLatest: View { var body: some View { VStack(spacing: 0) { - CaptionMText(t("wallet__activity")) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.bottom, 16) - if shouldShowBanner { ActivityBanner(type: bannerType, remainingDuration: remainingDuration) .padding(.bottom, 16) @@ -52,23 +47,10 @@ struct ActivityLatest: View { } } - if items.isEmpty { - Button( - action: { - sheets.showSheet(.receive) - }, - label: { - EmptyActivityRow() - } - ) - } else { - CustomButton(title: t("common__show_all"), variant: .tertiary) { - navigation.navigate(.activityList) - } - .accessibilityIdentifier("ActivityShowAll") + CustomButton(title: t("common__show_all"), variant: .tertiary) { + navigation.navigate(.activityList) } - } else { - EmptyView() + .accessibilityIdentifier("ActivityShowAll") } } .animation(.spring(response: 0.4, dampingFraction: 0.8), value: shouldShowBanner) diff --git a/Bitkit/Views/Wallets/Activity/EmptyActivityRow.swift b/Bitkit/Views/Wallets/Activity/EmptyActivityRow.swift deleted file mode 100644 index d911b8c6f..000000000 --- a/Bitkit/Views/Wallets/Activity/EmptyActivityRow.swift +++ /dev/null @@ -1,26 +0,0 @@ -import SwiftUI - -struct EmptyActivityRow: View { - var body: some View { - HStack(spacing: 16) { - CircularIcon( - icon: "activity", - iconColor: .yellowAccent, - backgroundColor: .yellow16 - ) - - VStack(alignment: .leading, spacing: 4) { - BodyMSBText(t("wallet__activity_no")) - CaptionBText(t("wallet__activity_no_explain")) - } - - Spacer() - } - } -} - -#Preview { - EmptyActivityRow() - .padding() - .preferredColorScheme(.dark) -} diff --git a/Bitkit/Views/Wallets/HomeView.swift b/Bitkit/Views/Wallets/HomeView.swift deleted file mode 100644 index 3e7238a6b..000000000 --- a/Bitkit/Views/Wallets/HomeView.swift +++ /dev/null @@ -1,168 +0,0 @@ -import SwiftUI - -struct HomeView: View { - @EnvironmentObject var activity: ActivityListViewModel - @EnvironmentObject var app: AppViewModel - @EnvironmentObject var currency: CurrencyViewModel - @EnvironmentObject var settings: SettingsViewModel - @EnvironmentObject var wallet: WalletViewModel - - @State private var isEditingWidgets = false - - var body: some View { - ZStack(alignment: .top) { - ScrollView(showsIndicators: false) { - MoneyStack( - sats: wallet.totalBalanceSats, - showSymbol: true, - showEyeIcon: true, - enableSwipeGesture: settings.swipeBalanceToHide, - enableHide: true - ) - .padding(.top, 16 + 48) - .padding(.horizontal, 16) - - if !app.showHomeViewEmptyState || wallet.totalBalanceSats > 0 { - VStack(spacing: 0) { - HStack(spacing: 0) { - NavigationLink(value: Route.savingsWallet) { - WalletBalanceView( - type: .onchain, - sats: UInt64(wallet.totalOnchainSats), - amountTestIdentifier: "ActivitySavings" - ) - } - - CustomDivider() - .frame(width: 1, height: 50) - .background(Color.gray4) - .padding(.horizontal, 16) - - NavigationLink(value: Route.spendingWallet) { - WalletBalanceView( - type: .lightning, - sats: UInt64(wallet.totalLightningSats), - amountTestIdentifier: "ActivitySpending" - ) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 28) - .padding(.horizontal) - - Suggestions() - - if settings.showWidgets { - Widgets(isEditing: $isEditingWidgets) - .padding(.top, 32) - .padding(.horizontal) - } - - ActivityLatest() - .padding(.top, 32) - .padding(.horizontal) - } - /// Leave some space for TabBar - .padding(.bottom, 130) - } - } - - // Gradients layer - VStack(spacing: 0) { - // Top gradient: black 100% to black 0% - LinearGradient( - colors: [.black, .black.opacity(0)], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 140) - - Spacer() - - // Bottom gradient: black 0% to black 100% - LinearGradient( - colors: [.black.opacity(0), .black], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 140) - } - .ignoresSafeArea() - .allowsHitTesting(false) - - // Header on top - Header() - } - /// Dismiss (calculator widget) keyboard when scrolling - .scrollDismissesKeyboard(.immediately) - .animation(.spring(response: 0.3), value: app.showHomeViewEmptyState) - .overlay { - if wallet.totalBalanceSats == 0 && app.showHomeViewEmptyState { - EmptyStateView( - type: .home, - onClose: { - withAnimation(.spring(response: 0.3)) { - app.showHomeViewEmptyState = false - } - } - ) - .padding(.horizontal) - } - } - .animation(.spring(response: 0.3), value: app.showHomeViewEmptyState) - .onChange(of: wallet.totalBalanceSats) { newValue in - if newValue > 0 && app.showHomeViewEmptyState { - withAnimation(.spring(response: 0.3)) { - app.showHomeViewEmptyState = false - } - } - } - .refreshable { - // Always refresh currency rates - needed for balance display - await currency.refresh() - - guard wallet.nodeLifecycleState == .running else { - return - } - do { - try await wallet.sync() - try await activity.syncLdkNodePayments() - } catch { - app.toast(error) - } - } - .navigationBarHidden(true) - .accentColor(.white) - .onAppear { - if Env.isPreview { - app.showHomeViewEmptyState = true - } - - // Notify timed sheet manager that user is on home screen - TimedSheetManager.shared.onHomeScreenEntered() - } - .onDisappear { - // Notify timed sheet manager that user left home screen - TimedSheetManager.shared.onHomeScreenExited() - } - .gesture( - DragGesture() - .onEnded { value in - if value.startLocation.x > UIScreen.main.bounds.width * 0.8 && value.translation.width < -50 { - withAnimation { - app.showDrawer = true - } - } - } - ) - } -} - -#Preview { - HomeView() - .environmentObject(ActivityListViewModel()) - .environmentObject(AppViewModel()) - .environmentObject(SettingsViewModel.shared) - .environmentObject(WalletViewModel()) - .preferredColorScheme(.dark) -} diff --git a/Bitkit/Views/Wallets/SavingsWalletView.swift b/Bitkit/Views/Wallets/SavingsWalletView.swift index a6620fa2c..d964805c3 100644 --- a/Bitkit/Views/Wallets/SavingsWalletView.swift +++ b/Bitkit/Views/Wallets/SavingsWalletView.swift @@ -6,6 +6,8 @@ struct SavingsWalletView: View { @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var wallet: WalletViewModel + private var bottomPadding: CGFloat { 64 + 32 } // tab bar + spacing + /// Calculate remaining duration for force close transfers private var forceCloseRemainingDuration: String? { guard let claimableAtHeight = wallet.forceCloseClaimableAtHeight, @@ -22,6 +24,7 @@ struct SavingsWalletView: View { ZStack(alignment: .top) { VStack(spacing: 0) { NavigationBar(title: t("wallet__savings__title"), icon: "btc") + .padding(.bottom, 16) MoneyStack( sats: wallet.totalOnchainSats, @@ -31,7 +34,6 @@ struct SavingsWalletView: View { enableHide: true, testIdPrefix: "TotalBalance" ) - .padding(.top) if wallet.balanceInTransferToSavings > 0 { IncomingTransfer( @@ -55,8 +57,7 @@ struct SavingsWalletView: View { CustomButton(title: t("common__show_all"), variant: .tertiary) { navigation.navigate(.activityList) } - /// Leave some space for TabBar - .padding(.bottom, 130) + .padding(.bottom, bottomPadding) } .accessibilityIdentifier("HomeScrollView") .refreshable { @@ -69,10 +70,14 @@ struct SavingsWalletView: View { } .frame(maxWidth: .infinity, minHeight: 400) .transition(.move(edge: .leading).combined(with: .opacity)) + } else { + Spacer() + WalletOnboardingView(type: .savings) + .padding(.bottom, bottomPadding) + .transition(.move(edge: .trailing).combined(with: .opacity)) } } .padding(.horizontal) - .frame(maxHeight: .infinity, alignment: .top) .background(alignment: .topTrailing) { Image("piggybank") .resizable() @@ -95,13 +100,6 @@ struct SavingsWalletView: View { } .navigationBarHidden(true) .animation(.spring(response: 0.3), value: wallet.totalOnchainSats) - .overlay { - if wallet.totalOnchainSats == 0 { - EmptyStateView(type: .savings) - .padding(.horizontal) - .transition(.move(edge: .trailing).combined(with: .opacity)) - } - } } var transferButton: some View { diff --git a/Bitkit/Views/Wallets/SpendingWalletView.swift b/Bitkit/Views/Wallets/SpendingWalletView.swift index 3f9667c3e..ccb79a37e 100644 --- a/Bitkit/Views/Wallets/SpendingWalletView.swift +++ b/Bitkit/Views/Wallets/SpendingWalletView.swift @@ -6,6 +6,8 @@ struct SpendingWalletView: View { @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var wallet: WalletViewModel + private var bottomPadding: CGFloat { 64 + 32 } // tab bar + spacing + var body: some View { ZStack(alignment: .top) { VStack(spacing: 0) { @@ -40,8 +42,7 @@ struct SpendingWalletView: View { CustomButton(title: t("common__show_all"), variant: .tertiary) { navigation.navigate(.activityList) } - /// Leave some space for TabBar - .padding(.bottom, 130) + .padding(.bottom, bottomPadding) } .accessibilityIdentifier("HomeScrollView") .refreshable { @@ -54,10 +55,14 @@ struct SpendingWalletView: View { } .frame(maxWidth: .infinity, minHeight: 400) .transition(.move(edge: .leading).combined(with: .opacity)) + } else { + Spacer() + WalletOnboardingView(type: .spending) + .padding(.bottom, bottomPadding) + .transition(.move(edge: .trailing).combined(with: .opacity)) } } .padding(.horizontal) - .frame(maxHeight: .infinity, alignment: .top) .background(alignment: .topTrailing) { Image("coin-stack-x-2") .resizable() @@ -80,13 +85,6 @@ struct SpendingWalletView: View { } .navigationBarHidden(true) .animation(.spring(response: 0.3), value: wallet.totalLightningSats) - .overlay { - if wallet.totalLightningSats == 0 { - EmptyStateView(type: .spending) - .padding(.horizontal) - .transition(.move(edge: .trailing).combined(with: .opacity)) - } - } } var transferButton: some View { From 58c210900a7941edaf2592b05d06a483a52b8b66 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Fri, 13 Feb 2026 19:36:00 +0100 Subject: [PATCH 3/4] feat(widgets): convert suggestions to widget --- .../suggestions-widget.pdf | Bin 11437 -> 13536 bytes Bitkit/Components/Home/Suggestions.swift | 12 +- Bitkit/Components/Widgets/BaseWidget.swift | 11 +- .../Widgets/SuggestionsWidget.swift | 19 +++ .../Components/Widgets/WidgetListItem.swift | 13 +- .../Localization/en.lproj/Localizable.strings | 3 + Bitkit/Utilities/WidgetsBackupConverter.swift | 4 +- Bitkit/ViewModels/WidgetsViewModel.swift | 135 +++--------------- Bitkit/Views/Home/HomeWidgetsView.swift | 59 +------- Bitkit/Views/HomeScreen.swift | 8 +- Bitkit/Views/Widgets/WidgetDetailView.swift | 4 +- Bitkit/Views/Widgets/WidgetEditLogic.swift | 14 +- Bitkit/Views/Widgets/WidgetEditModels.swift | 2 +- Bitkit/Views/Widgets/WidgetsListView.swift | 27 +++- 14 files changed, 114 insertions(+), 197 deletions(-) create mode 100644 Bitkit/Components/Widgets/SuggestionsWidget.swift diff --git a/Bitkit/Assets.xcassets/icons/suggestions-widget.imageset/suggestions-widget.pdf b/Bitkit/Assets.xcassets/icons/suggestions-widget.imageset/suggestions-widget.pdf index 0e0dc96d90641db96790fb5b6860f8002b33dbef..5ada45adf035a74e7beaf4385eeb1023660f631a 100644 GIT binary patch delta 3493 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 Doo57> delta 1377 zcmaEmxi)eGKM#+wk)8nR?}mgbSHpX%Lr$AHJ>yH)FT#zhhJzZonY&Lt?ET$J8$ z@Q%Dk;Ib~3s)hwyGs5jBy3U;RwtvDSyW`vRKhAws+4rdG^UR&eJRE zqkY;}W;dt%Ed3U2(R9Ct+1+={SV74`1c=j|MAf{x8Clr+5_>&tKV8D9X`sv zzxKS-w){00o=)Fbt9dl8SnKY^D|xN^j$b-@*LnGiIm$Y$XrPr#-pvJ-csdxaoUgPPOZhtg50`Y&fnfn4OV;mI;h@T-{wrUP4^tn=Ngt< zxAt7$c5qAIJGpEtcgN*DD_EtrO32Q+pxHM0#Nr=0UGY2urV0xipD-pgtYmD}o%^vZ ztvo&}uQy4N>%GWJBcCOK0up)!ox67iNJwu>%8(O3v=Wwn^%0^$I`i_ zOzXH@!rM7wDR1QO${y4|_9e(I_@~6vJ@+|JeX?@RcrYWlFT=K`ruXb^kB%Vq`|i3K z9$|*IHZ==upT{kgWQ*((l?haIcT~OSRjz9|oAaiGZ}ywqYl?+`4?f%>zqfv03Sn;BYeej~P&8#Ou3RjR5tGSxGKq|aO~16WcuveYv)HZe0YRxr}DFf%na zw*aPIn53DW2`oXwgp#=s#vl~ta3Rb=h$2`Z*?NdINP=+pBy%A|;dbUA2}0dwu7I!x z;RCoaaG_)_xG8Y495*hwDR4nJ15*laLNZcd0j=5`t-`{K61bbQ4W${GG`S`Ra*Ir! zYqY7}*xb}o0TOU|yj)<00We?<(Z$S6%q%d)EHTxY8ey1cZe(JHuGiei45Y3eXrlt$ zVoOU?b99B4mKcsPG%zqix7g6Y*wh3~uc3j72^KLkB*(~seQao8W@3z?+1$`_a<+-A moTZVGL5zX9k+G49xuvFphB1o3%@me>VWv!@`IF diff --git a/Bitkit/Components/Home/Suggestions.swift b/Bitkit/Components/Home/Suggestions.swift index a4af1e6dd..599c2148b 100644 --- a/Bitkit/Components/Home/Suggestions.swift +++ b/Bitkit/Components/Home/Suggestions.swift @@ -161,6 +161,9 @@ extension SuggestionCardData { } struct Suggestions: View { + /// When true, show only two static cards and ignore taps (e.g. widget detail preview). + var isPreview: Bool = false + @EnvironmentObject var app: AppViewModel @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var sheets: SheetViewModel @@ -180,8 +183,12 @@ struct Suggestions: View { return .onchain } - /// Up to 4 cards for the current wallet state, in priority order; completed and dismissed cards are skipped and the next in the set is shown. + /// Up to 4 cards for the current wallet state, in priority order; completed and dismissed cards are skipped and the next in the set is shown. In + /// preview, exactly 2 fixed cards. private var filteredCards: [SuggestionCardData] { + if isPreview { + return Array(cards.prefix(2)) + } let orderedIds = suggestionOrderByState[walletSuggestionState] ?? [] var result: [SuggestionCardData] = [] for id in orderedIds { @@ -222,12 +229,13 @@ struct Suggestions: View { ) { ForEach(filteredCards) { card in SuggestionCard(data: card, onDismiss: { dismissCard(card) }) - .onTapGesture { onItemTap(card) } + .onTapGesture { if !isPreview { onItemTap(card) } } .accessibilityIdentifier("Suggestion-\(card.accessibilityId)") } .accessibilityElement(children: .contain) .accessibilityIdentifier("Suggestions") } + .allowsHitTesting(!isPreview) } } diff --git a/Bitkit/Components/Widgets/BaseWidget.swift b/Bitkit/Components/Widgets/BaseWidget.swift index 43f54cf9d..a29811a39 100644 --- a/Bitkit/Components/Widgets/BaseWidget.swift +++ b/Bitkit/Components/Widgets/BaseWidget.swift @@ -73,6 +73,9 @@ struct BaseWidget: View { /// Flag indicating if the widget is in editing mode var isEditing: Bool = false + /// When false, the widget content has no gray background (e.g. suggestions). + var hasBackground: Bool = true + /// Callback to signal when editing should end var onEditingEnd: (() -> Void)? @@ -101,11 +104,13 @@ struct BaseWidget: View { init( type: WidgetType, isEditing: Bool = false, + hasBackground: Bool = true, onEditingEnd: (() -> Void)? = nil, @ViewBuilder content: () -> Content ) { self.type = type self.isEditing = isEditing + self.hasBackground = hasBackground self.onEditingEnd = onEditingEnd self.content = content() } @@ -198,9 +203,9 @@ struct BaseWidget: View { .accessibilityIdentifier("\(type.rawValue.capitalized)Widget") .buttonStyle(WidgetButtonStyle()) .frame(maxWidth: .infinity) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) + .padding((hasBackground || isEditing) ? 16 : 0) + .background((hasBackground || isEditing) ? Color.gray6 : Color.clear) + .cornerRadius(hasBackground || isEditing ? 16 : 0) .alert( t("widgets__delete__title"), isPresented: $showDeleteDialog, diff --git a/Bitkit/Components/Widgets/SuggestionsWidget.swift b/Bitkit/Components/Widgets/SuggestionsWidget.swift new file mode 100644 index 000000000..8a7a95848 --- /dev/null +++ b/Bitkit/Components/Widgets/SuggestionsWidget.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct SuggestionsWidget: View { + var isEditing: Bool = false + var onEditingEnd: (() -> Void)? + /// When true, only two cards are shown and taps do nothing (e.g. detail preview). + var isPreview: Bool = false + + var body: some View { + BaseWidget( + type: .suggestions, + isEditing: isEditing, + hasBackground: false, + onEditingEnd: onEditingEnd + ) { + Suggestions(isPreview: isPreview) + } + } +} diff --git a/Bitkit/Components/Widgets/WidgetListItem.swift b/Bitkit/Components/Widgets/WidgetListItem.swift index 17b78ce9d..18f05ec4c 100644 --- a/Bitkit/Components/Widgets/WidgetListItem.swift +++ b/Bitkit/Components/Widgets/WidgetListItem.swift @@ -2,9 +2,15 @@ import SwiftUI struct WidgetListItem: View { let id: WidgetType + let isDisabled: Bool - @EnvironmentObject private var navigation: NavigationViewModel @EnvironmentObject private var currency: CurrencyViewModel + @EnvironmentObject private var navigation: NavigationViewModel + + init(id: WidgetType, isDisabled: Bool = false) { + self.id = id + self.isDisabled = isDisabled + } // Widget data computed from the ID private var widget: (name: String, description: String, icon: String) { @@ -19,6 +25,10 @@ struct WidgetListItem: View { } private func onPress() { + if isDisabled { + return + } + navigation.navigate(.widgetDetail(id)) } @@ -56,6 +66,7 @@ struct WidgetListItem: View { } } .buttonStyle(PlainButtonStyle()) + .opacity(isDisabled ? 0.3 : 1) .accessibilityIdentifier("WidgetListItem-\(id.rawValue)") } } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 3249eaad0..e75b56297 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1224,6 +1224,7 @@ "widgets__widget__edit_description" = "Please select which fields you want to display in the {name} widget."; "widgets__widget__source" = "Source"; "widgets__add" = "Add Widget"; +"widgets__list__button" = "Enable In Settings"; "widgets__delete__title" = "Delete Widget?"; "widgets__delete__description" = "Are you sure you want to delete '{name}' from your widgets?"; "widgets__price__name" = "Bitcoin Price"; @@ -1239,6 +1240,8 @@ "widgets__facts__description" = "Discover fun facts about Bitcoin, every time you open your wallet."; "widgets__calculator__name" = "Bitcoin Calculator"; "widgets__calculator__description" = "Convert ₿ amounts to {fiatSymbol} or vice versa."; +"widgets__suggestions__name" = "Bitkit Suggestions"; +"widgets__suggestions__description" = "Discover everything Bitkit has to offer."; "widgets__weather__name" = "Bitcoin Weather"; "widgets__weather__description" = "Find out when it's a good time to transact on the Bitcoin blockchain."; "widgets__weather__condition__good__title" = "Favorable Conditions"; diff --git a/Bitkit/Utilities/WidgetsBackupConverter.swift b/Bitkit/Utilities/WidgetsBackupConverter.swift index 7ca055d1c..f1c87411c 100644 --- a/Bitkit/Utilities/WidgetsBackupConverter.swift +++ b/Bitkit/Utilities/WidgetsBackupConverter.swift @@ -69,7 +69,7 @@ enum WidgetsBackupConverter { "showSource": options.showSource, ] } - case .calculator: + case .calculator, .suggestions: break } } @@ -186,7 +186,7 @@ enum WidgetsBackupConverter { ) optionsData = try? JSONEncoder().encode(iosOptions) } - case .calculator: + case .calculator, .suggestions: break } diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index 5a1da4669..4db12aa8d 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -12,6 +12,8 @@ protocol WidgetOptionsProtocol: Codable, Equatable { // Default options for each widget type func getDefaultOptions(for type: WidgetType) -> Any { switch type { + case .suggestions, .calculator: + return EmptyWidgetOptions() case .blocks: return BlocksWidgetOptions() case .facts: @@ -22,8 +24,6 @@ func getDefaultOptions(for type: WidgetType) -> Any { return WeatherWidgetOptions() case .price: return PriceWidgetOptions() - case .calculator: - return EmptyWidgetOptions() } } @@ -63,8 +63,10 @@ struct Widget: Identifiable { @MainActor @ViewBuilder - func view(widgetsViewModel: WidgetsViewModel, isEditing: Bool, onEditingEnd: (() -> Void)? = nil) -> some View { + func view(widgetsViewModel: WidgetsViewModel, isEditing: Bool, onEditingEnd: (() -> Void)? = nil, isPreview: Bool = false) -> some View { switch type { + case .suggestions: + SuggestionsWidget(isEditing: isEditing, onEditingEnd: onEditingEnd, isPreview: isPreview) case .blocks: BlocksWidget( options: widgetsViewModel.getOptions(for: type, as: BlocksWidgetOptions.self), @@ -143,6 +145,7 @@ struct PlaceholderWidget: View { // MARK: - Widget Types enum WidgetType: String, CaseIterable, Codable { + case suggestions case price case news case blocks @@ -151,17 +154,14 @@ enum WidgetType: String, CaseIterable, Codable { case weather } -// MARK: - Widgets tab row (suggestions section or a widget) +// MARK: - Widgets tab row -/// A single row in the widgets tab: either the suggestions section or a widget. +/// A single row in the widgets tab (each row is a widget, including suggestions). enum WidgetsTabRow: Identifiable { - case suggestions case widget(Widget) var id: String { switch self { - case .suggestions: - return "suggestions" case let .widget(widget): return widget.type.rawValue } @@ -170,59 +170,30 @@ enum WidgetsTabRow: Identifiable { // MARK: - WidgetsViewModel -private let widgetsTabLayoutOrderKey = "widgetsTabLayoutOrder" - @MainActor class WidgetsViewModel: ObservableObject { @Published var savedWidgets: [Widget] = [] - // Single AppStorage key for widgets with their options + // Single AppStorage key for widgets with their options (array order = display order) @AppStorage("savedWidgets") private var savedWidgetsData: Data = .init() // In-memory storage for saved widgets with options private var savedWidgetsWithOptions: [SavedWidget] = [] - /// Order of widgets tab rows: "suggestions" or WidgetType.rawValue. Persisted separately so suggestions can sit between widgets. - @Published private(set) var layoutOrder: [String] = [] - // Default widgets for new installs and resets private static let defaultSavedWidgets: [SavedWidget] = [ + SavedWidget(type: .suggestions), SavedWidget(type: .price), SavedWidget(type: .blocks), ] - /// Default layout: suggestions first, then default widget types. - private static var defaultLayoutOrder: [String] { - ["suggestions"] + defaultSavedWidgets.map(\.type.rawValue) - } - init() { - loadLayoutOrder() loadSavedWidgets() } - /// Rows to display in the widgets tab (suggestions + widgets) in the user's order. + /// Rows to display in the widgets tab in the user's order (same as savedWidgets order). var orderedRows: [WidgetsTabRow] { - let widgetTypesInOrder = layoutOrder.compactMap { id -> WidgetType? in - id == "suggestions" ? nil : WidgetType(rawValue: id) - } - let validWidgetTypes = widgetTypesInOrder.filter { type in savedWidgets.contains { $0.type == type } } - let orderedWidgets = validWidgetTypes.compactMap { type in savedWidgets.first { $0.type == type } } - var result: [WidgetsTabRow] = [] - for id in layoutOrder { - if id == "suggestions" { - result.append(.suggestions) - } else if let widget = orderedWidgets.first(where: { $0.type.rawValue == id }) { - result.append(.widget(widget)) - } - } - for widget in savedWidgets where !layoutOrder.contains(widget.type.rawValue) { - result.append(.widget(widget)) - } - if !layoutOrder.contains("suggestions") { - result.insert(.suggestions, at: 0) - } - return result + savedWidgets.map { .widget($0) } } // MARK: - Public Methods @@ -240,10 +211,6 @@ class WidgetsViewModel: ObservableObject { let newSavedWidget = SavedWidget(type: type) savedWidgetsWithOptions.append(newSavedWidget) savedWidgets.append(newSavedWidget.toWidget()) - if !layoutOrder.contains(type.rawValue) { - layoutOrder.append(type.rawValue) - persistLayoutOrder() - } persistSavedWidgets() } @@ -251,37 +218,25 @@ class WidgetsViewModel: ObservableObject { func deleteWidget(_ type: WidgetType) { savedWidgetsWithOptions.removeAll { $0.type == type } savedWidgets.removeAll { $0.type == type } - layoutOrder.removeAll { $0 == type.rawValue } - persistLayoutOrder() persistSavedWidgets() } - /// Reorder the widgets tab list (suggestions + widgets). Updates layout order and, when a widget is moved, savedWidgets order. + /// Reorder the widgets tab list by moving one widget to a new index. func reorderWidgetsTab(from sourceIndex: Int, to destinationIndex: Int) { - let rows = orderedRows guard sourceIndex != destinationIndex, - sourceIndex >= 0, sourceIndex < rows.count, - destinationIndex >= 0, destinationIndex < rows.count + sourceIndex >= 0, sourceIndex < savedWidgetsWithOptions.count, + destinationIndex >= 0, destinationIndex < savedWidgetsWithOptions.count else { return } - - let moved = rows[sourceIndex] - var newOrder = rows.map(\.id) - newOrder.remove(at: sourceIndex) - newOrder.insert(moved.id, at: destinationIndex) - layoutOrder = newOrder - persistLayoutOrder() - - if case .widget = moved { - syncSavedWidgetsOrderFromLayoutOrder() - } + let moved = savedWidgetsWithOptions.remove(at: sourceIndex) + savedWidgetsWithOptions.insert(moved, at: destinationIndex) + savedWidgets = savedWidgetsWithOptions.map { $0.toWidget() } + persistSavedWidgets() } /// Clear all persisted widgets and restore defaults func clearWidgets() { savedWidgetsWithOptions = WidgetsViewModel.defaultSavedWidgets savedWidgets = savedWidgetsWithOptions.map { $0.toWidget() } - layoutOrder = Self.defaultLayoutOrder - persistLayoutOrder() persistSavedWidgets() } @@ -327,6 +282,8 @@ class WidgetsViewModel: ObservableObject { /// Check if widget has custom options (different from default) func hasCustomOptions(for type: WidgetType) -> Bool { switch type { + case .suggestions, .calculator: + return false case .blocks: let current: BlocksWidgetOptions = getOptions(for: type, as: BlocksWidgetOptions.self) let defaultOptions = BlocksWidgetOptions() @@ -347,8 +304,6 @@ class WidgetsViewModel: ObservableObject { let current: PriceWidgetOptions = getOptions(for: type, as: PriceWidgetOptions.self) let defaultOptions = PriceWidgetOptions() return current != defaultOptions - case .calculator: - return false // No customization available yet } } @@ -367,54 +322,6 @@ class WidgetsViewModel: ObservableObject { savedWidgets = savedWidgetsWithOptions.map { $0.toWidget() } persistSavedWidgets() } - syncLayoutOrderFromSavedWidgets() - } - - private func loadLayoutOrder() { - guard let data = UserDefaults.standard.data(forKey: widgetsTabLayoutOrderKey), - let decoded = try? JSONDecoder().decode([String].self, from: data) - else { - layoutOrder = Self.defaultLayoutOrder - persistLayoutOrder() - return - } - layoutOrder = decoded - } - - private func persistLayoutOrder() { - guard let data = try? JSONEncoder().encode(layoutOrder) else { return } - UserDefaults.standard.set(data, forKey: widgetsTabLayoutOrderKey) - } - - /// Ensure layoutOrder contains all current saved widget types and "suggestions"; append missing ids. - private func syncLayoutOrderFromSavedWidgets() { - let currentIds = Set(layoutOrder) - let widgetIds = Set(savedWidgets.map(\.type.rawValue)) - var needSync = false - if !currentIds.contains("suggestions") { - layoutOrder.insert("suggestions", at: 0) - needSync = true - } - for type in savedWidgets.map(\.type) { - if !currentIds.contains(type.rawValue) { - layoutOrder.append(type.rawValue) - needSync = true - } - } - layoutOrder = layoutOrder.filter { $0 == "suggestions" || widgetIds.contains($0) } - if needSync { persistLayoutOrder() } - } - - /// Update savedWidgets order to match the order of widget ids in layoutOrder. - private func syncSavedWidgetsOrderFromLayoutOrder() { - let widgetIdsInOrder = layoutOrder.compactMap { id -> WidgetType? in - id == "suggestions" ? nil : WidgetType(rawValue: id) - } - let ordered = widgetIdsInOrder.compactMap { type in savedWidgetsWithOptions.first(where: { $0.type == type }) } - let remaining = savedWidgetsWithOptions.filter { w in !widgetIdsInOrder.contains(w.type) } - savedWidgetsWithOptions = ordered + remaining - savedWidgets = savedWidgetsWithOptions.map { $0.toWidget() } - persistSavedWidgets() } private func persistSavedWidgets() { diff --git a/Bitkit/Views/Home/HomeWidgetsView.swift b/Bitkit/Views/Home/HomeWidgetsView.swift index aba0bcc7f..05e57f08c 100644 --- a/Bitkit/Views/Home/HomeWidgetsView.swift +++ b/Bitkit/Views/Home/HomeWidgetsView.swift @@ -42,14 +42,7 @@ struct HomeWidgetsView: View { @ViewBuilder private func rowContent(_ row: WidgetsTabRow) -> some View { - switch row { - case .suggestions: - if isEditingWidgets { - SuggestionsEditRow() - } else { - Suggestions() - } - case let .widget(widget): + if case let .widget(widget) = row { WidgetViewWrapper(widget: widget, isEditing: isEditingWidgets) { withAnimation { isEditingWidgets = false @@ -71,53 +64,3 @@ private struct WidgetViewWrapper: View { widget.view(widgetsViewModel: widgets, isEditing: isEditing, onEditingEnd: onEditingEnd) } } - -/// Collapsed suggestions row shown in edit mode. Matches widget edit layout with delete/edit disabled. -private struct SuggestionsEditRow: View { - var body: some View { - Button {} label: { - HStack(spacing: 16) { - Image("suggestions-widget") - .resizable() - .frame(width: 32, height: 32) - - BodyMSBText(t("cards__suggestions")) - .lineLimit(1) - - Spacer() - - HStack(spacing: 8) { - Image("trash") - .resizable() - .foregroundColor(.textPrimary) - .frame(width: 24, height: 24) - .frame(width: 32, height: 32) - Image("gear-six") - .resizable() - .foregroundColor(.textPrimary) - .frame(width: 24, height: 24) - .frame(width: 32, height: 32) - Image("burger") - .resizable() - .foregroundColor(.textPrimary) - .frame(width: 24, height: 24) - .frame(width: 32, height: 32) - .contentShape(Rectangle()) - .overlay { - Color.clear - .frame(width: 44, height: 44) - .contentShape(Rectangle()) - .trackDragHandle() - } - } - } - .contentShape(Rectangle()) - } - .buttonStyle(WidgetButtonStyle()) - .frame(maxWidth: .infinity) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) - .accessibilityIdentifier("SuggestionsWidget") - } -} diff --git a/Bitkit/Views/HomeScreen.swift b/Bitkit/Views/HomeScreen.swift index 81267a327..4c61979dd 100644 --- a/Bitkit/Views/HomeScreen.swift +++ b/Bitkit/Views/HomeScreen.swift @@ -9,6 +9,10 @@ struct HomeScreen: View { @State private var scrollPosition: Int? = 0 @State private var isEditingWidgets = false + private var hasActivity: Bool { + return activity.latestActivities?.isEmpty == false + } + private var currentPage: Int { scrollPosition ?? 0 } private var topPadding: CGFloat { windowSafeAreaInsets.top + 48 + 16 } // safe area + header + spacing private var bottomPadding: CGFloat { windowSafeAreaInsets.bottom + 64 + 32 } // safe area + tab bar + spacing @@ -35,8 +39,8 @@ struct HomeScreen: View { .scrollTargetBehavior(.paging) .scrollPosition(id: $scrollPosition) .onChange(of: scrollPosition) { _, newValue in - // Dismiss widgets onboarding hint the first time user scrolls to widgets - if newValue == 1 { + // Dismiss this hint after the user has seen it and scrolls to widgets + if hasActivity, newValue == 1 { app.hasDismissedWidgetsOnboardingHint = true } } diff --git a/Bitkit/Views/Widgets/WidgetDetailView.swift b/Bitkit/Views/Widgets/WidgetDetailView.swift index 409b1182b..1d455b584 100644 --- a/Bitkit/Views/Widgets/WidgetDetailView.swift +++ b/Bitkit/Views/Widgets/WidgetDetailView.swift @@ -32,7 +32,7 @@ struct WidgetDetailView: View { switch id { case .blocks, .facts, .news, .price, .weather: return true - case .calculator: + case .suggestions, .calculator: return false } } @@ -55,7 +55,7 @@ struct WidgetDetailView: View { @ViewBuilder private func renderWidget() -> some View { let widget = Widget(type: id) - widget.view(widgetsViewModel: widgets, isEditing: false) + widget.view(widgetsViewModel: widgets, isEditing: false, isPreview: true) } var body: some View { diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index 8d9359594..3820ab6f0 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -26,7 +26,7 @@ class WidgetEditLogic: ObservableObject { switch widgetType { case .facts, .blocks, .news, .price, .weather: return true - case .calculator: + case .calculator, .suggestions: return false } } @@ -46,7 +46,7 @@ class WidgetEditLogic: ObservableObject { case .price: // Price widget has options, check if at least one trading pair is selected return !priceOptions.selectedPairs.isEmpty - case .calculator: + case .calculator, .suggestions: return false } } @@ -68,7 +68,7 @@ class WidgetEditLogic: ObservableObject { case .price: let defaultOptions = PriceWidgetOptions() return priceOptions != defaultOptions - case .calculator: + case .calculator, .suggestions: return false } } @@ -159,7 +159,7 @@ class WidgetEditLogic: ObservableObject { default: break } - case .calculator: + case .calculator, .suggestions: break } onStateChange?() @@ -185,7 +185,7 @@ class WidgetEditLogic: ObservableObject { weatherOptions = widgetsViewModel.getOptions(for: widgetType, as: WeatherWidgetOptions.self) case .price: priceOptions = widgetsViewModel.getOptions(for: widgetType, as: PriceWidgetOptions.self) - case .calculator: + case .calculator, .suggestions: break } } @@ -202,7 +202,7 @@ class WidgetEditLogic: ObservableObject { weatherOptions = WeatherWidgetOptions() case .price: priceOptions = PriceWidgetOptions() - case .calculator: + case .calculator, .suggestions: break } onStateChange?() @@ -220,7 +220,7 @@ class WidgetEditLogic: ObservableObject { widgetsViewModel.saveOptions(weatherOptions, for: widgetType) case .price: widgetsViewModel.saveOptions(priceOptions, for: widgetType) - case .calculator: + case .calculator, .suggestions: break } } diff --git a/Bitkit/Views/Widgets/WidgetEditModels.swift b/Bitkit/Views/Widgets/WidgetEditModels.swift index 9f5fc24b0..87ae64ba6 100644 --- a/Bitkit/Views/Widgets/WidgetEditModels.swift +++ b/Bitkit/Views/Widgets/WidgetEditModels.swift @@ -537,7 +537,7 @@ enum WidgetEditItemFactory { return getPriceItems(priceOptions: priceOptions, priceDataByPeriod: priceDataByPeriod) case .weather: return getWeatherItems(weatherViewModel: weatherViewModel, weatherOptions: weatherOptions) - case .calculator: + case .calculator, .suggestions: return [] } } diff --git a/Bitkit/Views/Widgets/WidgetsListView.swift b/Bitkit/Views/Widgets/WidgetsListView.swift index 65793d80d..857c4b915 100644 --- a/Bitkit/Views/Widgets/WidgetsListView.swift +++ b/Bitkit/Views/Widgets/WidgetsListView.swift @@ -1,17 +1,34 @@ import SwiftUI struct WidgetsListView: View { + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var settings: SettingsViewModel + var body: some View { VStack(spacing: 0) { NavigationBar(title: t("widgets__add")) - ScrollView(showsIndicators: false) { - LazyVStack(spacing: 0) { - ForEach(WidgetType.allCases, id: \.rawValue) { widgetType in - WidgetListItem(id: widgetType) + GeometryReader { geometry in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(WidgetType.allCases, id: \.rawValue) { widgetType in + WidgetListItem(id: widgetType, isDisabled: !settings.showWidgets) + } + } + + Spacer() + + if !settings.showWidgets { + CustomButton(title: t("widgets__list__button")) { + navigation.navigate(.widgetsSettings) + } + } } + .frame(minHeight: geometry.size.height) + .padding(.top, 16) + .bottomSafeAreaPadding() } - .padding(.top) } } .navigationBarHidden(true) From dea14f219e25c00c0fbf09ce2f136b36495145b2 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Tue, 24 Feb 2026 17:41:33 +0100 Subject: [PATCH 4/4] fix(ui): allow free scroll on widgets screen --- Bitkit/Views/Home/HomeWidgetsView.swift | 73 +++++++++++-------------- Bitkit/Views/HomeScreen.swift | 6 +- 2 files changed, 34 insertions(+), 45 deletions(-) diff --git a/Bitkit/Views/Home/HomeWidgetsView.swift b/Bitkit/Views/Home/HomeWidgetsView.swift index 05e57f08c..953427722 100644 --- a/Bitkit/Views/Home/HomeWidgetsView.swift +++ b/Bitkit/Views/Home/HomeWidgetsView.swift @@ -10,57 +10,46 @@ struct HomeWidgetsView: View { private var bottomPadding: CGFloat { windowSafeAreaInsets.bottom + 64 + 32 } // safe area + tab bar + spacing var body: some View { - VStack(alignment: .leading, spacing: 0) { - DraggableList( - widgets.orderedRows, - id: \.id, - enableDrag: isEditingWidgets, - itemHeight: 80, - onReorder: { sourceIndex, destinationIndex in - widgets.reorderWidgetsTab(from: sourceIndex, to: destinationIndex) + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + DraggableList( + widgets.orderedRows, + id: \.id, + enableDrag: isEditingWidgets, + itemHeight: 80, + onReorder: { sourceIndex, destinationIndex in + widgets.reorderWidgetsTab(from: sourceIndex, to: destinationIndex) + } + ) { row in + rowContent(row) } - ) { row in - rowContent(row) - } - .id(widgets.orderedRows.map(\.id)) - - CustomButton(title: t("widgets__add"), variant: .tertiary) { - if app.hasSeenWidgetsIntro { - navigation.navigate(.widgetsList) - } else { - navigation.navigate(.widgetsIntro) + .id(widgets.orderedRows.map(\.id)) + + CustomButton(title: t("widgets__add"), variant: .tertiary) { + if app.hasSeenWidgetsIntro { + navigation.navigate(.widgetsList) + } else { + navigation.navigate(.widgetsIntro) + } } + .padding(.top, 16) + .accessibilityIdentifier("WidgetsAdd") } - .padding(.top, 16) - .accessibilityIdentifier("WidgetsAdd") + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.top, topPadding) + .padding(.bottom, bottomPadding) + .padding(.horizontal) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .padding(.top, topPadding) - .padding(.bottom, bottomPadding) - .padding(.horizontal) } @ViewBuilder private func rowContent(_ row: WidgetsTabRow) -> some View { if case let .widget(widget) = row { - WidgetViewWrapper(widget: widget, isEditing: isEditingWidgets) { - withAnimation { - isEditingWidgets = false - } - } + widget.view( + widgetsViewModel: widgets, + isEditing: isEditingWidgets, + onEditingEnd: { withAnimation { isEditingWidgets = false } } + ) } } } - -/// Wraps a widget and forwards view model + edit state to the widget's view builder. -private struct WidgetViewWrapper: View { - let widget: Widget - let isEditing: Bool - let onEditingEnd: (() -> Void)? - - @EnvironmentObject private var widgets: WidgetsViewModel - - var body: some View { - widget.view(widgetsViewModel: widgets, isEditing: isEditing, onEditingEnd: onEditingEnd) - } -} diff --git a/Bitkit/Views/HomeScreen.swift b/Bitkit/Views/HomeScreen.swift index 4c61979dd..984a56413 100644 --- a/Bitkit/Views/HomeScreen.swift +++ b/Bitkit/Views/HomeScreen.swift @@ -22,15 +22,15 @@ struct HomeScreen: View { Header(showWidgetEditButton: currentPage == 1, isEditingWidgets: $isEditingWidgets) GeometryReader { geometry in - ScrollView(.vertical, showsIndicators: false) { + ScrollView(showsIndicators: false) { LazyVStack { HomeWalletView() - .containerRelativeFrame(.vertical) + .frame(height: geometry.size.height) .id(0) if settings.showWidgets { HomeWidgetsView(isEditingWidgets: $isEditingWidgets) - .frame(minHeight: geometry.size.height) + .frame(height: geometry.size.height) .id(1) } }