You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

824 lines
33 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import SwiftUI
import QRCode
import CoreData
import Photos
#if canImport(PhotosUI)
import PhotosUI
#endif
// MARK: -
enum TabType: String, CaseIterable {
case colors = "colors"
case dots = "dots"
case eyes = "eyes"
case logos = "logos"
var displayName: String {
switch self {
case .colors: return "颜色"
case .dots: return "点类型"
case .eyes: return "眼睛"
case .logos: return "Logo"
}
}
var iconName: String {
switch self {
case .colors: return "paintpalette"
case .dots: return "circle.grid.3x3"
case .eyes: return "eye"
case .logos: return "photo"
}
}
}
// MARK: -
struct QRCodeStyleView: View {
let qrCodeContent: String
@Environment(\.dismiss) private var dismiss
@StateObject private var coreDataManager = CoreDataManager.shared
//
@State private var selectedForegroundColor: QRCodeColor = .black
@State private var selectedBackgroundColor: QRCodeColor = .white
//
@State private var selectedDotType: QRCodeDotType = .square
//
@State private var selectedEyeType: QRCodeEyeType = .square
// Logo
@State private var selectedLogo: QRCodeLogo? = nil
@State private var customLogoImage: UIImage? = nil
@State private var photoLibraryAccessGranted = false
@State private var showingImagePicker = false
//
@State private var qrCodeImage: UIImage?
@State private var isLoading = false
//
@State private var selectedTabType: TabType = .colors
// QRCode
private func createQRCodeDocument() -> QRCode.Document {
let d = try! QRCode.Document(engine: QRCodeEngineExternal())
// 使
d.utf8String = qrCodeContent
//
d.design.backgroundColor(selectedBackgroundColor.cgColor)
//
d.design.style.eye = QRCode.FillStyle.Solid(selectedForegroundColor.cgColor)
d.design.style.eyeBackground = selectedBackgroundColor.cgColor
//
d.design.shape.onPixels = selectedDotType.pixelShape
d.design.style.onPixels = QRCode.FillStyle.Solid(selectedForegroundColor.cgColor)
d.design.style.onPixelsBackground = selectedBackgroundColor.cgColor
d.design.shape.offPixels = selectedDotType.pixelShape
d.design.style.offPixels = QRCode.FillStyle.Solid(selectedBackgroundColor.cgColor)
d.design.style.offPixelsBackground = selectedBackgroundColor.cgColor
//
d.design.shape.eye = selectedEyeType.eyeShape
// LogoLogo
if let customLogoImage = customLogoImage,
let cgImage = customLogoImage.cgImage {
// 使Logo
print("应用自定义LogoCGImage大小: \(cgImage.width) x \(cgImage.height)")
d.logoTemplate = QRCode.LogoTemplate.CircleCenter(image: cgImage, inset: 0)
} else if let selectedLogo = selectedLogo,
let logoImage = selectedLogo.image,
let cgImage = logoImage.cgImage {
// 使Logo
print("应用预设Logo: \(selectedLogo.displayName)")
d.logoTemplate = QRCode.LogoTemplate.CircleCenter(image: cgImage)
} else {
print("没有设置任何Logo")
}
return d
}
var body: some View {
VStack(spacing: 0) {
//
qrCodePreviewSection
//
styleSelectionSection
}
.navigationTitle("自定义样式")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("保存") {
saveQRCode()
}
.font(.system(size: 16, weight: .semibold))
}
}
.onAppear {
checkPhotoLibraryPermission()
}
.sheet(isPresented: $showingImagePicker) {
ImagePicker(
onImageSelected: { image in
customLogoImage = image
selectedLogo = nil // Logo
},
shouldProcessImage: true,
targetSize: CGSize(width: 80, height: 80)
)
}
}
// MARK: -
private var qrCodePreviewSection: some View {
VStack(spacing: 16) {
QRCodeDocumentUIView(document: createQRCodeDocument())
.frame(width: 300, height: 300)
}
.padding()
.background(Color(.systemBackground))
}
// MARK: -
private var styleSelectionSection: some View {
VStack(spacing: 0) {
//
tabTypeSelection
//
contentArea
}
.background(Color(.systemGroupedBackground))
}
// MARK: -
private var tabTypeSelection: some View {
HStack(spacing: 0) {
ForEach(TabType.allCases, id: \.self) { tabType in
Button(action: {
selectedTabType = tabType
}) {
VStack(spacing: 4) {
Image(systemName: tabType.iconName)
.font(.system(size: 20))
.foregroundColor(selectedTabType == tabType ? .blue : .gray)
Text(tabType.displayName)
.font(.caption)
.foregroundColor(selectedTabType == tabType ? .blue : .gray)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(
Rectangle()
.fill(selectedTabType == tabType ? Color.blue.opacity(0.1) : Color.clear)
)
}
}
}
.background(Color(.systemBackground))
.overlay(
Rectangle()
.frame(height: 1)
.foregroundColor(Color(.separator)),
alignment: .bottom
)
}
// MARK: -
private var contentArea: some View {
Group {
switch selectedTabType {
case .colors:
colorsContent
case .dots:
dotsContent
case .eyes:
eyesContent
case .logos:
logosContent
}
}
.frame(maxHeight: 400)
}
// MARK: -
private var colorsContent: some View {
ScrollView {
VStack(spacing: 24) {
//
colorSelectionSection(
title: "前景色",
colors: QRCodeColor.foregroundColors,
selectedColor: $selectedForegroundColor
)
//
colorSelectionSection(
title: "背景色",
colors: QRCodeColor.backgroundColors,
selectedColor: $selectedBackgroundColor
)
}
.padding()
}
}
// MARK: -
private var dotsContent: some View {
ScrollView {
VStack(spacing: 16) {
Text("选择点类型")
.font(.title2)
.fontWeight(.bold)
.padding(.top)
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3), spacing: 16) {
ForEach(QRCodeDotType.allCases, id: \.self) { dotType in
Button(action: {
selectedDotType = dotType
}) {
VStack(spacing: 8) {
if let image = loadImage(named: dotType.thumbnailName) {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 60, height: 60)
.background(Color.white)
.cornerRadius(12)
} else {
RoundedRectangle(cornerRadius: 12)
.fill(Color.gray.opacity(0.3))
.frame(width: 60, height: 60)
.overlay(
Text("?")
.font(.title2)
.foregroundColor(.secondary)
)
}
Text(dotType.displayName)
.font(.caption)
.foregroundColor(.primary)
.multilineTextAlignment(.center)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(selectedDotType == dotType ? Color.blue.opacity(0.1) : Color.clear)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(selectedDotType == dotType ? Color.blue : Color.clear, lineWidth: 3)
)
)
}
}
}
.padding(.horizontal)
}
}
}
// MARK: -
private var eyesContent: some View {
ScrollView {
VStack(spacing: 16) {
Text("选择眼睛类型")
.font(.title2)
.fontWeight(.bold)
.padding(.top)
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3), spacing: 16) {
ForEach(QRCodeEyeType.allCases, id: \.self) { eyeType in
Button(action: {
selectedEyeType = eyeType
}) {
VStack(spacing: 8) {
if let image = loadImage(named: eyeType.thumbnailName) {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 60, height: 60)
.background(Color.white)
.cornerRadius(12)
} else {
RoundedRectangle(cornerRadius: 12)
.fill(Color.gray.opacity(0.3))
.frame(width: 60, height: 60)
.overlay(
Text("?")
.font(.title2)
.foregroundColor(.secondary)
)
}
Text(eyeType.displayName)
.font(.caption)
.foregroundColor(.primary)
.multilineTextAlignment(.center)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(selectedEyeType == eyeType ? Color.blue.opacity(0.1) : Color.clear)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(selectedEyeType == eyeType ? Color.blue : Color.clear, lineWidth: 3)
)
)
}
}
}
.padding(.horizontal)
}
}
}
// MARK: - Logo
private var logosContent: some View {
ScrollView {
VStack(spacing: 16) {
Text("选择Logo")
.font(.title2)
.fontWeight(.bold)
.padding(.top)
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3), spacing: 16) {
// Logo
Button(action: {
selectedLogo = nil
customLogoImage = nil
}) {
VStack(spacing: 8) {
RoundedRectangle(cornerRadius: 12)
.fill(Color.gray.opacity(0.3))
.frame(width: 60, height: 60)
.overlay(
Text("")
.font(.title2)
.foregroundColor(.secondary)
)
Text("无Logo")
.font(.caption)
.foregroundColor(.primary)
.multilineTextAlignment(.center)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(selectedLogo == nil && customLogoImage == nil ? Color.blue.opacity(0.1) : Color.clear)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(selectedLogo == nil && customLogoImage == nil ? Color.blue : Color.clear, lineWidth: 3)
)
)
}
// Logo
if photoLibraryAccessGranted {
Button(action: {
showingImagePicker = true
}) {
VStack(spacing: 8) {
if let customLogoImage = customLogoImage {
Image(uiImage: customLogoImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 60, height: 60)
.background(Color.white)
.cornerRadius(12)
} else {
RoundedRectangle(cornerRadius: 12)
.fill(Color.blue.opacity(0.2))
.frame(width: 60, height: 60)
.overlay(
Image(systemName: "photo.badge.plus")
.font(.title2)
.foregroundColor(.blue)
)
}
Text("自定义")
.font(.caption)
.foregroundColor(.primary)
.multilineTextAlignment(.center)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(customLogoImage != nil ? Color.blue.opacity(0.1) : Color.clear)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(customLogoImage != nil ? Color.blue : Color.clear, lineWidth: 3)
)
)
}
} else {
//
Button(action: {
//
if let settingsUrl = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(settingsUrl)
}
}) {
VStack(spacing: 8) {
RoundedRectangle(cornerRadius: 12)
.fill(Color.red.opacity(0.2))
.frame(width: 60, height: 60)
.overlay(
Image(systemName: "exclamationmark.triangle")
.font(.title2)
.foregroundColor(.red)
)
Text("需要权限")
.font(.caption)
.foregroundColor(.red)
.multilineTextAlignment(.center)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color.clear)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color.red.opacity(0.3), lineWidth: 1)
)
)
}
}
// Logo
ForEach(QRCodeLogo.allCases, id: \.self) { logo in
Button(action: {
selectedLogo = logo
customLogoImage = nil // Logo
}) {
VStack(spacing: 8) {
if let image = loadImage(named: logo.thumbnailName) {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 60, height: 60)
.background(Color.white)
.cornerRadius(12)
} else {
RoundedRectangle(cornerRadius: 12)
.fill(Color.gray.opacity(0.3))
.frame(width: 60, height: 60)
.overlay(
Text("?")
.font(.title2)
.foregroundColor(.secondary)
)
}
Text(logo.displayName)
.font(.caption)
.foregroundColor(.primary)
.multilineTextAlignment(.center)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(selectedLogo == logo ? Color.blue.opacity(0.1) : Color.clear)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(selectedLogo == logo ? Color.blue : Color.clear, lineWidth: 3)
)
)
}
}
}
.padding(.horizontal)
}
}
}
// MARK: -
private func colorSelectionSection(
title: String,
colors: [QRCodeColor],
selectedColor: Binding<QRCodeColor>
) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.headline)
.foregroundColor(.primary)
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 6), spacing: 12) {
ForEach(colors, id: \.self) { color in
Button(action: {
selectedColor.wrappedValue = color
}) {
RoundedRectangle(cornerRadius: 8)
.fill(color.color)
.frame(height: 40)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(selectedColor.wrappedValue == color ? Color.blue : Color.clear, lineWidth: 3)
)
}
}
}
}
}
// MARK: -
private func saveQRCode() {
guard let qrCodeImage = qrCodeImage else { return }
//
UIImageWriteToSavedPhotosAlbum(qrCodeImage, nil, nil, nil)
//
saveToHistory()
dismiss()
}
// MARK: -
private func saveToHistory() {
let context = coreDataManager.container.viewContext
let historyItem = HistoryItem(context: context)
historyItem.id = UUID()
historyItem.dataType = DataType.qrcode.rawValue
historyItem.dataSource = DataSource.created.rawValue
historyItem.createdAt = Date()
historyItem.isFavorite = false
historyItem.qrCodeType = "custom"
historyItem.content = qrCodeContent
do {
try context.save()
} catch {
print("保存到历史记录失败:\(error.localizedDescription)")
}
}
// MARK: -
private func checkPhotoLibraryPermission() {
let status = PHPhotoLibrary.authorizationStatus()
print("相册权限状态: \(status.rawValue)")
switch status {
case .authorized, .limited:
photoLibraryAccessGranted = true
print("相册权限已授权")
case .denied, .restricted:
photoLibraryAccessGranted = false
print("相册权限被拒绝")
case .notDetermined:
print("相册权限未确定,正在请求...")
PHPhotoLibrary.requestAuthorization { newStatus in
DispatchQueue.main.async {
self.photoLibraryAccessGranted = (newStatus == .authorized || newStatus == .limited)
print("权限请求结果: \(newStatus.rawValue), 授权状态: \(self.photoLibraryAccessGranted)")
}
}
@unknown default:
photoLibraryAccessGranted = false
print("相册权限未知状态")
}
}
// MARK: -
private func loadImage(named name: String) -> UIImage? {
// 1: Bundle
if let image = UIImage(named: name) {
return image
}
// 2: Resources
let subdirectories = ["dots", "eyes", "logos"]
for subdirectory in subdirectories {
if let path = Bundle.main.path(forResource: name, ofType: "png", inDirectory: "Resources/\(subdirectory)") {
return UIImage(contentsOfFile: path)
}
}
// 3: BundleResources
if let bundlePath = Bundle.main.path(forResource: "Resources", ofType: nil) {
for subdirectory in subdirectories {
if let imagePath = Bundle.main.path(forResource: name, ofType: "png", inDirectory: subdirectory) {
return UIImage(contentsOfFile: imagePath)
}
}
}
// 4: Assets.xcassets
if let image = UIImage(named: name, in: Bundle.main, with: nil) {
return image
}
// 5: Bundle
if let path = Bundle.main.path(forResource: name, ofType: "png") {
return UIImage(contentsOfFile: path)
}
return nil
}
}
// MARK: -
struct ImagePicker: UIViewControllerRepresentable {
let onImageSelected: (UIImage) -> Void
let shouldProcessImage: Bool
let targetSize: CGSize?
init(onImageSelected: @escaping (UIImage) -> Void, shouldProcessImage: Bool = false, targetSize: CGSize? = nil) {
self.onImageSelected = onImageSelected
self.shouldProcessImage = shouldProcessImage
self.targetSize = targetSize
}
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = .photoLibrary
picker.allowsEditing = false
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let parent: ImagePicker
init(_ parent: ImagePicker) {
self.parent = parent
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
if let image = info[.originalImage] as? UIImage {
let finalImage: UIImage
if parent.shouldProcessImage, let targetSize = parent.targetSize {
//
finalImage = processImageToSquare(image: image, targetSize: targetSize)
//
let memorySize = calculateImageMemorySize(image: finalImage)
print("📊 最终自定义图片内存大小: \(memorySize)")
} else {
// 使
finalImage = image
}
parent.onImageSelected(finalImage)
}
picker.dismiss(animated: true)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
}
// 5KB
private func processImageToSquare(image: UIImage, targetSize: CGSize) -> UIImage {
let originalSize = image.size
//
let squareSize = min(originalSize.width, originalSize.height)
//
let cropX = (originalSize.width - squareSize) / 2
let cropY = (originalSize.height - squareSize) / 2
let cropRect = CGRect(x: cropX, y: cropY, width: squareSize, height: squareSize)
//
guard let cgImage = image.cgImage?.cropping(to: cropRect) else {
return image
}
// UIImage
let croppedImage = UIImage(cgImage: cgImage)
//
let renderer = UIGraphicsImageRenderer(size: targetSize)
let scaledImage = renderer.image { context in
croppedImage.draw(in: CGRect(origin: .zero, size: targetSize))
}
// 5KB
return compressImageToTargetSize(scaledImage, targetSizeInKB: 5.0)
}
//
private func compressImageToTargetSize(_ image: UIImage, targetSizeInKB: Double) -> UIImage {
let targetSizeInBytes = Int64(targetSizeInKB * 1024)
//
let compressionQualities: [CGFloat] = [0.8, 0.6, 0.4, 0.2, 0.1, 0.05]
for quality in compressionQualities {
if let imageData = image.jpegData(compressionQuality: quality) {
let dataSize = Int64(imageData.count)
if dataSize <= targetSizeInBytes {
// UIImage
if let compressedImage = UIImage(data: imageData) {
print("✅ 图片压缩成功: \(dataSize) bytes (质量: \(quality))")
return compressedImage
}
}
}
}
// JPEG
print("⚠️ JPEG压缩后仍超过目标大小尝试缩小尺寸")
return compressImageByReducingSize(image, targetSizeInBytes: targetSizeInBytes)
}
//
private func compressImageByReducingSize(_ image: UIImage, targetSizeInBytes: Int64) -> UIImage {
let originalSize = image.size
let originalWidth = originalSize.width
let originalHeight = originalSize.height
//
let currentMemorySize = Int64(originalWidth * originalHeight * 4) // RGBA
//
let scaleFactor = sqrt(Double(targetSizeInBytes) / Double(currentMemorySize))
let newWidth = max(originalWidth * CGFloat(scaleFactor), 40) // 40
let newHeight = max(originalHeight * CGFloat(scaleFactor), 40)
let newSize = CGSize(width: newWidth, height: newHeight)
//
let renderer = UIGraphicsImageRenderer(size: newSize)
let resizedImage = renderer.image { context in
image.draw(in: CGRect(origin: .zero, size: newSize))
}
// JPEG
if let imageData = resizedImage.jpegData(compressionQuality: 0.3) {
let finalSize = Int64(imageData.count)
print("✅ 通过缩小尺寸压缩成功: \(finalSize) bytes (新尺寸: \(newWidth) x \(newHeight))")
if let finalImage = UIImage(data: imageData) {
return finalImage
}
}
//
print("⚠️ 无法压缩到目标大小,返回最小尺寸图片")
let minSize = CGSize(width: 40, height: 40)
let rendererMin = UIGraphicsImageRenderer(size: minSize)
return rendererMin.image { context in
image.draw(in: CGRect(origin: .zero, size: minSize))
}
}
//
private func calculateImageMemorySize(image: UIImage) -> String {
guard let cgImage = image.cgImage else {
return "无法计算"
}
let width = cgImage.width
let height = cgImage.height
let bitsPerComponent = cgImage.bitsPerComponent
let bytesPerRow = cgImage.bytesPerRow
let colorSpace = cgImage.colorSpace
//
let memorySizeInBytes = height * bytesPerRow
//
let formatter = ByteCountFormatter()
formatter.allowedUnits = [.useKB, .useMB]
formatter.countStyle = .memory
let memorySizeString = formatter.string(fromByteCount: Int64(memorySizeInBytes))
//
return "\(memorySizeString) (\(width) x \(height), \(bitsPerComponent) bits/component, \(bytesPerRow) bytes/row)"
}
}
}
// MARK: -
#Preview {
QRCodeStyleView(qrCodeContent: "https://www.example.com")
}