diff --git a/apple/OmnivoreKit/Sources/App/Views/AI/DigestConfigView.swift b/apple/OmnivoreKit/Sources/App/Views/AI/DigestConfigView.swift index 7536b0073..98ee89d4e 100644 --- a/apple/OmnivoreKit/Sources/App/Views/AI/DigestConfigView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/AI/DigestConfigView.swift @@ -18,6 +18,9 @@ public class DigestConfigViewModel: ObservableObject { @Published var presentedLibraryItem: String? @Published var presentWebContainer = false + @Published var notificationsEnabled = false + @Published var isTryingToEnableNotifications = false + @AppStorage(UserDefaultKey.lastVisitedDigestId.rawValue) var lastVisitedDigestId = "" func checkAlreadyOptedIn(dataService: DataService) async { @@ -37,6 +40,7 @@ public class DigestConfigViewModel: ObservableObject { try await dataService.setupUserDigestConfig() try await dataService.refreshDigest() digestEnabled = true + dataService.featureFlags.digestEnabled = true } catch { if error is IneligibleError { isIneligible = true @@ -46,6 +50,25 @@ public class DigestConfigViewModel: ObservableObject { } isLoading = false } + + func tryEnableNotifications(dataService: DataService) { + isTryingToEnableNotifications = true + UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { granted, _ in + DispatchQueue.main.async { + self.notificationsEnabled = granted + UserDefaults.standard.set(granted, forKey: UserDefaultKey.notificationsEnabled.rawValue) + + Task { + if let savedToken = UserDefaults.standard.string(forKey: UserDefaultKey.firebasePushToken.rawValue) { + _ = try? await dataService.syncDeviceToken( + deviceTokenOperation: DeviceTokenOperation.addToken(token: savedToken)) + } + NotificationCenter.default.post(name: Notification.Name("ReconfigurePushNotifications"), object: nil) + self.isTryingToEnableNotifications = false + } + } + } + } } @available(iOS 17.0, *) @@ -87,8 +110,23 @@ struct DigestConfigView: View { } .padding(.top, 50) } else if viewModel.digestEnabled { - Text("You've been added to the AI Digest demo. You first issue should be ready soon.") - .padding(15) + VStack(spacing: 25) { + Spacer() + // swiftlint:disable:next line_length + Text("You've been added to the AI Digest demo. Your first issue should be ready soon. When a new digest is ready the icon in the library header will change color. You can close this window now.") + if !viewModel.notificationsEnabled { + if viewModel.isTryingToEnableNotifications { + ProgressView() + } else { + Button(action: { + viewModel.tryEnableNotifications(dataService: dataService) + }, label: { Text("Enable digest notifications") }) + .buttonStyle(RoundedRectButtonStyle(color: Color.blue, textColor: Color.white)) + } + } + Spacer() + } + .padding(20) } else if viewModel.isIneligible { Text("To enable digest you need to have saved at least ten library items and have two active subscriptions.") .padding(15) diff --git a/apple/OmnivoreKit/Sources/App/Views/AI/FullScreenDigestView.swift b/apple/OmnivoreKit/Sources/App/Views/AI/FullScreenDigestView.swift index dbaf2bd2a..94ea44818 100644 --- a/apple/OmnivoreKit/Sources/App/Views/AI/FullScreenDigestView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/AI/FullScreenDigestView.swift @@ -12,17 +12,19 @@ func getChapterData(digest: DigestResult) -> [(DigestChapter, DigestChapterData) var currentAudioIndex = 0 var currentWordCount = 0.0 - for (index, speechFile) in digest.speechFiles.enumerated() { - let chapter = digest.chapters[index] + for (index, speechFile) in (digest.speechFiles ?? []).enumerated() { + let chapter = digest.chapters?[index] let duration = currentWordCount / SpeechDocument.averageWPM / speed * 60.0 - chapterData.append((chapter, DigestChapterData( - time: formatTimeInterval(duration) ?? "00:00", - start: Int(currentAudioIndex), - end: currentAudioIndex + Int(speechFile.utterances.count) - ))) - currentAudioIndex += Int(speechFile.utterances.count) - currentWordCount += chapter.wordCount + if let chapter = chapter { + chapterData.append((chapter, DigestChapterData( + time: formatTimeInterval(duration) ?? "00:00", + start: Int(currentAudioIndex), + end: currentAudioIndex + Int(speechFile.utterances.count) + ))) + currentAudioIndex += Int(speechFile.utterances.count) + currentWordCount += chapter.wordCount + } } return chapterData } @@ -39,6 +41,7 @@ func formatTimeInterval(_ time: TimeInterval) -> String? { public class FullScreenDigestViewModel: ObservableObject { @Published var isLoading = false @Published var hasError = false + @Published var isRunning = false @Published var digest: DigestResult? @Published var chapterInfo: [(DigestChapter, DigestChapterData)]? @Published var presentedLibraryItem: String? @@ -49,6 +52,7 @@ public class FullScreenDigestViewModel: ObservableObject { func load(dataService: DataService, audioController: AudioController) async { hasError = false isLoading = true + isRunning = false if !dataService.digestNeedsRefresh() { if let digest = dataService.loadStoredDigest() { @@ -69,6 +73,8 @@ public class FullScreenDigestViewModel: ObservableObject { self.digest = digest self.chapterInfo = getChapterData(digest: digest) self.lastVisitedDigestId = digest.id + self.isRunning = digest.jobState == "RUNNING" || digest.jobState == "PENDING" + self.hasError = digest.jobState == "FAILED" if let playingDigest = audioController.itemAudioProperties as? DigestAudioItem, playingDigest.digest.id == digest.id { // Don't think we need to do anything here @@ -179,10 +185,11 @@ struct FullScreenDigestView: View { await viewModel.load(dataService: dataService, audioController: audioController) } }, label: { Text("Try again") }) - .buttonStyle(RoundedRectButtonStyle(color: Color.blue, textColor: Color.white)) - + .buttonStyle(RoundedRectButtonStyle(color: Color.blue, textColor: Color.white)) Spacer() } + } else if viewModel.isRunning { + jobRunningText } else { itemBody } @@ -193,6 +200,19 @@ struct FullScreenDigestView: View { await viewModel.load(dataService: dataService, audioController: audioController) } } + + var jobRunningText: some View { + VStack { + Spacer() + Text(""" + You've been added to the AI Digest demo. Your first issue should be ready soon. + When a new digest is ready the icon in the library header will change color. + You can close this window now. + """) + .padding(20) + Spacer() + } + } var closeButton: some View { Button(action: { @@ -219,7 +239,7 @@ struct FullScreenDigestView: View { Spacer() } if let digest = viewModel.digest { - Text(digest.title) + Text(digest.title ?? "") .font(Font.system(size: 17, weight: .semibold)) .lineSpacing(5) .lineLimit(3) @@ -252,7 +272,8 @@ struct FullScreenDigestView: View { ChapterView( startTime: chapterData.time, skipIndex: chapterData.start, - chapter: chapter + chapter: chapter, + isCurrentChapter: currentChapter ) .onTapGesture { audioController.seek(toIdx: chapterData.start) @@ -264,8 +285,9 @@ struct FullScreenDigestView: View { viewModel.presentedLibraryItem = chapter.id viewModel.presentWebContainer = true } + .contentShape(Rectangle()) .background( - currentChapter ? Color.themeLabelBackground.opacity(0.6) : Color.clear + currentChapter ? Color.blue.opacity(0.2) : Color.clear ) .cornerRadius(5) } @@ -273,19 +295,18 @@ struct FullScreenDigestView: View { .padding(.top, 20) } - if let digest = viewModel.digest { + if let digest = viewModel.digest, let content = digest.content { Text("Transcript") .font(Font.system(size: 17, weight: .semibold)) .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 20) VStack { - Markdown(digest.content) + Markdown(content) .foregroundColor(Color.appGrayTextContrast) } .padding(15) .background(Color.themeLabelBackground.opacity(0.6)) - .cornerRadius(5) } Spacer(minLength: 60) @@ -300,27 +321,6 @@ struct FullScreenDigestView: View { RatingWidget() Spacer(minLength: 60) } -// -// VStack(alignment: .leading, spacing: 20) { -// Text("If you didn't like today's digest or would like another one you can create another one. The process takes a few minutes") -// Button(action: { -// Task { -// await viewModel.refreshDigest(dataService: dataService) -// } -// }, label: { -// Text("Create new digest") -// .font(Font.system(size: 13, weight: .medium)) -// .padding(.horizontal, 8) -// .padding(.vertical, 5) -// .tint(Color.blue) -// .background(Color.themeLabelBackground) -// .cornerRadius(5) -// }) -// } -// .padding(15) -// .background(Color.themeLabelBackground.opacity(0.6)) -// .cornerRadius(5) -// }.contentMargins(10, for: .scrollContent) Spacer() @@ -340,41 +340,70 @@ struct ChapterView: View { let startTime: String let skipIndex: Int let chapter: DigestChapter + let isCurrentChapter: Bool var body: some View { - HStack(spacing: 15) { + HStack { + VStack(spacing: 5) { + HStack { + Text(startTime) + .padding(4) + .padding(.horizontal, 4) + .foregroundColor(.blue) + .font(Font.system(size: 13)) + .background(Color.themeLabelBackground.opacity(0.6)) + .cornerRadius(5) + + if let author = chapter.author { + Text(author) + .font(Font.system(size: 14)) + .foregroundColor(Color.themeLibraryItemSubtle) + .lineLimit(1) + .padding(.trailing, 10) + } + + Spacer() + } + Text(chapter.title) + .foregroundColor(isCurrentChapter ? .primary :Color.themeLibraryItemSubtle.opacity(0.60)) + .font(Font.system(size: 14)) + .lineLimit(4) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .padding(.leading, 10) + Spacer() if let thumbnail = chapter.thumbnail, let thumbnailURL = URL(string: thumbnail) { AsyncImage(url: thumbnailURL) { image in image .resizable() .aspectRatio(contentMode: .fill) - .frame(width: 90, height: 50) + .frame(width: 65, height: 65) + .cornerRadius(5) .clipped() - } placeholder: { - Rectangle() - .foregroundColor(.gray) - .frame(width: 90, height: 50) - } - .cornerRadius(8) - } else { - Rectangle() - .foregroundColor(.gray) - .frame(width: 90, height: 50) - .cornerRadius(8) - } - VStack(alignment: .leading) { - (Text(startTime) - .foregroundColor(.blue) - .font(.caption) + .padding(.trailing, 10) - + - Text(" - " + chapter.title) - .foregroundColor(.primary) - .font(.caption)) - .lineLimit(2) + } placeholder: { + Rectangle() + .foregroundColor(.clear) + .frame(width: 65, height: 65) + .cornerRadius(5) + .padding(.trailing, 10) + } + } else { + ZStack { + Rectangle() + .foregroundColor(.thLibrarySeparator) + .frame(width: 65, height: 65) + .cornerRadius(5) + Image(systemName: "photo") + .foregroundColor(.thBorderColor) + .frame(width: 65, height: 65) + .cornerRadius(5) + } + .padding(.trailing, 10) } - Spacer() } + .frame(maxWidth: .infinity, alignment: .topLeading) .padding(.leading, 4) .padding(.vertical, 15) } @@ -424,8 +453,6 @@ struct PreviewItemView: View { } .padding(.top, 10) Text(viewModel.item.title) - // .font(.body) - // .fontWeight(.semibold) .font(Font.system(size: 18, weight: .semibold)) .frame(maxWidth: .infinity, alignment: .topLeading) diff --git a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift index 21b10a54b..ce28e4536 100644 --- a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift +++ b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift @@ -44,7 +44,7 @@ public struct DigestAudioItem: AudioItemProperties { public init(digest: DigestResult, chapters: [DigestChapterData]) { self.digest = digest self.itemID = digest.id - self.title = digest.title + self.title = digest.title ?? "Omnivore digest" self.chapters = chapters self.startIndex = 0 @@ -52,7 +52,7 @@ public struct DigestAudioItem: AudioItemProperties { self.imageURL = nil - if let first = digest.speechFiles.first { + if let first = digest.speechFiles?.first { self.language = first.language self.byline = digest.byline } @@ -1033,7 +1033,7 @@ public struct DigestAudioItem: AudioItemProperties { } func combineSpeechFiles(from digest: DigestResult) -> ([Utterance], Double) { - let allUtterances = digest.speechFiles.flatMap { $0.utterances } + let allUtterances = digest.speechFiles?.flatMap { $0.utterances } ?? [] var updatedUtterances: [Utterance] = [] var currentWordOffset = 0.0 @@ -1053,7 +1053,7 @@ public struct DigestAudioItem: AudioItemProperties { } func downloadDigestItemSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? { - if let digestItem = itemAudioProperties as? DigestAudioItem, let firstFile = digestItem.digest.speechFiles.first { + if let digestItem = itemAudioProperties as? DigestAudioItem, let firstFile = digestItem.digest.speechFiles?.first { let (utterances, wordCount) = combineSpeechFiles(from: digestItem.digest) let document = SpeechDocument( diff --git a/apple/OmnivoreKit/Sources/Services/DataService/AI/AITasks.swift b/apple/OmnivoreKit/Sources/Services/DataService/AI/AITasks.swift index 91d877499..088e83b5e 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/AI/AITasks.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/AI/AITasks.swift @@ -9,16 +9,16 @@ struct AITaskRequest: Decodable { public struct DigestResult: Codable { public let id: String - public let title: String - public let byline: String - public let content: String - public let description: String - public let urlsToAudio: [String] - public let chapters: [DigestChapter] - public let speechFiles: [SpeechDocument] + public let title: String? + public let byline: String? + public let content: String? + public let description: String? + public let urlsToAudio: [String]? + public let chapters: [DigestChapter]? + public let speechFiles: [SpeechDocument]? - public let jobState: String - public let createdAt: String + public let jobState: String? + public let createdAt: String? } public struct DigestChapter: Codable { @@ -26,11 +26,13 @@ public struct DigestChapter: Codable { public let id: String public let url: String public let wordCount: Double + public let author: String? public let thumbnail: String? - public init(title: String, id: String, url: String, wordCount: Double, thumbnail: String?) { + public init(title: String, id: String, url: String, wordCount: Double, author: String?, thumbnail: String?) { self.title = title self.id = id self.url = url + self.author = author self.wordCount = wordCount self.thumbnail = thumbnail } @@ -167,9 +169,10 @@ extension DataService { do { let digest = try await networker.urlSession.performRequest(resource: resource) - let oldDigest = loadStoredDigest() - saveDigest(digest) + if digest.jobState == "SUCCEEDED" { + saveDigest(digest) + } return digest } catch { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateUserPersonalization.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateUserPersonalization.swift new file mode 100644 index 000000000..25508cb7b --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateUserPersonalization.swift @@ -0,0 +1,70 @@ +import CoreData +import Foundation +import Models +import SwiftGraphQL + +struct DigestConfig { + let channels: [String] +} + +struct UserPersonalization { + let digestConfig: DigestConfig +} + +let digestConfigSelection = Selection.DigestConfig { + DigestConfig(channels: (try? $0.channels())?.compactMap { $0 } ?? []) +} + +let channelSelection = Selection.UserPersonalization { + try $0.digestConfig(selection: digestConfigSelection.nullable)?.channels ?? [] +} + + +public extension DataService { + func setupUserDigestConfig() async throws { + enum MutationResult { + case success(channels: [String]) + case error(errorMessage: String) + } + + let selection = Selection { + try $0.on( + setUserPersonalizationError: .init { + .error(errorMessage: try $0.errorCodes().first?.rawValue ?? "Unknown Error") + }, + setUserPersonalizationSuccess: .init { .success(channels: try $0.updatedUserPersonalization(selection: channelSelection)) } + ) + } + + let mutation = Selection.Mutation { + try $0.setUserPersonalization( + input: InputObjects.SetUserPersonalizationInput( + digestConfig: OptionalArgument( + InputObjects.DigestConfigInput(channels: OptionalArgument([OptionalArgument("push")])) + ) + ), + selection: selection + ) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return try await withCheckedThrowingContinuation { continuation in + send(mutation, to: path, headers: headers) { queryResult in + guard let payload = try? queryResult.get() else { + print("network error setting up user digest config") + continuation.resume(throwing: BasicError.message(messageText: "network error")) + return + } + switch payload.data { + case .success: + continuation.resume() + case let .error(errorMessage: errorMessage): + continuation.resume(throwing: BasicError.message(messageText: errorMessage)) + } + } + } + } +} + diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift index b6997b3db..54e3e54b8 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift @@ -18,7 +18,7 @@ enum FlairLabels: String { case .recommended: return Image.flairRecommended case .newsletter: return Image.flairNewsletter case .feed, .rss: return Image.flairFeed - case .digest: return Image.tabDigestSelected + case .digest: return Image.flairDigest } } diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.swift b/apple/OmnivoreKit/Sources/Views/Images/Images.swift index ccfb85f88..93e2c07a1 100644 --- a/apple/OmnivoreKit/Sources/Views/Images/Images.swift +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.swift @@ -53,7 +53,8 @@ public extension Image { static var flairNewsletter: Image { Image("flair-newsletter", bundle: .module) } static var flairPinned: Image { Image("flair-pinned", bundle: .module) } static var flairRecommended: Image { Image("flair-recommended", bundle: .module) } - + static var flairDigest: Image { Image("flair-digest", bundle: .module) } + static var doubleChevronUp: Image { Image("double_chevron_up", bundle: .module) } } diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-digest.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-digest.imageset/Contents.json new file mode 100644 index 000000000..85fade8b6 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-digest.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "flair-digest@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "flair-digest@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "flair-digest@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-digest.imageset/flair-digest@1x.png b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-digest.imageset/flair-digest@1x.png new file mode 100644 index 000000000..2f16d8e92 Binary files /dev/null and b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-digest.imageset/flair-digest@1x.png differ diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-digest.imageset/flair-digest@2x.png b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-digest.imageset/flair-digest@2x.png new file mode 100644 index 000000000..384b495f9 Binary files /dev/null and b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-digest.imageset/flair-digest@2x.png differ diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-digest.imageset/flair-digest@3x.png b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-digest.imageset/flair-digest@3x.png new file mode 100644 index 000000000..93195403d Binary files /dev/null and b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-digest.imageset/flair-digest@3x.png differ