From 4c1b6d3cbec83ba9a44df6e5df0da6f47f3a079f Mon Sep 17 00:00:00 2001 From: JONASXZB <88891196+JONASXZB@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:53:24 +0800 Subject: [PATCH 01/14] Optimize photo scanning workflow --- README.md | 1 + StorageLens/Models/AppModels.swift | 20 ++- .../PhotoLibraryPermissionManager.swift | 43 ++++++ .../Services/PhotoLibraryScanner.swift | 135 +++++++++++------- StorageLens/Utilities/AppFormatters.swift | 3 +- .../ViewModels/CleanupViewModels.swift | 27 ++++ StorageLens/Views/CleanupViews.swift | 39 +++-- StorageLens/Views/HomeView.swift | 6 +- StorageLens/Views/PermissionView.swift | 18 ++- StorageLens/Views/SettingsView.swift | 8 ++ 10 files changed, 229 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 42c1e6d..c9fc49e 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ StorageLens 是一个原生 iPhone 应用,用来帮助用户查看照片图库 - 分析用户已授权的照片图库。 - 找出大视频、屏幕截图、旧媒体和可能相似的照片。 - 按月份整理截图,支持多选和删除确认。 +- 对文件大小使用本机快速估算,避免为了排序或首页统计读取完整照片、视频数据。 - 删除前始终要求用户明确确认。 ## 它不能做什么 diff --git a/StorageLens/Models/AppModels.swift b/StorageLens/Models/AppModels.swift index 8d1a17e..49dd543 100644 --- a/StorageLens/Models/AppModels.swift +++ b/StorageLens/Models/AppModels.swift @@ -154,7 +154,25 @@ 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, +) } } 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..c218c67 100644 --- a/StorageLens/Services/PhotoLibraryScanner.swift +++ b/StorageLens/Services/PhotoLibraryScanner.swift @@ -14,30 +14,65 @@ 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 largeVideoCount = 0 + var oldMediaCount = 0 + var estimatedScreenshotBytes: Int64 = 0 + var estimatedLargeVideoBytes: Int64 = 0 + var recentPhotoCandidates: [PHAsset] = [] + + for asset in allAssets { + if Task.isCancelled { break } + + switch asset.mediaType { + case .image: + totalPhotos += 1 + if asset.mediaSubtypes.contains(.photoScreenshot) { + screenshotCount += 1 + estimatedScreenshotBytes += estimatedFileSize(for: asset, kind: .screenshot) + } else if recentPhotoCandidates.count < similarPhotoSummaryLimit { + recentPhotoCandidates.append(asset) + } + case .video: + totalVideos += 1 + let estimatedBytes = estimatedFileSize(for: asset, kind: .video) + if estimatedBytes >= largeVideoThreshold || asset.duration >= 180 { + largeVideoCount += 1 + estimatedLargeVideoBytes += estimatedBytes + } + default: + break + } - 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, +) + if let oneYearCutoff, (asset.creationDate ?? .distantFuture) < oneYearCutoff { + oldMediaCount += 1 + } + } + + let similarGroups = await similarPhotoGroups( + from: recentPhotoCandidates, + maximumAssets: similarPhotoSummaryLimit + ) return ScanSummary( - totalPhotos: photos.count, - totalVideos: videos.count, - screenshotCount: screenshots.count, - largeVideoCount: largeVideos.count, + totalPhotos: totalPhotos, + totalVideos: totalVideos, + screenshotCount: screenshotCount, + largeVideoCount: largeVideoCount, similarGroupCount: similarGroups.count, - oldMediaCount: oldMedia.count, - estimatedLargeVideoBytes: largeVideoBytes, - estimatedScreenshotBytes: Int64(screenshots.count) * screenshotFallbackBytes, + oldMediaCount: oldMediaCount, + estimatedLargeVideoBytes: estimatedLargeVideoBytes, + estimatedScreenshotBytes: estimatedScreenshotBytes, generatedAt: Date() ) } @@ -48,7 +83,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 +104,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 { @@ -87,8 +122,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 +133,21 @@ final class PhotoLibraryScanner { let photoAssets = fetchAssets(mediaType: .image) .filter { !$0.mediaSubtypes.contains(.photoScreenshot) } - .sorted { ($0.creationDate ?? .distantPast) < ($1.creationDate ?? .distantPast) } .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,7 +200,7 @@ final class PhotoLibraryScanner { return assets } - private func makeItem(from asset: PHAsset, includeFileSize: Bool) async -> MediaAssetItem { + private func makeItem(from asset: PHAsset) -> MediaAssetItem { let kind: MediaAssetKind if asset.mediaType == .video { kind = .video @@ -172,7 +213,7 @@ final class PhotoLibraryScanner { 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,38 +221,28 @@ final class PhotoLibraryScanner { ) } - private func estimatedFileSize(for asset: PHAsset) async -> Int64? { - let resources = PHAssetResource.assetResources(for: asset) - var totalBytes: Int64 = 0 - var hasValue = false - - 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: + 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) - return hasValue ? totalBytes : nil - } + case .screenshot: + return max(pixelCount / 2, 700 * 1024) - private func byteCount(for resource: PHAssetResource) async -> Int64? { - await withCheckedContinuation { continuation in - let options = PHAssetResourceRequestOptions() - options.isNetworkAccessAllowed = false - var totalBytes: Int64 = 0 - - PHAssetResourceManager.default().requestData( - for: resource, - options: options, - dataReceivedHandler: { data in - totalBytes += Int64(data.count) - }, - completionHandler: { error in - continuation.resume(returning: error == nil ? totalBytes : nil) - } - ) + case .photo: + return max(pixelCount / 3, 900 * 1024) } } diff --git a/StorageLens/Utilities/AppFormatters.swift b/StorageLens/Utilities/AppFormatters.swift index 82417c7..7dee24f 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) } diff --git a/StorageLens/ViewModels/CleanupViewModels.swift b/StorageLens/ViewModels/CleanupViewModels.swift index c7b76b1..52bb2bf 100644 --- a/StorageLens/ViewModels/CleanupViewModels.swift +++ b/StorageLens/ViewModels/CleanupViewModels.swift @@ -336,6 +336,20 @@ final class SimilarPhotosViewModel: ObservableObject { .reduce(0, +) } + var selectedRecommendedKeepCount: Int { + groups.reduce(0) { count, group in + guard let keepID = group.recommendedKeepID else { return count } + return selectedIDs.contains(keepID) ? count + 1 : count + } + } + + var deleteConfirmationMessage: String { + if selectedRecommendedKeepCount > 0 { + return "你选中了 \(selectedRecommendedKeepCount) 张建议保留的照片。请再次确认这些照片确实不需要。" + } + return "StorageLens 不会自动删除相似照片,请确认你已经手动选中要删除的项目。" + } + func load() async { isLoading = true defer { isLoading = false } @@ -357,6 +371,19 @@ final class SimilarPhotosViewModel: ObservableObject { Haptics.selectionChanged() } + func selectLikelyDuplicates(in group: SimilarPhotoGroup) { + let keepID = group.recommendedKeepID + let duplicateIDs = group.items + .map(\.id) + .filter { $0 != keepID } + + selectedIDs.formUnion(duplicateIDs) + if let keepID { + selectedIDs.remove(keepID) + } + Haptics.selectionChanged() + } + func deleteSelected() async { let ids = selectedIDs let itemCount = ids.count diff --git a/StorageLens/Views/CleanupViews.swift b/StorageLens/Views/CleanupViews.swift index bbb274d..dd92411 100644 --- a/StorageLens/Views/CleanupViews.swift +++ b/StorageLens/Views/CleanupViews.swift @@ -194,11 +194,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( @@ -228,24 +235,36 @@ struct SimilarPhotosView: View { } } .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: viewModel.isDeleting, + actionTitle: "删除所选" + ) { + showsDeleteConfirmation = true + } } + .background(.bar) } .task { await viewModel.load() } .refreshable { await viewModel.load() } .confirmationDialog("确认删除所选照片?", isPresented: $showsDeleteConfirmation, titleVisibility: .visible) { - Button("删除所选照片", role: .destructive) { + Button(viewModel.selectedRecommendedKeepCount > 0 ? "仍然删除所选照片" : "删除所选照片", role: .destructive) { Task { await viewModel.deleteSelected() } } Button("取消", role: .cancel) {} } message: { - Text("StorageLens 不会自动删除相似照片,请确认你已经手动选中要删除的项目。") + Text(viewModel.deleteConfirmationMessage) } .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..ab5abae 100644 --- a/StorageLens/Views/HomeView.swift +++ b/StorageLens/Views/HomeView.swift @@ -21,7 +21,7 @@ struct HomeView: View { Section { SummaryMetricCard( - title: "预计可清理空间", + title: "已估算可清理空间", englishTitle: "Estimated cleanable space", value: AppFormatters.fileSize(viewModel.summary?.estimatedCleanableBytes), systemImage: "internaldrive" @@ -54,9 +54,9 @@ struct HomeView: View { } Button { - permissionManager.openAppSettings() + permissionManager.presentLimitedLibraryPicker() } label: { - Label("调整照片访问范围", systemImage: "gearshape") + Label("选择更多照片", systemImage: "photo.stack") } } } diff --git a/StorageLens/Views/PermissionView.swift b/StorageLens/Views/PermissionView.swift index 15f1863..13e0d82 100644 --- a/StorageLens/Views/PermissionView.swift +++ b/StorageLens/Views/PermissionView.swift @@ -51,10 +51,20 @@ struct PermissionView: View { .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..a92b276 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: { From beb2da5621007af0ad583e4c195890de43f2a180 Mon Sep 17 00:00:00 2001 From: JONASXZB <88891196+JONASXZB@users.noreply.github.com> Date: Fri, 5 Jun 2026 01:38:47 +0800 Subject: [PATCH 02/14] Improve storage review workflow --- README.md | 8 +- StorageLens.xcodeproj/project.pbxproj | 8 +- StorageLens/Components/AppComponents.swift | 3 +- StorageLens/Models/AppModels.swift | 110 ++++- .../Services/PhotoLibraryScanner.swift | 128 +++++- StorageLens/StorageLensApp.swift | 2 + StorageLens/Utilities/AppFormatters.swift | 6 +- .../ViewModels/CleanupViewModels.swift | 405 ++++++++++++------ StorageLens/Views/AppRootView.swift | 1 + StorageLens/Views/CleanupViews.swift | 368 +++++++++++++--- StorageLens/Views/HomeView.swift | 131 +++++- 11 files changed, 923 insertions(+), 247 deletions(-) diff --git a/README.md b/README.md index c9fc49e..a909ea7 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,11 @@ StorageLens 是一个原生 iPhone 应用,用来帮助用户查看照片图库 ## 它能做什么 - 分析用户已授权的照片图库。 -- 找出大视频、屏幕截图、旧媒体和可能相似的照片。 -- 按月份整理截图,支持多选和删除确认。 +- 首页汇总图库估算占用、分类数量、最近扫描时间和本机隐私状态。 +- 找出大视频、屏幕录制、屏幕截图、Live Photos、旧媒体和可能相似的照片。 +- 按月份整理截图,支持月份批量选择和年龄筛选。 +- 用 Review Basket 汇总待处理项目,只有在最终确认页才会请求系统删除。 +- 用月份时间线展示哪些月份可能占用最多空间。 - 对文件大小使用本机快速估算,避免为了排序或首页统计读取完整照片、视频数据。 - 删除前始终要求用户明确确认。 @@ -24,6 +27,7 @@ StorageLens 是一个原生 iPhone 应用,用来帮助用户查看照片图库 - 不清理其他 App 的缓存。 - 不声称“清理系统垃圾”“加速 iPhone”或“删除隐藏缓存”。 - 不会自动删除照片或视频。 +- 不会绕过系统照片权限,也不会访问其他 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..ebacb8a 100644 --- a/StorageLens/Components/AppComponents.swift +++ b/StorageLens/Components/AppComponents.swift @@ -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 { @@ -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 49dd543..9d36505 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" } } } @@ -63,10 +77,29 @@ struct MediaAssetItem: Identifiable, Hashable { 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,34 @@ 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 estimatedLargeVideoBytes: Int64 let estimatedScreenshotBytes: Int64 + let estimatedScreenRecordingBytes: Int64 + let estimatedLivePhotoBytes: Int64 + let timelineMonths: [StorageTimelineMonth] let generatedAt: Date var estimatedCleanableBytes: Int64 { - estimatedLargeVideoBytes + estimatedScreenshotBytes + estimatedLargeVideoBytes + estimatedScreenshotBytes + estimatedScreenRecordingBytes + estimatedLivePhotoBytes + } + + 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] { @@ -117,13 +169,22 @@ struct ScanSummary: Hashable { estimatedBytes: estimatedScreenshotBytes ), CleanupCategory( - kind: .similarPhotos, - title: "相似照片", - englishTitle: "Similar Photos", - detail: "按时间和尺寸线索找出可能相似的照片", - systemImage: "square.stack.3d.up", - itemCount: similarGroupCount, - estimatedBytes: nil + kind: .screenRecordings, + title: "屏幕录制", + englishTitle: "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, @@ -133,11 +194,29 @@ struct ScanSummary: Hashable { systemImage: "calendar", itemCount: oldMediaCount, estimatedBytes: nil + ), + CleanupCategory( + kind: .similarPhotos, + title: "相似照片", + englishTitle: "Similar Photos", + detail: "按时间和尺寸线索找出可能相似的照片", + systemImage: "square.stack.3d.up", + itemCount: similarGroupCount, + estimatedBytes: nil ) ] } } +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 @@ -176,6 +255,19 @@ struct SimilarPhotoGroup: Identifiable, Hashable { } } +struct ReviewBasketItem: Identifiable, Hashable { + let item: MediaAssetItem + let categoryKind: CleanupCategoryKind + + var id: String { item.id } +} + +struct CleanupResult: Identifiable, Hashable { + let id = UUID() + let deletedCount: Int + let estimatedBytes: Int64 +} + struct UserMessage: Identifiable { let id = UUID() let title: String diff --git a/StorageLens/Services/PhotoLibraryScanner.swift b/StorageLens/Services/PhotoLibraryScanner.swift index c218c67..2c724be 100644 --- a/StorageLens/Services/PhotoLibraryScanner.swift +++ b/StorageLens/Services/PhotoLibraryScanner.swift @@ -25,27 +25,45 @@ final class PhotoLibraryScanner { 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 timelineBuckets: [String: TimelineAccumulator] = [:] 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 + ) switch asset.mediaType { case .image: totalPhotos += 1 - if asset.mediaSubtypes.contains(.photoScreenshot) { + if kind == .screenshot { screenshotCount += 1 - estimatedScreenshotBytes += estimatedFileSize(for: asset, kind: .screenshot) + estimatedScreenshotBytes += estimatedBytes + } else if kind == .livePhoto { + livePhotoCount += 1 + estimatedLivePhotoBytes += estimatedBytes } else if recentPhotoCandidates.count < similarPhotoSummaryLimit { recentPhotoCandidates.append(asset) } case .video: totalVideos += 1 - let estimatedBytes = estimatedFileSize(for: asset, kind: .video) + if kind == .screenRecording { + screenRecordingCount += 1 + estimatedScreenRecordingBytes += estimatedBytes + } if estimatedBytes >= largeVideoThreshold || asset.duration >= 180 { largeVideoCount += 1 estimatedLargeVideoBytes += estimatedBytes @@ -68,11 +86,27 @@ final class PhotoLibraryScanner { totalPhotos: totalPhotos, totalVideos: totalVideos, screenshotCount: screenshotCount, + screenRecordingCount: screenRecordingCount, + livePhotoCount: livePhotoCount, largeVideoCount: largeVideoCount, similarGroupCount: similarGroups.count, oldMediaCount: oldMediaCount, estimatedLargeVideoBytes: estimatedLargeVideoBytes, estimatedScreenshotBytes: estimatedScreenshotBytes, + estimatedScreenRecordingBytes: estimatedScreenRecordingBytes, + estimatedLivePhotoBytes: estimatedLivePhotoBytes, + 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() ) } @@ -112,6 +146,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 [] } @@ -201,14 +269,7 @@ final class PhotoLibraryScanner { } private func makeItem(from asset: PHAsset) -> MediaAssetItem { - let kind: MediaAssetKind - if asset.mediaType == .video { - kind = .video - } else if asset.mediaSubtypes.contains(.photoScreenshot) { - kind = .screenshot - } else { - kind = .photo - } + let kind = mediaKind(for: asset) return MediaAssetItem( id: asset.localIdentifier, @@ -221,11 +282,23 @@ final class PhotoLibraryScanner { ) } + 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 + } + } + private func estimatedFileSize(for asset: PHAsset, kind: MediaAssetKind) -> Int64 { let pixelCount = max(Int64(asset.pixelWidth) * Int64(asset.pixelHeight), 1) switch kind { - case .video: + case .video, .screenRecording: let bytesPerSecond: Int64 switch pixelCount { case 8_000_000...: @@ -241,11 +314,24 @@ final class PhotoLibraryScanner { case .screenshot: return max(pixelCount / 2, 700 * 1024) + case .livePhoto: + return max(pixelCount / 2, 2 * 1024 * 1024) + case .photo: return max(pixelCount / 3, 900 * 1024) } } + private func isLikelyScreenRecording(_ asset: PHAsset) -> Bool { + guard asset.mediaType == .video, 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 { guard let lhsDate = lhs.creationDate, let rhsDate = rhs.creationDate else { return false } guard Calendar.current.isDate(lhsDate, inSameDayAs: rhsDate) else { return false } @@ -263,3 +349,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 7dee24f..5c6bd2e 100644 --- a/StorageLens/Utilities/AppFormatters.swift +++ b/StorageLens/Utilities/AppFormatters.swift @@ -73,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 52bb2bf..907b0dd 100644 --- a/StorageLens/ViewModels/CleanupViewModels.swift +++ b/StorageLens/ViewModels/CleanupViewModels.swift @@ -1,6 +1,76 @@ 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.map(\.categoryKind)) + return CleanupCategoryKind.allCases + .filter { kinds.contains($0) } + .map(\.title) + .joined(separator: "、") + } + + func add(_ items: [MediaAssetItem], from categoryKind: CleanupCategoryKind) { + for item in items { + itemsByID[item.id] = ReviewBasketItem(item: item, categoryKind: categoryKind) + } + 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 deletedCount = ids.count + let deletedBytes = estimatedBytes + guard !ids.isEmpty else { return } + + isDeleting = true + defer { isDeleting = false } + + do { + try await deletionService.deleteAssets(withLocalIdentifiers: ids) + itemsByID.removeAll() + cleanupResult = CleanupResult(deletedCount: deletedCount, estimatedBytes: deletedBytes) + Haptics.cleanupSucceeded() + } catch { + message = UserMessage(title: "删除失败", message: error.localizedDescription) + } + } +} + enum OldMediaFilter: String, CaseIterable, Identifiable { case sixMonths case oneYear @@ -33,6 +103,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 +179,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 +224,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 +233,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 +248,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 +279,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) } @@ -192,27 +304,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 +409,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 +450,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 +465,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 +503,16 @@ 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 } @@ -343,13 +520,6 @@ final class SimilarPhotosViewModel: ObservableObject { } } - var deleteConfirmationMessage: String { - if selectedRecommendedKeepCount > 0 { - return "你选中了 \(selectedRecommendedKeepCount) 张建议保留的照片。请再次确认这些照片确实不需要。" - } - return "StorageLens 不会自动删除相似照片,请确认你已经手动选中要删除的项目。" - } - func load() async { isLoading = true defer { isLoading = false } @@ -383,27 +553,4 @@ final class SimilarPhotosViewModel: 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) - } - } } 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 dd92411..883e2e3 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) @@ -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) + } + } + } + + 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) } + ) + } } - .disabled(viewModel.selectedIDs.isEmpty || viewModel.isDeleting) - .accessibilityLabel("删除所选截图") + } + } + .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() + } + } + .safeAreaInset(edge: .bottom) { + SelectionSummaryBar( + selectedCount: viewModel.selectedIDs.count, + estimatedBytes: viewModel.selectedEstimatedBytes, + isWorking: false, + actionTitle: "加入篮子" + ) { + reviewBasket.add(viewModel.selectedItems, from: .livePhotos) + viewModel.selectedIDs.removeAll() } - Button("取消", role: .cancel) {} - } message: { - Text("删除前请确认这些截图不再需要。") } + .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) @@ -225,13 +336,7 @@ 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) { @@ -248,33 +353,169 @@ struct SimilarPhotosView: View { SelectionSummaryBar( selectedCount: viewModel.selectedIDs.count, estimatedBytes: viewModel.selectedEstimatedBytes, - isWorking: viewModel.isDeleting, - actionTitle: "删除所选" + isWorking: false, + actionTitle: "加入篮子" ) { - showsDeleteConfirmation = true + 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(viewModel.selectedRecommendedKeepCount > 0 ? "仍然删除所选照片" : "删除所选照片", 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 showsDeleteConfirmation = 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.categoryKind.title) · \(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("清空") { + reviewBasket.clear() + } + } + } + } + .safeAreaInset(edge: .bottom) { + if !reviewBasket.items.isEmpty { + SelectionSummaryBar( + selectedCount: reviewBasket.itemCount, + estimatedBytes: reviewBasket.estimatedBytes, + isWorking: reviewBasket.isDeleting, + actionTitle: "确认删除", + actionSystemImage: "trash" + ) { + showsDeleteConfirmation = true + } + } + } + .confirmationDialog("确认删除篮子中的项目?", isPresented: $showsDeleteConfirmation, titleVisibility: .visible) { + Button("删除篮子中的项目", role: .destructive) { + Task { await reviewBasket.deleteAll() } } Button("取消", role: .cancel) {} } message: { - Text(viewModel.deleteConfirmationMessage) + Text("这些项目会从系统照片图库中删除。StorageLens 不会自动删除任何内容,只有你在这里确认后才会执行。") } - .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 CleanupResultView: View { + let result: CleanupResult + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section { + VStack(spacing: 12) { + Image(systemName: "checkmark.circle") + .font(.system(size: 48)) + .foregroundStyle(.green) + Text("删除完成") + .font(.title2.bold()) + Text("已删除 \(result.deletedCount) 个项目,估算释放 \(AppFormatters.fileSize(result.estimatedBytes))。") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + } + + Section { + Text("已删除项目会进入系统照片的“最近删除”。如果需要恢复,请前往照片 App 查看。") + .foregroundStyle(.secondary) + } + } + .navigationTitle("结果") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("完成") { + dismiss() + } + } + } + } + } +} + +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 { @@ -316,23 +557,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() } @@ -340,14 +576,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 ab5abae..9c398e3 100644 --- a/StorageLens/Views/HomeView.swift +++ b/StorageLens/Views/HomeView.swift @@ -20,16 +20,22 @@ 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 { + Label { + Text("On-device analysis · No photo upload") + } icon: { + Image(systemName: "lock.shield") + .foregroundStyle(.green) + } + .font(.footnote) + .foregroundStyle(.secondary) + } + Section("清理建议 / Cleanup") { if viewModel.isLoading && viewModel.summary == nil { ProgressView("正在分析照片图库...") @@ -61,27 +67,41 @@ struct HomeView: View { } } - 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) + } + } + } + } } } .navigationTitle("首页") .toolbar { + ToolbarItem(placement: .topBarLeading) { + ReviewBasketLink() + } + ToolbarItem(placement: .topBarTrailing) { NavigationLink { SettingsView() @@ -114,11 +134,78 @@ 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) } } @@ -128,11 +215,16 @@ private extension ScanSummary { totalPhotos: 0, totalVideos: 0, screenshotCount: 0, + screenRecordingCount: 0, + livePhotoCount: 0, largeVideoCount: 0, similarGroupCount: 0, oldMediaCount: 0, estimatedLargeVideoBytes: 0, estimatedScreenshotBytes: 0, + estimatedScreenRecordingBytes: 0, + estimatedLivePhotoBytes: 0, + timelineMonths: [], generatedAt: Date() ) } @@ -141,4 +233,5 @@ private extension ScanSummary { #Preview { HomeView() .environmentObject(PhotoLibraryPermissionManager()) + .environmentObject(ReviewBasketStore()) } From aa3a9ca5b6b58cebf37c000fb99448a35a9620f7 Mon Sep 17 00:00:00 2001 From: JONASXZB <88891196+JONASXZB@users.noreply.github.com> Date: Fri, 5 Jun 2026 01:48:24 +0800 Subject: [PATCH 03/14] Tighten estimated storage review copy --- StorageLens/Components/AppComponents.swift | 8 ++++---- StorageLens/Models/AppModels.swift | 13 ++++++------- StorageLens/Services/PhotoLibraryScanner.swift | 18 ++++++++++++++++++ StorageLens/Views/CleanupViews.swift | 17 +++++++++++++---- StorageLens/Views/HomeView.swift | 3 +++ 5 files changed, 44 insertions(+), 15 deletions(-) diff --git a/StorageLens/Components/AppComponents.swift b/StorageLens/Components/AppComponents.swift index ebacb8a..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) ?? "日期未知") @@ -202,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) } diff --git a/StorageLens/Models/AppModels.swift b/StorageLens/Models/AppModels.swift index 9d36505..bdac5b4 100644 --- a/StorageLens/Models/AppModels.swift +++ b/StorageLens/Models/AppModels.swift @@ -70,7 +70,7 @@ 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))" } } @@ -123,17 +123,16 @@ struct ScanSummary: Hashable { 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 + estimatedScreenRecordingBytes + estimatedLivePhotoBytes - } - var estimatedLibraryBytes: Int64 { timelineMonths.map(\.estimatedBytes).reduce(0, +) } @@ -193,7 +192,7 @@ struct ScanSummary: Hashable { detail: "查看较久以前的照片和视频", systemImage: "calendar", itemCount: oldMediaCount, - estimatedBytes: nil + estimatedBytes: estimatedOldMediaBytes ), CleanupCategory( kind: .similarPhotos, @@ -202,7 +201,7 @@ struct ScanSummary: Hashable { detail: "按时间和尺寸线索找出可能相似的照片", systemImage: "square.stack.3d.up", itemCount: similarGroupCount, - estimatedBytes: nil + estimatedBytes: estimatedSimilarPhotoBytes ) ] } diff --git a/StorageLens/Services/PhotoLibraryScanner.swift b/StorageLens/Services/PhotoLibraryScanner.swift index 2c724be..3417b32 100644 --- a/StorageLens/Services/PhotoLibraryScanner.swift +++ b/StorageLens/Services/PhotoLibraryScanner.swift @@ -33,7 +33,9 @@ final class PhotoLibraryScanner { 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 { @@ -52,9 +54,11 @@ final class PhotoLibraryScanner { 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) } @@ -63,10 +67,12 @@ final class PhotoLibraryScanner { 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 @@ -74,6 +80,8 @@ final class PhotoLibraryScanner { if let oneYearCutoff, (asset.creationDate ?? .distantFuture) < oneYearCutoff { oldMediaCount += 1 + estimatedOldMediaBytes += estimatedBytes + reviewCandidateBytesByID[asset.localIdentifier] = estimatedBytes } } @@ -81,6 +89,13 @@ final class PhotoLibraryScanner { 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: totalPhotos, @@ -91,10 +106,13 @@ final class PhotoLibraryScanner { largeVideoCount: largeVideoCount, similarGroupCount: similarGroups.count, 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( diff --git a/StorageLens/Views/CleanupViews.swift b/StorageLens/Views/CleanupViews.swift index 883e2e3..df7e120 100644 --- a/StorageLens/Views/CleanupViews.swift +++ b/StorageLens/Views/CleanupViews.swift @@ -305,7 +305,7 @@ struct SimilarPhotosView: View { VStack(alignment: .leading, spacing: 2) { Text(group.title) .font(.headline) - Text("\(group.items.count) 张,预计可删 \(AppFormatters.fileSize(group.estimatedDuplicateBytes))") + Text("\(group.items.count) 张,建议保留外估算 \(AppFormatters.fileSize(group.estimatedDuplicateBytes))") .font(.footnote) .foregroundStyle(.secondary) } @@ -373,6 +373,7 @@ struct SimilarPhotosView: View { struct ReviewBasketView: View { @EnvironmentObject private var reviewBasket: ReviewBasketStore @State private var showsDeleteConfirmation = false + @State private var showsClearConfirmation = false var body: some View { List { @@ -397,7 +398,7 @@ struct ReviewBasketView: View { VStack(alignment: .leading, spacing: 3) { Text(basketItem.item.kind.title) .font(.headline) - Text("\(basketItem.categoryKind.title) · \(AppFormatters.fileSize(basketItem.item.estimatedFileSize))") + Text("\(basketItem.categoryKind.title) · 估算 \(AppFormatters.fileSize(basketItem.item.estimatedFileSize))") .font(.footnote) .foregroundStyle(.secondary) Text(basketItem.item.creationDate.map(AppFormatters.date) ?? "日期未知") @@ -424,8 +425,8 @@ struct ReviewBasketView: View { .toolbar { ToolbarItem(placement: .topBarTrailing) { if !reviewBasket.items.isEmpty { - Button("清空") { - reviewBasket.clear() + Button("清空", role: .destructive) { + showsClearConfirmation = true } } } @@ -451,6 +452,14 @@ struct ReviewBasketView: View { } message: { Text("这些项目会从系统照片图库中删除。StorageLens 不会自动删除任何内容,只有你在这里确认后才会执行。") } + .confirmationDialog("清空 Review Basket?", isPresented: $showsClearConfirmation, titleVisibility: .visible) { + Button("清空篮子", role: .destructive) { + reviewBasket.clear() + } + Button("取消", role: .cancel) {} + } message: { + Text("这只会移除待确认列表,不会删除照片图库中的任何项目。") + } .sheet(item: $reviewBasket.cleanupResult) { result in CleanupResultView(result: result) } diff --git a/StorageLens/Views/HomeView.swift b/StorageLens/Views/HomeView.swift index 9c398e3..e6b52e8 100644 --- a/StorageLens/Views/HomeView.swift +++ b/StorageLens/Views/HomeView.swift @@ -220,10 +220,13 @@ private extension ScanSummary { largeVideoCount: 0, similarGroupCount: 0, oldMediaCount: 0, + estimatedCleanableBytes: 0, estimatedLargeVideoBytes: 0, estimatedScreenshotBytes: 0, estimatedScreenRecordingBytes: 0, estimatedLivePhotoBytes: 0, + estimatedOldMediaBytes: 0, + estimatedSimilarPhotoBytes: 0, timelineMonths: [], generatedAt: Date() ) From 0eab1f19e93b290472709fffecd0a313ae14bdbc Mon Sep 17 00:00:00 2001 From: JONASXZB <88891196+JONASXZB@users.noreply.github.com> Date: Fri, 5 Jun 2026 01:51:09 +0800 Subject: [PATCH 04/14] Refine privacy-first review wording --- StorageLens/Models/AppModels.swift | 6 +++--- StorageLens/Views/CleanupViews.swift | 4 ++-- StorageLens/Views/HomeView.swift | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/StorageLens/Models/AppModels.swift b/StorageLens/Models/AppModels.swift index bdac5b4..9ffd518 100644 --- a/StorageLens/Models/AppModels.swift +++ b/StorageLens/Models/AppModels.swift @@ -153,7 +153,7 @@ struct ScanSummary: Hashable { kind: .largeVideos, title: "大视频", englishTitle: "Large Videos", - detail: "优先查看占用空间较大的视频", + detail: "优先查看估算占用较大的视频", systemImage: "video.fill", itemCount: largeVideoCount, estimatedBytes: estimatedLargeVideoBytes @@ -162,7 +162,7 @@ struct ScanSummary: Hashable { kind: .screenshots, title: "屏幕截图", englishTitle: "Screenshots", - detail: "按月份整理截图,手动选择删除", + detail: "按月份整理截图,加入篮子后确认", systemImage: "rectangle.on.rectangle", itemCount: screenshotCount, estimatedBytes: estimatedScreenshotBytes @@ -180,7 +180,7 @@ struct ScanSummary: Hashable { kind: .livePhotos, title: "Live Photos", englishTitle: "Live Photos", - detail: "只支持查看和手动删除,不转换格式", + detail: "只支持查看和手动选择,不转换格式", systemImage: "livephoto", itemCount: livePhotoCount, estimatedBytes: estimatedLivePhotoBytes diff --git a/StorageLens/Views/CleanupViews.swift b/StorageLens/Views/CleanupViews.swift index df7e120..d4182c8 100644 --- a/StorageLens/Views/CleanupViews.swift +++ b/StorageLens/Views/CleanupViews.swift @@ -305,7 +305,7 @@ struct SimilarPhotosView: View { VStack(alignment: .leading, spacing: 2) { Text(group.title) .font(.headline) - Text("\(group.items.count) 张,建议保留外估算 \(AppFormatters.fileSize(group.estimatedDuplicateBytes))") + Text("\(group.items.count) 张,保留建议外估算 \(AppFormatters.fileSize(group.estimatedDuplicateBytes))") .font(.footnote) .foregroundStyle(.secondary) } @@ -313,7 +313,7 @@ struct SimilarPhotosView: View { Button { viewModel.selectLikelyDuplicates(in: group) } label: { - Label("选择可删", systemImage: "checkmark.circle") + Label("选择建议项", systemImage: "checkmark.circle") } .buttonStyle(.bordered) diff --git a/StorageLens/Views/HomeView.swift b/StorageLens/Views/HomeView.swift index e6b52e8..3d71d2e 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) } @@ -36,7 +36,7 @@ struct HomeView: View { .foregroundStyle(.secondary) } - Section("清理建议 / Cleanup") { + Section("整理建议 / Review") { if viewModel.isLoading && viewModel.summary == nil { ProgressView("正在分析照片图库...") } @@ -172,7 +172,7 @@ private struct StorageOverviewCard: View { HStack(spacing: 16) { OverviewMetric( - title: "可清理", + title: "待整理估算", value: AppFormatters.fileSize(summary?.estimatedCleanableBytes) ) OverviewMetric( From 4998c78e4f4bff4e4b6a2d3d13846f7f796fa54a Mon Sep 17 00:00:00 2001 From: JONASXZB <88891196+JONASXZB@users.noreply.github.com> Date: Fri, 5 Jun 2026 02:03:51 +0800 Subject: [PATCH 05/14] Polish photo permission onboarding --- StorageLens/Views/PermissionView.swift | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/StorageLens/Views/PermissionView.swift b/StorageLens/Views/PermissionView.swift index 13e0d82..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,9 +43,9 @@ struct PermissionView: View { ) PermissionPointRow( systemImage: "checkmark.circle", - title: "由你决定删除内容", - detail: "删除前始终由你手动选择并确认。", - englishDetail: "You choose what to delete." + title: "由你决定整理内容", + detail: "移除前始终由你手动选择并确认。", + englishDetail: "You confirm every change." ) } .padding() From ad742a0e27073225c844030cd39ae2abbe9dede5 Mon Sep 17 00:00:00 2001 From: JONASXZB <88891196+JONASXZB@users.noreply.github.com> Date: Fri, 5 Jun 2026 02:09:30 +0800 Subject: [PATCH 06/14] Tighten App Store-safe boundary wording --- README.md | 6 +++--- StorageLens/Views/SettingsView.swift | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a909ea7..f3dfce3 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,9 @@ StorageLens 是一个原生 iPhone 应用,用来帮助用户查看照片图库 ## 它不能做什么 -- 不清理 iOS 系统数据。 -- 不清理其他 App 的缓存。 -- 不声称“清理系统垃圾”“加速 iPhone”或“删除隐藏缓存”。 +- 不处理 iOS 系统数据。 +- 不读取或处理其他 App 的缓存。 +- 不提供性能加速、系统级维护或后台缓存管理功能。 - 不会自动删除照片或视频。 - 不会绕过系统照片权限,也不会访问其他 App 的数据。 diff --git a/StorageLens/Views/SettingsView.swift b/StorageLens/Views/SettingsView.swift index a92b276..8086bae 100644 --- a/StorageLens/Views/SettingsView.swift +++ b/StorageLens/Views/SettingsView.swift @@ -66,7 +66,7 @@ struct PrivacyView: View { } Section("边界 / Boundaries") { - Text("StorageLens 只分析你授权的照片图库,不清理 iOS 系统数据,也不清理其他 App 的缓存。") + Text("StorageLens 只分析你授权的照片图库,不处理 iOS 系统数据,也不会读取其他 App 的缓存。") .foregroundStyle(.secondary) } } @@ -103,7 +103,7 @@ struct AboutView: View { } Section("不会做的事 / Cannot Do") { - Text("不会声称清理系统垃圾,不会加速 iPhone,不会移除隐藏缓存,也不会自动删除照片。") + Text("不会处理 iOS 系统数据,不会读取其他 App 的缓存,也不会自动删除照片。") .foregroundStyle(.secondary) } } From 97674c158ddab96ab56e5f6b97d0c3aae1a31995 Mon Sep 17 00:00:00 2001 From: JONASXZB <88891196+JONASXZB@users.noreply.github.com> Date: Fri, 5 Jun 2026 02:13:23 +0800 Subject: [PATCH 07/14] Improve screenshot month selection clarity --- StorageLens/ViewModels/CleanupViewModels.swift | 5 +++++ StorageLens/Views/CleanupViews.swift | 8 ++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/StorageLens/ViewModels/CleanupViewModels.swift b/StorageLens/ViewModels/CleanupViewModels.swift index 907b0dd..3a72d18 100644 --- a/StorageLens/ViewModels/CleanupViewModels.swift +++ b/StorageLens/ViewModels/CleanupViewModels.swift @@ -294,6 +294,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) { diff --git a/StorageLens/Views/CleanupViews.swift b/StorageLens/Views/CleanupViews.swift index d4182c8..f5c6122 100644 --- a/StorageLens/Views/CleanupViews.swift +++ b/StorageLens/Views/CleanupViews.swift @@ -89,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) } @@ -103,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) { From 303d71936a61390dc2b1d90ea854de1bcd3c67c3 Mon Sep 17 00:00:00 2001 From: JONASXZB <88891196+JONASXZB@users.noreply.github.com> Date: Fri, 5 Jun 2026 02:17:26 +0800 Subject: [PATCH 08/14] Add final basket deletion confirmation screen --- StorageLens/Views/CleanupViews.swift | 83 ++++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 10 deletions(-) diff --git a/StorageLens/Views/CleanupViews.swift b/StorageLens/Views/CleanupViews.swift index f5c6122..ebfc0e7 100644 --- a/StorageLens/Views/CleanupViews.swift +++ b/StorageLens/Views/CleanupViews.swift @@ -372,7 +372,7 @@ struct SimilarPhotosView: View { struct ReviewBasketView: View { @EnvironmentObject private var reviewBasket: ReviewBasketStore - @State private var showsDeleteConfirmation = false + @State private var showsDeleteReview = false @State private var showsClearConfirmation = false var body: some View { @@ -437,20 +437,15 @@ struct ReviewBasketView: View { selectedCount: reviewBasket.itemCount, estimatedBytes: reviewBasket.estimatedBytes, isWorking: reviewBasket.isDeleting, - actionTitle: "确认删除", + actionTitle: "最终确认", actionSystemImage: "trash" ) { - showsDeleteConfirmation = true + showsDeleteReview = true } } } - .confirmationDialog("确认删除篮子中的项目?", isPresented: $showsDeleteConfirmation, titleVisibility: .visible) { - Button("删除篮子中的项目", role: .destructive) { - Task { await reviewBasket.deleteAll() } - } - Button("取消", role: .cancel) {} - } message: { - Text("这些项目会从系统照片图库中删除。StorageLens 不会自动删除任何内容,只有你在这里确认后才会执行。") + .navigationDestination(isPresented: $showsDeleteReview) { + BasketDeleteConfirmationView() } .confirmationDialog("清空 Review Basket?", isPresented: $showsClearConfirmation, titleVisibility: .visible) { Button("清空篮子", role: .destructive) { @@ -469,6 +464,74 @@ struct ReviewBasketView: View { } } +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 From 7cb8afba70b55f218512a7b7f719fab52e451ee7 Mon Sep 17 00:00:00 2001 From: JONASXZB <88891196+JONASXZB@users.noreply.github.com> Date: Fri, 5 Jun 2026 05:09:28 +0800 Subject: [PATCH 09/14] Use cautious screen recording wording --- README.md | 2 +- StorageLens/Models/AppModels.swift | 8 ++++---- StorageLens/ViewModels/CleanupViewModels.swift | 2 +- StorageLens/Views/CleanupViews.swift | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f3dfce3..6bf82c2 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ StorageLens 是一个原生 iPhone 应用,用来帮助用户查看照片图库 - 分析用户已授权的照片图库。 - 首页汇总图库估算占用、分类数量、最近扫描时间和本机隐私状态。 -- 找出大视频、屏幕录制、屏幕截图、Live Photos、旧媒体和可能相似的照片。 +- 找出大视频、可能的屏幕录制、屏幕截图、Live Photos、旧媒体和可能相似的照片。 - 按月份整理截图,支持月份批量选择和年龄筛选。 - 用 Review Basket 汇总待处理项目,只有在最终确认页才会请求系统删除。 - 用月份时间线展示哪些月份可能占用最多空间。 diff --git a/StorageLens/Models/AppModels.swift b/StorageLens/Models/AppModels.swift index 9ffd518..f93cab7 100644 --- a/StorageLens/Models/AppModels.swift +++ b/StorageLens/Models/AppModels.swift @@ -91,7 +91,7 @@ enum CleanupCategoryKind: String, CaseIterable, Identifiable { case .screenshots: return "屏幕截图" case .screenRecordings: - return "屏幕录制" + return "可能的屏幕录制" case .livePhotos: return "Live Photos" case .oldMedia: @@ -169,9 +169,9 @@ struct ScanSummary: Hashable { ), CleanupCategory( kind: .screenRecordings, - title: "屏幕录制", - englishTitle: "Screen Recordings", - detail: "查看可能的屏幕录制视频", + title: "可能的屏幕录制", + englishTitle: "Possible Screen Recordings", + detail: "基于尺寸和时长线索谨慎识别", systemImage: "record.circle", itemCount: screenRecordingCount, estimatedBytes: estimatedScreenRecordingBytes diff --git a/StorageLens/ViewModels/CleanupViewModels.swift b/StorageLens/ViewModels/CleanupViewModels.swift index 3a72d18..85be519 100644 --- a/StorageLens/ViewModels/CleanupViewModels.swift +++ b/StorageLens/ViewModels/CleanupViewModels.swift @@ -344,7 +344,7 @@ final class ScreenRecordingsViewModel: ObservableObject { applySort() selectedIDs = selectedIDs.intersection(Set(items.map(\.id))) } catch { - message = UserMessage(title: "无法载入屏幕录制", message: error.localizedDescription) + message = UserMessage(title: "无法载入可能的屏幕录制", message: error.localizedDescription) } } diff --git a/StorageLens/Views/CleanupViews.swift b/StorageLens/Views/CleanupViews.swift index ebfc0e7..51e034f 100644 --- a/StorageLens/Views/CleanupViews.swift +++ b/StorageLens/Views/CleanupViews.swift @@ -172,7 +172,7 @@ struct ScreenRecordingsView: View { } if viewModel.isLoading && viewModel.items.isEmpty { - ProgressView("正在查找屏幕录制...") + ProgressView("正在查找可能的屏幕录制...") } else if viewModel.items.isEmpty { EmptyStateView( systemImage: "record.circle", @@ -191,7 +191,7 @@ struct ScreenRecordingsView: View { } } } - .navigationTitle("屏幕录制") + .navigationTitle("可能的屏幕录制") .toolbar { ToolbarItem(placement: .topBarTrailing) { ReviewBasketLink() From b51ad0246aa5ab144fe5c28e551d0514530f4fce Mon Sep 17 00:00:00 2001 From: JONASXZB <88891196+JONASXZB@users.noreply.github.com> Date: Fri, 5 Jun 2026 05:12:21 +0800 Subject: [PATCH 10/14] Keep Live Photos out of similar photo groups --- StorageLens/Services/PhotoLibraryScanner.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/StorageLens/Services/PhotoLibraryScanner.swift b/StorageLens/Services/PhotoLibraryScanner.swift index 3417b32..d1d0784 100644 --- a/StorageLens/Services/PhotoLibraryScanner.swift +++ b/StorageLens/Services/PhotoLibraryScanner.swift @@ -219,6 +219,7 @@ final class PhotoLibraryScanner { let photoAssets = fetchAssets(mediaType: .image) .filter { !$0.mediaSubtypes.contains(.photoScreenshot) } + .filter { !$0.mediaSubtypes.contains(.photoLive) } .prefix(maxAssets) return await similarPhotoGroups(from: Array(photoAssets), maximumAssets: maxAssets) From b205dca8c4bdfa16c60c5eb3d862d75d32d31e4c Mon Sep 17 00:00:00 2001 From: JONASXZB <88891196+JONASXZB@users.noreply.github.com> Date: Fri, 5 Jun 2026 05:16:31 +0800 Subject: [PATCH 11/14] Add full storage timeline detail view --- StorageLens/Views/HomeView.swift | 118 +++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/StorageLens/Views/HomeView.swift b/StorageLens/Views/HomeView.swift index 3d71d2e..3472688 100644 --- a/StorageLens/Views/HomeView.swift +++ b/StorageLens/Views/HomeView.swift @@ -93,6 +93,12 @@ struct HomeView: View { } } } + + NavigationLink { + StorageTimelineView(summary: summary) + } label: { + Label("查看全部月份", systemImage: "calendar") + } } } } @@ -209,6 +215,118 @@ private struct OverviewMetric: View { } } +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)") + } +} + private extension ScanSummary { static var mock: ScanSummary { ScanSummary( From 4692e1e3119f526465ed0735db0f4b1156353eec Mon Sep 17 00:00:00 2001 From: JONASXZB <88891196+JONASXZB@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:08:35 +0800 Subject: [PATCH 12/14] Improve screen recording detection --- StorageLens/Models/AppModels.swift | 2 +- StorageLens/Services/PhotoLibraryScanner.swift | 16 +++++++++++++++- StorageLens/Views/CleanupViews.swift | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/StorageLens/Models/AppModels.swift b/StorageLens/Models/AppModels.swift index f93cab7..2a44b91 100644 --- a/StorageLens/Models/AppModels.swift +++ b/StorageLens/Models/AppModels.swift @@ -171,7 +171,7 @@ struct ScanSummary: Hashable { kind: .screenRecordings, title: "可能的屏幕录制", englishTitle: "Possible Screen Recordings", - detail: "基于尺寸和时长线索谨慎识别", + detail: "优先使用图库标记,并结合本机线索谨慎识别", systemImage: "record.circle", itemCount: screenRecordingCount, estimatedBytes: estimatedScreenRecordingBytes diff --git a/StorageLens/Services/PhotoLibraryScanner.swift b/StorageLens/Services/PhotoLibraryScanner.swift index d1d0784..e56b6ad 100644 --- a/StorageLens/Services/PhotoLibraryScanner.swift +++ b/StorageLens/Services/PhotoLibraryScanner.swift @@ -342,7 +342,21 @@ final class PhotoLibraryScanner { } private func isLikelyScreenRecording(_ asset: PHAsset) -> Bool { - guard asset.mediaType == .video, asset.duration >= 5 else { return false } + guard asset.mediaType == .video else { return false } + if asset.mediaSubtypes.contains(.videoScreenRecording) { + return true + } + + 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) diff --git a/StorageLens/Views/CleanupViews.swift b/StorageLens/Views/CleanupViews.swift index 51e034f..f98193c 100644 --- a/StorageLens/Views/CleanupViews.swift +++ b/StorageLens/Views/CleanupViews.swift @@ -160,7 +160,7 @@ struct ScreenRecordingsView: View { var body: some View { List { Section { - Text("这里显示可能的屏幕录制视频。识别基于本机尺寸和时长线索,可能并不完全准确。") + Text("这里显示可能的屏幕录制视频。识别优先使用照片图库标记,并结合本机尺寸和时长线索,可能并不完全准确。") .font(.footnote) .foregroundStyle(.secondary) From 9c364b3dc8cea9bb8df24ef2861b6034cbc5c972 Mon Sep 17 00:00:00 2001 From: JONASXZB <88891196+JONASXZB@users.noreply.github.com> Date: Tue, 9 Jun 2026 20:27:10 +0800 Subject: [PATCH 13/14] Report accurate photo deletion results --- StorageLens/Models/AppModels.swift | 1 + .../Services/PhotoDeletionService.swift | 11 +++++-- .../ViewModels/CleanupViewModels.swift | 18 +++++++---- StorageLens/Views/CleanupViews.swift | 30 ++++++++++++++----- 4 files changed, 46 insertions(+), 14 deletions(-) diff --git a/StorageLens/Models/AppModels.swift b/StorageLens/Models/AppModels.swift index 2a44b91..d5416e3 100644 --- a/StorageLens/Models/AppModels.swift +++ b/StorageLens/Models/AppModels.swift @@ -264,6 +264,7 @@ struct ReviewBasketItem: Identifiable, Hashable { struct CleanupResult: Identifiable, Hashable { let id = UUID() let deletedCount: Int + let unavailableCount: Int let estimatedBytes: Int64 } 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/ViewModels/CleanupViewModels.swift b/StorageLens/ViewModels/CleanupViewModels.swift index 85be519..928589d 100644 --- a/StorageLens/ViewModels/CleanupViewModels.swift +++ b/StorageLens/ViewModels/CleanupViewModels.swift @@ -53,18 +53,26 @@ final class ReviewBasketStore: ObservableObject { func deleteAll() async { let ids = Set(itemsByID.keys) - let deletedCount = ids.count - let deletedBytes = estimatedBytes + let requestedItems = itemsByID guard !ids.isEmpty else { return } isDeleting = true defer { isDeleting = false } do { - try await deletionService.deleteAssets(withLocalIdentifiers: ids) + let deletedIDs = try await deletionService.deleteAssets(withLocalIdentifiers: ids) + let deletedBytes = deletedIDs + .compactMap { requestedItems[$0]?.item.estimatedFileSize } + .reduce(0, +) itemsByID.removeAll() - cleanupResult = CleanupResult(deletedCount: deletedCount, estimatedBytes: deletedBytes) - Haptics.cleanupSucceeded() + cleanupResult = CleanupResult( + deletedCount: deletedIDs.count, + unavailableCount: ids.subtracting(deletedIDs).count, + estimatedBytes: deletedBytes + ) + if !deletedIDs.isEmpty { + Haptics.cleanupSucceeded() + } } catch { message = UserMessage(title: "删除失败", message: error.localizedDescription) } diff --git a/StorageLens/Views/CleanupViews.swift b/StorageLens/Views/CleanupViews.swift index f98193c..dede4cf 100644 --- a/StorageLens/Views/CleanupViews.swift +++ b/StorageLens/Views/CleanupViews.swift @@ -541,12 +541,12 @@ struct CleanupResultView: View { List { Section { VStack(spacing: 12) { - Image(systemName: "checkmark.circle") + Image(systemName: result.deletedCount > 0 ? "checkmark.circle" : "photo.badge.exclamationmark") .font(.system(size: 48)) - .foregroundStyle(.green) - Text("删除完成") + .foregroundStyle(result.deletedCount > 0 ? Color.green : Color.secondary) + Text(result.deletedCount > 0 ? "删除完成" : "没有可删除项目") .font(.title2.bold()) - Text("已删除 \(result.deletedCount) 个项目,估算释放 \(AppFormatters.fileSize(result.estimatedBytes))。") + Text(resultSummary) .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -555,9 +555,18 @@ struct CleanupResultView: View { .padding(.vertical, 24) } - Section { - Text("已删除项目会进入系统照片的“最近删除”。如果需要恢复,请前往照片 App 查看。") - .foregroundStyle(.secondary) + if result.deletedCount > 0 { + Section { + Text("已删除项目会进入系统照片的“最近删除”。如果需要恢复,请前往照片 App 查看。") + .foregroundStyle(.secondary) + } + } + + if result.unavailableCount > 0 { + Section("未处理项目") { + Text("另有 \(result.unavailableCount) 个项目在确认前已不在当前可访问的照片图库中,因此未计入删除结果。") + .foregroundStyle(.secondary) + } } } .navigationTitle("结果") @@ -570,6 +579,13 @@ struct CleanupResultView: View { } } } + + private var resultSummary: String { + guard result.deletedCount > 0 else { + return "确认时没有在当前可访问的照片图库中找到可删除项目。" + } + return "已删除 \(result.deletedCount) 个项目,估算释放 \(AppFormatters.fileSize(result.estimatedBytes))。" + } } struct ReviewBasketLink: View { From 605df02fc39b3cedac63361d55529a5510161a1b Mon Sep 17 00:00:00 2001 From: JONASXZB <88891196+JONASXZB@users.noreply.github.com> Date: Tue, 9 Jun 2026 20:29:11 +0800 Subject: [PATCH 14/14] Preserve basket category sources --- StorageLens/Models/AppModels.swift | 9 ++++++++- StorageLens/ViewModels/CleanupViewModels.swift | 5 +++-- StorageLens/Views/CleanupViews.swift | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/StorageLens/Models/AppModels.swift b/StorageLens/Models/AppModels.swift index d5416e3..2568205 100644 --- a/StorageLens/Models/AppModels.swift +++ b/StorageLens/Models/AppModels.swift @@ -256,9 +256,16 @@ struct SimilarPhotoGroup: Identifiable, Hashable { struct ReviewBasketItem: Identifiable, Hashable { let item: MediaAssetItem - let categoryKind: CleanupCategoryKind + 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 { diff --git a/StorageLens/ViewModels/CleanupViewModels.swift b/StorageLens/ViewModels/CleanupViewModels.swift index 928589d..39ac8e7 100644 --- a/StorageLens/ViewModels/CleanupViewModels.swift +++ b/StorageLens/ViewModels/CleanupViewModels.swift @@ -28,7 +28,7 @@ final class ReviewBasketStore: ObservableObject { } var includedCategoryTitles: String { - let kinds = Set(itemsByID.values.map(\.categoryKind)) + let kinds = Set(itemsByID.values.flatMap(\.categoryKinds)) return CleanupCategoryKind.allCases .filter { kinds.contains($0) } .map(\.title) @@ -37,7 +37,8 @@ final class ReviewBasketStore: ObservableObject { func add(_ items: [MediaAssetItem], from categoryKind: CleanupCategoryKind) { for item in items { - itemsByID[item.id] = ReviewBasketItem(item: item, categoryKind: categoryKind) + let categoryKinds = itemsByID[item.id]?.categoryKinds.union([categoryKind]) ?? [categoryKind] + itemsByID[item.id] = ReviewBasketItem(item: item, categoryKinds: categoryKinds) } Haptics.selectionChanged() } diff --git a/StorageLens/Views/CleanupViews.swift b/StorageLens/Views/CleanupViews.swift index dede4cf..08263e8 100644 --- a/StorageLens/Views/CleanupViews.swift +++ b/StorageLens/Views/CleanupViews.swift @@ -398,7 +398,7 @@ struct ReviewBasketView: View { VStack(alignment: .leading, spacing: 3) { Text(basketItem.item.kind.title) .font(.headline) - Text("\(basketItem.categoryKind.title) · 估算 \(AppFormatters.fileSize(basketItem.item.estimatedFileSize))") + Text("\(basketItem.categoryTitles) · 估算 \(AppFormatters.fileSize(basketItem.item.estimatedFileSize))") .font(.footnote) .foregroundStyle(.secondary) Text(basketItem.item.creationDate.map(AppFormatters.date) ?? "日期未知")