From ec634a5a8f00464d3ccf688b50c4c83dbc8d595a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 27 May 2026 10:49:56 -0300 Subject: [PATCH 01/31] feat: data model --- Bitkit/ViewModels/WidgetsViewModel.swift | 66 +++++++++++++++++++----- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index afd9e59ed..7e226e986 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -42,10 +42,25 @@ struct WidgetMetadata { } } +// MARK: - Widget Size + +/// Display size for a widget on the home grid. +/// `small` occupies a single grid column (half-width square); `wide` spans both columns. +enum WidgetSize: String, Codable, CaseIterable { + case small + case wide +} + // MARK: - Widget Models struct Widget: Identifiable { let type: WidgetType + let size: WidgetSize + + init(type: WidgetType, size: WidgetSize = .wide) { + self.type = type + self.size = size + } /// Use type as identifier since only one widget per type is allowed var id: WidgetType { @@ -99,20 +114,36 @@ struct Widget: Identifiable { struct SavedWidget: Codable, Identifiable { let type: WidgetType let optionsData: Data? + let size: WidgetSize /// Use type as identifier since only one widget per type is allowed var id: WidgetType { type } - init(type: WidgetType, optionsData: Data? = nil) { + init(type: WidgetType, optionsData: Data? = nil, size: WidgetSize = .wide) { self.type = type self.optionsData = optionsData + self.size = size + } + + private enum CodingKeys: String, CodingKey { + case type + case optionsData + case size + } + + /// v60 saved blobs have no `size` key — default missing values to `.wide`. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + type = try container.decode(WidgetType.self, forKey: .type) + optionsData = try container.decodeIfPresent(Data.self, forKey: .optionsData) + size = try container.decodeIfPresent(WidgetSize.self, forKey: .size) ?? .wide } /// Convert to Widget for UI func toWidget() -> Widget { - return Widget(type: type) + return Widget(type: type, size: size) } } @@ -161,9 +192,11 @@ class WidgetsViewModel: ObservableObject { /// Default widgets for new installs and resets private static let defaultSavedWidgets: [SavedWidget] = [ - SavedWidget(type: .suggestions), - SavedWidget(type: .price), - SavedWidget(type: .blocks), + SavedWidget(type: .suggestions, size: .wide), + SavedWidget(type: .price, size: .wide), + SavedWidget(type: .blocks, size: .small), + SavedWidget(type: .facts, size: .small), + SavedWidget(type: .news, size: .wide), ] init() { @@ -178,17 +211,22 @@ class WidgetsViewModel: ObservableObject { } /// Save a new widget - func saveWidget(_ type: WidgetType) { - // Don't add duplicates - guard !isWidgetSaved(type) else { return } - - if !savedWidgetsWithOptions.contains(where: { $0.type == type }) { - savedWidgetsWithOptions.append(SavedWidget(type: type)) + func saveWidget(_ type: WidgetType, size: WidgetSize = .wide) { + if let index = savedWidgetsWithOptions.firstIndex(where: { $0.type == type }) { + let existing = savedWidgetsWithOptions[index] + guard existing.size != size else { return } + savedWidgetsWithOptions[index] = SavedWidget(type: type, optionsData: existing.optionsData, size: size) + } else { + savedWidgetsWithOptions.append(SavedWidget(type: type, size: size)) } savedWidgets = savedWidgetsWithOptions.map { $0.toWidget() } persistSavedWidgets() } + func getSize(for type: WidgetType) -> WidgetSize { + savedWidgetsWithOptions.first(where: { $0.type == type })?.size ?? .wide + } + /// Delete a widget func deleteWidget(_ type: WidgetType) { savedWidgetsWithOptions.removeAll { $0.type == type } @@ -238,10 +276,12 @@ class WidgetsViewModel: ObservableObject { // Find existing saved widget or create new one if let index = savedWidgetsWithOptions.firstIndex(where: { $0.type == type }) { - // Update existing widget with new options + // Update existing widget with new options — preserve the chosen size. + let existing = savedWidgetsWithOptions[index] savedWidgetsWithOptions[index] = SavedWidget( type: type, - optionsData: optionsData + optionsData: optionsData, + size: existing.size ) } else { // Create new saved widget with options From 35b2079e6a501cf54e599e8e4c8aaec59fab0a75 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 27 May 2026 11:01:43 -0300 Subject: [PATCH 02/31] feat: sheet infrastructure --- Bitkit/MainNavView.swift | 8 +++ Bitkit/Styles/SheetStyles.swift | 7 +-- Bitkit/ViewModels/SheetViewModel.swift | 15 ++++++ Bitkit/Views/Sheets/Sheet.swift | 13 +++-- .../Views/Widgets/WidgetEditSheetView.swift | 19 +++++++ .../Widgets/WidgetPreviewSheetView.swift | 19 +++++++ .../Views/Widgets/WidgetsListSheetView.swift | 37 +++++++++++++ Bitkit/Views/Widgets/WidgetsSheet.swift | 53 +++++++++++++++++++ 8 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 Bitkit/Views/Widgets/WidgetEditSheetView.swift create mode 100644 Bitkit/Views/Widgets/WidgetPreviewSheetView.swift create mode 100644 Bitkit/Views/Widgets/WidgetsListSheetView.swift create mode 100644 Bitkit/Views/Widgets/WidgetsSheet.swift diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index a89f282a8..f491de87a 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -179,6 +179,14 @@ struct MainNavView: View { ) { config in ForceTransferSheet(config: config) } + .sheet( + item: $sheets.widgetsSheetItem, + onDismiss: { + sheets.hideSheet() + } + ) { + config in WidgetsSheet(config: config) + } .accentColor(.white) .overlay { TabBar() diff --git a/Bitkit/Styles/SheetStyles.swift b/Bitkit/Styles/SheetStyles.swift index d99d2aba7..6ad318cb0 100644 --- a/Bitkit/Styles/SheetStyles.swift +++ b/Bitkit/Styles/SheetStyles.swift @@ -1,8 +1,9 @@ import SwiftUI extension View { - /// Applies a standard dark gradient background for sheet views - func sheetBackground() -> some View { + /// Applies the standard sheet gradient over the given base color. Default base is black; + /// the v61 widgets sheet uses gray7. + func sheetBackground(base: Color = .black) -> some View { background( LinearGradient( gradient: Gradient(colors: [Color.white.opacity(0.08), Color.white.opacity(0.012)]), @@ -10,6 +11,6 @@ extension View { endPoint: .bottom ) ) - .background(Color.black) + .background(base) } } diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index 69219ea2b..9844b01e7 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -22,6 +22,7 @@ enum SheetID: String, CaseIterable { case send case tagFilter case dateRangeSelector + case widgets } struct SheetConfiguration { @@ -350,4 +351,18 @@ class SheetViewModel: ObservableObject { } } } + + var widgetsSheetItem: WidgetsSheetItem? { + get { + guard let config = activeSheetConfiguration, config.id == .widgets else { return nil } + let widgetsConfig = config.data as? WidgetsConfig + let initialRoute = widgetsConfig?.initialRoute ?? .list + return WidgetsSheetItem(initialRoute: initialRoute) + } + set { + if newValue == nil { + activeSheetConfiguration = nil + } + } + } } diff --git a/Bitkit/Views/Sheets/Sheet.swift b/Bitkit/Views/Sheets/Sheet.swift index ee32d67df..6aca98340 100644 --- a/Bitkit/Views/Sheets/Sheet.swift +++ b/Bitkit/Views/Sheets/Sheet.swift @@ -100,10 +100,17 @@ protocol SheetItem: Identifiable { struct Sheet: View { @EnvironmentObject private var sheets: SheetViewModel let configuration: SheetConfiguration + let backgroundColor: Color let content: () -> Content - init(id: SheetID, data: (any SheetItem)? = nil, @ViewBuilder content: @escaping () -> Content) { + init( + id: SheetID, + data: (any SheetItem)? = nil, + backgroundColor: Color = .black, + @ViewBuilder content: @escaping () -> Content + ) { configuration = SheetConfiguration(id: id, data: data) + self.backgroundColor = backgroundColor self.content = content } @@ -117,7 +124,7 @@ struct Sheet: View { var body: some View { ZStack(alignment: .top) { content() - .sheetBackground() + .sheetBackground(base: backgroundColor) .bottomSafeAreaPadding() // Custom drag indicator - always on top @@ -130,6 +137,6 @@ struct Sheet: View { .presentationDetents([.height(sheetSize.height)]) .presentationDragIndicator(.hidden) .presentationCornerRadius(32) - .presentationBackground { Color.black } + .presentationBackground { backgroundColor } } } diff --git a/Bitkit/Views/Widgets/WidgetEditSheetView.swift b/Bitkit/Views/Widgets/WidgetEditSheetView.swift new file mode 100644 index 000000000..aa6647392 --- /dev/null +++ b/Bitkit/Views/Widgets/WidgetEditSheetView.swift @@ -0,0 +1,19 @@ +import SwiftUI + +/// Placeholder — final implementation lands in step 6 (sheet wrapper around existing edit logic). +struct WidgetEditSheetView: View { + let type: WidgetType + @Binding var navigationPath: [WidgetsRoute] + + var body: some View { + VStack(spacing: 16) { + SheetHeader(title: t("widgets__widget__settings"), showBackButton: true) + + Spacer() + BodyMText("Edit placeholder for \(type.rawValue)", textColor: .textSecondary) + Spacer() + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + } +} diff --git a/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift b/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift new file mode 100644 index 000000000..3a1e2eee9 --- /dev/null +++ b/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift @@ -0,0 +1,19 @@ +import SwiftUI + +/// Placeholder — final implementation lands in step 5 (unified preview with size carousel). +struct WidgetPreviewSheetView: View { + let type: WidgetType + @Binding var navigationPath: [WidgetsRoute] + + var body: some View { + VStack(spacing: 16) { + SheetHeader(title: t("widgets__\(type.rawValue)__name"), showBackButton: true) + + Spacer() + BodyMText("Preview placeholder for \(type.rawValue)", textColor: .textSecondary) + Spacer() + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + } +} diff --git a/Bitkit/Views/Widgets/WidgetsListSheetView.swift b/Bitkit/Views/Widgets/WidgetsListSheetView.swift new file mode 100644 index 000000000..a22d10e36 --- /dev/null +++ b/Bitkit/Views/Widgets/WidgetsListSheetView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +/// Placeholder — final implementation lands in step 3 (grid of widget tiles). +struct WidgetsListSheetView: View { + @Binding var navigationPath: [WidgetsRoute] + + var body: some View { + VStack(spacing: 16) { + SheetHeader(title: t("widgets__add")) + + ScrollView { + LazyVStack(spacing: 8) { + ForEach(WidgetType.allCases.filter { $0 != .suggestions }, id: \.rawValue) { type in + Button { + navigationPath.append(.preview(type)) + } label: { + HStack { + BodyMText(t("widgets__\(type.rawValue)__name")) + Spacer() + Image("chevron") + .resizable() + .frame(width: 24, height: 24) + .foregroundColor(.textSecondary) + } + .padding() + .background(Color.gray6) + .cornerRadius(12) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + } + } + .navigationBarHidden(true) + } +} diff --git a/Bitkit/Views/Widgets/WidgetsSheet.swift b/Bitkit/Views/Widgets/WidgetsSheet.swift new file mode 100644 index 000000000..51b2d248a --- /dev/null +++ b/Bitkit/Views/Widgets/WidgetsSheet.swift @@ -0,0 +1,53 @@ +import SwiftUI + +enum WidgetsRoute: Hashable { + case list + case preview(WidgetType) + case edit(WidgetType) +} + +struct WidgetsConfig { + let initialRoute: WidgetsRoute + + init(initialRoute: WidgetsRoute = .list) { + self.initialRoute = initialRoute + } +} + +struct WidgetsSheetItem: SheetItem { + let id: SheetID = .widgets + let size: SheetSize = .large + let initialRoute: WidgetsRoute + + init(initialRoute: WidgetsRoute = .list) { + self.initialRoute = initialRoute + } +} + +struct WidgetsSheet: View { + @State private var navigationPath: [WidgetsRoute] = [] + let config: WidgetsSheetItem + + var body: some View { + Sheet(id: .widgets, data: config, backgroundColor: .gray7) { + NavigationStack(path: $navigationPath) { + viewForRoute(config.initialRoute) + .navigationDestination(for: WidgetsRoute.self) { route in + viewForRoute(route) + } + } + } + } + + @ViewBuilder + private func viewForRoute(_ route: WidgetsRoute) -> some View { + switch route { + case .list: + WidgetsListSheetView(navigationPath: $navigationPath) + case let .preview(type): + WidgetPreviewSheetView(type: type, navigationPath: $navigationPath) + case let .edit(type): + WidgetEditSheetView(type: type, navigationPath: $navigationPath) + } + } +} From 4680fe1e074fc5f959a4eba1f9319fe1de76acd5 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 27 May 2026 11:15:06 -0300 Subject: [PATCH 03/31] feat: add widget list grid --- .../Views/Widgets/WidgetsListSheetView.swift | 200 ++++++++++++++++-- 1 file changed, 180 insertions(+), 20 deletions(-) diff --git a/Bitkit/Views/Widgets/WidgetsListSheetView.swift b/Bitkit/Views/Widgets/WidgetsListSheetView.swift index a22d10e36..9a4a05b61 100644 --- a/Bitkit/Views/Widgets/WidgetsListSheetView.swift +++ b/Bitkit/Views/Widgets/WidgetsListSheetView.swift @@ -1,37 +1,197 @@ import SwiftUI -/// Placeholder — final implementation lands in step 3 (grid of widget tiles). struct WidgetsListSheetView: View { @Binding var navigationPath: [WidgetsRoute] + private static let columns: [GridItem] = [ + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16), + ] + + /// Widget types shown in the add-list, in display order. `suggestions` is system-managed and excluded. + private static let listedTypes: [WidgetType] = [.price, .weather, .news, .blocks, .facts, .calculator] + var body: some View { - VStack(spacing: 16) { + VStack(spacing: 0) { SheetHeader(title: t("widgets__add")) - ScrollView { - LazyVStack(spacing: 8) { - ForEach(WidgetType.allCases.filter { $0 != .suggestions }, id: \.rawValue) { type in - Button { - navigationPath.append(.preview(type)) - } label: { - HStack { - BodyMText(t("widgets__\(type.rawValue)__name")) - Spacer() - Image("chevron") - .resizable() - .frame(width: 24, height: 24) - .foregroundColor(.textSecondary) + ScrollView(showsIndicators: false) { + LazyVGrid(columns: Self.columns, spacing: 16) { + ForEach(Self.listedTypes, id: \.rawValue) { type in + tile(for: type) + .gridCellColumns(displaySize(for: type) == .wide ? 2 : 1) + .onTapGesture { + navigationPath.append(.preview(type)) } - .padding() - .background(Color.gray6) - .cornerRadius(12) - } - .buttonStyle(.plain) + .accessibilityIdentifier("WidgetListItem-\(type.rawValue)") } } .padding(.horizontal, 16) + .padding(.bottom, 16) } } .navigationBarHidden(true) } + + /// Display size each widget uses in the list grid (purely visual — not the saved size). + private func displaySize(for type: WidgetType) -> WidgetSize { + switch type { + case .news, .blocks: return .wide + default: return .small + } + } + + private func tile(for type: WidgetType) -> some View { + VStack(alignment: .leading, spacing: 8) { + BodySSBText(t("widgets__\(type.rawValue)__name"), textColor: .textPrimary) + + tileCard(for: type) + } + } + + private func tileCard(for type: WidgetType) -> some View { + Group { + switch type { + case .price: PriceTile() + case .weather: WeatherTile() + case .news: NewsTile() + case .blocks: BlocksTile() + case .facts: FactsTile() + case .calculator: CalculatorTile() + case .suggestions: EmptyView() + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: displaySize(for: type) == .small ? 160 : nil, alignment: .topLeading) + .background(Color.gray6) + .cornerRadius(16) + } +} + +// MARK: - Per-type tiles + +private struct PriceTile: View { + @StateObject private var viewModel = PriceViewModel.shared + + private let options = PriceWidgetOptions() + + var body: some View { + Group { + if let data = primaryPrice { + PriceWidgetCompactContent(data: data, period: options.selectedPeriod) + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .task { + viewModel.fetchPriceData(pairs: [options.selectedPair], period: options.selectedPeriod) + } + } + + private var primaryPrice: PriceData? { + let data = viewModel.getCurrentData(for: options.selectedPeriod) + return data.first(where: { $0.name == options.selectedPair }) ?? data.first + } +} + +private struct WeatherTile: View { + @StateObject private var viewModel = WeatherViewModel.shared + @EnvironmentObject private var currency: CurrencyViewModel + + private let options = WeatherWidgetOptions() + + var body: some View { + Group { + if let data = viewModel.weatherData { + WeatherWidgetCompactContent( + data: data, + metric: options.selectedMetric, + conditionTitle: t(data.condition.titleKey), + metricLabel: t(options.selectedMetric.labelKey) + ) + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .task { + viewModel.setCurrencyViewModel(currency) + viewModel.startUpdates() + } + } +} + +private struct NewsTile: View { + @StateObject private var viewModel = NewsViewModel.shared + + private let options = NewsWidgetOptions() + + var body: some View { + Group { + if let data = viewModel.widgetData { + NewsWidgetWideContent( + title: data.title, + publisher: data.publisher, + timeAgo: data.timeAgo, + options: options + ) + } else { + ProgressView() + .frame(maxWidth: .infinity, minHeight: 60) + } + } + .task { + viewModel.startUpdates() + } + } +} + +private struct BlocksTile: View { + @StateObject private var viewModel = BlocksViewModel.shared + + private let options = BlocksWidgetOptions() + + var body: some View { + Group { + if let data = viewModel.blockData { + BlocksWidgetWideContent(data: data, options: options) + } else { + ProgressView() + .frame(maxWidth: .infinity, minHeight: 60) + } + } + .task { + viewModel.startUpdates() + } + } +} + +private struct FactsTile: View { + @StateObject private var viewModel = FactsViewModel.shared + + var body: some View { + FactsWidgetCompactContent(fact: viewModel.fact) + } +} + +private struct CalculatorTile: View { + /// Calculator has no compact content form. Show a static representation + /// so the tile reads as a calculator at a glance. + var body: some View { + VStack(alignment: .leading, spacing: 4) { + BodyMSBText("$0.00", textColor: .textPrimary) + BodySText("0", textColor: .textSecondary) + Spacer() + HStack(spacing: 4) { + ForEach(0 ..< 3) { _ in + RoundedRectangle(cornerRadius: 4) + .fill(Color.white10) + .frame(height: 16) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } } From 2dd0bcce66a938ef236d64d56933206cd38c46bc Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 27 May 2026 11:28:19 -0300 Subject: [PATCH 04/31] feat: unified preview sheet --- .../Widgets/WidgetPreviewSheetView.swift | 572 +++++++++++++++++- 1 file changed, 566 insertions(+), 6 deletions(-) diff --git a/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift b/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift index 3a1e2eee9..af160aa96 100644 --- a/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift +++ b/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift @@ -1,19 +1,579 @@ import SwiftUI -/// Placeholder — final implementation lands in step 5 (unified preview with size carousel). +/// Unified preview screen for every widget type. +/// User picks `small` vs `wide` via the carousel; tapping "Save Widget" persists the chosen size. struct WidgetPreviewSheetView: View { let type: WidgetType @Binding var navigationPath: [WidgetsRoute] + @EnvironmentObject private var currency: CurrencyViewModel + @EnvironmentObject private var sheets: SheetViewModel + @EnvironmentObject private var widgets: WidgetsViewModel + + @State private var carouselPage: Int + @State private var showDeleteAlert = false + + init(type: WidgetType, navigationPath: Binding<[WidgetsRoute]>) { + self.type = type + _navigationPath = navigationPath + _carouselPage = State(initialValue: type == .calculator ? 1 : Self.initialCarouselPage(for: type, widgets: nil)) + } + + /// Picks the page index that matches the widget's currently-saved size (or `.small` if new). + /// Called from `task` once the environment is available. + private static func initialCarouselPage(for type: WidgetType, widgets: WidgetsViewModel?) -> Int { + guard let widgets, widgets.isWidgetSaved(type) else { return 0 } + return widgets.getSize(for: type) == .wide ? 1 : 0 + } + + private var metadata: WidgetMetadata { + WidgetMetadata(type: type, fiatSymbol: currency.symbol) + } + + private var hasSettings: Bool { + switch type { + case .price, .news, .blocks, .weather: return true + case .facts, .calculator, .suggestions: return false + } + } + + private var supportsSmall: Bool { + type != .calculator + } + + private var isWidgetSaved: Bool { + widgets.isWidgetSaved(type) + } + + private var hasCustomOptions: Bool { + widgets.hasCustomOptions(for: type) + } + + private var chosenSize: WidgetSize { + carouselPage == 0 && supportsSmall ? .small : .wide + } + var body: some View { - VStack(spacing: 16) { - SheetHeader(title: t("widgets__\(type.rawValue)__name"), showBackButton: true) + VStack(alignment: .leading, spacing: 16) { + SheetHeader(title: metadata.name, showBackButton: true) + + VStack(alignment: .leading, spacing: 0) { + BodyMText(metadata.description, textColor: .textSecondary) + .padding(.bottom, 16) - Spacer() - BodyMText("Preview placeholder for \(type.rawValue)", textColor: .textSecondary) - Spacer() + Divider().background(Color.white.opacity(0.1)) + + if hasSettings { + settingsRow + Divider().background(Color.white.opacity(0.1)) + } + } + + VStack(spacing: 16) { + carousel + sizeLabel + pageIndicator + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + buttonsRow } .navigationBarHidden(true) .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .task { + if supportsSmall { + carouselPage = Self.initialCarouselPage(for: type, widgets: widgets) + } + } + .alert( + t("widgets__delete__title"), + isPresented: $showDeleteAlert, + actions: { + Button(t("common__cancel"), role: .cancel) { showDeleteAlert = false } + Button(t("common__delete_yes"), role: .destructive) { onDelete() } + }, + message: { + Text(t("widgets__delete__description", variables: ["name": metadata.name])) + } + ) + } + + // MARK: - Settings cell + + private var settingsRow: some View { + Button { + navigationPath.append(.edit(type)) + } label: { + HStack(alignment: .center, spacing: 0) { + BodyMText(t("widgets__widget__settings"), textColor: .textPrimary) + + Spacer() + + BodyMText( + hasCustomOptions + ? t("widgets__widget__edit_custom") + : t("widgets__widget__edit_default"), + textColor: .textSecondary + ) + + Image("chevron") + .resizable() + .foregroundColor(.textSecondary) + .frame(width: 24, height: 24) + .padding(.leading, 5) + } + .frame(maxWidth: .infinity, minHeight: 51) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("WidgetEdit") + } + + // MARK: - Carousel + + private var carousel: some View { + TabView(selection: $carouselPage) { + if supportsSmall { + smallPage.tag(0) + } + widePage.tag(1) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(maxHeight: .infinity) + } + + private var smallPage: some View { + VStack { + Spacer(minLength: 0) + smallContent + .frame(width: 163, height: 192) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + } + + private var widePage: some View { + VStack { + Spacer(minLength: 0) + wideContent + .frame(maxWidth: .infinity) + Spacer(minLength: 0) + } + } + + @ViewBuilder + private var smallContent: some View { + switch type { + case .price: PriceSmallPreview() + case .news: NewsSmallPreview() + case .blocks: BlocksSmallPreview() + case .weather: WeatherSmallPreview() + case .facts: FactsSmallPreview() + case .calculator, .suggestions: EmptyView() + } + } + + @ViewBuilder + private var wideContent: some View { + switch type { + case .price: PriceWidePreview() + case .news: NewsWidePreview() + case .blocks: BlocksWidePreview() + case .weather: WeatherWidePreview() + case .facts: FactsWidePreview() + case .calculator: CalculatorWidePreview() + case .suggestions: EmptyView() + } + } + + // MARK: - Size label & page indicator + + @ViewBuilder + private var sizeLabel: some View { + if supportsSmall { + HStack { + Spacer() + CaptionMText( + carouselPage == 0 + ? t("widgets__widget__size_small") + : t("widgets__widget__size_wide"), + textColor: .textSecondary + ) + .textCase(.uppercase) + Spacer() + } + } + } + + @ViewBuilder + private var pageIndicator: some View { + if supportsSmall { + HStack(spacing: 8) { + Spacer() + ForEach(0 ..< 2, id: \.self) { index in + Circle() + .fill(carouselPage == index ? Color.white : Color.white.opacity(0.32)) + .frame(width: 8, height: 8) + } + Spacer() + } + } + } + + // MARK: - Buttons + + private var buttonsRow: some View { + HStack(spacing: 16) { + if isWidgetSaved { + CustomButton( + title: t("common__delete"), + variant: .secondary, + size: .large, + shouldExpand: true + ) { + showDeleteAlert = true + } + .accessibilityIdentifier("WidgetDelete") + } + + CustomButton( + title: t("widgets__widget__save_widget"), + variant: .primary, + size: .large, + shouldExpand: true, + action: onSave + ) + .accessibilityIdentifier("WidgetSave") + } + } + + // MARK: - Actions + + private func onSave() { + widgets.saveWidget(type, size: chosenSize) + sheets.hideSheet() + } + + private func onDelete() { + widgets.deleteWidget(type) + sheets.hideSheet() + } +} + +// MARK: - Per-type preview pages + +// +// Each owns its singleton view-model observation and applies the standard card chrome +// (gray6 background, 16pt corner radius, 16pt padding). + +private struct PriceSmallPreview: View { + @EnvironmentObject private var widgets: WidgetsViewModel + @StateObject private var viewModel = PriceViewModel.shared + + private var options: PriceWidgetOptions { + widgets.getOptions(for: .price, as: PriceWidgetOptions.self) + } + + var body: some View { + Group { + if let data = primary { + PriceWidgetCompactContent(data: data, period: options.selectedPeriod) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + } else { + Color.gray6 + .cornerRadius(16) + .overlay(ProgressView()) + } + } + .task(id: options) { + viewModel.fetchPriceData(pairs: [options.selectedPair], period: options.selectedPeriod) + } + } + + private var primary: PriceData? { + let data = viewModel.getCurrentData(for: options.selectedPeriod) + return data.first(where: { $0.name == options.selectedPair }) ?? data.first + } +} + +private struct PriceWidePreview: View { + @EnvironmentObject private var widgets: WidgetsViewModel + @StateObject private var viewModel = PriceViewModel.shared + + private var options: PriceWidgetOptions { + widgets.getOptions(for: .price, as: PriceWidgetOptions.self) + } + + var body: some View { + Group { + if let data = primary { + PriceWidgetWideContent(data: data, period: options.selectedPeriod) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + } else { + Color.gray6 + .cornerRadius(16) + .frame(height: 152) + .overlay(ProgressView()) + } + } + .task(id: options) { + viewModel.fetchPriceData(pairs: [options.selectedPair], period: options.selectedPeriod) + } + } + + private var primary: PriceData? { + let data = viewModel.getCurrentData(for: options.selectedPeriod) + return data.first(where: { $0.name == options.selectedPair }) ?? data.first + } +} + +private struct NewsSmallPreview: View { + @EnvironmentObject private var widgets: WidgetsViewModel + @StateObject private var viewModel = NewsViewModel.shared + + private var options: NewsWidgetOptions { + widgets.getOptions(for: .news, as: NewsWidgetOptions.self) + } + + var body: some View { + Group { + if let data = viewModel.widgetData { + NewsWidgetCompactContent(title: data.title, timeAgo: data.timeAgo, options: options) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + } else { + Color.gray6 + .cornerRadius(16) + .overlay(ProgressView()) + } + } + .task { viewModel.startUpdates() } + } +} + +private struct NewsWidePreview: View { + @EnvironmentObject private var widgets: WidgetsViewModel + @StateObject private var viewModel = NewsViewModel.shared + + private var options: NewsWidgetOptions { + widgets.getOptions(for: .news, as: NewsWidgetOptions.self) + } + + var body: some View { + Group { + if let data = viewModel.widgetData { + NewsWidgetWideContent(title: data.title, publisher: data.publisher, timeAgo: data.timeAgo, options: options) + .frame(height: NewsWidgetWideContent.inAppContentHeight) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + } else { + ProgressView() + .frame(maxWidth: .infinity) + .frame(height: NewsWidgetWideContent.inAppContentHeight) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + } + } + .task { viewModel.startUpdates() } + } +} + +private struct BlocksSmallPreview: View { + @EnvironmentObject private var widgets: WidgetsViewModel + @StateObject private var viewModel = BlocksViewModel.shared + + private var options: BlocksWidgetOptions { + widgets.getOptions(for: .blocks, as: BlocksWidgetOptions.self) + } + + var body: some View { + Group { + if let data = viewModel.blockData { + BlocksWidgetCompactContent(data: data, options: options) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + } else { + Color.gray6 + .cornerRadius(16) + .overlay(ProgressView()) + } + } + .task { viewModel.startUpdates() } + } +} + +private struct BlocksWidePreview: View { + @EnvironmentObject private var widgets: WidgetsViewModel + @StateObject private var viewModel = BlocksViewModel.shared + + private var options: BlocksWidgetOptions { + widgets.getOptions(for: .blocks, as: BlocksWidgetOptions.self) + } + + var body: some View { + Group { + if let data = viewModel.blockData { + BlocksWidgetWideContent(data: data, options: options) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + } else { + ProgressView() + .frame(maxWidth: .infinity) + .frame(height: BlocksWidgetWideContent.inAppContentHeight) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + } + } + .task { viewModel.startUpdates() } + } +} + +private struct WeatherSmallPreview: View { + @EnvironmentObject private var widgets: WidgetsViewModel + @EnvironmentObject private var currency: CurrencyViewModel + @StateObject private var viewModel = WeatherViewModel.shared + + private var options: WeatherWidgetOptions { + widgets.getOptions(for: .weather, as: WeatherWidgetOptions.self) + } + + var body: some View { + Group { + if let data = viewModel.weatherData { + WeatherWidgetCompactContent( + data: data, + metric: options.selectedMetric, + conditionTitle: t(data.condition.titleKey), + metricLabel: t(options.selectedMetric.labelKey) + ) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + } else { + Color.gray6 + .cornerRadius(16) + .overlay(ProgressView()) + } + } + .task { + viewModel.setCurrencyViewModel(currency) + viewModel.startUpdates() + } + } +} + +private struct WeatherWidePreview: View { + @EnvironmentObject private var widgets: WidgetsViewModel + @EnvironmentObject private var currency: CurrencyViewModel + @StateObject private var viewModel = WeatherViewModel.shared + + private var options: WeatherWidgetOptions { + widgets.getOptions(for: .weather, as: WeatherWidgetOptions.self) + } + + var body: some View { + Group { + if let data = viewModel.weatherData { + WeatherWidgetWideContent( + data: data, + metric: options.selectedMetric, + conditionTitle: t(data.condition.titleKey), + conditionDescription: t(data.condition.descriptionKey), + metricLabel: t(options.selectedMetric.labelKey) + ) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + } else { + ProgressView() + .frame(maxWidth: .infinity, minHeight: 120) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + } + } + .task { + viewModel.setCurrencyViewModel(currency) + viewModel.startUpdates() + } + } +} + +private struct FactsSmallPreview: View { + @StateObject private var viewModel = FactsViewModel.shared + + var body: some View { + FactsWidgetCompactContent(fact: viewModel.fact) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + } +} + +private struct FactsWidePreview: View { + @StateObject private var viewModel = FactsViewModel.shared + + var body: some View { + FactsWidgetWideContent(fact: viewModel.fact) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + .frame(maxWidth: .infinity) + } +} + +private struct CalculatorWidePreview: View { + @EnvironmentObject private var currency: CurrencyViewModel + + @State private var previewActiveInput: CalculatorMoneyType? + @State private var values = CalculatorWidgetValues() + + var body: some View { + CalculatorWidgetWideContent( + values: values, + activeInput: previewActiveInput, + onSelectInput: { input in previewActiveInput = input } + ) + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + .frame(maxWidth: .infinity) + .task { hydrate() } + .onChange(of: currency.selectedCurrency) { hydrate() } + .onChange(of: currency.displayUnit) { hydrate() } + .onChange(of: currency.rates) { hydrate() } + .onDisappear { previewActiveInput = nil } + } + + private func hydrate() { + let saved = CalculatorWidgetOptionsStore.load() + let bitcoinValue = CalculatorWidgetPreviewView.previewBitcoinValue(saved: saved, displayUnit: currency.displayUnit) + + values = CalculatorWidgetValues( + bitcoinValue: bitcoinValue, + fiatValue: CalculatorWidgetPreviewView.previewFiatValue(saved: saved, recalculatedFiatValue: fiatValue(for: bitcoinValue)), + displayUnit: currency.displayUnit, + currencySymbol: currency.symbol, + selectedCurrency: currency.selectedCurrency + ) + } + + private func fiatValue(for bitcoinValue: String) -> String { + guard !bitcoinValue.isEmpty else { return "" } + let sats = CalculatorWidgetFormatter.bitcoinValueToSats(bitcoinValue, displayUnit: currency.displayUnit) + if sats == 0 { return "0.00" } + guard let converted = currency.convert(sats: sats) else { return "" } + return CalculatorWidgetFormatter.fiatRawValue(from: converted.value) } } From 12a05cc11a0304f8b25c455f987b46645568a401 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 27 May 2026 11:35:56 -0300 Subject: [PATCH 05/31] feat: edit sheet shell --- .../Views/Widgets/WidgetEditSheetView.swift | 100 +++++++++++++++++- 1 file changed, 95 insertions(+), 5 deletions(-) diff --git a/Bitkit/Views/Widgets/WidgetEditSheetView.swift b/Bitkit/Views/Widgets/WidgetEditSheetView.swift index aa6647392..9ecf88907 100644 --- a/Bitkit/Views/Widgets/WidgetEditSheetView.swift +++ b/Bitkit/Views/Widgets/WidgetEditSheetView.swift @@ -1,19 +1,109 @@ import SwiftUI -/// Placeholder — final implementation lands in step 6 (sheet wrapper around existing edit logic). +/// Sheet-shell wrapper around the existing `WidgetEditLogic` + `WidgetEditItemView` flow. +/// Header swaps the full-screen `NavigationBar` for a `SheetHeader` and the "Preview" +/// button pops back to the preview route inside the same sheet navigation stack. struct WidgetEditSheetView: View { let type: WidgetType @Binding var navigationPath: [WidgetsRoute] + @EnvironmentObject private var currency: CurrencyViewModel + @EnvironmentObject private var widgets: WidgetsViewModel + + @State private var editLogic: WidgetEditLogic? + @State private var refreshTrigger = false + + @StateObject private var blocksViewModel = BlocksViewModel.shared + @StateObject private var newsViewModel = NewsViewModel.shared + @StateObject private var priceViewModel = PriceViewModel.shared + @StateObject private var weatherViewModel = WeatherViewModel.shared + + private var widgetName: String { + t("widgets__\(type.rawValue)__name") + } + var body: some View { - VStack(spacing: 16) { - SheetHeader(title: t("widgets__widget__settings"), showBackButton: true) + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: widgetName, showBackButton: true) + + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 0) { + ForEach(items, id: \.key) { item in + WidgetEditItemView( + item: item, + onToggle: { editLogic?.toggleOption(item) } + ) + .accessibilityIdentifier("\(item.key)_setting_row") + } + } + .id(refreshTrigger) + } Spacer() - BodyMText("Edit placeholder for \(type.rawValue)", textColor: .textSecondary) - Spacer() + + HStack(spacing: 16) { + CustomButton( + title: t("common__reset"), + variant: .secondary, + size: .large, + isDisabled: !(editLogic?.hasEdited ?? false), + shouldExpand: true, + action: onReset + ) + .accessibilityIdentifier("WidgetEditReset") + + CustomButton( + title: t("common__preview"), + variant: .primary, + size: .large, + isDisabled: !(editLogic?.hasEnabledOption ?? false), + shouldExpand: true, + action: onPreview + ) + .accessibilityIdentifier("WidgetEditPreview") + } + .padding(.top, 16) } .navigationBarHidden(true) .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + if editLogic == nil { + let logic = WidgetEditLogic(widgetType: type, widgetsViewModel: widgets) + logic.onStateChange = { refreshTrigger.toggle() } + editLogic = logic + } + editLogic?.loadCurrentOptions() + + if type == .price { + priceViewModel.fetchForEditView() + } + } + } + + private var items: [WidgetEditItem] { + guard let editLogic else { return [] } + return WidgetEditItemFactory.getItems( + for: type, + blocksViewModel: blocksViewModel, + newsViewModel: newsViewModel, + priceDataByPeriod: priceViewModel.dataByPeriod, + weatherViewModel: weatherViewModel, + blocksOptions: editLogic.blocksOptions, + newsOptions: editLogic.newsOptions, + priceOptions: editLogic.priceOptions, + weatherOptions: editLogic.weatherOptions + ) + } + + private func onPreview() { + editLogic?.saveOptions() + if !navigationPath.isEmpty { + navigationPath.removeLast() + } + } + + private func onReset() { + editLogic?.resetOptions() } } From ff016ae4d20e0451578ec324e8c5ea125d8f0ec0 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 27 May 2026 13:47:15 -0300 Subject: [PATCH 06/31] feat: widgets grid --- Bitkit/Components/Widgets/BaseWidget.swift | 279 +++++++++--------- Bitkit/Components/Widgets/BlocksWidget.swift | 12 +- Bitkit/Components/Widgets/FactsWidget.swift | 10 +- Bitkit/Components/Widgets/NewsWidget.swift | 26 +- Bitkit/Components/Widgets/PriceWidget.swift | 10 +- Bitkit/Components/Widgets/WeatherWidget.swift | 27 +- Bitkit/ViewModels/WidgetsViewModel.swift | 18 +- Bitkit/Views/Home/HomeWidgetsView.swift | 119 +++++++- .../Views/Widgets/WidgetsListSheetView.swift | 66 ++++- 9 files changed, 386 insertions(+), 181 deletions(-) diff --git a/Bitkit/Components/Widgets/BaseWidget.swift b/Bitkit/Components/Widgets/BaseWidget.swift index 4d543743b..a3fdb7eab 100644 --- a/Bitkit/Components/Widgets/BaseWidget.swift +++ b/Bitkit/Components/Widgets/BaseWidget.swift @@ -60,195 +60,200 @@ enum WidgetContentBuilder { } } -/// BaseWidget component that forms the foundation for all widget types in the app -struct BaseWidget: View { - // MARK: - Properties +/// Drop type used by the home grid drag-and-drop reorder. +/// Drag payload is the widget's `WidgetType.rawValue`. +let widgetReorderDragType = "to.bitkit.widget.reorder" - /// Widget type identifier +/// Foundation container for all widgets. Owns the card chrome (gray6 bg, 16pt radius), +/// the v61 editing overlay (dashed brand border + centred action icons), and the small/wide +/// sizing rules used by the home grid. +struct BaseWidget: View { let type: WidgetType - - /// Content to display within the widget let content: Content - - /// Flag indicating if the widget is in editing mode + var size: WidgetSize = .wide var isEditing: Bool = false - - /// When false, the widget content has no gray background (e.g. suggestions). var hasBackground: Bool = true - - /// Callback to signal when editing should end var onEditingEnd: (() -> Void)? - /// State for showing the delete confirmation dialog @State private var showDeleteDialog = false - @EnvironmentObject private var navigation: NavigationViewModel - @EnvironmentObject private var widgets: WidgetsViewModel @EnvironmentObject private var currency: CurrencyViewModel + @EnvironmentObject private var sheets: SheetViewModel + @EnvironmentObject private var widgets: WidgetsViewModel - /// Widget metadata computed from type - private var metadata: WidgetMetadata { - let fiatSymbol = currency.symbol - return WidgetMetadata(type: type, fiatSymbol: fiatSymbol) + private static var smallHeight: CGFloat { + 192 } - // MARK: - Initialization + private var metadata: WidgetMetadata { + WidgetMetadata(type: type, fiatSymbol: currency.symbol) + } - /// Initialize a new widget with required and optional parameters - /// - Parameters: - /// - type: Widget type identifier - /// - isEditing: Flag indicating if the widget is in editing mode - /// - onEditingEnd: Callback to signal when editing should end - /// - content: Content view builder for the widget init( type: WidgetType, + size: WidgetSize = .wide, isEditing: Bool = false, hasBackground: Bool = true, onEditingEnd: (() -> Void)? = nil, @ViewBuilder content: () -> Content ) { self.type = type + self.size = size self.isEditing = isEditing self.hasBackground = hasBackground self.onEditingEnd = onEditingEnd self.content = content() } - private func onEdit() { - navigation.navigate(.widgetDetail(type)) - onEditingEnd?() - } - - private func onDelete() { - showDeleteDialog = true - } - var body: some View { - VStack(spacing: 0) { - if isEditing { - HStack { - HStack(spacing: 16) { - Image(metadata.icon) - .resizable() - .frame(width: 32, height: 32) - - BodyMSBText(truncate(metadata.name, 18)) - .lineLimit(1) - } - - Spacer() - - // Action buttons when in edit mode - if isEditing { - HStack(spacing: 8) { - // Delete button - Button { - onDelete() - } label: { - Image("trash") - .resizable() - .foregroundColor(.textPrimary) - .frame(width: 24, height: 24) - } - .frame(width: 32, height: 32) - .contentShape(Rectangle()) - .accessibilityIdentifier("\(metadata.name)_WidgetActionDelete") - - // Edit button - Button { - onEdit() - } label: { - Image("gear-six") - .resizable() - .foregroundColor(.textPrimary) - .frame(width: 24, height: 24) - } - .frame(width: 32, height: 32) - .contentShape(Rectangle()) - .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") - } + cardBody + .frame(maxWidth: .infinity) + .frame(height: size == .small ? Self.smallHeight : nil, alignment: .topLeading) + .accessibilityElement(children: .contain) + .accessibilityIdentifierIfPresent(isEditing ? nil : "\(type.rawValue.capitalized)Widget") + .alert( + t("widgets__delete__title"), + isPresented: $showDeleteDialog, + actions: { + Button(t("common__cancel"), role: .cancel) { showDeleteDialog = false } + Button(t("common__delete_yes"), role: .destructive) { + widgets.deleteWidget(type) + showDeleteDialog = false } + }, + message: { + Text(t("widgets__delete__description", variables: ["name": metadata.name])) } - } + ) + } - // Widget content (only shown when not editing) - if !isEditing { - content + private var cardBody: some View { + ZStack { + content + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .opacity(isEditing ? 0.2 : 1) + .accessibilityHidden(isEditing) + + if isEditing { + editingOverlay } } - .contentShape(Rectangle()) - .accessibilityElement(children: .contain) - .accessibilityIdentifierIfPresent(isEditing ? nil : "\(type.rawValue.capitalized)Widget") - .frame(maxWidth: .infinity) .padding((hasBackground || isEditing) ? 16 : 0) .background((hasBackground || isEditing) ? Color.gray6 : Color.clear) .cornerRadius(hasBackground || isEditing ? 16 : 0) - .alert( - t("widgets__delete__title"), - isPresented: $showDeleteDialog, - actions: { - Button(t("common__cancel"), role: .cancel) { - showDeleteDialog = false - } + .overlay { + if isEditing { + RoundedRectangle(cornerRadius: 16) + .strokeBorder( + Color.brandAccent, + style: StrokeStyle(lineWidth: 2, dash: [6, 4]) + ) + } + } + } - Button(t("common__delete_yes"), role: .destructive) { - widgets.deleteWidget(type) - showDeleteDialog = false + private var editingOverlay: some View { + VStack(spacing: 12) { + BodyMSBText(metadata.name) + .lineLimit(1) + .minimumScaleFactor(0.8) + + HStack(spacing: 16) { + Button(action: { showDeleteDialog = true }) { + Image("trash") + .resizable() + .foregroundColor(.textPrimary) + .frame(width: 24, height: 24) + } + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + .accessibilityIdentifier("\(metadata.name)_WidgetActionDelete") + + Button(action: onEdit) { + Image("gear-six") + .resizable() + .foregroundColor(.textPrimary) + .frame(width: 24, height: 24) } - }, - message: { - Text(t("widgets__delete__description", variables: ["name": metadata.name])) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + .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() + } + .draggable(type.rawValue) { + dragPreview + } + .accessibilityIdentifier("\(metadata.name)_WidgetActionReorder") } - ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } - /// Truncate a string to a maximum length - private func truncate(_ text: String, _ maxLength: Int) -> String { - if text.count <= maxLength { - return text + /// Snapshot of the card in its editing state — dashed brand border, centred name, + /// gray6 fill. Used as the floating preview while the user drags the burger handle so + /// the dashed component "follows" the finger instead of just the icon. + private var dragPreview: some View { + VStack(spacing: 12) { + BodyMSBText(metadata.name) + .lineLimit(1) + .minimumScaleFactor(0.8) + + HStack(spacing: 16) { + Image("trash") + .resizable() + .foregroundColor(.textPrimary) + .frame(width: 24, height: 24) + Image("gear-six") + .resizable() + .foregroundColor(.textPrimary) + .frame(width: 24, height: 24) + Image("burger") + .resizable() + .foregroundColor(.textPrimary) + .frame(width: 24, height: 24) + } } + .frame( + width: size == .small ? 172 : 343, + height: size == .small ? Self.smallHeight : 120 + ) + .background(Color.gray6) + .cornerRadius(16) + .overlay { + RoundedRectangle(cornerRadius: 16) + .strokeBorder( + Color.brandAccent, + style: StrokeStyle(lineWidth: 2, dash: [6, 4]) + ) + } + } - let index = text.index(text.startIndex, offsetBy: maxLength - 3) - return String(text[..: View { } .padding() .background(Color.black) - .environmentObject(WidgetsViewModel()) - .environmentObject(NavigationViewModel()) .environmentObject(CurrencyViewModel()) .environmentObject(SettingsViewModel.shared) + .environmentObject(SheetViewModel()) + .environmentObject(WidgetsViewModel()) .preferredColorScheme(.dark) } diff --git a/Bitkit/Components/Widgets/BlocksWidget.swift b/Bitkit/Components/Widgets/BlocksWidget.swift index faae7a4d0..dd1c0fb5f 100644 --- a/Bitkit/Components/Widgets/BlocksWidget.swift +++ b/Bitkit/Components/Widgets/BlocksWidget.swift @@ -6,6 +6,7 @@ import SwiftUI /// and the wide carousel page on the preview screen. struct BlocksWidget: View { var options: BlocksWidgetOptions = .init() + var size: WidgetSize = .wide var isEditing: Bool = false var onEditingEnd: (() -> Void)? @@ -13,10 +14,12 @@ struct BlocksWidget: View { init( options: BlocksWidgetOptions = BlocksWidgetOptions(), + size: WidgetSize = .wide, isEditing: Bool = false, onEditingEnd: (() -> Void)? = nil ) { self.options = options + self.size = size self.isEditing = isEditing self.onEditingEnd = onEditingEnd } @@ -24,6 +27,7 @@ struct BlocksWidget: View { var body: some View { BaseWidget( type: .blocks, + size: size, isEditing: isEditing, onEditingEnd: onEditingEnd ) { @@ -41,8 +45,12 @@ struct BlocksWidget: View { } else if viewModel.error != nil && viewModel.blockData == nil { WidgetContentBuilder.errorView(t("widgets__blocks__error")) } else if let data = viewModel.blockData { - BlocksWidgetWideContent(data: data, options: options) - .frame(height: BlocksWidgetWideContent.inAppContentHeight) + if size == .small { + BlocksWidgetCompactContent(data: data, options: options) + } else { + BlocksWidgetWideContent(data: data, options: options) + .frame(height: BlocksWidgetWideContent.inAppContentHeight) + } } } } diff --git a/Bitkit/Components/Widgets/FactsWidget.swift b/Bitkit/Components/Widgets/FactsWidget.swift index f2c8f5652..80d6660ca 100644 --- a/Bitkit/Components/Widgets/FactsWidget.swift +++ b/Bitkit/Components/Widgets/FactsWidget.swift @@ -1,15 +1,18 @@ import SwiftUI struct FactsWidget: View { + var size: WidgetSize = .wide var isEditing: Bool = false var onEditingEnd: (() -> Void)? @StateObject private var viewModel = FactsViewModel.shared init( + size: WidgetSize = .wide, isEditing: Bool = false, onEditingEnd: (() -> Void)? = nil ) { + self.size = size self.isEditing = isEditing self.onEditingEnd = onEditingEnd } @@ -17,10 +20,15 @@ struct FactsWidget: View { var body: some View { BaseWidget( type: .facts, + size: size, isEditing: isEditing, onEditingEnd: onEditingEnd ) { - FactsWidgetWideContent(fact: viewModel.fact) + if size == .small { + FactsWidgetCompactContent(fact: viewModel.fact) + } else { + FactsWidgetWideContent(fact: viewModel.fact) + } } } } diff --git a/Bitkit/Components/Widgets/NewsWidget.swift b/Bitkit/Components/Widgets/NewsWidget.swift index 1ba8a3f4f..66c0fe362 100644 --- a/Bitkit/Components/Widgets/NewsWidget.swift +++ b/Bitkit/Components/Widgets/NewsWidget.swift @@ -3,6 +3,7 @@ import SwiftUI /// A widget that displays a news article. struct NewsWidget: View { var options: NewsWidgetOptions = .init() + var size: WidgetSize = .wide var isEditing: Bool = false var onEditingEnd: (() -> Void)? @@ -10,10 +11,12 @@ struct NewsWidget: View { init( options: NewsWidgetOptions = NewsWidgetOptions(), + size: WidgetSize = .wide, isEditing: Bool = false, onEditingEnd: (() -> Void)? = nil ) { self.options = options + self.size = size self.isEditing = isEditing self.onEditingEnd = onEditingEnd } @@ -21,6 +24,7 @@ struct NewsWidget: View { var body: some View { BaseWidget( type: .news, + size: size, isEditing: isEditing, onEditingEnd: onEditingEnd ) { @@ -44,13 +48,21 @@ struct NewsWidget: View { } else if viewModel.error != nil { WidgetContentBuilder.errorView(t("widgets__news__error")) } else if let data = viewModel.widgetData { - NewsWidgetWideContent( - title: data.title, - publisher: data.publisher, - timeAgo: data.timeAgo, - options: options - ) - .frame(height: NewsWidgetWideContent.inAppContentHeight) + if size == .small { + NewsWidgetCompactContent( + title: data.title, + timeAgo: data.timeAgo, + options: options + ) + } else { + NewsWidgetWideContent( + title: data.title, + publisher: data.publisher, + timeAgo: data.timeAgo, + options: options + ) + .frame(height: NewsWidgetWideContent.inAppContentHeight) + } } } } diff --git a/Bitkit/Components/Widgets/PriceWidget.swift b/Bitkit/Components/Widgets/PriceWidget.swift index da82ac30e..1131ae13a 100644 --- a/Bitkit/Components/Widgets/PriceWidget.swift +++ b/Bitkit/Components/Widgets/PriceWidget.swift @@ -3,6 +3,7 @@ import SwiftUI /// Displays Bitcoin price for the user's selected trading pair and timeframe. struct PriceWidget: View { var options: PriceWidgetOptions = .init() + var size: WidgetSize = .wide var isEditing: Bool = false var onEditingEnd: (() -> Void)? @@ -10,10 +11,12 @@ struct PriceWidget: View { init( options: PriceWidgetOptions = PriceWidgetOptions(), + size: WidgetSize = .wide, isEditing: Bool = false, onEditingEnd: (() -> Void)? = nil ) { self.options = options + self.size = size self.isEditing = isEditing self.onEditingEnd = onEditingEnd } @@ -21,6 +24,7 @@ struct PriceWidget: View { var body: some View { BaseWidget( type: .price, + size: size, isEditing: isEditing, onEditingEnd: onEditingEnd ) { @@ -36,7 +40,11 @@ struct PriceWidget: View { } else if viewModel.error != nil { WidgetContentBuilder.errorView(t("widgets__price__error")) } else if let primary = primaryPrice { - PriceWidgetWideContent(data: primary, period: options.selectedPeriod) + if size == .small { + PriceWidgetCompactContent(data: primary, period: options.selectedPeriod) + } else { + PriceWidgetWideContent(data: primary, period: options.selectedPeriod) + } } } diff --git a/Bitkit/Components/Widgets/WeatherWidget.swift b/Bitkit/Components/Widgets/WeatherWidget.swift index 2c4cb4495..d7c16564e 100644 --- a/Bitkit/Components/Widgets/WeatherWidget.swift +++ b/Bitkit/Components/Widgets/WeatherWidget.swift @@ -2,6 +2,7 @@ import SwiftUI struct WeatherWidget: View { var options: WeatherWidgetOptions = .init() + var size: WidgetSize = .wide var isEditing: Bool = false var onEditingEnd: (() -> Void)? @@ -10,10 +11,12 @@ struct WeatherWidget: View { init( options: WeatherWidgetOptions = WeatherWidgetOptions(), + size: WidgetSize = .wide, isEditing: Bool = false, onEditingEnd: (() -> Void)? = nil ) { self.options = options + self.size = size self.isEditing = isEditing self.onEditingEnd = onEditingEnd } @@ -21,6 +24,7 @@ struct WeatherWidget: View { var body: some View { BaseWidget( type: .weather, + size: size, isEditing: isEditing, onEditingEnd: onEditingEnd ) { @@ -39,13 +43,22 @@ struct WeatherWidget: View { } else if viewModel.error != nil && viewModel.weatherData == nil { WidgetContentBuilder.errorView(t("widgets__weather__error")) } else if let data = viewModel.weatherData { - WeatherWidgetWideContent( - data: data, - metric: options.selectedMetric, - conditionTitle: t(data.condition.titleKey), - conditionDescription: t(data.condition.descriptionKey), - metricLabel: t(options.selectedMetric.labelKey) - ) + if size == .small { + WeatherWidgetCompactContent( + data: data, + metric: options.selectedMetric, + conditionTitle: t(data.condition.titleKey), + metricLabel: t(options.selectedMetric.labelKey) + ) + } else { + WeatherWidgetWideContent( + data: data, + metric: options.selectedMetric, + conditionTitle: t(data.condition.titleKey), + conditionDescription: t(data.condition.descriptionKey), + metricLabel: t(options.selectedMetric.labelKey) + ) + } } } } diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index 7e226e986..877bbe6d9 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -81,28 +81,33 @@ struct Widget: Identifiable { case .blocks: BlocksWidget( options: widgetsViewModel.getOptions(for: type, as: BlocksWidgetOptions.self), + size: size, isEditing: isEditing, onEditingEnd: onEditingEnd ) case .calculator: + // Calculator is wide-only — small variant is deferred to a follow-up plan. CalculatorWidget(isEditing: isEditing, onEditingEnd: onEditingEnd) case .facts: - FactsWidget(isEditing: isEditing, onEditingEnd: onEditingEnd) + FactsWidget(size: size, isEditing: isEditing, onEditingEnd: onEditingEnd) case .news: NewsWidget( options: widgetsViewModel.getOptions(for: type, as: NewsWidgetOptions.self), + size: size, isEditing: isEditing, onEditingEnd: onEditingEnd ) case .price: PriceWidget( options: widgetsViewModel.getOptions(for: type, as: PriceWidgetOptions.self), + size: size, isEditing: isEditing, onEditingEnd: onEditingEnd ) case .weather: WeatherWidget( options: widgetsViewModel.getOptions(for: type, as: WeatherWidgetOptions.self), + size: size, isEditing: isEditing, onEditingEnd: onEditingEnd ) @@ -212,12 +217,17 @@ class WidgetsViewModel: ObservableObject { /// Save a new widget func saveWidget(_ type: WidgetType, size: WidgetSize = .wide) { + // Suggestions and Calculator are wide-only on the home grid — coerce any + // accidental `.small` callers so the persisted size can never disagree + // with the layout rules in `HomeWidgetsView.displayedSize`. + let resolvedSize: WidgetSize = (type == .suggestions || type == .calculator) ? .wide : size + if let index = savedWidgetsWithOptions.firstIndex(where: { $0.type == type }) { let existing = savedWidgetsWithOptions[index] - guard existing.size != size else { return } - savedWidgetsWithOptions[index] = SavedWidget(type: type, optionsData: existing.optionsData, size: size) + guard existing.size != resolvedSize else { return } + savedWidgetsWithOptions[index] = SavedWidget(type: type, optionsData: existing.optionsData, size: resolvedSize) } else { - savedWidgetsWithOptions.append(SavedWidget(type: type, size: size)) + savedWidgetsWithOptions.append(SavedWidget(type: type, size: resolvedSize)) } savedWidgets = savedWidgetsWithOptions.map { $0.toWidget() } persistSavedWidgets() diff --git a/Bitkit/Views/Home/HomeWidgetsView.swift b/Bitkit/Views/Home/HomeWidgetsView.swift index 6fe927f38..c6d3cff4f 100644 --- a/Bitkit/Views/Home/HomeWidgetsView.swift +++ b/Bitkit/Views/Home/HomeWidgetsView.swift @@ -6,6 +6,7 @@ struct HomeWidgetsView: View { @Environment(KeyboardManager.self) private var keyboard @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var settings: SettingsViewModel + @EnvironmentObject var sheets: SheetViewModel @EnvironmentObject var suggestionsManager: SuggestionsManager @EnvironmentObject var wallet: WalletViewModel @EnvironmentObject var widgets: WidgetsViewModel @@ -70,16 +71,23 @@ struct HomeWidgetsView: View { .frame(height: firstCalculatorTopPadding) } - DraggableList( - visibleWidgets, - id: \.id, - enableDrag: isEditingWidgets && !calculatorInput.isPresented, - itemHeight: 80, - onReorder: { sourceIndex, destinationIndex in - widgets.reorderWidgets(from: sourceIndex, to: destinationIndex) + Grid(horizontalSpacing: 16, verticalSpacing: 16) { + ForEach(gridRows) { row in + GridRow { + switch row { + case let .wide(widget): + cell(widget) + .gridCellColumns(2) + case let .pair(first, second): + cell(first) + if let second { + cell(second) + } else { + Color.clear + } + } + } } - ) { widget in - rowContent(widget) } .id(visibleWidgets.map(\.id)) @@ -87,7 +95,7 @@ struct HomeWidgetsView: View { calculatorInput.dismiss() if app.hasSeenWidgetsIntro { - navigation.navigate(.widgetsList) + sheets.showSheet(.widgets, data: WidgetsConfig(initialRoute: .list)) } else { navigation.navigate(.widgetsIntro) } @@ -304,6 +312,71 @@ struct HomeWidgetsView: View { focusBottomY - (windowSafeAreaInsets.bottom > 0 ? windowSafeAreaInsets.bottom : 16) } + /// Resolved layout size for the grid. Calculator and suggestions are always wide + /// regardless of the value stored on `SavedWidget`. + private func displayedSize(for widget: Widget) -> WidgetSize { + switch widget.type { + case .calculator, .suggestions: return .wide + default: return widget.size + } + } + + /// A logical row in the home grid — either a single wide widget spanning both columns, + /// or up to two small widgets paired side-by-side. Built by walking `visibleWidgets` + /// in order; the second slot of a `pair` is `nil` if a small widget has no neighbour. + private enum WidgetRow: Identifiable { + case wide(Widget) + case pair(Widget, Widget?) + + var id: String { + switch self { + case let .wide(w): return "wide-\(w.id.rawValue)" + case let .pair(a, b): return "pair-\(a.id.rawValue)-\(b?.id.rawValue ?? "_")" + } + } + } + + private var gridRows: [WidgetRow] { + var result: [WidgetRow] = [] + var pendingSmall: Widget? + + for widget in visibleWidgets { + if displayedSize(for: widget) == .wide { + if let pending = pendingSmall { + result.append(.pair(pending, nil)) + pendingSmall = nil + } + result.append(.wide(widget)) + } else if let pending = pendingSmall { + result.append(.pair(pending, widget)) + pendingSmall = nil + } else { + pendingSmall = widget + } + } + if let pending = pendingSmall { + result.append(.pair(pending, nil)) + } + return result + } + + private func cell(_ widget: Widget) -> some View { + rowContent(widget) + .onDrop(of: [.utf8PlainText], delegate: WidgetReorderDropDelegate(target: widget, host: self)) + } + + /// Reorder by resolved type. Returns `true` if anything moved. + @discardableResult + fileprivate func reorder(from sourceType: WidgetType, to targetType: WidgetType) -> Bool { + guard sourceType != targetType, + let sourceIdx = widgets.savedWidgets.firstIndex(where: { $0.type == sourceType }), + let destIdx = widgets.savedWidgets.firstIndex(where: { $0.type == targetType }) + else { return false } + widgets.reorderWidgets(from: sourceIdx, to: destIdx) + Haptics.notify(.success) + return true + } + @ViewBuilder private func rowContent(_ widget: Widget) -> some View { if widget.type == .calculator { @@ -340,6 +413,32 @@ struct HomeWidgetsView: View { } } +/// Drop delegate that proposes `.move` to suppress the iOS "+" badge on the drag preview. +/// Reads the dragged widget type from the text item provider and asks the host view to reorder. +private struct WidgetReorderDropDelegate: DropDelegate { + let target: Widget + let host: HomeWidgetsView + + func dropUpdated(info _: DropInfo) -> DropProposal? { + DropProposal(operation: .move) + } + + func validateDrop(info: DropInfo) -> Bool { + info.hasItemsConforming(to: [.utf8PlainText]) + } + + func performDrop(info: DropInfo) -> Bool { + guard let provider = info.itemProviders(for: [.utf8PlainText]).first else { return false } + provider.loadObject(ofClass: NSString.self) { item, _ in + guard let raw = item as? String, let sourceType = WidgetType(rawValue: raw) else { return } + DispatchQueue.main.async { + host.reorder(from: sourceType, to: target.type) + } + } + return true + } +} + private final class CalculatorFocusAdjustmentState: ObservableObject { var delta: CGFloat? var hasStartedPresentation = false diff --git a/Bitkit/Views/Widgets/WidgetsListSheetView.swift b/Bitkit/Views/Widgets/WidgetsListSheetView.swift index 9a4a05b61..afaa0fa87 100644 --- a/Bitkit/Views/Widgets/WidgetsListSheetView.swift +++ b/Bitkit/Views/Widgets/WidgetsListSheetView.swift @@ -3,27 +3,63 @@ import SwiftUI struct WidgetsListSheetView: View { @Binding var navigationPath: [WidgetsRoute] - private static let columns: [GridItem] = [ - GridItem(.flexible(), spacing: 16), - GridItem(.flexible(), spacing: 16), - ] - /// Widget types shown in the add-list, in display order. `suggestions` is system-managed and excluded. private static let listedTypes: [WidgetType] = [.price, .weather, .news, .blocks, .facts, .calculator] + private enum TileRow: Identifiable { + case wide(WidgetType) + case pair(WidgetType, WidgetType?) + + var id: String { + switch self { + case let .wide(t): return "wide-\(t.rawValue)" + case let .pair(a, b): return "pair-\(a.rawValue)-\(b?.rawValue ?? "_")" + } + } + } + + private var rows: [TileRow] { + var result: [TileRow] = [] + var pending: WidgetType? + for type in Self.listedTypes { + if displaySize(for: type) == .wide { + if let p = pending { + result.append(.pair(p, nil)) + pending = nil + } + result.append(.wide(type)) + } else if let p = pending { + result.append(.pair(p, type)) + pending = nil + } else { + pending = type + } + } + if let p = pending { result.append(.pair(p, nil)) } + return result + } + var body: some View { VStack(spacing: 0) { SheetHeader(title: t("widgets__add")) ScrollView(showsIndicators: false) { - LazyVGrid(columns: Self.columns, spacing: 16) { - ForEach(Self.listedTypes, id: \.rawValue) { type in - tile(for: type) - .gridCellColumns(displaySize(for: type) == .wide ? 2 : 1) - .onTapGesture { - navigationPath.append(.preview(type)) + Grid(horizontalSpacing: 16, verticalSpacing: 16) { + ForEach(rows) { row in + GridRow { + switch row { + case let .wide(type): + tappableTile(type) + .gridCellColumns(2) + case let .pair(first, second): + tappableTile(first) + if let second { + tappableTile(second) + } else { + Color.clear + } } - .accessibilityIdentifier("WidgetListItem-\(type.rawValue)") + } } } .padding(.horizontal, 16) @@ -33,6 +69,12 @@ struct WidgetsListSheetView: View { .navigationBarHidden(true) } + private func tappableTile(_ type: WidgetType) -> some View { + tile(for: type) + .onTapGesture { navigationPath.append(.preview(type)) } + .accessibilityIdentifier("WidgetListItem-\(type.rawValue)") + } + /// Display size each widget uses in the list grid (purely visual — not the saved size). private func displaySize(for type: WidgetType) -> WidgetSize { switch type { From 82f888a4a12c05a88afba9ac1daa04065718bab3 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 27 May 2026 14:01:49 -0300 Subject: [PATCH 07/31] fix: update when dropped on a cell gap --- Bitkit/Views/Home/HomeWidgetsView.swift | 61 +++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/Bitkit/Views/Home/HomeWidgetsView.swift b/Bitkit/Views/Home/HomeWidgetsView.swift index c6d3cff4f..ef6680132 100644 --- a/Bitkit/Views/Home/HomeWidgetsView.swift +++ b/Bitkit/Views/Home/HomeWidgetsView.swift @@ -22,6 +22,7 @@ struct HomeWidgetsView: View { @State private var focusedContentOffsetY: CGFloat = 0 @State private var firstCalculatorTopPadding: CGFloat = 0 @State private var numberPadFrame: CGRect? + @StateObject private var cellFrames = WidgetCellFrameStore() private static let focusAnimation = Animation.easeOut(duration: focusAnimationDuration) private static let focusAnimationDuration = 0.12 @@ -90,6 +91,10 @@ struct HomeWidgetsView: View { } } .id(visibleWidgets.map(\.id)) + .onPreferenceChange(WidgetCellFramesPreferenceKey.self) { frames in + cellFrames.frames = frames + } + .onDrop(of: [.utf8PlainText], delegate: WidgetReorderDropDelegate(host: self, cellFrames: cellFrames)) CustomButton(title: t("widgets__add"), variant: .tertiary) { calculatorInput.dismiss() @@ -362,7 +367,14 @@ struct HomeWidgetsView: View { private func cell(_ widget: Widget) -> some View { rowContent(widget) - .onDrop(of: [.utf8PlainText], delegate: WidgetReorderDropDelegate(target: widget, host: self)) + .background( + GeometryReader { proxy in + Color.clear.preference( + key: WidgetCellFramesPreferenceKey.self, + value: [WidgetCellFrame(type: widget.type, rect: proxy.frame(in: .global))] + ) + } + ) } /// Reorder by resolved type. Returns `true` if anything moved. @@ -413,11 +425,31 @@ struct HomeWidgetsView: View { } } -/// Drop delegate that proposes `.move` to suppress the iOS "+" badge on the drag preview. -/// Reads the dragged widget type from the text item provider and asks the host view to reorder. +/// Captured frame of one widget cell in `.global` coordinates. +struct WidgetCellFrame: Equatable { + let type: WidgetType + let rect: CGRect +} + +/// PreferenceKey carrying every cell's frame up to the grid so the drop delegate can +/// resolve which widget the drop landed nearest to (including drops that fall in the +/// 16pt gap between cells). +struct WidgetCellFramesPreferenceKey: PreferenceKey { + static var defaultValue: [WidgetCellFrame] = [] + static func reduce(value: inout [WidgetCellFrame], nextValue: () -> [WidgetCellFrame]) { + value.append(contentsOf: nextValue()) + } +} + +/// Holds the latest set of cell frames so the (struct-based, value-type) `DropDelegate` +/// can read them via reference without becoming stale. +final class WidgetCellFrameStore: ObservableObject { + @Published var frames: [WidgetCellFrame] = [] +} + private struct WidgetReorderDropDelegate: DropDelegate { - let target: Widget let host: HomeWidgetsView + let cellFrames: WidgetCellFrameStore func dropUpdated(info _: DropInfo) -> DropProposal? { DropProposal(operation: .move) @@ -429,14 +461,33 @@ private struct WidgetReorderDropDelegate: DropDelegate { func performDrop(info: DropInfo) -> Bool { guard let provider = info.itemProviders(for: [.utf8PlainText]).first else { return false } + let location = info.location + let frames = cellFrames.frames + provider.loadObject(ofClass: NSString.self) { item, _ in guard let raw = item as? String, let sourceType = WidgetType(rawValue: raw) else { return } + guard let target = Self.targetType(at: location, in: frames) else { return } DispatchQueue.main.async { - host.reorder(from: sourceType, to: target.type) + host.reorder(from: sourceType, to: target) } } return true } + + /// Cell containing the point, or — if dropped in a gap — the cell with the smallest + /// distance from the drop point to the cell rect. + private static func targetType(at point: CGPoint, in frames: [WidgetCellFrame]) -> WidgetType? { + if let direct = frames.first(where: { $0.rect.contains(point) }) { + return direct.type + } + return frames.min(by: { distance(from: point, to: $0.rect) < distance(from: point, to: $1.rect) })?.type + } + + private static func distance(from point: CGPoint, to rect: CGRect) -> CGFloat { + let dx = max(rect.minX - point.x, 0, point.x - rect.maxX) + let dy = max(rect.minY - point.y, 0, point.y - rect.maxY) + return sqrt(dx * dx + dy * dy) + } } private final class CalculatorFocusAdjustmentState: ObservableObject { From d50bf63b09856f458516408c322b5c573629b649 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 27 May 2026 14:20:59 -0300 Subject: [PATCH 08/31] feat: intro screen --- .../Localization/en.lproj/Localizable.strings | 1 + Bitkit/ViewModels/AppViewModel.swift | 4 + Bitkit/Views/HomeScreen.swift | 10 +++ Bitkit/Views/Widgets/WidgetsIntroView.swift | 88 +++++++++++++++---- 4 files changed, 87 insertions(+), 16 deletions(-) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index d3fac9163..454a39944 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1378,6 +1378,7 @@ "widgets__onboarding__swipe" = "Swipe down\nto find your\nwidgets"; "widgets__onboarding__title" = "Hello,\nWidgets"; "widgets__onboarding__description" = "Enjoy decentralized feeds from your favorite web services, by adding fun and useful widgets to your Bitkit wallet."; +"widgets__onboarding__view_organize" = "View & Organize"; "widgets__nav_title" = "Widgets"; "widgets__widget__nav_title" = "Widget"; "widgets__widget__edit" = "Widget Feed"; diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 276d90cc9..281ce9d29 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -64,6 +64,10 @@ class AppViewModel: ObservableObject { /// Drawer menu @Published var showDrawer = false + /// One-shot signal asking `HomeScreen` to scroll to a specific page (0 = wallet, 1 = widgets). + /// `HomeScreen` consumes the value and clears it back to `nil`. + @Published var requestedHomePage: Int? + /// Payment hashes for which we navigated to the pending screen. /// When payment succeeds/fails, we show toast and publish resolution so SendPendingScreen can navigate. private var pendingPaymentHashes: Set = [] diff --git a/Bitkit/Views/HomeScreen.swift b/Bitkit/Views/HomeScreen.swift index b92cf00fc..58ecd6c49 100644 --- a/Bitkit/Views/HomeScreen.swift +++ b/Bitkit/Views/HomeScreen.swift @@ -86,9 +86,19 @@ struct HomeScreen: View { .navigationBarHidden(true) .onAppear { TimedSheetManager.shared.onHomeScreenEntered() + consumeRequestedHomePage() } .onDisappear { TimedSheetManager.shared.onHomeScreenExited() } + .onChange(of: app.requestedHomePage) { _, _ in + consumeRequestedHomePage() + } + } + + private func consumeRequestedHomePage() { + guard let requested = app.requestedHomePage else { return } + withAnimation { scrollPosition = requested } + app.requestedHomePage = nil } } diff --git a/Bitkit/Views/Widgets/WidgetsIntroView.swift b/Bitkit/Views/Widgets/WidgetsIntroView.swift index 3227df60d..8e18070f0 100644 --- a/Bitkit/Views/Widgets/WidgetsIntroView.swift +++ b/Bitkit/Views/Widgets/WidgetsIntroView.swift @@ -3,27 +3,83 @@ import SwiftUI struct WidgetsIntroView: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var sheets: SheetViewModel var body: some View { - OnboardingView( - navTitle: t("widgets__nav_title"), - title: t("widgets__onboarding__title"), - description: t("widgets__onboarding__description"), - imageName: "puzzle", - buttonText: t("common__continue"), - onButtonPress: { - app.hasSeenWidgetsIntro = true - navigation.navigate(.widgetsList) - }, - imagePosition: .center, - testID: "WidgetsOnboarding" - ) + VStack(spacing: 0) { + NavigationBar(title: t("widgets__nav_title")) + + VStack(spacing: 0) { + VStack { + Spacer() + Image("puzzle") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + Spacer() + } + .frame(maxWidth: .infinity) + .frame(maxHeight: .infinity) + .layoutPriority(1) + + VStack(alignment: .leading, spacing: 14) { + DisplayText(t("widgets__onboarding__title"), accentColor: .brandAccent) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + + BodyMText(t("widgets__onboarding__description")) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + + HStack(spacing: 16) { + CustomButton( + title: t("widgets__onboarding__view_organize"), + variant: .secondary, + size: .large, + shouldExpand: true, + action: onViewOrganize + ) + .accessibilityIdentifier("WidgetsOnboardingViewOrganize") + + CustomButton( + title: t("widgets__add"), + variant: .primary, + size: .large, + shouldExpand: true, + action: onAddWidget + ) + .accessibilityIdentifier("WidgetsOnboardingAddWidget") + } + .padding(.top, 32) + } + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + } .navigationBarHidden(true) + .accessibilityIdentifier("WidgetsOnboarding") + } + + private func onViewOrganize() { + app.hasSeenWidgetsIntro = true + app.requestedHomePage = 1 + navigation.reset() + } + + private func onAddWidget() { + app.hasSeenWidgetsIntro = true + navigation.reset() + sheets.showSheet(.widgets, data: WidgetsConfig(initialRoute: .list)) } } #Preview { - WidgetsIntroView() - .environmentObject(AppViewModel()) - .preferredColorScheme(.dark) + NavigationStack { + WidgetsIntroView() + .environmentObject(AppViewModel()) + .environmentObject(NavigationViewModel()) + .environmentObject(SheetViewModel()) + } + .preferredColorScheme(.dark) } From 000f4fe3a4ffdd1e1ccea260805567bcdcf99755 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 27 May 2026 14:35:10 -0300 Subject: [PATCH 09/31] chore: code cleanup --- Bitkit/Components/DrawerView.swift | 10 +- Bitkit/Components/ProfileEditFormView.swift | 7 - Bitkit/Components/PubkyImage.swift | 1 - Bitkit/Components/Trezor/TrezorPinPad.swift | 6 +- .../Components/Widgets/WidgetListItem.swift | 85 ------ Bitkit/MainNavView.swift | 19 -- Bitkit/Models/PubkyProfile.swift | 4 +- Bitkit/Services/Trezor/TrezorBLEManager.swift | 9 +- Bitkit/Services/Trezor/TrezorDebugLog.swift | 8 +- Bitkit/Services/Trezor/TrezorTransport.swift | 2 - Bitkit/ViewModels/NavigationViewModel.swift | 5 +- Bitkit/Views/Contacts/EditContactView.swift | 1 - Bitkit/Views/Profile/AddLinkSheet.swift | 1 - Bitkit/Views/Profile/AddProfileTagSheet.swift | 1 - Bitkit/Views/Profile/CreateProfileView.swift | 4 - Bitkit/Views/Profile/ProfileView.swift | 7 - .../PubkyAuthApprovalSheet.swift | 8 - Bitkit/Views/Trezor/TrezorAddressView.swift | 1 - .../Trezor/TrezorSendTransactionView.swift | 2 +- .../Views/Trezor/TrezorSignMessageView.swift | 1 - .../Widgets/BlocksWidgetPreviewView.swift | 253 ----------------- .../Widgets/CalculatorWidgetPreviewView.swift | 226 --------------- .../Widgets/FactsWidgetPreviewView.swift | 173 ------------ .../Views/Widgets/NewsWidgetPreviewView.swift | 261 ----------------- .../Widgets/PriceWidgetPreviewView.swift | 263 ----------------- .../Widgets/WeatherWidgetPreviewView.swift | 264 ------------------ Bitkit/Views/Widgets/WidgetDetailView.swift | 186 ------------ Bitkit/Views/Widgets/WidgetEditView.swift | 130 --------- .../Widgets/WidgetPreviewSheetView.swift | 17 +- Bitkit/Views/Widgets/WidgetsListView.swift | 44 --- 30 files changed, 38 insertions(+), 1961 deletions(-) delete mode 100644 Bitkit/Components/Widgets/WidgetListItem.swift delete mode 100644 Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift delete mode 100644 Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift delete mode 100644 Bitkit/Views/Widgets/FactsWidgetPreviewView.swift delete mode 100644 Bitkit/Views/Widgets/NewsWidgetPreviewView.swift delete mode 100644 Bitkit/Views/Widgets/PriceWidgetPreviewView.swift delete mode 100644 Bitkit/Views/Widgets/WeatherWidgetPreviewView.swift delete mode 100644 Bitkit/Views/Widgets/WidgetDetailView.swift delete mode 100644 Bitkit/Views/Widgets/WidgetEditView.swift delete mode 100644 Bitkit/Views/Widgets/WidgetsListView.swift diff --git a/Bitkit/Components/DrawerView.swift b/Bitkit/Components/DrawerView.swift index bdbb41df2..b1bff29d8 100644 --- a/Bitkit/Components/DrawerView.swift +++ b/Bitkit/Components/DrawerView.swift @@ -70,6 +70,7 @@ enum DrawerMenuItem: Int, CaseIterable, Identifiable, Hashable { struct DrawerView: View { @EnvironmentObject private var app: AppViewModel @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var sheets: SheetViewModel @EnvironmentObject private var wallet: WalletViewModel @State private var currentDragOffset: CGFloat = 0 @@ -96,14 +97,15 @@ struct DrawerView: View { .transition(.opacity) } - /// Route to push when selecting this drawer item (nil for Wallet = pop to root). + /// Route to push when selecting this drawer item (nil for Wallet = pop to root, + /// and also nil for `.widgets` when already onboarded — that case opens the sheet instead). private func route(for item: DrawerMenuItem) -> Route? { switch item { case .wallet: return nil case .activity: return .activityList case .contacts: return .contacts case .profile: return .profile - case .widgets: return app.hasSeenWidgetsIntro ? .widgetsList : .widgetsIntro + case .widgets: return app.hasSeenWidgetsIntro ? nil : .widgetsIntro case .shop: return app.hasSeenShopIntro ? .shopDiscover : .shopIntro case .support: return .support case .settings: return .settings @@ -117,6 +119,10 @@ struct DrawerView: View { } else { navigation.path = [] } + // After onboarding, the widgets drawer entry opens the widgets sheet instead of a route. + if item == .widgets, app.hasSeenWidgetsIntro { + sheets.showSheet(.widgets, data: WidgetsConfig(initialRoute: .list)) + } closeMenu() } diff --git a/Bitkit/Components/ProfileEditFormView.swift b/Bitkit/Components/ProfileEditFormView.swift index ca2bcc3ff..0e02bb782 100644 --- a/Bitkit/Components/ProfileEditFormView.swift +++ b/Bitkit/Components/ProfileEditFormView.swift @@ -109,7 +109,6 @@ struct ProfileEditFormView: View { // MARK: - Pubky Key Section - @ViewBuilder private var pubkyKeySection: some View { VStack(spacing: 8) { CaptionMText(publicKeyLabel, textColor: .white64) @@ -126,7 +125,6 @@ struct ProfileEditFormView: View { // MARK: - Bio Section - @ViewBuilder private var bioSection: some View { VStack(alignment: .leading, spacing: 8) { CaptionMText(t("profile__create_bio_label"), textColor: .white64) @@ -144,7 +142,6 @@ struct ProfileEditFormView: View { // MARK: - Links Section - @ViewBuilder private var linksSection: some View { VStack(alignment: .leading, spacing: 8) { ForEach(links.indices, id: \.self) { index in @@ -224,7 +221,6 @@ struct ProfileEditFormView: View { // MARK: - Delete Section - @ViewBuilder private func deleteSection(label: String, action: @escaping () -> Void) -> some View { VStack(alignment: .leading, spacing: 8) { CaptionMText(t("profile__edit_delete_section"), textColor: .white64) @@ -259,7 +255,6 @@ struct ProfileEditFormView: View { // MARK: - Footnote Section - @ViewBuilder private func footnoteSection(_ note: String) -> some View { BodySText(note, textColor: .white64) .fixedSize(horizontal: false, vertical: true) @@ -268,7 +263,6 @@ struct ProfileEditFormView: View { // MARK: - Tags Section - @ViewBuilder private var tagsSection: some View { VStack(alignment: .leading, spacing: 8) { if !tags.isEmpty { @@ -294,7 +288,6 @@ struct ProfileEditFormView: View { } } - @ViewBuilder private var footerBar: some View { VStack(spacing: 0) { LinearGradient( diff --git a/Bitkit/Components/PubkyImage.swift b/Bitkit/Components/PubkyImage.swift index 9af821326..ab0e7b4cc 100644 --- a/Bitkit/Components/PubkyImage.swift +++ b/Bitkit/Components/PubkyImage.swift @@ -31,7 +31,6 @@ struct PubkyImage: View { } } - @ViewBuilder private var placeholder: some View { Circle() .fill(Color.gray5) diff --git a/Bitkit/Components/Trezor/TrezorPinPad.swift b/Bitkit/Components/Trezor/TrezorPinPad.swift index de7423fda..3feeaf6da 100644 --- a/Bitkit/Components/Trezor/TrezorPinPad.swift +++ b/Bitkit/Components/Trezor/TrezorPinPad.swift @@ -9,8 +9,8 @@ struct TrezorPinPad: View { /// Maximum PIN length var maxLength: Int = 9 - // PIN pad layout (positions map to device keypad) - // The Trezor shows scrambled numbers, we show only position dots + /// PIN pad layout (positions map to device keypad) + /// The Trezor shows scrambled numbers, we show only position dots private let positions = [ ["7", "8", "9"], ["4", "5", "6"], @@ -63,13 +63,11 @@ struct TrezorPinPad: View { private func handleDigitTap(_ position: String) { guard pin.count < maxLength else { return } pin += position - } private func handleDelete() { guard !pin.isEmpty else { return } pin.removeLast() - } } diff --git a/Bitkit/Components/Widgets/WidgetListItem.swift b/Bitkit/Components/Widgets/WidgetListItem.swift deleted file mode 100644 index a889593e0..000000000 --- a/Bitkit/Components/Widgets/WidgetListItem.swift +++ /dev/null @@ -1,85 +0,0 @@ -import SwiftUI - -struct WidgetListItem: View { - let id: WidgetType - let isDisabled: Bool - - @EnvironmentObject private var currency: CurrencyViewModel - @EnvironmentObject private var navigation: NavigationViewModel - - init(id: WidgetType, isDisabled: Bool = false) { - self.id = id - self.isDisabled = isDisabled - } - - /// Widget data computed from the ID - private var widget: (name: String, description: String, icon: String) { - let name = t("widgets__\(id.rawValue)__name") - - // Get fiat symbol from currency conversion - let fiatSymbol = currency.symbol - let description = t("widgets__\(id.rawValue)__description", variables: ["fiatSymbol": fiatSymbol]) - let icon = "\(id.rawValue)-widget" - - return (name: name, description: description, icon: icon) - } - - private func onPress() { - if isDisabled { - return - } - - navigation.navigate(.widgetDetail(id)) - } - - var body: some View { - Button(action: onPress) { - VStack(spacing: 0) { - HStack(spacing: 0) { - Image(widget.icon) - .resizable() - .frame(width: 48, height: 48) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .padding(.trailing, 16) - - VStack(alignment: .leading, spacing: 0) { - BodyMSBText(widget.name) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - - CaptionBText(widget.description, textColor: .textSecondary) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.trailing, 20) - - Image("chevron") - .resizable() - .foregroundColor(.textSecondary) - .frame(width: 24, height: 24) - } - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) - - Divider() - .padding(.vertical, 16) - } - } - .buttonStyle(PlainButtonStyle()) - .opacity(isDisabled ? 0.3 : 1) - .accessibilityIdentifier("WidgetListItem-\(id.rawValue)") - } -} - -#Preview { - VStack(spacing: 16) { - WidgetListItem(id: .price) - WidgetListItem(id: .news) - WidgetListItem(id: .facts) - } - .padding() - .background(Color.black) - .environmentObject(NavigationViewModel()) - .environmentObject(CurrencyViewModel()) - .preferredColorScheme(.dark) -} diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index f491de87a..3b56fa03a 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -479,25 +479,6 @@ struct MainNavView: View { // Widgets case .widgetsIntro: WidgetsIntroView() - case .widgetsList: WidgetsListView() - case let .widgetDetail(widgetType): - switch widgetType { - case .price: - PriceWidgetPreviewView() - case .news: - NewsWidgetPreviewView() - case .blocks: - BlocksWidgetPreviewView() - case .facts: - FactsWidgetPreviewView() - case .weather: - WeatherWidgetPreviewView() - case .calculator: - CalculatorWidgetPreviewView() - default: - WidgetDetailView(id: widgetType) - } - case let .widgetEdit(widgetType): WidgetEditView(id: widgetType) // Settings case .settings: MainSettingsScreen() diff --git a/Bitkit/Models/PubkyProfile.swift b/Bitkit/Models/PubkyProfile.swift index a228ab37b..5445d1c5e 100644 --- a/Bitkit/Models/PubkyProfile.swift +++ b/Bitkit/Models/PubkyProfile.swift @@ -79,7 +79,7 @@ struct PubkyProfileData: Codable { // MARK: - PubkyProfileLink -struct PubkyProfileLink: Identifiable, Sendable { +struct PubkyProfileLink: Identifiable { let id = UUID() let label: String let url: String @@ -87,7 +87,7 @@ struct PubkyProfileLink: Identifiable, Sendable { // MARK: - PubkyProfile -struct PubkyProfile: Sendable { +struct PubkyProfile { let publicKey: String let name: String let bio: String diff --git a/Bitkit/Services/Trezor/TrezorBLEManager.swift b/Bitkit/Services/Trezor/TrezorBLEManager.swift index 91f309528..0af190e52 100644 --- a/Bitkit/Services/Trezor/TrezorBLEManager.swift +++ b/Bitkit/Services/Trezor/TrezorBLEManager.swift @@ -137,7 +137,7 @@ class TrezorBLEManager: NSObject { // MARK: - Initialization - private override init() { + override private init() { super.init() // CBCentralManager is created lazily via ensureStarted() to avoid // triggering the BLE stack and permission dialogs at app launch. @@ -308,7 +308,7 @@ class TrezorBLEManager: NSObject { } } - guard let peripheral = peripheral else { + guard let peripheral else { debugLog("Peripheral not found in cache or by UUID: \(path)") throw TrezorBLEError.deviceNotFound(path) } @@ -533,7 +533,6 @@ class TrezorBLEManager: NSObject { throw lastError } - } // MARK: - CBCentralManagerDelegate @@ -679,7 +678,9 @@ struct DiscoveredBLEDevice: Identifiable, Equatable { let name: String? let identifier: UUID - var id: String { path } + var id: String { + path + } } /// Errors specific to BLE operations diff --git a/Bitkit/Services/Trezor/TrezorDebugLog.swift b/Bitkit/Services/Trezor/TrezorDebugLog.swift index afdd94062..39cf2cf42 100644 --- a/Bitkit/Services/Trezor/TrezorDebugLog.swift +++ b/Bitkit/Services/Trezor/TrezorDebugLog.swift @@ -20,16 +20,16 @@ class TrezorDebugLog { /// Minimum interval between flushes to @Published private static let flushInterval: TimeInterval = 0.25 - nonisolated(unsafe) private let formatter: DateFormatter = { + private nonisolated(unsafe) let formatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "HH:mm:ss.SSS" return f }() /// Thread-safe buffer for incoming log messages - nonisolated(unsafe) private let bufferLock = NSLock() - nonisolated(unsafe) private var buffer: [String] = [] - nonisolated(unsafe) private var flushScheduled = false + private nonisolated(unsafe) let bufferLock = NSLock() + private nonisolated(unsafe) var buffer: [String] = [] + private nonisolated(unsafe) var flushScheduled = false private init() {} diff --git a/Bitkit/Services/Trezor/TrezorTransport.swift b/Bitkit/Services/Trezor/TrezorTransport.swift index a464a77b5..63e8781f2 100644 --- a/Bitkit/Services/Trezor/TrezorTransport.swift +++ b/Bitkit/Services/Trezor/TrezorTransport.swift @@ -262,7 +262,6 @@ final class TrezorTransport: TrezorTransportCallback { return credential } - // MARK: - Device Scanning Helpers /// Start scanning for BLE devices @@ -279,7 +278,6 @@ final class TrezorTransport: TrezorTransportCallback { var bluetoothState: CBManagerState { bleManager.bluetoothState } - } // MARK: - Transport Errors diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index b1b416c46..091ac2c33 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -51,11 +51,8 @@ enum Route: Hashable { case shopDiscover case shopMain(page: String) - // Widgets + /// Widgets case widgetsIntro - case widgetsList - case widgetDetail(WidgetType) - case widgetEdit(WidgetType) // Support case reportIssue diff --git a/Bitkit/Views/Contacts/EditContactView.swift b/Bitkit/Views/Contacts/EditContactView.swift index 5cc685af2..56d24e964 100644 --- a/Bitkit/Views/Contacts/EditContactView.swift +++ b/Bitkit/Views/Contacts/EditContactView.swift @@ -62,7 +62,6 @@ struct EditContactView: View { // MARK: - Avatar - @ViewBuilder private var avatarSection: some View { PhotosPicker(selection: $selectedPhotoItem, matching: .images) { Group { diff --git a/Bitkit/Views/Profile/AddLinkSheet.swift b/Bitkit/Views/Profile/AddLinkSheet.swift index 82416a037..72dc4763f 100644 --- a/Bitkit/Views/Profile/AddLinkSheet.swift +++ b/Bitkit/Views/Profile/AddLinkSheet.swift @@ -66,7 +66,6 @@ struct AddLinkSheet: View { } } - @ViewBuilder private var labelFieldWithSuggestions: some View { HStack(spacing: 0) { ZStack(alignment: .leading) { diff --git a/Bitkit/Views/Profile/AddProfileTagSheet.swift b/Bitkit/Views/Profile/AddProfileTagSheet.swift index 846b8d4f7..3617b33f1 100644 --- a/Bitkit/Views/Profile/AddProfileTagSheet.swift +++ b/Bitkit/Views/Profile/AddProfileTagSheet.swift @@ -45,7 +45,6 @@ struct AddProfileTagSheet: View { } } - @ViewBuilder private var tagFieldWithSuggestions: some View { HStack(spacing: 0) { ZStack(alignment: .leading) { diff --git a/Bitkit/Views/Profile/CreateProfileView.swift b/Bitkit/Views/Profile/CreateProfileView.swift index de1e4fca0..16a2f872e 100644 --- a/Bitkit/Views/Profile/CreateProfileView.swift +++ b/Bitkit/Views/Profile/CreateProfileView.swift @@ -77,7 +77,6 @@ struct CreateProfileView: View { // MARK: - Avatar Section - @ViewBuilder private var avatarSection: some View { PhotosPicker(selection: $selectedPhotoItem, matching: .images) { avatarContent @@ -112,7 +111,6 @@ struct CreateProfileView: View { // MARK: - Name Input - @ViewBuilder private var nameInput: some View { SwiftUI.TextField( t("profile__create_name_placeholder"), @@ -129,7 +127,6 @@ struct CreateProfileView: View { // MARK: - Pubky Key Section - @ViewBuilder private var pubkyKeySection: some View { VStack(spacing: 8) { CaptionMText(t("profile__create_pubky_display_label"), textColor: .white64) @@ -147,7 +144,6 @@ struct CreateProfileView: View { // MARK: - Loading - @ViewBuilder private var loadingView: some View { VStack(spacing: 12) { Spacer() diff --git a/Bitkit/Views/Profile/ProfileView.swift b/Bitkit/Views/Profile/ProfileView.swift index 49cbb336f..3e4f86872 100644 --- a/Bitkit/Views/Profile/ProfileView.swift +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -46,7 +46,6 @@ struct ProfileView: View { // MARK: - Profile Content - @ViewBuilder private func profileContent(_ profile: PubkyProfile) -> some View { ScrollView { VStack(spacing: 0) { @@ -86,7 +85,6 @@ struct ProfileView: View { // MARK: - Actions (edit, copy, share) - @ViewBuilder private var profileActions: some View { HStack(spacing: 16) { GradientCircleButton(icon: "pencil", accessibilityLabel: t("profile__edit")) { @@ -111,7 +109,6 @@ struct ProfileView: View { // MARK: - QR Code - @ViewBuilder private func profileQRCode(_ profile: PubkyProfile) -> some View { Button { UIPasteboard.general.string = profile.publicKey @@ -141,7 +138,6 @@ struct ProfileView: View { // MARK: - Links / Metadata - @ViewBuilder private func profileLinks(_ profile: PubkyProfile) -> some View { VStack(alignment: .leading, spacing: 0) { ForEach(Array(profile.links.enumerated()), id: \.element.id) { index, link in @@ -152,7 +148,6 @@ struct ProfileView: View { // MARK: - Tags - @ViewBuilder private func profileTags(_ profile: PubkyProfile) -> some View { VStack(alignment: .leading, spacing: 8) { CaptionMText(t("profile__create_tags_label"), textColor: .white64) @@ -168,7 +163,6 @@ struct ProfileView: View { // MARK: - Loading / Empty States - @ViewBuilder private var loadingContent: some View { VStack { Spacer() @@ -178,7 +172,6 @@ struct ProfileView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - @ViewBuilder private var emptyContent: some View { VStack(spacing: 16) { Spacer() diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index 03e3723b8..ed22131f8 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -92,7 +92,6 @@ struct PubkyAuthApprovalSheet: View { // MARK: - Authorize State (Screen 3) - @ViewBuilder private var authorizeContent: some View { VStack(alignment: .leading, spacing: 0) { descriptionText @@ -125,7 +124,6 @@ struct PubkyAuthApprovalSheet: View { // MARK: - Authorizing State (Screen 4) - @ViewBuilder private var authorizingContent: some View { VStack(alignment: .leading, spacing: 0) { descriptionText @@ -149,7 +147,6 @@ struct PubkyAuthApprovalSheet: View { // MARK: - Success State (Screen 5) - @ViewBuilder private var successContent: some View { VStack(alignment: .leading, spacing: 0) { successDescriptionText @@ -178,7 +175,6 @@ struct PubkyAuthApprovalSheet: View { config.request.serviceNames.joined(separator: " and ") } - @ViewBuilder private var descriptionText: some View { BodyMText( t("pubky_auth__description_prefix") + "" + serviceText + "" + t("pubky_auth__description_suffix"), @@ -201,7 +197,6 @@ struct PubkyAuthApprovalSheet: View { .lineSpacing(4) } - @ViewBuilder private var permissionsSection: some View { VStack(alignment: .leading, spacing: 8) { CaptionMText(t("pubky_auth__requested_permissions"), textColor: .white64) @@ -214,7 +209,6 @@ struct PubkyAuthApprovalSheet: View { } } - @ViewBuilder private func permissionRow(_ permission: PubkyAuthPermission) -> some View { HStack(spacing: 4) { Image(systemName: "folder") @@ -230,13 +224,11 @@ struct PubkyAuthApprovalSheet: View { } } - @ViewBuilder private var trustWarning: some View { BodySText(t("pubky_auth__trust_warning")) .lineSpacing(4) } - @ViewBuilder private var profileCard: some View { VStack(alignment: .leading, spacing: 16) { CaptionMText( diff --git a/Bitkit/Views/Trezor/TrezorAddressView.swift b/Bitkit/Views/Trezor/TrezorAddressView.swift index 5bb2c4843..231a4ce6c 100644 --- a/Bitkit/Views/Trezor/TrezorAddressView.swift +++ b/Bitkit/Views/Trezor/TrezorAddressView.swift @@ -377,7 +377,6 @@ private struct CopyButton: View { UIPasteboard.general.string = address copied = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { copied = false } diff --git a/Bitkit/Views/Trezor/TrezorSendTransactionView.swift b/Bitkit/Views/Trezor/TrezorSendTransactionView.swift index c654ebbc4..b42355cbc 100644 --- a/Bitkit/Views/Trezor/TrezorSendTransactionView.swift +++ b/Bitkit/Views/Trezor/TrezorSendTransactionView.swift @@ -390,7 +390,7 @@ private struct SignedResultSectionView: View { Button(action: { UIPasteboard.general.string = signedTx.serializedTx - + copiedRawTx = true DispatchQueue.main.asyncAfter(deadline: .now() + 2) { copiedRawTx = false diff --git a/Bitkit/Views/Trezor/TrezorSignMessageView.swift b/Bitkit/Views/Trezor/TrezorSignMessageView.swift index fefba4183..dd25c084b 100644 --- a/Bitkit/Views/Trezor/TrezorSignMessageView.swift +++ b/Bitkit/Views/Trezor/TrezorSignMessageView.swift @@ -299,7 +299,6 @@ private struct SignedMessageResult: View { UIPasteboard.general.string = response.signature copiedSignature = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { copiedSignature = false } diff --git a/Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift b/Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift deleted file mode 100644 index 195935cf7..000000000 --- a/Bitkit/Views/Widgets/BlocksWidgetPreviewView.swift +++ /dev/null @@ -1,253 +0,0 @@ -import SwiftUI - -/// Preview screen for the Bitcoin Blocks widget. -struct BlocksWidgetPreviewView: View { - @EnvironmentObject private var navigation: NavigationViewModel - @EnvironmentObject private var widgets: WidgetsViewModel - - @StateObject private var viewModel = BlocksViewModel.shared - - // TODO: revert to 0 to re-enable the compact widget preview - @State private var carouselPage: Int = 1 - @State private var showDeleteAlert = false - - private let widgetType: WidgetType = .blocks - - private var widgetName: String { - t("widgets__blocks__name") - } - - private var widgetDescription: String { - t("widgets__blocks__description") - } - - private var isWidgetSaved: Bool { - widgets.isWidgetSaved(widgetType) - } - - private var hasCustomOptions: Bool { - widgets.hasCustomOptions(for: widgetType) - } - - private var currentOptions: BlocksWidgetOptions { - widgets.getOptions(for: widgetType, as: BlocksWidgetOptions.self) - } - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - NavigationBar(title: widgetName, showMenuButton: true) - - VStack(alignment: .leading, spacing: 0) { - BodyMText(widgetDescription, textColor: .textSecondary) - .padding(.bottom, 16) - - Divider().background(Color.white.opacity(0.1)) - - widgetSettingsRow - - Divider().background(Color.white.opacity(0.1)) - } - - VStack(spacing: 16) { - carousel - - // Size label hidden while only the wide widget is shown - // sizeLabel - - // Page indicator hidden while only the wide widget is shown - // pageIndicator - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - buttonsRow - } - .navigationBarHidden(true) - .padding(.horizontal, 16) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .bottomSafeAreaPadding() - .task { - viewModel.startUpdates() - } - .alert( - t("widgets__delete__title"), - isPresented: $showDeleteAlert, - actions: { - Button(t("common__cancel"), role: .cancel) { showDeleteAlert = false } - Button(t("common__delete_yes"), role: .destructive) { onDelete() } - }, - message: { - Text(t("widgets__delete__description", variables: ["name": widgetName])) - } - ) - } - - // MARK: - Widget Settings cell - - private var widgetSettingsRow: some View { - Button(action: { navigation.navigate(.widgetEdit(widgetType)) }) { - HStack(alignment: .center, spacing: 0) { - BodyMText(t("widgets__widget__settings"), textColor: .textPrimary) - - Spacer() - - BodyMText( - hasCustomOptions - ? t("widgets__widget__edit_custom") - : t("widgets__widget__edit_default"), - textColor: .textSecondary - ) - - Image("chevron") - .resizable() - .foregroundColor(.textSecondary) - .frame(width: 24, height: 24) - .padding(.leading, 5) - } - .frame(maxWidth: .infinity, minHeight: 51) - .contentShape(Rectangle()) - } - .buttonStyle(PlainButtonStyle()) - .accessibilityIdentifier("WidgetEdit") - } - - // MARK: - Carousel - - private var carousel: some View { - TabView(selection: $carouselPage) { - // Compact preview temporarily hidden — only the wide widget can be added for now - // compactPage.tag(0) - widePage.tag(1) - } - .tabViewStyle(.page(indexDisplayMode: .never)) - .frame(maxHeight: .infinity) - } - - private var compactPage: some View { - VStack { - Spacer(minLength: 0) - Group { - if let data = viewModel.blockData { - BlocksWidgetCompactContent(data: data, options: currentOptions) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) - } else { - placeholderCompact - } - } - .frame(width: 163, height: 192) - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity) - } - - private var widePage: some View { - VStack { - Spacer(minLength: 0) - Group { - if let data = viewModel.blockData { - BlocksWidgetWideContent(data: data, options: currentOptions) - .frame(height: BlocksWidgetWideContent.inAppContentHeight) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) - } else { - placeholderWide - } - } - .frame(maxWidth: .infinity) - Spacer(minLength: 0) - } - } - - private var placeholderCompact: some View { - Color.gray6 - .cornerRadius(16) - .overlay(ProgressView()) - } - - private var placeholderWide: some View { - ProgressView() - .frame(maxWidth: .infinity) - .frame(height: BlocksWidgetWideContent.inAppContentHeight) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) - } - - // MARK: - Size label & page indicator - - private var sizeLabel: some View { - HStack { - Spacer() - CaptionMText( - carouselPage == 0 - ? t("widgets__widget__size_small") - : t("widgets__widget__size_wide"), - textColor: .textSecondary - ) - .textCase(.uppercase) - Spacer() - } - } - - private var pageIndicator: some View { - HStack(spacing: 8) { - Spacer() - ForEach(0 ..< 2, id: \.self) { index in - Circle() - .fill(carouselPage == index ? Color.white : Color.white.opacity(0.32)) - .frame(width: 8, height: 8) - } - Spacer() - } - } - - // MARK: - Buttons - - private var buttonsRow: some View { - HStack(spacing: 16) { - if isWidgetSaved { - CustomButton( - title: t("common__delete"), - variant: .secondary, - size: .large, - shouldExpand: true - ) { - showDeleteAlert = true - } - .accessibilityIdentifier("WidgetDelete") - } - - CustomButton( - title: t("widgets__widget__save_widget"), - variant: .primary, - size: .large, - shouldExpand: true, - action: onSave - ) - .accessibilityIdentifier("WidgetSave") - } - } - - // MARK: - Actions - - private func onSave() { - widgets.saveWidget(widgetType) - navigation.reset() - } - - private func onDelete() { - widgets.deleteWidget(widgetType) - navigation.reset() - } -} - -#Preview { - NavigationStack { - BlocksWidgetPreviewView() - .environmentObject(NavigationViewModel()) - .environmentObject(WidgetsViewModel()) - } - .preferredColorScheme(.dark) -} diff --git a/Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift b/Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift deleted file mode 100644 index 4505057c5..000000000 --- a/Bitkit/Views/Widgets/CalculatorWidgetPreviewView.swift +++ /dev/null @@ -1,226 +0,0 @@ -import SwiftUI - -/// Preview screen for the Calculator widget. -struct CalculatorWidgetPreviewView: View { - @EnvironmentObject private var navigation: NavigationViewModel - @EnvironmentObject private var widgets: WidgetsViewModel - @EnvironmentObject private var currency: CurrencyViewModel - - // TODO: revert to 0 to re-enable the compact widget preview - @State private var carouselPage: Int = 1 - @State private var previewActiveInput: CalculatorMoneyType? - @State private var showDeleteAlert = false - @State private var values = CalculatorWidgetValues() - - private let widgetType: WidgetType = .calculator - - private var widgetName: String { - t("widgets__calculator__name") - } - - private var widgetDescription: String { - t("widgets__calculator__description", variables: ["fiatSymbol": currency.symbol]) - } - - private var isWidgetSaved: Bool { - widgets.isWidgetSaved(widgetType) - } - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - NavigationBar(title: widgetName, showMenuButton: false) - - BodyMText(widgetDescription, textColor: .textSecondary) - - VStack(spacing: 16) { - carousel - - // Size label hidden while only the wide widget is shown - // sizeLabel - - // Page indicator hidden while only the wide widget is shown - // pageIndicator - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - buttonsRow - } - .navigationBarHidden(true) - .padding(.horizontal, 16) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .bottomSafeAreaPadding() - .task { - hydrateValues() - } - .onChange(of: currency.selectedCurrency) { - hydrateValues() - } - .onChange(of: currency.displayUnit) { - hydrateValues() - } - .onChange(of: currency.rates) { - hydrateValues() - } - .onDisappear { - previewActiveInput = nil - } - .alert( - t("widgets__delete__title"), - isPresented: $showDeleteAlert, - actions: { - Button(t("common__cancel"), role: .cancel) { showDeleteAlert = false } - Button(t("common__delete_yes"), role: .destructive) { onDelete() } - }, - message: { - Text(t("widgets__delete__description", variables: ["name": widgetName])) - } - ) - } - - private var carousel: some View { - TabView(selection: $carouselPage) { - // Compact preview temporarily hidden — only the wide widget can be added for now - // compactPage.tag(0) - widePage.tag(1) - } - .tabViewStyle(.page(indexDisplayMode: .never)) - .frame(maxHeight: .infinity) - } - - private var compactPage: some View { - VStack { - Spacer(minLength: 0) - CalculatorWidgetCompactContent(values: values) - .frame(width: 163, height: 192) - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity) - } - - private var widePage: some View { - VStack { - Spacer(minLength: 0) - CalculatorWidgetWideContent( - values: values, - activeInput: previewActiveInput, - onSelectInput: selectInput - ) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) - .frame(maxWidth: .infinity) - Spacer(minLength: 0) - } - } - - private var sizeLabel: some View { - HStack { - Spacer() - CaptionMText( - carouselPage == 0 - ? t("widgets__widget__size_small") - : t("widgets__widget__size_wide"), - textColor: .textSecondary - ) - .textCase(.uppercase) - Spacer() - } - } - - private var pageIndicator: some View { - HStack(spacing: 8) { - Spacer() - ForEach(0 ..< 2, id: \.self) { index in - Circle() - .fill(carouselPage == index ? Color.white : Color.white.opacity(0.32)) - .frame(width: 8, height: 8) - } - Spacer() - } - } - - private var buttonsRow: some View { - HStack(spacing: 16) { - if isWidgetSaved { - CustomButton( - title: t("common__delete"), - variant: .secondary, - size: .large, - shouldExpand: true - ) { - showDeleteAlert = true - } - .accessibilityIdentifier("WidgetDelete") - } - - CustomButton( - title: t("widgets__widget__save_widget"), - variant: .primary, - size: .large, - shouldExpand: true, - action: onSave - ) - .accessibilityIdentifier("WidgetSave") - } - } - - private func hydrateValues() { - let saved = CalculatorWidgetOptionsStore.load() - let bitcoinValue = Self.previewBitcoinValue(saved: saved, displayUnit: currency.displayUnit) - - values = CalculatorWidgetValues( - bitcoinValue: bitcoinValue, - fiatValue: Self.previewFiatValue(saved: saved, recalculatedFiatValue: fiatValue(for: bitcoinValue)), - displayUnit: currency.displayUnit, - currencySymbol: currency.symbol, - selectedCurrency: currency.selectedCurrency - ) - } - - static func previewBitcoinValue(saved: CalculatorWidgetValues, displayUnit: BitcoinDisplayUnit) -> String { - guard !saved.bitcoinValue.isEmpty else { return "" } - - let savedSats = CalculatorWidgetFormatter.bitcoinValueToSats(saved.bitcoinValue, displayUnit: saved.displayUnit) - return savedSats == 0 - ? "0" - : CalculatorWidgetFormatter.satsToBitcoinValue(savedSats, displayUnit: displayUnit) - } - - static func previewFiatValue(saved: CalculatorWidgetValues, recalculatedFiatValue: String) -> String { - saved.shouldRefreshBitcoinFromFiat ? saved.fiatValue : recalculatedFiatValue - } - - private func fiatValue(for bitcoinValue: String) -> String { - guard !bitcoinValue.isEmpty else { return "" } - let sats = CalculatorWidgetFormatter.bitcoinValueToSats(bitcoinValue, displayUnit: currency.displayUnit) - if sats == 0 { return "0.00" } - guard let converted = currency.convert(sats: sats) else { - return "" - } - return CalculatorWidgetFormatter.fiatRawValue(from: converted.value) - } - - private func selectInput(_ input: CalculatorMoneyType) { - previewActiveInput = input - } - - private func onSave() { - widgets.saveWidget(widgetType) - navigation.reset() - } - - private func onDelete() { - widgets.deleteWidget(widgetType) - navigation.reset() - } -} - -#Preview { - NavigationStack { - CalculatorWidgetPreviewView() - .environmentObject(NavigationViewModel()) - .environmentObject(WidgetsViewModel()) - .environmentObject(CurrencyViewModel()) - } - .preferredColorScheme(.dark) -} diff --git a/Bitkit/Views/Widgets/FactsWidgetPreviewView.swift b/Bitkit/Views/Widgets/FactsWidgetPreviewView.swift deleted file mode 100644 index 1f2496c98..000000000 --- a/Bitkit/Views/Widgets/FactsWidgetPreviewView.swift +++ /dev/null @@ -1,173 +0,0 @@ -import SwiftUI - -/// Preview screen for the Bitcoin Facts widget. -struct FactsWidgetPreviewView: View { - @EnvironmentObject private var navigation: NavigationViewModel - @EnvironmentObject private var widgets: WidgetsViewModel - - @StateObject private var viewModel = FactsViewModel.shared - - // TODO: revert to 0 to re-enable the compact widget preview - @State private var carouselPage: Int = 1 - @State private var showDeleteAlert = false - - private let widgetType: WidgetType = .facts - - private var widgetName: String { - t("widgets__facts__name") - } - - private var widgetDescription: String { - t("widgets__facts__description") - } - - private var isWidgetSaved: Bool { - widgets.isWidgetSaved(widgetType) - } - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - NavigationBar(title: widgetName, showMenuButton: true) - - VStack(alignment: .leading, spacing: 0) { - BodyMText(widgetDescription, textColor: .textSecondary) - .padding(.bottom, 16) - - Divider().background(Color.white.opacity(0.1)) - } - - VStack(spacing: 16) { - carousel - - // Size label hidden while only the wide widget is shown - // sizeLabel - - // Page indicator hidden while only the wide widget is shown - // pageIndicator - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - buttonsRow - } - .navigationBarHidden(true) - .padding(.horizontal, 16) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .bottomSafeAreaPadding() - .alert( - t("widgets__delete__title"), - isPresented: $showDeleteAlert, - actions: { - Button(t("common__cancel"), role: .cancel) { showDeleteAlert = false } - Button(t("common__delete_yes"), role: .destructive) { onDelete() } - }, - message: { - Text(t("widgets__delete__description", variables: ["name": widgetName])) - } - ) - } - - private var carousel: some View { - TabView(selection: $carouselPage) { - // Compact preview temporarily hidden — only the wide widget can be added for now - // compactPage.tag(0) - widePage.tag(1) - } - .tabViewStyle(.page(indexDisplayMode: .never)) - .frame(maxHeight: .infinity) - } - - private var compactPage: some View { - VStack { - Spacer(minLength: 0) - FactsWidgetCompactContent(fact: viewModel.fact) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) - .frame(width: 163, height: 192) - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity) - } - - private var widePage: some View { - VStack { - Spacer(minLength: 0) - FactsWidgetWideContent(fact: viewModel.fact) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) - .frame(maxWidth: .infinity) - Spacer(minLength: 0) - } - } - - private var sizeLabel: some View { - HStack { - Spacer() - CaptionMText( - carouselPage == 0 - ? t("widgets__widget__size_small") - : t("widgets__widget__size_wide"), - textColor: .textSecondary - ) - .textCase(.uppercase) - Spacer() - } - } - - private var pageIndicator: some View { - HStack(spacing: 8) { - Spacer() - ForEach(0 ..< 2, id: \.self) { index in - Circle() - .fill(carouselPage == index ? Color.white : Color.white.opacity(0.32)) - .frame(width: 8, height: 8) - } - Spacer() - } - } - - private var buttonsRow: some View { - HStack(spacing: 16) { - if isWidgetSaved { - CustomButton( - title: t("common__delete"), - variant: .secondary, - size: .large, - shouldExpand: true - ) { - showDeleteAlert = true - } - .accessibilityIdentifier("WidgetDelete") - } - - CustomButton( - title: t("widgets__widget__save_widget"), - variant: .primary, - size: .large, - shouldExpand: true, - action: onSave - ) - .accessibilityIdentifier("WidgetSave") - } - } - - private func onSave() { - widgets.saveWidget(widgetType) - navigation.reset() - } - - private func onDelete() { - widgets.deleteWidget(widgetType) - navigation.reset() - } -} - -#Preview { - NavigationStack { - FactsWidgetPreviewView() - .environmentObject(NavigationViewModel()) - .environmentObject(WidgetsViewModel()) - } - .preferredColorScheme(.dark) -} diff --git a/Bitkit/Views/Widgets/NewsWidgetPreviewView.swift b/Bitkit/Views/Widgets/NewsWidgetPreviewView.swift deleted file mode 100644 index 868bc8e09..000000000 --- a/Bitkit/Views/Widgets/NewsWidgetPreviewView.swift +++ /dev/null @@ -1,261 +0,0 @@ -import SwiftUI - -/// Preview screen for the Bitcoin Headlines widget. -struct NewsWidgetPreviewView: View { - @EnvironmentObject private var navigation: NavigationViewModel - @EnvironmentObject private var widgets: WidgetsViewModel - - @StateObject private var viewModel = NewsViewModel.shared - - // TODO: revert to 0 to re-enable the compact widget preview - @State private var carouselPage: Int = 1 - @State private var showDeleteAlert = false - - private let widgetType: WidgetType = .news - - private var widgetName: String { - t("widgets__news__name") - } - - private var widgetDescription: String { - t("widgets__news__description") - } - - private var isWidgetSaved: Bool { - widgets.isWidgetSaved(widgetType) - } - - private var hasCustomOptions: Bool { - widgets.hasCustomOptions(for: widgetType) - } - - private var currentOptions: NewsWidgetOptions { - widgets.getOptions(for: widgetType, as: NewsWidgetOptions.self) - } - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - NavigationBar(title: widgetName, showMenuButton: true) - - VStack(alignment: .leading, spacing: 0) { - BodyMText(widgetDescription, textColor: .textSecondary) - .padding(.bottom, 16) - - Divider().background(Color.white.opacity(0.1)) - - widgetSettingsRow - - Divider().background(Color.white.opacity(0.1)) - } - - VStack(spacing: 16) { - carousel - - // Size label hidden while only the wide widget is shown - // sizeLabel - - // Page indicator hidden while only the wide widget is shown - // pageIndicator - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - buttonsRow - } - .navigationBarHidden(true) - .padding(.horizontal, 16) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .bottomSafeAreaPadding() - .task { - viewModel.startUpdates() - } - .alert( - t("widgets__delete__title"), - isPresented: $showDeleteAlert, - actions: { - Button(t("common__cancel"), role: .cancel) { showDeleteAlert = false } - Button(t("common__delete_yes"), role: .destructive) { onDelete() } - }, - message: { - Text(t("widgets__delete__description", variables: ["name": widgetName])) - } - ) - } - - // MARK: - Widget Settings cell - - private var widgetSettingsRow: some View { - Button(action: { navigation.navigate(.widgetEdit(widgetType)) }) { - HStack(alignment: .center, spacing: 0) { - BodyMText(t("widgets__widget__settings"), textColor: .textPrimary) - - Spacer() - - BodyMText( - hasCustomOptions - ? t("widgets__widget__edit_custom") - : t("widgets__widget__edit_default"), - textColor: .textSecondary - ) - - Image("chevron") - .resizable() - .foregroundColor(.textSecondary) - .frame(width: 24, height: 24) - .padding(.leading, 5) - } - .frame(maxWidth: .infinity, minHeight: 51) - .contentShape(Rectangle()) - } - .buttonStyle(PlainButtonStyle()) - .accessibilityIdentifier("WidgetEdit") - } - - // MARK: - Carousel - - private var carousel: some View { - TabView(selection: $carouselPage) { - // Compact preview temporarily hidden — only the wide widget can be added for now - // compactPage - // .tag(0) - - widePage - .tag(1) - } - .tabViewStyle(.page(indexDisplayMode: .never)) - .frame(maxHeight: .infinity) - } - - private var compactPage: some View { - VStack { - Spacer(minLength: 0) - Group { - if let data = viewModel.widgetData { - NewsWidgetCompactContent(title: data.title, timeAgo: data.timeAgo, options: currentOptions) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) - } else { - placeholderCompact - } - } - .frame(width: 163, height: 192) - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity) - } - - private var widePage: some View { - VStack { - Spacer(minLength: 0) - Group { - if let data = viewModel.widgetData { - NewsWidgetWideContent( - title: data.title, - publisher: data.publisher, - timeAgo: data.timeAgo, - options: currentOptions - ) - .frame(height: NewsWidgetWideContent.inAppContentHeight) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) - } else { - placeholderWide - } - } - .frame(maxWidth: .infinity) - Spacer(minLength: 0) - } - } - - private var placeholderCompact: some View { - Color.gray6 - .cornerRadius(16) - .overlay(ProgressView()) - } - - private var placeholderWide: some View { - ProgressView() - .frame(maxWidth: .infinity) - .frame(height: NewsWidgetWideContent.inAppContentHeight) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) - } - - // MARK: - Size label & page indicator - - private var sizeLabel: some View { - HStack { - Spacer() - CaptionMText( - carouselPage == 0 - ? t("widgets__widget__size_small") - : t("widgets__widget__size_wide"), - textColor: .textSecondary - ) - .textCase(.uppercase) - Spacer() - } - } - - private var pageIndicator: some View { - HStack(spacing: 8) { - Spacer() - ForEach(0 ..< 2, id: \.self) { index in - Circle() - .fill(carouselPage == index ? Color.white : Color.white.opacity(0.32)) - .frame(width: 8, height: 8) - } - Spacer() - } - } - - // MARK: - Buttons - - private var buttonsRow: some View { - HStack(spacing: 16) { - if isWidgetSaved { - CustomButton( - title: t("common__delete"), - variant: .secondary, - size: .large, - shouldExpand: true - ) { - showDeleteAlert = true - } - .accessibilityIdentifier("WidgetDelete") - } - - CustomButton( - title: t("widgets__widget__save_widget"), - variant: .primary, - size: .large, - shouldExpand: true, - action: onSave - ) - .accessibilityIdentifier("WidgetSave") - } - } - - // MARK: - Actions - - private func onSave() { - widgets.saveWidget(widgetType) - navigation.reset() - } - - private func onDelete() { - widgets.deleteWidget(widgetType) - navigation.reset() - } -} - -#Preview { - NavigationStack { - NewsWidgetPreviewView() - .environmentObject(NavigationViewModel()) - .environmentObject(WidgetsViewModel()) - } - .preferredColorScheme(.dark) -} diff --git a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift b/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift deleted file mode 100644 index 0c3695fd3..000000000 --- a/Bitkit/Views/Widgets/PriceWidgetPreviewView.swift +++ /dev/null @@ -1,263 +0,0 @@ -import SwiftUI - -/// Preview screen for the Bitcoin Price widget. -struct PriceWidgetPreviewView: View { - @EnvironmentObject private var navigation: NavigationViewModel - @EnvironmentObject private var widgets: WidgetsViewModel - - @StateObject private var viewModel = PriceViewModel.shared - - // TODO: revert to 0 to re-enable the compact widget preview - @State private var carouselPage: Int = 1 - @State private var showDeleteAlert = false - - private let widgetType: WidgetType = .price - - private var widgetName: String { - t("widgets__price__name") - } - - private var widgetDescription: String { - t("widgets__price__description") - } - - private var isWidgetSaved: Bool { - widgets.isWidgetSaved(widgetType) - } - - private var hasCustomOptions: Bool { - widgets.hasCustomOptions(for: widgetType) - } - - private var currentOptions: PriceWidgetOptions { - widgets.getOptions(for: widgetType, as: PriceWidgetOptions.self) - } - - private var primaryPrice: PriceData? { - let options = currentOptions - let currentPeriodData = viewModel.getCurrentData(for: options.selectedPeriod) - if let match = currentPeriodData.first(where: { $0.name == options.selectedPair }) { - return match - } - return currentPeriodData.first - } - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - NavigationBar(title: widgetName, showMenuButton: true) - - VStack(alignment: .leading, spacing: 0) { - BodyMText(widgetDescription, textColor: .textSecondary) - .padding(.bottom, 16) - - Divider().background(Color.white.opacity(0.1)) - - widgetSettingsRow - - Divider().background(Color.white.opacity(0.1)) - } - - VStack(spacing: 16) { - carousel - - // Size label hidden while only the wide widget is shown - // sizeLabel - - // Page indicator hidden while only the wide widget is shown - // pageIndicator - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - buttonsRow - } - .navigationBarHidden(true) - .padding(.horizontal, 16) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .bottomSafeAreaPadding() - .task { - let options = currentOptions - viewModel.fetchPriceData(pairs: [options.selectedPair], period: options.selectedPeriod) - } - .alert( - t("widgets__delete__title"), - isPresented: $showDeleteAlert, - actions: { - Button(t("common__cancel"), role: .cancel) { showDeleteAlert = false } - Button(t("common__delete_yes"), role: .destructive) { onDelete() } - }, - message: { - Text(t("widgets__delete__description", variables: ["name": widgetName])) - } - ) - } - - // MARK: - Widget Settings cell - - private var widgetSettingsRow: some View { - Button(action: { navigation.navigate(.widgetEdit(widgetType)) }) { - HStack(alignment: .center, spacing: 0) { - BodyMText(t("widgets__widget__settings"), textColor: .textPrimary) - - Spacer() - - BodyMText( - hasCustomOptions - ? t("widgets__widget__edit_custom") - : t("widgets__widget__edit_default"), - textColor: .textSecondary - ) - - Image("chevron") - .resizable() - .foregroundColor(.textSecondary) - .frame(width: 24, height: 24) - .padding(.leading, 5) - } - .frame(maxWidth: .infinity, minHeight: 51) - .contentShape(Rectangle()) - } - .buttonStyle(PlainButtonStyle()) - .accessibilityIdentifier("WidgetEdit") - } - - // MARK: - Carousel - - private var carousel: some View { - TabView(selection: $carouselPage) { - // Compact preview temporarily hidden — only the wide widget can be added for now - // compactPage - // .tag(0) - - widePage - .tag(1) - } - .tabViewStyle(.page(indexDisplayMode: .never)) - .frame(maxHeight: .infinity) - } - - private var compactPage: some View { - VStack { - Spacer(minLength: 0) - Group { - if let data = primaryPrice { - PriceWidgetCompactContent(data: data, period: currentOptions.selectedPeriod) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) - } else { - placeholderCompact - } - } - .frame(width: 163, height: 192) - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity) - } - - private var widePage: some View { - VStack { - Spacer(minLength: 0) - Group { - if let data = primaryPrice { - PriceWidgetWideContent(data: data, period: currentOptions.selectedPeriod) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) - } else { - placeholderWide - } - } - .frame(maxWidth: .infinity) - Spacer(minLength: 0) - } - } - - private var placeholderCompact: some View { - Color.gray6 - .cornerRadius(16) - .overlay(ProgressView()) - } - - private var placeholderWide: some View { - Color.gray6 - .cornerRadius(16) - .frame(height: 152) - .overlay(ProgressView()) - } - - // MARK: - Size label & page indicator - - private var sizeLabel: some View { - HStack { - Spacer() - CaptionMText( - carouselPage == 0 - ? t("widgets__widget__size_small") - : t("widgets__widget__size_wide"), - textColor: .textSecondary - ) - .textCase(.uppercase) - Spacer() - } - } - - private var pageIndicator: some View { - HStack(spacing: 8) { - Spacer() - ForEach(0 ..< 2, id: \.self) { index in - Circle() - .fill(carouselPage == index ? Color.white : Color.white.opacity(0.32)) - .frame(width: 8, height: 8) - } - Spacer() - } - } - - // MARK: - Buttons - - private var buttonsRow: some View { - HStack(spacing: 16) { - if isWidgetSaved { - CustomButton( - title: t("common__delete"), - variant: .secondary, - size: .large, - shouldExpand: true - ) { - showDeleteAlert = true - } - .accessibilityIdentifier("WidgetDelete") - } - - CustomButton( - title: t("widgets__widget__save_widget"), - variant: .primary, - size: .large, - shouldExpand: true, - action: onSave - ) - .accessibilityIdentifier("WidgetSave") - } - } - - // MARK: - Actions - - private func onSave() { - widgets.saveWidget(widgetType) - navigation.reset() - } - - private func onDelete() { - widgets.deleteWidget(widgetType) - navigation.reset() - } -} - -#Preview { - NavigationStack { - PriceWidgetPreviewView() - .environmentObject(NavigationViewModel()) - .environmentObject(WidgetsViewModel()) - } - .preferredColorScheme(.dark) -} diff --git a/Bitkit/Views/Widgets/WeatherWidgetPreviewView.swift b/Bitkit/Views/Widgets/WeatherWidgetPreviewView.swift deleted file mode 100644 index aae69c505..000000000 --- a/Bitkit/Views/Widgets/WeatherWidgetPreviewView.swift +++ /dev/null @@ -1,264 +0,0 @@ -import SwiftUI - -/// Preview screen for the Bitcoin Weather widget. -struct WeatherWidgetPreviewView: View { - @EnvironmentObject private var navigation: NavigationViewModel - @EnvironmentObject private var widgets: WidgetsViewModel - @EnvironmentObject private var currency: CurrencyViewModel - - @StateObject private var viewModel = WeatherViewModel.shared - - // TODO: revert to 0 to re-enable the compact widget preview - @State private var carouselPage: Int = 1 - @State private var showDeleteAlert = false - - private let widgetType: WidgetType = .weather - - private var widgetName: String { - t("widgets__weather__name") - } - - private var widgetDescription: String { - t("widgets__weather__description") - } - - private var isWidgetSaved: Bool { - widgets.isWidgetSaved(widgetType) - } - - private var hasCustomOptions: Bool { - widgets.hasCustomOptions(for: widgetType) - } - - private var currentOptions: WeatherWidgetOptions { - widgets.getOptions(for: widgetType, as: WeatherWidgetOptions.self) - } - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - NavigationBar(title: widgetName, showMenuButton: true) - - VStack(alignment: .leading, spacing: 0) { - BodyMText(widgetDescription, textColor: .textSecondary) - .padding(.bottom, 16) - - Divider().background(Color.white.opacity(0.1)) - - widgetSettingsRow - - Divider().background(Color.white.opacity(0.1)) - } - - VStack(spacing: 16) { - carousel - - // Size label hidden while only the wide widget is shown - // sizeLabel - - // Page indicator hidden while only the wide widget is shown - // pageIndicator - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - buttonsRow - } - .navigationBarHidden(true) - .padding(.horizontal, 16) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .bottomSafeAreaPadding() - .task { - viewModel.setCurrencyViewModel(currency) - viewModel.startUpdates() - } - .alert( - t("widgets__delete__title"), - isPresented: $showDeleteAlert, - actions: { - Button(t("common__cancel"), role: .cancel) { showDeleteAlert = false } - Button(t("common__delete_yes"), role: .destructive) { onDelete() } - }, - message: { - Text(t("widgets__delete__description", variables: ["name": widgetName])) - } - ) - } - - // MARK: - Widget Settings cell - - private var widgetSettingsRow: some View { - Button(action: { navigation.navigate(.widgetEdit(widgetType)) }) { - HStack(alignment: .center, spacing: 0) { - BodyMText(t("widgets__widget__settings"), textColor: .textPrimary) - - Spacer() - - BodyMText( - hasCustomOptions - ? t("widgets__widget__edit_custom") - : t("widgets__widget__edit_default"), - textColor: .textSecondary - ) - - Image("chevron") - .resizable() - .foregroundColor(.textSecondary) - .frame(width: 24, height: 24) - .padding(.leading, 5) - } - .frame(maxWidth: .infinity, minHeight: 51) - .contentShape(Rectangle()) - } - .buttonStyle(PlainButtonStyle()) - .accessibilityIdentifier("WidgetEdit") - } - - // MARK: - Carousel - - private var carousel: some View { - TabView(selection: $carouselPage) { - // Compact preview temporarily hidden — only the wide widget can be added for now - // compactPage.tag(0) - widePage.tag(1) - } - .tabViewStyle(.page(indexDisplayMode: .never)) - .frame(maxHeight: .infinity) - } - - private var compactPage: some View { - VStack { - Spacer(minLength: 0) - Group { - if let data = viewModel.weatherData { - WeatherWidgetCompactContent( - data: data, - metric: currentOptions.selectedMetric, - conditionTitle: t(data.condition.shortTitleKey), - metricLabel: t(currentOptions.selectedMetric.labelKey) - ) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) - } else { - placeholderCompact - } - } - .frame(width: 163, height: 192) - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity) - } - - private var widePage: some View { - VStack { - Spacer(minLength: 0) - Group { - if let data = viewModel.weatherData { - WeatherWidgetWideContent( - data: data, - metric: currentOptions.selectedMetric, - conditionTitle: t(data.condition.titleKey), - conditionDescription: t(data.condition.descriptionKey), - metricLabel: t(currentOptions.selectedMetric.labelKey) - ) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) - } else { - placeholderWide - } - } - .frame(maxWidth: .infinity) - Spacer(minLength: 0) - } - } - - private var placeholderCompact: some View { - Color.gray6 - .cornerRadius(16) - .overlay(ProgressView()) - } - - private var placeholderWide: some View { - Color.gray6 - .cornerRadius(16) - .frame(height: 180) - .overlay(ProgressView()) - } - - // MARK: - Size label & page indicator - - private var sizeLabel: some View { - HStack { - Spacer() - CaptionMText( - carouselPage == 0 - ? t("widgets__widget__size_small") - : t("widgets__widget__size_wide"), - textColor: .textSecondary - ) - .textCase(.uppercase) - Spacer() - } - } - - private var pageIndicator: some View { - HStack(spacing: 8) { - Spacer() - ForEach(0 ..< 2, id: \.self) { index in - Circle() - .fill(carouselPage == index ? Color.white : Color.white.opacity(0.32)) - .frame(width: 8, height: 8) - } - Spacer() - } - } - - // MARK: - Buttons - - private var buttonsRow: some View { - HStack(spacing: 16) { - if isWidgetSaved { - CustomButton( - title: t("common__delete"), - variant: .secondary, - size: .large, - shouldExpand: true - ) { - showDeleteAlert = true - } - .accessibilityIdentifier("WidgetDelete") - } - - CustomButton( - title: t("widgets__widget__save_widget"), - variant: .primary, - size: .large, - shouldExpand: true, - action: onSave - ) - .accessibilityIdentifier("WidgetSave") - } - } - - // MARK: - Actions - - private func onSave() { - widgets.saveWidget(widgetType) - navigation.reset() - } - - private func onDelete() { - widgets.deleteWidget(widgetType) - navigation.reset() - } -} - -#Preview { - NavigationStack { - WeatherWidgetPreviewView() - .environmentObject(NavigationViewModel()) - .environmentObject(WidgetsViewModel()) - .environmentObject(CurrencyViewModel()) - } - .preferredColorScheme(.dark) -} diff --git a/Bitkit/Views/Widgets/WidgetDetailView.swift b/Bitkit/Views/Widgets/WidgetDetailView.swift deleted file mode 100644 index 362e5f2f4..000000000 --- a/Bitkit/Views/Widgets/WidgetDetailView.swift +++ /dev/null @@ -1,186 +0,0 @@ -import SwiftUI - -struct WidgetDetailView: View { - @EnvironmentObject var app: AppViewModel - @EnvironmentObject var navigation: NavigationViewModel - @EnvironmentObject private var currency: CurrencyViewModel - @EnvironmentObject private var widgets: WidgetsViewModel - let id: WidgetType - - /// State for managing widget actions - @State private var showDeleteAlert = false - - /// Widget data computed from the ID - private var widget: (name: String, description: String, icon: String) { - let name = t("widgets__\(id.rawValue)__name") - - // Get fiat symbol from currency conversion - let fiatSymbol = currency.symbol - let description = t("widgets__\(id.rawValue)__description", variables: ["fiatSymbol": fiatSymbol]) - let icon = "\(id.rawValue)-widget" - - return (name: name, description: description, icon: icon) - } - - /// Check if widget is already saved (for showing delete button) - private var isWidgetSaved: Bool { - widgets.isWidgetSaved(id) - } - - /// Check if widget has customization options - private var hasOptions: Bool { - switch id { - case .blocks, .news, .price, .weather: - return true - case .suggestions, .calculator, .facts: - return false - } - } - - /// Check if widget has custom options - private var hasCustomOptions: Bool { - widgets.hasCustomOptions(for: id) - } - - private func onSave() { - widgets.saveWidget(id) - navigation.reset() - } - - private func onDelete() { - widgets.deleteWidget(id) - navigation.reset() - } - - @ViewBuilder - private func renderWidget() -> some View { - let widget = Widget(type: id) - widget.view(widgetsViewModel: widgets, isEditing: false, isPreview: true) - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: t("widgets__widget__nav_title")) - .padding(.bottom, 16) - - HStack(spacing: 0) { - VStack(alignment: .leading, spacing: 0) { - HeadlineText(widget.name.replacingOccurrences(of: " ", with: "\n")) - } - - Spacer() - - Image(widget.icon) - .resizable() - .frame(width: 64, height: 64) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - .padding(.bottom, 16) - - BodyMText(widget.description, textColor: .textSecondary) - - if hasOptions { - Button(action: { - navigation.navigate(.widgetEdit(id)) - }) { - HStack(alignment: .center, spacing: 0) { - BodyMText(t("widgets__widget__edit"), textColor: .textPrimary) - - Spacer() - - BodyMText( - hasCustomOptions - ? t("widgets__widget__edit_custom") - : t("widgets__widget__edit_default"), - textColor: .textPrimary - ) - - Image("chevron") - .resizable() - .foregroundColor(.textSecondary) - .frame(width: 24, height: 24) - .padding(.leading, 5) - } - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) - } - .buttonStyle(PlainButtonStyle()) - .padding(.vertical, 14) - .overlay( - VStack { - Divider() - .background(Color.white.opacity(0.1)) - Spacer() - Divider() - .background(Color.white.opacity(0.1)) - } - ) - .padding(.top, 16) - .accessibilityIdentifier("WidgetEdit") - } - - Spacer() - - VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("common__preview")) - .padding(.top, 16) - .padding(.bottom, 16) - - renderWidget() - - HStack(spacing: 16) { - if isWidgetSaved { - CustomButton( - title: t("common__delete"), - variant: .secondary, - size: .large, - shouldExpand: true - ) { - showDeleteAlert = true - } - .accessibilityIdentifier("WidgetDelete") - } - - CustomButton( - title: t("common__save"), - variant: .primary, - size: .large, - shouldExpand: true, - action: onSave - ) - .accessibilityIdentifier("WidgetSave") - } - .padding(.top, 16) - } - } - .navigationBarHidden(true) - .padding(.horizontal, 16) - .alert( - t("widgets__delete__title"), - isPresented: $showDeleteAlert, - actions: { - Button(t("common__cancel"), role: .cancel) { - showDeleteAlert = false - } - - Button(t("common__delete_yes"), role: .destructive) { - onDelete() - } - }, - message: { - Text(t("widgets__delete__description", variables: ["name": widget.name])) - } - ) - } -} - -#Preview { - NavigationStack { - WidgetDetailView(id: .price) - .environmentObject(AppViewModel()) - .environmentObject(NavigationViewModel()) - .environmentObject(CurrencyViewModel()) - .environmentObject(WidgetsViewModel()) - } - .preferredColorScheme(.dark) -} diff --git a/Bitkit/Views/Widgets/WidgetEditView.swift b/Bitkit/Views/Widgets/WidgetEditView.swift deleted file mode 100644 index 464d64dd2..000000000 --- a/Bitkit/Views/Widgets/WidgetEditView.swift +++ /dev/null @@ -1,130 +0,0 @@ -import SwiftUI - -// MARK: - Widget Edit View - -struct WidgetEditView: View { - @EnvironmentObject var navigation: NavigationViewModel - @EnvironmentObject private var currency: CurrencyViewModel - @EnvironmentObject private var widgets: WidgetsViewModel - - let id: WidgetType - - // Logic handler - @State private var editLogic: WidgetEditLogic? - @State private var refreshTrigger = false - - // View models for getting actual content - @StateObject private var blocksViewModel = BlocksViewModel.shared - @StateObject private var newsViewModel = NewsViewModel.shared - @StateObject private var priceViewModel = PriceViewModel.shared - @StateObject private var weatherViewModel = WeatherViewModel.shared - - /// Widget data computed from the ID - private var widget: (name: String, description: String, icon: String) { - let name = t("widgets__\(id.rawValue)__name") - let fiatSymbol = currency.symbol - let description = t("widgets__\(id.rawValue)__description", variables: ["fiatSymbol": fiatSymbol]) - let icon = "\(id.rawValue)-widget" - return (name: name, description: description, icon: icon) - } - - private func getItems() -> [WidgetEditItem] { - guard let editLogic else { return [] } - return WidgetEditItemFactory.getItems( - for: id, - blocksViewModel: blocksViewModel, - newsViewModel: newsViewModel, - priceDataByPeriod: priceViewModel.dataByPeriod, - weatherViewModel: weatherViewModel, - blocksOptions: editLogic.blocksOptions, - newsOptions: editLogic.newsOptions, - priceOptions: editLogic.priceOptions, - weatherOptions: editLogic.weatherOptions - ) - } - - private func onPreview() { - editLogic?.saveOptions() - navigation.navigateBack() - } - - private func onReset() { - editLogic?.resetOptions() - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - NavigationBar( - title: widget.name, - showMenuButton: true - ) - .padding(.bottom, 16) - - ScrollView(showsIndicators: false) { - LazyVStack(spacing: 0) { - ForEach(getItems(), id: \.key) { item in - WidgetEditItemView( - item: item, - onToggle: { editLogic?.toggleOption(item) } - ) - .accessibilityIdentifier("\(item.key)_setting_row") - } - } - .id(refreshTrigger) // Force refresh when refreshTrigger changes - } - - Spacer() - - HStack(spacing: 16) { - CustomButton( - title: t("common__reset"), - variant: .secondary, - size: .large, - isDisabled: !(editLogic?.hasEdited ?? false), - shouldExpand: true - ) { - onReset() - } - .accessibilityIdentifier("WidgetEditReset") - - CustomButton( - title: t("common__preview"), - variant: .primary, - size: .large, - isDisabled: !(editLogic?.hasEnabledOption ?? false), - shouldExpand: true, - action: onPreview - ) - .accessibilityIdentifier("WidgetEditPreview") - } - .padding(.top, 16) - } - .navigationBarHidden(true) - .padding(.horizontal, 16) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear { - if editLogic == nil { - let logic = WidgetEditLogic(widgetType: id, widgetsViewModel: widgets) - logic.onStateChange = { - refreshTrigger.toggle() - } - editLogic = logic - } - editLogic?.loadCurrentOptions() - - if id == .price { - priceViewModel.fetchForEditView() - } - } - } -} - -#Preview { - NavigationStack { - WidgetEditView(id: .news) - .environmentObject(NavigationViewModel()) - .environmentObject(CurrencyViewModel()) - .environmentObject(WidgetsViewModel()) - } - .preferredColorScheme(.dark) -} diff --git a/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift b/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift index af160aa96..8b247aa1a 100644 --- a/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift +++ b/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift @@ -558,11 +558,11 @@ private struct CalculatorWidePreview: View { private func hydrate() { let saved = CalculatorWidgetOptionsStore.load() - let bitcoinValue = CalculatorWidgetPreviewView.previewBitcoinValue(saved: saved, displayUnit: currency.displayUnit) + let bitcoinValue = Self.previewBitcoinValue(saved: saved, displayUnit: currency.displayUnit) values = CalculatorWidgetValues( bitcoinValue: bitcoinValue, - fiatValue: CalculatorWidgetPreviewView.previewFiatValue(saved: saved, recalculatedFiatValue: fiatValue(for: bitcoinValue)), + fiatValue: Self.previewFiatValue(saved: saved, recalculatedFiatValue: fiatValue(for: bitcoinValue)), displayUnit: currency.displayUnit, currencySymbol: currency.symbol, selectedCurrency: currency.selectedCurrency @@ -576,4 +576,17 @@ private struct CalculatorWidePreview: View { guard let converted = currency.convert(sats: sats) else { return "" } return CalculatorWidgetFormatter.fiatRawValue(from: converted.value) } + + private static func previewBitcoinValue(saved: CalculatorWidgetValues, displayUnit: BitcoinDisplayUnit) -> String { + guard !saved.bitcoinValue.isEmpty else { return "" } + + let savedSats = CalculatorWidgetFormatter.bitcoinValueToSats(saved.bitcoinValue, displayUnit: saved.displayUnit) + return savedSats == 0 + ? "0" + : CalculatorWidgetFormatter.satsToBitcoinValue(savedSats, displayUnit: displayUnit) + } + + private static func previewFiatValue(saved: CalculatorWidgetValues, recalculatedFiatValue: String) -> String { + saved.shouldRefreshBitcoinFromFiat ? saved.fiatValue : recalculatedFiatValue + } } diff --git a/Bitkit/Views/Widgets/WidgetsListView.swift b/Bitkit/Views/Widgets/WidgetsListView.swift deleted file mode 100644 index 857c4b915..000000000 --- a/Bitkit/Views/Widgets/WidgetsListView.swift +++ /dev/null @@ -1,44 +0,0 @@ -import SwiftUI - -struct WidgetsListView: View { - @EnvironmentObject var navigation: NavigationViewModel - @EnvironmentObject var settings: SettingsViewModel - - var body: some View { - VStack(spacing: 0) { - NavigationBar(title: t("widgets__add")) - - GeometryReader { geometry in - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(WidgetType.allCases, id: \.rawValue) { widgetType in - WidgetListItem(id: widgetType, isDisabled: !settings.showWidgets) - } - } - - Spacer() - - if !settings.showWidgets { - CustomButton(title: t("widgets__list__button")) { - navigation.navigate(.widgetsSettings) - } - } - } - .frame(minHeight: geometry.size.height) - .padding(.top, 16) - .bottomSafeAreaPadding() - } - } - } - .navigationBarHidden(true) - .padding(.horizontal, 16) - } -} - -#Preview { - NavigationStack { - WidgetsListView() - } - .preferredColorScheme(.dark) -} From 474d13479336260c98fd336140f2bb44ad629ea1 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 28 May 2026 09:13:22 -0300 Subject: [PATCH 10/31] fix: set sheet solid background color --- Bitkit/Styles/SheetStyles.swift | 33 ++++++++++++++----- Bitkit/Views/Sheets/Sheet.swift | 5 ++- .../Views/Widgets/WidgetEditSheetView.swift | 3 ++ .../Widgets/WidgetPreviewSheetView.swift | 3 ++ Bitkit/Views/Widgets/WidgetsSheet.swift | 2 +- 5 files changed, 36 insertions(+), 10 deletions(-) diff --git a/Bitkit/Styles/SheetStyles.swift b/Bitkit/Styles/SheetStyles.swift index 6ad318cb0..963d3e491 100644 --- a/Bitkit/Styles/SheetStyles.swift +++ b/Bitkit/Styles/SheetStyles.swift @@ -4,13 +4,30 @@ extension View { /// Applies the standard sheet gradient over the given base color. Default base is black; /// the v61 widgets sheet uses gray7. func sheetBackground(base: Color = .black) -> some View { - background( - LinearGradient( - gradient: Gradient(colors: [Color.white.opacity(0.08), Color.white.opacity(0.012)]), - startPoint: .top, - endPoint: .bottom - ) - ) - .background(base) + modifier(SheetBackgroundModifier(base: base, applyGradient: true)) + } +} + +/// Sheet fill. When `applyGradient` is true a subtle white top-down gradient is layered over +/// the base color (the default chrome). The widgets sheet sets it false to match Figma's solid +/// gray7 modal (the gradient would otherwise lighten it). +struct SheetBackgroundModifier: ViewModifier { + let base: Color + let applyGradient: Bool + + func body(content: Content) -> some View { + if applyGradient { + content + .background( + LinearGradient( + gradient: Gradient(colors: [Color.white.opacity(0.08), Color.white.opacity(0.012)]), + startPoint: .top, + endPoint: .bottom + ) + ) + .background(base) + } else { + content.background(base) + } } } diff --git a/Bitkit/Views/Sheets/Sheet.swift b/Bitkit/Views/Sheets/Sheet.swift index 6aca98340..c303c4ca7 100644 --- a/Bitkit/Views/Sheets/Sheet.swift +++ b/Bitkit/Views/Sheets/Sheet.swift @@ -101,16 +101,19 @@ struct Sheet: View { @EnvironmentObject private var sheets: SheetViewModel let configuration: SheetConfiguration let backgroundColor: Color + let applyGradient: Bool let content: () -> Content init( id: SheetID, data: (any SheetItem)? = nil, backgroundColor: Color = .black, + applyGradient: Bool = true, @ViewBuilder content: @escaping () -> Content ) { configuration = SheetConfiguration(id: id, data: data) self.backgroundColor = backgroundColor + self.applyGradient = applyGradient self.content = content } @@ -124,7 +127,7 @@ struct Sheet: View { var body: some View { ZStack(alignment: .top) { content() - .sheetBackground(base: backgroundColor) + .modifier(SheetBackgroundModifier(base: backgroundColor, applyGradient: applyGradient)) .bottomSafeAreaPadding() // Custom drag indicator - always on top diff --git a/Bitkit/Views/Widgets/WidgetEditSheetView.swift b/Bitkit/Views/Widgets/WidgetEditSheetView.swift index 9ecf88907..3fee3ea77 100644 --- a/Bitkit/Views/Widgets/WidgetEditSheetView.swift +++ b/Bitkit/Views/Widgets/WidgetEditSheetView.swift @@ -67,6 +67,9 @@ struct WidgetEditSheetView: View { .navigationBarHidden(true) .padding(.horizontal, 16) .frame(maxWidth: .infinity, maxHeight: .infinity) + // Pushed navigationDestination views get an opaque system background; override it + // with the sheet's gray7 so edit matches the list/preview routes. + .background(Color.gray7) .onAppear { if editLogic == nil { let logic = WidgetEditLogic(widgetType: type, widgetsViewModel: widgets) diff --git a/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift b/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift index 8b247aa1a..43ab874ea 100644 --- a/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift +++ b/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift @@ -81,6 +81,9 @@ struct WidgetPreviewSheetView: View { .navigationBarHidden(true) .padding(.horizontal, 16) .frame(maxWidth: .infinity, maxHeight: .infinity) + // Pushed navigationDestination views get an opaque system background; override it + // with the sheet's gray7 so preview matches the list route. + .background(Color.gray7) .task { if supportsSmall { carouselPage = Self.initialCarouselPage(for: type, widgets: widgets) diff --git a/Bitkit/Views/Widgets/WidgetsSheet.swift b/Bitkit/Views/Widgets/WidgetsSheet.swift index 51b2d248a..3ec7ae169 100644 --- a/Bitkit/Views/Widgets/WidgetsSheet.swift +++ b/Bitkit/Views/Widgets/WidgetsSheet.swift @@ -29,7 +29,7 @@ struct WidgetsSheet: View { let config: WidgetsSheetItem var body: some View { - Sheet(id: .widgets, data: config, backgroundColor: .gray7) { + Sheet(id: .widgets, data: config, backgroundColor: .gray7, applyGradient: false) { NavigationStack(path: $navigationPath) { viewForRoute(config.initialRoute) .navigationDestination(for: WidgetsRoute.self) { route in From c98ee8877b4beb243d3c737c3900a55de3731c1f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 28 May 2026 09:39:21 -0300 Subject: [PATCH 11/31] fix: replace grab release strategy with instant update --- Bitkit/Components/Widgets/BaseWidget.swift | 6 +- Bitkit/Views/Home/HomeWidgetsView.swift | 106 +++++++++------------ 2 files changed, 52 insertions(+), 60 deletions(-) diff --git a/Bitkit/Components/Widgets/BaseWidget.swift b/Bitkit/Components/Widgets/BaseWidget.swift index a3fdb7eab..e88f4ba0e 100644 --- a/Bitkit/Components/Widgets/BaseWidget.swift +++ b/Bitkit/Components/Widgets/BaseWidget.swift @@ -80,6 +80,7 @@ struct BaseWidget: View { @EnvironmentObject private var currency: CurrencyViewModel @EnvironmentObject private var sheets: SheetViewModel @EnvironmentObject private var widgets: WidgetsViewModel + @Environment(\.widgetDragState) private var dragState private static var smallHeight: CGFloat { 192 @@ -191,7 +192,10 @@ struct BaseWidget: View { .contentShape(Rectangle()) .trackDragHandle() } - .draggable(type.rawValue) { + .onDrag { + dragState.draggingType = type + return NSItemProvider(object: type.rawValue as NSString) + } preview: { dragPreview } .accessibilityIdentifier("\(metadata.name)_WidgetActionReorder") diff --git a/Bitkit/Views/Home/HomeWidgetsView.swift b/Bitkit/Views/Home/HomeWidgetsView.swift index ef6680132..f54ae46ff 100644 --- a/Bitkit/Views/Home/HomeWidgetsView.swift +++ b/Bitkit/Views/Home/HomeWidgetsView.swift @@ -22,7 +22,7 @@ struct HomeWidgetsView: View { @State private var focusedContentOffsetY: CGFloat = 0 @State private var firstCalculatorTopPadding: CGFloat = 0 @State private var numberPadFrame: CGRect? - @StateObject private var cellFrames = WidgetCellFrameStore() + @State private var dragState = WidgetDragState() private static let focusAnimation = Animation.easeOut(duration: focusAnimationDuration) private static let focusAnimationDuration = 0.12 @@ -91,10 +91,10 @@ struct HomeWidgetsView: View { } } .id(visibleWidgets.map(\.id)) - .onPreferenceChange(WidgetCellFramesPreferenceKey.self) { frames in - cellFrames.frames = frames - } - .onDrop(of: [.utf8PlainText], delegate: WidgetReorderDropDelegate(host: self, cellFrames: cellFrames)) + .environment(\.widgetDragState, dragState) + // Catch-all so drops that land in a gap are still accepted (the live + // reorder already happened on hover); avoids the snap-back animation. + .onDrop(of: [.utf8PlainText], delegate: WidgetGridDropDelegate(dragState: dragState)) CustomButton(title: t("widgets__add"), variant: .tertiary) { calculatorInput.dismiss() @@ -367,13 +367,9 @@ struct HomeWidgetsView: View { private func cell(_ widget: Widget) -> some View { rowContent(widget) - .background( - GeometryReader { proxy in - Color.clear.preference( - key: WidgetCellFramesPreferenceKey.self, - value: [WidgetCellFrame(type: widget.type, rect: proxy.frame(in: .global))] - ) - } + .onDrop( + of: [.utf8PlainText], + delegate: WidgetCellDropDelegate(target: widget, dragState: dragState, reorder: reorder) ) } @@ -385,7 +381,6 @@ struct HomeWidgetsView: View { let destIdx = widgets.savedWidgets.firstIndex(where: { $0.type == targetType }) else { return false } widgets.reorderWidgets(from: sourceIdx, to: destIdx) - Haptics.notify(.success) return true } @@ -425,68 +420,61 @@ struct HomeWidgetsView: View { } } -/// Captured frame of one widget cell in `.global` coordinates. -struct WidgetCellFrame: Equatable { - let type: WidgetType - let rect: CGRect +/// Shared drag context for the home widget grid. The dragged widget's type is recorded when +/// the burger handle's drag starts (in `BaseWidget`) so drop delegates can reorder live on hover +/// without having to asynchronously load the item provider. +final class WidgetDragState { + var draggingType: WidgetType? } -/// PreferenceKey carrying every cell's frame up to the grid so the drop delegate can -/// resolve which widget the drop landed nearest to (including drops that fall in the -/// 16pt gap between cells). -struct WidgetCellFramesPreferenceKey: PreferenceKey { - static var defaultValue: [WidgetCellFrame] = [] - static func reduce(value: inout [WidgetCellFrame], nextValue: () -> [WidgetCellFrame]) { - value.append(contentsOf: nextValue()) - } +private struct WidgetDragStateKey: EnvironmentKey { + static let defaultValue = WidgetDragState() } -/// Holds the latest set of cell frames so the (struct-based, value-type) `DropDelegate` -/// can read them via reference without becoming stale. -final class WidgetCellFrameStore: ObservableObject { - @Published var frames: [WidgetCellFrame] = [] +extension EnvironmentValues { + var widgetDragState: WidgetDragState { + get { self[WidgetDragStateKey.self] } + set { self[WidgetDragStateKey.self] = newValue } + } } -private struct WidgetReorderDropDelegate: DropDelegate { - let host: HomeWidgetsView - let cellFrames: WidgetCellFrameStore +/// Per-cell delegate that reorders **live** as the dragged widget hovers over this cell. +/// This makes drop position robust — you never need to release in the exact gap, because the +/// array is already reordered by the time you let go. +private struct WidgetCellDropDelegate: DropDelegate { + let target: Widget + let dragState: WidgetDragState + let reorder: (WidgetType, WidgetType) -> Bool + + func dropEntered(info _: DropInfo) { + guard let source = dragState.draggingType, source != target.type else { return } + if reorder(source, target.type) { + UIImpactFeedbackGenerator(style: .soft).impactOccurred(intensity: 0.7) + } + } func dropUpdated(info _: DropInfo) -> DropProposal? { DropProposal(operation: .move) } - func validateDrop(info: DropInfo) -> Bool { - info.hasItemsConforming(to: [.utf8PlainText]) - } - - func performDrop(info: DropInfo) -> Bool { - guard let provider = info.itemProviders(for: [.utf8PlainText]).first else { return false } - let location = info.location - let frames = cellFrames.frames - - provider.loadObject(ofClass: NSString.self) { item, _ in - guard let raw = item as? String, let sourceType = WidgetType(rawValue: raw) else { return } - guard let target = Self.targetType(at: location, in: frames) else { return } - DispatchQueue.main.async { - host.reorder(from: sourceType, to: target) - } - } + func performDrop(info _: DropInfo) -> Bool { + dragState.draggingType = nil return true } +} - /// Cell containing the point, or — if dropped in a gap — the cell with the smallest - /// distance from the drop point to the cell rect. - private static func targetType(at point: CGPoint, in frames: [WidgetCellFrame]) -> WidgetType? { - if let direct = frames.first(where: { $0.rect.contains(point) }) { - return direct.type - } - return frames.min(by: { distance(from: point, to: $0.rect) < distance(from: point, to: $1.rect) })?.type +/// Catch-all delegate on the grid itself: accepts drops that land in a gap (no reorder needed — +/// the cell delegates already moved things on hover) and clears the drag state. +private struct WidgetGridDropDelegate: DropDelegate { + let dragState: WidgetDragState + + func dropUpdated(info _: DropInfo) -> DropProposal? { + DropProposal(operation: .move) } - private static func distance(from point: CGPoint, to rect: CGRect) -> CGFloat { - let dx = max(rect.minX - point.x, 0, point.x - rect.maxX) - let dy = max(rect.minY - point.y, 0, point.y - rect.maxY) - return sqrt(dx * dx + dy * dy) + func performDrop(info _: DropInfo) -> Bool { + dragState.draggingType = nil + return true } } From 2e9377c27be193eafce5d7cd50f00650a8a077c6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 28 May 2026 11:12:29 -0300 Subject: [PATCH 12/31] feat: implement calculator in-app compact mode and simplify CalculatorNumberPadBar display logic --- .../Components/Widgets/CalculatorWidget.swift | 167 ++++++-------- Bitkit/ViewModels/WidgetsViewModel.swift | 8 +- Bitkit/Views/Home/HomeWidgetsView.swift | 204 ++---------------- .../Widgets/WidgetPreviewSheetView.swift | 42 +++- .../Views/Widgets/WidgetsListSheetView.swift | 40 ++-- 5 files changed, 146 insertions(+), 315 deletions(-) diff --git a/Bitkit/Components/Widgets/CalculatorWidget.swift b/Bitkit/Components/Widgets/CalculatorWidget.swift index e82edf10c..e2212a822 100644 --- a/Bitkit/Components/Widgets/CalculatorWidget.swift +++ b/Bitkit/Components/Widgets/CalculatorWidget.swift @@ -2,6 +2,7 @@ import SwiftUI /// A widget that provides Bitcoin to fiat currency conversion. struct CalculatorWidget: View { + var size: WidgetSize = .wide var isEditing: Bool = false var onEditingEnd: (() -> Void)? @@ -10,54 +11,26 @@ struct CalculatorWidget: View { @State private var values = CalculatorWidgetValues() @State private var hasHydrated = false - @State private var isNumberPadMounted = false - @State private var numberPadHeight: CGFloat = Self.collapsedNumberPadHeight @State private var previousDisplayUnit: BitcoinDisplayUnit = .modern - private static let numberPadDismissAnimation = Animation.easeOut(duration: numberPadDismissAnimationDuration) - private static let numberPadDismissAnimationDuration = 0.14 - private static let collapsedNumberPadHeight = 1 / UIScreen.main.scale - private static var fullNumberPadHeight: CGFloat { - 8 + NumberPad.contentHeight + (windowSafeAreaInsets.bottom > 0 ? windowSafeAreaInsets.bottom : 16) - } - init( + size: WidgetSize = .wide, isEditing: Bool = false, onEditingEnd: (() -> Void)? = nil ) { + self.size = size self.isEditing = isEditing self.onEditingEnd = onEditingEnd } var body: some View { - VStack(spacing: 0) { - BaseWidget( - type: .calculator, - isEditing: isEditing, - onEditingEnd: onEditingEnd - ) { - CalculatorWidgetWideContent( - values: currentValues, - activeInput: calculatorInput.activeInput, - onSelectInput: selectInput - ) - } - - if isNumberPadMounted { - numberPad - .frame(height: numberPadHeight, alignment: .top) - .clipped() - .allowsHitTesting(calculatorInput.isPresented) - .trackCalculatorNumberPadFrame() - .transition(.identity) - } - } - .animation(.easeOut(duration: 0.14), value: calculatorInput.isPresented) - .onAppear { - updateNumberPadPresentation(isPresented: calculatorInput.isPresented, animated: false) - } - .onChange(of: calculatorInput.isPresented) { _, isPresented in - updateNumberPadPresentation(isPresented: isPresented, animated: true) + BaseWidget( + type: .calculator, + size: size, + isEditing: isEditing, + onEditingEnd: onEditingEnd + ) { + content } .task { hydrateValuesIfNeeded() @@ -89,26 +62,20 @@ struct CalculatorWidget: View { } } - private var numberPad: some View { - VStack(spacing: 0) { - Spacer() - .frame(height: 8) - - VStack(spacing: 0) { - NumberPad( - type: calculatorInput.numberPadType, - decimalSeparator: calculatorInput.decimalSeparator, - errorKey: calculatorInput.errorKey, - onDeleteLongPress: { - calculatorInput.clear() - } - ) { key in - calculatorInput.submit(key) - } - .padding(.horizontal, 16) - } - .padding(.bottom, windowSafeAreaInsets.bottom > 0 ? windowSafeAreaInsets.bottom : 16) - .background(Color.black.ignoresSafeArea(edges: .bottom)) + @ViewBuilder + private var content: some View { + if size == .small { + CalculatorWidgetCompactContent( + values: currentValues, + activeInput: calculatorInput.activeInput, + onSelectInput: selectInput + ) + } else { + CalculatorWidgetWideContent( + values: currentValues, + activeInput: calculatorInput.activeInput, + onSelectInput: selectInput + ) } } @@ -122,34 +89,6 @@ struct CalculatorWidget: View { ) } - private func updateNumberPadPresentation(isPresented: Bool, animated: Bool) { - if isPresented { - var transaction = Transaction() - transaction.disablesAnimations = true - - withTransaction(transaction) { - isNumberPadMounted = true - numberPadHeight = Self.fullNumberPadHeight - } - return - } - - let collapse = { - numberPadHeight = Self.collapsedNumberPadHeight - } - - if animated { - withAnimation(Self.numberPadDismissAnimation, collapse) - } else { - collapse() - } - - DispatchQueue.main.asyncAfter(deadline: .now() + Self.numberPadDismissAnimationDuration) { - guard !calculatorInput.isPresented else { return } - isNumberPadMounted = false - } - } - private func hydrateValuesIfNeeded() { guard !hasHydrated else { return } hasHydrated = true @@ -381,10 +320,12 @@ struct CalculatorWidgetWideContent: View { } } -// MARK: - Compact layout (small carousel page) +// MARK: - Compact layout (small home grid + carousel page) struct CalculatorWidgetCompactContent: View { let values: CalculatorWidgetValues + var activeInput: CalculatorMoneyType? + var onSelectInput: ((CalculatorMoneyType) -> Void)? var body: some View { VStack(spacing: 16) { @@ -394,7 +335,9 @@ struct CalculatorWidgetCompactContent: View { iconSize: 24, rowPadding: 12, showsLabel: false, - isActive: false + isActive: activeInput == .bitcoin, + accessibilityIdentifier: "CalculatorBtcInput", + onTap: onSelectInput.map { handler in { handler(.bitcoin) } } ) CalculatorWidgetRow( @@ -403,13 +346,12 @@ struct CalculatorWidgetCompactContent: View { iconSize: 24, rowPadding: 12, showsLabel: false, - isActive: false + isActive: activeInput == .fiat, + accessibilityIdentifier: "CalculatorFiatInput", + onTap: onSelectInput.map { handler in { handler(.fiat) } } ) } - .padding(16) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.gray6) - .cornerRadius(16) + .frame(maxWidth: .infinity) } } @@ -507,23 +449,38 @@ private struct CalculatorCursor: View { } } -struct CalculatorNumberPadFramePreferenceKey: PreferenceKey { - static var defaultValue: CGRect? +// MARK: - Number pad bar (screen-level overlay) + +/// Full-width number pad pinned to the bottom of the screen by `HomeWidgetsView` while a +/// calculator field is focused. Routes key presses through the shared `CalculatorInputManager`, +/// so it works for both the wide and compact calculator without living inside the widget cell. +struct CalculatorNumberPadBar: View { + @Environment(CalculatorInputManager.self) private var calculatorInput - static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) { - value = nextValue() ?? value + static var height: CGFloat { + 8 + NumberPad.contentHeight + (windowSafeAreaInsets.bottom > 0 ? windowSafeAreaInsets.bottom : 16) } -} -private extension View { - func trackCalculatorNumberPadFrame() -> some View { - background { - GeometryReader { proxy in - Color.clear.preference( - key: CalculatorNumberPadFramePreferenceKey.self, - value: proxy.frame(in: .global) - ) + var body: some View { + VStack(spacing: 0) { + Spacer() + .frame(height: 8) + + VStack(spacing: 0) { + NumberPad( + type: calculatorInput.numberPadType, + decimalSeparator: calculatorInput.decimalSeparator, + errorKey: calculatorInput.errorKey, + onDeleteLongPress: { + calculatorInput.clear() + } + ) { key in + calculatorInput.submit(key) + } + .padding(.horizontal, 16) } + .padding(.bottom, windowSafeAreaInsets.bottom > 0 ? windowSafeAreaInsets.bottom : 16) + .background(Color.black.ignoresSafeArea(edges: .bottom)) } } } diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index 877bbe6d9..6b822bcd5 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -86,8 +86,7 @@ struct Widget: Identifiable { onEditingEnd: onEditingEnd ) case .calculator: - // Calculator is wide-only — small variant is deferred to a follow-up plan. - CalculatorWidget(isEditing: isEditing, onEditingEnd: onEditingEnd) + CalculatorWidget(size: size, isEditing: isEditing, onEditingEnd: onEditingEnd) case .facts: FactsWidget(size: size, isEditing: isEditing, onEditingEnd: onEditingEnd) case .news: @@ -217,10 +216,7 @@ class WidgetsViewModel: ObservableObject { /// Save a new widget func saveWidget(_ type: WidgetType, size: WidgetSize = .wide) { - // Suggestions and Calculator are wide-only on the home grid — coerce any - // accidental `.small` callers so the persisted size can never disagree - // with the layout rules in `HomeWidgetsView.displayedSize`. - let resolvedSize: WidgetSize = (type == .suggestions || type == .calculator) ? .wide : size + let resolvedSize: WidgetSize = type == .suggestions ? .wide : size if let index = savedWidgetsWithOptions.firstIndex(where: { $0.type == type }) { let existing = savedWidgetsWithOptions[index] diff --git a/Bitkit/Views/Home/HomeWidgetsView.swift b/Bitkit/Views/Home/HomeWidgetsView.swift index f54ae46ff..fad6b48d0 100644 --- a/Bitkit/Views/Home/HomeWidgetsView.swift +++ b/Bitkit/Views/Home/HomeWidgetsView.swift @@ -15,20 +15,16 @@ struct HomeWidgetsView: View { @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false + /// Global frame of the (single) calculator widget card, reported via preference key. + /// Used to compute how far to lift content so the focused calculator sits above the keypad. @State private var calculatorFrame: CGRect? - @State private var didApplyFirstCalculatorFocusPadding = false @State private var didStartCalculatorDismissDrag = false - @StateObject private var focusAdjustmentState = CalculatorFocusAdjustmentState() @State private var focusedContentOffsetY: CGFloat = 0 - @State private var firstCalculatorTopPadding: CGFloat = 0 - @State private var numberPadFrame: CGRect? @State private var dragState = WidgetDragState() private static let focusAnimation = Animation.easeOut(duration: focusAnimationDuration) private static let focusAnimationDuration = 0.12 private static let focusDismissDragMinimumDistance: CGFloat = 8 - private static let maxFocusAdjustmentPasses = 4 - private static let numberPadEstimatedHeight = 8 + NumberPad.contentHeight + (windowSafeAreaInsets.bottom > 0 ? windowSafeAreaInsets.bottom : 16) private var isPaykitUIActive: Bool { PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled @@ -40,10 +36,6 @@ struct HomeWidgetsView: View { return keyboard.isPresented ? inset : ScreenLayout.bottomPaddingWithSafeArea } - private var isCalculatorFirst: Bool { - widgetsToShow.first?.type == .calculator - } - /// Widgets to display; suggestions widget is hidden when it would show no cards (unless editing). private var widgetsToShow: [Widget] { widgets.savedWidgets.filter { widget in @@ -64,14 +56,9 @@ struct HomeWidgetsView: View { } var body: some View { - ScrollViewReader { proxy in + ScrollViewReader { _ in ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { - if isCalculatorFirst { - Color.clear - .frame(height: firstCalculatorTopPadding) - } - Grid(horizontalSpacing: 16, verticalSpacing: 16) { ForEach(gridRows) { row in GridRow { @@ -135,51 +122,38 @@ struct HomeWidgetsView: View { ) // Dismiss (calculator widget) keyboard when scrolling .scrollDismissesKeyboard(.interactively) + .overlay(alignment: .bottom) { + if calculatorInput.isPresented { + CalculatorNumberPadBar() + .transition(.move(edge: .bottom)) + } + } + .animation(.easeOut(duration: 0.14), value: calculatorInput.isPresented) .onChange(of: calculatorInput.isPresented) { _, isPresented in if isPresented { - startFocusedCalculatorTransition(proxy) + focusCalculator() } else { setFocusedContentOffsetY(0) - setFirstCalculatorTopPadding(0) - finishFocusedCalculatorDismissal() } } .onPreferenceChange(CalculatorWidgetFramePreferenceKey.self) { frame in calculatorFrame = frame } - .onPreferenceChange(CalculatorNumberPadFramePreferenceKey.self) { frame in - numberPadFrame = frame - settleFocusedCalculator(proxy, numberPadFrame: frame) - } .onDisappear { calculatorInput.dismiss() } } } - private func startFocusedCalculatorTransition(_ proxy: ScrollViewProxy) { - if focusAdjustmentState.hasStartedPresentation { - if isCalculatorFirst, !didApplyFirstCalculatorFocusPadding { - applyFirstCalculatorFocusPadding(proxy) - } - - return - } - - resetFocusAdjustment() - focusAdjustmentState.hasStartedPresentation = true - - if isCalculatorFirst { + private func focusCalculator() { + guard let frame = calculatorFrame else { setFocusedContentOffsetY(0) - applyFirstCalculatorFocusPadding(proxy) return } - - if firstCalculatorTopPadding > 0 { - setFirstCalculatorTopPadding(0) - } - - settleFocusedCalculator(proxy) + let keypadTop = UIScreen.main.bounds.height - CalculatorNumberPadBar.height + let desiredMaxY = keypadTop - 16 + let overlap = frame.maxY - desiredMaxY + setFocusedContentOffsetY(overlap > 0 ? -overlap : 0) } private func handleWidgetsPageDragChanged(_ value: DragGesture.Value) { @@ -189,118 +163,6 @@ struct HomeWidgetsView: View { } } - private func applyFirstCalculatorFocusPadding(_ proxy: ScrollViewProxy) { - guard let bottomGap = focusedNumberPadBottomGap(numberPadFrame: numberPadFrame) else { return } - - didApplyFirstCalculatorFocusPadding = true - setFirstCalculatorTopPadding(max(0, bottomGap)) - } - - private func settleFocusedCalculator( - _ proxy: ScrollViewProxy, - numberPadFrame proposedNumberPadFrame: CGRect? = nil - ) { - guard calculatorInput.isPresented else { return } - - let currentNumberPadFrame = proposedNumberPadFrame ?? numberPadFrame - - if isCalculatorFirst { - if !didApplyFirstCalculatorFocusPadding { - startFocusedCalculatorTransition(proxy) - return - } - - settleFirstCalculatorStack(numberPadFrame: currentNumberPadFrame) - return - } else if firstCalculatorTopPadding > 0 { - setFirstCalculatorTopPadding(0) - } - - guard let bottomGap = focusedNumberPadBottomGap(numberPadFrame: currentNumberPadFrame), - !focusAdjustmentState.isAdjusting - else { return } - - guard abs(bottomGap) > 1 else { - focusAdjustmentState.resetCorrection() - return - } - - guard focusAdjustmentState.passes < Self.maxFocusAdjustmentPasses else { return } - - adjustFocusedCalculatorByBottomGap(bottomGap, numberPadFrame: currentNumberPadFrame) - } - - private func settleFirstCalculatorStack(numberPadFrame: CGRect?) { - guard let bottomGap = focusedNumberPadBottomGap(numberPadFrame: numberPadFrame) else { return } - guard abs(bottomGap) > 1 else { return } - guard shouldApplyFocusAdjustment(for: numberPadFrame) else { return } - - setFirstCalculatorTopPadding(max(0, firstCalculatorTopPadding + bottomGap)) - } - - private func focusedNumberPadBottomGap(numberPadFrame: CGRect?) -> CGFloat? { - guard let numberPadFrame else { return nil } - - if numberPadFrame.height >= Self.numberPadEstimatedHeight - 1 { - return focusBottomY - numberPadFrame.maxY - } - - if numberPadFrame.height >= NumberPad.contentHeight - 1 { - return numberPadButtonsBottomY - numberPadFrame.maxY - } - - return nil - } - - private func adjustFocusedCalculatorByBottomGap(_ bottomGap: CGFloat, numberPadFrame: CGRect?) { - guard shouldApplyFocusAdjustment(for: numberPadFrame) else { return } - - focusAdjustmentState.delta = (focusAdjustmentState.delta ?? 0) + bottomGap - focusAdjustmentState.passes += 1 - focusAdjustmentState.isAdjusting = true - setFocusedContentOffsetY(focusedContentOffsetY + bottomGap) - - DispatchQueue.main.asyncAfter(deadline: .now() + Self.focusAnimationDuration + 0.03) { - guard calculatorInput.isPresented, !isCalculatorFirst else { return } - focusAdjustmentState.isAdjusting = false - } - } - - private func shouldApplyFocusAdjustment(for numberPadFrame: CGRect?) -> Bool { - guard let numberPadFrame else { return true } - - let maxY = numberPadFrame.maxY - if let lastAdjustedNumberPadMaxY = focusAdjustmentState.lastAdjustedNumberPadMaxY, abs(lastAdjustedNumberPadMaxY - maxY) <= 1 { - return false - } - - focusAdjustmentState.lastAdjustedNumberPadMaxY = maxY - return true - } - - private func resetFocusAdjustment() { - focusAdjustmentState.reset() - } - - private func finishFocusedCalculatorDismissal() { - DispatchQueue.main.asyncAfter(deadline: .now() + Self.focusAnimationDuration + 0.05) { - guard !calculatorInput.isPresented else { return } - - calculatorFrame = nil - didApplyFirstCalculatorFocusPadding = false - numberPadFrame = nil - resetFocusAdjustment() - } - } - - private func setFirstCalculatorTopPadding(_ value: CGFloat) { - guard abs(value - firstCalculatorTopPadding) > 1 else { return } - - withAnimation(Self.focusAnimation) { - firstCalculatorTopPadding = value - } - } - private func setFocusedContentOffsetY(_ value: CGFloat) { guard abs(value - focusedContentOffsetY) > 1 else { return } @@ -309,19 +171,11 @@ struct HomeWidgetsView: View { } } - private var focusBottomY: CGFloat { - UIScreen.main.bounds.height - } - - private var numberPadButtonsBottomY: CGFloat { - focusBottomY - (windowSafeAreaInsets.bottom > 0 ? windowSafeAreaInsets.bottom : 16) - } - /// Resolved layout size for the grid. Calculator and suggestions are always wide /// regardless of the value stored on `SavedWidget`. private func displayedSize(for widget: Widget) -> WidgetSize { switch widget.type { - case .calculator, .suggestions: return .wide + case .suggestions: return .wide default: return widget.size } } @@ -478,28 +332,6 @@ private struct WidgetGridDropDelegate: DropDelegate { } } -private final class CalculatorFocusAdjustmentState: ObservableObject { - var delta: CGFloat? - var hasStartedPresentation = false - var isAdjusting = false - var lastAdjustedNumberPadMaxY: CGFloat? - var passes = 0 - - func reset() { - delta = nil - hasStartedPresentation = false - isAdjusting = false - lastAdjustedNumberPadMaxY = nil - passes = 0 - } - - func resetCorrection() { - delta = nil - lastAdjustedNumberPadMaxY = nil - passes = 0 - } -} - private struct CalculatorWidgetFramePreferenceKey: PreferenceKey { static var defaultValue: CGRect? diff --git a/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift b/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift index 43ab874ea..82217ead9 100644 --- a/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift +++ b/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift @@ -16,7 +16,7 @@ struct WidgetPreviewSheetView: View { init(type: WidgetType, navigationPath: Binding<[WidgetsRoute]>) { self.type = type _navigationPath = navigationPath - _carouselPage = State(initialValue: type == .calculator ? 1 : Self.initialCarouselPage(for: type, widgets: nil)) + _carouselPage = State(initialValue: Self.initialCarouselPage(for: type, widgets: nil)) } /// Picks the page index that matches the widget's currently-saved size (or `.small` if new). @@ -38,7 +38,7 @@ struct WidgetPreviewSheetView: View { } private var supportsSmall: Bool { - type != .calculator + type != .suggestions } private var isWidgetSaved: Bool { @@ -173,7 +173,8 @@ struct WidgetPreviewSheetView: View { case .blocks: BlocksSmallPreview() case .weather: WeatherSmallPreview() case .facts: FactsSmallPreview() - case .calculator, .suggestions: EmptyView() + case .calculator: CalculatorSmallPreview() + case .suggestions: EmptyView() } } @@ -593,3 +594,38 @@ private struct CalculatorWidePreview: View { saved.shouldRefreshBitcoinFromFiat ? saved.fiatValue : recalculatedFiatValue } } + +private struct CalculatorSmallPreview: View { + @EnvironmentObject private var currency: CurrencyViewModel + + @State private var values = CalculatorWidgetValues() + + var body: some View { + // Display-only (no `onSelectInput`) — the preview carousel doesn't host the keypad. + CalculatorWidgetCompactContent(values: values) + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .background(Color.gray6) + .cornerRadius(16) + .task { hydrate() } + .onChange(of: currency.selectedCurrency) { hydrate() } + .onChange(of: currency.displayUnit) { hydrate() } + .onChange(of: currency.rates) { hydrate() } + } + + private func hydrate() { + let saved = CalculatorWidgetOptionsStore.load() + let savedSats = CalculatorWidgetFormatter.bitcoinValueToSats(saved.bitcoinValue, displayUnit: saved.displayUnit) + let bitcoinValue = saved.bitcoinValue.isEmpty + ? "" + : CalculatorWidgetFormatter.satsToBitcoinValue(savedSats, displayUnit: currency.displayUnit) + + values = CalculatorWidgetValues( + bitcoinValue: bitcoinValue, + fiatValue: saved.fiatValue, + displayUnit: currency.displayUnit, + currencySymbol: currency.symbol, + selectedCurrency: currency.selectedCurrency + ) + } +} diff --git a/Bitkit/Views/Widgets/WidgetsListSheetView.swift b/Bitkit/Views/Widgets/WidgetsListSheetView.swift index afaa0fa87..23bd1b570 100644 --- a/Bitkit/Views/Widgets/WidgetsListSheetView.swift +++ b/Bitkit/Views/Widgets/WidgetsListSheetView.swift @@ -219,21 +219,31 @@ private struct FactsTile: View { } private struct CalculatorTile: View { - /// Calculator has no compact content form. Show a static representation - /// so the tile reads as a calculator at a glance. + @EnvironmentObject private var currency: CurrencyViewModel + @State private var values = CalculatorWidgetValues() + var body: some View { - VStack(alignment: .leading, spacing: 4) { - BodyMSBText("$0.00", textColor: .textPrimary) - BodySText("0", textColor: .textSecondary) - Spacer() - HStack(spacing: 4) { - ForEach(0 ..< 3) { _ in - RoundedRectangle(cornerRadius: 4) - .fill(Color.white10) - .frame(height: 16) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) + // Display-only compact calculator (no `onSelectInput`) for the add-list preview. + CalculatorWidgetCompactContent(values: values) + .task { hydrate() } + .onChange(of: currency.selectedCurrency) { hydrate() } + .onChange(of: currency.displayUnit) { hydrate() } + .onChange(of: currency.rates) { hydrate() } + } + + private func hydrate() { + let saved = CalculatorWidgetOptionsStore.load() + let savedSats = CalculatorWidgetFormatter.bitcoinValueToSats(saved.bitcoinValue, displayUnit: saved.displayUnit) + let bitcoinValue = saved.bitcoinValue.isEmpty + ? "" + : CalculatorWidgetFormatter.satsToBitcoinValue(savedSats, displayUnit: currency.displayUnit) + + values = CalculatorWidgetValues( + bitcoinValue: bitcoinValue, + fiatValue: saved.fiatValue, + displayUnit: currency.displayUnit, + currencySymbol: currency.symbol, + selectedCurrency: currency.selectedCurrency + ) } } From e03060231ecebfac54036ea7889c19732e788126 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 28 May 2026 11:44:13 -0300 Subject: [PATCH 13/31] feat: disabled widgets flow --- Bitkit/Views/Widgets/WidgetsIntroView.swift | 10 +++++- .../Views/Widgets/WidgetsListSheetView.swift | 35 ++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/Bitkit/Views/Widgets/WidgetsIntroView.swift b/Bitkit/Views/Widgets/WidgetsIntroView.swift index 8e18070f0..a4a650f63 100644 --- a/Bitkit/Views/Widgets/WidgetsIntroView.swift +++ b/Bitkit/Views/Widgets/WidgetsIntroView.swift @@ -3,6 +3,7 @@ import SwiftUI struct WidgetsIntroView: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var sheets: SheetViewModel var body: some View { @@ -63,8 +64,14 @@ struct WidgetsIntroView: View { private func onViewOrganize() { app.hasSeenWidgetsIntro = true - app.requestedHomePage = 1 navigation.reset() + // When widgets are disabled the home widgets page is hidden, so there's nothing to + // organize — open the widgets sheet instead (matches Android `onViewOrganize`). + if settings.showWidgets { + app.requestedHomePage = 1 + } else { + sheets.showSheet(.widgets, data: WidgetsConfig(initialRoute: .list)) + } } private func onAddWidget() { @@ -79,6 +86,7 @@ struct WidgetsIntroView: View { WidgetsIntroView() .environmentObject(AppViewModel()) .environmentObject(NavigationViewModel()) + .environmentObject(SettingsViewModel.shared) .environmentObject(SheetViewModel()) } .preferredColorScheme(.dark) diff --git a/Bitkit/Views/Widgets/WidgetsListSheetView.swift b/Bitkit/Views/Widgets/WidgetsListSheetView.swift index 23bd1b570..70de0a2a2 100644 --- a/Bitkit/Views/Widgets/WidgetsListSheetView.swift +++ b/Bitkit/Views/Widgets/WidgetsListSheetView.swift @@ -3,9 +3,15 @@ import SwiftUI struct WidgetsListSheetView: View { @Binding var navigationPath: [WidgetsRoute] + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var settings: SettingsViewModel + @EnvironmentObject private var sheets: SheetViewModel + /// Widget types shown in the add-list, in display order. `suggestions` is system-managed and excluded. private static let listedTypes: [WidgetType] = [.price, .weather, .news, .blocks, .facts, .calculator] + private static let disabledTileAlpha: CGFloat = 0.42 + private enum TileRow: Identifiable { case wide(WidgetType) case pair(WidgetType, WidgetType?) @@ -65,16 +71,43 @@ struct WidgetsListSheetView: View { .padding(.horizontal, 16) .padding(.bottom, 16) } + + // When widgets are disabled, tiles are dimmed/non-tappable and this CTA routes to settings. + if !settings.showWidgets { + CustomButton( + title: t("widgets__list__button"), + variant: .primary, + size: .large, + shouldExpand: true, + action: enableInSettings + ) + .padding(.horizontal, 16) + .padding(.top, 8) + .accessibilityIdentifier("WidgetEnableInSettings") + } } .navigationBarHidden(true) + .bottomSafeAreaPadding() } + @ViewBuilder private func tappableTile(_ type: WidgetType) -> some View { + let enabled = settings.showWidgets tile(for: type) - .onTapGesture { navigationPath.append(.preview(type)) } + .opacity(enabled ? 1 : Self.disabledTileAlpha) + .contentShape(Rectangle()) + .onTapGesture { + guard enabled else { return } + navigationPath.append(.preview(type)) + } .accessibilityIdentifier("WidgetListItem-\(type.rawValue)") } + private func enableInSettings() { + sheets.hideSheet() + navigation.navigate(.widgetsSettings) + } + /// Display size each widget uses in the list grid (purely visual — not the saved size). private func displaySize(for type: WidgetType) -> WidgetSize { switch type { From 9eb3cb3e5f1b377c63f62d6c76f3834970933389 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 28 May 2026 13:24:24 -0300 Subject: [PATCH 14/31] feat: display suggestions in widgets preview sheet --- .../Widgets/WidgetPreviewSheetView.swift | 9 +++- .../Views/Widgets/WidgetsListSheetView.swift | 53 ++++++++++++------- 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift b/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift index 82217ead9..b79fc0fd6 100644 --- a/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift +++ b/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift @@ -187,7 +187,7 @@ struct WidgetPreviewSheetView: View { case .weather: WeatherWidePreview() case .facts: FactsWidePreview() case .calculator: CalculatorWidePreview() - case .suggestions: EmptyView() + case .suggestions: SuggestionsWidePreview() } } @@ -629,3 +629,10 @@ private struct CalculatorSmallPreview: View { ) } } + +private struct SuggestionsWidePreview: View { + var body: some View { + Suggestions(isPreview: true) + .frame(maxWidth: .infinity) + } +} diff --git a/Bitkit/Views/Widgets/WidgetsListSheetView.swift b/Bitkit/Views/Widgets/WidgetsListSheetView.swift index 70de0a2a2..9b7b3f2ee 100644 --- a/Bitkit/Views/Widgets/WidgetsListSheetView.swift +++ b/Bitkit/Views/Widgets/WidgetsListSheetView.swift @@ -7,8 +7,8 @@ struct WidgetsListSheetView: View { @EnvironmentObject private var settings: SettingsViewModel @EnvironmentObject private var sheets: SheetViewModel - /// Widget types shown in the add-list, in display order. `suggestions` is system-managed and excluded. - private static let listedTypes: [WidgetType] = [.price, .weather, .news, .blocks, .facts, .calculator] + /// Widget types shown in the add-list, in display order. + private static let listedTypes: [WidgetType] = [.price, .weather, .news, .blocks, .facts, .calculator, .suggestions] private static let disabledTileAlpha: CGFloat = 0.42 @@ -111,7 +111,7 @@ struct WidgetsListSheetView: View { /// Display size each widget uses in the list grid (purely visual — not the saved size). private func displaySize(for type: WidgetType) -> WidgetSize { switch type { - case .news, .blocks: return .wide + case .news, .blocks, .suggestions: return .wide default: return .small } } @@ -124,23 +124,32 @@ struct WidgetsListSheetView: View { } } + @ViewBuilder private func tileCard(for type: WidgetType) -> some View { - Group { - switch type { - case .price: PriceTile() - case .weather: WeatherTile() - case .news: NewsTile() - case .blocks: BlocksTile() - case .facts: FactsTile() - case .calculator: CalculatorTile() - case .suggestions: EmptyView() - } + // Suggestions cards carry their own backgrounds — no gray6 chrome (matches Android). + if type == .suggestions { + SuggestionsTile() + } else { + chromedTile(for: type) + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: displaySize(for: type) == .small ? 160 : nil, alignment: .topLeading) + .background(Color.gray6) + .cornerRadius(16) + } + } + + @ViewBuilder + private func chromedTile(for type: WidgetType) -> some View { + switch type { + case .price: PriceTile() + case .weather: WeatherTile() + case .news: NewsTile() + case .blocks: BlocksTile() + case .facts: FactsTile() + case .calculator: CalculatorTile() + case .suggestions: EmptyView() } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: displaySize(for: type) == .small ? 160 : nil, alignment: .topLeading) - .background(Color.gray6) - .cornerRadius(16) } } @@ -251,6 +260,14 @@ private struct FactsTile: View { } } +private struct SuggestionsTile: View { + var body: some View { + // Non-interactive preview grid; the suggestion cards supply their own backgrounds. + Suggestions(isPreview: true) + .frame(maxWidth: .infinity) + } +} + private struct CalculatorTile: View { @EnvironmentObject private var currency: CurrencyViewModel @State private var values = CalculatorWidgetValues() From a1648bffb99655c6075f7ed1c25f2adb8d753095 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 28 May 2026 13:33:00 -0300 Subject: [PATCH 15/31] feat: update default widgets set --- Bitkit/ViewModels/WidgetsViewModel.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index 6b822bcd5..7c8d624c5 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -200,6 +200,8 @@ class WidgetsViewModel: ObservableObject { SavedWidget(type: .price, size: .wide), SavedWidget(type: .blocks, size: .small), SavedWidget(type: .facts, size: .small), + SavedWidget(type: .weather, size: .small), + SavedWidget(type: .calculator, size: .small), SavedWidget(type: .news, size: .wide), ] From 74497e41bf4ea5e09222f04b031ff6f9cb678e65 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 28 May 2026 13:52:52 -0300 Subject: [PATCH 16/31] feat: display widgets page from drawer view --- Bitkit/Components/DrawerView.swift | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/Bitkit/Components/DrawerView.swift b/Bitkit/Components/DrawerView.swift index b1bff29d8..580ff6d11 100644 --- a/Bitkit/Components/DrawerView.swift +++ b/Bitkit/Components/DrawerView.swift @@ -70,6 +70,7 @@ enum DrawerMenuItem: Int, CaseIterable, Identifiable, Hashable { struct DrawerView: View { @EnvironmentObject private var app: AppViewModel @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var settings: SettingsViewModel @EnvironmentObject private var sheets: SheetViewModel @EnvironmentObject private var wallet: WalletViewModel @@ -97,15 +98,15 @@ struct DrawerView: View { .transition(.opacity) } - /// Route to push when selecting this drawer item (nil for Wallet = pop to root, - /// and also nil for `.widgets` when already onboarded — that case opens the sheet instead). + /// Route to push when selecting this drawer item (nil for Wallet = pop to root). + /// `.widgets` is handled separately in `selectWidgets()`. private func route(for item: DrawerMenuItem) -> Route? { switch item { case .wallet: return nil case .activity: return .activityList case .contacts: return .contacts case .profile: return .profile - case .widgets: return app.hasSeenWidgetsIntro ? nil : .widgetsIntro + case .widgets: return nil case .shop: return app.hasSeenShopIntro ? .shopDiscover : .shopIntro case .support: return .support case .settings: return .settings @@ -114,16 +115,32 @@ struct DrawerView: View { } private func selectDrawerItem(_ item: DrawerMenuItem) { + if item == .widgets { + selectWidgets() + closeMenu() + return + } + if let route = route(for: item) { navigation.path = [route] } else { navigation.path = [] } - // After onboarding, the widgets drawer entry opens the widgets sheet instead of a route. - if item == .widgets, app.hasSeenWidgetsIntro { + closeMenu() + } + + private func selectWidgets() { + guard app.hasSeenWidgetsIntro else { + navigation.path = [.widgetsIntro] + return + } + + navigation.path = [] + if settings.showWidgets { + app.requestedHomePage = 1 + } else { sheets.showSheet(.widgets, data: WidgetsConfig(initialRoute: .list)) } - closeMenu() } var body: some View { From db2a508661083fabf3b93b49570b3cbee7238bab Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 28 May 2026 14:35:39 -0300 Subject: [PATCH 17/31] fix: limit bloks wide max height --- Bitkit/Views/Widgets/WidgetPreviewSheetView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift b/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift index b79fc0fd6..addd8b59e 100644 --- a/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift +++ b/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift @@ -427,6 +427,7 @@ private struct BlocksWidePreview: View { Group { if let data = viewModel.blockData { BlocksWidgetWideContent(data: data, options: options) + .frame(height: BlocksWidgetWideContent.inAppContentHeight) .padding(16) .background(Color.gray6) .cornerRadius(16) From afed0920fa9b44dacb371e7bc7f66027d78d235c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 28 May 2026 14:43:49 -0300 Subject: [PATCH 18/31] fix: calculator compact content vertical alignment --- Bitkit/Components/Widgets/CalculatorWidget.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit/Components/Widgets/CalculatorWidget.swift b/Bitkit/Components/Widgets/CalculatorWidget.swift index e2212a822..089da00f9 100644 --- a/Bitkit/Components/Widgets/CalculatorWidget.swift +++ b/Bitkit/Components/Widgets/CalculatorWidget.swift @@ -351,7 +351,7 @@ struct CalculatorWidgetCompactContent: View { onTap: onSelectInput.map { handler in { handler(.fiat) } } ) } - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity) } } From 5ff37392905a68b7fa56f50c49b2342bce203f04 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 28 May 2026 14:51:53 -0300 Subject: [PATCH 19/31] fix: add widget icon --- Bitkit/Views/Home/HomeWidgetsView.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Bitkit/Views/Home/HomeWidgetsView.swift b/Bitkit/Views/Home/HomeWidgetsView.swift index fad6b48d0..2a7c02206 100644 --- a/Bitkit/Views/Home/HomeWidgetsView.swift +++ b/Bitkit/Views/Home/HomeWidgetsView.swift @@ -83,7 +83,15 @@ struct HomeWidgetsView: View { // reorder already happened on hover); avoids the snap-back animation. .onDrop(of: [.utf8PlainText], delegate: WidgetGridDropDelegate(dragState: dragState)) - CustomButton(title: t("widgets__add"), variant: .tertiary) { + CustomButton( + title: t("widgets__add"), + variant: .tertiary, + icon: Image("plus") + .resizable() + .renderingMode(.template) + .frame(width: 16, height: 16) + .foregroundColor(.white80) + ) { calculatorInput.dismiss() if app.hasSeenWidgetsIntro { From 99dccd20ba137d0fb17a38d0ee29e0655a052394 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 29 May 2026 07:26:30 -0300 Subject: [PATCH 20/31] chore: tidy up comment --- Bitkit/Styles/SheetStyles.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Bitkit/Styles/SheetStyles.swift b/Bitkit/Styles/SheetStyles.swift index 963d3e491..af8e4724b 100644 --- a/Bitkit/Styles/SheetStyles.swift +++ b/Bitkit/Styles/SheetStyles.swift @@ -2,15 +2,11 @@ import SwiftUI extension View { /// Applies the standard sheet gradient over the given base color. Default base is black; - /// the v61 widgets sheet uses gray7. func sheetBackground(base: Color = .black) -> some View { modifier(SheetBackgroundModifier(base: base, applyGradient: true)) } } -/// Sheet fill. When `applyGradient` is true a subtle white top-down gradient is layered over -/// the base color (the default chrome). The widgets sheet sets it false to match Figma's solid -/// gray7 modal (the gradient would otherwise lighten it). struct SheetBackgroundModifier: ViewModifier { let base: Color let applyGradient: Bool From af82b807c09eaf96e78073d6b3c1f659e9a37386 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 29 May 2026 07:33:21 -0300 Subject: [PATCH 21/31] chore: tidy up comment --- Bitkit/ViewModels/AppViewModel.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 281ce9d29..6f0961f88 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -64,8 +64,6 @@ class AppViewModel: ObservableObject { /// Drawer menu @Published var showDrawer = false - /// One-shot signal asking `HomeScreen` to scroll to a specific page (0 = wallet, 1 = widgets). - /// `HomeScreen` consumes the value and clears it back to `nil`. @Published var requestedHomePage: Int? /// Payment hashes for which we navigated to the pending screen. From a4523634b3dd0fb3815562a9637efecd70931b46 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 29 May 2026 08:14:51 -0300 Subject: [PATCH 22/31] refactor: remove dead code --- Bitkit/Views/Home/HomeWidgetsView.swift | 166 ++++++++++++------------ 1 file changed, 82 insertions(+), 84 deletions(-) diff --git a/Bitkit/Views/Home/HomeWidgetsView.swift b/Bitkit/Views/Home/HomeWidgetsView.swift index 2a7c02206..f46a49b77 100644 --- a/Bitkit/Views/Home/HomeWidgetsView.swift +++ b/Bitkit/Views/Home/HomeWidgetsView.swift @@ -56,101 +56,99 @@ struct HomeWidgetsView: View { } var body: some View { - ScrollViewReader { _ in - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - Grid(horizontalSpacing: 16, verticalSpacing: 16) { - ForEach(gridRows) { row in - GridRow { - switch row { - case let .wide(widget): - cell(widget) - .gridCellColumns(2) - case let .pair(first, second): - cell(first) - if let second { - cell(second) - } else { - Color.clear - } + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + Grid(horizontalSpacing: 16, verticalSpacing: 16) { + ForEach(gridRows) { row in + GridRow { + switch row { + case let .wide(widget): + cell(widget) + .gridCellColumns(2) + case let .pair(first, second): + cell(first) + if let second { + cell(second) + } else { + Color.clear } } } } - .id(visibleWidgets.map(\.id)) - .environment(\.widgetDragState, dragState) - // Catch-all so drops that land in a gap are still accepted (the live - // reorder already happened on hover); avoids the snap-back animation. - .onDrop(of: [.utf8PlainText], delegate: WidgetGridDropDelegate(dragState: dragState)) - - CustomButton( - title: t("widgets__add"), - variant: .tertiary, - icon: Image("plus") - .resizable() - .renderingMode(.template) - .frame(width: 16, height: 16) - .foregroundColor(.white80) - ) { - calculatorInput.dismiss() - - if app.hasSeenWidgetsIntro { - sheets.showSheet(.widgets, data: WidgetsConfig(initialRoute: .list)) - } else { - navigation.navigate(.widgetsIntro) - } - } - .padding(.top, 16) - .opacity(calculatorInput.isPresented ? 0 : 1) - .allowsHitTesting(!calculatorInput.isPresented) - .accessibilityHidden(calculatorInput.isPresented) - .accessibilityIdentifier("WidgetsAdd") - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .padding(.top, ScreenLayout.topPaddingWithSafeArea) - .padding(.bottom, bottomPadding) - .padding(.horizontal) - .offset(y: focusedContentOffsetY) - .background { - Color.clear - .contentShape(Rectangle()) - .onTapGesture { - calculatorInput.dismiss() - } } - } - .scrollDisabled(calculatorInput.isPresented) - .simultaneousGesture( - DragGesture(minimumDistance: Self.focusDismissDragMinimumDistance) - .onChanged(handleWidgetsPageDragChanged) - .onEnded { _ in - didStartCalculatorDismissDrag = false - }, - including: calculatorInput.isPresented || didStartCalculatorDismissDrag ? .all : .none - ) - // Dismiss (calculator widget) keyboard when scrolling - .scrollDismissesKeyboard(.interactively) - .overlay(alignment: .bottom) { - if calculatorInput.isPresented { - CalculatorNumberPadBar() - .transition(.move(edge: .bottom)) + .id(visibleWidgets.map(\.id)) + .environment(\.widgetDragState, dragState) + // Catch-all so drops that land in a gap are still accepted (the live + // reorder already happened on hover); avoids the snap-back animation. + .onDrop(of: [.utf8PlainText], delegate: WidgetGridDropDelegate(dragState: dragState)) + + CustomButton( + title: t("widgets__add"), + variant: .tertiary, + icon: Image("plus") + .resizable() + .renderingMode(.template) + .frame(width: 16, height: 16) + .foregroundColor(.white80) + ) { + calculatorInput.dismiss() + + if app.hasSeenWidgetsIntro { + sheets.showSheet(.widgets, data: WidgetsConfig(initialRoute: .list)) + } else { + navigation.navigate(.widgetsIntro) + } } + .padding(.top, 16) + .opacity(calculatorInput.isPresented ? 0 : 1) + .allowsHitTesting(!calculatorInput.isPresented) + .accessibilityHidden(calculatorInput.isPresented) + .accessibilityIdentifier("WidgetsAdd") } - .animation(.easeOut(duration: 0.14), value: calculatorInput.isPresented) - .onChange(of: calculatorInput.isPresented) { _, isPresented in - if isPresented { - focusCalculator() - } else { - setFocusedContentOffsetY(0) - } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.top, ScreenLayout.topPaddingWithSafeArea) + .padding(.bottom, bottomPadding) + .padding(.horizontal) + .offset(y: focusedContentOffsetY) + .background { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + calculatorInput.dismiss() + } } - .onPreferenceChange(CalculatorWidgetFramePreferenceKey.self) { frame in - calculatorFrame = frame + } + .scrollDisabled(calculatorInput.isPresented) + .simultaneousGesture( + DragGesture(minimumDistance: Self.focusDismissDragMinimumDistance) + .onChanged(handleWidgetsPageDragChanged) + .onEnded { _ in + didStartCalculatorDismissDrag = false + }, + including: calculatorInput.isPresented || didStartCalculatorDismissDrag ? .all : .none + ) + // Dismiss (calculator widget) keyboard when scrolling + .scrollDismissesKeyboard(.interactively) + .overlay(alignment: .bottom) { + if calculatorInput.isPresented { + CalculatorNumberPadBar() + .transition(.move(edge: .bottom)) } - .onDisappear { - calculatorInput.dismiss() + } + .animation(.easeOut(duration: 0.14), value: calculatorInput.isPresented) + .onChange(of: calculatorInput.isPresented) { _, isPresented in + if isPresented { + focusCalculator() + } else { + setFocusedContentOffsetY(0) } } + .onPreferenceChange(CalculatorWidgetFramePreferenceKey.self) { frame in + calculatorFrame = frame + } + .onDisappear { + calculatorInput.dismiss() + } } private func focusCalculator() { From dd80f9af654a1fc6b1da2e20582f99dc07b720ca Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 29 May 2026 08:50:25 -0300 Subject: [PATCH 23/31] feat: add reorder animation --- Bitkit/Views/Home/HomeWidgetsView.swift | 133 ++++++++++++++---------- 1 file changed, 76 insertions(+), 57 deletions(-) diff --git a/Bitkit/Views/Home/HomeWidgetsView.swift b/Bitkit/Views/Home/HomeWidgetsView.swift index f46a49b77..391cb6b7f 100644 --- a/Bitkit/Views/Home/HomeWidgetsView.swift +++ b/Bitkit/Views/Home/HomeWidgetsView.swift @@ -58,25 +58,12 @@ struct HomeWidgetsView: View { var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { - Grid(horizontalSpacing: 16, verticalSpacing: 16) { - ForEach(gridRows) { row in - GridRow { - switch row { - case let .wide(widget): - cell(widget) - .gridCellColumns(2) - case let .pair(first, second): - cell(first) - if let second { - cell(second) - } else { - Color.clear - } - } - } + WidgetFlowLayout(spacing: 16) { + ForEach(visibleWidgets) { widget in + cell(widget) + .layoutValue(key: WidgetIsWideKey.self, value: displayedSize(for: widget) == .wide) } } - .id(visibleWidgets.map(\.id)) .environment(\.widgetDragState, dragState) // Catch-all so drops that land in a gap are still accepted (the live // reorder already happened on hover); avoids the snap-back animation. @@ -186,45 +173,6 @@ struct HomeWidgetsView: View { } } - /// A logical row in the home grid — either a single wide widget spanning both columns, - /// or up to two small widgets paired side-by-side. Built by walking `visibleWidgets` - /// in order; the second slot of a `pair` is `nil` if a small widget has no neighbour. - private enum WidgetRow: Identifiable { - case wide(Widget) - case pair(Widget, Widget?) - - var id: String { - switch self { - case let .wide(w): return "wide-\(w.id.rawValue)" - case let .pair(a, b): return "pair-\(a.id.rawValue)-\(b?.id.rawValue ?? "_")" - } - } - } - - private var gridRows: [WidgetRow] { - var result: [WidgetRow] = [] - var pendingSmall: Widget? - - for widget in visibleWidgets { - if displayedSize(for: widget) == .wide { - if let pending = pendingSmall { - result.append(.pair(pending, nil)) - pendingSmall = nil - } - result.append(.wide(widget)) - } else if let pending = pendingSmall { - result.append(.pair(pending, widget)) - pendingSmall = nil - } else { - pendingSmall = widget - } - } - if let pending = pendingSmall { - result.append(.pair(pending, nil)) - } - return result - } - private func cell(_ widget: Widget) -> some View { rowContent(widget) .onDrop( @@ -240,7 +188,9 @@ struct HomeWidgetsView: View { let sourceIdx = widgets.savedWidgets.firstIndex(where: { $0.type == sourceType }), let destIdx = widgets.savedWidgets.firstIndex(where: { $0.type == targetType }) else { return false } - widgets.reorderWidgets(from: sourceIdx, to: destIdx) + withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { + widgets.reorderWidgets(from: sourceIdx, to: destIdx) + } return true } @@ -338,6 +288,75 @@ private struct WidgetGridDropDelegate: DropDelegate { } } +private struct WidgetIsWideKey: LayoutValueKey { + static let defaultValue = false +} + +/// Two-column flow layout for the home widget grid. Wide widgets span the full width on their own +/// line; consecutive small widgets pair up side by side (a lone trailing small occupies the left +/// column). Children are flat and individually identified, so reordering animates as smooth moves +/// when the mutation is wrapped in `withAnimation`. +private struct WidgetFlowLayout: Layout { + let spacing: CGFloat + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) -> CGSize { + let width = proposal.width ?? UIScreen.main.bounds.width + let height = walk(subviews: subviews, width: width) { _, _, _ in } + return CGSize(width: width, height: height) + } + + func placeSubviews(in bounds: CGRect, proposal _: ProposedViewSize, subviews: Subviews, cache _: inout ()) { + _ = walk(subviews: subviews, width: bounds.width) { subview, origin, size in + subview.place( + at: CGPoint(x: bounds.minX + origin.x, y: bounds.minY + origin.y), + anchor: .topLeading, + proposal: ProposedViewSize(size) + ) + } + } + + /// Walks the subviews applying the pairing rule, invoking `place` for each placed subview and + /// returning the total content height. + private func walk( + subviews: Subviews, + width: CGFloat, + place: (LayoutSubview, CGPoint, CGSize) -> Void + ) -> CGFloat { + let columnWidth = (width - spacing) / 2 + var y: CGFloat = 0 + var index = 0 + + func isWide(_ i: Int) -> Bool { + subviews[i][WidgetIsWideKey.self] + } + func height(_ i: Int, _ w: CGFloat) -> CGFloat { + subviews[i].sizeThatFits(ProposedViewSize(width: w, height: nil)).height + } + + while index < subviews.count { + if isWide(index) { + let h = height(index, width) + place(subviews[index], CGPoint(x: 0, y: y), CGSize(width: width, height: h)) + y += h + spacing + index += 1 + } else if index + 1 < subviews.count, !isWide(index + 1) { + let h = max(height(index, columnWidth), height(index + 1, columnWidth)) + place(subviews[index], CGPoint(x: 0, y: y), CGSize(width: columnWidth, height: h)) + place(subviews[index + 1], CGPoint(x: columnWidth + spacing, y: y), CGSize(width: columnWidth, height: h)) + y += h + spacing + index += 2 + } else { + let h = height(index, columnWidth) + place(subviews[index], CGPoint(x: 0, y: y), CGSize(width: columnWidth, height: h)) + y += h + spacing + index += 1 + } + } + + return max(0, y - spacing) + } +} + private struct CalculatorWidgetFramePreferenceKey: PreferenceKey { static var defaultValue: CGRect? From 8c8219fbb944ec4b8ae3830d5c7ec70e4d40034b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 29 May 2026 09:28:54 -0300 Subject: [PATCH 24/31] refactor: comments cleanup --- Bitkit/Views/Widgets/WidgetEditSheetView.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Bitkit/Views/Widgets/WidgetEditSheetView.swift b/Bitkit/Views/Widgets/WidgetEditSheetView.swift index 3fee3ea77..9435bde24 100644 --- a/Bitkit/Views/Widgets/WidgetEditSheetView.swift +++ b/Bitkit/Views/Widgets/WidgetEditSheetView.swift @@ -1,8 +1,5 @@ import SwiftUI -/// Sheet-shell wrapper around the existing `WidgetEditLogic` + `WidgetEditItemView` flow. -/// Header swaps the full-screen `NavigationBar` for a `SheetHeader` and the "Preview" -/// button pops back to the preview route inside the same sheet navigation stack. struct WidgetEditSheetView: View { let type: WidgetType @Binding var navigationPath: [WidgetsRoute] From df36e63855092021ef358e00544d1a4be33b2cc9 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 29 May 2026 10:16:07 -0300 Subject: [PATCH 25/31] fix: holt draft options in an independent variable --- Bitkit/Components/Widgets/BaseWidget.swift | 4 - Bitkit/ViewModels/WidgetsViewModel.swift | 97 +++++++++++++--------- Bitkit/Views/Home/HomeWidgetsView.swift | 4 +- Bitkit/Views/Widgets/WidgetEditLogic.swift | 8 +- Bitkit/Views/Widgets/WidgetsSheet.swift | 4 + 5 files changed, 68 insertions(+), 49 deletions(-) diff --git a/Bitkit/Components/Widgets/BaseWidget.swift b/Bitkit/Components/Widgets/BaseWidget.swift index e88f4ba0e..13e83e9c1 100644 --- a/Bitkit/Components/Widgets/BaseWidget.swift +++ b/Bitkit/Components/Widgets/BaseWidget.swift @@ -60,10 +60,6 @@ enum WidgetContentBuilder { } } -/// Drop type used by the home grid drag-and-drop reorder. -/// Drag payload is the widget's `WidgetType.rawValue`. -let widgetReorderDragType = "to.bitkit.widget.reorder" - /// Foundation container for all widgets. Owns the card chrome (gray6 bg, 16pt radius), /// the v61 editing overlay (dashed brand border + centred action icons), and the small/wide /// sizing rules used by the home grid. diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index 7c8d624c5..ded960f22 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -194,6 +194,8 @@ class WidgetsViewModel: ObservableObject { /// In-memory storage for saved widgets with options private var savedWidgetsWithOptions: [SavedWidget] = [] + @Published private var draftOptionsData: [WidgetType: Data] = [:] + /// Default widgets for new installs and resets private static let defaultSavedWidgets: [SavedWidget] = [ SavedWidget(type: .suggestions, size: .wide), @@ -216,17 +218,26 @@ class WidgetsViewModel: ObservableObject { return savedWidgets.contains { $0.type == type } } - /// Save a new widget + /// Commit a widget to the grid at the chosen size, folding in any staged option edits. + /// This is the single commit point: it persists the widget, syncs staged options to the + /// iOS home-screen widget, and clears the draft. func saveWidget(_ type: WidgetType, size: WidgetSize = .wide) { let resolvedSize: WidgetSize = type == .suggestions ? .wide : size + let draft = draftOptionsData[type] if let index = savedWidgetsWithOptions.firstIndex(where: { $0.type == type }) { let existing = savedWidgetsWithOptions[index] - guard existing.size != resolvedSize else { return } - savedWidgetsWithOptions[index] = SavedWidget(type: type, optionsData: existing.optionsData, size: resolvedSize) + let optionsData = draft ?? existing.optionsData + savedWidgetsWithOptions[index] = SavedWidget(type: type, optionsData: optionsData, size: resolvedSize) } else { - savedWidgetsWithOptions.append(SavedWidget(type: type, size: resolvedSize)) + savedWidgetsWithOptions.append(SavedWidget(type: type, optionsData: draft, size: resolvedSize)) + } + + if let draft { + syncHomeScreenWidgetOptions(for: type, optionsData: draft) + draftOptionsData[type] = nil } + savedWidgets = savedWidgetsWithOptions.map { $0.toWidget() } persistSavedWidgets() } @@ -239,9 +250,17 @@ class WidgetsViewModel: ObservableObject { func deleteWidget(_ type: WidgetType) { savedWidgetsWithOptions.removeAll { $0.type == type } savedWidgets.removeAll { $0.type == type } + draftOptionsData[type] = nil persistSavedWidgets() } + /// Discard all uncommitted option edits. Call when the widgets sheet is dismissed so staged + /// edits don't leak into a later session. + func clearDrafts() { + guard !draftOptionsData.isEmpty else { return } + draftOptionsData = [:] + } + /// Reorder the widgets list by moving one widget to a new index. func reorderWidgets(from sourceIndex: Int, to destinationIndex: Int) { guard sourceIndex != destinationIndex, @@ -265,6 +284,13 @@ class WidgetsViewModel: ObservableObject { /// Get options for a specific widget type func getOptions(for type: WidgetType, as optionsType: T.Type) -> T { + // A staged draft (uncommitted edit) shadows the persisted value so the preview reflects it. + if let draft = draftOptionsData[type], + let options = try? JSONDecoder().decode(optionsType, from: draft) + { + return options + } + // Find the saved widget with this type if let savedWidget = savedWidgetsWithOptions.first(where: { $0.type == type }), let optionsData = savedWidget.optionsData, @@ -277,46 +303,39 @@ class WidgetsViewModel: ObservableObject { return getDefaultOptions(for: type) as! T } - /// Save options for a specific widget type - func saveOptions(_ options: some Codable, for type: WidgetType) { + /// Stage option edits for a widget type without committing. The edit is held in memory and + /// shadows the persisted value via `getOptions(...)`; it is only persisted (and synced to the + /// iOS home-screen widget) once the user taps "Save Widget" (`saveWidget`). + func stageOptions(_ options: some Codable, for type: WidgetType) { do { - let optionsData = try JSONEncoder().encode(options) - - // Find existing saved widget or create new one - if let index = savedWidgetsWithOptions.firstIndex(where: { $0.type == type }) { - // Update existing widget with new options — preserve the chosen size. - let existing = savedWidgetsWithOptions[index] - savedWidgetsWithOptions[index] = SavedWidget( - type: type, - optionsData: optionsData, - size: existing.size - ) - } else { - // Create new saved widget with options - savedWidgetsWithOptions.append(SavedWidget(type: type, optionsData: optionsData)) - } - - // Keep the @Published mirror in lockstep so other callers see a consistent picture. - savedWidgets = savedWidgetsWithOptions.map { $0.toWidget() } - persistSavedWidgets() + draftOptionsData[type] = try JSONEncoder().encode(options) + } catch { + Logger.error("Failed to stage widget options: \(error)", context: "WidgetsViewModel") + } + } - if type == .price, let priceOptions = options as? PriceWidgetOptions { - syncPriceOptionsToHomeScreenWidget(priceOptions) + /// Persist the given options to the shared App Group store and reload the iOS home-screen + /// widget timeline. Called on commit (`saveWidget`) for the types that back a home-screen widget. + private func syncHomeScreenWidgetOptions(for type: WidgetType, optionsData: Data) { + switch type { + case .price: + if let options = try? JSONDecoder().decode(PriceWidgetOptions.self, from: optionsData) { + syncPriceOptionsToHomeScreenWidget(options) } - - if type == .news, let newsOptions = options as? NewsWidgetOptions { - syncNewsOptionsToHomeScreenWidget(newsOptions) + case .news: + if let options = try? JSONDecoder().decode(NewsWidgetOptions.self, from: optionsData) { + syncNewsOptionsToHomeScreenWidget(options) } - - if type == .blocks, let blocksOptions = options as? BlocksWidgetOptions { - syncBlocksOptionsToHomeScreenWidget(blocksOptions) + case .blocks: + if let options = try? JSONDecoder().decode(BlocksWidgetOptions.self, from: optionsData) { + syncBlocksOptionsToHomeScreenWidget(options) } - - if type == .weather, let weatherOptions = options as? WeatherWidgetOptions { - syncWeatherOptionsToHomeScreenWidget(weatherOptions) + case .weather: + if let options = try? JSONDecoder().decode(WeatherWidgetOptions.self, from: optionsData) { + syncWeatherOptionsToHomeScreenWidget(options) } - } catch { - print("Failed to save widget options: \(error)") + case .calculator, .facts, .suggestions: + break } } @@ -391,7 +410,7 @@ class WidgetsViewModel: ObservableObject { let encodedData = try JSONEncoder().encode(savedWidgetsWithOptions) UserDefaults.standard.set(encodedData, forKey: Self.savedWidgetsKey) } catch { - print("Failed to persist widgets: \(error)") + Logger.error("Failed to persist widgets: \(error)", context: "WidgetsViewModel") } } diff --git a/Bitkit/Views/Home/HomeWidgetsView.swift b/Bitkit/Views/Home/HomeWidgetsView.swift index 391cb6b7f..97f7e2519 100644 --- a/Bitkit/Views/Home/HomeWidgetsView.swift +++ b/Bitkit/Views/Home/HomeWidgetsView.swift @@ -164,8 +164,8 @@ struct HomeWidgetsView: View { } } - /// Resolved layout size for the grid. Calculator and suggestions are always wide - /// regardless of the value stored on `SavedWidget`. + /// Resolved layout size for the grid. Suggestions is always wide regardless of the value + /// stored on `SavedWidget` private func displayedSize(for widget: Widget) -> WidgetSize { switch widget.type { case .suggestions: return .wide diff --git a/Bitkit/Views/Widgets/WidgetEditLogic.swift b/Bitkit/Views/Widgets/WidgetEditLogic.swift index ebf6e2d06..49ecc33d5 100644 --- a/Bitkit/Views/Widgets/WidgetEditLogic.swift +++ b/Bitkit/Views/Widgets/WidgetEditLogic.swift @@ -186,13 +186,13 @@ class WidgetEditLogic: ObservableObject { func saveOptions() { switch widgetType { case .blocks: - widgetsViewModel.saveOptions(blocksOptions, for: widgetType) + widgetsViewModel.stageOptions(blocksOptions, for: widgetType) case .news: - widgetsViewModel.saveOptions(newsOptions, for: widgetType) + widgetsViewModel.stageOptions(newsOptions, for: widgetType) case .weather: - widgetsViewModel.saveOptions(weatherOptions, for: widgetType) + widgetsViewModel.stageOptions(weatherOptions, for: widgetType) case .price: - widgetsViewModel.saveOptions(priceOptions, for: widgetType) + widgetsViewModel.stageOptions(priceOptions, for: widgetType) case .calculator, .suggestions, .facts: break } diff --git a/Bitkit/Views/Widgets/WidgetsSheet.swift b/Bitkit/Views/Widgets/WidgetsSheet.swift index 3ec7ae169..be2f9a0d4 100644 --- a/Bitkit/Views/Widgets/WidgetsSheet.swift +++ b/Bitkit/Views/Widgets/WidgetsSheet.swift @@ -25,6 +25,7 @@ struct WidgetsSheetItem: SheetItem { } struct WidgetsSheet: View { + @EnvironmentObject private var widgets: WidgetsViewModel @State private var navigationPath: [WidgetsRoute] = [] let config: WidgetsSheetItem @@ -36,6 +37,9 @@ struct WidgetsSheet: View { viewForRoute(route) } } + .onDisappear { + widgets.clearDrafts() + } } } From f7cc36e2d41ce7814536383e5d9be5b57a6cbb3c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 29 May 2026 10:18:51 -0300 Subject: [PATCH 26/31] refactor: reuse widgetCardChrome --- .../Widgets/WidgetPreviewSheetView.swift | 64 +++++++------------ 1 file changed, 22 insertions(+), 42 deletions(-) diff --git a/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift b/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift index addd8b59e..96cc05191 100644 --- a/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift +++ b/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift @@ -283,9 +283,7 @@ private struct PriceSmallPreview: View { Group { if let data = primary { PriceWidgetCompactContent(data: data, period: options.selectedPeriod) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) + .widgetCardChrome() } else { Color.gray6 .cornerRadius(16) @@ -315,9 +313,7 @@ private struct PriceWidePreview: View { Group { if let data = primary { PriceWidgetWideContent(data: data, period: options.selectedPeriod) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) + .widgetCardChrome() } else { Color.gray6 .cornerRadius(16) @@ -348,9 +344,7 @@ private struct NewsSmallPreview: View { Group { if let data = viewModel.widgetData { NewsWidgetCompactContent(title: data.title, timeAgo: data.timeAgo, options: options) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) + .widgetCardChrome() } else { Color.gray6 .cornerRadius(16) @@ -374,16 +368,12 @@ private struct NewsWidePreview: View { if let data = viewModel.widgetData { NewsWidgetWideContent(title: data.title, publisher: data.publisher, timeAgo: data.timeAgo, options: options) .frame(height: NewsWidgetWideContent.inAppContentHeight) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) + .widgetCardChrome() } else { ProgressView() .frame(maxWidth: .infinity) .frame(height: NewsWidgetWideContent.inAppContentHeight) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) + .widgetCardChrome() } } .task { viewModel.startUpdates() } @@ -402,9 +392,7 @@ private struct BlocksSmallPreview: View { Group { if let data = viewModel.blockData { BlocksWidgetCompactContent(data: data, options: options) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) + .widgetCardChrome() } else { Color.gray6 .cornerRadius(16) @@ -428,16 +416,12 @@ private struct BlocksWidePreview: View { if let data = viewModel.blockData { BlocksWidgetWideContent(data: data, options: options) .frame(height: BlocksWidgetWideContent.inAppContentHeight) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) + .widgetCardChrome() } else { ProgressView() .frame(maxWidth: .infinity) .frame(height: BlocksWidgetWideContent.inAppContentHeight) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) + .widgetCardChrome() } } .task { viewModel.startUpdates() } @@ -462,9 +446,7 @@ private struct WeatherSmallPreview: View { conditionTitle: t(data.condition.titleKey), metricLabel: t(options.selectedMetric.labelKey) ) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) + .widgetCardChrome() } else { Color.gray6 .cornerRadius(16) @@ -497,15 +479,11 @@ private struct WeatherWidePreview: View { conditionDescription: t(data.condition.descriptionKey), metricLabel: t(options.selectedMetric.labelKey) ) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) + .widgetCardChrome() } else { ProgressView() .frame(maxWidth: .infinity, minHeight: 120) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) + .widgetCardChrome() } } .task { @@ -520,9 +498,7 @@ private struct FactsSmallPreview: View { var body: some View { FactsWidgetCompactContent(fact: viewModel.fact) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) + .widgetCardChrome() } } @@ -531,9 +507,7 @@ private struct FactsWidePreview: View { var body: some View { FactsWidgetWideContent(fact: viewModel.fact) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) + .widgetCardChrome() .frame(maxWidth: .infinity) } } @@ -550,9 +524,7 @@ private struct CalculatorWidePreview: View { activeInput: previewActiveInput, onSelectInput: { input in previewActiveInput = input } ) - .padding(16) - .background(Color.gray6) - .cornerRadius(16) + .widgetCardChrome() .frame(maxWidth: .infinity) .task { hydrate() } .onChange(of: currency.selectedCurrency) { hydrate() } @@ -637,3 +609,11 @@ private struct SuggestionsWidePreview: View { .frame(maxWidth: .infinity) } } + +private extension View { + func widgetCardChrome() -> some View { + padding(16) + .background(Color.gray6) + .cornerRadius(16) + } +} From 71127dd5f0cae35e6a1eb19f8c723df79aa30df0 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 29 May 2026 10:33:49 -0300 Subject: [PATCH 27/31] test: make grid logic more testable and add unit tests --- Bitkit/Views/Home/HomeWidgetsView.swift | 75 +++++++++------- .../Widgets/WidgetPreviewSheetView.swift | 10 ++- BitkitTests/CalculatorWidgetTests.swift | 8 +- BitkitTests/NewsWidgetTitleTests.swift | 3 +- BitkitTests/SavedWidgetDecodingTests.swift | 49 +++++++++++ BitkitTests/WidgetGridLayoutTests.swift | 86 +++++++++++++++++++ .../WidgetsViewModelReorderTests.swift | 64 ++++++++++++++ BitkitTests/WidgetsViewModelTests.swift | 2 +- 8 files changed, 258 insertions(+), 39 deletions(-) create mode 100644 BitkitTests/SavedWidgetDecodingTests.swift create mode 100644 BitkitTests/WidgetGridLayoutTests.swift create mode 100644 BitkitTests/WidgetsViewModelReorderTests.swift diff --git a/Bitkit/Views/Home/HomeWidgetsView.swift b/Bitkit/Views/Home/HomeWidgetsView.swift index 97f7e2519..090d59453 100644 --- a/Bitkit/Views/Home/HomeWidgetsView.swift +++ b/Bitkit/Views/Home/HomeWidgetsView.swift @@ -316,45 +316,62 @@ private struct WidgetFlowLayout: Layout { } /// Walks the subviews applying the pairing rule, invoking `place` for each placed subview and - /// returning the total content height. + /// returning the total content height. Delegates the geometry to the pure `widgetGridSlots` + /// helper so the pairing rule can be unit-tested without SwiftUI's opaque `Subviews`. private func walk( subviews: Subviews, width: CGFloat, place: (LayoutSubview, CGPoint, CGSize) -> Void ) -> CGFloat { - let columnWidth = (width - spacing) / 2 - var y: CGFloat = 0 - var index = 0 - - func isWide(_ i: Int) -> Bool { - subviews[i][WidgetIsWideKey.self] + let isWide = (0 ..< subviews.count).map { subviews[$0][WidgetIsWideKey.self] } + let result = widgetGridSlots(isWide: isWide, width: width, spacing: spacing) { index, proposedWidth in + subviews[index].sizeThatFits(ProposedViewSize(width: proposedWidth, height: nil)).height } - func height(_ i: Int, _ w: CGFloat) -> CGFloat { - subviews[i].sizeThatFits(ProposedViewSize(width: w, height: nil)).height + for slot in result.slots { + place(subviews[slot.index], slot.frame.origin, slot.frame.size) } + return result.totalHeight + } +} - while index < subviews.count { - if isWide(index) { - let h = height(index, width) - place(subviews[index], CGPoint(x: 0, y: y), CGSize(width: width, height: h)) - y += h + spacing - index += 1 - } else if index + 1 < subviews.count, !isWide(index + 1) { - let h = max(height(index, columnWidth), height(index + 1, columnWidth)) - place(subviews[index], CGPoint(x: 0, y: y), CGSize(width: columnWidth, height: h)) - place(subviews[index + 1], CGPoint(x: columnWidth + spacing, y: y), CGSize(width: columnWidth, height: h)) - y += h + spacing - index += 2 - } else { - let h = height(index, columnWidth) - place(subviews[index], CGPoint(x: 0, y: y), CGSize(width: columnWidth, height: h)) - y += h + spacing - index += 1 - } - } +/// A placed widget in the home grid: the subview's index and its frame within the grid's bounds. +struct WidgetGridSlot: Equatable { + let index: Int + let frame: CGRect +} - return max(0, y - spacing) +func widgetGridSlots( + isWide: [Bool], + width: CGFloat, + spacing: CGFloat, + height: (_ index: Int, _ proposedWidth: CGFloat) -> CGFloat +) -> (slots: [WidgetGridSlot], totalHeight: CGFloat) { + let columnWidth = (width - spacing) / 2 + var slots: [WidgetGridSlot] = [] + var y: CGFloat = 0 + var index = 0 + + while index < isWide.count { + if isWide[index] { + let h = height(index, width) + slots.append(WidgetGridSlot(index: index, frame: CGRect(x: 0, y: y, width: width, height: h))) + y += h + spacing + index += 1 + } else if index + 1 < isWide.count, !isWide[index + 1] { + let h = max(height(index, columnWidth), height(index + 1, columnWidth)) + slots.append(WidgetGridSlot(index: index, frame: CGRect(x: 0, y: y, width: columnWidth, height: h))) + slots.append(WidgetGridSlot(index: index + 1, frame: CGRect(x: columnWidth + spacing, y: y, width: columnWidth, height: h))) + y += h + spacing + index += 2 + } else { + let h = height(index, columnWidth) + slots.append(WidgetGridSlot(index: index, frame: CGRect(x: 0, y: y, width: columnWidth, height: h))) + y += h + spacing + index += 1 + } } + + return (slots, max(0, y - spacing)) } private struct CalculatorWidgetFramePreferenceKey: PreferenceKey { diff --git a/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift b/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift index 96cc05191..fe957e756 100644 --- a/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift +++ b/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift @@ -535,11 +535,11 @@ private struct CalculatorWidePreview: View { private func hydrate() { let saved = CalculatorWidgetOptionsStore.load() - let bitcoinValue = Self.previewBitcoinValue(saved: saved, displayUnit: currency.displayUnit) + let bitcoinValue = CalculatorWidgetPreviewLogic.previewBitcoinValue(saved: saved, displayUnit: currency.displayUnit) values = CalculatorWidgetValues( bitcoinValue: bitcoinValue, - fiatValue: Self.previewFiatValue(saved: saved, recalculatedFiatValue: fiatValue(for: bitcoinValue)), + fiatValue: CalculatorWidgetPreviewLogic.previewFiatValue(saved: saved, recalculatedFiatValue: fiatValue(for: bitcoinValue)), displayUnit: currency.displayUnit, currencySymbol: currency.symbol, selectedCurrency: currency.selectedCurrency @@ -553,8 +553,10 @@ private struct CalculatorWidePreview: View { guard let converted = currency.convert(sats: sats) else { return "" } return CalculatorWidgetFormatter.fiatRawValue(from: converted.value) } +} - private static func previewBitcoinValue(saved: CalculatorWidgetValues, displayUnit: BitcoinDisplayUnit) -> String { +enum CalculatorWidgetPreviewLogic { + static func previewBitcoinValue(saved: CalculatorWidgetValues, displayUnit: BitcoinDisplayUnit) -> String { guard !saved.bitcoinValue.isEmpty else { return "" } let savedSats = CalculatorWidgetFormatter.bitcoinValueToSats(saved.bitcoinValue, displayUnit: saved.displayUnit) @@ -563,7 +565,7 @@ private struct CalculatorWidePreview: View { : CalculatorWidgetFormatter.satsToBitcoinValue(savedSats, displayUnit: displayUnit) } - private static func previewFiatValue(saved: CalculatorWidgetValues, recalculatedFiatValue: String) -> String { + static func previewFiatValue(saved: CalculatorWidgetValues, recalculatedFiatValue: String) -> String { saved.shouldRefreshBitcoinFromFiat ? saved.fiatValue : recalculatedFiatValue } } diff --git a/BitkitTests/CalculatorWidgetTests.swift b/BitkitTests/CalculatorWidgetTests.swift index 735194ad5..8cf03eb66 100644 --- a/BitkitTests/CalculatorWidgetTests.swift +++ b/BitkitTests/CalculatorWidgetTests.swift @@ -133,20 +133,20 @@ final class CalculatorWidgetTests: XCTestCase { func testPreviewPreservesPersistedFiatOnlyValue() { let values = CalculatorWidgetValues(bitcoinValue: "", fiatValue: "12.34") - XCTAssertEqual(CalculatorWidgetPreviewView.previewFiatValue(saved: values, recalculatedFiatValue: ""), "12.34") + XCTAssertEqual(CalculatorWidgetPreviewLogic.previewFiatValue(saved: values, recalculatedFiatValue: ""), "12.34") } func testPreviewUsesRecalculatedFiatWhenBitcoinValueExists() { let values = CalculatorWidgetValues(bitcoinValue: "10000", fiatValue: "12.34") - XCTAssertEqual(CalculatorWidgetPreviewView.previewFiatValue(saved: values, recalculatedFiatValue: "10.00"), "10.00") + XCTAssertEqual(CalculatorWidgetPreviewLogic.previewFiatValue(saved: values, recalculatedFiatValue: "10.00"), "10.00") } func testPreviewKeepsPersistedZeroBitcoinValueVisible() { let values = CalculatorWidgetValues(bitcoinValue: "0", fiatValue: "0.00") - XCTAssertEqual(CalculatorWidgetPreviewView.previewBitcoinValue(saved: values, displayUnit: .modern), "0") - XCTAssertEqual(CalculatorWidgetPreviewView.previewBitcoinValue(saved: values, displayUnit: .classic), "0") + XCTAssertEqual(CalculatorWidgetPreviewLogic.previewBitcoinValue(saved: values, displayUnit: .modern), "0") + XCTAssertEqual(CalculatorWidgetPreviewLogic.previewBitcoinValue(saved: values, displayUnit: .classic), "0") } func testCurrencySymbolFallsBackToFirstCharacterForLongSymbols() { diff --git a/BitkitTests/NewsWidgetTitleTests.swift b/BitkitTests/NewsWidgetTitleTests.swift index 314ab7f84..0f9a6bfbf 100644 --- a/BitkitTests/NewsWidgetTitleTests.swift +++ b/BitkitTests/NewsWidgetTitleTests.swift @@ -30,10 +30,11 @@ final class NewsWidgetTitleTests: XCTestCase { /// back on so the widget always renders a headline. func testLoadCurrentOptions_ForcesShowTitleEnabled_EvenWhenPersistedFalse() { let widgets = WidgetsViewModel() - widgets.saveOptions( + widgets.stageOptions( NewsWidgetOptions(showDate: true, showTitle: false, showSource: false), for: .news ) + widgets.saveWidget(.news) let logic = WidgetEditLogic(widgetType: .news, widgetsViewModel: widgets) logic.loadCurrentOptions() diff --git a/BitkitTests/SavedWidgetDecodingTests.swift b/BitkitTests/SavedWidgetDecodingTests.swift new file mode 100644 index 000000000..0ace891cf --- /dev/null +++ b/BitkitTests/SavedWidgetDecodingTests.swift @@ -0,0 +1,49 @@ +@testable import Bitkit +import XCTest + +/// Locks in the v60 → v61 migration contract for `SavedWidget`: blobs persisted before the +/// small/wide size system existed have no `size` key and must decode as `.wide`, while v61 blobs +/// round-trip their size faithfully. +final class SavedWidgetDecodingTests: XCTestCase { + private func decode(_ json: String) throws -> SavedWidget { + try JSONDecoder().decode(SavedWidget.self, from: Data(json.utf8)) + } + + private func decodeArray(_ json: String) throws -> [SavedWidget] { + try JSONDecoder().decode([SavedWidget].self, from: Data(json.utf8)) + } + + /// v60 blob (no `size` key) must default to `.wide`. + func testDecode_LegacyBlobWithoutSize_DefaultsToWide() throws { + let widget = try decode(#"{"type":"price"}"#) + XCTAssertEqual(widget.type, .price) + XCTAssertEqual(widget.size, .wide) + XCTAssertNil(widget.optionsData) + } + + /// v61 blob with an explicit size must decode that size. + func testDecode_BlobWithSize_UsesStoredSize() throws { + XCTAssertEqual(try decode(#"{"type":"blocks","size":"small"}"#).size, .small) + XCTAssertEqual(try decode(#"{"type":"news","size":"wide"}"#).size, .wide) + } + + /// A mixed array (legacy + v61 entries) must apply the per-entry rule independently. + func testDecode_MixedArray_AppliesPerEntryDefault() throws { + let widgets = try decodeArray(#"[{"type":"price","size":"small"},{"type":"news"},{"type":"weather","size":"wide"}]"#) + XCTAssertEqual(widgets.map(\.type), [.price, .news, .weather]) + XCTAssertEqual(widgets.map(\.size), [.small, .wide, .wide]) + } + + /// Encoding then decoding must preserve type, size, and optionsData. + func testRoundTrip_PreservesSize() throws { + let optionsData = try JSONEncoder().encode(PriceWidgetOptions(selectedPair: "BTC/EUR", selectedPeriod: .oneWeek)) + let original = SavedWidget(type: .price, optionsData: optionsData, size: .small) + + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(SavedWidget.self, from: encoded) + + XCTAssertEqual(decoded.type, .price) + XCTAssertEqual(decoded.size, .small) + XCTAssertEqual(decoded.optionsData, optionsData) + } +} diff --git a/BitkitTests/WidgetGridLayoutTests.swift b/BitkitTests/WidgetGridLayoutTests.swift new file mode 100644 index 000000000..64a4f7af5 --- /dev/null +++ b/BitkitTests/WidgetGridLayoutTests.swift @@ -0,0 +1,86 @@ +@testable import Bitkit +import XCTest + +/// Covers the home grid pairing algorithm (`widgetGridSlots`, extracted from `WidgetFlowLayout`): +/// wide items span the full width; consecutive smalls pair side by side at equal (max) height; +/// a lone trailing small — or a small immediately followed by a wide — takes the left column only. +final class WidgetGridLayoutTests: XCTestCase { + private let width: CGFloat = 343 + private let spacing: CGFloat = 16 + private var columnWidth: CGFloat { + (width - spacing) / 2 + } // 163.5 + + /// Convenience runner with a constant-height provider. + private func layout(_ isWide: [Bool], heights: [CGFloat]) -> (slots: [WidgetGridSlot], totalHeight: CGFloat) { + widgetGridSlots(isWide: isWide, width: width, spacing: spacing) { index, _ in heights[index] } + } + + func testEmpty_ProducesNoSlotsAndZeroHeight() { + let result = widgetGridSlots(isWide: [], width: width, spacing: spacing) { _, _ in 0 } + XCTAssertTrue(result.slots.isEmpty) + XCTAssertEqual(result.totalHeight, 0) + } + + func testSingleWide_SpansFullWidth() { + let result = layout([true], heights: [100]) + XCTAssertEqual(result.slots, [WidgetGridSlot(index: 0, frame: CGRect(x: 0, y: 0, width: width, height: 100))]) + XCTAssertEqual(result.totalHeight, 100) + } + + func testLoneSmall_TakesLeftColumn() { + let result = layout([false], heights: [150]) + XCTAssertEqual(result.slots, [WidgetGridSlot(index: 0, frame: CGRect(x: 0, y: 0, width: columnWidth, height: 150))]) + XCTAssertEqual(result.totalHeight, 150) + } + + func testTwoSmalls_PairSideBySideAtMaxHeight() { + let result = layout([false, false], heights: [100, 160]) + XCTAssertEqual(result.slots, [ + WidgetGridSlot(index: 0, frame: CGRect(x: 0, y: 0, width: columnWidth, height: 160)), + WidgetGridSlot(index: 1, frame: CGRect(x: columnWidth + spacing, y: 0, width: columnWidth, height: 160)), + ]) + XCTAssertEqual(result.totalHeight, 160) + } + + func testSmallFollowedByWide_SmallTakesLeftColumnThenWideOnNextRow() { + let result = layout([false, true], heights: [100, 120]) + XCTAssertEqual(result.slots, [ + WidgetGridSlot(index: 0, frame: CGRect(x: 0, y: 0, width: columnWidth, height: 100)), + WidgetGridSlot(index: 1, frame: CGRect(x: 0, y: 100 + spacing, width: width, height: 120)), + ]) + XCTAssertEqual(result.totalHeight, 100 + spacing + 120) + } + + func testThreeSmalls_FirstTwoPairThirdIsLone() { + let result = layout([false, false, false], heights: [100, 100, 100]) + XCTAssertEqual(result.slots, [ + WidgetGridSlot(index: 0, frame: CGRect(x: 0, y: 0, width: columnWidth, height: 100)), + WidgetGridSlot(index: 1, frame: CGRect(x: columnWidth + spacing, y: 0, width: columnWidth, height: 100)), + WidgetGridSlot(index: 2, frame: CGRect(x: 0, y: 100 + spacing, width: columnWidth, height: 100)), + ]) + XCTAssertEqual(result.totalHeight, 100 + spacing + 100) + } + + func testWideThenSmallPair_StacksRows() { + let result = layout([true, false, false], heights: [120, 90, 90]) + XCTAssertEqual(result.slots, [ + WidgetGridSlot(index: 0, frame: CGRect(x: 0, y: 0, width: width, height: 120)), + WidgetGridSlot(index: 1, frame: CGRect(x: 0, y: 120 + spacing, width: columnWidth, height: 90)), + WidgetGridSlot(index: 2, frame: CGRect(x: columnWidth + spacing, y: 120 + spacing, width: columnWidth, height: 90)), + ]) + XCTAssertEqual(result.totalHeight, 120 + spacing + 90) + } + + /// The height provider must be asked for the width each item is actually laid out at: + /// full width for a wide item, column width for paired/lone smalls. + func testHeightProvider_ReceivesResolvedWidthPerItem() { + // Returning the proposed width as the height lets us assert which width was used. + let result = widgetGridSlots(isWide: [true, false, false], width: width, spacing: spacing) { _, proposedWidth in + proposedWidth + } + XCTAssertEqual(result.slots[0].frame.height, width) // wide → full width + XCTAssertEqual(result.slots[1].frame.height, columnWidth) // paired small → column width + XCTAssertEqual(result.slots[2].frame.height, columnWidth) + } +} diff --git a/BitkitTests/WidgetsViewModelReorderTests.swift b/BitkitTests/WidgetsViewModelReorderTests.swift new file mode 100644 index 000000000..50557ce26 --- /dev/null +++ b/BitkitTests/WidgetsViewModelReorderTests.swift @@ -0,0 +1,64 @@ +@testable import Bitkit +import XCTest + +/// Covers the drag-reorder core (`WidgetsViewModel.reorderWidgets`) that backs the home grid's +/// live drag-and-drop: moves must update the published order, persist, and reject invalid indices. +@MainActor +final class WidgetsViewModelReorderTests: XCTestCase { + override func setUp() { + super.setUp() + UserDefaults.standard.removeObject(forKey: "savedWidgets") + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: "savedWidgets") + super.tearDown() + } + + /// Builds a deterministic three-widget set regardless of the default install set. + private func makeViewModel(order: [WidgetType] = [.price, .blocks, .news]) -> WidgetsViewModel { + let widgets = WidgetsViewModel() + for type in WidgetType.allCases { + widgets.deleteWidget(type) + } + for type in order { + widgets.saveWidget(type) + } + XCTAssertEqual(widgets.savedWidgets.map(\.type), order, "precondition: known starting order") + return widgets + } + + func testReorder_MovesWidgetForwardAndUpdatesPublishedOrder() { + let widgets = makeViewModel() + widgets.reorderWidgets(from: 0, to: 2) + XCTAssertEqual(widgets.savedWidgets.map(\.type), [.blocks, .news, .price]) + } + + func testReorder_MovesWidgetBackward() { + let widgets = makeViewModel() + widgets.reorderWidgets(from: 2, to: 0) + XCTAssertEqual(widgets.savedWidgets.map(\.type), [.news, .price, .blocks]) + } + + func testReorder_Persists_ReflectedAfterReload() { + let widgets = makeViewModel() + widgets.reorderWidgets(from: 0, to: 2) + + let reloaded = WidgetsViewModel() + XCTAssertEqual(reloaded.savedWidgets.map(\.type), [.blocks, .news, .price]) + } + + func testReorder_SameIndex_IsNoOp() { + let widgets = makeViewModel() + widgets.reorderWidgets(from: 1, to: 1) + XCTAssertEqual(widgets.savedWidgets.map(\.type), [.price, .blocks, .news]) + } + + func testReorder_OutOfBoundsIndices_AreIgnored() { + let widgets = makeViewModel() + widgets.reorderWidgets(from: 5, to: 0) + widgets.reorderWidgets(from: 0, to: 9) + widgets.reorderWidgets(from: -1, to: 1) + XCTAssertEqual(widgets.savedWidgets.map(\.type), [.price, .blocks, .news]) + } +} diff --git a/BitkitTests/WidgetsViewModelTests.swift b/BitkitTests/WidgetsViewModelTests.swift index 6f97925de..acf5fd235 100644 --- a/BitkitTests/WidgetsViewModelTests.swift +++ b/BitkitTests/WidgetsViewModelTests.swift @@ -19,7 +19,7 @@ final class WidgetsViewModelTests: XCTestCase { widgets.deleteWidget(.price) widgets.deleteWidget(.blocks) - widgets.saveOptions(PriceWidgetOptions(selectedPair: "BTC/EUR", selectedPeriod: .oneWeek), for: .price) + widgets.stageOptions(PriceWidgetOptions(selectedPair: "BTC/EUR", selectedPeriod: .oneWeek), for: .price) widgets.saveWidget(.price) let reloadedWidgets = WidgetsViewModel() From 01d44efffb3e2ac918ade36a4a86ff63e74bfec9 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 29 May 2026 10:56:34 -0300 Subject: [PATCH 28/31] fix: blocks content --- .../Components/Widgets/BlocksWidgetContent.swift | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/Bitkit/Components/Widgets/BlocksWidgetContent.swift b/Bitkit/Components/Widgets/BlocksWidgetContent.swift index 1a06f9612..3259f819b 100644 --- a/Bitkit/Components/Widgets/BlocksWidgetContent.swift +++ b/Bitkit/Components/Widgets/BlocksWidgetContent.swift @@ -16,9 +16,11 @@ struct BlocksWidgetWideContent: View { var body: some View { let palette = WidgetPalette(renderingMode: renderingMode) - VStack(alignment: .leading, spacing: 0) { - ForEach(Array(options.enabledFields.enumerated()), id: \.element) { index, field in - if index > 0 { + let fields = options.enabledFields + let topAligned = fields.count <= 2 + VStack(alignment: .leading, spacing: topAligned ? 8 : 0) { + ForEach(Array(fields.enumerated()), id: \.element) { index, field in + if index > 0, !topAligned { Spacer(minLength: 8) } BlocksWidgetWideRow(field: field, value: field.value(from: data), palette: palette) @@ -59,9 +61,11 @@ struct BlocksWidgetCompactContent: View { var body: some View { let palette = WidgetPalette(renderingMode: renderingMode) - VStack(alignment: .leading, spacing: 0) { - ForEach(Array(options.enabledFields.enumerated()), id: \.element) { index, field in - if index > 0 { + let fields = options.enabledFields + let topAligned = fields.count <= 2 + VStack(alignment: .leading, spacing: topAligned ? 8 : 0) { + ForEach(Array(fields.enumerated()), id: \.element) { index, field in + if index > 0, !topAligned { Spacer(minLength: 8) } HStack(alignment: .center, spacing: 8) { From 6701436ebbc9aa31e903855e894899369cda1f1e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 29 May 2026 11:00:28 -0300 Subject: [PATCH 29/31] feat: set 4 card suggestions for preview mode --- Bitkit/Components/Widgets/Suggestions.swift | 15 ++++++++++++--- Bitkit/Views/Widgets/WidgetPreviewSheetView.swift | 2 +- Bitkit/Views/Widgets/WidgetsListSheetView.swift | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/Bitkit/Components/Widgets/Suggestions.swift b/Bitkit/Components/Widgets/Suggestions.swift index 7591fad56..ed4d9b4b8 100644 --- a/Bitkit/Components/Widgets/Suggestions.swift +++ b/Bitkit/Components/Widgets/Suggestions.swift @@ -161,9 +161,13 @@ extension SuggestionCardData { } struct Suggestions: View { - /// When true, show only two static cards and ignore taps (e.g. widget detail preview). + /// When true, show a fixed set of static cards and ignore taps (e.g. widget preview). var isPreview: Bool = false + var previewCardIds: [String]? + + static let previewSheetCardIds = ["backupSeedPhrase", "pin", "transferToSpending", "support"] + @EnvironmentObject var app: AppViewModel @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var sheets: SheetViewModel @@ -189,9 +193,13 @@ struct Suggestions: View { suggestionsManager: SuggestionsManager, pubkyProfile: PubkyProfileManager? = nil, isPaykitUIEnabled: Bool = PaykitFeatureFlags.isUIEnabled, - isPreview: Bool = false + isPreview: Bool = false, + previewCardIds: [String]? = nil ) -> [SuggestionCardData] { if isPreview { + if let previewCardIds { + return previewCardIds.compactMap { cardsById[$0] } + } return Array(cards.prefix(2)) } let state: WalletSuggestionState = if wallet.totalBalanceSats == 0 { @@ -237,7 +245,8 @@ struct Suggestions: View { suggestionsManager: suggestionsManager, pubkyProfile: pubkyProfile, isPaykitUIEnabled: isPaykitUIActive, - isPreview: isPreview + isPreview: isPreview, + previewCardIds: previewCardIds ) } diff --git a/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift b/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift index fe957e756..3ad9abe32 100644 --- a/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift +++ b/Bitkit/Views/Widgets/WidgetPreviewSheetView.swift @@ -607,7 +607,7 @@ private struct CalculatorSmallPreview: View { private struct SuggestionsWidePreview: View { var body: some View { - Suggestions(isPreview: true) + Suggestions(isPreview: true, previewCardIds: Suggestions.previewSheetCardIds) .frame(maxWidth: .infinity) } } diff --git a/Bitkit/Views/Widgets/WidgetsListSheetView.swift b/Bitkit/Views/Widgets/WidgetsListSheetView.swift index 9b7b3f2ee..dff30e8b1 100644 --- a/Bitkit/Views/Widgets/WidgetsListSheetView.swift +++ b/Bitkit/Views/Widgets/WidgetsListSheetView.swift @@ -263,7 +263,7 @@ private struct FactsTile: View { private struct SuggestionsTile: View { var body: some View { // Non-interactive preview grid; the suggestion cards supply their own backgrounds. - Suggestions(isPreview: true) + Suggestions(isPreview: true, previewCardIds: Suggestions.previewSheetCardIds) .frame(maxWidth: .infinity) } } From e6509b08e09ef79c8e26af12b8da04d8e477e570 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 29 May 2026 11:16:46 -0300 Subject: [PATCH 30/31] fix: compact widget height for list sheet --- Bitkit/Views/Widgets/WidgetsListSheetView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit/Views/Widgets/WidgetsListSheetView.swift b/Bitkit/Views/Widgets/WidgetsListSheetView.swift index dff30e8b1..e644c2353 100644 --- a/Bitkit/Views/Widgets/WidgetsListSheetView.swift +++ b/Bitkit/Views/Widgets/WidgetsListSheetView.swift @@ -133,7 +133,7 @@ struct WidgetsListSheetView: View { chromedTile(for: type) .padding(16) .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: displaySize(for: type) == .small ? 160 : nil, alignment: .topLeading) + .frame(height: displaySize(for: type) == .small ? 192 : nil, alignment: .topLeading) .background(Color.gray6) .cornerRadius(16) } From 881dbe95e43be0231c02573677dad617dbd293bb Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 29 May 2026 12:56:09 -0300 Subject: [PATCH 31/31] fix: backup widgets size --- Bitkit/Utilities/WidgetsBackupConverter.swift | 6 +- Bitkit/ViewModels/WidgetsViewModel.swift | 18 +++--- BitkitTests/WidgetsBackupConverterTests.swift | 56 +++++++++++++++++++ 3 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 BitkitTests/WidgetsBackupConverterTests.swift diff --git a/Bitkit/Utilities/WidgetsBackupConverter.swift b/Bitkit/Utilities/WidgetsBackupConverter.swift index 75b18d088..b625c2468 100644 --- a/Bitkit/Utilities/WidgetsBackupConverter.swift +++ b/Bitkit/Utilities/WidgetsBackupConverter.swift @@ -19,6 +19,7 @@ enum WidgetsBackupConverter { widgetsArray.append([ "type": androidType, "position": index, + "size": widget.size.rawValue, ]) if let optionsData = widget.optionsData { @@ -157,7 +158,10 @@ enum WidgetsBackupConverter { break } - return (position: position, widget: SavedWidget(type: widgetType, optionsData: optionsData)) + let size: WidgetSize = (widgetDict["size"] as? String) + .flatMap(WidgetSize.init(rawValue:)) ?? WidgetSize.default(for: widgetType) + + return (position: position, widget: SavedWidget(type: widgetType, optionsData: optionsData, size: size)) } let sortedWidgets = widgetsWithPosition.sorted { $0.position < $1.position } diff --git a/Bitkit/ViewModels/WidgetsViewModel.swift b/Bitkit/ViewModels/WidgetsViewModel.swift index ded960f22..2f3ec05ba 100644 --- a/Bitkit/ViewModels/WidgetsViewModel.swift +++ b/Bitkit/ViewModels/WidgetsViewModel.swift @@ -49,6 +49,14 @@ struct WidgetMetadata { enum WidgetSize: String, Codable, CaseIterable { case small case wide + + /// Default grid size for a freshly added widget of this type. + static func `default`(for type: WidgetType) -> WidgetSize { + switch type { + case .price, .news, .suggestions: return .wide + default: return .small + } + } } // MARK: - Widget Models @@ -198,14 +206,8 @@ class WidgetsViewModel: ObservableObject { /// Default widgets for new installs and resets private static let defaultSavedWidgets: [SavedWidget] = [ - SavedWidget(type: .suggestions, size: .wide), - SavedWidget(type: .price, size: .wide), - SavedWidget(type: .blocks, size: .small), - SavedWidget(type: .facts, size: .small), - SavedWidget(type: .weather, size: .small), - SavedWidget(type: .calculator, size: .small), - SavedWidget(type: .news, size: .wide), - ] + .suggestions, .price, .blocks, .facts, .weather, .calculator, .news, + ].map { SavedWidget(type: $0, size: .default(for: $0)) } init() { loadSavedWidgets() diff --git a/BitkitTests/WidgetsBackupConverterTests.swift b/BitkitTests/WidgetsBackupConverterTests.swift new file mode 100644 index 000000000..d5a0d86d9 --- /dev/null +++ b/BitkitTests/WidgetsBackupConverterTests.swift @@ -0,0 +1,56 @@ +@testable import Bitkit +import XCTest + +/// Locks in the backup contract for widget size: v61 sizes must survive an export → import +/// round-trip, exports must carry a `size` field, and backups predating the field (Android / +/// pre-v61 iOS) must fall back to the per-type default rather than blanket `.wide`. +final class WidgetsBackupConverterTests: XCTestCase { + private func widgetsArray(_ androidFormat: [String: Any]) throws -> [[String: Any]] { + return try XCTUnwrap(androidFormat["widgets"] as? [[String: Any]]) + } + + /// Export → import preserves type, size, and order for a mix of small/wide widgets. + func testRoundTrip_PreservesSizeAndOrder() throws { + let original: [SavedWidget] = [ + SavedWidget(type: .news, size: .wide), + SavedWidget(type: .blocks, size: .small), + SavedWidget(type: .weather, size: .small), + SavedWidget(type: .price, size: .wide), + ] + + let android = try WidgetsBackupConverter.convertToAndroidFormat(savedWidgets: original) + let restored = try WidgetsBackupConverter.convertFromAndroidFormat(jsonDict: android) + + XCTAssertEqual(restored.map(\.type), original.map(\.type)) + XCTAssertEqual(restored.map(\.size), original.map(\.size)) + } + + /// Each exported entry carries its size as a raw string. + func testExport_WritesSizeField() throws { + let android = try WidgetsBackupConverter.convertToAndroidFormat(savedWidgets: [ + SavedWidget(type: .blocks, size: .small), + SavedWidget(type: .news, size: .wide), + ]) + + let entries = try widgetsArray(android) + XCTAssertEqual(entries.count, 2) + XCTAssertEqual(entries[0]["size"] as? String, "small") + XCTAssertEqual(entries[1]["size"] as? String, "wide") + } + + /// Entries with no `size` (Android / pre-v61 backups) fall back to the per-type default. + func testImport_MissingSize_FallsBackToPerTypeDefault() throws { + let json: [String: Any] = [ + "widgets": [ + ["type": "BLOCK", "position": 0], // -> blocks, default .small + ["type": "PRICE", "position": 1], // -> price, default .wide + ["type": "WEATHER", "position": 2], // -> weather, default .small + ], + ] + + let restored = try WidgetsBackupConverter.convertFromAndroidFormat(jsonDict: json) + + XCTAssertEqual(restored.map(\.type), [.blocks, .price, .weather]) + XCTAssertEqual(restored.map(\.size), [.small, .wide, .small]) + } +}