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.

838 lines
34 KiB

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 photoPickerItem: Any? = nil
@State private var photoLibraryAccessGranted = false
@State private var showingImagePicker = false
@State private var showingImageCropper = false
@State private var imageToCrop: UIImage? = nil
//
@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)
} 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 { image in
imageToCrop = image
showingImageCropper = true
}
}
.sheet(isPresented: $showingImageCropper) {
if let imageToCrop = imageToCrop {
ImageCropperView(image: imageToCrop) { croppedImage in
customLogoImage = croppedImage
selectedLogo = nil // Logo
self.imageToCrop = nil
}
}
}
}
// 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 ImageCropperView: View {
let image: UIImage
let onCropComplete: (UIImage) -> Void
@Environment(\.dismiss) private var dismiss
@State private var scale: CGFloat = 1.0
@State private var lastScale: CGFloat = 1.0
@State private var offset: CGSize = .zero
@State private var lastOffset: CGSize = .zero
var body: some View {
NavigationView {
GeometryReader { geometry in
ZStack {
Color.black
.ignoresSafeArea()
VStack {
//
ZStack {
//
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.scaleEffect(scale)
.offset(offset)
.gesture(
SimultaneousGesture(
MagnificationGesture()
.onChanged { value in
let delta = value / lastScale
lastScale = value
scale = min(max(scale * delta, 0.5), 3.0)
}
.onEnded { _ in
lastScale = 1.0
},
DragGesture()
.onChanged { value in
let delta = CGSize(
width: value.translation.width - lastOffset.width,
height: value.translation.height - lastOffset.height
)
lastOffset = value.translation
offset = CGSize(
width: offset.width + delta.width,
height: offset.height + delta.height
)
}
.onEnded { _ in
lastOffset = .zero
}
)
)
//
CropOverlay()
}
.frame(height: geometry.size.width) //
.clipped()
Spacer()
//
Text("拖动和缩放来选择圆形Logo区域")
.foregroundColor(.white)
.font(.caption)
.padding(.bottom, 20)
}
}
}
.navigationTitle("裁剪圆形Logo")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("取消") {
dismiss()
}
.foregroundColor(.white)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("完成") {
let croppedImage = cropImage()
onCropComplete(croppedImage)
dismiss()
}
.foregroundColor(.white)
.font(.system(size: 16, weight: .semibold))
}
}
}
}
private func cropImage() -> UIImage {
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 80, height: 80))
return renderer.image { context in
//
let imageSize = image.size
let viewSize = CGSize(width: 80, height: 80)
//
let imageAspectRatio = imageSize.width / imageSize.height
let viewAspectRatio = viewSize.width / viewSize.height
let scaledImageSize: CGSize
let scaledImageOffset: CGPoint
if imageAspectRatio > viewAspectRatio {
//
scaledImageSize = CGSize(width: viewSize.height * imageAspectRatio, height: viewSize.height)
scaledImageOffset = CGPoint(x: (viewSize.width - scaledImageSize.width) / 2, y: 0)
} else {
//
scaledImageSize = CGSize(width: viewSize.width, height: viewSize.width / imageAspectRatio)
scaledImageOffset = CGPoint(x: 0, y: (viewSize.height - scaledImageSize.height) / 2)
}
//
let finalImageSize = CGSize(
width: scaledImageSize.width * scale,
height: scaledImageSize.height * scale
)
let finalImageOffset = CGPoint(
x: scaledImageOffset.x + offset.width,
y: scaledImageOffset.y + offset.height
)
//
let cropRect = CGRect(
x: -finalImageOffset.x,
y: -finalImageOffset.y,
width: finalImageSize.width,
height: finalImageSize.height
)
// -
let circlePath = UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 80, height: 80))
circlePath.addClip()
//
image.draw(in: cropRect)
}
}
}
// MARK: -
struct CropOverlay: View {
var body: some View {
GeometryReader { geometry in
ZStack {
//
Color.black.opacity(0.5)
.mask(
Rectangle()
.overlay(
Circle()
.frame(
width: min(geometry.size.width, geometry.size.height) * 0.8,
height: min(geometry.size.width, geometry.size.height) * 0.8
)
.blendMode(.destinationOut)
)
)
//
Circle()
.stroke(Color.white, lineWidth: 2)
.frame(
width: min(geometry.size.width, geometry.size.height) * 0.8,
height: min(geometry.size.width, geometry.size.height) * 0.8
)
//
ForEach(0..<4) { corner in
Circle()
.fill(Color.white)
.frame(width: 6, height: 6)
.offset(
x: corner % 2 == 0 ? -min(geometry.size.width, geometry.size.height) * 0.4 : min(geometry.size.width, geometry.size.height) * 0.4,
y: corner < 2 ? -min(geometry.size.width, geometry.size.height) * 0.4 : min(geometry.size.width, geometry.size.height) * 0.4
)
}
}
}
}
}
// MARK: -
#Preview {
QRCodeStyleView(qrCodeContent: "https://www.example.com")
}