~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
159 lines · 5.2 KB Raw History
import Foundation

/// Format a Double aperture value as "f/N" with up to 2 fraction digits, dropping
/// trailing zeros. e.g., 2.0 → "f/2", 2.5 → "f/2.5", 2.83 → "f/2.83".
func formatAperture(_ value: Double) -> String {
    var str = String(format: "%.2f", value)
    while str.hasSuffix("0") {
        str.removeLast()
    }
    if str.hasSuffix(".") {
        str.removeLast()
    }
    return "f/" + str
}

/// Format shutter speed. Fast shutters (< 1s) use "1/Ns" with a whole-number
/// denominator. Slow shutters preserve up to one decimal place, dropping trailing
/// zeros. e.g., 0.002 → "1/500s", 0.5 → "1/2s", 1.0 → "1s", 1.5 → "1.5s", 2.0 → "2s".
func formatShutterSpeed(seconds value: Double) -> String {
    guard value > 0 else { return "0s" }
    if value < 1 {
        let denom = max(1, Int((1 / value).rounded()))
        return "1/\(denom)s"
    }
    // Slow shutter: one decimal, trim trailing zero
    var str = String(format: "%.1f", value)
    if str.hasSuffix("0") {
        str.removeLast()
    }
    if str.hasSuffix(".") {
        str.removeLast()
    }
    return str + "s"
}

/// Format focal length as "Nmm" with whole-number millimeters. e.g., 35.0 → "35mm",
/// 50.5 → "51mm".
func formatFocalLength(mm value: Double) -> String {
    "\(Int(value.rounded()))mm"
}

/// social.grain.photo.defs#photoView
struct GrainPhoto: Codable, Sendable, Identifiable {
    let uri: String
    let cid: String
    let thumb: String
    let fullsize: String
    var alt: String?
    let aspectRatio: AspectRatio
    var exif: GrainExif?
    var gallery: PhotoGalleryState?

    var id: String {
        uri
    }
}

/// social.grain.photo.defs#exifView
struct GrainExif: Codable, Sendable {
    let uri: String
    let cid: String
    let photo: String
    var record: AnyCodable?
    let createdAt: String
    var dateTimeOriginal: String?
    var exposureTime: String?
    var fNumber: String?
    var flash: String?
    var focalLengthIn35mmFormat: String?
    var iSO: Int?
    var lensMake: String?
    var lensModel: String?
    var make: String?
    var model: String?

    var cameraName: String? {
        let parts = [make, model].compactMap(\.self).filter { !$0.isEmpty }
        return parts.isEmpty ? nil : parts.joined(separator: " ")
    }

    var lensName: String? {
        if let lensModel, !lensModel.isEmpty {
            return lensModel
        }
        let parts = [lensMake, lensModel].compactMap(\.self).filter { !$0.isEmpty }
        return parts.isEmpty ? nil : parts.joined(separator: " ")
    }

    /// Re-formats the server-supplied fNumber string to drop trailing zeros.
    /// "f/2.0" → "f/2", "2.0" → "f/2", "f/2.83" → "f/2.83".
    var formattedFNumber: String? {
        guard let fNumber else { return nil }
        let cleaned = fNumber
            .replacingOccurrences(of: "f/", with: "")
            .trimmingCharacters(in: .whitespaces)
        guard let value = Double(cleaned) else { return fNumber }
        return formatAperture(value)
    }

    /// Re-formats the server-supplied exposure time string so the 1/N denominator
    /// is always a whole number. Handles "1/500", "1/500s", "1/500.0", and seconds
    /// strings like "0.002s" or "30s". Falls back to the original string for any
    /// input we can't parse (so "bulb", "1/0", etc. pass through unchanged).
    var formattedExposureTime: String? {
        guard let exposureTime else { return nil }
        var cleaned = exposureTime.trimmingCharacters(in: .whitespaces)
        if cleaned.hasSuffix("s") {
            cleaned.removeLast()
        }
        cleaned = cleaned.trimmingCharacters(in: .whitespaces)

        if cleaned.hasPrefix("1/") {
            let denomStr = String(cleaned.dropFirst(2))
            if let denom = Double(denomStr), denom > 0 {
                return formatShutterSpeed(seconds: 1.0 / denom)
            }
        }
        if let seconds = Double(cleaned) {
            return formatShutterSpeed(seconds: seconds)
        }
        return exposureTime
    }

    /// Re-formats the server-supplied focal length so the millimeters value is always
    /// a whole number. Handles "35", "35.0", "35mm", "35.0mm".
    var formattedFocalLength: String? {
        guard let focalLengthIn35mmFormat else { return nil }
        var cleaned = focalLengthIn35mmFormat.trimmingCharacters(in: .whitespaces)
        if cleaned.hasSuffix("mm") {
            cleaned.removeLast(2)
        }
        cleaned = cleaned.trimmingCharacters(in: .whitespaces)
        if let value = Double(cleaned) {
            return formatFocalLength(mm: value)
        }
        return focalLengthIn35mmFormat
    }

    var settingsLine: String? {
        let parts = [
            formattedFocalLength,
            formattedFNumber,
            formattedExposureTime,
            iSO.map { "ISO \($0)" },
        ].compactMap(\.self).filter { !$0.isEmpty }
        return parts.isEmpty ? nil : parts.joined(separator: "  ·  ")
    }

    var hasDisplayableData: Bool {
        cameraName != nil || lensName != nil || settingsLine != nil
    }
}

/// social.grain.photo.defs#galleryState
struct PhotoGalleryState: Codable, Sendable {
    let item: String
    let itemCreatedAt: String
    let itemPosition: Int
}