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 00000000..fb8b31dc Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/trezor-card.imageset/trezor-card.png differ diff --git a/Bitkit/Assets.xcassets/icons/arrow-widgets.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/arrow-widgets.imageset/Contents.json new file mode 100644 index 00000000..7aa9d7e2 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/arrow-widgets.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "arrow-widgets.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Bitkit/Assets.xcassets/icons/arrow-widgets.imageset/arrow-widgets.pdf b/Bitkit/Assets.xcassets/icons/arrow-widgets.imageset/arrow-widgets.pdf new file mode 100644 index 00000000..78c03880 Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/arrow-widgets.imageset/arrow-widgets.pdf differ 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/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 00000000..0e0dc96d Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/suggestions-widget.imageset/suggestions-widget.pdf differ 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/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/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..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("checkmark") + Image("check-mark") .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/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/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/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/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/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/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/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) -} 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)