From 08aeae395a5ce584f999ad83112cd80ba88d5a2b Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Mon, 2 Feb 2026 17:31:48 +0100 Subject: [PATCH 1/2] 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 00874bb2..31ab20f6 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 92764256..6e8ae1a4 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 47e70289..07047db5 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 4663ad98..3d39e8a0 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 f260fb48..99ab776d 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 add5c6cb..752b3e41 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 81326603..a9d9fe3b 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 49ebd1ec..423477db 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 7bb8d6685d15e8c52277362917ea7edaaa100f16 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Mon, 2 Feb 2026 23:51:44 +0100 Subject: [PATCH 2/2] feat(home): rework home screen with tabs --- .../trezor-card.imageset/Contents.json | 21 ++ .../trezor-card.imageset/trezor-card.png | Bin 0 -> 66046 bytes .../arrow-widgets.imageset/Contents.json | 12 ++ .../arrow-widgets.imageset/arrow-widgets.pdf | Bin 0 -> 5690 bytes .../suggestions-widget.imageset/Contents.json | 12 ++ .../suggestions-widget.pdf | Bin 0 -> 11437 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 | 10 +- 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 | 61 ++++++ Bitkit/Extensions/View+SafeArea.swift | 11 +- 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/WalletTabView.swift | 71 ++++++ Bitkit/Views/Home/WidgetsTabView.swift | 124 +++++++++++ Bitkit/Views/HomeScreen.swift | 61 ++++++ .../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 | 5 +- .../Wallets/Activity/ActivityLatest.swift | 24 +-- .../Wallets/Activity/EmptyActivityRow.swift | 26 --- Bitkit/Views/Wallets/HomeView.swift | 168 --------------- 44 files changed, 964 insertions(+), 774 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/arrow-widgets.imageset/Contents.json create mode 100644 Bitkit/Assets.xcassets/icons/arrow-widgets.imageset/arrow-widgets.pdf 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 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/WalletTabView.swift create mode 100644 Bitkit/Views/Home/WidgetsTabView.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/Assets.xcassets/Illustrations/trezor-card.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/trezor-card.imageset/Contents.json new file mode 100644 index 00000000..00f74f3d --- /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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 CgW)Xz literal 0 HcmV?d00001 diff --git a/Bitkit/Assets.xcassets/icons/suggestions-widget.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/suggestions-widget.imageset/Contents.json new file mode 100644 index 00000000..22b07362 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/suggestions-widget.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "suggestions-widget.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Bitkit/Assets.xcassets/icons/suggestions-widget.imageset/suggestions-widget.pdf b/Bitkit/Assets.xcassets/icons/suggestions-widget.imageset/suggestions-widget.pdf new file mode 100644 index 0000000000000000000000000000000000000000..0e0dc96d90641db96790fb5b6860f8002b33dbef GIT binary patch literal 11437 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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 85c5c919..00000000 --- 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 d899ab6b..00000000 --- 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 00000000..4de2ce76 --- /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 396fc88c..c33ffa46 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 20934bf1..c5cf047f 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 9f17e5de..54343e55 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 3053eda3..7c4d3e43 100644 --- a/Bitkit/Components/EmptyStateView.swift +++ b/Bitkit/Components/EmptyStateView.swift @@ -30,6 +30,14 @@ struct EmptyStateView: View { let type: EmptyStateType var onClose: (() -> Void)? + var bottomPadding: CGFloat { + if type == .home { + return windowSafeAreaInsets.bottom > 0 ? 160 : 125 + } else { + return 100 + } + } + var body: some View { VStack { Spacer() @@ -46,7 +54,7 @@ struct EmptyStateView: View { Spacer() } .frame(maxWidth: .infinity) - .padding(.bottom, 100) + .padding(.bottom, bottomPadding) .overlay { if let onClose { VStack { diff --git a/Bitkit/Components/Header.swift b/Bitkit/Components/Header.swift index a3771e1f..7190d060 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 fc467bf7..a4af1e6d 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 50fe2544..a44f53f7 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 38fb6057..00000000 --- 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 8d7ffd56..aa4e9250 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 07047db5..38328a2b 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 00000000..bd6a1305 --- /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 9445ca86..43f54cf9 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 00000000..761616e5 --- /dev/null +++ b/Bitkit/Components/WidgetsOnboardingView.swift @@ -0,0 +1,61 @@ +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 { + @EnvironmentObject var app: AppViewModel + + var body: some View { + VStack { + HStack(alignment: .bottom, spacing: 0) { + WidgetsOnboardingText(text: t("widgets__onboarding__swipe")) + + Image("arrow-widgets") + .resizable() + .scaledToFit() + .frame(maxWidth: 110) + .padding(.trailing, 32) + } + .padding(.vertical, 16) + .frame(maxWidth: .infinity) + .overlay { + VStack { + Button(action: { + Haptics.play(.buttonTap) + app.hasDismissedWidgetsOnboardingHint = true + }) { + 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: 0) + + Spacer() + } + .frame(maxWidth: .infinity, alignment: .topTrailing) + } + } + } +} diff --git a/Bitkit/Extensions/View+SafeArea.swift b/Bitkit/Extensions/View+SafeArea.swift index 37136847..2a83d837 100644 --- a/Bitkit/Extensions/View+SafeArea.swift +++ b/Bitkit/Extensions/View+SafeArea.swift @@ -1,13 +1,18 @@ import SwiftUI import UIKit -var hasHomeIndicator: Bool { +private var hasHomeIndicator: 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 diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 3e0fe2a1..81a55d38 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 45f4d8a4..df471171 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 c0242321..ee7239ed 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 026da25b..b1171dc7 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"; @@ -1208,6 +1210,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 to find\nyour widgets"; "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 8a9c6c61..a47d6580 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 0eb93f71..6131e1d3 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 22b6e9b8..9ec8119f 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 3c80b1f0..5a1da466 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/WalletTabView.swift b/Bitkit/Views/Home/WalletTabView.swift new file mode 100644 index 00000000..57ea2850 --- /dev/null +++ b/Bitkit/Views/Home/WalletTabView.swift @@ -0,0 +1,71 @@ +import SwiftUI + +struct WalletTabView: 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 + } + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 32) { + MoneyStack( + sats: wallet.totalBalanceSats, + showSymbol: true, + showEyeIcon: true, + enableSwipeGesture: settings.swipeBalanceToHide, + enableHide: true + ) + + 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(maxWidth: .infinity, alignment: .leading) + + if hasActivity { + VStack(spacing: 0) { + ActivityLatest() + + if settings.showWidgets, !app.hasDismissedWidgetsOnboardingHint { + WidgetsOnboardingView() + } + } + } + } + .padding(.top, windowSafeAreaInsets.top + 48) // Safe area + header + .padding(.horizontal) + .padding(.bottom, 120) // Leave space for tab bar and dots + } + .scrollDisabled(!hasActivity) + .refreshable { + guard wallet.nodeLifecycleState == .running else { + return + } + do { + try await wallet.sync() + try await activity.syncLdkNodePayments() + } catch { + app.toast(error) + } + } + .animation(.spring(response: 0.3), value: hasActivity) + .overlay { + if !hasActivity { + EmptyStateView(type: .home) + .padding(.horizontal) + } + } + } +} diff --git a/Bitkit/Views/Home/WidgetsTabView.swift b/Bitkit/Views/Home/WidgetsTabView.swift new file mode 100644 index 00000000..09190cf2 --- /dev/null +++ b/Bitkit/Views/Home/WidgetsTabView.swift @@ -0,0 +1,124 @@ +import SwiftUI + +struct WidgetsTabView: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var widgets: WidgetsViewModel + @Binding var isEditingWidgets: Bool + + var body: some View { + 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) + } + .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, windowSafeAreaInsets.top + 48) + .padding(.horizontal) + .padding(.bottom, 150) // Leave space for tab bar and dots + } + .scrollDismissesKeyboard(.immediately) + } + + @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) + .opacity(0.2) + Image("gear-six") + .resizable() + .foregroundColor(.textPrimary) + .frame(width: 24, height: 24) + .frame(width: 32, height: 32) + .opacity(0.2) + 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 00000000..718f5cf9 --- /dev/null +++ b/Bitkit/Views/HomeScreen.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct HomeScreen: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var settings: SettingsViewModel + + @State private var currentTab = 0 + @State private var isEditingWidgets = false + + var body: some View { + ZStack(alignment: .top) { + Header(showWidgetEditButton: currentTab == 1, isEditingWidgets: $isEditingWidgets) + + TabView(selection: $currentTab) { + WalletTabView() + .tag(0) + + if settings.showWidgets { + WidgetsTabView(isEditingWidgets: $isEditingWidgets) + .tag(1) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .ignoresSafeArea() + + if settings.showWidgets { + TabViewDots(numberOfTabs: 2, currentTab: currentTab) + .offset(y: windowSafeAreaInsets.bottom > 0 ? -74 : -90) + .ignoresSafeArea(.keyboard) + } + + // Top and bottom gradients + VStack(spacing: 0) { + LinearGradient( + colors: [.black, .black.opacity(0)], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: windowSafeAreaInsets.top + 48 + 16) // safe area + header + spacing + + Spacer() + + LinearGradient( + colors: [.black.opacity(0), .black], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 130) + } + .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 4444f45e..4f73a5ed 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 253ac824..d04ec8bb 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 c7c310f9..718fdfa1 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 7123f062..e5345946 100644 --- a/Bitkit/Views/Onboarding/RestoreWalletView.swift +++ b/Bitkit/Views/Onboarding/RestoreWalletView.swift @@ -251,7 +251,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 94c45eec..6ec2849b 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 1ff458be..30e5a99b 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 45c1ba8f..c4387cfd 100644 --- a/Bitkit/Views/Sheets/Sheet.swift +++ b/Bitkit/Views/Sheets/Sheet.swift @@ -5,10 +5,7 @@ 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 diff --git a/Bitkit/Views/Wallets/Activity/ActivityLatest.swift b/Bitkit/Views/Wallets/Activity/ActivityLatest.swift index 85e9a29a..49d5be6a 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 d911b8c6..00000000 --- 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 3e7238a6..00000000 --- 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) -}