@@ -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
235281private 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+
252435private 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
353608private 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