diff --git a/Bitkit/Assets.xcassets/Illustrations/lightbulb.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/lightbulb-figure.imageset/Contents.json similarity index 86% rename from Bitkit/Assets.xcassets/Illustrations/lightbulb.imageset/Contents.json rename to Bitkit/Assets.xcassets/Illustrations/lightbulb-figure.imageset/Contents.json index 2a4514b86..5ba053448 100644 --- a/Bitkit/Assets.xcassets/Illustrations/lightbulb.imageset/Contents.json +++ b/Bitkit/Assets.xcassets/Illustrations/lightbulb-figure.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "lightbulb.png", + "filename" : "lightbulb-figure.png", "idiom" : "universal", "scale" : "1x" }, diff --git a/Bitkit/Assets.xcassets/Illustrations/lightbulb-figure.imageset/lightbulb-figure.png b/Bitkit/Assets.xcassets/Illustrations/lightbulb-figure.imageset/lightbulb-figure.png new file mode 100644 index 000000000..6f5838659 Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/lightbulb-figure.imageset/lightbulb-figure.png differ diff --git a/Bitkit/Assets.xcassets/icons/lightbulb.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/lightbulb.imageset/Contents.json new file mode 100644 index 000000000..1b4e3b069 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/lightbulb.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "lightbulb.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/Illustrations/lightbulb.imageset/lightbulb.png b/Bitkit/Assets.xcassets/icons/lightbulb.imageset/lightbulb.png similarity index 100% rename from Bitkit/Assets.xcassets/Illustrations/lightbulb.imageset/lightbulb.png rename to Bitkit/Assets.xcassets/icons/lightbulb.imageset/lightbulb.png diff --git a/Bitkit/Components/Activity/ActivityList.swift b/Bitkit/Components/Activity/ActivityList.swift index a3a570fb9..41826b841 100644 --- a/Bitkit/Components/Activity/ActivityList.swift +++ b/Bitkit/Components/Activity/ActivityList.swift @@ -4,7 +4,6 @@ import SwiftUI struct ActivityList: View { @EnvironmentObject var activity: ActivityListViewModel @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager - @State private var isHorizontalSwipe = false let viewType: ActivityViewType @@ -31,7 +30,6 @@ struct ActivityList: View { ActivityRow(item: item, feeEstimates: feeEstimatesManager.estimates) } .accessibilityIdentifier("Activity-\(index)") - .disabled(isHorizontalSwipe) } } } diff --git a/Bitkit/Components/Card.swift b/Bitkit/Components/Card.swift new file mode 100644 index 000000000..25bf7bda4 --- /dev/null +++ b/Bitkit/Components/Card.swift @@ -0,0 +1,85 @@ +import SwiftUI + +/// Shared gradient tile used by suggestions widget and shop discover +struct Card: View { + let title: String + let description: String + let imageName: String + let accentColor: Color + let onTap: () -> Void + let onDismiss: (() -> Void)? + + init( + title: String, + description: String, + imageName: String, + accentColor: Color, + onTap: @escaping () -> Void, + onDismiss: (() -> Void)? = nil + ) { + self.title = title + self.description = description + self.imageName = imageName + self.accentColor = accentColor + self.onTap = onTap + self.onDismiss = onDismiss + } + + var body: some View { + ZStack(alignment: .topTrailing) { + Button(action: onTap) { + VStack(alignment: .leading, spacing: 0) { + Spacer() + + Image(imageName) + .resizable() + .scaledToFit() + .frame(width: 96, height: 96) + .frame(maxWidth: .infinity, alignment: .center) + + Text(title) + .font(.custom(Fonts.black, size: 20)) + .lineLimit(1) + .kerning(-0.5) + .textCase(.uppercase) + .padding(.top, 4) + + CaptionBText(description) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(.vertical, 12) + .padding(.horizontal, 16) + .background( + RoundedRectangle(cornerRadius: 16) + .fill( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: accentColor, location: 0.0), + .init(color: Color.black.opacity(0.1), location: 0.9), + .init(color: Color.black, location: 1.0), + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + ) + } + .buttonStyle(.plain) + + if let onDismiss { + Button(action: onDismiss) { + Image("x-mark") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.textSecondary) + .frame(width: 16, height: 16) + .padding(8) + } + .padding(8) + .accessibilityIdentifier("SuggestionDismiss") + .accessibility(label: Text("Dismiss \(title)")) + .buttonStyle(.plain) + } + } + } +} diff --git a/Bitkit/Components/Home/SuggestionsCard.swift b/Bitkit/Components/Home/SuggestionsCard.swift deleted file mode 100644 index a44f53f74..000000000 --- a/Bitkit/Components/Home/SuggestionsCard.swift +++ /dev/null @@ -1,57 +0,0 @@ -import SwiftUI - -struct SuggestionCard: View { - let data: SuggestionCardData - var onDismiss: () -> Void = {} - - var body: some View { - ZStack(alignment: .topTrailing) { - VStack(alignment: .leading, spacing: 0) { - Spacer() - - Image(data.imageName) - .resizable() - .interpolation(.high) - .scaledToFit() - .frame(height: 80) - - Text(data.title) - .font(.custom(Fonts.black, size: 20)) - .lineLimit(1) - .kerning(-0.5) - .textCase(.uppercase) - .padding(.top, 4) - - CaptionBText(data.description) - } - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: 16) - .fill( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: data.color, location: 0.0), - .init(color: Color.black.opacity(0.1), location: 0.9), - .init(color: Color.black, location: 1.0), - ]), - startPoint: .top, - endPoint: .bottom - ) - ) - ) - - Button(action: onDismiss) { - Image("x-mark") - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundColor(.textSecondary) - .frame(width: 16, height: 16) - .padding(8) - } - .padding(8) - .accessibilityIdentifier("SuggestionDismiss") - .accessibility(label: Text("Dismiss \(data.title)")) - } - } -} diff --git a/Bitkit/Components/InsetHeaderScrollView.swift b/Bitkit/Components/InsetHeaderScrollView.swift new file mode 100644 index 000000000..4bf7e66d6 --- /dev/null +++ b/Bitkit/Components/InsetHeaderScrollView.swift @@ -0,0 +1,64 @@ +import SwiftUI + +// MARK: - InsetHeaderScrollView + +// Measured top header (`safeAreaInset`) and scroll content with `minHeight` to fill the viewport below it. +// Optional `scrollModifier` for refresh, margins, etc. + +private enum HeaderHeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + let next = nextValue() + guard next > 0 else { return } + value = next + } +} + +private struct HeaderHeightMeasure: View { + var body: some View { + GeometryReader { proxy in + Color.clear.preference(key: HeaderHeightPreferenceKey.self, value: proxy.size.height) + } + } +} + +struct InsetHeaderScrollView: View { + let header: () -> Header + let content: () -> Content + let scrollModifier: ScrollModifier + + @State private var headerHeight: CGFloat = 0 + + init( + header: @escaping () -> Header, + content: @escaping () -> Content, + scrollModifier: ScrollModifier = EmptyModifier() + ) { + self.header = header + self.content = content + self.scrollModifier = scrollModifier + } + + var body: some View { + GeometryReader { geo in + ScrollView(showsIndicators: false) { + content() + .frame(minHeight: contentMinHeight(in: geo), alignment: .top) + } + .safeAreaInset(edge: .top, spacing: 0) { + header().background(HeaderHeightMeasure()) + } + .modifier(scrollModifier) + .onPreferenceChange(HeaderHeightPreferenceKey.self) { newValue in + if newValue > 0 { headerHeight = newValue } + } + } + } + + /// Before the first header measurement, use full height so `minHeight` is non-negative. + private func contentMinHeight(in geo: GeometryProxy) -> CGFloat { + let insetTop = headerHeight > 0 ? headerHeight : geo.size.height + return max(0, geo.size.height - insetTop) + } +} diff --git a/Bitkit/Components/Home/Suggestions.swift b/Bitkit/Components/Widgets/Suggestions.swift similarity index 94% rename from Bitkit/Components/Home/Suggestions.swift rename to Bitkit/Components/Widgets/Suggestions.swift index 676bdff8c..582c418dd 100644 --- a/Bitkit/Components/Home/Suggestions.swift +++ b/Bitkit/Components/Widgets/Suggestions.swift @@ -75,7 +75,7 @@ let cards: [SuggestionCardData] = [ id: "support", title: t("cards__support__title"), description: t("cards__support__description"), - imageName: "lightbulb", + imageName: "lightbulb-figure", color: .yellow24, action: .support ), @@ -245,10 +245,16 @@ struct Suggestions: View { spacing: 16 ) { ForEach(visibleCards) { card in - SuggestionCard(data: card, onDismiss: { dismissCard(card) }) - .onTapGesture { if !isPreview { onItemTap(card) } } - .accessibilityElement(children: .contain) - .accessibilityIdentifier("Suggestion-\(card.accessibilityId)") + Card( + title: card.title, + description: card.description, + imageName: card.imageName, + accentColor: card.color, + onTap: { if !isPreview { onItemTap(card) } }, + onDismiss: { dismissCard(card) } + ) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("Suggestion-\(card.accessibilityId)") } } .allowsHitTesting(!isPreview) diff --git a/Bitkit/Extensions/View+AllowSwipeBack.swift b/Bitkit/Extensions/View+AllowSwipeBack.swift deleted file mode 100644 index 81949857a..000000000 --- a/Bitkit/Extensions/View+AllowSwipeBack.swift +++ /dev/null @@ -1,21 +0,0 @@ -import SwiftUI - -extension View { - /// Controls whether the interactive swipe-back gesture is enabled on this screen. - /// Use `.allowSwipeBack(false)` on screens that use a custom header without a back button - /// (e.g. `SheetHeader` with default `showBackButton: false`) so users can't swipe to dismiss. - /// Default is `true`; only apply this modifier when you want to disable the gesture. - func allowSwipeBack(_ allowed: Bool) -> some View { - modifier(AllowSwipeBackModifier(allowed: allowed)) - } -} - -private struct AllowSwipeBackModifier: ViewModifier { - let allowed: Bool - - func body(content: Content) -> some View { - content - .onAppear { SwipeBackState.allowSwipeBack = allowed } - .onDisappear { SwipeBackState.allowSwipeBack = true } - } -} diff --git a/Bitkit/Extensions/View+SwipeGestures.swift b/Bitkit/Extensions/View+SwipeGestures.swift new file mode 100644 index 000000000..b713e9118 --- /dev/null +++ b/Bitkit/Extensions/View+SwipeGestures.swift @@ -0,0 +1,55 @@ +import SwiftUI + +// Swipe-to-go-back (nav stack) and horizontal swipes between `SegmentedControl` tabs. + +extension View { + /// Controls whether the interactive swipe-back gesture is enabled on this screen. + /// Use `.allowSwipeBack(false)` on screens that use a custom header without a back button + /// (e.g. `SheetHeader` with default `showBackButton: false`) so users can't swipe to dismiss. + /// Default is `true`; only apply this modifier when you want to disable the gesture. + func allowSwipeBack(_ allowed: Bool) -> some View { + modifier(AllowSwipeBackModifier(allowed: allowed)) + } + + // MARK: Segmented tab swipes + + /// Swipe left/right to move between adjacent tabs (same order as `T.allCases` / `SegmentedControl`). + func swipeSegmentedTabs( + selection: Binding, + minimumDragDistance: CGFloat = 20, + swipeThreshold: CGFloat = 50, + animation: Animation = .easeInOut(duration: 0.2) + ) -> some View { + highPriorityGesture( + DragGesture(minimumDistance: minimumDragDistance, coordinateSpace: .local) + .onEnded { value in + let horizontalAmount = value.translation.width + let verticalAmount = value.translation.height + guard abs(horizontalAmount) > abs(verticalAmount) else { return } + + let tabs = Array(T.allCases) + guard let currentIndex = tabs.firstIndex(of: selection.wrappedValue) else { return } + + if horizontalAmount < -swipeThreshold, currentIndex < tabs.count - 1 { + withAnimation(animation) { + selection.wrappedValue = tabs[currentIndex + 1] + } + } else if horizontalAmount > swipeThreshold, currentIndex > 0 { + withAnimation(animation) { + selection.wrappedValue = tabs[currentIndex - 1] + } + } + } + ) + } +} + +private struct AllowSwipeBackModifier: ViewModifier { + let allowed: Bool + + func body(content: Content) -> some View { + content + .onAppear { SwipeBackState.allowSwipeBack = allowed } + .onDisappear { SwipeBackState.allowSwipeBack = true } + } +} diff --git a/Bitkit/Views/Settings/MainSettingsScreen.swift b/Bitkit/Views/Settings/MainSettingsScreen.swift index 93088c337..00506a780 100644 --- a/Bitkit/Views/Settings/MainSettingsScreen.swift +++ b/Bitkit/Views/Settings/MainSettingsScreen.swift @@ -22,22 +22,34 @@ struct MainSettingsScreen: View { } var body: some View { - VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: t("settings__settings")) - .padding(.horizontal, 16) + InsetHeaderScrollView( + header: { + VStack(spacing: 0) { + NavigationBar(title: t("settings__settings")) + .padding(.horizontal, 16) - SegmentedControl(selectedTab: $selectedTab, tabItems: settingsTabItems) - .padding(.horizontal, 16) - - Group { - switch selectedTab { - case .general: GeneralSettingsView() - case .security: SecuritySettingsView() - case .advanced: AdvancedSettingsView() + SegmentedControl(selectedTab: $selectedTab, tabItems: settingsTabItems) + .padding(.horizontal, 16) + } + .background( + BlurView() + .ignoresSafeArea(edges: .top) + ) + .compositingGroup() + .shadow(color: Color.black.opacity(0.5), radius: 8, x: 0, y: 20) + }, + content: { + Group { + switch selectedTab { + case .general: GeneralSettingsView() + case .security: SecuritySettingsView() + case .advanced: AdvancedSettingsView() + } } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .swipeSegmentedTabs(selection: $selectedTab) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } + ) .navigationBarHidden(true) } } diff --git a/Bitkit/Views/Settings/SupportScreen.swift b/Bitkit/Views/Settings/SupportScreen.swift index 2feb70bf6..7e8ce0d58 100644 --- a/Bitkit/Views/Settings/SupportScreen.swift +++ b/Bitkit/Views/Settings/SupportScreen.swift @@ -61,103 +61,102 @@ struct SupportScreen: View { } var body: some View { - VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: t("settings__support__title")) - .padding(.horizontal, 16) - .padding(.bottom, 16) - - GeometryReader { geometry in - ScrollView(showsIndicators: false) { - ZStack { - // Orange diagonal background (scrolls with content) - Color.brandAccent - .clipShape(DiagonalCut()) - .ignoresSafeArea() - - VStack(alignment: .leading, spacing: 0) { - BodyMText(t("settings__support__text")) - .padding(.bottom, 16) - - VStack(spacing: 0) { - NavigationLink(value: Route.reportIssue) { - SettingsRow(title: t("settings__support__report"), iconName: "warning") - } - - Button(action: { - openURL(URL(string: Env.helpUrl)!) - }) { - SettingsRow(title: t("settings__support__help"), iconName: "question") - } - - NavigationLink(value: Route.appStatus) { - SettingsRow(title: t("settings__support__status"), iconName: "power") - } - .accessibilityIdentifier("AppStatus") - - Button(action: { - openURL(URL(string: Env.termsOfServiceUrl)!) - }) { - SettingsRow(title: t("settings__about__legal"), iconName: "file-text") - } - - ShareLink(item: shareText, message: Text(shareText)) { - SettingsRow(title: t("settings__about__share"), iconName: "share") - } - - Button(action: { - onVersionTap() - }) { - SettingsRow( - title: t("settings__about__version"), - iconName: "stack", - rightText: appVersion, - rightIcon: nil - ) - } - .accessibilityIdentifier("DevOptions") - } + InsetHeaderScrollView( + header: { + NavigationBar(title: t("settings__support__title")) + .padding(.horizontal, 16) + .padding(.bottom, 16) + }, + content: { + ZStack { + // Orange diagonal background (scrolls with content) + Color.brandAccent + .clipShape(DiagonalCut()) + .ignoresSafeArea() + + VStack(alignment: .leading, spacing: 0) { + BodyMText(t("settings__support__text")) + .padding(.bottom, 16) - Spacer(minLength: 32) + VStack(spacing: 0) { + NavigationLink(value: Route.reportIssue) { + SettingsRow(title: t("settings__support__report"), iconName: "warning") + } - VStack(alignment: .center, spacing: 0) { - Image("logo") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxHeight: 100) - .accessibilityIdentifier("AboutLogo") + Button(action: { + openURL(URL(string: Env.helpUrl)!) + }) { + SettingsRow(title: t("settings__support__help"), iconName: "question") } - .frame(maxWidth: .infinity) - .padding(.bottom, 16) - Social() - .padding(.bottom, 16) + NavigationLink(value: Route.appStatus) { + SettingsRow(title: t("settings__support__status"), iconName: "power") + } + .accessibilityIdentifier("AppStatus") - BodyMText("Bitkit was crafted by Synonym Software, S.A. DE C.V. ©2025. All rights reserved.") - .padding(.bottom, 16) + Button(action: { + openURL(URL(string: Env.termsOfServiceUrl)!) + }) { + SettingsRow(title: t("settings__about__legal"), iconName: "file-text") + } - HStack(alignment: .center, spacing: 10) { - Image("synonym-logo") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(height: 24) + ShareLink(item: shareText, message: Text(shareText)) { + SettingsRow(title: t("settings__about__share"), iconName: "share") + } - Image("tether-logo") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(height: 16) + Button(action: { + onVersionTap() + }) { + SettingsRow( + title: t("settings__about__version"), + iconName: "stack", + rightText: appVersion, + rightIcon: nil + ) } - .frame(maxWidth: .infinity, alignment: .center) - .frame(height: 24) - .padding(.bottom, 32) + .accessibilityIdentifier("DevOptions") + } + + Spacer(minLength: 32) + + VStack(alignment: .center, spacing: 0) { + Image("logo") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxHeight: 100) + .accessibilityIdentifier("AboutLogo") } - .frame(minHeight: geometry.size.height) - .padding(.horizontal, 16) - .bottomSafeAreaPadding() + .frame(maxWidth: .infinity) + .padding(.bottom, 16) + + Social() + .padding(.bottom, 16) + + BodyMText("Bitkit was crafted by Synonym Software, S.A. DE C.V. ©2025. All rights reserved.") + .padding(.bottom, 16) + + HStack(alignment: .center, spacing: 10) { + Image("synonym-logo") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 24) + + Image("tether-logo") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 16) + } + .frame(maxWidth: .infinity, alignment: .center) + .frame(height: 24) + .padding(.bottom, 32) } + .padding(.horizontal, 16) + .bottomSafeAreaPadding() } + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .ignoresSafeArea() - } + ) + .ignoresSafeArea(edges: .bottom) .navigationBarHidden(true) } diff --git a/Bitkit/Views/Shop/ShopDiscover.swift b/Bitkit/Views/Shop/ShopDiscover.swift index 8855af60d..cb3192e87 100644 --- a/Bitkit/Views/Shop/ShopDiscover.swift +++ b/Bitkit/Views/Shop/ShopDiscover.swift @@ -24,16 +24,15 @@ enum ShopTab: String, CaseIterable, CustomStringConvertible { var description: String { switch self { - case .shop: - return t("other__shop__discover__tabs__shop") - case .map: - return t("other__shop__discover__tabs__map") + case .shop: t("other__shop__discover__tabs__shop") + case .map: t("other__shop__discover__tabs__map") } } } struct ShopDiscover: View { @EnvironmentObject var navigation: NavigationViewModel + @State private var selectedTab: ShopTab = .shop /// Categories data @@ -95,129 +94,95 @@ struct ShopDiscover: View { ] var body: some View { - VStack(spacing: 0) { - NavigationBar(title: t("other__shop__discover__nav_title")) - .padding(.horizontal, 16) - - SegmentedControl(selectedTab: $selectedTab, tabs: ShopTab.allCases) - .padding(.horizontal, 16) + InsetHeaderScrollView( + header: { + VStack(spacing: 0) { + NavigationBar(title: t("other__shop__discover__nav_title")) + .padding(.horizontal, 16) - Group { - switch selectedTab { - case .shop: - shopContent - case .map: - ShopWebView(url: Env.btcMapUrl) - .padding(.top, 16) + SegmentedControl(selectedTab: $selectedTab, tabs: ShopTab.allCases) .padding(.horizontal, 16) } + .background( + ZStack { + BlurView() + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color.black, location: 0.0), + .init(color: Color.black, location: 0.4), + .init(color: Color.black.opacity(0), location: 1.0), + ]), + startPoint: .top, + endPoint: .bottom + ) + } + .ignoresSafeArea(edges: .top) + ) + .compositingGroup() + .shadow(color: Color.black.opacity(0.5), radius: 8, x: 0, y: 20) + }, + content: { + Group { + switch selectedTab { + case .shop: + shopContent + case .map: + ShopWebView(url: Env.btcMapUrl) + .padding(.top, 16) + .padding(.horizontal, 16) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .swipeSegmentedTabs(selection: $selectedTab) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } + ) .navigationBarHidden(true) } private var shopContent: some View { - GeometryReader { geometry in - let cardSize = (geometry.size.width - 32 - 16) / 2 - - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - LazyVGrid( - columns: [ - GridItem(.flexible(), spacing: 16), - GridItem(.flexible(), spacing: 16), - ], - spacing: 16 - ) { - ForEach(cards) { card in - ShopDiscoverCard( - title: card.title, - description: card.description, - imageName: card.imageName, - color: card.color, - size: cardSize - ) { - navigation.navigate(.shopMain(page: card.route)) - } + VStack(alignment: .leading, spacing: 0) { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16), + ], + spacing: 16 + ) { + ForEach(cards) { card in + Card( + title: card.title, + description: card.description, + imageName: card.imageName, + accentColor: card.color, + onTap: { + navigation.navigate(.shopMain(page: card.route)) } - } - .padding(.bottom, 16) + ) + } + } + .padding(.bottom, 16) - VStack { - CaptionMText(t("other__shop__discover__label")) - .frame(maxWidth: .infinity, alignment: .leading) - } - .frame(height: 50) + VStack { + CaptionMText(t("other__shop__discover__label")) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.bottom, 8) - - LazyVStack(spacing: 0) { - ForEach(categories) { category in - ShopCategoryRow( - title: category.title, - iconName: category.iconName - ) { - navigation.navigate(.shopMain(page: category.route)) - } - } + } + .frame(height: 50) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 8) + + LazyVStack(spacing: 0) { + ForEach(categories) { category in + ShopCategoryRow( + title: category.title, + iconName: category.iconName + ) { + navigation.navigate(.shopMain(page: category.route)) } } - .padding(.top, 16) - .padding(.horizontal, 16) } } - } -} - -// MARK: - Shop Discover Card Component - -struct ShopDiscoverCard: View { - let title: String - let description: String - let imageName: String - let color: Color - let size: CGFloat - let onTap: () -> Void - - var body: some View { - Button(action: onTap) { - VStack(alignment: .leading, spacing: 0) { - Spacer() - - Image(imageName) - .resizable() - .scaledToFit() - .frame(width: 96, height: 96) - .frame(maxWidth: .infinity, alignment: .center) - - Text(title) - .font(.custom(Fonts.black, size: 20)) - .lineLimit(1) - .kerning(-0.5) - .textCase(.uppercase) - .padding(.top, 4) - - CaptionBText(description) - } - .padding() - .frame(width: size, height: size, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: 16) - .fill( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: color, location: 0.0), - .init(color: Color.black.opacity(0.1), location: 0.9), - .init(color: Color.black, location: 1.0), - ]), - startPoint: .top, - endPoint: .bottom - ) - ) - ) - } - .buttonStyle(PlainButtonStyle()) + .padding(.top, 16) + .padding(.horizontal, 16) } } diff --git a/Bitkit/Views/Wallets/Activity/AllActivityView.swift b/Bitkit/Views/Wallets/Activity/AllActivityView.swift index 0939b097d..041a703a7 100644 --- a/Bitkit/Views/Wallets/Activity/AllActivityView.swift +++ b/Bitkit/Views/Wallets/Activity/AllActivityView.swift @@ -5,92 +5,51 @@ struct AllActivityView: View { @EnvironmentObject private var app: AppViewModel @EnvironmentObject private var wallet: WalletViewModel - private var headerTopPadding: CGFloat { - // NavBar + Filter + SegmentedControl + spacing - return ScreenLayout.topPaddingWithoutSafeArea + 116 - } - var body: some View { ZStack(alignment: .top) { - // ScrollView - base layer, full height, content scrolls behind header - ScrollView(showsIndicators: false) { - ActivityList(viewType: .all) - // .padding(.top, headerTopPadding) - .scrollDismissesKeyboard(.interactively) - .highPriorityGesture( - // TODO: rewrite using TabView - DragGesture(minimumDistance: 20, coordinateSpace: .local) - .onEnded { value in - let horizontalAmount = value.translation.width - let verticalAmount = value.translation.height - - if abs(horizontalAmount) > abs(verticalAmount) { - if horizontalAmount < -50 { - // Swipe left - move to next tab - if let currentIndex = ActivityTab.allCases.firstIndex(of: activity.selectedTab), - currentIndex < ActivityTab.allCases.count - 1 - { - withAnimation(.easeInOut(duration: 0.2)) { - activity.selectedTab = ActivityTab.allCases[currentIndex + 1] - } - } - } else if horizontalAmount > 50 { - // Swipe right - move to previous tab - if let currentIndex = ActivityTab.allCases.firstIndex(of: activity.selectedTab), - currentIndex > 0 - { - withAnimation(.easeInOut(duration: 0.2)) { - activity.selectedTab = ActivityTab.allCases[currentIndex - 1] - } - } - } - } - } - ) - } - .contentMargins(.top, headerTopPadding) - .contentMargins(.bottom, ScreenLayout.bottomPaddingWithSafeArea) - .padding(.horizontal, 16) - .scrollDismissesKeyboard(.interactively) - .refreshable { - do { - try await wallet.sync() - try await activity.syncLdkNodePayments() - } catch { - app.toast(error) - } - } - .transition(.move(edge: .leading).combined(with: .opacity)) + InsetHeaderScrollView( + header: { + VStack(spacing: 0) { + NavigationBar(title: t("wallet__activity")) + .padding(.bottom, 16) - // Header - overlay on top, scroll content goes behind it - VStack(spacing: 0) { - NavigationBar(title: t("wallet__activity")) - .padding(.bottom, 16) + ActivityListFilter(viewModel: activity) + .padding(.bottom, 16) - ActivityListFilter(viewModel: activity) - .padding(.bottom, 16) - - SegmentedControl(selectedTab: $activity.selectedTab, tabs: ActivityTab.allCases) - } - .frame(maxWidth: .infinity, alignment: .top) - .padding(.horizontal, 16) - .background( - ZStack { - BlurView() - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color.black, location: 0.0), - .init(color: Color.black, location: 0.4), - .init(color: Color.black.opacity(0), location: 1.0), - ]), - startPoint: .top, - endPoint: .bottom + SegmentedControl(selectedTab: $activity.selectedTab, tabs: ActivityTab.allCases) + } + .frame(maxWidth: .infinity, alignment: .top) + .padding(.horizontal, 16) + .background( + ZStack { + BlurView() + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color.black, location: 0.0), + .init(color: Color.black, location: 0.4), + .init(color: Color.black.opacity(0), location: 1.0), + ]), + startPoint: .top, + endPoint: .bottom + ) + } + .ignoresSafeArea(edges: .top) ) - } - .ignoresSafeArea(edges: .top) + .compositingGroup() + .shadow(color: Color.black.opacity(0.5), radius: 8, x: 0, y: 20) + }, + content: { + ActivityList(viewType: .all) + .padding(.horizontal, 16) + .swipeSegmentedTabs(selection: $activity.selectedTab) + }, + scrollModifier: ActivityScrollModifier( + activity: activity, + app: app, + wallet: wallet + ) ) - .compositingGroup() - .shadow(color: Color.black.opacity(0.5), radius: 8, x: 0, y: 20) + .transition(.move(edge: .leading).combined(with: .opacity)) // Bottom gradient overlay VStack { @@ -112,10 +71,33 @@ struct AllActivityView: View { } } +private struct ActivityScrollModifier: ViewModifier { + let activity: ActivityListViewModel + let app: AppViewModel + let wallet: WalletViewModel + + func body(content: Content) -> some View { + content + .contentMargins(.top, 16) + .contentMargins(.bottom, ScreenLayout.bottomPaddingWithSafeArea) + .scrollDismissesKeyboard(.interactively) + .refreshable { + do { + try await wallet.sync() + try await activity.syncLdkNodePayments() + } catch { + app.toast(error) + } + } + } +} + #Preview { NavigationStack { AllActivityView() .environmentObject(ActivityListViewModel()) + .environmentObject(AppViewModel()) + .environmentObject(WalletViewModel()) .preferredColorScheme(.dark) } } diff --git a/changelog.d/next/535.changed.md b/changelog.d/next/535.changed.md new file mode 100644 index 000000000..2a446847f --- /dev/null +++ b/changelog.d/next/535.changed.md @@ -0,0 +1 @@ +Added swipe gestures on the tabs in settings and shop and polished the header areas on those screens