Sync OTG transfer photo list UI

This commit is contained in:
2026-07-08 11:00:44 +08:00
parent d642234b38
commit 87fba3d5d6
5 changed files with 839 additions and 91 deletions

View File

@ -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 {
///

View File

@ -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("正在上传,请稍候")