From 9b9cd44f8b3b9afb761edf4b7bdd1363ae98807e Mon Sep 17 00:00:00 2001 From: Renato Ferraz Date: Thu, 18 Dec 2025 12:49:53 -0300 Subject: [PATCH 1/4] Auto stash before merge of "release/v5" and "origin/release/v5" --- .../Helper/FeedDotsIndicatorView.swift | 1 - .../WatchApp/Helper/FlowTagsView.swift | 106 ----- .../ViewModel/FeedRootViewModel.swift | 70 ++-- .../WatchApp/Views/FeedDetailView.swift | 30 +- MacMagazine/WatchApp/Views/FeedMainView.swift | 370 +++++++++++++----- .../WatchApp/Views/Row/FeedRowView.swift | 46 +-- 6 files changed, 314 insertions(+), 309 deletions(-) delete mode 100644 MacMagazine/WatchApp/Helper/FlowTagsView.swift diff --git a/MacMagazine/WatchApp/Helper/FeedDotsIndicatorView.swift b/MacMagazine/WatchApp/Helper/FeedDotsIndicatorView.swift index 7fd54e98..ad45f9b4 100644 --- a/MacMagazine/WatchApp/Helper/FeedDotsIndicatorView.swift +++ b/MacMagazine/WatchApp/Helper/FeedDotsIndicatorView.swift @@ -11,7 +11,6 @@ public struct FeedDotsIndicatorView: View { dots } .padding(6) - .glassEffect(.clear) .allowsHitTesting(false) } diff --git a/MacMagazine/WatchApp/Helper/FlowTagsView.swift b/MacMagazine/WatchApp/Helper/FlowTagsView.swift deleted file mode 100644 index 328d5a51..00000000 --- a/MacMagazine/WatchApp/Helper/FlowTagsView.swift +++ /dev/null @@ -1,106 +0,0 @@ -import SwiftUI - -struct FlowTagsView: View { - - // MARK: - Properties - - let tags: [Tag] - let horizontalSpacing: CGFloat - let verticalSpacing: CGFloat - let content: (Tag) -> Content - - @State private var measuredHeight: CGFloat = 0 - - // MARK: - Init - - init( - tags: [Tag], - horizontalSpacing: CGFloat, - verticalSpacing: CGFloat, - @ViewBuilder content: @escaping (Tag) -> Content - ) { - self.tags = tags - self.horizontalSpacing = horizontalSpacing - self.verticalSpacing = verticalSpacing - self.content = content - } - - // MARK: - Body - - var body: some View { - GeometryReader { proxy in - let rows = makeRows(availableWidth: proxy.size.width) - - VStack(alignment: .leading, spacing: verticalSpacing) { - ForEach(rows.indices, id: \.self) { rowIndex in - HStack(spacing: horizontalSpacing) { - ForEach(rows[rowIndex], id: \.self) { tag in - content(tag) - .fixedSize(horizontal: true, vertical: true) - } - } - } - } - .background( - GeometryReader { innerProxy in - Color.clear - .preference(key: HeightPreferenceKey.self, value: innerProxy.size.height) - } - ) - } - .frame(height: measuredHeight) - .onPreferenceChange(HeightPreferenceKey.self) { height in - if height != measuredHeight { - measuredHeight = height - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) - } - - // MARK: - Layout - - private func makeRows(availableWidth: CGFloat) -> [[Tag]] { - var rows: [[Tag]] = [[]] - var currentRowWidth: CGFloat = 0 - - for tag in tags { - let tagWidth = estimatedTagWidth(tag: tag) - - if rows[rows.count - 1].isEmpty { - rows[rows.count - 1].append(tag) - currentRowWidth = tagWidth - continue - } - - let nextWidth = currentRowWidth + horizontalSpacing + tagWidth - - if nextWidth <= availableWidth { - rows[rows.count - 1].append(tag) - currentRowWidth = nextWidth - } else { - rows.append([tag]) - currentRowWidth = tagWidth - } - } - - return rows - } - - private func estimatedTagWidth(tag: Tag) -> CGFloat { - let text = String(describing: tag) - let estimatedCharacterWidth: CGFloat = 6 - let horizontalPadding: CGFloat = 16 - return CGFloat(text.count) * estimatedCharacterWidth + horizontalPadding - } -} - -// MARK: - Height Preference - -private struct HeightPreferenceKey: PreferenceKey { - static var defaultValue: CGFloat = 0 - - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = max(value, nextValue()) - } -} diff --git a/MacMagazine/WatchApp/ViewModel/FeedRootViewModel.swift b/MacMagazine/WatchApp/ViewModel/FeedRootViewModel.swift index 14285344..2e287cf9 100644 --- a/MacMagazine/WatchApp/ViewModel/FeedRootViewModel.swift +++ b/MacMagazine/WatchApp/ViewModel/FeedRootViewModel.swift @@ -13,7 +13,9 @@ final class FeedRootViewModel: ObservableObject { @Published private(set) var status: FeedViewModel.Status = .loading @Published var selectedIndex: Int = 0 @Published var showActions: Bool = false + @Published var showContextMenu: Bool = false @Published var selectedPostForDetail: SelectedPost? + @Published private(set) var isRefreshing: Bool = false // MARK: - Private @@ -28,27 +30,33 @@ final class FeedRootViewModel: ObservableObject { // MARK: - Public API - func refresh() async { - _ = try? await feedViewModel.getWatchFeed() - status = feedViewModel.status - } + func loadInitial(hasItems: Bool, modelContext: ModelContext) async { + if hasItems { + status = .done + return + } - func toggleActions() { - showActions.toggle() + await refresh(modelContext: modelContext) } - func hideActions() { - showActions = false + func refresh(modelContext: ModelContext) async { + isRefreshing = true + + defer { + isRefreshing = false + } + + _ = try? await feedViewModel.getWatchFeed() + status = feedViewModel.status } - func toggleFavorite(post: FeedDB) { - let context = feedViewModel.context + func toggleFavorite(post: FeedDB, modelContext: ModelContext) { post.favorite.toggle() - try? context.save() + try? modelContext.save() showActions = true } - // MARK: - Index Calculation + // MARK: - Index / Helpers func computeSelectedIndexByMidY(items: [FeedDB], positions: [String: CGPoint]) -> Int { guard !items.isEmpty else { return 0 } @@ -71,45 +79,13 @@ final class FeedRootViewModel: ObservableObject { return bestIndex } - func computeSelectedIndexByMidX(items: [FeedDB], positions: [String: CGPoint]) -> Int { - guard !items.isEmpty else { return 0 } - - let screenMidX = WKInterfaceDevice.current().screenBounds.midX - - var bestIndex = 0 - var bestDistance = CGFloat.greatestFiniteMagnitude - - for (index, item) in items.enumerated() { - guard let point = positions[item.postId] else { continue } - let distance = abs(point.x - screenMidX) - - if distance < bestDistance { - bestDistance = distance - bestIndex = index - } - } - - return bestIndex - } - func clampIndex(_ index: Int, quantity: Int) -> Int { guard quantity > 0 else { return 0 } return min(max(index, 0), quantity - 1) } -} - -// MARK: - Preview Support - -#if DEBUG -extension FeedRootViewModel { - static func preview() -> FeedRootViewModel { - let database = Database(models: [FeedDB.self], inMemory: true) - let feedVM = FeedViewModel(storage: database) - let viewModel = FeedRootViewModel(feedViewModel: feedVM) - - viewModel.status = .done - return viewModel + @MainActor + func setStatusForPreview(_ status: FeedViewModel.Status) { + self.status = status } } -#endif diff --git a/MacMagazine/WatchApp/Views/FeedDetailView.swift b/MacMagazine/WatchApp/Views/FeedDetailView.swift index 33549a6c..75c8d7b5 100644 --- a/MacMagazine/WatchApp/Views/FeedDetailView.swift +++ b/MacMagazine/WatchApp/Views/FeedDetailView.swift @@ -3,6 +3,8 @@ import SwiftUI struct FeedDetailView: View { + @Environment(\.modelContext) private var modelContext + // MARK: - Properties let post: FeedDB @@ -22,7 +24,6 @@ struct FeedDetailView: View { ScrollView { VStack(alignment: .leading, spacing: 12) { header - categories bodyText footer } @@ -49,28 +50,6 @@ struct FeedDetailView: View { } } - // MARK: - Categories (Flow Tags) - - @ViewBuilder - private var categories: some View { - if !post.categories.isEmpty { - FlowTagsView( - tags: post.categories.map { $0.uppercased() }, - horizontalSpacing: 6, - verticalSpacing: 6 - ) { tag in - Text(tag) - .font(.caption2) - .foregroundStyle(.primary) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(.secondary.opacity(0.4)) - .clipShape(Capsule()) - } - .allowsHitTesting(false) - } - } - // MARK: - Body @ViewBuilder @@ -93,7 +72,7 @@ struct FeedDetailView: View { .padding(.top, 4) Button { - viewModel.toggleFavorite(post: post) + viewModel.toggleFavorite(post: post, modelContext: modelContext) } label: { Image(systemName: post.favorite ? "star.fill" : "star") } @@ -105,6 +84,7 @@ struct FeedDetailView: View { // MARK: - Preview #if DEBUG #Preview { - FeedDetailView(viewModel: .preview(), post: .previewItem) + FeedDetailView(viewModel: .preview(status: .done), + post: .previewItem) } #endif diff --git a/MacMagazine/WatchApp/Views/FeedMainView.swift b/MacMagazine/WatchApp/Views/FeedMainView.swift index 4c448e19..215e3060 100644 --- a/MacMagazine/WatchApp/Views/FeedMainView.swift +++ b/MacMagazine/WatchApp/Views/FeedMainView.swift @@ -1,4 +1,5 @@ import FeedLibrary +import StorageLibrary import SwiftData import SwiftUI import WatchKit @@ -12,6 +13,12 @@ struct FeedMainView: View { @Query(sort: \FeedDB.pubDate, order: .reverse) private var items: [FeedDB] + // MARK: - Preview Guard + + private var isRunningForPreviews: Bool { + ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" + } + // MARK: - State @StateObject private var viewModel: FeedRootViewModel @@ -28,21 +35,12 @@ struct FeedMainView: View { NavigationStack { rootContent .navigationBarTitleDisplayMode(.inline) - .navigationTitle { - if items.isEmpty { - Text("MacMagazine") - .font(.system(size: 12)) - } - - Text("MacMagazine\n\(viewModel.selectedIndex + 1) de \(min(items.count, 10))") - .font(.system(size: 12)) - .frame(alignment: .trailing) - .multilineTextAlignment(.trailing) - .lineLimit(2) - .offset(y: 14) - } - .task { - await viewModel.refresh() + .task(id: items.count) { + guard !isRunningForPreviews else { return } + await viewModel.loadInitial( + hasItems: !items.isEmpty, + modelContext: modelContext + ) } .navigationDestination(item: $viewModel.selectedPostForDetail) { payload in FeedDetailView(viewModel: viewModel, post: payload.post) @@ -56,18 +54,125 @@ struct FeedMainView: View { private var rootContent: some View { switch viewModel.status { case .loading: - ProgressView("Carregando…") + loadingView + .navigationTitle { navigationTitle() } case .error(let reason): - errorView(reason: reason) + errorScreen(reason: reason) case .done: if items.isEmpty { - emptyView + emptyScreen } else { - carouselRowScreen(items: Array(items.prefix(10))) + carouselRowScreen(items: items) + .navigationTitle { + Text("MacMagazine\n\(viewModel.selectedIndex + 1) de \(items.count)") + .font(.caption) + .frame(alignment: .trailing) + .multilineTextAlignment(.trailing) + .lineLimit(2) + .offset(y: 14) + .opacity(viewModel.isRefreshing ? 0 : 1) + } + } + } + } + + // MARK: - Navigation Title + + @ViewBuilder + private func navigationTitle() -> some View { + Text("MacMagazine") + .font(.caption) + .opacity(viewModel.isRefreshing ? 0 : 1) + } + + // MARK: - Loading + + private var loadingView: some View { + VStack(spacing: 10) { + Spacer() + + ProgressView() + Text("Carregando…") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Spacer() + } + .padding() + } + + // MARK: - Error / Empty Screens + + private func errorScreen(reason: String) -> some View { + statusScreen( + imageSystemName: "exclamationmark.triangle.fill", + title: "Algo deu errado", + message: reason, + buttonTitle: "Tentar novamente", + buttonAction: { + Task { + await viewModel.refresh(modelContext: modelContext) + } + } + ) + } + + private var emptyScreen: some View { + statusScreen( + imageSystemName: "tray.fill", + title: "Sem itens", + message: "Toque abaixo para tentar carregar novamente.", + buttonTitle: "Atualizar", + buttonAction: { + Task { + await viewModel.refresh(modelContext: modelContext) + } + } + ) + } + + private func statusScreen( + imageSystemName: String, + title: String, + message: String, + buttonTitle: String, + buttonAction: @escaping () -> Void + ) -> some View { + ZStack { + VStack(spacing: 10) { + Spacer() + Image(systemName: imageSystemName) + .font(.system(size: 28, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + + Text(title) + .font(.headline) + .multilineTextAlignment(.center) + + Text(message) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) } + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + .frame(maxHeight: .infinity, alignment: .center) } + .safeAreaInset(edge: .bottom) { + Spacer() + Button(buttonTitle, action: buttonAction) + .buttonStyle(.glass) + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + .padding(.top, 20) + } + .padding(.top, 8) + .ignoresSafeArea() } // MARK: - Full Screen Carousel @@ -76,39 +181,44 @@ struct FeedMainView: View { GeometryReader { geometry in let size = geometry.size - ZStack(alignment: .leading) { - ScrollView(.vertical, showsIndicators: false) { - LazyVStack(spacing: 0) { - ForEach(Array(items.enumerated()), id: \.element.postId) { index, post in - FeedRowView(post: post) - .frame(width: size.width, height: size.height) - .id(index) - .reportFeedRowPosition(postId: post.postId) - } + ZStack(alignment: .trailing) { + List { + ForEach(Array(items.enumerated()), id: \.element.postId) { index, post in + FeedRowView(post: post) + .frame(width: size.width, height: size.height) + .id(index) + .reportFeedRowPosition(postId: post.postId) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .onTapGesture { + viewModel.selectedPostForDetail = SelectedPost(post: post) + } + .onLongPressGesture { + viewModel.showContextMenu = true + } } - .scrollTargetLayout() } - .scrollTargetBehavior(.paging) + .listStyle(.carousel) .scrollIndicators(.hidden) - .contentShape(Rectangle()) - .simultaneousGesture( - TapGesture().onEnded { - viewModel.toggleActions() - } - ) .onPreferenceChange(FeedScrollPositionKey.self) { positions in - let newIndex = viewModel.computeSelectedIndexByMidY( - items: items, - positions: positions - ) - + let newIndex = viewModel.computeSelectedIndexByMidY(items: items, positions: positions) if newIndex != viewModel.selectedIndex { viewModel.selectedIndex = newIndex - viewModel.hideActions() } } - .overlay(alignment: .bottom) { - actionsOverlay(items: items) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + Task { + await viewModel.refresh(modelContext: modelContext) + } + } label: { + Image(systemName: "arrow.clockwise") + } + .glassEffect(.clear) + .opacity(viewModel.isRefreshing ? 0.5 : 1.0) + .disabled(viewModel.isRefreshing) + } } .refreshable { await viewModel.refresh() @@ -118,40 +228,92 @@ struct FeedMainView: View { count: items.count, selectedIndex: viewModel.selectedIndex ) - .frame(maxHeight: .infinity, alignment: .center) - .padding(.leading, 6) - .offset(x: -6) + .frame(maxHeight: .infinity, alignment: .trailing) + .opacity(viewModel.isRefreshing ? 0 : 1) + } + .overlay { + if viewModel.isRefreshing { + refreshOverlay + } + } + .sheet(isPresented: $viewModel.showContextMenu) { + contextMenuSheet(items: items) } } .ignoresSafeArea() } - // MARK: - Actions Overlay + // MARK: - Context Menu Sheet + + private func contextMenuSheet(items: [FeedDB]) -> some View { + let post = currentPost(items: items) + + return ScrollView { + VStack(spacing: 12) { + // Atualizar posts + Button { + viewModel.showContextMenu = false + Task { + await viewModel.refresh(modelContext: modelContext) + } + } label: { + Label("Atualizar posts", systemImage: "arrow.clockwise") + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) - private func actionsOverlay(items: [FeedDB]) -> some View { - Group { - if viewModel.showActions, let post = currentPost(items: items) { - HStack(spacing: 10) { + if let post = post { + Divider() + + // Favoritar post Button { - viewModel.toggleFavorite(post: post) + viewModel.showContextMenu = false + viewModel.toggleFavorite(post: post, modelContext: modelContext) } label: { - Image(systemName: post.favorite ? "star.fill" : "star") + Label( + post.favorite ? "Remover favorito" : "Favoritar post", + systemImage: post.favorite ? "star.slash" : "star" + ) + .frame(maxWidth: .infinity, alignment: .leading) } - .buttonStyle(.glass) + .buttonStyle(.plain) + + Divider() - Button("Ver mais") { + // Ler post + Button { + viewModel.showContextMenu = false viewModel.selectedPostForDetail = SelectedPost(post: post) + } label: { + Label("Ler post", systemImage: "doc.text") + .frame(maxWidth: .infinity, alignment: .leading) } - .buttonStyle(.glass) + .buttonStyle(.plain) } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - .padding(.bottom, 10) - .transition(.opacity) } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + } + + // MARK: - Refresh Overlay + + private var refreshOverlay: some View { + ZStack { + VStack(spacing: 8) { + ProgressView() + Text("Atualizando…") + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(.black.opacity(0.75)) + ) } - .animation(.easeInOut(duration: 0.15), value: viewModel.showActions) + .transition(.opacity) } // MARK: - Helpers @@ -161,74 +323,74 @@ struct FeedMainView: View { let index = viewModel.clampIndex(viewModel.selectedIndex, quantity: items.count) return items[index] } +} - // MARK: - Shared Views - - private func errorView(reason: String) -> some View { - VStack(spacing: 10) { - Text("Não foi possível carregar.") - .font(.headline) - - Text(reason) - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) +// MARK: - Preview Support - Button("Tentar novamente") { - Task { - await viewModel.refresh() - } - } - } - .padding() - } +#if DEBUG +extension FeedRootViewModel { + static func preview(status: FeedViewModel.Status) -> FeedRootViewModel { + let database = Database(models: [FeedDB.self], inMemory: true) + let feedVM = FeedViewModel(storage: database) + let viewModel = FeedRootViewModel(feedViewModel: feedVM) - private var emptyView: some View { - ScrollView { - VStack(spacing: 8) { - Text("Sem itens") - .font(.headline) + viewModel.setStatusForPreview(status) - Text("Puxe para atualizar.") - .font(.footnote) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, minHeight: WKInterfaceDevice.current().screenBounds.height * 0.8) - } + return viewModel } } - -// MARK: - Preview +#endif #if DEBUG private struct FeedRootPreviewHost: View { - let container: ModelContainer + let viewModel: FeedRootViewModel - init() { + init( + status: FeedViewModel.Status, + seedItems: Bool + ) { let schema = Schema([FeedDB.self]) let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) do { - container = try ModelContainer(for: schema, configurations: [config]) + let tmpContainer = try ModelContainer(for: schema, configurations: [config]) - FeedDB.previewItems.forEach { item in - container.mainContext.insert(item) + if seedItems { + for item in FeedDB.previewItems { + tmpContainer.mainContext.insert(item) + } + try tmpContainer.mainContext.save() } - try container.mainContext.save() + self.container = tmpContainer + self.viewModel = .preview(status: status) } catch { fatalError("Failed to create preview container: \(error)") } } var body: some View { - FeedMainView(viewModel: .preview()) + FeedRootView(viewModel: viewModel) .modelContainer(container) } } +#endif + +#if DEBUG +#Preview("Feed • Loading") { + FeedRootPreviewHost(status: .loading, seedItems: false) +} + +#Preview("Feed • Error") { + FeedRootPreviewHost(status: .error(reason: "Sem conexão com a internet."), seedItems: false) +} + +#Preview("Feed • Done (sem registros)") { + FeedRootPreviewHost(status: .done, seedItems: false) +} -#Preview("Carousel Full Screen") { - FeedRootPreviewHost() +#Preview("Feed • Done (com registros)") { + FeedRootPreviewHost(status: .done, seedItems: true) } #endif diff --git a/MacMagazine/WatchApp/Views/Row/FeedRowView.swift b/MacMagazine/WatchApp/Views/Row/FeedRowView.swift index 74c2fb2b..5a7f26c4 100644 --- a/MacMagazine/WatchApp/Views/Row/FeedRowView.swift +++ b/MacMagazine/WatchApp/Views/Row/FeedRowView.swift @@ -1,5 +1,7 @@ import FeedLibrary import SwiftUI +import UIComponentsLibrary +import UtilityLibrary struct FeedRowView: View { let post: FeedDB @@ -21,30 +23,15 @@ struct FeedRowView: View { Color.black if let url = post.artworkRemoteURL { - AsyncImage(url: url) { phase in - switch phase { - case .success(let image): - image - .resizable() - .scaledToFill() - .frame(width: size.width, height: size.height) - .clipped() - - case .failure: - Color.black.opacity(0.25) - - case .empty: - ProgressView() - - @unknown default: - Color.black.opacity(0.25) - } - } + CachedAsyncImage(image: url, contentMode: .fill) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() } else { Color.black.opacity(0.25) } } .frame(width: size.width, height: size.height) + .clipped() } private var overlayGradient: some View { @@ -59,23 +46,30 @@ struct FeedRowView: View { } private var content: some View { - VStack(spacing: 10) { + VStack(spacing: 6) { Text(post.title) - .font(.system(size: 14)) + .font(.headline) .fontWeight(.bold) - .foregroundStyle(.white) + .foregroundStyle(.primary) .frame(maxWidth: .infinity, alignment: .leading) .lineLimit(3) - .padding(.leading, 20) + .padding(.trailing, 12) Text(post.dateText) - .font(.system(size: 10)) - .foregroundStyle(.white.opacity(0.85)) + .font(.caption2) + .foregroundStyle(.primary.opacity(0.85)) + .frame(maxWidth: .infinity, alignment: .center) + .lineLimit(1) + + Text("Ler mais") + .font(.caption2) + .foregroundStyle(.primary.opacity(0.50)) .frame(maxWidth: .infinity, alignment: .center) .lineLimit(1) + .padding(.top, 6) + .padding(.bottom, 12) } .padding(.horizontal, 8) - .padding(.bottom, 8) } } From 99b3e9dc451852b33a81cf0bab4eb30c7663eac0 Mon Sep 17 00:00:00 2001 From: Renato Ferraz Date: Thu, 18 Dec 2025 13:58:11 -0300 Subject: [PATCH 2/4] Refactors FeedRootViewModel to FeedMainViewModel Renames `FeedRootViewModel` to `FeedMainViewModel` for clarity. Updates `FeedMainView` to use the renamed ViewModel and initializes the `items` with a fetch limit of 10. Improves the initial data loading logic by ensuring it only runs once. Adds a check to prevent refreshing while a refresh is already in progress. Streamlines navigation title. Removes context menu sheet options for favoriting, reading posts, as well as the refreshable modifier and topBarLeading toolbarItem as the button functionality has been removed --- MacMagazine/WatchApp/MainApp/WatchApp.swift | 2 +- .../ViewModel/FeedRootViewModel.swift | 10 +- .../WatchApp/Views/FeedDetailView.swift | 4 +- MacMagazine/WatchApp/Views/FeedMainView.swift | 102 +++++------------- .../WatchApp/Views/Row/FeedRowView.swift | 4 + 5 files changed, 41 insertions(+), 81 deletions(-) diff --git a/MacMagazine/WatchApp/MainApp/WatchApp.swift b/MacMagazine/WatchApp/MainApp/WatchApp.swift index 6d9fd7ac..a2e87953 100644 --- a/MacMagazine/WatchApp/MainApp/WatchApp.swift +++ b/MacMagazine/WatchApp/MainApp/WatchApp.swift @@ -11,7 +11,7 @@ struct WatchApp: App { var body: some Scene { WindowGroup { FeedMainView( - viewModel: FeedRootViewModel( + viewModel: FeedMainViewModel( feedViewModel: FeedViewModel(storage: database) ) ) diff --git a/MacMagazine/WatchApp/ViewModel/FeedRootViewModel.swift b/MacMagazine/WatchApp/ViewModel/FeedRootViewModel.swift index 2e287cf9..97e7a344 100644 --- a/MacMagazine/WatchApp/ViewModel/FeedRootViewModel.swift +++ b/MacMagazine/WatchApp/ViewModel/FeedRootViewModel.swift @@ -6,7 +6,7 @@ import SwiftData import WatchKit @MainActor -final class FeedRootViewModel: ObservableObject { +final class FeedMainViewModel: ObservableObject { // MARK: - Published @@ -20,9 +20,9 @@ final class FeedRootViewModel: ObservableObject { // MARK: - Private private let feedViewModel: FeedViewModel + private var didLoadInitial: Bool = false // MARK: - Init - init(feedViewModel: FeedViewModel) { self.feedViewModel = feedViewModel status = feedViewModel.status @@ -30,7 +30,10 @@ final class FeedRootViewModel: ObservableObject { // MARK: - Public API - func loadInitial(hasItems: Bool, modelContext: ModelContext) async { + func loadInitialIfNeeded(hasItems: Bool, modelContext: ModelContext) async { + guard !didLoadInitial else { return } + didLoadInitial = true + if hasItems { status = .done return @@ -40,6 +43,7 @@ final class FeedRootViewModel: ObservableObject { } func refresh(modelContext: ModelContext) async { + guard !isRefreshing else { return } isRefreshing = true defer { diff --git a/MacMagazine/WatchApp/Views/FeedDetailView.swift b/MacMagazine/WatchApp/Views/FeedDetailView.swift index 75c8d7b5..951a8f89 100644 --- a/MacMagazine/WatchApp/Views/FeedDetailView.swift +++ b/MacMagazine/WatchApp/Views/FeedDetailView.swift @@ -9,11 +9,11 @@ struct FeedDetailView: View { let post: FeedDB - @StateObject private var viewModel: FeedRootViewModel + @StateObject private var viewModel: FeedMainViewModel // MARK: - Init - init(viewModel: FeedRootViewModel, post: FeedDB) { + init(viewModel: FeedMainViewModel, post: FeedDB) { self.post = post _viewModel = StateObject(wrappedValue: viewModel) } diff --git a/MacMagazine/WatchApp/Views/FeedMainView.swift b/MacMagazine/WatchApp/Views/FeedMainView.swift index 215e3060..91f95c0d 100644 --- a/MacMagazine/WatchApp/Views/FeedMainView.swift +++ b/MacMagazine/WatchApp/Views/FeedMainView.swift @@ -10,8 +10,17 @@ struct FeedMainView: View { @Environment(\.modelContext) private var modelContext - @Query(sort: \FeedDB.pubDate, order: .reverse) - private var items: [FeedDB] + @Query private var items: [FeedDB] + + init(viewModel: FeedMainViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\FeedDB.pubDate, order: .reverse)] + ) + descriptor.fetchLimit = 10 + _items = Query(descriptor) + } // MARK: - Preview Guard @@ -21,13 +30,7 @@ struct FeedMainView: View { // MARK: - State - @StateObject private var viewModel: FeedRootViewModel - - // MARK: - Init - - init(viewModel: FeedRootViewModel) { - _viewModel = StateObject(wrappedValue: viewModel) - } + @StateObject private var viewModel: FeedMainViewModel // MARK: - Body @@ -37,7 +40,7 @@ struct FeedMainView: View { .navigationBarTitleDisplayMode(.inline) .task(id: items.count) { guard !isRunningForPreviews else { return } - await viewModel.loadInitial( + await viewModel.loadInitialIfNeeded( hasItems: !items.isEmpty, modelContext: modelContext ) @@ -55,7 +58,7 @@ struct FeedMainView: View { switch viewModel.status { case .loading: loadingView - .navigationTitle { navigationTitle() } + .navigationTitle { navigationTitle("MacMagazine") } case .error(let reason): errorScreen(reason: reason) @@ -66,13 +69,8 @@ struct FeedMainView: View { } else { carouselRowScreen(items: items) .navigationTitle { - Text("MacMagazine\n\(viewModel.selectedIndex + 1) de \(items.count)") - .font(.caption) - .frame(alignment: .trailing) - .multilineTextAlignment(.trailing) - .lineLimit(2) + navigationTitle("MacMagazine\n\(viewModel.selectedIndex + 1) de \(items.count)") .offset(y: 14) - .opacity(viewModel.isRefreshing ? 0 : 1) } } } @@ -81,10 +79,13 @@ struct FeedMainView: View { // MARK: - Navigation Title @ViewBuilder - private func navigationTitle() -> some View { - Text("MacMagazine") + private func navigationTitle(_ title: String) -> some View { + Text(title) .font(.caption) .opacity(viewModel.isRefreshing ? 0 : 1) + .frame(alignment: .trailing) + .multilineTextAlignment(.trailing) + .lineLimit(2) } // MARK: - Loading @@ -206,23 +207,6 @@ struct FeedMainView: View { viewModel.selectedIndex = newIndex } } - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button { - Task { - await viewModel.refresh(modelContext: modelContext) - } - } label: { - Image(systemName: "arrow.clockwise") - } - .glassEffect(.clear) - .opacity(viewModel.isRefreshing ? 0.5 : 1.0) - .disabled(viewModel.isRefreshing) - } - } - .refreshable { - await viewModel.refresh() - } FeedDotsIndicatorView( count: items.count, @@ -246,11 +230,8 @@ struct FeedMainView: View { // MARK: - Context Menu Sheet private func contextMenuSheet(items: [FeedDB]) -> some View { - let post = currentPost(items: items) - return ScrollView { VStack(spacing: 12) { - // Atualizar posts Button { viewModel.showContextMenu = false Task { @@ -260,36 +241,7 @@ struct FeedMainView: View { Label("Atualizar posts", systemImage: "arrow.clockwise") .frame(maxWidth: .infinity, alignment: .leading) } - .buttonStyle(.plain) - - if let post = post { - Divider() - - // Favoritar post - Button { - viewModel.showContextMenu = false - viewModel.toggleFavorite(post: post, modelContext: modelContext) - } label: { - Label( - post.favorite ? "Remover favorito" : "Favoritar post", - systemImage: post.favorite ? "star.slash" : "star" - ) - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.plain) - - Divider() - - // Ler post - Button { - viewModel.showContextMenu = false - viewModel.selectedPostForDetail = SelectedPost(post: post) - } label: { - Label("Ler post", systemImage: "doc.text") - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.plain) - } + .glassEffect(.clear) } .padding(.horizontal, 16) .padding(.vertical, 8) @@ -328,11 +280,11 @@ struct FeedMainView: View { // MARK: - Preview Support #if DEBUG -extension FeedRootViewModel { - static func preview(status: FeedViewModel.Status) -> FeedRootViewModel { +extension FeedMainViewModel { + static func preview(status: FeedViewModel.Status) -> FeedMainViewModel { let database = Database(models: [FeedDB.self], inMemory: true) let feedVM = FeedViewModel(storage: database) - let viewModel = FeedRootViewModel(feedViewModel: feedVM) + let viewModel = FeedMainViewModel(feedViewModel: feedVM) viewModel.setStatusForPreview(status) @@ -342,9 +294,9 @@ extension FeedRootViewModel { #endif #if DEBUG -private struct FeedRootPreviewHost: View { +struct FeedRootPreviewHost: View { let container: ModelContainer - let viewModel: FeedRootViewModel + let viewModel: FeedMainViewModel init( status: FeedViewModel.Status, @@ -371,7 +323,7 @@ private struct FeedRootPreviewHost: View { } var body: some View { - FeedRootView(viewModel: viewModel) + FeedMainView(viewModel: viewModel) .modelContainer(container) } } diff --git a/MacMagazine/WatchApp/Views/Row/FeedRowView.swift b/MacMagazine/WatchApp/Views/Row/FeedRowView.swift index 5a7f26c4..a163a1be 100644 --- a/MacMagazine/WatchApp/Views/Row/FeedRowView.swift +++ b/MacMagazine/WatchApp/Views/Row/FeedRowView.swift @@ -77,4 +77,8 @@ struct FeedRowView: View { #Preview("FeedRowView") { FeedRowView(post: .previewItem) } + +#Preview("Feed • Done (com registros)") { + FeedRootPreviewHost(status: .done, seedItems: true) +} #endif From 1c1ad225fcc956ce53f6ad6740a7740f17cad35a Mon Sep 17 00:00:00 2001 From: Renato Ferraz Date: Thu, 18 Dec 2025 14:07:25 -0300 Subject: [PATCH 3/4] Updates context menu in Watch app Simplifies the context menu structure for a clearer and more efficient user experience on the watchOS app. Removes unnecessary ScrollView and VStack nesting to streamline the layout. --- MacMagazine/WatchApp/Views/FeedMainView.swift | 56 +++++++++---------- 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/MacMagazine/WatchApp/Views/FeedMainView.swift b/MacMagazine/WatchApp/Views/FeedMainView.swift index 91f95c0d..84fc0a57 100644 --- a/MacMagazine/WatchApp/Views/FeedMainView.swift +++ b/MacMagazine/WatchApp/Views/FeedMainView.swift @@ -56,23 +56,23 @@ struct FeedMainView: View { @ViewBuilder private var rootContent: some View { switch viewModel.status { - case .loading: - loadingView - .navigationTitle { navigationTitle("MacMagazine") } - - case .error(let reason): - errorScreen(reason: reason) - - case .done: - if items.isEmpty { - emptyScreen - } else { - carouselRowScreen(items: items) - .navigationTitle { - navigationTitle("MacMagazine\n\(viewModel.selectedIndex + 1) de \(items.count)") - .offset(y: 14) - } - } + case .loading: + loadingView + .navigationTitle { navigationTitle("MacMagazine") } + + case .error(let reason): + errorScreen(reason: reason) + + case .done: + if items.isEmpty { + emptyScreen + } else { + carouselRowScreen(items: items) + .navigationTitle { + navigationTitle("MacMagazine\n\(viewModel.selectedIndex + 1) de \(items.count)") + .offset(y: 14) + } + } } } @@ -230,21 +230,17 @@ struct FeedMainView: View { // MARK: - Context Menu Sheet private func contextMenuSheet(items: [FeedDB]) -> some View { - return ScrollView { - VStack(spacing: 12) { - Button { - viewModel.showContextMenu = false - Task { - await viewModel.refresh(modelContext: modelContext) - } - } label: { - Label("Atualizar posts", systemImage: "arrow.clockwise") - .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .center, spacing: 12) { + Button { + viewModel.showContextMenu = false + Task { + await viewModel.refresh(modelContext: modelContext) } - .glassEffect(.clear) + } label: { + Label("Atualizar posts", systemImage: "arrow.clockwise") + .frame(maxWidth: .infinity, alignment: .leading) } - .padding(.horizontal, 16) - .padding(.vertical, 8) + .glassEffect(.clear) } } From 1f8eddbf25f4a5e98599c9c0c0b50e2e11ed513c Mon Sep 17 00:00:00 2001 From: Renato Ferraz Date: Thu, 18 Dec 2025 14:13:13 -0300 Subject: [PATCH 4/4] Updates layout based on loading state Refactors the `FeedMainView` to improve the layout based on the current loading state. This change enhances the user experience by providing a more consistent and predictable interface during data loading. --- MacMagazine/WatchApp/Views/FeedMainView.swift | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/MacMagazine/WatchApp/Views/FeedMainView.swift b/MacMagazine/WatchApp/Views/FeedMainView.swift index 84fc0a57..2d6bb905 100644 --- a/MacMagazine/WatchApp/Views/FeedMainView.swift +++ b/MacMagazine/WatchApp/Views/FeedMainView.swift @@ -56,23 +56,23 @@ struct FeedMainView: View { @ViewBuilder private var rootContent: some View { switch viewModel.status { - case .loading: - loadingView - .navigationTitle { navigationTitle("MacMagazine") } - - case .error(let reason): - errorScreen(reason: reason) - - case .done: - if items.isEmpty { - emptyScreen - } else { - carouselRowScreen(items: items) - .navigationTitle { - navigationTitle("MacMagazine\n\(viewModel.selectedIndex + 1) de \(items.count)") - .offset(y: 14) - } - } + case .loading: + loadingView + .navigationTitle { navigationTitle("MacMagazine") } + + case .error(let reason): + errorScreen(reason: reason) + + case .done: + if items.isEmpty { + emptyScreen + } else { + carouselRowScreen(items: items) + .navigationTitle { + navigationTitle("MacMagazine\n\(viewModel.selectedIndex + 1) de \(items.count)") + .offset(y: 14) + } + } } }