Skip to content

Commit 0e28c56

Browse files
authored
Show a breakdown view based on media item types (#24971)
* The "Buy Storage Add-ons" web page now only shows storage add-ons * Show a breakdown view based on media item types
1 parent a252e96 commit 0e28c56

File tree

1 file changed

+300
-9
lines changed

1 file changed

+300
-9
lines changed

WordPress/Classes/ViewRelated/Media/SiteMedia/Views/MediaStorageDetailsView.swift

Lines changed: 300 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,9 @@ private struct WebPurchase: Identifiable {
165165

166166
static func storage(blog: Blog) -> Self {
167167
WebPurchase(
168-
url: URL(string: "https://wordpress.com/add-ons/")!.appending(path: blog.primaryDomainAddress),
168+
url: URL(string: "https://wordpress.com/add-ons/")!
169+
.appending(path: blog.primaryDomainAddress)
170+
.appending(queryItems: [.init(name: "product", value: "storage")]),
169171
title: Strings.buyStorageTitle,
170172
successMessage: Strings.storageUpgradeSuccessMessage
171173
)
@@ -204,19 +206,35 @@ private struct UsageView: View {
204206

205207
GeometryReader { geometry in
206208
ZStack(alignment: .leading) {
207-
RoundedRectangle(cornerRadius: 3)
209+
Rectangle()
208210
.fill(Color(white: 0.9))
209211
.frame(height: geometry.size.height)
210212

211-
RoundedRectangle(cornerRadius: 3)
212-
.fill(progressColor)
213-
.frame(
214-
width: geometry.size.width * min(usage?.percentage ?? 0, 1),
215-
height: geometry.size.height
216-
)
213+
if let breakdown = usage?.breakdown {
214+
HStack(spacing: 0) {
215+
ForEach(breakdown.items) { item in
216+
Rectangle()
217+
.fill(item.category.color)
218+
.frame(width: geometry.size.width * item.percentage)
219+
}
220+
}
221+
.frame(height: geometry.size.height)
222+
} else {
223+
Rectangle()
224+
.fill(progressColor)
225+
.frame(
226+
width: geometry.size.width * min(usage?.percentage ?? 0, 1),
227+
height: geometry.size.height
228+
)
229+
}
217230
}
231+
.clipShape(.rect(cornerRadius: 3))
218232
}
219233
.frame(height: 16)
234+
235+
if let breakdown = usage?.breakdown {
236+
legendView(breakdown: breakdown)
237+
}
220238
}
221239
}
222240

@@ -230,11 +248,40 @@ private struct UsageView: View {
230248
return Color(red: 0.0, green: 0.48, blue: 0.8)
231249
}
232250
}
251+
252+
@ViewBuilder
253+
private func legendView(breakdown: MediaTypeBreakdown) -> some View {
254+
VStack(alignment: .leading, spacing: 4) {
255+
ForEach(breakdown.items) { item in
256+
HStack(spacing: 4) {
257+
Circle()
258+
.fill(item.category.color)
259+
.frame(width: 8, height: 8)
260+
261+
Text(item.category.displayName)
262+
.font(.caption)
263+
.foregroundColor(.secondary)
264+
265+
Spacer()
266+
267+
Text(item.displaySize)
268+
.font(.caption)
269+
.foregroundColor(.secondary)
270+
271+
Text(verbatim: "(\(Int(item.percentage * 100))%)")
272+
.font(.caption)
273+
.foregroundColor(.secondary)
274+
}
275+
}
276+
}
277+
.padding(.top, 8)
278+
}
233279
}
234280

