Add Quick Look Preview and Thumbnail extensions for .casera files
- Add CaseraQLPreview extension to show custom preview with contractor details and import instructions when viewing .casera files - Add CaseraQLThumbnail extension to display teal icon in Messages and Files app instead of generic white box - Update UTExportedTypeDeclarations to conform to public.content for better system file type recognition 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
27
iosApp/CaseraQLPreview/Base.lproj/MainInterface.storyboard
Normal file
27
iosApp/CaseraQLPreview/Base.lproj/MainInterface.storyboard
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="22505" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="M4Y-Lb-cyx">
|
||||
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22504"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Preview View Controller-->
|
||||
<scene sceneID="cwh-vc-ff4">
|
||||
<objects>
|
||||
<viewController id="M4Y-Lb-cyx" customClass="PreviewViewController" customModule="CaseraQLPreview" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="S3S-Oj-5AN">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<viewLayoutGuide key="safeArea" id="bzV-dz-m25"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
</view>
|
||||
<extendedEdge key="edgesForExtendedLayout"/>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="vXp-U4-Rya" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="705" y="299"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
38
iosApp/CaseraQLPreview/Info.plist
Normal file
38
iosApp/CaseraQLPreview/Info.plist
Normal file
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Casera Preview</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>QLSupportedContentTypes</key>
|
||||
<array>
|
||||
<string>com.casera.contractor</string>
|
||||
</array>
|
||||
<key>QLSupportsSearchableItems</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>NSExtensionMainStoryboard</key>
|
||||
<string>MainInterface</string>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.quicklook.preview</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
57
iosApp/CaseraQLPreview/PreviewProvider.swift
Normal file
57
iosApp/CaseraQLPreview/PreviewProvider.swift
Normal file
@@ -0,0 +1,57 @@
|
||||
//
|
||||
// PreviewProvider.swift
|
||||
// CaseraQLPreview
|
||||
//
|
||||
// Created by Trey Tartt on 12/6/25.
|
||||
//
|
||||
|
||||
import QuickLook
|
||||
|
||||
class PreviewProvider: QLPreviewProvider, QLPreviewingController {
|
||||
|
||||
|
||||
/*
|
||||
Use a QLPreviewProvider to provide data-based previews.
|
||||
|
||||
To set up your extension as a data-based preview extension:
|
||||
|
||||
- Modify the extension's Info.plist by setting
|
||||
<key>QLIsDataBasedPreview</key>
|
||||
<true/>
|
||||
|
||||
- Add the supported content types to QLSupportedContentTypes array in the extension's Info.plist.
|
||||
|
||||
- Remove
|
||||
<key>NSExtensionMainStoryboard</key>
|
||||
<string>MainInterface</string>
|
||||
|
||||
and replace it by setting the NSExtensionPrincipalClass to this class, e.g.
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).PreviewProvider</string>
|
||||
|
||||
- Implement providePreview(for:)
|
||||
*/
|
||||
|
||||
func providePreview(for request: QLFilePreviewRequest) async throws -> QLPreviewReply {
|
||||
|
||||
//You can create a QLPreviewReply in several ways, depending on the format of the data you want to return.
|
||||
//To return Data of a supported content type:
|
||||
|
||||
let contentType = UTType.plainText // replace with your data type
|
||||
|
||||
let reply = QLPreviewReply.init(dataOfContentType: contentType, contentSize: CGSize.init(width: 800, height: 800)) { (replyToUpdate : QLPreviewReply) in
|
||||
|
||||
let data = Data("Hello world".utf8)
|
||||
|
||||
//setting the stringEncoding for text and html data is optional and defaults to String.Encoding.utf8
|
||||
replyToUpdate.stringEncoding = .utf8
|
||||
|
||||
//initialize your data here
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
return reply
|
||||
}
|
||||
|
||||
}
|
||||
264
iosApp/CaseraQLPreview/PreviewViewController.swift
Normal file
264
iosApp/CaseraQLPreview/PreviewViewController.swift
Normal file
@@ -0,0 +1,264 @@
|
||||
import UIKit
|
||||
import QuickLook
|
||||
|
||||
class PreviewViewController: UIViewController, QLPreviewingController {
|
||||
|
||||
// MARK: - UI Elements
|
||||
|
||||
private let containerView: UIView = {
|
||||
let view = UIView()
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
return view
|
||||
}()
|
||||
|
||||
private let iconImageView: UIImageView = {
|
||||
let imageView = UIImageView()
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.tintColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1) // App primary color
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
|
||||
imageView.image = UIImage(systemName: "person.crop.rectangle.stack", withConfiguration: config)
|
||||
return imageView
|
||||
}()
|
||||
|
||||
private let titleLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.font = .systemFont(ofSize: 24, weight: .bold)
|
||||
label.textColor = .label
|
||||
label.textAlignment = .center
|
||||
label.numberOfLines = 2
|
||||
return label
|
||||
}()
|
||||
|
||||
private let subtitleLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.font = .systemFont(ofSize: 15, weight: .medium)
|
||||
label.textColor = .secondaryLabel
|
||||
label.textAlignment = .center
|
||||
label.text = "Casera Contractor File"
|
||||
return label
|
||||
}()
|
||||
|
||||
private let dividerView: UIView = {
|
||||
let view = UIView()
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.backgroundColor = .separator
|
||||
return view
|
||||
}()
|
||||
|
||||
private let detailsStackView: UIStackView = {
|
||||
let stack = UIStackView()
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 12
|
||||
stack.alignment = .leading
|
||||
return stack
|
||||
}()
|
||||
|
||||
private let instructionCard: UIView = {
|
||||
let view = UIView()
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.backgroundColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 0.1)
|
||||
view.layer.cornerRadius = 12
|
||||
return view
|
||||
}()
|
||||
|
||||
private let instructionLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.font = .systemFont(ofSize: 15, weight: .medium)
|
||||
label.textColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
|
||||
label.textAlignment = .center
|
||||
label.numberOfLines = 0
|
||||
label.text = "Tap the share button below, then select \"Casera\" to import this contractor."
|
||||
return label
|
||||
}()
|
||||
|
||||
private let arrowImageView: UIImageView = {
|
||||
let imageView = UIImageView()
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.tintColor = .secondaryLabel
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 24, weight: .medium)
|
||||
imageView.image = UIImage(systemName: "arrow.down", withConfiguration: config)
|
||||
return imageView
|
||||
}()
|
||||
|
||||
// MARK: - Data
|
||||
|
||||
private var contractorData: ContractorPreviewData?
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
print("CaseraQLPreview: viewDidLoad called")
|
||||
setupUI()
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupUI() {
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
view.addSubview(containerView)
|
||||
containerView.addSubview(iconImageView)
|
||||
containerView.addSubview(titleLabel)
|
||||
containerView.addSubview(subtitleLabel)
|
||||
containerView.addSubview(dividerView)
|
||||
containerView.addSubview(detailsStackView)
|
||||
containerView.addSubview(instructionCard)
|
||||
instructionCard.addSubview(instructionLabel)
|
||||
containerView.addSubview(arrowImageView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
containerView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -40),
|
||||
containerView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 32),
|
||||
containerView.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -32),
|
||||
containerView.widthAnchor.constraint(lessThanOrEqualToConstant: 340),
|
||||
|
||||
iconImageView.topAnchor.constraint(equalTo: containerView.topAnchor),
|
||||
iconImageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
|
||||
iconImageView.widthAnchor.constraint(equalToConstant: 80),
|
||||
iconImageView.heightAnchor.constraint(equalToConstant: 80),
|
||||
|
||||
titleLabel.topAnchor.constraint(equalTo: iconImageView.bottomAnchor, constant: 16),
|
||||
titleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
|
||||
subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
|
||||
subtitleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
subtitleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
|
||||
dividerView.topAnchor.constraint(equalTo: subtitleLabel.bottomAnchor, constant: 20),
|
||||
dividerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
dividerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
dividerView.heightAnchor.constraint(equalToConstant: 1),
|
||||
|
||||
detailsStackView.topAnchor.constraint(equalTo: dividerView.bottomAnchor, constant: 20),
|
||||
detailsStackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
detailsStackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
|
||||
instructionCard.topAnchor.constraint(equalTo: detailsStackView.bottomAnchor, constant: 24),
|
||||
instructionCard.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
instructionCard.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
|
||||
instructionLabel.topAnchor.constraint(equalTo: instructionCard.topAnchor, constant: 16),
|
||||
instructionLabel.leadingAnchor.constraint(equalTo: instructionCard.leadingAnchor, constant: 16),
|
||||
instructionLabel.trailingAnchor.constraint(equalTo: instructionCard.trailingAnchor, constant: -16),
|
||||
instructionLabel.bottomAnchor.constraint(equalTo: instructionCard.bottomAnchor, constant: -16),
|
||||
|
||||
arrowImageView.topAnchor.constraint(equalTo: instructionCard.bottomAnchor, constant: 16),
|
||||
arrowImageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
|
||||
arrowImageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
private func addDetailRow(icon: String, text: String) {
|
||||
let rowStack = UIStackView()
|
||||
rowStack.axis = .horizontal
|
||||
rowStack.spacing = 12
|
||||
rowStack.alignment = .center
|
||||
|
||||
let iconView = UIImageView()
|
||||
iconView.translatesAutoresizingMaskIntoConstraints = false
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 16, weight: .medium)
|
||||
iconView.image = UIImage(systemName: icon, withConfiguration: config)
|
||||
iconView.tintColor = .secondaryLabel
|
||||
iconView.widthAnchor.constraint(equalToConstant: 24).isActive = true
|
||||
iconView.heightAnchor.constraint(equalToConstant: 24).isActive = true
|
||||
|
||||
let textLabel = UILabel()
|
||||
textLabel.font = .systemFont(ofSize: 15)
|
||||
textLabel.textColor = .label
|
||||
textLabel.text = text
|
||||
textLabel.numberOfLines = 1
|
||||
|
||||
rowStack.addArrangedSubview(iconView)
|
||||
rowStack.addArrangedSubview(textLabel)
|
||||
|
||||
detailsStackView.addArrangedSubview(rowStack)
|
||||
}
|
||||
|
||||
// MARK: - QLPreviewingController
|
||||
|
||||
func preparePreviewOfFile(at url: URL) async throws {
|
||||
print("CaseraQLPreview: preparePreviewOfFile called with URL: \(url)")
|
||||
// Parse the .casera file
|
||||
let data = try Data(contentsOf: url)
|
||||
let decoder = JSONDecoder()
|
||||
let contractor = try decoder.decode(ContractorPreviewData.self, from: data)
|
||||
self.contractorData = contractor
|
||||
print("CaseraQLPreview: Parsed contractor: \(contractor.name)")
|
||||
|
||||
await MainActor.run {
|
||||
self.updateUI(with: contractor)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateUI(with contractor: ContractorPreviewData) {
|
||||
titleLabel.text = contractor.name
|
||||
|
||||
// Clear existing details
|
||||
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
|
||||
// Add details
|
||||
if let company = contractor.company, !company.isEmpty {
|
||||
addDetailRow(icon: "building.2", text: company)
|
||||
}
|
||||
|
||||
if let phone = contractor.phone, !phone.isEmpty {
|
||||
addDetailRow(icon: "phone", text: phone)
|
||||
}
|
||||
|
||||
if let email = contractor.email, !email.isEmpty {
|
||||
addDetailRow(icon: "envelope", text: email)
|
||||
}
|
||||
|
||||
if !contractor.specialtyNames.isEmpty {
|
||||
let specialties = contractor.specialtyNames.joined(separator: ", ")
|
||||
addDetailRow(icon: "wrench.and.screwdriver", text: specialties)
|
||||
}
|
||||
|
||||
if let exportedBy = contractor.exportedBy, !exportedBy.isEmpty {
|
||||
addDetailRow(icon: "person", text: "Shared by \(exportedBy)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Model
|
||||
|
||||
struct ContractorPreviewData: Codable {
|
||||
let version: Int
|
||||
let name: String
|
||||
let company: String?
|
||||
let phone: String?
|
||||
let email: String?
|
||||
let website: String?
|
||||
let notes: String?
|
||||
let streetAddress: String?
|
||||
let city: String?
|
||||
let stateProvince: String?
|
||||
let postalCode: String?
|
||||
let specialtyNames: [String]
|
||||
let rating: Double?
|
||||
let isFavorite: Bool
|
||||
let exportedAt: String?
|
||||
let exportedBy: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case version, name, company, phone, email, website, notes
|
||||
case streetAddress = "street_address"
|
||||
case city
|
||||
case stateProvince = "state_province"
|
||||
case postalCode = "postal_code"
|
||||
case specialtyNames = "specialty_names"
|
||||
case rating
|
||||
case isFavorite = "is_favorite"
|
||||
case exportedAt = "exported_at"
|
||||
case exportedBy = "exported_by"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user