Sync OTG transfer photo list UI
This commit is contained in:
@ -31,6 +31,27 @@ struct TravelAlbumOTGPhotoItem: Hashable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// OTG 传输页半小时维度时间槽。
|
||||
struct TravelAlbumOTGTimeSlot: Hashable, Sendable {
|
||||
let id: String
|
||||
let dateLabel: String
|
||||
let slotStartLabel: String
|
||||
let photoCount: Int
|
||||
}
|
||||
|
||||
/// OTG 传输页左侧时间导航日期分组。
|
||||
struct TravelAlbumOTGDateGroup: Hashable, Sendable {
|
||||
let dateLabel: String
|
||||
let slots: [TravelAlbumOTGTimeSlot]
|
||||
}
|
||||
|
||||
/// OTG 传输页照片列表半小时分段。
|
||||
struct TravelAlbumOTGPhotoSection: Hashable, Sendable {
|
||||
let slotId: String
|
||||
let headerTitle: String
|
||||
let photos: [TravelAlbumOTGPhotoItem]
|
||||
}
|
||||
|
||||
/// OTG 传输页 Tab。
|
||||
enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable {
|
||||
case all
|
||||
@ -46,6 +67,130 @@ enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
extension TravelAlbumOTGPhotoItem {
|
||||
/// 将 Android 同款 `yyyy-MM-dd HH:mm:ss` 拍摄时间转为本地日期。
|
||||
func capturedDate() -> Date? {
|
||||
TravelAlbumOTGTimeGrouping.capturedDate(from: capturedAt)
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == TravelAlbumOTGPhotoItem {
|
||||
/// 按 Android OTG 页规则构造半小时照片分段。
|
||||
func buildPhotoSections() -> [TravelAlbumOTGPhotoSection] {
|
||||
let pairs = compactMap { photo -> (slotStart: Date, photo: TravelAlbumOTGPhotoItem)? in
|
||||
guard let date = photo.capturedDate() else { return nil }
|
||||
return (TravelAlbumOTGTimeGrouping.halfHourSlotStart(for: date), photo)
|
||||
}
|
||||
let grouped = Dictionary(grouping: pairs, by: \.slotStart)
|
||||
return grouped.keys
|
||||
.sorted(by: >)
|
||||
.map { slotStart in
|
||||
let photos = (grouped[slotStart] ?? [])
|
||||
.map(\.photo)
|
||||
.sorted { lhs, rhs in
|
||||
(lhs.capturedDate() ?? .distantPast) > (rhs.capturedDate() ?? .distantPast)
|
||||
}
|
||||
return TravelAlbumOTGPhotoSection(
|
||||
slotId: TravelAlbumOTGTimeGrouping.slotId(for: slotStart),
|
||||
headerTitle: TravelAlbumOTGTimeGrouping.sectionHeaderTitle(for: slotStart),
|
||||
photos: photos
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 按 Android OTG 页规则构造左侧时间快速导航分组。
|
||||
func buildSidebarGroups() -> [TravelAlbumOTGDateGroup] {
|
||||
var slotCounts: [Date: Int] = [:]
|
||||
for photo in self {
|
||||
guard let date = photo.capturedDate() else { continue }
|
||||
let slotStart = TravelAlbumOTGTimeGrouping.halfHourSlotStart(for: date)
|
||||
slotCounts[slotStart, default: 0] += 1
|
||||
}
|
||||
let groupedByDay = Dictionary(grouping: slotCounts.keys) { slotStart in
|
||||
TravelAlbumOTGTimeGrouping.dayStart(for: slotStart)
|
||||
}
|
||||
return groupedByDay.keys
|
||||
.sorted(by: >)
|
||||
.map { dayStart in
|
||||
let slots = (groupedByDay[dayStart] ?? [])
|
||||
.sorted(by: >)
|
||||
.map { slotStart in
|
||||
TravelAlbumOTGTimeSlot(
|
||||
id: TravelAlbumOTGTimeGrouping.slotId(for: slotStart),
|
||||
dateLabel: TravelAlbumOTGTimeGrouping.dateLabel(for: slotStart),
|
||||
slotStartLabel: TravelAlbumOTGTimeGrouping.timeLabel(for: slotStart),
|
||||
photoCount: slotCounts[slotStart] ?? 0
|
||||
)
|
||||
}
|
||||
return TravelAlbumOTGDateGroup(
|
||||
dateLabel: TravelAlbumOTGTimeGrouping.dateLabel(for: dayStart),
|
||||
slots: slots
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OTG 传输页时间分组格式化工具,对齐 Android 的半小时槽规则。
|
||||
enum TravelAlbumOTGTimeGrouping {
|
||||
private static let calendar = Calendar(identifier: .gregorian)
|
||||
|
||||
/// 解析 Android OTG 页面使用的拍摄时间字符串。
|
||||
static func capturedDate(from text: String) -> Date? {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
return formatter.date(from: text)
|
||||
}
|
||||
|
||||
/// 计算半小时槽起点。
|
||||
static func halfHourSlotStart(for date: Date) -> Date {
|
||||
let components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date)
|
||||
let minute = ((components.minute ?? 0) / 30) * 30
|
||||
return calendar.date(
|
||||
from: DateComponents(
|
||||
year: components.year,
|
||||
month: components.month,
|
||||
day: components.day,
|
||||
hour: components.hour,
|
||||
minute: minute
|
||||
)
|
||||
) ?? date
|
||||
}
|
||||
|
||||
/// 计算日期起点。
|
||||
static func dayStart(for date: Date) -> Date {
|
||||
calendar.startOfDay(for: date)
|
||||
}
|
||||
|
||||
/// 时间槽唯一标识。
|
||||
static func slotId(for date: Date) -> String {
|
||||
format(date, pattern: "yyyy-MM-dd'T'HH:mm", locale: Locale(identifier: "en_US_POSIX"))
|
||||
}
|
||||
|
||||
/// 主列表分段标题。
|
||||
static func sectionHeaderTitle(for date: Date) -> String {
|
||||
let end = calendar.date(byAdding: .minute, value: 30, to: date) ?? date
|
||||
return "\(dateLabel(for: date)) \(timeLabel(for: date)) - \(timeLabel(for: end))"
|
||||
}
|
||||
|
||||
/// 日期标签。
|
||||
static func dateLabel(for date: Date) -> String {
|
||||
format(date, pattern: "MM月dd日", locale: Locale(identifier: "zh_CN"))
|
||||
}
|
||||
|
||||
/// 时间标签。
|
||||
static func timeLabel(for date: Date) -> String {
|
||||
format(date, pattern: "HH:mm", locale: Locale(identifier: "en_US_POSIX"))
|
||||
}
|
||||
|
||||
private static func format(_ date: Date, pattern: String, locale: Locale) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = locale
|
||||
formatter.dateFormat = pattern
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
/// 相机照片格式匹配工具,对齐 Android `OtgPhotoFormatMatcher`。
|
||||
enum TravelAlbumOTGPhotoFormatMatcher {
|
||||
/// 相机照片类型。
|
||||
|
||||
@ -17,10 +17,16 @@ final class WiredCameraTransferViewModel {
|
||||
didSet { notifyStateChanged() }
|
||||
}
|
||||
private(set) var photos: [TravelAlbumOTGPhotoItem] = [] {
|
||||
didSet { notifyStateChanged() }
|
||||
didSet {
|
||||
normalizeSelectedTimeSlot()
|
||||
notifyStateChanged()
|
||||
}
|
||||
}
|
||||
private(set) var selectedTab: TravelAlbumOTGTransferTab = .all {
|
||||
didSet { notifyStateChanged() }
|
||||
didSet {
|
||||
normalizeSelectedTimeSlot()
|
||||
notifyStateChanged()
|
||||
}
|
||||
}
|
||||
private(set) var selectedPhotoIds: Set<String> = [] {
|
||||
didSet { notifyStateChanged() }
|
||||
@ -28,6 +34,12 @@ final class WiredCameraTransferViewModel {
|
||||
private(set) var selectUploadMode = false {
|
||||
didSet { notifyStateChanged() }
|
||||
}
|
||||
private(set) var sidebarExpanded = true {
|
||||
didSet { notifyStateChanged() }
|
||||
}
|
||||
private(set) var selectedTimeSlotId: String? {
|
||||
didSet { notifyStateChanged() }
|
||||
}
|
||||
private(set) var sonyMTPHint: String?
|
||||
private(set) var isContentCatalogReady = false
|
||||
|
||||
@ -135,6 +147,32 @@ final class WiredCameraTransferViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 过滤后照片的半小时时间分段。
|
||||
var photoSections: [TravelAlbumOTGPhotoSection] {
|
||||
filteredPhotos().buildPhotoSections()
|
||||
}
|
||||
|
||||
/// 过滤后照片的左侧时间导航分组。
|
||||
var sidebarGroups: [TravelAlbumOTGDateGroup] {
|
||||
filteredPhotos().buildSidebarGroups()
|
||||
}
|
||||
|
||||
/// 当前可见列表中可被选择上传的照片 ID。
|
||||
var selectableVisiblePhotoIds: Set<String> {
|
||||
Set(filteredPhotos().filter(\.canSelectForUpload).map(\.id))
|
||||
}
|
||||
|
||||
/// 当前可见列表中是否已全选可上传照片。
|
||||
var allVisibleSelectablePhotosSelected: Bool {
|
||||
let ids = selectableVisiblePhotoIds
|
||||
return !ids.isEmpty && ids.isSubset(of: selectedPhotoIds)
|
||||
}
|
||||
|
||||
/// 当前可见列表中已选择的可上传照片数量。
|
||||
var selectedVisibleUploadCount: Int {
|
||||
selectedPhotoIds.intersection(selectableVisiblePhotoIds).count
|
||||
}
|
||||
|
||||
/// 绑定连接管理器并启动发现。
|
||||
func start() {
|
||||
connectionManager.configureLiveTransfer(albumID: albumId)
|
||||
@ -178,6 +216,17 @@ final class WiredCameraTransferViewModel {
|
||||
selectedPhotoIds = selectedPhotoIds.intersection(Set(filteredPhotos().map(\.id)))
|
||||
}
|
||||
|
||||
/// 展开或收起左侧时间快速导航。
|
||||
func toggleSidebar() {
|
||||
sidebarExpanded.toggle()
|
||||
}
|
||||
|
||||
/// 选择左侧时间槽,并联动主列表滚动。
|
||||
func selectTimeSlot(id: String) {
|
||||
guard photoSections.contains(where: { $0.slotId == id }) else { return }
|
||||
selectedTimeSlotId = id
|
||||
}
|
||||
|
||||
/// 切换传输模式。
|
||||
func selectTransferMode(_ option: String) {
|
||||
guard option == "边拍边传" || option == "拍后传输" else { return }
|
||||
@ -252,6 +301,18 @@ final class WiredCameraTransferViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换当前可见照片的全选状态。
|
||||
func toggleVisibleSelectablePhotos() {
|
||||
guard selectUploadMode else { return }
|
||||
let ids = selectableVisiblePhotoIds
|
||||
guard !ids.isEmpty else { return }
|
||||
if ids.isSubset(of: selectedPhotoIds) {
|
||||
selectedPhotoIds.subtract(ids)
|
||||
} else {
|
||||
selectedPhotoIds.formUnion(ids)
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传选中照片。
|
||||
func uploadSelectedTransferPhotos() {
|
||||
let ids = selectedPhotoIds
|
||||
@ -303,6 +364,17 @@ final class WiredCameraTransferViewModel {
|
||||
photos = persistedItems.sorted { $0.capturedAt > $1.capturedAt }
|
||||
}
|
||||
|
||||
private func normalizeSelectedTimeSlot() {
|
||||
let sections = filteredPhotos().buildPhotoSections()
|
||||
guard let firstSlotId = sections.first?.slotId else {
|
||||
selectedTimeSlotId = nil
|
||||
return
|
||||
}
|
||||
if selectedTimeSlotId == nil || !sections.contains(where: { $0.slotId == selectedTimeSlotId }) {
|
||||
selectedTimeSlotId = firstSlotId
|
||||
}
|
||||
}
|
||||
|
||||
private func startUploadByIds(_ ids: Set<String>, emptyMessage: String) {
|
||||
guard !isUploading else {
|
||||
showMessage("正在上传,请稍候")
|
||||
|
||||
Reference in New Issue
Block a user