iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

Round shutter speed denominator and focal length to whole numbers

GrainExif gains formattedExposureTime and formattedFocalLength computed
properties that re-parse the server-supplied strings (which can include
trailing decimals like "1/500.0" or "35.0mm") and route them through
formatShutterSpeed(seconds:) and formatFocalLength(mm:) helpers.

Fast shutters (< 1s) always show as "1/Ns" with a whole-number
denominator. Slow shutters preserve one decimal of precision so 1.5s
exposures don't round to 1s. Focal lengths always show as whole "Nmm".

settingsLine now uses the formatted versions for focal length, fNumber,
and exposure time.

Comprehensive XCTest coverage in PhotoModelsTests including the parser
fallback paths (garbage strings, divide-by-zero, suffix preservation).

+252 -3
+59 -2
Grain/Models/Views/PhotoModels.swift
··· 11 11 return "f/" + str 12 12 } 13 13 14 + /// Format shutter speed. Fast shutters (< 1s) use "1/Ns" with a whole-number 15 + /// denominator. Slow shutters preserve up to one decimal place, dropping trailing 16 + /// zeros. e.g., 0.002 → "1/500s", 0.5 → "1/2s", 1.0 → "1s", 1.5 → "1.5s", 2.0 → "2s". 17 + func formatShutterSpeed(seconds value: Double) -> String { 18 + guard value > 0 else { return "0s" } 19 + if value < 1 { 20 + let denom = max(1, Int((1 / value).rounded())) 21 + return "1/\(denom)s" 22 + } 23 + // Slow shutter: one decimal, trim trailing zero 24 + var str = String(format: "%.1f", value) 25 + if str.hasSuffix("0") { str.removeLast() } 26 + if str.hasSuffix(".") { str.removeLast() } 27 + return str + "s" 28 + } 29 + 30 + /// Format focal length as "Nmm" with whole-number millimeters. e.g., 35.0 → "35mm", 31 + /// 50.5 → "51mm". 32 + func formatFocalLength(mm value: Double) -> String { 33 + "\(Int(value.rounded()))mm" 34 + } 35 + 14 36 /// social.grain.photo.defs#photoView 15 37 struct GrainPhoto: Codable, Sendable, Identifiable { 16 38 let uri: String ··· 67 89 return formatAperture(value) 68 90 } 69 91 92 + /// Re-formats the server-supplied exposure time string so the 1/N denominator 93 + /// is always a whole number. Handles "1/500", "1/500s", "1/500.0", and seconds 94 + /// strings like "0.002s" or "30s". Falls back to the original string for any 95 + /// input we can't parse (so "bulb", "1/0", etc. pass through unchanged). 96 + var formattedExposureTime: String? { 97 + guard let exposureTime else { return nil } 98 + var cleaned = exposureTime.trimmingCharacters(in: .whitespaces) 99 + if cleaned.hasSuffix("s") { cleaned.removeLast() } 100 + cleaned = cleaned.trimmingCharacters(in: .whitespaces) 101 + 102 + if cleaned.hasPrefix("1/") { 103 + let denomStr = String(cleaned.dropFirst(2)) 104 + if let denom = Double(denomStr), denom > 0 { 105 + return formatShutterSpeed(seconds: 1.0 / denom) 106 + } 107 + } 108 + if let seconds = Double(cleaned) { 109 + return formatShutterSpeed(seconds: seconds) 110 + } 111 + return exposureTime 112 + } 113 + 114 + /// Re-formats the server-supplied focal length so the millimeters value is always 115 + /// a whole number. Handles "35", "35.0", "35mm", "35.0mm". 116 + var formattedFocalLength: String? { 117 + guard let focalLengthIn35mmFormat else { return nil } 118 + var cleaned = focalLengthIn35mmFormat.trimmingCharacters(in: .whitespaces) 119 + if cleaned.hasSuffix("mm") { cleaned.removeLast(2) } 120 + cleaned = cleaned.trimmingCharacters(in: .whitespaces) 121 + if let value = Double(cleaned) { 122 + return formatFocalLength(mm: value) 123 + } 124 + return focalLengthIn35mmFormat 125 + } 126 + 70 127 var settingsLine: String? { 71 128 let parts = [ 72 - focalLengthIn35mmFormat, 129 + formattedFocalLength, 73 130 formattedFNumber, 74 - exposureTime, 131 + formattedExposureTime, 75 132 iSO.map { "ISO \($0)" }, 76 133 ].compactMap(\.self).filter { !$0.isEmpty } 77 134 return parts.isEmpty ? nil : parts.joined(separator: " · ")
+193 -1
GrainTests/PhotoModelsTests.swift
··· 78 78 // MARK: - settingsLine 79 79 80 80 func testSettingsLineAllPresent() { 81 + // settingsLine now routes focalLength + fNumber + exposureTime through their 82 + // formatter helpers, which always emit "Nmm", "f/N", and "1/Ns" forms. 81 83 let exif = makeExif(focalLength: "35mm", fNumber: "f/1.4", exposureTime: "1/250", iso: 400) 82 - XCTAssertEqual(exif.settingsLine, "35mm · f/1.4 · 1/250 · ISO 400") 84 + XCTAssertEqual(exif.settingsLine, "35mm · f/1.4 · 1/250s · ISO 400") 83 85 } 84 86 85 87 func testSettingsLinePartial() { ··· 102 104 func testHasDisplayableDataFalse() { 103 105 let exif = makeExif() 104 106 XCTAssertFalse(exif.hasDisplayableData) 107 + } 108 + 109 + // MARK: - formatAperture 110 + 111 + func testFormatApertureWholeNumberDropsZero() { 112 + XCTAssertEqual(formatAperture(2.0), "f/2") 113 + } 114 + 115 + func testFormatApertureOneDecimal() { 116 + XCTAssertEqual(formatAperture(2.5), "f/2.5") 117 + } 118 + 119 + func testFormatApertureTwoDecimals() { 120 + XCTAssertEqual(formatAperture(2.83), "f/2.83") 121 + } 122 + 123 + func testFormatApertureRoundsToTwoDecimals() { 124 + XCTAssertEqual(formatAperture(2.834), "f/2.83") 125 + XCTAssertEqual(formatAperture(2.836), "f/2.84") 126 + } 127 + 128 + func testFormatApertureSmallValue() { 129 + XCTAssertEqual(formatAperture(1.4), "f/1.4") 130 + } 131 + 132 + // MARK: - formatShutterSpeed 133 + 134 + func testFormatShutterSpeedFastFraction() { 135 + XCTAssertEqual(formatShutterSpeed(seconds: 0.002), "1/500s") 136 + XCTAssertEqual(formatShutterSpeed(seconds: 0.001), "1/1000s") 137 + } 138 + 139 + func testFormatShutterSpeedHalfSecond() { 140 + XCTAssertEqual(formatShutterSpeed(seconds: 0.5), "1/2s") 141 + } 142 + 143 + func testFormatShutterSpeedRoundsDenominator() { 144 + // 1/249.something should round to 1/250 145 + XCTAssertEqual(formatShutterSpeed(seconds: 1.0 / 249.7), "1/250s") 146 + } 147 + 148 + func testFormatShutterSpeedSlowWholeSecond() { 149 + XCTAssertEqual(formatShutterSpeed(seconds: 1.0), "1s") 150 + XCTAssertEqual(formatShutterSpeed(seconds: 2.0), "2s") 151 + XCTAssertEqual(formatShutterSpeed(seconds: 30.0), "30s") 152 + } 153 + 154 + func testFormatShutterSpeedSlowPreservesHalfSecond() { 155 + // Slow shutters keep one decimal of precision so 1.5s, 2.5s, etc. survive. 156 + XCTAssertEqual(formatShutterSpeed(seconds: 1.5), "1.5s") 157 + XCTAssertEqual(formatShutterSpeed(seconds: 2.5), "2.5s") 158 + } 159 + 160 + func testFormatShutterSpeedZeroOrNegative() { 161 + XCTAssertEqual(formatShutterSpeed(seconds: 0), "0s") 162 + XCTAssertEqual(formatShutterSpeed(seconds: -1), "0s") 163 + } 164 + 165 + // MARK: - formatFocalLength 166 + 167 + func testFormatFocalLengthWholeNumber() { 168 + XCTAssertEqual(formatFocalLength(mm: 35.0), "35mm") 169 + } 170 + 171 + func testFormatFocalLengthRoundsUp() { 172 + XCTAssertEqual(formatFocalLength(mm: 50.5), "51mm") 173 + } 174 + 175 + func testFormatFocalLengthRoundsDown() { 176 + XCTAssertEqual(formatFocalLength(mm: 24.4), "24mm") 177 + } 178 + 179 + // MARK: - formattedFNumber 180 + 181 + func testFormattedFNumberStripsPrefix() { 182 + let exif = makeExif(fNumber: "f/2.0") 183 + XCTAssertEqual(exif.formattedFNumber, "f/2") 184 + } 185 + 186 + func testFormattedFNumberWithoutPrefix() { 187 + let exif = makeExif(fNumber: "2.5") 188 + XCTAssertEqual(exif.formattedFNumber, "f/2.5") 189 + } 190 + 191 + func testFormattedFNumberWithDecimals() { 192 + let exif = makeExif(fNumber: "f/2.83") 193 + XCTAssertEqual(exif.formattedFNumber, "f/2.83") 194 + } 195 + 196 + func testFormattedFNumberNilWhenMissing() { 197 + let exif = makeExif() 198 + XCTAssertNil(exif.formattedFNumber) 199 + } 200 + 201 + func testFormattedFNumberFallsBackOnGarbage() { 202 + let exif = makeExif(fNumber: "bulb") 203 + XCTAssertEqual(exif.formattedFNumber, "bulb") 204 + } 205 + 206 + // MARK: - formattedExposureTime 207 + 208 + func testFormattedExposureTimeWithDecimalDenominator() { 209 + let exif = makeExif(exposureTime: "1/500.0") 210 + XCTAssertEqual(exif.formattedExposureTime, "1/500s") 211 + } 212 + 213 + func testFormattedExposureTimeWithSuffix() { 214 + let exif = makeExif(exposureTime: "1/250s") 215 + XCTAssertEqual(exif.formattedExposureTime, "1/250s") 216 + } 217 + 218 + func testFormattedExposureTimeAsDecimalSeconds() { 219 + let exif = makeExif(exposureTime: "0.002") 220 + XCTAssertEqual(exif.formattedExposureTime, "1/500s") 221 + } 222 + 223 + func testFormattedExposureTimeWholeSecond() { 224 + let exif = makeExif(exposureTime: "2.0s") 225 + XCTAssertEqual(exif.formattedExposureTime, "2s") 226 + } 227 + 228 + func testFormattedExposureTimeNilWhenMissing() { 229 + let exif = makeExif() 230 + XCTAssertNil(exif.formattedExposureTime) 231 + } 232 + 233 + func testFormattedExposureTimeRejectsZeroDenominator() { 234 + let exif = makeExif(exposureTime: "1/0") 235 + // 1/0 is invalid; we should pass through the original string rather than 236 + // produce a divide-by-zero result. 237 + XCTAssertEqual(exif.formattedExposureTime, "1/0") 238 + } 239 + 240 + func testFormattedExposureTimeFallsBackOnGarbage() { 241 + let exif = makeExif(exposureTime: "bulb") 242 + XCTAssertEqual(exif.formattedExposureTime, "bulb") 243 + } 244 + 245 + func testFormattedExposureTimePreservesSecAfterTrim() { 246 + // The string "1/500 sec" should NOT get mangled into "1/500 ec" — only the 247 + // single trailing "s" is stripped. 248 + let exif = makeExif(exposureTime: "1/500 sec") 249 + // With the safe trimming, "sec" doesn't end in just "s" cleanly — it does 250 + // end in "c", so suffix-trim of "s" doesn't apply. The string falls through 251 + // to the parser which can't parse "1/500 sec", so we return the original. 252 + XCTAssertEqual(exif.formattedExposureTime, "1/500 sec") 253 + } 254 + 255 + // MARK: - formattedFocalLength 256 + 257 + func testFormattedFocalLengthStripsSuffix() { 258 + let exif = makeExif(focalLength: "35.0mm") 259 + XCTAssertEqual(exif.formattedFocalLength, "35mm") 260 + } 261 + 262 + func testFormattedFocalLengthWithoutSuffix() { 263 + let exif = makeExif(focalLength: "50.0") 264 + XCTAssertEqual(exif.formattedFocalLength, "50mm") 265 + } 266 + 267 + func testFormattedFocalLengthRounds() { 268 + let exif = makeExif(focalLength: "23.6mm") 269 + XCTAssertEqual(exif.formattedFocalLength, "24mm") 270 + } 271 + 272 + func testFormattedFocalLengthNilWhenMissing() { 273 + let exif = makeExif() 274 + XCTAssertNil(exif.formattedFocalLength) 275 + } 276 + 277 + // MARK: - settingsLine uses formatted versions 278 + 279 + func testSettingsLineUsesFormattedFocalLength() { 280 + let exif = makeExif(focalLength: "35.0mm", iso: 100) 281 + XCTAssertEqual(exif.settingsLine, "35mm · ISO 100") 282 + } 283 + 284 + func testSettingsLineUsesFormattedExposureTime() { 285 + let exif = makeExif(exposureTime: "1/500.0", iso: 100) 286 + XCTAssertEqual(exif.settingsLine, "1/500s · ISO 100") 287 + } 288 + 289 + func testSettingsLineFullyFormatted() { 290 + let exif = makeExif( 291 + focalLength: "35.0mm", 292 + fNumber: "f/2.0", 293 + exposureTime: "1/500.0", 294 + iso: 400 295 + ) 296 + XCTAssertEqual(exif.settingsLine, "35mm · f/2 · 1/500s · ISO 400") 105 297 } 106 298 }