diff --git a/MacMagazine/WatchApp/ContentView.swift b/MacMagazine/WatchApp/ContentView.swift deleted file mode 100644 index 1df364ec..00000000 --- a/MacMagazine/WatchApp/ContentView.swift +++ /dev/null @@ -1,27 +0,0 @@ -import FeedLibrary -import StorageLibrary -import SwiftUI - -struct ContentView: View { - let viewModel = FeedViewModel( - storage: Database(models: [FeedDB.self], inMemory: true) - ) - - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() - .task { - let feed = try? await viewModel.getWatchFeed() - print(feed?.map { $0.title } ?? "") - } - } -} - -#Preview { - ContentView() -} diff --git a/MacMagazine/WatchApp/Extension/FeedDB.swift b/MacMagazine/WatchApp/Extension/FeedDB.swift new file mode 100644 index 00000000..56bc4161 --- /dev/null +++ b/MacMagazine/WatchApp/Extension/FeedDB.swift @@ -0,0 +1,71 @@ +import FeedLibrary +import Foundation + +extension FeedDB { + var linkURL: URL? { + guard !link.isEmpty else { return nil } + return URL(string: link) + } + + var dateText: String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "pt_BR") + formatter.dateFormat = "dd/MM/yyyy 'às' HH:mm" + return formatter.string(from: pubDate) + } + + var displaySubtitle: String? { + let subtitle = subtitle.trimmingCharacters(in: .whitespacesAndNewlines) + return subtitle.isEmpty ? nil : subtitle + } + + var displayBody: String? { + let full = fullContent.trimmingCharacters(in: .whitespacesAndNewlines) + if !full.isEmpty { return full } + + let excerpt = excerpt.trimmingCharacters(in: .whitespacesAndNewlines) + return excerpt.isEmpty ? nil : excerpt + } + + var artworkRemoteURL: URL? { + guard !artworkURL.isEmpty else { return nil } + return URL(string: artworkURL) + } +} + +#if DEBUG +extension FeedDB { + + static var previewItem: FeedDB { + FeedDB( + postId: UUID().uuidString, + title: "Apple lança atualização do watchOS", + subtitle: "Mudanças importantes para o Apple Watch", + pubDate: Date().addingTimeInterval(-3600), + artworkURL: "https://picsum.photos/400/400", + link: "https://macmagazine.com.br", + categories: ["watchos", "news", "teste1", "teste2", "teste 3"], + excerpt: "Resumo curto para teste no relógio…", + fullContent: "", + favorite: false + ) + } + + static var previewItems: [FeedDB] { + (1...10).map { index in + FeedDB( + postId: UUID().uuidString, + title: "Notícia \(index): título de teste para o Watch", + subtitle: "Subtítulo \(index)", + pubDate: Date().addingTimeInterval(TimeInterval(-index * 900)), + artworkURL: "https://picsum.photos/seed/\(index)/600/600", + link: "https://macmagazine.com.br", + categories: ["news", "teste1", "teste2", "teste 3"], + excerpt: "Excerpt \(index) – texto curto para validar layout.", + fullContent: "", + favorite: index % 3 == 0 + ) + } + } +} +#endif diff --git a/MacMagazine/WatchApp/Helper/FeedDotsIndicatorView.swift b/MacMagazine/WatchApp/Helper/FeedDotsIndicatorView.swift new file mode 100644 index 00000000..7fd54e98 --- /dev/null +++ b/MacMagazine/WatchApp/Helper/FeedDotsIndicatorView.swift @@ -0,0 +1,44 @@ +import SwiftUI + +// MARK: - Dots Indicator + +public struct FeedDotsIndicatorView: View { + let count: Int + let selectedIndex: Int + + public var body: some View { + VStack(spacing: 5) { + dots + } + .padding(6) + .glassEffect(.clear) + .allowsHitTesting(false) + } + + private var dots: some View { + ForEach(0.. Color { + index == selectedIndex ? .white : .white.opacity(0.4) + } + + private func dotSize(for index: Int) -> CGFloat { + index == selectedIndex ? 8 : 5 + } +} + +#if DEBUG +#Preview { + ZStack { + Color.gray + FeedDotsIndicatorView(count: 10, selectedIndex: 3) + } +} +#endif diff --git a/MacMagazine/WatchApp/Helper/FlowTagsView.swift b/MacMagazine/WatchApp/Helper/FlowTagsView.swift new file mode 100644 index 00000000..328d5a51 --- /dev/null +++ b/MacMagazine/WatchApp/Helper/FlowTagsView.swift @@ -0,0 +1,106 @@ +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 new file mode 100644 index 00000000..172484f2 --- /dev/null +++ b/MacMagazine/WatchApp/MainApp/WatchApp.swift @@ -0,0 +1,24 @@ +import FeedLibrary +import StorageLibrary +import SwiftData +import SwiftUI + +@main +struct WatchApp: App { + + private let database = Database(models: [FeedDB.self], inMemory: true) + + var body: some Scene { + WindowGroup { + FeedRootView( + viewModel: FeedRootViewModel( + feedViewModel: FeedViewModel( + network: nil, + storage: database + ) + ) + ) + .modelContainer(database.sharedModelContainer) + } + } +} diff --git a/MacMagazine/WatchApp/Model/SelectedPost.swift b/MacMagazine/WatchApp/Model/SelectedPost.swift new file mode 100644 index 00000000..ef22fa05 --- /dev/null +++ b/MacMagazine/WatchApp/Model/SelectedPost.swift @@ -0,0 +1,14 @@ +import FeedLibrary +import SwiftUI + +// MARK: - Navigation Payload + +struct SelectedPost: Hashable, Identifiable { + let id: String + let post: FeedDB + + init(post: FeedDB) { + id = post.postId + self.post = post + } +} diff --git a/MacMagazine/WatchApp/ViewModel/FeedRootViewModel.swift b/MacMagazine/WatchApp/ViewModel/FeedRootViewModel.swift new file mode 100644 index 00000000..cf526f11 --- /dev/null +++ b/MacMagazine/WatchApp/ViewModel/FeedRootViewModel.swift @@ -0,0 +1,123 @@ +import Combine +import FeedLibrary +import Foundation +import StorageLibrary +import SwiftData +import WatchKit + +@MainActor +final class FeedRootViewModel: ObservableObject { + + // MARK: - Published + + @Published private(set) var status: FeedViewModel.Status = .loading + @Published var selectedIndex: Int = 0 + @Published var showActions: Bool = false + @Published var selectedPostForDetail: SelectedPost? + + // MARK: - Private + + private let feedViewModel: FeedViewModel + + // MARK: - Init + + init(feedViewModel: FeedViewModel) { + self.feedViewModel = feedViewModel + status = feedViewModel.status + } + + // MARK: - Public API + + func loadInitial(hasItems: Bool) async { + if !hasItems { + await refresh() + } else { + status = feedViewModel.status + } + } + + func refresh() async { + _ = try? await feedViewModel.getWatchFeed() + status = feedViewModel.status + } + + func toggleActions() { + showActions.toggle() + } + + func hideActions() { + showActions = false + } + + func toggleFavorite(post: FeedDB) { + let context = feedViewModel.context + post.favorite.toggle() + try? context.save() + showActions = true + } + + // MARK: - Index Calculation + + func computeSelectedIndexByMidY(items: [FeedDB], positions: [String: CGPoint]) -> Int { + guard !items.isEmpty else { return 0 } + + let screenMidY = WKInterfaceDevice.current().screenBounds.midY + + 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.y - screenMidY) + + if distance < bestDistance { + bestDistance = distance + bestIndex = index + } + } + + 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, count: Int) -> Int { + guard count > 0 else { return 0 } + return min(max(index, 0), count - 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 + } +} +#endif diff --git a/MacMagazine/WatchApp/ViewModel/FeedScrollPositionKey.swift b/MacMagazine/WatchApp/ViewModel/FeedScrollPositionKey.swift new file mode 100644 index 00000000..56c161f1 --- /dev/null +++ b/MacMagazine/WatchApp/ViewModel/FeedScrollPositionKey.swift @@ -0,0 +1,36 @@ +import SwiftUI +import WatchKit + +struct FeedScrollPositionKey: PreferenceKey { + static var defaultValue: [String: CGPoint] = [:] + + static func reduce(value: inout [String: CGPoint], nextValue: () -> [String: CGPoint]) { + value.merge(nextValue(), uniquingKeysWith: { $1 }) + } +} + +struct FeedRowPositionReporter: ViewModifier { + let postId: String + + func body(content: Content) -> some View { + content + .background { + GeometryReader { proxy in + let frame = proxy.frame(in: .global) + let point = CGPoint(x: frame.midX, y: frame.midY) + + Color.clear + .preference( + key: FeedScrollPositionKey.self, + value: [postId: point] + ) + } + } + } +} + +extension View { + func reportFeedRowPosition(postId: String) -> some View { + modifier(FeedRowPositionReporter(postId: postId)) + } +} diff --git a/MacMagazine/WatchApp/Views/FeedDetailView.swift b/MacMagazine/WatchApp/Views/FeedDetailView.swift new file mode 100644 index 00000000..7d707f5f --- /dev/null +++ b/MacMagazine/WatchApp/Views/FeedDetailView.swift @@ -0,0 +1,110 @@ +import FeedLibrary +import SwiftUI + +struct FeedDetailView: View { + + // MARK: - Properties + + let post: FeedDB + + @StateObject private var viewModel: FeedRootViewModel + + // MARK: - Init + + init(viewModel: FeedRootViewModel, post: FeedDB) { + self.post = post + _viewModel = StateObject(wrappedValue: viewModel) + } + + // MARK: - Body + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + header + categories + bodyText + footer + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .navigationTitle("Notícia") + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: - Header + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + Text(post.title) + .font(.system(size: 14)) + .bold() + .lineLimit(3) + + Text(post.dateText) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + + // 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(.system(size: 8, weight: .semibold)) + .foregroundStyle(.primary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.secondary.opacity(0.4)) + .clipShape(Capsule()) + } + .allowsHitTesting(false) + } + } + + // MARK: - Body + + @ViewBuilder + private var bodyText: some View { + if let content = post.displayBody { + Divider() + .padding(.vertical, 4) + + Text(content) + .font(.system(size: 12)) + .multilineTextAlignment(.leading) + } + } + + // MARK: - Footer + + private var footer: some View { + VStack(spacing: 8) { + Divider() + .padding(.top, 4) + + Button { + viewModel.toggleFavorite(post: post) + } label: { + Image(systemName: post.favorite ? "star.fill" : "star") + } + .buttonStyle(.glass) + } + } +} + +// MARK: - Preview +#if DEBUG +#Preview { + FeedDetailView(viewModel: .preview(), post: .previewItem) +} +#endif diff --git a/MacMagazine/WatchApp/Views/FeedRootView.swift b/MacMagazine/WatchApp/Views/FeedRootView.swift new file mode 100644 index 00000000..4f3b5a95 --- /dev/null +++ b/MacMagazine/WatchApp/Views/FeedRootView.swift @@ -0,0 +1,234 @@ +import FeedLibrary +import SwiftData +import SwiftUI +import WatchKit + +struct FeedRootView: View { + + // MARK: - SwiftData + + @Environment(\.modelContext) private var modelContext + + @Query(sort: \FeedDB.pubDate, order: .reverse) + private var items: [FeedDB] + + // MARK: - State + + @StateObject private var viewModel: FeedRootViewModel + + // MARK: - Init + + init(viewModel: FeedRootViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } + + // 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 \(items.count)") + .font(.system(size: 12)) + .frame(alignment: .trailing) + .multilineTextAlignment(.trailing) + .lineLimit(2) + .offset(y: 14) + } + .refreshable { + await viewModel.refresh() + } + .task { + await viewModel.loadInitial(hasItems: !items.isEmpty) + } + .navigationDestination(item: $viewModel.selectedPostForDetail) { payload in + FeedDetailView(viewModel: viewModel, post: payload.post) + } + } + } + + // MARK: - Root Content + + @ViewBuilder + private var rootContent: some View { + switch viewModel.status { + case .loading: + ProgressView("Carregando…") + + case .error(let reason): + errorView(reason: reason) + + case .done: + if items.isEmpty { + emptyView + } else { + carouselRowScreen(items: items) + } + } + } + + // 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) + } + } + .scrollTargetLayout() + } + .scrollTargetBehavior(.paging) + .scrollIndicators(.hidden) + .contentShape(Rectangle()) + .simultaneousGesture( + TapGesture().onEnded { + viewModel.toggleActions() + } + ) + .onPreferenceChange(FeedScrollPositionKey.self) { positions in + let newIndex = viewModel.computeSelectedIndexByMidY( + items: items, + positions: positions + ) + + if newIndex != viewModel.selectedIndex { + viewModel.selectedIndex = newIndex + viewModel.hideActions() + } + } + .overlay(alignment: .bottom) { + actionsOverlay(items: items) + } + + FeedDotsIndicatorView( + count: items.count, + selectedIndex: viewModel.selectedIndex + ) + .frame(maxHeight: .infinity, alignment: .center) + .padding(.leading, 6) + .offset(x: -6) + } + } + .ignoresSafeArea() + } + + // MARK: - Actions Overlay + + 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) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .padding(.bottom, 10) + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.15), value: viewModel.showActions) + } + + // MARK: - Helpers + + private func currentPost(items: [FeedDB]) -> FeedDB? { + guard !items.isEmpty else { return nil } + let index = viewModel.clampIndex(viewModel.selectedIndex, count: 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) + + Button("Tentar novamente") { + Task { + await viewModel.refresh() + } + } + } + .padding() + } + + private var emptyView: some View { + ScrollView { + VStack(spacing: 8) { + Text("Sem itens") + .font(.headline) + + Text("Puxe para atualizar.") + .font(.footnote) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, minHeight: WKInterfaceDevice.current().screenBounds.height * 0.8) + } + } +} + +// MARK: - Preview + +#if DEBUG +private struct FeedRootPreviewHost: View { + + let container: ModelContainer + + init() { + let schema = Schema([FeedDB.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + + do { + container = try ModelContainer(for: schema, configurations: [config]) + + FeedDB.previewItems.forEach { item in + container.mainContext.insert(item) + } + + try container.mainContext.save() + } catch { + fatalError("Failed to create preview container: \(error)") + } + } + + var body: some View { + FeedRootView(viewModel: .preview()) + .modelContainer(container) + } +} + +#Preview("Carousel Full Screen") { + FeedRootPreviewHost() +} +#endif diff --git a/MacMagazine/WatchApp/Views/Row/FeedRowView.swift b/MacMagazine/WatchApp/Views/Row/FeedRowView.swift new file mode 100644 index 00000000..ec685b3b --- /dev/null +++ b/MacMagazine/WatchApp/Views/Row/FeedRowView.swift @@ -0,0 +1,84 @@ +import FeedLibrary +import SwiftUI + +struct FeedRowView: View { + let post: FeedDB + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .bottom) { + background(size: geometry.size) + overlayGradient + content + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private func background(size: CGSize) -> some View { + ZStack { + 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) + } + } + } else { + Color.black.opacity(0.25) + } + } + .frame(width: size.width, height: size.height) + } + + private var overlayGradient: some View { + LinearGradient( + colors: [ + .black.opacity(0.05), + .black.opacity(0.80) + ], + startPoint: .top, + endPoint: .bottom + ) + } + + private var content: some View { + VStack(spacing: 10) { + Text(post.title) + .font(.system(size: 14)) + .fontWeight(.bold) + .foregroundStyle(.white) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(3) + .padding(.leading, 20) + + Text(post.dateText) + .font(.system(size: 10)) + .foregroundStyle(.white.opacity(0.85)) + .frame(maxWidth: .infinity, alignment: .center) + .lineLimit(1) + } + .padding(.horizontal, 8) + .padding(.bottom, 8) + } +} + +#Preview("FeedRowView") { + FeedRowView(post: .previewItem) +} diff --git a/MacMagazine/WatchApp/WatchApp.swift b/MacMagazine/WatchApp/WatchApp.swift deleted file mode 100644 index 725406dc..00000000 --- a/MacMagazine/WatchApp/WatchApp.swift +++ /dev/null @@ -1,10 +0,0 @@ -import SwiftUI - -@main -struct WatchApp: App { - var body: some Scene { - WindowGroup { - ContentView() - } - } -}