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/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 14285344..97e7a344 100644 --- a/MacMagazine/WatchApp/ViewModel/FeedRootViewModel.swift +++ b/MacMagazine/WatchApp/ViewModel/FeedRootViewModel.swift @@ -6,21 +6,23 @@ import SwiftData import WatchKit @MainActor -final class FeedRootViewModel: ObservableObject { +final class FeedMainViewModel: ObservableObject { // MARK: - Published @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 private let feedViewModel: FeedViewModel + private var didLoadInitial: Bool = false // MARK: - Init - init(feedViewModel: FeedViewModel) { self.feedViewModel = feedViewModel status = feedViewModel.status @@ -28,27 +30,37 @@ final class FeedRootViewModel: ObservableObject { // MARK: - Public API - func refresh() async { - _ = try? await feedViewModel.getWatchFeed() - status = feedViewModel.status - } + func loadInitialIfNeeded(hasItems: Bool, modelContext: ModelContext) async { + guard !didLoadInitial else { return } + didLoadInitial = true - func toggleActions() { - showActions.toggle() + if hasItems { + status = .done + return + } + + await refresh(modelContext: modelContext) } - func hideActions() { - showActions = false + func refresh(modelContext: ModelContext) async { + guard !isRefreshing else { return } + 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 +83,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..951a8f89 100644 --- a/MacMagazine/WatchApp/Views/FeedDetailView.swift +++ b/MacMagazine/WatchApp/Views/FeedDetailView.swift @@ -3,15 +3,17 @@ import SwiftUI struct FeedDetailView: View { + @Environment(\.modelContext) private var modelContext + // MARK: - Properties 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) } @@ -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..2d6bb905 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 @@ -9,40 +10,40 @@ struct FeedMainView: View { @Environment(\.modelContext) private var modelContext - @Query(sort: \FeedDB.pubDate, order: .reverse) - private var items: [FeedDB] + @Query private var items: [FeedDB] - // MARK: - State + init(viewModel: FeedMainViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) - @StateObject private var viewModel: FeedRootViewModel + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\FeedDB.pubDate, order: .reverse)] + ) + descriptor.fetchLimit = 10 + _items = Query(descriptor) + } - // MARK: - Init + // MARK: - Preview Guard - init(viewModel: FeedRootViewModel) { - _viewModel = StateObject(wrappedValue: viewModel) + private var isRunningForPreviews: Bool { + ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" } + // MARK: - State + + @StateObject private var viewModel: FeedMainViewModel + // MARK: - Body var body: some 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.loadInitialIfNeeded( + hasItems: !items.isEmpty, + modelContext: modelContext + ) } .navigationDestination(item: $viewModel.selectedPostForDetail) { payload in FeedDetailView(viewModel: viewModel, post: payload.post) @@ -56,102 +57,211 @@ struct FeedMainView: View { private var rootContent: some View { switch viewModel.status { case .loading: - ProgressView("Carregando…") + loadingView + .navigationTitle { navigationTitle("MacMagazine") } 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 { + navigationTitle("MacMagazine\n\(viewModel.selectedIndex + 1) de \(items.count)") + .offset(y: 14) + } } } } + // MARK: - Navigation Title + + @ViewBuilder + 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 + + 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 private func carouselRowScreen(items: [FeedDB]) -> some 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) - } - .refreshable { - await viewModel.refresh() - } FeedDotsIndicatorView( 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 actionsOverlay(items: [FeedDB]) -> some View { - Group { - if viewModel.showActions, let post = currentPost(items: items) { - HStack(spacing: 10) { - Button { - viewModel.toggleFavorite(post: post) - } label: { - Image(systemName: post.favorite ? "star.fill" : "star") - } - .buttonStyle(.glass) - - Button("Ver mais") { - viewModel.selectedPostForDetail = SelectedPost(post: post) - } - .buttonStyle(.glass) + private func contextMenuSheet(items: [FeedDB]) -> some View { + VStack(alignment: .center, spacing: 12) { + Button { + viewModel.showContextMenu = false + Task { + await viewModel.refresh(modelContext: modelContext) } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - .padding(.bottom, 10) - .transition(.opacity) + } label: { + Label("Atualizar posts", systemImage: "arrow.clockwise") + .frame(maxWidth: .infinity, alignment: .leading) } + .glassEffect(.clear) } - .animation(.easeInOut(duration: 0.15), value: viewModel.showActions) + } + + // 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)) + ) + } + .transition(.opacity) } // MARK: - Helpers @@ -161,74 +271,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 FeedMainViewModel { + static func preview(status: FeedViewModel.Status) -> FeedMainViewModel { + let database = Database(models: [FeedDB.self], inMemory: true) + let feedVM = FeedViewModel(storage: database) + let viewModel = FeedMainViewModel(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 { - +struct FeedRootPreviewHost: View { let container: ModelContainer + let viewModel: FeedMainViewModel - 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()) + FeedMainView(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..a163a1be 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) } } @@ -83,4 +77,8 @@ struct FeedRowView: View { #Preview("FeedRowView") { FeedRowView(post: .previewItem) } + +#Preview("Feed • Done (com registros)") { + FeedRootPreviewHost(status: .done, seedItems: true) +} #endif