对齐旅拍相册详情页 Android UI,并修复网格预览与布局问题。
重构相册详情为信息卡片、Tab 筛选、排序、批量删除与底部上传栏;修复网格重叠、禁用按钮蒙层,并支持点击预览大图。同步扩展素材列表 API 与 ViewModel 分页逻辑,并优化有线传图缩略图与传输性能。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,34 @@
|
||||
//
|
||||
// MiddleTruncatedText.swift
|
||||
// suixinkan
|
||||
//
|
||||
// 文件名中间省略展示,对齐 Android MiddleEllipsisTextView。
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// 中间省略文本,用于长文件名展示。
|
||||
struct MiddleTruncatedText: UIViewRepresentable {
|
||||
let text: String
|
||||
var font: UIFont = .systemFont(ofSize: 11, weight: .regular)
|
||||
var textColor: UIColor = UIColor(red: 0.2, green: 0.2, blue: 0.2, alpha: 1)
|
||||
|
||||
/// 创建中间省略 UILabel。
|
||||
func makeUIView(context: Context) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.lineBreakMode = .byTruncatingMiddle
|
||||
label.numberOfLines = 1
|
||||
label.setContentHuggingPriority(.required, for: .vertical)
|
||||
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
label.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
return label
|
||||
}
|
||||
|
||||
/// 更新文本样式。
|
||||
func updateUIView(_ uiView: UILabel, context: Context) {
|
||||
uiView.text = text
|
||||
uiView.font = font
|
||||
uiView.textColor = textColor
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
//
|
||||
// TravelAlbumDetailDesign.swift
|
||||
// suixinkan
|
||||
//
|
||||
// 旅拍相册详情页设计 Token,对齐 Android TravelAlbumDetailScreen。
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 旅拍相册详情页专用色值与尺寸。
|
||||
enum TravelAlbumDetailDesign {
|
||||
static let pageBackground = Color(hex: 0xF5F5F5)
|
||||
static let text333 = Color(hex: 0x333333)
|
||||
static let text999 = Color(hex: 0x999999)
|
||||
static let lightBlue = Color(hex: 0xEAF4FF)
|
||||
static let deleteRed = Color(hex: 0xE53935)
|
||||
static let uploadedGreen = Color(hex: 0x34C759)
|
||||
static let borderLight = Color(hex: 0xEEEEEE)
|
||||
static let selectionInactiveBackground = Color(hex: 0xF4F4F4)
|
||||
static let cardShadow = Color.black.opacity(0.08)
|
||||
|
||||
static let cardCornerRadius: CGFloat = 12
|
||||
static let cardShadowRadius: CGFloat = 2
|
||||
static let infoIconSize: CGFloat = 48
|
||||
static let infoIconCornerRadius: CGFloat = 10
|
||||
static let callButtonSize: CGFloat = 40
|
||||
static let tabPillCornerRadius: CGFloat = 18
|
||||
static let actionCircleSize: CGFloat = 32
|
||||
static let gridHorizontalSpacing: CGFloat = 8
|
||||
static let gridVerticalSpacing: CGFloat = 12
|
||||
static let gridPadding: CGFloat = 12
|
||||
static let thumbnailCornerRadius: CGFloat = 8
|
||||
static let uploadedBadgeCornerRadius: CGFloat = 4
|
||||
static let bottomButtonHeight: CGFloat = 48
|
||||
static let bottomButtonCornerRadius: CGFloat = 10
|
||||
static let contentHorizontalPadding: CGFloat = 16
|
||||
static let sectionSpacing: CGFloat = 12
|
||||
static let bottomBarHorizontalPadding: CGFloat = 20
|
||||
static let bottomBarVerticalPadding: CGFloat = 12
|
||||
static let selectionCheckSize: CGFloat = 20
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
//
|
||||
// TravelAlbumDetailInfoCard.swift
|
||||
// suixinkan
|
||||
//
|
||||
// 旅拍相册详情信息卡片,对齐 Android AlbumInfoCard。
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 旅拍相册详情顶部信息卡片。
|
||||
struct TravelAlbumDetailInfoCard: View {
|
||||
let album: TravelAlbumItem
|
||||
let onCallClick: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
RoundedRectangle(cornerRadius: TravelAlbumDetailDesign.infoIconCornerRadius)
|
||||
.fill(AppDesign.primary)
|
||||
.frame(width: TravelAlbumDetailDesign.infoIconSize, height: TravelAlbumDetailDesign.infoIconSize)
|
||||
.overlay {
|
||||
Image(systemName: "list.clipboard.fill")
|
||||
.font(.system(size: 28))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("手机号 \(OrderListUI.maskPhone(album.displayPhone))")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(TravelAlbumDetailDesign.text333)
|
||||
Text("创建时间 \(album.createdAt)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(TravelAlbumDetailDesign.text999)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Button {
|
||||
onCallClick(album.displayPhone)
|
||||
} label: {
|
||||
Circle()
|
||||
.fill(TravelAlbumDetailDesign.lightBlue)
|
||||
.frame(width: TravelAlbumDetailDesign.callButtonSize, height: TravelAlbumDetailDesign.callButtonSize)
|
||||
.overlay {
|
||||
Image(systemName: "phone.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("拨打电话")
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: TravelAlbumDetailDesign.cardCornerRadius))
|
||||
.shadow(color: TravelAlbumDetailDesign.cardShadow, radius: TravelAlbumDetailDesign.cardShadowRadius, x: 0, y: 1)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
//
|
||||
// TravelAlbumDetailMaterialGridItem.swift
|
||||
// suixinkan
|
||||
//
|
||||
// 旅拍相册详情素材网格项,对齐 Android MaterialGridItem。
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 旅拍相册详情素材网格单元。
|
||||
struct TravelAlbumDetailMaterialGridItem: View {
|
||||
let material: TravelAlbumMaterial
|
||||
let isSelectionMode: Bool
|
||||
let isSelected: Bool
|
||||
let onSelect: () -> Void
|
||||
let onPreview: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
thumbnailSection
|
||||
|
||||
MiddleTruncatedText(
|
||||
text: material.fileName,
|
||||
font: .systemFont(ofSize: 11),
|
||||
textColor: UIColor(TravelAlbumDetailDesign.text333)
|
||||
)
|
||||
.frame(maxWidth: .infinity, minHeight: 14, maxHeight: 14, alignment: .leading)
|
||||
|
||||
Text(WiredTransferPhotoRecord.formatFileSize(Int64(material.fileSize)))
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(TravelAlbumDetailDesign.text999)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if isSelectionMode {
|
||||
onSelect()
|
||||
} else {
|
||||
onPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 1:1 缩略图区域,限制在网格单元宽度内避免重叠溢出。
|
||||
private var thumbnailSection: some View {
|
||||
Color.clear
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.overlay {
|
||||
RemoteImage(
|
||||
urlString: material.thumbnailURLString,
|
||||
contentMode: .fill
|
||||
) {
|
||||
Color(hex: 0xE5E7EB)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: TravelAlbumDetailDesign.thumbnailCornerRadius))
|
||||
.overlay(alignment: .topLeading) {
|
||||
Text("已上传")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 2)
|
||||
.background(TravelAlbumDetailDesign.uploadedGreen)
|
||||
.clipShape(RoundedRectangle(cornerRadius: TravelAlbumDetailDesign.uploadedBadgeCornerRadius))
|
||||
.padding(4)
|
||||
}
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if isSelectionMode {
|
||||
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: TravelAlbumDetailDesign.selectionCheckSize))
|
||||
.foregroundStyle(isSelected ? AppDesign.primary : .white)
|
||||
.padding(4)
|
||||
.background(Color.black.opacity(0.4), in: Circle())
|
||||
.padding(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,153 @@
|
||||
//
|
||||
// TravelAlbumDetailPhotoManageCard.swift
|
||||
// suixinkan
|
||||
//
|
||||
// 旅拍相册详情照片管理卡片,对齐 Android PhotoManageCard。
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 旅拍相册详情照片管理区域。
|
||||
struct TravelAlbumDetailPhotoManageCard: View {
|
||||
let materials: [TravelAlbumMaterial]
|
||||
let allPhotoCount: Int
|
||||
let selectedTab: Int
|
||||
let orderBy: Int
|
||||
let isSelectionMode: Bool
|
||||
let selectedMaterialIDs: Set<Int>
|
||||
let onTabSelect: (Int) -> Void
|
||||
let onOrderBySelect: (Int) -> Void
|
||||
let onToggleSelectionMode: () -> Void
|
||||
let onMaterialClick: (TravelAlbumMaterial) -> Void
|
||||
let onMaterialPreview: (TravelAlbumMaterial) -> Void
|
||||
let onLoadMore: (Int) -> Void
|
||||
|
||||
private let gridColumns = [
|
||||
GridItem(.flexible(), spacing: TravelAlbumDetailDesign.gridHorizontalSpacing),
|
||||
GridItem(.flexible(), spacing: TravelAlbumDetailDesign.gridHorizontalSpacing),
|
||||
GridItem(.flexible())
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
tabToolbar
|
||||
Divider().overlay(TravelAlbumDetailDesign.borderLight)
|
||||
photoGrid
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: TravelAlbumDetailDesign.cardCornerRadius))
|
||||
.shadow(color: TravelAlbumDetailDesign.cardShadow, radius: TravelAlbumDetailDesign.cardShadowRadius, x: 0, y: 1)
|
||||
}
|
||||
|
||||
private var tabToolbar: some View {
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
tabPill(
|
||||
text: "全部照片\(allPhotoCount)",
|
||||
selected: selectedTab == TravelAlbumDetailViewModel.tabAll
|
||||
) {
|
||||
onTabSelect(TravelAlbumDetailViewModel.tabAll)
|
||||
}
|
||||
tabPill(
|
||||
text: "已购照片",
|
||||
selected: selectedTab == TravelAlbumDetailViewModel.tabPurchased
|
||||
) {
|
||||
onTabSelect(TravelAlbumDetailViewModel.tabPurchased)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
sortMenu
|
||||
|
||||
if selectedTab == TravelAlbumDetailViewModel.tabAll {
|
||||
Button(action: onToggleSelectionMode) {
|
||||
Circle()
|
||||
.fill(isSelectionMode ? TravelAlbumDetailDesign.lightBlue : TravelAlbumDetailDesign.selectionInactiveBackground)
|
||||
.frame(width: TravelAlbumDetailDesign.actionCircleSize, height: TravelAlbumDetailDesign.actionCircleSize)
|
||||
.overlay {
|
||||
Image(systemName: isSelectionMode ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(isSelectionMode ? AppDesign.primary : TravelAlbumDetailDesign.text999)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("选择")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
private var sortMenu: some View {
|
||||
Menu {
|
||||
ForEach(TravelAlbumDetailViewModel.sortOptions, id: \.value) { option in
|
||||
Button {
|
||||
onOrderBySelect(option.value)
|
||||
} label: {
|
||||
if orderBy == option.value {
|
||||
Label(option.label, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(option.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Circle()
|
||||
.fill(TravelAlbumDetailDesign.lightBlue)
|
||||
.frame(width: TravelAlbumDetailDesign.actionCircleSize, height: TravelAlbumDetailDesign.actionCircleSize)
|
||||
.overlay {
|
||||
Image(systemName: "line.3.horizontal.decrease")
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("筛选")
|
||||
}
|
||||
|
||||
private var photoGrid: some View {
|
||||
ScrollView {
|
||||
if materials.isEmpty {
|
||||
Text("暂无照片")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(TravelAlbumDetailDesign.text999)
|
||||
.frame(maxWidth: .infinity, minHeight: 180)
|
||||
} else {
|
||||
LazyVGrid(
|
||||
columns: gridColumns,
|
||||
spacing: TravelAlbumDetailDesign.gridVerticalSpacing
|
||||
) {
|
||||
ForEach(Array(materials.enumerated()), id: \.element.id) { index, material in
|
||||
TravelAlbumDetailMaterialGridItem(
|
||||
material: material,
|
||||
isSelectionMode: isSelectionMode,
|
||||
isSelected: selectedMaterialIDs.contains(material.id),
|
||||
onSelect: { onMaterialClick(material) },
|
||||
onPreview: { onMaterialPreview(material) }
|
||||
)
|
||||
.onAppear {
|
||||
onLoadMore(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(TravelAlbumDetailDesign.gridPadding)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
/// Tab 胶囊按钮。
|
||||
private func tabPill(text: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(text)
|
||||
.font(.system(size: 13, weight: selected ? .medium : .regular))
|
||||
.foregroundStyle(selected ? Color.white : AppDesign.primary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 7)
|
||||
.background(selected ? AppDesign.primary : TravelAlbumDetailDesign.lightBlue)
|
||||
.clipShape(RoundedRectangle(cornerRadius: TravelAlbumDetailDesign.tabPillCornerRadius))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user