Merge pull request #3959 from omnivore-app/feat/ios-digest-cleanup
Add UI for digest
This commit is contained in:
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) }
|
||||
|
||||
}
|
||||
|
||||
23
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-digest.imageset/Contents.json
vendored
Normal file
23
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-digest.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-digest.imageset/flair-digest@1x.png
vendored
Normal file
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-digest.imageset/flair-digest@1x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 369 B |
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-digest.imageset/flair-digest@2x.png
vendored
Normal file
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-digest.imageset/flair-digest@2x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 553 B |
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-digest.imageset/flair-digest@3x.png
vendored
Normal file
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-digest.imageset/flair-digest@3x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 467 B |
Reference in New Issue
Block a user