Merge pull request #3959 from omnivore-app/feat/ios-digest-cleanup

Add UI for digest
This commit is contained in:
Jackson Harper
2024-05-17 14:44:29 +08:00
committed by GitHub
11 changed files with 246 additions and 84 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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(

View File

@ -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 {

View File

@ -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<MutationResult, Unions.SetUserPersonalizationResult> {
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))
}
}
}
}
}

View File

@ -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
}
}

View File

@ -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) }
}

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 B