对齐有线传图页 Android UI,并改用相对路径持久化本地照片。

还原顶栏、浮动卡片、时间侧栏与底部交互;照片路径改为 CameraDownloads 相对路径并兼容旧数据;格式 Chip 固定 JPG 且不可点击。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 11:16:28 +08:00
parent e91a46c315
commit c71b45bdfd
29 changed files with 2097 additions and 256 deletions

View File

@ -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)
}
/// 0520 12:00 - 12:30
static func sectionHeaderTitle(for slotStart: Date) -> String {
"\(dateLabel(for: slotStart)) \(timeLabel(for: slotStart)) - \(timeLabel(for: slotStart.addingTimeInterval(30 * 60)))"
}
/// 0520
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 {