Add save file option for profile sharing

Add "Save File" and "Save Content JSON" options to profile share menu,
allowing users to save profiles directly to a chosen location using
fileExporter instead of the system share sheet.
This commit is contained in:
世界
2026-01-01 20:25:28 +08:00
parent 55e01a89b5
commit 313cb5d213
11 changed files with 1674 additions and 26 deletions
@@ -0,0 +1,193 @@
import Foundation
import QRCode
import SwiftUI
private extension CGColor {
static var labelColor: CGColor {
#if canImport(UIKit)
UIColor.label.cgColor
#elseif canImport(AppKit)
NSColor.labelColor.cgColor
#endif
}
}
@MainActor
public struct QRSDisplayView: View {
private let data: Data
@State private var encoder: LubyTransformEncoder?
@State private var currentBlock: EncodedBlock?
@State private var frameCount = 0
@State private var isPlaying = true
@State private var fps: Double = 10
@State private var sliceSize: Int = 500
@State private var timer: Timer?
public init(data: Data) {
self.data = data
}
public var body: some View {
VStack(spacing: 16) {
if let block = currentBlock {
QRCodeViewUI(
content: block.toBase64(),
errorCorrection: .low,
foregroundColor: .labelColor,
backgroundColor: CGColor(gray: 1.0, alpha: 0.0)
)
.aspectRatio(1, contentMode: .fit)
#if os(macOS)
.frame(minWidth: 280, minHeight: 280)
#else
.frame(maxWidth: 300, maxHeight: 300)
#endif
} else {
ProgressView()
.frame(width: 280, height: 280)
}
VStack(spacing: 4) {
Text("Frame: \(frameCount)")
.font(.caption)
if let encoder {
Text("Source blocks: \(encoder.k)")
.font(.caption)
Text("Data size: \(encoder.bytes) bytes")
.font(.caption)
}
}
.foregroundStyle(.secondary)
VStack(spacing: 12) {
HStack {
Button {
isPlaying.toggle()
} label: {
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
}
#if os(macOS)
.buttonStyle(.bordered)
#endif
Spacer()
Text(String(localized: "Ideal FPS"))
.font(.caption)
Slider(value: $fps, in: 1 ... 30, step: 1)
.frame(maxWidth: 120)
Text("\(Int(fps))")
.font(.caption)
.frame(width: 24, alignment: .trailing)
}
HStack {
Text(String(localized: "Slice Size"))
.font(.caption)
Picker("", selection: $sliceSize) {
Text("200").tag(200)
Text("500").tag(500)
Text("1000").tag(1000)
}
.pickerStyle(.segmented)
.frame(maxWidth: 200)
}
}
.padding(.horizontal)
}
.padding()
.onAppear {
setupEncoder()
startAnimation()
}
.onDisappear {
stopAnimation()
}
.onChange(of: fps) { _ in
if isPlaying {
restartTimer()
}
}
.onChange(of: isPlaying) { playing in
if playing {
startAnimation()
} else {
stopAnimation()
}
}
.onChange(of: sliceSize) { _ in
setupEncoder()
frameCount = 0
}
}
private func setupEncoder() {
encoder = LubyTransformEncoder(data: data, sliceSize: sliceSize, compress: true)
}
private func startAnimation() {
nextFrame()
restartTimer()
}
private func restartTimer() {
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: 1.0 / fps, repeats: true) { _ in
Task { @MainActor in
nextFrame()
}
}
}
private func stopAnimation() {
timer?.invalidate()
timer = nil
}
private func nextFrame() {
guard let encoder else { return }
currentBlock = encoder.fountain().next()
frameCount += 1
}
}
@MainActor
public struct QRSSheet: View {
@Environment(\.dismiss) private var dismiss
private let profileName: String
private let profileData: Data
public init(profileName: String, profileData: Data) {
self.profileName = profileName
self.profileData = profileData
}
public var body: some View {
#if os(macOS)
NavigationSheet(title: String(localized: "Share as QRS")) {
VStack {
QRSDisplayView(data: profileData)
Text("Ask the receiver to scan continuously until complete.")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.bottom)
}
}
.frame(minWidth: 400, minHeight: 520)
#elseif os(iOS) || os(tvOS)
NavigationSheet(title: String(localized: "Share as QRS"), size: .large) {
VStack {
QRSDisplayView(data: profileData)
Text("Ask the receiver to scan continuously until complete.")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.bottom)
}
}
#endif
}
}