From 241622bbbdd0f90d3abff5c97bfcecc046b3aae9 Mon Sep 17 00:00:00 2001 From: Renato Ferraz Date: Wed, 17 Dec 2025 16:22:47 -0300 Subject: [PATCH 1/2] 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 9d3ba88dc9ac415123f113af343034d9d5d29488 Mon Sep 17 00:00:00 2001 From: Renato Ferraz Date: Wed, 17 Dec 2025 17:34:15 -0300 Subject: [PATCH 2/2] Adapts mini player controls color Ensures mini player controls color adapts to the app's color scheme to improve visibility in both light and dark modes. --- .../Modifiers/PodcastMiniPlayerModifier.swift | 7 +++++-- .../Podcast/Views/Player/MiniPlayerView.swift | 17 ++++++++++++++++- .../MacMagazine.xcodeproj/project.pbxproj | 6 ++++-- 3 files changed, 25 insertions(+), 5 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()) } } diff --git a/MacMagazine/MacMagazine.xcodeproj/project.pbxproj b/MacMagazine/MacMagazine.xcodeproj/project.pbxproj index b42af89b..bc153785 100644 --- a/MacMagazine/MacMagazine.xcodeproj/project.pbxproj +++ b/MacMagazine/MacMagazine.xcodeproj/project.pbxproj @@ -627,9 +627,10 @@ DEVELOPMENT_TEAM = A5VW9QUF9L; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = WatchkApp; + INFOPLIST_KEY_CFBundleDisplayName = MacMagazine; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.brit.beta.macmagazine; + INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -661,9 +662,10 @@ DEVELOPMENT_TEAM = A5VW9QUF9L; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = WatchkApp; + INFOPLIST_KEY_CFBundleDisplayName = MacMagazine; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.brit.beta.macmagazine; + INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks",