From 241622bbbdd0f90d3abff5c97bfcecc046b3aae9 Mon Sep 17 00:00:00 2001 From: Renato Ferraz Date: Wed, 17 Dec 2025 16:22:47 -0300 Subject: [PATCH 1/3] Adds Watch app with full screen feed Adds a Watch app target with initial feed display. The Watch app fetches and displays MacMagazine news feed. Includes support for full-screen article presentation with basic actions and navigation. Implements UI elements like carousels, indicators and detail views. --- .../Podcast/Views/Player/MiniPlayerView.swift | 5 +- MacMagazine/WatchApp/Extension/FeedDB.swift | 66 ++++++ .../Helper/FeedDotsIndicatorView.swift | 65 ++++++ .../WatchApp/Helper/FlowTagsView.swift | 106 ++++++++++ MacMagazine/WatchApp/Model/SelectedPost.swift | 14 ++ MacMagazine/WatchApp/Resources/WatchApp.swift | 22 ++ .../ViewModel/FeedRootViewModel.swift | 151 ++++++++++++++ .../ViewModel/FeedScrollPositionKey.swift | 36 ++++ .../WatchApp/Views/FeedDetailView.swift | 109 ++++++++++ MacMagazine/WatchApp/Views/FeedRootView.swift | 197 ++++++++++++++++++ .../Views/Row/FeedFullScreenRowView.swift | 84 ++++++++ .../WatchApp/Views/Row/FeedRowView.swift | 113 ++++++++++ MacMagazine/WatchApp/WatchApp.swift | 10 - 13 files changed, 967 insertions(+), 11 deletions(-) create mode 100644 MacMagazine/WatchApp/Extension/FeedDB.swift create mode 100644 MacMagazine/WatchApp/Helper/FeedDotsIndicatorView.swift create mode 100644 MacMagazine/WatchApp/Helper/FlowTagsView.swift create mode 100644 MacMagazine/WatchApp/Model/SelectedPost.swift create mode 100644 MacMagazine/WatchApp/Resources/WatchApp.swift create mode 100644 MacMagazine/WatchApp/ViewModel/FeedRootViewModel.swift create mode 100644 MacMagazine/WatchApp/ViewModel/FeedScrollPositionKey.swift create mode 100644 MacMagazine/WatchApp/Views/FeedDetailView.swift create mode 100644 MacMagazine/WatchApp/Views/FeedRootView.swift create mode 100644 MacMagazine/WatchApp/Views/Row/FeedFullScreenRowView.swift create mode 100644 MacMagazine/WatchApp/Views/Row/FeedRowView.swift delete mode 100644 MacMagazine/WatchApp/WatchApp.swift diff --git a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/MiniPlayerView.swift b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/MiniPlayerView.swift index eb167ba0..7f8be826 100644 --- a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/MiniPlayerView.swift +++ b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/MiniPlayerView.swift @@ -66,6 +66,7 @@ private extension MiniPlayerView { Image(systemName: "gobackward.15") .font(.system(size: 20)) } + .buttonStyle(.plain) Button { playerManager.togglePlayPause() @@ -74,6 +75,7 @@ private extension MiniPlayerView { Image(systemName: playerManager.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 24)) } + .buttonStyle(.plain) Button { playerManager.skip(by: 15) @@ -81,11 +83,12 @@ private extension MiniPlayerView { Image(systemName: "goforward.15") .font(.system(size: 20)) } + .buttonStyle(.plain) + } } .contentShape(Rectangle()) } - .foregroundColor(.primary) } @ViewBuilder diff --git a/MacMagazine/WatchApp/Extension/FeedDB.swift b/MacMagazine/WatchApp/Extension/FeedDB.swift new file mode 100644 index 00000000..c0229389 --- /dev/null +++ b/MacMagazine/WatchApp/Extension/FeedDB.swift @@ -0,0 +1,66 @@ +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) + } + + 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 + ) + } + } +} diff --git a/MacMagazine/WatchApp/Helper/FeedDotsIndicatorView.swift b/MacMagazine/WatchApp/Helper/FeedDotsIndicatorView.swift new file mode 100644 index 00000000..0eae6206 --- /dev/null +++ b/MacMagazine/WatchApp/Helper/FeedDotsIndicatorView.swift @@ -0,0 +1,65 @@ +import SwiftUI + +enum IndicatorAxis { + case horizontal + case vertical +} + +// MARK: - Dots Indicator + +public struct FeedDotsIndicatorView: View { + let axis: IndicatorAxis + let count: Int + let selectedIndex: Int + + public var body: some View { + Group { + switch axis { + case .vertical: + VStack(spacing: 5) { + dots + } + + case .horizontal: + HStack(spacing: 6) { + 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 + } +} + +#Preview("Vertical") { + ZStack { + Color.gray + FeedDotsIndicatorView(axis: .vertical, count: 10, selectedIndex: 3) + } +} + +#Preview("Horizontal") { + ZStack { + Color.gray + FeedDotsIndicatorView(axis: .horizontal, count: 5, selectedIndex: 2) + } +} 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/Model/SelectedPost.swift b/MacMagazine/WatchApp/Model/SelectedPost.swift new file mode 100644 index 00000000..0acec746 --- /dev/null +++ b/MacMagazine/WatchApp/Model/SelectedPost.swift @@ -0,0 +1,14 @@ +import SwiftUI +import FeedLibrary + +// 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/Resources/WatchApp.swift b/MacMagazine/WatchApp/Resources/WatchApp.swift new file mode 100644 index 00000000..19eaa295 --- /dev/null +++ b/MacMagazine/WatchApp/Resources/WatchApp.swift @@ -0,0 +1,22 @@ +import FeedLibrary +import StorageLibrary +import SwiftUI + +@main +struct WatchApp: App { + + private let database = Database(models: [FeedDB.self], inMemory: false) + + var body: some Scene { + WindowGroup { + FeedRootView( + viewModel: FeedRootViewModel( + feedViewModel: FeedViewModel( + network: nil, + storage: database + ) + ) + ) + } + } +} diff --git a/MacMagazine/WatchApp/ViewModel/FeedRootViewModel.swift b/MacMagazine/WatchApp/ViewModel/FeedRootViewModel.swift new file mode 100644 index 00000000..567fe84a --- /dev/null +++ b/MacMagazine/WatchApp/ViewModel/FeedRootViewModel.swift @@ -0,0 +1,151 @@ +import Combine +import FeedLibrary +import Foundation +import StorageLibrary +import SwiftData +import WatchKit + +@MainActor +final class FeedRootViewModel: ObservableObject { + + @Published private(set) var items: [FeedDB] = [] + @Published private(set) var status: FeedViewModel.Status = .loading + @Published var selectedIndex: Int = 0 + @Published var showActions: Bool = false + @Published var selectedPostForDetail: SelectedPost? + + private let feedViewModel: FeedViewModel + + init(feedViewModel: FeedViewModel) { + self.feedViewModel = feedViewModel + self.status = feedViewModel.status + } + + // MARK: - Public API + + func loadInitial() async { + await loadFromDB() + + if items.isEmpty { + await refresh() + } else { + status = feedViewModel.status + } + } + + func refresh() async { + _ = try? await feedViewModel.getWatchFeed() + status = feedViewModel.status + await loadFromDB() + } + + // MARK: - Private + + private func loadFromDB() async { + let context = feedViewModel.context + + let sort: [SortDescriptor] = [ + SortDescriptor(\FeedDB.pubDate, order: .reverse) + ] + + let descriptor = FetchDescriptor(sortBy: sort) + + do { + items = try context.fetch(descriptor) + } catch { + items = [] + } + + #if DEBUG + if let item = items.first { + debugPrint("item.title:", item.title) + debugPrint("item.pubDate:", item.pubDate) + debugPrint("item.artworkURL:", item.artworkURL) + debugPrint("item.link:", item.link) + } + #endif + } + + 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 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 clampIndex(_ index: Int, count: Int) -> Int { + guard count > 0 else { return 0 } + return min(max(index, 0), count - 1) + } + + func toggleActions() { + showActions.toggle() + } + + func hideActions() { + showActions = false + } + + func toggleFavorite(post: FeedDB) { + let context = feedViewModel.context + post.favorite.toggle() + + do { + try context.save() + } catch { + #if DEBUG + debugPrint("Failed to save favorite:", error.localizedDescription) + #endif + } + + showActions = true + } +} + +// MARK: - Preview Support + +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.items = FeedDB.previewItems + viewModel.status = .done + + return viewModel + } +} 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..9be91d6d --- /dev/null +++ b/MacMagazine/WatchApp/Views/FeedDetailView.swift @@ -0,0 +1,109 @@ +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 + +#Preview { + FeedDetailView(viewModel: .preview(), post: .previewItem) +} diff --git a/MacMagazine/WatchApp/Views/FeedRootView.swift b/MacMagazine/WatchApp/Views/FeedRootView.swift new file mode 100644 index 00000000..7d8616e2 --- /dev/null +++ b/MacMagazine/WatchApp/Views/FeedRootView.swift @@ -0,0 +1,197 @@ +import FeedLibrary +import SwiftUI +import WatchKit + +struct FeedRootView: View { + + // 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 viewModel.items.isEmpty { + Text("MacMagazine") + .font(.system(size: 12)) + } + + Text("MacMagazine\n\(viewModel.selectedIndex + 1) de \(viewModel.items.count)") + .font(.system(size: 12)) + .frame(alignment: .trailing) + .multilineTextAlignment(.trailing) + .lineLimit(2) + .offset(y: 14) + } + .refreshable { + await viewModel.refresh() + } + .task { + if viewModel.items.isEmpty { + await viewModel.loadInitial() + } + } + .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 viewModel.items.isEmpty { + emptyView + } else { + carouselFullScreen(items: viewModel.items) + } + } + } + + // MARK: - Full Screen Carousel + + private func carouselFullScreen(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 + FeedFullScreenRowView(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( + axis: .vertical, + 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 { + VStack(spacing: 8) { + Text("Sem itens") + .font(.headline) + + Text("Puxe para atualizar.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } +} + +// MARK: - Preview + +#Preview("Carousel Full Screen") { + FeedRootView(viewModel: .preview()) +} diff --git a/MacMagazine/WatchApp/Views/Row/FeedFullScreenRowView.swift b/MacMagazine/WatchApp/Views/Row/FeedFullScreenRowView.swift new file mode 100644 index 00000000..73c1f776 --- /dev/null +++ b/MacMagazine/WatchApp/Views/Row/FeedFullScreenRowView.swift @@ -0,0 +1,84 @@ +import FeedLibrary +import SwiftUI + +struct FeedFullScreenRowView: 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("FeedFullScreenRowView") { + FeedFullScreenRowView(post: .previewItem) +} diff --git a/MacMagazine/WatchApp/Views/Row/FeedRowView.swift b/MacMagazine/WatchApp/Views/Row/FeedRowView.swift new file mode 100644 index 00000000..e1916af4 --- /dev/null +++ b/MacMagazine/WatchApp/Views/Row/FeedRowView.swift @@ -0,0 +1,113 @@ +import FeedLibrary +import SwiftUI + +struct FeedRowView: View { + let post: FeedDB + + var body: some View { + GeometryReader { proxy in + ZStack(alignment: .bottomLeading) { + backgroundImage(proxy: proxy) + overlayGradient + content + } + .frame(width: proxy.size.width, height: rowHeight) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + } + .frame(height: rowHeight) + .padding(.horizontal, 6) + } + + // MARK: - Layout + + private var rowHeight: CGFloat { + WKInterfaceDevice.current().screenBounds.height * 0.60 + } + + // MARK: - Background + + @ViewBuilder + private func backgroundImage(proxy: GeometryProxy) -> some View { + if let url = post.artworkRemoteURL { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + .frame(width: proxy.size.width, height: rowHeight + parallaxExtraHeight) + .offset(y: parallaxOffset(proxy: proxy)) + .clipped() + default: + fallbackBackground + } + } + } else { + fallbackBackground + } + } + + private var fallbackBackground: some View { + Color.black.opacity(0.25) + } + + private var parallaxExtraHeight: CGFloat { + 70 + } + + private func parallaxOffset(proxy: GeometryProxy) -> CGFloat { + let screen = WKInterfaceDevice.current().screenBounds + let screenMidY = screen.midY + let cardMidY = proxy.frame(in: .global).midY + + let distance = cardMidY - screenMidY + let maxDistance = screen.height + + let progress = distance / maxDistance + let amplitude: CGFloat = 28 + + return -progress * amplitude + } + + // MARK: - Overlay + + private var overlayGradient: some View { + LinearGradient( + colors: [ + .black.opacity(0.10), + .black.opacity(0.75) + ], + startPoint: .top, + endPoint: .bottom + ) + } + + // MARK: - Content + + private var content: some View { + VStack(spacing: 10) { + Text(post.title) + .font(.system(size: 16)) + .bold() + .foregroundStyle(.white) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(3) + + Text(post.dateText) + .font(.system(size: 10)) + .foregroundStyle(.white.opacity(0.85)) + .frame(maxWidth: .infinity, alignment: .center) + .lineLimit(1) + } + .padding(.horizontal, 8) + .padding(.bottom, 40) + } +} + +#Preview("FeedRowView - Watch") { + FeedRowView(post: .previewItem) +} + +#Preview { + FeedRootView(viewModel: .preview()) +} 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() - } - } -} From 2bc2b2ac631e4a0d1e298bc430d0253674e3e13b Mon Sep 17 00:00:00 2001 From: Renato Ferraz Date: Wed, 17 Dec 2025 18:05:31 -0300 Subject: [PATCH 2/3] Adapts mini player color to color scheme Ensures that the mini player's controls color adapts correctly to the user's selected color scheme, providing better visual contrast in both light and dark modes. --- .../Modifiers/PodcastMiniPlayerModifier.swift | 7 +++++-- .../Podcast/Views/Player/MiniPlayerView.swift | 17 ++++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Modifiers/PodcastMiniPlayerModifier.swift b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Modifiers/PodcastMiniPlayerModifier.swift index 7bacb8fe..98b35384 100644 --- a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Modifiers/PodcastMiniPlayerModifier.swift +++ b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Modifiers/PodcastMiniPlayerModifier.swift @@ -11,6 +11,7 @@ public extension View { private struct PodcastMiniPlayerModifier: ViewModifier { @Environment(PodcastPlayerManager.self) private var manager @Environment(\.shouldUseSidebar) private var shouldUseSidebar + @Environment(\.colorScheme) private var appColorScheme @Namespace private var animation public init() {} @@ -22,7 +23,8 @@ private struct PodcastMiniPlayerModifier: ViewModifier { .safeAreaInset(edge: .bottom, spacing: 16) { MiniPlayerView( playerManager: manager, - currentPodcast: current + currentPodcast: current, + appColorScheme: appColorScheme ) .frame(maxWidth: 480, alignment: .center) .frame(height: 60) @@ -35,7 +37,8 @@ private struct PodcastMiniPlayerModifier: ViewModifier { .tabViewBottomAccessory { MiniPlayerView( playerManager: manager, - currentPodcast: current + currentPodcast: current, + appColorScheme: appColorScheme ) .matchedTransitionSource(id: "MINIPLAYER", in: animation) .padding(.horizontal, 8) diff --git a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/MiniPlayerView.swift b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/MiniPlayerView.swift index 7f8be826..47f6e3a4 100644 --- a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/MiniPlayerView.swift +++ b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/MiniPlayerView.swift @@ -10,13 +10,27 @@ struct MiniPlayerView: View { @Bindable var playerManager: PodcastPlayerManager let currentPodcast: PodcastDB + let appColorScheme: ColorScheme + + private var controlsColor: Color { + switch appColorScheme { + case .dark: + return .white + case .light: + return .black + @unknown default: + return .primary + } + } init( playerManager: PodcastPlayerManager, - currentPodcast: PodcastDB + currentPodcast: PodcastDB, + appColorScheme: ColorScheme ) { self.playerManager = playerManager self.currentPodcast = currentPodcast + self.appColorScheme = appColorScheme } var body: some View { @@ -87,6 +101,7 @@ private extension MiniPlayerView { } } + .foregroundStyle(controlsColor) .contentShape(Rectangle()) } } From a2e632c5adfec84acf060ea13389d20dd42e4994 Mon Sep 17 00:00:00 2001 From: Renato Ferraz Date: Wed, 17 Dec 2025 18:12:09 -0300 Subject: [PATCH 3/3] Reorders imports Moves SwiftUI import below FeedLibrary for better organization. --- MacMagazine/WatchApp/Model/SelectedPost.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MacMagazine/WatchApp/Model/SelectedPost.swift b/MacMagazine/WatchApp/Model/SelectedPost.swift index 0acec746..ef22fa05 100644 --- a/MacMagazine/WatchApp/Model/SelectedPost.swift +++ b/MacMagazine/WatchApp/Model/SelectedPost.swift @@ -1,5 +1,5 @@ -import SwiftUI import FeedLibrary +import SwiftUI // MARK: - Navigation Payload