diff --git a/README.md b/README.md index 42c1e6d..6bf82c2 100644 --- a/README.md +++ b/README.md @@ -13,16 +13,21 @@ StorageLens 是一个原生 iPhone 应用,用来帮助用户查看照片图库 ## 它能做什么 - 分析用户已授权的照片图库。 -- 找出大视频、屏幕截图、旧媒体和可能相似的照片。 -- 按月份整理截图,支持多选和删除确认。 +- 首页汇总图库估算占用、分类数量、最近扫描时间和本机隐私状态。 +- 找出大视频、可能的屏幕录制、屏幕截图、Live Photos、旧媒体和可能相似的照片。 +- 按月份整理截图,支持月份批量选择和年龄筛选。 +- 用 Review Basket 汇总待处理项目,只有在最终确认页才会请求系统删除。 +- 用月份时间线展示哪些月份可能占用最多空间。 +- 对文件大小使用本机快速估算,避免为了排序或首页统计读取完整照片、视频数据。 - 删除前始终要求用户明确确认。 ## 它不能做什么 -- 不清理 iOS 系统数据。 -- 不清理其他 App 的缓存。 -- 不声称“清理系统垃圾”“加速 iPhone”或“删除隐藏缓存”。 +- 不处理 iOS 系统数据。 +- 不读取或处理其他 App 的缓存。 +- 不提供性能加速、系统级维护或后台缓存管理功能。 - 不会自动删除照片或视频。 +- 不会绕过系统照片权限,也不会访问其他 App 的数据。 ## 隐私模型 diff --git a/StorageLens.xcodeproj/project.pbxproj b/StorageLens.xcodeproj/project.pbxproj index 94e0e88..4de5ef2 100644 --- a/StorageLens.xcodeproj/project.pbxproj +++ b/StorageLens.xcodeproj/project.pbxproj @@ -380,7 +380,7 @@ ASSETCATALOG_COMPILER_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = FCKB5654HM; ENABLE_PREVIEWS = YES; @@ -399,7 +399,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = com.jonasxzb.storagelens; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -414,7 +414,7 @@ ASSETCATALOG_COMPILER_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = FCKB5654HM; GENERATE_INFOPLIST_FILE = YES; @@ -432,7 +432,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = com.jonasxzb.storagelens; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/StorageLens/Components/AppComponents.swift b/StorageLens/Components/AppComponents.swift index df63be4..8f0bd18 100644 --- a/StorageLens/Components/AppComponents.swift +++ b/StorageLens/Components/AppComponents.swift @@ -63,7 +63,7 @@ struct CleanupCategoryRow: View { Text("\(category.itemCount)") .font(.headline) if let estimatedBytes = category.estimatedBytes { - Text(AppFormatters.fileSize(estimatedBytes)) + Text("估算 \(AppFormatters.fileSize(estimatedBytes))") .font(.caption) .foregroundStyle(.secondary) } @@ -93,8 +93,8 @@ struct MediaAssetRow: View { .foregroundStyle(.secondary) HStack(spacing: 8) { - Text(AppFormatters.fileSize(item.estimatedFileSize)) - if item.kind == .video { + Text("估算 \(AppFormatters.fileSize(item.estimatedFileSize))") + if item.kind == .video || item.kind == .screenRecording { Text(AppFormatters.duration(item.duration)) } Text(item.creationDate.map(AppFormatters.date) ?? "日期未知") @@ -193,6 +193,7 @@ struct SelectionSummaryBar: View { let estimatedBytes: Int64 let isWorking: Bool let actionTitle: String + var actionSystemImage = "tray.and.arrow.down" let action: () -> Void var body: some View { @@ -201,7 +202,7 @@ struct SelectionSummaryBar: View { VStack(alignment: .leading, spacing: 2) { Text("已选择 \(selectedCount) 项") .font(.headline) - Text("预计 \(AppFormatters.fileSize(estimatedBytes))") + Text("估算 \(AppFormatters.fileSize(estimatedBytes))") .font(.caption) .foregroundStyle(.secondary) } @@ -212,7 +213,7 @@ struct SelectionSummaryBar: View { if isWorking { ProgressView() } else { - Label(actionTitle, systemImage: "trash") + Label(actionTitle, systemImage: actionSystemImage) } } .buttonStyle(.borderedProminent) diff --git a/StorageLens/Models/AppModels.swift b/StorageLens/Models/AppModels.swift index 8d1a17e..2568205 100644 --- a/StorageLens/Models/AppModels.swift +++ b/StorageLens/Models/AppModels.swift @@ -5,6 +5,8 @@ enum MediaAssetKind: String, Hashable { case photo case video case screenshot + case screenRecording + case livePhoto var title: String { switch self { @@ -14,6 +16,10 @@ enum MediaAssetKind: String, Hashable { return "视频" case .screenshot: return "屏幕截图" + case .screenRecording: + return "可能的屏幕录制" + case .livePhoto: + return "Live Photo" } } @@ -25,6 +31,10 @@ enum MediaAssetKind: String, Hashable { return "Video" case .screenshot: return "Screenshot" + case .screenRecording: + return "Possible Screen Recording" + case .livePhoto: + return "Live Photo" } } @@ -36,6 +46,10 @@ enum MediaAssetKind: String, Hashable { return "video" case .screenshot: return "rectangle.on.rectangle" + case .screenRecording: + return "record.circle" + case .livePhoto: + return "livephoto" } } } @@ -56,17 +70,36 @@ struct MediaAssetItem: Identifiable, Hashable { var accessibilityLabel: String { let dateText = creationDate.map(AppFormatters.date) ?? "日期未知" - return "\(kind.title),\(dateText),\(AppFormatters.fileSize(estimatedFileSize))" + return "\(kind.title),\(dateText),估算大小 \(AppFormatters.fileSize(estimatedFileSize))" } } enum CleanupCategoryKind: String, CaseIterable, Identifiable { case largeVideos case screenshots - case similarPhotos + case screenRecordings + case livePhotos case oldMedia + case similarPhotos var id: String { rawValue } + + var title: String { + switch self { + case .largeVideos: + return "大视频" + case .screenshots: + return "屏幕截图" + case .screenRecordings: + return "可能的屏幕录制" + case .livePhotos: + return "Live Photos" + case .oldMedia: + return "旧媒体" + case .similarPhotos: + return "相似照片" + } + } } struct CleanupCategory: Identifiable, Hashable { @@ -85,15 +118,33 @@ struct ScanSummary: Hashable { let totalPhotos: Int let totalVideos: Int let screenshotCount: Int + let screenRecordingCount: Int + let livePhotoCount: Int let largeVideoCount: Int let similarGroupCount: Int let oldMediaCount: Int + let estimatedCleanableBytes: Int64 let estimatedLargeVideoBytes: Int64 let estimatedScreenshotBytes: Int64 + let estimatedScreenRecordingBytes: Int64 + let estimatedLivePhotoBytes: Int64 + let estimatedOldMediaBytes: Int64 + let estimatedSimilarPhotoBytes: Int64 + let timelineMonths: [StorageTimelineMonth] let generatedAt: Date - var estimatedCleanableBytes: Int64 { - estimatedLargeVideoBytes + estimatedScreenshotBytes + var estimatedLibraryBytes: Int64 { + timelineMonths.map(\.estimatedBytes).reduce(0, +) + } + + var topTimelineMonths: [StorageTimelineMonth] { + timelineMonths.sorted { $0.estimatedBytes > $1.estimatedBytes }.prefix(3).map { $0 } + } + + var insightText: String? { + guard let topMonth = topTimelineMonths.first, topMonth.estimatedBytes > 0 else { return nil } + let source = topMonth.estimatedVideoBytes >= topMonth.estimatedPhotoBytes ? "视频" : "照片" + return "\(topMonth.title) 新增估算空间主要来自\(source)。" } var categories: [CleanupCategory] { @@ -102,7 +153,7 @@ struct ScanSummary: Hashable { kind: .largeVideos, title: "大视频", englishTitle: "Large Videos", - detail: "优先查看占用空间较大的视频", + detail: "优先查看估算占用较大的视频", systemImage: "video.fill", itemCount: largeVideoCount, estimatedBytes: estimatedLargeVideoBytes @@ -111,19 +162,28 @@ struct ScanSummary: Hashable { kind: .screenshots, title: "屏幕截图", englishTitle: "Screenshots", - detail: "按月份整理截图,手动选择删除", + detail: "按月份整理截图,加入篮子后确认", systemImage: "rectangle.on.rectangle", itemCount: screenshotCount, estimatedBytes: estimatedScreenshotBytes ), CleanupCategory( - kind: .similarPhotos, - title: "相似照片", - englishTitle: "Similar Photos", - detail: "按时间和尺寸线索找出可能相似的照片", - systemImage: "square.stack.3d.up", - itemCount: similarGroupCount, - estimatedBytes: nil + kind: .screenRecordings, + title: "可能的屏幕录制", + englishTitle: "Possible Screen Recordings", + detail: "优先使用图库标记,并结合本机线索谨慎识别", + systemImage: "record.circle", + itemCount: screenRecordingCount, + estimatedBytes: estimatedScreenRecordingBytes + ), + CleanupCategory( + kind: .livePhotos, + title: "Live Photos", + englishTitle: "Live Photos", + detail: "只支持查看和手动选择,不转换格式", + systemImage: "livephoto", + itemCount: livePhotoCount, + estimatedBytes: estimatedLivePhotoBytes ), CleanupCategory( kind: .oldMedia, @@ -132,12 +192,30 @@ struct ScanSummary: Hashable { detail: "查看较久以前的照片和视频", systemImage: "calendar", itemCount: oldMediaCount, - estimatedBytes: nil + estimatedBytes: estimatedOldMediaBytes + ), + CleanupCategory( + kind: .similarPhotos, + title: "相似照片", + englishTitle: "Similar Photos", + detail: "按时间和尺寸线索找出可能相似的照片", + systemImage: "square.stack.3d.up", + itemCount: similarGroupCount, + estimatedBytes: estimatedSimilarPhotoBytes ) ] } } +struct StorageTimelineMonth: Identifiable, Hashable { + let id: String + let title: String + let estimatedBytes: Int64 + let estimatedPhotoBytes: Int64 + let estimatedVideoBytes: Int64 + let itemCount: Int +} + struct ScreenshotMonthGroup: Identifiable, Hashable { let id: String let title: String @@ -154,10 +232,49 @@ struct SimilarPhotoGroup: Identifiable, Hashable { let items: [MediaAssetItem] var recommendedKeepID: String? { - items.first?.id + items.max { lhs, rhs in + ( + lhs.pixelWidth * lhs.pixelHeight, + lhs.estimatedFileSize ?? 0, + lhs.creationDate ?? .distantPast + ) < ( + rhs.pixelWidth * rhs.pixelHeight, + rhs.estimatedFileSize ?? 0, + rhs.creationDate ?? .distantPast + ) + }?.id + } + + var estimatedDuplicateBytes: Int64 { + let keepID = recommendedKeepID + return items + .filter { $0.id != keepID } + .compactMap(\.estimatedFileSize) + .reduce(0, +) } } +struct ReviewBasketItem: Identifiable, Hashable { + let item: MediaAssetItem + let categoryKinds: Set + + var id: String { item.id } + + var categoryTitles: String { + CleanupCategoryKind.allCases + .filter { categoryKinds.contains($0) } + .map(\.title) + .joined(separator: "、") + } +} + +struct CleanupResult: Identifiable, Hashable { + let id = UUID() + let deletedCount: Int + let unavailableCount: Int + let estimatedBytes: Int64 +} + struct UserMessage: Identifiable { let id = UUID() let title: String diff --git a/StorageLens/Services/PhotoDeletionService.swift b/StorageLens/Services/PhotoDeletionService.swift index e0ebf83..a7c808f 100644 --- a/StorageLens/Services/PhotoDeletionService.swift +++ b/StorageLens/Services/PhotoDeletionService.swift @@ -2,10 +2,16 @@ import Foundation import Photos final class PhotoDeletionService { - func deleteAssets(withLocalIdentifiers identifiers: Set) async throws { - guard !identifiers.isEmpty else { return } + func deleteAssets(withLocalIdentifiers identifiers: Set) async throws -> Set { + guard !identifiers.isEmpty else { return [] } let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: Array(identifiers), options: nil) + var resolvedIdentifiers: Set = [] + fetchResult.enumerateObjects { asset, _, _ in + resolvedIdentifiers.insert(asset.localIdentifier) + } + guard !resolvedIdentifiers.isEmpty else { return [] } + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in PHPhotoLibrary.shared().performChanges { PHAssetChangeRequest.deleteAssets(fetchResult) @@ -19,5 +25,6 @@ final class PhotoDeletionService { } } } + return resolvedIdentifiers } } diff --git a/StorageLens/Services/PhotoLibraryPermissionManager.swift b/StorageLens/Services/PhotoLibraryPermissionManager.swift index f1aa9bd..01fd4ef 100644 --- a/StorageLens/Services/PhotoLibraryPermissionManager.swift +++ b/StorageLens/Services/PhotoLibraryPermissionManager.swift @@ -1,6 +1,7 @@ import Combine import Foundation import Photos +import PhotosUI import UIKit @MainActor @@ -49,4 +50,46 @@ final class PhotoLibraryPermissionManager: ObservableObject { guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) } + + func presentLimitedLibraryPicker() { + guard status == .limited else { + openAppSettings() + return + } + + guard let presenter = UIApplication.shared.activeTopViewController else { return } + PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: presenter) { [weak self] _ in + Task { await self?.refresh() } + } + } +} + +private extension UIApplication { + var activeTopViewController: UIViewController? { + connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive }? + .windows + .first { $0.isKeyWindow }? + .rootViewController? + .topMostPresentedViewController + } +} + +private extension UIViewController { + var topMostPresentedViewController: UIViewController { + if let navigationController = self as? UINavigationController { + return navigationController.visibleViewController?.topMostPresentedViewController ?? navigationController + } + + if let tabBarController = self as? UITabBarController { + return tabBarController.selectedViewController?.topMostPresentedViewController ?? tabBarController + } + + if let presentedViewController { + return presentedViewController.topMostPresentedViewController + } + + return self + } } diff --git a/StorageLens/Services/PhotoLibraryScanner.swift b/StorageLens/Services/PhotoLibraryScanner.swift index 91374f1..e56b6ad 100644 --- a/StorageLens/Services/PhotoLibraryScanner.swift +++ b/StorageLens/Services/PhotoLibraryScanner.swift @@ -14,30 +14,117 @@ enum PhotoLibraryScannerError: LocalizedError { final class PhotoLibraryScanner { private let largeVideoThreshold: Int64 = 100 * 1024 * 1024 - private let screenshotFallbackBytes: Int64 = 2 * 1024 * 1024 + private let similarPhotoSummaryLimit = 260 func scanSummary() async throws -> ScanSummary { try ensurePhotoAccess() let allAssets = fetchAssets() - let photos = allAssets.filter { $0.mediaType == .image } - let videos = allAssets.filter { $0.mediaType == .video } - let screenshots = photos.filter { $0.mediaSubtypes.contains(.photoScreenshot) } + let oneYearCutoff = OldMediaFilter.oneYear.cutoffDate + + var totalPhotos = 0 + var totalVideos = 0 + var screenshotCount = 0 + var screenRecordingCount = 0 + var livePhotoCount = 0 + var largeVideoCount = 0 + var oldMediaCount = 0 + var estimatedScreenshotBytes: Int64 = 0 + var estimatedScreenRecordingBytes: Int64 = 0 + var estimatedLivePhotoBytes: Int64 = 0 + var estimatedLargeVideoBytes: Int64 = 0 + var estimatedOldMediaBytes: Int64 = 0 + var timelineBuckets: [String: TimelineAccumulator] = [:] + var reviewCandidateBytesByID: [String: Int64] = [:] + var recentPhotoCandidates: [PHAsset] = [] + + for asset in allAssets { + if Task.isCancelled { break } + let kind = mediaKind(for: asset) + let estimatedBytes = estimatedFileSize(for: asset, kind: kind) + let monthKey = AppFormatters.monthKey(for: asset.creationDate) + timelineBuckets[monthKey, default: TimelineAccumulator(date: asset.creationDate)].add( + asset: asset, + estimatedBytes: estimatedBytes + ) - let largeVideos = try await fetchLargeVideos() - let oldMedia = try await fetchOldMedia(olderThan: .oneYear) - let similarGroups = try await fetchSimilarPhotoGroups(maxAssets: 260) - let largeVideoBytes = largeVideos.compactMap(\.estimatedFileSize).reduce(0, +) + switch asset.mediaType { + case .image: + totalPhotos += 1 + if kind == .screenshot { + screenshotCount += 1 + estimatedScreenshotBytes += estimatedBytes + reviewCandidateBytesByID[asset.localIdentifier] = estimatedBytes + } else if kind == .livePhoto { + livePhotoCount += 1 + estimatedLivePhotoBytes += estimatedBytes + reviewCandidateBytesByID[asset.localIdentifier] = estimatedBytes + } else if recentPhotoCandidates.count < similarPhotoSummaryLimit { + recentPhotoCandidates.append(asset) + } + case .video: + totalVideos += 1 + if kind == .screenRecording { + screenRecordingCount += 1 + estimatedScreenRecordingBytes += estimatedBytes + reviewCandidateBytesByID[asset.localIdentifier] = estimatedBytes + } + if estimatedBytes >= largeVideoThreshold || asset.duration >= 180 { + largeVideoCount += 1 + estimatedLargeVideoBytes += estimatedBytes + reviewCandidateBytesByID[asset.localIdentifier] = estimatedBytes + } + default: + break + } + + if let oneYearCutoff, (asset.creationDate ?? .distantFuture) < oneYearCutoff { + oldMediaCount += 1 + estimatedOldMediaBytes += estimatedBytes + reviewCandidateBytesByID[asset.localIdentifier] = estimatedBytes + } + } + + let similarGroups = await similarPhotoGroups( + from: recentPhotoCandidates, + maximumAssets: similarPhotoSummaryLimit + ) + let estimatedSimilarPhotoBytes = similarGroups.map(\.estimatedDuplicateBytes).reduce(0, +) + for group in similarGroups { + let keepID = group.recommendedKeepID + for item in group.items where item.id != keepID { + reviewCandidateBytesByID[item.id] = item.estimatedFileSize ?? 0 + } + } return ScanSummary( - totalPhotos: photos.count, - totalVideos: videos.count, - screenshotCount: screenshots.count, - largeVideoCount: largeVideos.count, + totalPhotos: totalPhotos, + totalVideos: totalVideos, + screenshotCount: screenshotCount, + screenRecordingCount: screenRecordingCount, + livePhotoCount: livePhotoCount, + largeVideoCount: largeVideoCount, similarGroupCount: similarGroups.count, - oldMediaCount: oldMedia.count, - estimatedLargeVideoBytes: largeVideoBytes, - estimatedScreenshotBytes: Int64(screenshots.count) * screenshotFallbackBytes, + oldMediaCount: oldMediaCount, + estimatedCleanableBytes: reviewCandidateBytesByID.values.reduce(0, +), + estimatedLargeVideoBytes: estimatedLargeVideoBytes, + estimatedScreenshotBytes: estimatedScreenshotBytes, + estimatedScreenRecordingBytes: estimatedScreenRecordingBytes, + estimatedLivePhotoBytes: estimatedLivePhotoBytes, + estimatedOldMediaBytes: estimatedOldMediaBytes, + estimatedSimilarPhotoBytes: estimatedSimilarPhotoBytes, + timelineMonths: timelineBuckets + .map { key, value in + StorageTimelineMonth( + id: key, + title: AppFormatters.monthTitle(for: value.date), + estimatedBytes: value.estimatedBytes, + estimatedPhotoBytes: value.estimatedPhotoBytes, + estimatedVideoBytes: value.estimatedVideoBytes, + itemCount: value.itemCount + ) + } + .sorted { $0.estimatedBytes > $1.estimatedBytes }, generatedAt: Date() ) } @@ -48,7 +135,7 @@ final class PhotoLibraryScanner { var items: [MediaAssetItem] = [] for asset in fetchAssets(mediaType: .video) { if Task.isCancelled { break } - let item = await makeItem(from: asset, includeFileSize: true) + let item = makeItem(from: asset) let isProbablyLarge = (item.estimatedFileSize ?? 0) >= largeVideoThreshold || item.duration >= 180 if isProbablyLarge { items.append(item) @@ -69,7 +156,7 @@ final class PhotoLibraryScanner { for asset in screenshots { if Task.isCancelled { break } - items.append(await makeItem(from: asset, includeFileSize: true)) + items.append(makeItem(from: asset)) } return items.sorted { @@ -77,6 +164,40 @@ final class PhotoLibraryScanner { } } + func fetchScreenRecordings() async throws -> [MediaAssetItem] { + try ensurePhotoAccess() + + var items: [MediaAssetItem] = [] + for asset in fetchAssets(mediaType: .video) { + if Task.isCancelled { break } + guard isLikelyScreenRecording(asset) else { continue } + items.append(makeItem(from: asset)) + } + + return items.sorted { + ($0.estimatedFileSize ?? 0, $0.creationDate ?? .distantPast) > + ($1.estimatedFileSize ?? 0, $1.creationDate ?? .distantPast) + } + } + + func fetchLivePhotos() async throws -> [MediaAssetItem] { + try ensurePhotoAccess() + + var items: [MediaAssetItem] = [] + let livePhotos = fetchAssets(mediaType: .image) + .filter { $0.mediaSubtypes.contains(.photoLive) } + + for asset in livePhotos { + if Task.isCancelled { break } + items.append(makeItem(from: asset)) + } + + return items.sorted { + ($0.estimatedFileSize ?? 0, $0.creationDate ?? .distantPast) > + ($1.estimatedFileSize ?? 0, $1.creationDate ?? .distantPast) + } + } + func fetchOldMedia(olderThan filter: OldMediaFilter) async throws -> [MediaAssetItem] { try ensurePhotoAccess() guard let cutoffDate = filter.cutoffDate else { return [] } @@ -87,8 +208,7 @@ final class PhotoLibraryScanner { var items: [MediaAssetItem] = [] for asset in oldAssets { if Task.isCancelled { break } - let shouldEstimateSize = asset.mediaType == .video - items.append(await makeItem(from: asset, includeFileSize: shouldEstimateSize)) + items.append(makeItem(from: asset)) } return items @@ -99,14 +219,22 @@ final class PhotoLibraryScanner { let photoAssets = fetchAssets(mediaType: .image) .filter { !$0.mediaSubtypes.contains(.photoScreenshot) } - .sorted { ($0.creationDate ?? .distantPast) < ($1.creationDate ?? .distantPast) } + .filter { !$0.mediaSubtypes.contains(.photoLive) } .prefix(maxAssets) + return await similarPhotoGroups(from: Array(photoAssets), maximumAssets: maxAssets) + } + + private func similarPhotoGroups(from assets: [PHAsset], maximumAssets: Int) async -> [SimilarPhotoGroup] { + let photoAssets = assets + .prefix(maximumAssets) + .sorted { ($0.creationDate ?? .distantPast) < ($1.creationDate ?? .distantPast) } + var groups: [[MediaAssetItem]] = [] for asset in photoAssets { if Task.isCancelled { break } - let item = await makeItem(from: asset, includeFileSize: true) + let item = makeItem(from: asset) if let lastGroup = groups.indices.last, let previous = groups[lastGroup].last, @@ -159,20 +287,13 @@ final class PhotoLibraryScanner { return assets } - private func makeItem(from asset: PHAsset, includeFileSize: Bool) async -> MediaAssetItem { - let kind: MediaAssetKind - if asset.mediaType == .video { - kind = .video - } else if asset.mediaSubtypes.contains(.photoScreenshot) { - kind = .screenshot - } else { - kind = .photo - } + private func makeItem(from asset: PHAsset) -> MediaAssetItem { + let kind = mediaKind(for: asset) return MediaAssetItem( id: asset.localIdentifier, kind: kind, - estimatedFileSize: includeFileSize ? await estimatedFileSize(for: asset) : nil, + estimatedFileSize: estimatedFileSize(for: asset, kind: kind), duration: asset.duration, creationDate: asset.creationDate, pixelWidth: asset.pixelWidth, @@ -180,39 +301,68 @@ final class PhotoLibraryScanner { ) } - private func estimatedFileSize(for asset: PHAsset) async -> Int64? { - let resources = PHAssetResource.assetResources(for: asset) - var totalBytes: Int64 = 0 - var hasValue = false + private func mediaKind(for asset: PHAsset) -> MediaAssetKind { + if asset.mediaType == .video { + return isLikelyScreenRecording(asset) ? .screenRecording : .video + } else if asset.mediaSubtypes.contains(.photoScreenshot) { + return .screenshot + } else if asset.mediaSubtypes.contains(.photoLive) { + return .livePhoto + } else { + return .photo + } + } - for resource in resources { - if Task.isCancelled { break } - if let byteCount = await byteCount(for: resource) { - totalBytes += byteCount - hasValue = true + private func estimatedFileSize(for asset: PHAsset, kind: MediaAssetKind) -> Int64 { + let pixelCount = max(Int64(asset.pixelWidth) * Int64(asset.pixelHeight), 1) + + switch kind { + case .video, .screenRecording: + let bytesPerSecond: Int64 + switch pixelCount { + case 8_000_000...: + bytesPerSecond = 7_000_000 + case 2_000_000...: + bytesPerSecond = 2_500_000 + default: + bytesPerSecond = 1_000_000 } - } + let duration = max(Int64(asset.duration.rounded(.up)), 1) + return max(duration * bytesPerSecond, 5 * 1024 * 1024) + + case .screenshot: + return max(pixelCount / 2, 700 * 1024) - return hasValue ? totalBytes : nil + case .livePhoto: + return max(pixelCount / 2, 2 * 1024 * 1024) + + case .photo: + return max(pixelCount / 3, 900 * 1024) + } } - private func byteCount(for resource: PHAssetResource) async -> Int64? { - await withCheckedContinuation { continuation in - let options = PHAssetResourceRequestOptions() - options.isNetworkAccessAllowed = false - var totalBytes: Int64 = 0 + private func isLikelyScreenRecording(_ asset: PHAsset) -> Bool { + guard asset.mediaType == .video else { return false } + if asset.mediaSubtypes.contains(.videoScreenRecording) { + return true + } - PHAssetResourceManager.default().requestData( - for: resource, - options: options, - dataReceivedHandler: { data in - totalBytes += Int64(data.count) - }, - completionHandler: { error in - continuation.resume(returning: error == nil ? totalBytes : nil) - } - ) + let cameraVideoSubtypes: PHAssetMediaSubtype = [ + .videoHighFrameRate, + .videoTimelapse, + .videoCinematic + ] + guard asset.mediaSubtypes.intersection(cameraVideoSubtypes).isEmpty, + asset.duration >= 5 else { + return false } + + let width = max(asset.pixelWidth, 1) + let height = max(asset.pixelHeight, 1) + let longSide = max(width, height) + let shortSide = min(width, height) + let aspectRatio = Double(longSide) / Double(shortSide) + return longSide >= 1280 && aspectRatio >= 1.75 && aspectRatio <= 2.35 } private func isProbablySimilar(_ lhs: MediaAssetItem, _ rhs: MediaAssetItem) -> Bool { @@ -232,3 +382,21 @@ final class PhotoLibraryScanner { return Double(larger - smaller) / Double(smaller) <= 0.18 } } + +private struct TimelineAccumulator { + let date: Date? + private(set) var estimatedBytes: Int64 = 0 + private(set) var estimatedPhotoBytes: Int64 = 0 + private(set) var estimatedVideoBytes: Int64 = 0 + private(set) var itemCount: Int = 0 + + mutating func add(asset: PHAsset, estimatedBytes: Int64) { + self.estimatedBytes += estimatedBytes + itemCount += 1 + if asset.mediaType == .video { + estimatedVideoBytes += estimatedBytes + } else { + estimatedPhotoBytes += estimatedBytes + } + } +} diff --git a/StorageLens/StorageLensApp.swift b/StorageLens/StorageLensApp.swift index c3cdfd0..a125d91 100644 --- a/StorageLens/StorageLensApp.swift +++ b/StorageLens/StorageLensApp.swift @@ -4,11 +4,13 @@ import SwiftUI @main struct StorageLensApp: App { @StateObject private var permissionManager = PhotoLibraryPermissionManager() + @StateObject private var reviewBasket = ReviewBasketStore() var body: some Scene { WindowGroup { AppRootView() .environmentObject(permissionManager) + .environmentObject(reviewBasket) .environment(\.locale, AppLanguage.primaryLocale) } } diff --git a/StorageLens/Utilities/AppFormatters.swift b/StorageLens/Utilities/AppFormatters.swift index 82417c7..5c6bd2e 100644 --- a/StorageLens/Utilities/AppFormatters.swift +++ b/StorageLens/Utilities/AppFormatters.swift @@ -41,7 +41,8 @@ enum AppFormatters { }() static func fileSize(_ bytes: Int64?) -> String { - guard let bytes, bytes > 0 else { return "待估算" } + guard let bytes else { return "待估算" } + guard bytes > 0 else { return "0 KB" } return byteFormatter.string(fromByteCount: bytes) } @@ -72,7 +73,11 @@ enum AppFormatters { } static func monthKey(for item: MediaAssetItem) -> String { - guard let date = item.creationDate else { return "unknown" } + monthKey(for: item.creationDate) + } + + static func monthKey(for date: Date?) -> String { + guard let date else { return "unknown" } return monthKeyFormatter.string(from: date) } } diff --git a/StorageLens/ViewModels/CleanupViewModels.swift b/StorageLens/ViewModels/CleanupViewModels.swift index c7b76b1..39ac8e7 100644 --- a/StorageLens/ViewModels/CleanupViewModels.swift +++ b/StorageLens/ViewModels/CleanupViewModels.swift @@ -1,6 +1,85 @@ import Combine import Foundation +@MainActor +final class ReviewBasketStore: ObservableObject { + @Published private(set) var itemsByID: [String: ReviewBasketItem] = [:] + @Published private(set) var isDeleting = false + @Published var message: UserMessage? + @Published var cleanupResult: CleanupResult? + + private let deletionService: PhotoDeletionService + + init(deletionService: PhotoDeletionService = PhotoDeletionService()) { + self.deletionService = deletionService + } + + var items: [ReviewBasketItem] { + itemsByID.values.sorted { + ($0.item.estimatedFileSize ?? 0, $0.item.creationDate ?? .distantPast) > + ($1.item.estimatedFileSize ?? 0, $1.item.creationDate ?? .distantPast) + } + } + + var itemCount: Int { itemsByID.count } + + var estimatedBytes: Int64 { + itemsByID.values.compactMap(\.item.estimatedFileSize).reduce(0, +) + } + + var includedCategoryTitles: String { + let kinds = Set(itemsByID.values.flatMap(\.categoryKinds)) + return CleanupCategoryKind.allCases + .filter { kinds.contains($0) } + .map(\.title) + .joined(separator: "、") + } + + func add(_ items: [MediaAssetItem], from categoryKind: CleanupCategoryKind) { + for item in items { + let categoryKinds = itemsByID[item.id]?.categoryKinds.union([categoryKind]) ?? [categoryKind] + itemsByID[item.id] = ReviewBasketItem(item: item, categoryKinds: categoryKinds) + } + Haptics.selectionChanged() + } + + func remove(_ item: ReviewBasketItem) { + itemsByID.removeValue(forKey: item.id) + Haptics.selectionChanged() + } + + func clear() { + itemsByID.removeAll() + } + + func deleteAll() async { + let ids = Set(itemsByID.keys) + let requestedItems = itemsByID + guard !ids.isEmpty else { return } + + isDeleting = true + defer { isDeleting = false } + + do { + let deletedIDs = try await deletionService.deleteAssets(withLocalIdentifiers: ids) + let deletedBytes = deletedIDs + .compactMap { requestedItems[$0]?.item.estimatedFileSize } + .reduce(0, +) + itemsByID.removeAll() + cleanupResult = CleanupResult( + deletedCount: deletedIDs.count, + unavailableCount: ids.subtracting(deletedIDs).count, + estimatedBytes: deletedBytes + ) + if !deletedIDs.isEmpty { + Haptics.cleanupSucceeded() + } + } catch { + message = UserMessage(title: "删除失败", message: error.localizedDescription) + } + } +} + enum OldMediaFilter: String, CaseIterable, Identifiable { case sixMonths case oneYear @@ -33,6 +112,62 @@ enum OldMediaFilter: String, CaseIterable, Identifiable { } } +enum ScreenshotAgeFilter: String, CaseIterable, Identifiable { + case all + case threeMonths + case sixMonths + case oneYear + + var id: String { rawValue } + + var title: String { + switch self { + case .all: + return "全部" + case .threeMonths: + return "3 个月+" + case .sixMonths: + return "6 个月+" + case .oneYear: + return "1 年+" + } + } + + var cutoffDate: Date? { + let months: Int + switch self { + case .all: + return nil + case .threeMonths: + months = -3 + case .sixMonths: + months = -6 + case .oneYear: + months = -12 + } + return Calendar.current.date(byAdding: .month, value: months, to: Date()) + } +} + +enum MediaSort: String, CaseIterable, Identifiable { + case estimatedSize + case date + case duration + + var id: String { rawValue } + + var title: String { + switch self { + case .estimatedSize: + return "按大小" + case .date: + return "按日期" + case .duration: + return "按时长" + } + } +} + enum OldMediaSort: String, CaseIterable, Identifiable { case estimatedSize case date @@ -53,34 +188,36 @@ enum OldMediaSort: String, CaseIterable, Identifiable { final class LargeVideosViewModel: ObservableObject { @Published private(set) var items: [MediaAssetItem] = [] @Published var selectedIDs: Set = [] + @Published var sort: MediaSort = .estimatedSize { + didSet { applySort() } + } @Published private(set) var isLoading = false - @Published private(set) var isDeleting = false @Published var message: UserMessage? private let scanner: PhotoLibraryScanner - private let deletionService: PhotoDeletionService - init( - scanner: PhotoLibraryScanner = PhotoLibraryScanner(), - deletionService: PhotoDeletionService = PhotoDeletionService() - ) { + init(scanner: PhotoLibraryScanner = PhotoLibraryScanner()) { self.scanner = scanner - self.deletionService = deletionService } var selectedEstimatedBytes: Int64 { - items - .filter { selectedIDs.contains($0.id) } + selectedItems .compactMap(\.estimatedFileSize) .reduce(0, +) } + var selectedItems: [MediaAssetItem] { + items + .filter { selectedIDs.contains($0.id) } + } + func load() async { isLoading = true defer { isLoading = false } do { items = try await scanner.fetchLargeVideos() + applySort() selectedIDs = selectedIDs.intersection(Set(items.map(\.id))) } catch { message = UserMessage(title: "无法载入大视频", message: error.localizedDescription) @@ -96,27 +233,8 @@ final class LargeVideosViewModel: ObservableObject { Haptics.selectionChanged() } - func deleteSelected() async { - let ids = selectedIDs - let itemCount = ids.count - let estimatedBytes = selectedEstimatedBytes - guard !ids.isEmpty else { return } - - isDeleting = true - defer { isDeleting = false } - - do { - try await deletionService.deleteAssets(withLocalIdentifiers: ids) - Haptics.cleanupSucceeded() - selectedIDs.removeAll() - await load() - message = UserMessage( - title: "已删除所选视频", - message: "已删除 \(itemCount) 个项目,预计释放 \(AppFormatters.fileSize(estimatedBytes))。" - ) - } catch { - message = UserMessage(title: "删除失败", message: error.localizedDescription) - } + private func applySort() { + items.sort(using: sort) } } @@ -124,19 +242,14 @@ final class LargeVideosViewModel: ObservableObject { final class ScreenshotsViewModel: ObservableObject { @Published private(set) var groups: [ScreenshotMonthGroup] = [] @Published var selectedIDs: Set = [] + @Published var filter: ScreenshotAgeFilter = .all @Published private(set) var isLoading = false - @Published private(set) var isDeleting = false @Published var message: UserMessage? private let scanner: PhotoLibraryScanner - private let deletionService: PhotoDeletionService - init( - scanner: PhotoLibraryScanner = PhotoLibraryScanner(), - deletionService: PhotoDeletionService = PhotoDeletionService() - ) { + init(scanner: PhotoLibraryScanner = PhotoLibraryScanner()) { self.scanner = scanner - self.deletionService = deletionService } var allItems: [MediaAssetItem] { @@ -144,19 +257,27 @@ final class ScreenshotsViewModel: ObservableObject { } var selectedEstimatedBytes: Int64 { - allItems - .filter { selectedIDs.contains($0.id) } + selectedItems .compactMap(\.estimatedFileSize) .reduce(0, +) } + var selectedItems: [MediaAssetItem] { + allItems + .filter { selectedIDs.contains($0.id) } + } + func load() async { isLoading = true defer { isLoading = false } do { - let items = try await scanner.fetchScreenshots() - groups = Dictionary(grouping: items) { item in + let loadedItems = try await scanner.fetchScreenshots() + let filteredItems = loadedItems.filter { item in + guard let cutoffDate = filter.cutoffDate else { return true } + return (item.creationDate ?? .distantFuture) < cutoffDate + } + groups = Dictionary(grouping: filteredItems) { item in AppFormatters.monthKey(for: item) } .map { key, items in @@ -167,7 +288,7 @@ final class ScreenshotsViewModel: ObservableObject { ) } .sorted { $0.id > $1.id } - selectedIDs = selectedIDs.intersection(Set(items.map(\.id))) + selectedIDs = selectedIDs.intersection(Set(filteredItems.map(\.id))) } catch { message = UserMessage(title: "无法载入截图", message: error.localizedDescription) } @@ -182,6 +303,11 @@ final class ScreenshotsViewModel: ObservableObject { Haptics.selectionChanged() } + func isMonthFullySelected(_ group: ScreenshotMonthGroup) -> Bool { + let groupIDs = Set(group.items.map(\.id)) + return !groupIDs.isEmpty && groupIDs.isSubset(of: selectedIDs) + } + func toggleMonth(_ group: ScreenshotMonthGroup) { let groupIDs = Set(group.items.map(\.id)) if groupIDs.isSubset(of: selectedIDs) { @@ -192,27 +318,99 @@ final class ScreenshotsViewModel: ObservableObject { Haptics.selectionChanged() } - func deleteSelected() async { - let ids = selectedIDs - let itemCount = ids.count - let estimatedBytes = selectedEstimatedBytes - guard !ids.isEmpty else { return } +} - isDeleting = true - defer { isDeleting = false } +@MainActor +final class ScreenRecordingsViewModel: ObservableObject { + @Published private(set) var items: [MediaAssetItem] = [] + @Published var selectedIDs: Set = [] + @Published var sort: MediaSort = .estimatedSize { + didSet { applySort() } + } + @Published private(set) var isLoading = false + @Published var message: UserMessage? + + private let scanner: PhotoLibraryScanner + + init(scanner: PhotoLibraryScanner = PhotoLibraryScanner()) { + self.scanner = scanner + } + + var selectedEstimatedBytes: Int64 { + selectedItems.compactMap(\.estimatedFileSize).reduce(0, +) + } + + var selectedItems: [MediaAssetItem] { + items.filter { selectedIDs.contains($0.id) } + } + + func load() async { + isLoading = true + defer { isLoading = false } do { - try await deletionService.deleteAssets(withLocalIdentifiers: ids) - Haptics.cleanupSucceeded() - selectedIDs.removeAll() - await load() - message = UserMessage( - title: "已删除所选截图", - message: "已删除 \(itemCount) 个项目,预计释放 \(AppFormatters.fileSize(estimatedBytes))。" - ) + items = try await scanner.fetchScreenRecordings() + applySort() + selectedIDs = selectedIDs.intersection(Set(items.map(\.id))) } catch { - message = UserMessage(title: "删除失败", message: error.localizedDescription) + message = UserMessage(title: "无法载入可能的屏幕录制", message: error.localizedDescription) + } + } + + func toggleSelection(for item: MediaAssetItem) { + if selectedIDs.contains(item.id) { + selectedIDs.remove(item.id) + } else { + selectedIDs.insert(item.id) } + Haptics.selectionChanged() + } + + private func applySort() { + items.sort(using: sort) + } +} + +@MainActor +final class LivePhotosViewModel: ObservableObject { + @Published private(set) var items: [MediaAssetItem] = [] + @Published var selectedIDs: Set = [] + @Published private(set) var isLoading = false + @Published var message: UserMessage? + + private let scanner: PhotoLibraryScanner + + init(scanner: PhotoLibraryScanner = PhotoLibraryScanner()) { + self.scanner = scanner + } + + var selectedEstimatedBytes: Int64 { + selectedItems.compactMap(\.estimatedFileSize).reduce(0, +) + } + + var selectedItems: [MediaAssetItem] { + items.filter { selectedIDs.contains($0.id) } + } + + func load() async { + isLoading = true + defer { isLoading = false } + + do { + items = try await scanner.fetchLivePhotos() + selectedIDs = selectedIDs.intersection(Set(items.map(\.id))) + } catch { + message = UserMessage(title: "无法载入 Live Photos", message: error.localizedDescription) + } + } + + func toggleSelection(for item: MediaAssetItem) { + if selectedIDs.contains(item.id) { + selectedIDs.remove(item.id) + } else { + selectedIDs.insert(item.id) + } + Haptics.selectionChanged() } } @@ -225,27 +423,25 @@ final class OldMediaViewModel: ObservableObject { didSet { applySort() } } @Published private(set) var isLoading = false - @Published private(set) var isDeleting = false @Published var message: UserMessage? private let scanner: PhotoLibraryScanner - private let deletionService: PhotoDeletionService - init( - scanner: PhotoLibraryScanner = PhotoLibraryScanner(), - deletionService: PhotoDeletionService = PhotoDeletionService() - ) { + init(scanner: PhotoLibraryScanner = PhotoLibraryScanner()) { self.scanner = scanner - self.deletionService = deletionService } var selectedEstimatedBytes: Int64 { - items - .filter { selectedIDs.contains($0.id) } + selectedItems .compactMap(\.estimatedFileSize) .reduce(0, +) } + var selectedItems: [MediaAssetItem] { + items + .filter { selectedIDs.contains($0.id) } + } + func load() async { isLoading = true defer { isLoading = false } @@ -268,29 +464,6 @@ final class OldMediaViewModel: ObservableObject { Haptics.selectionChanged() } - func deleteSelected() async { - let ids = selectedIDs - let itemCount = ids.count - let estimatedBytes = selectedEstimatedBytes - guard !ids.isEmpty else { return } - - isDeleting = true - defer { isDeleting = false } - - do { - try await deletionService.deleteAssets(withLocalIdentifiers: ids) - Haptics.cleanupSucceeded() - selectedIDs.removeAll() - await load() - message = UserMessage( - title: "已删除所选旧媒体", - message: "已删除 \(itemCount) 个项目,预计释放 \(AppFormatters.fileSize(estimatedBytes))。" - ) - } catch { - message = UserMessage(title: "删除失败", message: error.localizedDescription) - } - } - private func applySort() { switch sort { case .estimatedSize: @@ -306,23 +479,37 @@ final class OldMediaViewModel: ObservableObject { } } +private extension Array where Element == MediaAssetItem { + mutating func sort(using sort: MediaSort) { + switch sort { + case .estimatedSize: + self.sort { + ($0.estimatedFileSize ?? 0, $0.creationDate ?? .distantPast) > + ($1.estimatedFileSize ?? 0, $1.creationDate ?? .distantPast) + } + case .date: + self.sort { + ($0.creationDate ?? .distantPast) > ($1.creationDate ?? .distantPast) + } + case .duration: + self.sort { + ($0.duration, $0.estimatedFileSize ?? 0) > ($1.duration, $1.estimatedFileSize ?? 0) + } + } + } +} + @MainActor final class SimilarPhotosViewModel: ObservableObject { @Published private(set) var groups: [SimilarPhotoGroup] = [] @Published var selectedIDs: Set = [] @Published private(set) var isLoading = false - @Published private(set) var isDeleting = false @Published var message: UserMessage? private let scanner: PhotoLibraryScanner - private let deletionService: PhotoDeletionService - init( - scanner: PhotoLibraryScanner = PhotoLibraryScanner(), - deletionService: PhotoDeletionService = PhotoDeletionService() - ) { + init(scanner: PhotoLibraryScanner = PhotoLibraryScanner()) { self.scanner = scanner - self.deletionService = deletionService } var allItems: [MediaAssetItem] { @@ -330,12 +517,23 @@ final class SimilarPhotosViewModel: ObservableObject { } var selectedEstimatedBytes: Int64 { - allItems - .filter { selectedIDs.contains($0.id) } + selectedItems .compactMap(\.estimatedFileSize) .reduce(0, +) } + var selectedItems: [MediaAssetItem] { + allItems + .filter { selectedIDs.contains($0.id) } + } + + var selectedRecommendedKeepCount: Int { + groups.reduce(0) { count, group in + guard let keepID = group.recommendedKeepID else { return count } + return selectedIDs.contains(keepID) ? count + 1 : count + } + } + func load() async { isLoading = true defer { isLoading = false } @@ -357,26 +555,16 @@ final class SimilarPhotosViewModel: ObservableObject { Haptics.selectionChanged() } - func deleteSelected() async { - let ids = selectedIDs - let itemCount = ids.count - let estimatedBytes = selectedEstimatedBytes - guard !ids.isEmpty else { return } + func selectLikelyDuplicates(in group: SimilarPhotoGroup) { + let keepID = group.recommendedKeepID + let duplicateIDs = group.items + .map(\.id) + .filter { $0 != keepID } - isDeleting = true - defer { isDeleting = false } - - do { - try await deletionService.deleteAssets(withLocalIdentifiers: ids) - Haptics.cleanupSucceeded() - selectedIDs.removeAll() - await load() - message = UserMessage( - title: "已删除所选照片", - message: "已删除 \(itemCount) 个项目,预计释放 \(AppFormatters.fileSize(estimatedBytes))。" - ) - } catch { - message = UserMessage(title: "删除失败", message: error.localizedDescription) + selectedIDs.formUnion(duplicateIDs) + if let keepID { + selectedIDs.remove(keepID) } + Haptics.selectionChanged() } } diff --git a/StorageLens/Views/AppRootView.swift b/StorageLens/Views/AppRootView.swift index 99c40a6..3027028 100644 --- a/StorageLens/Views/AppRootView.swift +++ b/StorageLens/Views/AppRootView.swift @@ -21,4 +21,5 @@ struct AppRootView: View { #Preview { AppRootView() .environmentObject(PhotoLibraryPermissionManager()) + .environmentObject(ReviewBasketStore()) } diff --git a/StorageLens/Views/CleanupViews.swift b/StorageLens/Views/CleanupViews.swift index bbb274d..08263e8 100644 --- a/StorageLens/Views/CleanupViews.swift +++ b/StorageLens/Views/CleanupViews.swift @@ -2,10 +2,18 @@ import SwiftUI struct LargeVideosView: View { @StateObject private var viewModel = LargeVideosViewModel() - @State private var showsDeleteConfirmation = false + @EnvironmentObject private var reviewBasket: ReviewBasketStore var body: some View { List { + Section { + Picker("排序", selection: $viewModel.sort) { + ForEach(MediaSort.allCases) { sort in + Text(sort.title).tag(sort) + } + } + } + if viewModel.isLoading && viewModel.items.isEmpty { ProgressView("正在分析大视频...") } else if viewModel.items.isEmpty { @@ -27,35 +35,22 @@ struct LargeVideosView: View { .navigationTitle("大视频") .toolbar { ToolbarItem(placement: .topBarTrailing) { - Button { - showsDeleteConfirmation = true - } label: { - Label("删除", systemImage: "trash") - } - .disabled(viewModel.selectedIDs.isEmpty || viewModel.isDeleting) - .accessibilityLabel("删除所选大视频") + ReviewBasketLink() } } .safeAreaInset(edge: .bottom) { SelectionSummaryBar( selectedCount: viewModel.selectedIDs.count, estimatedBytes: viewModel.selectedEstimatedBytes, - isWorking: viewModel.isDeleting, - actionTitle: "删除所选" + isWorking: false, + actionTitle: "加入篮子" ) { - showsDeleteConfirmation = true + reviewBasket.add(viewModel.selectedItems, from: .largeVideos) + viewModel.selectedIDs.removeAll() } } .task { await viewModel.load() } .refreshable { await viewModel.load() } - .confirmationDialog("确认删除所选视频?", isPresented: $showsDeleteConfirmation, titleVisibility: .visible) { - Button("删除所选视频", role: .destructive) { - Task { await viewModel.deleteSelected() } - } - Button("取消", role: .cancel) {} - } message: { - Text("这些项目会从系统照片图库中删除。请确认你已经选好了要移除的内容。") - } .alert(item: $viewModel.message) { message in Alert(title: Text(message.title), message: Text(message.message), dismissButton: .default(Text("好"))) } @@ -64,7 +59,7 @@ struct LargeVideosView: View { struct ScreenshotsView: View { @StateObject private var viewModel = ScreenshotsViewModel() - @State private var showsDeleteConfirmation = false + @EnvironmentObject private var reviewBasket: ReviewBasketStore private let columns = [ GridItem(.adaptive(minimum: 96, maximum: 140), spacing: 10) @@ -73,6 +68,13 @@ struct ScreenshotsView: View { var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 22) { + Picker("时间范围", selection: $viewModel.filter) { + ForEach(ScreenshotAgeFilter.allCases) { filter in + Text(filter.title).tag(filter) + } + } + .pickerStyle(.segmented) + if viewModel.isLoading && viewModel.groups.isEmpty { ProgressView("正在整理截图...") .frame(maxWidth: .infinity) @@ -87,11 +89,12 @@ struct ScreenshotsView: View { } else { ForEach(viewModel.groups) { group in VStack(alignment: .leading, spacing: 10) { + let isFullySelected = viewModel.isMonthFullySelected(group) HStack { VStack(alignment: .leading, spacing: 2) { Text(group.title) .font(.headline) - Text("\(group.items.count) 张截图") + Text("\(group.items.count) 张截图 · 估算 \(AppFormatters.fileSize(group.estimatedBytes))") .font(.footnote) .foregroundStyle(.secondary) } @@ -101,11 +104,10 @@ struct ScreenshotsView: View { Button { viewModel.toggleMonth(group) } label: { - Label("全选", systemImage: "checkmark.circle") - .labelStyle(.iconOnly) + Label(isFullySelected ? "取消" : "全选", systemImage: isFullySelected ? "minus.circle" : "checkmark.circle") } .buttonStyle(.bordered) - .accessibilityLabel("选择 \(group.title) 的全部截图") + .accessibilityLabel(isFullySelected ? "取消选择 \(group.title) 的全部截图" : "选择 \(group.title) 的全部截图") } LazyVGrid(columns: columns, spacing: 10) { @@ -127,35 +129,144 @@ struct ScreenshotsView: View { .navigationTitle("屏幕截图") .toolbar { ToolbarItem(placement: .topBarTrailing) { - Button { - showsDeleteConfirmation = true - } label: { - Label("删除", systemImage: "trash") + ReviewBasketLink() + } + } + .safeAreaInset(edge: .bottom) { + SelectionSummaryBar( + selectedCount: viewModel.selectedIDs.count, + estimatedBytes: viewModel.selectedEstimatedBytes, + isWorking: false, + actionTitle: "加入篮子" + ) { + reviewBasket.add(viewModel.selectedItems, from: .screenshots) + viewModel.selectedIDs.removeAll() + } + } + .task(id: viewModel.filter) { + await viewModel.load() + } + .refreshable { await viewModel.load() } + .alert(item: $viewModel.message) { message in + Alert(title: Text(message.title), message: Text(message.message), dismissButton: .default(Text("好"))) + } + } +} + +struct ScreenRecordingsView: View { + @StateObject private var viewModel = ScreenRecordingsViewModel() + @EnvironmentObject private var reviewBasket: ReviewBasketStore + + var body: some View { + List { + Section { + Text("这里显示可能的屏幕录制视频。识别优先使用照片图库标记,并结合本机尺寸和时长线索,可能并不完全准确。") + .font(.footnote) + .foregroundStyle(.secondary) + + Picker("排序", selection: $viewModel.sort) { + ForEach(MediaSort.allCases) { sort in + Text(sort.title).tag(sort) + } } - .disabled(viewModel.selectedIDs.isEmpty || viewModel.isDeleting) - .accessibilityLabel("删除所选截图") + } + + if viewModel.isLoading && viewModel.items.isEmpty { + ProgressView("正在查找可能的屏幕录制...") + } else if viewModel.items.isEmpty { + EmptyStateView( + systemImage: "record.circle", + title: "暂未发现可能的屏幕录制", + message: "检测结果会以谨慎方式显示,你始终手动决定是否加入篮子。" + ) + } else { + Section("可能的屏幕录制 / Possible") { + ForEach(viewModel.items) { item in + MediaAssetRow( + item: item, + isSelected: viewModel.selectedIDs.contains(item.id), + onToggle: { viewModel.toggleSelection(for: item) } + ) + } + } + } + } + .navigationTitle("可能的屏幕录制") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + ReviewBasketLink() } } .safeAreaInset(edge: .bottom) { SelectionSummaryBar( selectedCount: viewModel.selectedIDs.count, estimatedBytes: viewModel.selectedEstimatedBytes, - isWorking: viewModel.isDeleting, - actionTitle: "删除所选" + isWorking: false, + actionTitle: "加入篮子" ) { - showsDeleteConfirmation = true + reviewBasket.add(viewModel.selectedItems, from: .screenRecordings) + viewModel.selectedIDs.removeAll() } } .task { await viewModel.load() } .refreshable { await viewModel.load() } - .confirmationDialog("确认删除所选截图?", isPresented: $showsDeleteConfirmation, titleVisibility: .visible) { - Button("删除所选截图", role: .destructive) { - Task { await viewModel.deleteSelected() } + .alert(item: $viewModel.message) { message in + Alert(title: Text(message.title), message: Text(message.message), dismissButton: .default(Text("好"))) + } + } +} + +struct LivePhotosView: View { + @StateObject private var viewModel = LivePhotosViewModel() + @EnvironmentObject private var reviewBasket: ReviewBasketStore + + var body: some View { + List { + Section { + Text("StorageLens 只帮助你查看和选择 Live Photos,不会转换、压缩或修改它们。") + .font(.footnote) + .foregroundStyle(.secondary) + } + + if viewModel.isLoading && viewModel.items.isEmpty { + ProgressView("正在查找 Live Photos...") + } else if viewModel.items.isEmpty { + EmptyStateView( + systemImage: "livephoto.slash", + title: "暂未发现 Live Photos", + message: "发现 Live Photos 后会显示在这里。" + ) + } else { + Section("Live Photos") { + ForEach(viewModel.items) { item in + MediaAssetRow( + item: item, + isSelected: viewModel.selectedIDs.contains(item.id), + onToggle: { viewModel.toggleSelection(for: item) } + ) + } + } + } + } + .navigationTitle("Live Photos") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + ReviewBasketLink() } - Button("取消", role: .cancel) {} - } message: { - Text("删除前请确认这些截图不再需要。") } + .safeAreaInset(edge: .bottom) { + SelectionSummaryBar( + selectedCount: viewModel.selectedIDs.count, + estimatedBytes: viewModel.selectedEstimatedBytes, + isWorking: false, + actionTitle: "加入篮子" + ) { + reviewBasket.add(viewModel.selectedItems, from: .livePhotos) + viewModel.selectedIDs.removeAll() + } + } + .task { await viewModel.load() } + .refreshable { await viewModel.load() } .alert(item: $viewModel.message) { message in Alert(title: Text(message.title), message: Text(message.message), dismissButton: .default(Text("好"))) } @@ -164,7 +275,7 @@ struct ScreenshotsView: View { struct SimilarPhotosView: View { @StateObject private var viewModel = SimilarPhotosViewModel() - @State private var showsDeleteConfirmation = false + @EnvironmentObject private var reviewBasket: ReviewBasketStore private let columns = [ GridItem(.adaptive(minimum: 104, maximum: 150), spacing: 10) @@ -194,11 +305,18 @@ struct SimilarPhotosView: View { VStack(alignment: .leading, spacing: 2) { Text(group.title) .font(.headline) - Text("建议保留一张 / Keep one suggested") + Text("\(group.items.count) 张,保留建议外估算 \(AppFormatters.fileSize(group.estimatedDuplicateBytes))") .font(.footnote) .foregroundStyle(.secondary) } + Button { + viewModel.selectLikelyDuplicates(in: group) + } label: { + Label("选择建议项", systemImage: "checkmark.circle") + } + .buttonStyle(.bordered) + LazyVGrid(columns: columns, spacing: 10) { ForEach(group.items) { item in MediaGridTile( @@ -218,44 +336,274 @@ struct SimilarPhotosView: View { .navigationTitle("相似照片") .toolbar { ToolbarItem(placement: .topBarTrailing) { - Button { - showsDeleteConfirmation = true - } label: { - Label("删除", systemImage: "trash") - } - .disabled(viewModel.selectedIDs.isEmpty || viewModel.isDeleting) - .accessibilityLabel("删除所选相似照片") + ReviewBasketLink() } } .safeAreaInset(edge: .bottom) { - SelectionSummaryBar( - selectedCount: viewModel.selectedIDs.count, - estimatedBytes: viewModel.selectedEstimatedBytes, - isWorking: viewModel.isDeleting, - actionTitle: "删除所选" - ) { - showsDeleteConfirmation = true + VStack(spacing: 0) { + if viewModel.selectedRecommendedKeepCount > 0 { + Label("已选中建议保留的照片", systemImage: "exclamationmark.triangle") + .font(.footnote) + .foregroundStyle(.orange) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.top, 10) + } + + SelectionSummaryBar( + selectedCount: viewModel.selectedIDs.count, + estimatedBytes: viewModel.selectedEstimatedBytes, + isWorking: false, + actionTitle: "加入篮子" + ) { + reviewBasket.add(viewModel.selectedItems, from: .similarPhotos) + viewModel.selectedIDs.removeAll() + } } + .background(.bar) } .task { await viewModel.load() } .refreshable { await viewModel.load() } - .confirmationDialog("确认删除所选照片?", isPresented: $showsDeleteConfirmation, titleVisibility: .visible) { - Button("删除所选照片", role: .destructive) { - Task { await viewModel.deleteSelected() } + .alert(item: $viewModel.message) { message in + Alert(title: Text(message.title), message: Text(message.message), dismissButton: .default(Text("好"))) + } + } +} + +struct ReviewBasketView: View { + @EnvironmentObject private var reviewBasket: ReviewBasketStore + @State private var showsDeleteReview = false + @State private var showsClearConfirmation = false + + var body: some View { + List { + if reviewBasket.items.isEmpty { + EmptyStateView( + systemImage: "basket", + title: "篮子是空的", + message: "在各分类中选择项目并加入篮子后,再到这里统一确认删除。" + ) + } else { + Section("汇总 / Summary") { + LabeledContent("项目", value: "\(reviewBasket.itemCount)") + LabeledContent("估算大小", value: AppFormatters.fileSize(reviewBasket.estimatedBytes)) + LabeledContent("包含分类", value: reviewBasket.includedCategoryTitles) + } + + Section("待确认项目 / Review") { + ForEach(reviewBasket.items) { basketItem in + HStack(spacing: 12) { + MediaThumbnailView(localIdentifier: basketItem.item.id, sideLength: 56) + + VStack(alignment: .leading, spacing: 3) { + Text(basketItem.item.kind.title) + .font(.headline) + Text("\(basketItem.categoryTitles) · 估算 \(AppFormatters.fileSize(basketItem.item.estimatedFileSize))") + .font(.footnote) + .foregroundStyle(.secondary) + Text(basketItem.item.creationDate.map(AppFormatters.date) ?? "日期未知") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + Button { + reviewBasket.remove(basketItem) + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + .accessibilityLabel("从篮子移除") + } + .padding(.vertical, 4) + } + } + } + } + .navigationTitle("Review Basket") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if !reviewBasket.items.isEmpty { + Button("清空", role: .destructive) { + showsClearConfirmation = true + } + } + } + } + .safeAreaInset(edge: .bottom) { + if !reviewBasket.items.isEmpty { + SelectionSummaryBar( + selectedCount: reviewBasket.itemCount, + estimatedBytes: reviewBasket.estimatedBytes, + isWorking: reviewBasket.isDeleting, + actionTitle: "最终确认", + actionSystemImage: "trash" + ) { + showsDeleteReview = true + } + } + } + .navigationDestination(isPresented: $showsDeleteReview) { + BasketDeleteConfirmationView() + } + .confirmationDialog("清空 Review Basket?", isPresented: $showsClearConfirmation, titleVisibility: .visible) { + Button("清空篮子", role: .destructive) { + reviewBasket.clear() } Button("取消", role: .cancel) {} } message: { - Text("StorageLens 不会自动删除相似照片,请确认你已经手动选中要删除的项目。") + Text("这只会移除待确认列表,不会删除照片图库中的任何项目。") } - .alert(item: $viewModel.message) { message in + .sheet(item: $reviewBasket.cleanupResult) { result in + CleanupResultView(result: result) + } + .alert(item: $reviewBasket.message) { message in Alert(title: Text(message.title), message: Text(message.message), dismissButton: .default(Text("好"))) } } } +struct BasketDeleteConfirmationView: View { + @EnvironmentObject private var reviewBasket: ReviewBasketStore + @Environment(\.dismiss) private var dismiss + + var body: some View { + List { + Section { + VStack(spacing: 12) { + Image(systemName: "trash.circle") + .font(.system(size: 48)) + .foregroundStyle(.red) + .accessibilityHidden(true) + + Text("最终确认") + .font(.title2.bold()) + + Text("请确认这些是你想从系统照片图库移除的项目。StorageLens 不会自动删除任何内容。") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + } + + Section("待删除项目 / Review") { + LabeledContent("项目", value: "\(reviewBasket.itemCount)") + LabeledContent("估算大小", value: AppFormatters.fileSize(reviewBasket.estimatedBytes)) + LabeledContent("包含分类", value: reviewBasket.includedCategoryTitles) + } + + Section { + Text("确认后,这些项目会进入系统照片 App 的“最近删除”。你仍可以在系统允许的时间内前往照片 App 恢复。") + .foregroundStyle(.secondary) + } + + Section { + Button(role: .destructive) { + Task { + await reviewBasket.deleteAll() + if reviewBasket.cleanupResult != nil { + dismiss() + } + } + } label: { + if reviewBasket.isDeleting { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Label("删除所选项目", systemImage: "trash") + .frame(maxWidth: .infinity) + } + } + .disabled(reviewBasket.isDeleting || reviewBasket.items.isEmpty) + } + } + .navigationTitle("最终确认") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("取消") { + dismiss() + } + .disabled(reviewBasket.isDeleting) + } + } + } +} + +struct CleanupResultView: View { + let result: CleanupResult + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section { + VStack(spacing: 12) { + Image(systemName: result.deletedCount > 0 ? "checkmark.circle" : "photo.badge.exclamationmark") + .font(.system(size: 48)) + .foregroundStyle(result.deletedCount > 0 ? Color.green : Color.secondary) + Text(result.deletedCount > 0 ? "删除完成" : "没有可删除项目") + .font(.title2.bold()) + Text(resultSummary) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + } + + if result.deletedCount > 0 { + Section { + Text("已删除项目会进入系统照片的“最近删除”。如果需要恢复,请前往照片 App 查看。") + .foregroundStyle(.secondary) + } + } + + if result.unavailableCount > 0 { + Section("未处理项目") { + Text("另有 \(result.unavailableCount) 个项目在确认前已不在当前可访问的照片图库中,因此未计入删除结果。") + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("结果") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("完成") { + dismiss() + } + } + } + } + } + + private var resultSummary: String { + guard result.deletedCount > 0 else { + return "确认时没有在当前可访问的照片图库中找到可删除项目。" + } + return "已删除 \(result.deletedCount) 个项目,估算释放 \(AppFormatters.fileSize(result.estimatedBytes))。" + } +} + +struct ReviewBasketLink: View { + @EnvironmentObject private var reviewBasket: ReviewBasketStore + + var body: some View { + NavigationLink { + ReviewBasketView() + } label: { + Label("Review Basket", systemImage: reviewBasket.itemCount > 0 ? "basket.fill" : "basket") + } + .accessibilityLabel("Review Basket,\(reviewBasket.itemCount) 项") + } +} + struct OldMediaView: View { @StateObject private var viewModel = OldMediaViewModel() - @State private var showsDeleteConfirmation = false + @EnvironmentObject private var reviewBasket: ReviewBasketStore var body: some View { List { @@ -297,23 +645,18 @@ struct OldMediaView: View { .navigationTitle("旧媒体") .toolbar { ToolbarItem(placement: .topBarTrailing) { - Button { - showsDeleteConfirmation = true - } label: { - Label("删除", systemImage: "trash") - } - .disabled(viewModel.selectedIDs.isEmpty || viewModel.isDeleting) - .accessibilityLabel("删除所选旧媒体") + ReviewBasketLink() } } .safeAreaInset(edge: .bottom) { SelectionSummaryBar( selectedCount: viewModel.selectedIDs.count, estimatedBytes: viewModel.selectedEstimatedBytes, - isWorking: viewModel.isDeleting, - actionTitle: "删除所选" + isWorking: false, + actionTitle: "加入篮子" ) { - showsDeleteConfirmation = true + reviewBasket.add(viewModel.selectedItems, from: .oldMedia) + viewModel.selectedIDs.removeAll() } } .task { await viewModel.load() } @@ -321,14 +664,6 @@ struct OldMediaView: View { .onChange(of: viewModel.filter) { _, _ in Task { await viewModel.load() } } - .confirmationDialog("确认删除所选旧媒体?", isPresented: $showsDeleteConfirmation, titleVisibility: .visible) { - Button("删除所选旧媒体", role: .destructive) { - Task { await viewModel.deleteSelected() } - } - Button("取消", role: .cancel) {} - } message: { - Text("删除前请确认这些较早的照片或视频不再需要。") - } .alert(item: $viewModel.message) { message in Alert(title: Text(message.title), message: Text(message.message), dismissButton: .default(Text("好"))) } diff --git a/StorageLens/Views/HomeView.swift b/StorageLens/Views/HomeView.swift index 98dd4d3..3472688 100644 --- a/StorageLens/Views/HomeView.swift +++ b/StorageLens/Views/HomeView.swift @@ -12,7 +12,7 @@ struct HomeView: View { VStack(alignment: .leading, spacing: 8) { Text("StorageLens") .font(.largeTitle.bold()) - Text("私密释放空间 / Free up space, privately.") + Text("私密整理图库 / Review storage, privately.") .font(.headline) .foregroundStyle(.secondary) } @@ -20,17 +20,23 @@ struct HomeView: View { } Section { - SummaryMetricCard( - title: "预计可清理空间", - englishTitle: "Estimated cleanable space", - value: AppFormatters.fileSize(viewModel.summary?.estimatedCleanableBytes), - systemImage: "internaldrive" - ) + StorageOverviewCard(summary: viewModel.summary) .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) .listRowBackground(Color.clear) } - Section("清理建议 / Cleanup") { + Section { + Label { + Text("On-device analysis · No photo upload") + } icon: { + Image(systemName: "lock.shield") + .foregroundStyle(.green) + } + .font(.footnote) + .foregroundStyle(.secondary) + } + + Section("整理建议 / Review") { if viewModel.isLoading && viewModel.summary == nil { ProgressView("正在分析照片图库...") } @@ -54,34 +60,54 @@ struct HomeView: View { } Button { - permissionManager.openAppSettings() + permissionManager.presentLimitedLibraryPicker() } label: { - Label("调整照片访问范围", systemImage: "gearshape") + Label("选择更多照片", systemImage: "photo.stack") } } } - Section { - Label { - Text("所有分析都在本机完成。照片不会上传。") - } icon: { - Image(systemName: "lock.shield") - .foregroundStyle(.green) - } - .font(.footnote) - .foregroundStyle(.secondary) - } - if let summary = viewModel.summary { Section("图库概览 / Library") { LabeledContent("照片", value: "\(summary.totalPhotos)") LabeledContent("视频", value: "\(summary.totalVideos)") LabeledContent("上次分析", value: AppFormatters.time(summary.generatedAt)) } + + Section("Storage Timeline") { + if let insightText = summary.insightText { + Text(insightText) + .font(.footnote) + .foregroundStyle(.secondary) + } + + ForEach(summary.topTimelineMonths) { month in + LabeledContent { + Text(AppFormatters.fileSize(month.estimatedBytes)) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(month.title) + Text("\(month.itemCount) 项") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + NavigationLink { + StorageTimelineView(summary: summary) + } label: { + Label("查看全部月份", systemImage: "calendar") + } + } } } .navigationTitle("首页") .toolbar { + ToolbarItem(placement: .topBarLeading) { + ReviewBasketLink() + } + ToolbarItem(placement: .topBarTrailing) { NavigationLink { SettingsView() @@ -114,11 +140,190 @@ struct HomeView: View { LargeVideosView() case .screenshots: ScreenshotsView() - case .similarPhotos: - SimilarPhotosView() + case .screenRecordings: + ScreenRecordingsView() + case .livePhotos: + LivePhotosView() case .oldMedia: OldMediaView() + case .similarPhotos: + SimilarPhotosView() + } + } +} + +private struct StorageOverviewCard: View { + let summary: ScanSummary? + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 12) { + Image(systemName: "internaldrive") + .font(.title2) + .foregroundStyle(.blue) + .frame(width: 44, height: 44) + .background(Color(.tertiarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 3) { + Text("Storage Overview") + .font(.headline) + Text("照片图库估算概览") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + } + + HStack(spacing: 16) { + OverviewMetric( + title: "待整理估算", + value: AppFormatters.fileSize(summary?.estimatedCleanableBytes) + ) + OverviewMetric( + title: "图库估算", + value: AppFormatters.fileSize(summary?.estimatedLibraryBytes) + ) + } + + if let generatedAt = summary?.generatedAt { + Text("上次分析 \(AppFormatters.time(generatedAt))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding() + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } +} + +private struct OverviewMetric: View { + let title: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.title3.bold()) + .contentTransition(.numericText()) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct StorageTimelineView: View { + let summary: ScanSummary + + var body: some View { + List { + Section { + VStack(alignment: .leading, spacing: 12) { + Label("Storage Timeline", systemImage: "calendar") + .font(.headline) + + Text("按月份查看照片图库的估算占用。所有统计都来自本机 PhotoKit 元数据,不上传照片。") + .font(.footnote) + .foregroundStyle(.secondary) + + if let insightText = summary.insightText { + Label(insightText, systemImage: "lightbulb") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + if summary.timelineMonths.isEmpty { + Section { + EmptyStateView( + systemImage: "calendar.badge.exclamationmark", + title: "暂无时间线数据", + message: "完成照片图库分析后,各月份估算占用会显示在这里。" + ) + } + } else { + Section("月份 / Months") { + ForEach(summary.timelineMonths) { month in + TimelineMonthRow( + month: month, + maximumBytes: summary.timelineMonths.first?.estimatedBytes ?? month.estimatedBytes + ) + } + } + } + } + .navigationTitle("Storage Timeline") + } +} + +private struct TimelineMonthRow: View { + let month: StorageTimelineMonth + let maximumBytes: Int64 + + private var fillFraction: Double { + guard maximumBytes > 0 else { return 0 } + return min(Double(month.estimatedBytes) / Double(maximumBytes), 1) + } + + private var videoFraction: Double { + guard month.estimatedBytes > 0 else { return 0 } + return min(Double(month.estimatedVideoBytes) / Double(month.estimatedBytes), 1) + } + + private var dominantMediaText: String { + month.estimatedVideoBytes >= month.estimatedPhotoBytes ? "主要来自视频" : "主要来自照片" + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text(month.title) + .font(.headline) + Text("\(month.itemCount) 项 · \(dominantMediaText)") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(AppFormatters.fileSize(month.estimatedBytes)) + .font(.headline) + .monospacedDigit() + } + + GeometryReader { proxy in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color(.tertiarySystemGroupedBackground)) + + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(.blue.opacity(0.25)) + .frame(width: proxy.size.width * fillFraction) + + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(.blue) + .frame(width: proxy.size.width * fillFraction * videoFraction) + } + } + .frame(height: 8) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 4) { + Label("照片估算 \(AppFormatters.fileSize(month.estimatedPhotoBytes))", systemImage: "photo") + Label("视频估算 \(AppFormatters.fileSize(month.estimatedVideoBytes))", systemImage: "video") + } + .font(.caption) + .foregroundStyle(.secondary) } + .padding(.vertical, 6) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(month.title),\(month.itemCount) 项,估算 \(AppFormatters.fileSize(month.estimatedBytes)),\(dominantMediaText)") } } @@ -128,11 +333,19 @@ private extension ScanSummary { totalPhotos: 0, totalVideos: 0, screenshotCount: 0, + screenRecordingCount: 0, + livePhotoCount: 0, largeVideoCount: 0, similarGroupCount: 0, oldMediaCount: 0, + estimatedCleanableBytes: 0, estimatedLargeVideoBytes: 0, estimatedScreenshotBytes: 0, + estimatedScreenRecordingBytes: 0, + estimatedLivePhotoBytes: 0, + estimatedOldMediaBytes: 0, + estimatedSimilarPhotoBytes: 0, + timelineMonths: [], generatedAt: Date() ) } @@ -141,4 +354,5 @@ private extension ScanSummary { #Preview { HomeView() .environmentObject(PhotoLibraryPermissionManager()) + .environmentObject(ReviewBasketStore()) } diff --git a/StorageLens/Views/PermissionView.swift b/StorageLens/Views/PermissionView.swift index 15f1863..ca24f21 100644 --- a/StorageLens/Views/PermissionView.swift +++ b/StorageLens/Views/PermissionView.swift @@ -8,10 +8,11 @@ struct PermissionView: View { VStack(spacing: 28) { Spacer(minLength: 24) - Image("StorageLensMark") - .resizable() - .scaledToFit() - .frame(width: 96, height: 96) + Image(systemName: "photo.stack.fill") + .font(.system(size: 48, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 92, height: 92) + .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) .accessibilityHidden(true) VStack(spacing: 10) { @@ -42,19 +43,29 @@ struct PermissionView: View { ) PermissionPointRow( systemImage: "checkmark.circle", - title: "由你决定删除内容", - detail: "删除前始终由你手动选择并确认。", - englishDetail: "You choose what to delete." + title: "由你决定整理内容", + detail: "移除前始终由你手动选择并确认。", + englishDetail: "You confirm every change." ) } .padding() .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) if permissionManager.status == .limited { - Label("当前是部分访问模式,可在系统设置中调整可见照片。", systemImage: "exclamationmark.triangle") - .font(.footnote) - .foregroundStyle(.orange) - .multilineTextAlignment(.leading) + VStack(alignment: .leading, spacing: 10) { + Label("当前是部分访问模式,分析结果只包含你选择的照片。", systemImage: "exclamationmark.triangle") + .font(.footnote) + .foregroundStyle(.orange) + .multilineTextAlignment(.leading) + + Button { + permissionManager.presentLimitedLibraryPicker() + } label: { + Label("选择更多照片", systemImage: "photo.stack") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } } VStack(spacing: 12) { diff --git a/StorageLens/Views/SettingsView.swift b/StorageLens/Views/SettingsView.swift index ab1b615..8086bae 100644 --- a/StorageLens/Views/SettingsView.swift +++ b/StorageLens/Views/SettingsView.swift @@ -8,6 +8,14 @@ struct SettingsView: View { Section("权限 / Permissions") { LabeledContent("照片访问", value: permissionManager.statusTitle) + if permissionManager.status == .limited { + Button { + permissionManager.presentLimitedLibraryPicker() + } label: { + Label("选择更多照片", systemImage: "photo.stack") + } + } + Button { permissionManager.openAppSettings() } label: { @@ -58,7 +66,7 @@ struct PrivacyView: View { } Section("边界 / Boundaries") { - Text("StorageLens 只分析你授权的照片图库,不清理 iOS 系统数据,也不清理其他 App 的缓存。") + Text("StorageLens 只分析你授权的照片图库,不处理 iOS 系统数据,也不会读取其他 App 的缓存。") .foregroundStyle(.secondary) } } @@ -95,7 +103,7 @@ struct AboutView: View { } Section("不会做的事 / Cannot Do") { - Text("不会声称清理系统垃圾,不会加速 iPhone,不会移除隐藏缓存,也不会自动删除照片。") + Text("不会处理 iOS 系统数据,不会读取其他 App 的缓存,也不会自动删除照片。") .foregroundStyle(.secondary) } }