对齐有线传图页 Android UI,并改用相对路径持久化本地照片。
还原顶栏、浮动卡片、时间侧栏与底部交互;照片路径改为 CameraDownloads 相对路径并兼容旧数据;格式 Chip 固定 JPG 且不可点击。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -281,6 +281,28 @@ enum WiredTransferUploadStatus: String, Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 有线传图时间侧栏时间段。
|
||||
struct WiredTransferTimeSlot: Identifiable, Equatable {
|
||||
let id: String
|
||||
let dateLabel: String
|
||||
let slotStartLabel: String
|
||||
let photoCount: Int
|
||||
}
|
||||
|
||||
/// 有线传图时间侧栏日期分组。
|
||||
struct WiredTransferDateGroup: Equatable {
|
||||
let dateLabel: String
|
||||
let slots: [WiredTransferTimeSlot]
|
||||
}
|
||||
|
||||
/// 有线传图列表分组(30 分钟时间段)。
|
||||
struct WiredTransferPhotoSection: Identifiable, Equatable {
|
||||
var id: String { slotId }
|
||||
let slotId: String
|
||||
let headerTitle: String
|
||||
let photos: [WiredTransferPhotoItem]
|
||||
}
|
||||
|
||||
/// 有线传图列表项。
|
||||
struct WiredTransferPhotoItem: Identifiable, Equatable {
|
||||
let id: String
|
||||
@ -289,6 +311,7 @@ struct WiredTransferPhotoItem: Identifiable, Equatable {
|
||||
let thumbnailURL: String
|
||||
let capturedAt: String
|
||||
let fileSizeText: String
|
||||
let resolutionText: String
|
||||
let status: WiredTransferUploadStatus
|
||||
let progress: Int
|
||||
let errorMessage: String?
|
||||
@ -300,6 +323,7 @@ struct WiredTransferPhotoItem: Identifiable, Equatable {
|
||||
thumbnailURL: String,
|
||||
capturedAt: String,
|
||||
fileSizeText: String,
|
||||
resolutionText: String = "",
|
||||
status: WiredTransferUploadStatus,
|
||||
progress: Int = 0,
|
||||
errorMessage: String? = nil
|
||||
@ -310,6 +334,7 @@ struct WiredTransferPhotoItem: Identifiable, Equatable {
|
||||
self.thumbnailURL = thumbnailURL
|
||||
self.capturedAt = capturedAt
|
||||
self.fileSizeText = fileSizeText
|
||||
self.resolutionText = resolutionText
|
||||
self.status = status
|
||||
self.progress = progress
|
||||
self.errorMessage = errorMessage
|
||||
@ -322,6 +347,24 @@ struct WiredTransferPhotoItem: Identifiable, Equatable {
|
||||
var isNotUploaded: Bool {
|
||||
status == .pending || status == .failed
|
||||
}
|
||||
|
||||
/// 解析拍摄时间。
|
||||
func capturedDate() -> Date? {
|
||||
Self.capturedAtFormatter.date(from: capturedAt)
|
||||
}
|
||||
|
||||
/// 是否为今日拍摄。
|
||||
func isCapturedToday(calendar: Calendar = .current) -> Bool {
|
||||
guard let date = capturedDate() else { return false }
|
||||
return calendar.isDateInToday(date)
|
||||
}
|
||||
|
||||
private static let capturedAtFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
||||
/// 有线传图持久化记录。
|
||||
@ -342,18 +385,14 @@ struct WiredTransferPhotoRecord: Codable, Equatable {
|
||||
let updatedAt: TimeInterval
|
||||
|
||||
func toPhotoItem() -> WiredTransferPhotoItem {
|
||||
let previewPath = [thumbnailPath, localPath, remoteURL]
|
||||
.first { path in
|
||||
!path.isEmpty && (path.hasPrefix("http") || FileManager.default.fileExists(atPath: path))
|
||||
} ?? ""
|
||||
let thumbnailURL: String
|
||||
if previewPath.hasPrefix("http") {
|
||||
thumbnailURL = previewPath
|
||||
} else if !previewPath.isEmpty {
|
||||
thumbnailURL = "file://\(previewPath)"
|
||||
} else {
|
||||
thumbnailURL = ""
|
||||
}
|
||||
let previewCandidates = [thumbnailPath, localPath, remoteURL]
|
||||
let thumbnailURL = previewCandidates.compactMap { path -> String? in
|
||||
guard !path.isEmpty else { return nil }
|
||||
if path.hasPrefix("http") { return path }
|
||||
let preview = CameraDownloadStorage.previewURLString(from: path)
|
||||
return preview.isEmpty ? nil : preview
|
||||
}.first ?? ""
|
||||
|
||||
return WiredTransferPhotoItem(
|
||||
id: id,
|
||||
sourceId: sourceId,
|
||||
@ -425,6 +464,114 @@ extension Array where Element == WiredTransferPhotoItem {
|
||||
var selectablePendingPhotoIDs: Set<String> {
|
||||
Set(filter { $0.canSelectForSpecifyUpload }.map(\.id))
|
||||
}
|
||||
|
||||
/// 构建 30 分钟时间段分组列表。
|
||||
func buildPhotoSections() -> [WiredTransferPhotoSection] {
|
||||
let grouped = Dictionary(grouping: compactMap { photo -> (Date, WiredTransferPhotoItem)? in
|
||||
guard let date = photo.capturedDate() else { return nil }
|
||||
return (WiredTransferTimeGrouping.halfHourSlotStart(for: date), photo)
|
||||
}) { $0.0 }
|
||||
|
||||
return grouped.keys
|
||||
.sorted(by: >)
|
||||
.map { slotStart in
|
||||
let photos = (grouped[slotStart] ?? [])
|
||||
.map(\.1)
|
||||
.sorted { ($0.capturedDate() ?? .distantPast) > ($1.capturedDate() ?? .distantPast) }
|
||||
return WiredTransferPhotoSection(
|
||||
slotId: WiredTransferTimeGrouping.slotID(for: slotStart),
|
||||
headerTitle: WiredTransferTimeGrouping.sectionHeaderTitle(for: slotStart),
|
||||
photos: photos
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建时间侧栏分组。
|
||||
func buildSidebarGroups() -> [WiredTransferDateGroup] {
|
||||
var slotCounts: [Date: Int] = [:]
|
||||
for photo in self {
|
||||
guard let date = photo.capturedDate() else { continue }
|
||||
let slotStart = WiredTransferTimeGrouping.halfHourSlotStart(for: date)
|
||||
slotCounts[slotStart, default: 0] += 1
|
||||
}
|
||||
|
||||
let calendar = Calendar.current
|
||||
let groupedByDay = Dictionary(grouping: slotCounts.keys) { calendar.startOfDay(for: $0) }
|
||||
|
||||
return groupedByDay.keys
|
||||
.sorted(by: >)
|
||||
.map { day in
|
||||
let slots = (groupedByDay[day] ?? [])
|
||||
.sorted(by: >)
|
||||
.compactMap { slotStart -> WiredTransferTimeSlot? in
|
||||
guard let count = slotCounts[slotStart] else { return nil }
|
||||
return WiredTransferTimeSlot(
|
||||
id: WiredTransferTimeGrouping.slotID(for: slotStart),
|
||||
dateLabel: WiredTransferTimeGrouping.dateLabel(for: slotStart),
|
||||
slotStartLabel: WiredTransferTimeGrouping.timeLabel(for: slotStart),
|
||||
photoCount: count
|
||||
)
|
||||
}
|
||||
return WiredTransferDateGroup(
|
||||
dateLabel: WiredTransferTimeGrouping.dateLabel(for: day),
|
||||
slots: slots
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 有线传图 30 分钟时间段分组工具。
|
||||
enum WiredTransferTimeGrouping {
|
||||
/// 计算 30 分钟时间段起点。
|
||||
static func halfHourSlotStart(for date: Date, calendar: Calendar = .current) -> Date {
|
||||
var components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date)
|
||||
let minute = (components.minute ?? 0) / 30 * 30
|
||||
components.minute = minute
|
||||
components.second = 0
|
||||
components.nanosecond = 0
|
||||
return calendar.date(from: components) ?? date
|
||||
}
|
||||
|
||||
/// 时间段唯一标识。
|
||||
static func slotID(for slotStart: Date) -> String {
|
||||
slotIDFormatter.string(from: slotStart)
|
||||
}
|
||||
|
||||
/// 分组标题,例如「05月20日 12:00 - 12:30」。
|
||||
static func sectionHeaderTitle(for slotStart: Date) -> String {
|
||||
"\(dateLabel(for: slotStart)) \(timeLabel(for: slotStart)) - \(timeLabel(for: slotStart.addingTimeInterval(30 * 60)))"
|
||||
}
|
||||
|
||||
/// 日期标签,例如「05月20日」。
|
||||
static func dateLabel(for date: Date) -> String {
|
||||
dateLabelFormatter.string(from: date)
|
||||
}
|
||||
|
||||
/// 时间标签,例如「12:05」。
|
||||
static func timeLabel(for date: Date) -> String {
|
||||
timeLabelFormatter.string(from: date)
|
||||
}
|
||||
|
||||
private static let slotIDFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let dateLabelFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "MM月dd日"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let timeLabelFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "HH:mm"
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
|
||||
Reference in New Issue
Block a user