235281
private struct Usage {
236282
var used: Measurement<UnitInformationStorage>
237283
var total: Measurement<UnitInformationStorage>
284+
var breakdown: MediaTypeBreakdown?
238285

239286
var usedText: String {
240287
ByteCountFormatter.string(from: used, countStyle: .binary)
@@ -249,6 +296,142 @@ private struct Usage {
249296
}
250297
}
251298

299+
// Known issue: when the app is in middle of its very first media library sync, unsynced items will be shown as "Other".
300+
// Ideally, we should show a loading indicator in the `UsageView` to indicate that something is happening in the
301+
// background and update the breakdown view as the background syncing progresses.
302+
private struct MediaTypeBreakdown {
303+
struct Item: Identifiable {
304+
enum Category: Int, Hashable, Comparable {
305+
case image
306+
case video
307+
case document
308+
case powerpoint
309+
case audio
310+
case other
311+
312+
init(mediaType: MediaType) {
313+
switch mediaType {
314+
case .image:
315+
self = .image
316+
case .video:
317+
self = .video
318+
case .document:
319+
self = .document
320+
case .powerpoint:
321+
self = .powerpoint
322+
case .audio:
323+
self = .audio
324+
@unknown default:
325+
self = .other
326+
}
327+
}
328+
329+
var displayName: String {
330+
switch self {
331+
case .image:
332+
return Strings.mediaTypeImage
333+
case .video:
334+
return Strings.mediaTypeVideo
335+
case .document:
336+
return Strings.mediaTypeDocument
337+
case .powerpoint:
338+
return Strings.mediaTypePowerpoint
339+
case .audio:
340+
return Strings.mediaTypeAudio
341+
case .other:
342+
return Strings.mediaTypeOther
343+
}
344+
}
345+
346+
var color: Color {
347+
switch self {
348+
case .image:
349+
return Color(red: 0.0, green: 0.48, blue: 0.8)
350+
case .video:
351+
return .purple
352+
case .document:
353+
return .green
354+
case .powerpoint:
355+
return .orange
356+
case .audio:
357+
return .pink
358+
case .other:
359+
return .gray
360+
}
361+
}
362+
363+
static func < (lhs: Category, rhs: Category) -> Bool {
364+
return lhs.rawValue < rhs.rawValue
365+
}
366+
}
367+
368+
let category: Category
369+
var size: Measurement<UnitInformationStorage>
370+
var percentage: Double
371+
372+
var id: Category {
373+
category
374+
}
375+
376+
var displaySize: String {
377+
ByteCountFormatter.string(from: size, countStyle: .binary)
378+
}
379+
}
380+
381+
let items: [Item]
382+
383+
init?(media: [Media], used: Double, allowed: Double) {
384+
precondition(allowed > 0)
385+
386+
guard !media.isEmpty else {
387+
return nil
388+
}
389+
390+
// First, we categorize all media items by `Category`.
391+
var categorized: [Item.Category: [Media]] = [:]
392+
var knownSizes = 0.0
393+
for item in media {
394+
var category = Item.Category(mediaType: item.mediaType)
395+
let size = item.actualFileSize
396+
if size > 0 {
397+
knownSizes += size
398+
} else {
399+
// Media items with `mediaType` that is not handled by the app are consider "others". In an unlikely
400+
// scenario where the media file size is zero, we'll consider them as "others", too. That's to avoid
401+
// showing a specifc type with incorrect total file size.
402+
category = .other
403+
}
404+
categorized[category, default: []].append(item)
405+
}
406+
407+
// Then, we group media items into `Item` for displaying on the breakdown view.
408+
var items = categorized.map { (category, media) in
409+
let size = media.reduce(0) { $0 + $1.actualFileSize }
410+
return Item(category: category, size: Measurement(value: size, unit: .bytes), percentage: size / allowed)
411+
}
412+
413+
// We need to handle the "others" category additionally. See the comments above about the "others" category.
414+
var otherItem: Item
415+
if let index = items.firstIndex(where: { $0.category == .other }) {
416+
otherItem = items.remove(at: index)
417+
} else {
418+
otherItem = Item(category: .other, size: Measurement(value: 0, unit: .bytes), percentage: 0)
419+
}
420+
421+
if knownSizes < used {
422+
let newValue = otherItem.size.value + (used - knownSizes)
423+
otherItem.size = Measurement(value: newValue, unit: .bytes)
424+
otherItem.percentage = newValue / allowed
425+
}
426+
427+
if otherItem.size.value > 0 {
428+
items.append(otherItem)
429+
}
430+
431+
self.items = items.sorted(using: KeyPathComparator(\.category))
432+
}
433+
}
434+
252435
private struct WebPurchaseView: UIViewControllerRepresentable {
253436
let viewModel: CheckoutViewModel
254437
let customTitle: String
@@ -343,11 +526,83 @@ final class MediaStorageDetailsViewModel: ObservableObject {
343526

344527
private func updateUsage() {
345528
if let used = blog.quotaSpaceUsed, let allowed = blog.quotaSpaceAllowed {
346-
self.usage = .init(used: .init(value: used.doubleValue, unit: .bytes), total: .init(value: allowed.doubleValue, unit: .bytes))
529+
let breakdown = calculateMediaBreakdown(used: used.doubleValue, allowed: allowed.doubleValue)
530+
self.usage = .init(
531+
used: .init(value: used.doubleValue, unit: .bytes),
532+
total: .init(value: allowed.doubleValue, unit: .bytes),
533+
breakdown: breakdown
534+
)
347535
} else {
348536
self.usage = nil
349537
}
350538
}
539+
540+
private func calculateMediaBreakdown(used: Double, allowed: Double) -> MediaTypeBreakdown? {
541+
guard let context = blog.managedObjectContext else {
542+
return nil
543+
}
544+
545+
guard allowed > 0 else {
546+
return nil
547+
}
548+
549+
let fetchRequest = NSFetchRequest<Media>(entityName: "Media")
550+
fetchRequest.predicate = NSPredicate(
551+
format: "blog == %@ AND remoteStatusNumber == %d",
552+
blog,
553+
MediaRemoteStatus.sync.rawValue
554+
)
555+
556+
guard let allMedia = try? context.fetch(fetchRequest) else {
557+
return nil
558+
}
559+
560+
return MediaTypeBreakdown(media: allMedia, used: used, allowed: allowed)
561+
}
562+
}
563+
564+
private extension Media {
565+
566+
// Parse the `formattedSize` String as Double (in bytes).
567+
//
568+
// The 'size' returned by WP.com API is computed using `size_format` function in
569+
// https://github.com/WordPress/wordpress-develop/blob/6.8.3/src/wp-includes/functions.php#L468
570+
//
571+
// The implementation here may not match the php function exactly.
572+
// The REST API should return the file size in number. See https://linear.app/a8c/issue/AINFRA-1496
573+
var actualFileSize: Double {
574+
guard let formattedSize = formattedSize?.trimmingCharacters(in: .whitespaces),
575+
!formattedSize.isEmpty else {
576+
return 0
577+
}
578+
579+
let components = formattedSize.split(separator: " ", maxSplits: 1)
580+
guard components.count == 2 else {
581+
return 0
582+
}
583+
584+
let numberString = components[0].replacingOccurrences(of: ",", with: "")
585+
guard let value = Double(numberString) else {
586+
return 0
587+
}
588+
589+
let unit = String(components[1])
590+
let multiplier: Double = switch unit {
591+
case "B": 1
592+
case "KB": 1024
593+
case "MB": 1024 * 1024
594+
case "GB": 1024 * 1024 * 1024
595+
case "TB": 1024 * 1024 * 1024 * 1024
596+
case "PB": 1024 * 1024 * 1024 * 1024 * 1024
597+
case "EB": 1024 * 1024 * 1024 * 1024 * 1024 * 1024
598+
case "ZB": 1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024
599+
case "YB": 1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024
600+
default: 0
601+
}
602+
603+
return value * multiplier
604+
}
605+
351606
}
352607

353608
private enum Strings {
@@ -404,4 +659,40 @@ private enum Strings {
404659
value: "Your site plan has been upgraded!",
405660
comment: "Success message shown after upgrading plan"
406661
)
662+
663+
static let mediaTypeImage = NSLocalizedString(
664+
"mediaLibrary.storageDetails.mediaType.image",
665+
value: "Images",
666+
comment: "Label for image media type in storage breakdown"
667+
)
668+
669+
static let mediaTypeVideo = NSLocalizedString(
670+
"mediaLibrary.storageDetails.mediaType.video",
671+
value: "Videos",
672+
comment: "Label for video media type in storage breakdown"
673+
)
674+
675+
static let mediaTypeDocument = NSLocalizedString(
676+
"mediaLibrary.storageDetails.mediaType.document",
677+
value: "Documents",
678+
comment: "Label for document media type in storage breakdown"
679+
)
680+
681+
static let mediaTypePowerpoint = NSLocalizedString(
682+
"mediaLibrary.storageDetails.mediaType.powerpoint",
683+
value: "Presentations",
684+
comment: "Label for PowerPoint/presentation media type in storage breakdown"
685+
)
686+
687+
static let mediaTypeAudio = NSLocalizedString(
688+
"mediaLibrary.storageDetails.mediaType.audio",
689+
value: "Audio",
690+
comment: "Label for audio media type in storage breakdown"
691+
)
692+
693+
static let mediaTypeOther = NSLocalizedString(
694+
"mediaLibrary.storageDetails.mediaType.other",
695+
value: "Other",
696+
comment: "Label for other/unknown media type in storage breakdown"
697+
)
407698
}

0 commit comments

Comments
 (0)