ironOS native ios app
2
fork

Configure Feed

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

feat: add ipad layout

+310 -124
+2 -2
ios/Tinkcil.xcodeproj/project.pbxproj
··· 253 253 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 254 254 CODE_SIGN_IDENTITY = "Apple Development"; 255 255 CODE_SIGN_STYLE = Automatic; 256 - CURRENT_PROJECT_VERSION = 7; 256 + CURRENT_PROJECT_VERSION = 8; 257 257 DEVELOPMENT_TEAM = M67B42LX8D; 258 258 ENABLE_PREVIEWS = YES; 259 259 GENERATE_INFOPLIST_FILE = YES; ··· 292 292 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 293 293 CODE_SIGN_IDENTITY = "Apple Development"; 294 294 CODE_SIGN_STYLE = Automatic; 295 - CURRENT_PROJECT_VERSION = 7; 295 + CURRENT_PROJECT_VERSION = 8; 296 296 DEVELOPMENT_TEAM = M67B42LX8D; 297 297 ENABLE_PREVIEWS = YES; 298 298 GENERATE_INFOPLIST_FILE = YES;
+1 -1
ios/Tinkcil/BLEManager.swift
··· 23 23 var isDemoMode = false 24 24 25 25 // Temperature history for graph (circular buffer) 26 - var temperatureHistory = CircularBuffer<TemperaturePoint>(capacity: 60) 26 + var temperatureHistory = CircularBuffer<TemperaturePoint>(capacity: 150) 27 27 var temperatureHistoryArray: [TemperaturePoint] { temperatureHistory.elements } 28 28 29 29 // Settings cache
+202 -70
ios/Tinkcil/ContentView.swift
··· 6 6 import SwiftUI 7 7 8 8 struct ContentView: View { 9 + @Environment(\.horizontalSizeClass) private var horizontalSizeClass 9 10 @State private var bleManager = BLEManager() 10 11 @State private var targetTemp: Double = 300 11 12 @State private var isEditingSlider = false ··· 13 14 @State private var lastSendTime: Date = .distantPast 14 15 @State private var isTopBarExpanded = false 15 16 @State private var showingSettings = false 17 + @State private var showingSettingsPanel = false 16 18 @State private var showingError = false 17 19 @State private var lastConnectionState: BLEManager.ConnectionState = .disconnected 18 20 @State private var lastMode: OperatingMode? 19 21 22 + private var isRegularWidth: Bool { 23 + horizontalSizeClass == .regular 24 + } 25 + 20 26 private var isHeating: Bool { 21 27 bleManager.liveData.mode?.isActive == true 22 28 } 23 29 24 30 var body: some View { 25 - ZStack { 26 - // Background graph 27 - if bleManager.temperatureHistory.count > 0 { 28 - TemperatureGraph( 29 - history: bleManager.temperatureHistoryArray, 30 - currentSetpoint: Int(targetTemp) 31 - ) 32 - .padding(.horizontal, 20) 33 - .padding(.vertical, 120) 34 - .accessibilityLabel("Temperature history graph") 35 - .accessibilityHint("Visual representation of temperature over time") 31 + GeometryReader { geo in 32 + Group { 33 + if isRegularWidth && geo.size.width > geo.size.height { 34 + iPadBody 35 + } else { 36 + iPhoneBody 37 + } 36 38 } 37 - 38 - // Main content 39 - if bleManager.connectionState.isConnected { 40 - connectedView 41 - } else { 42 - scanningView 43 - } 39 + .frame(maxWidth: .infinity, maxHeight: .infinity) 44 40 } 45 41 .background(Color(.systemBackground)) 46 42 .dynamicTypeSize(...DynamicTypeSize.xxxLarge) ··· 60 56 } 61 57 .onChange(of: bleManager.lastError) { _, error in 62 58 if error != nil { 63 - hapticError() 59 + Haptics.error() 64 60 showingError = true 65 61 } 66 62 } ··· 73 69 } 74 70 } 75 71 76 - // MARK: - Connected View 72 + // MARK: - iPhone Body 73 + 74 + private var iPhoneBody: some View { 75 + ZStack { 76 + if bleManager.temperatureHistory.count > 0 { 77 + TemperatureGraph( 78 + history: bleManager.temperatureHistoryArray, 79 + currentSetpoint: Int(targetTemp) 80 + ) 81 + .padding(.horizontal, 20) 82 + .padding(.vertical, 120) 83 + .accessibilityLabel("Temperature history graph") 84 + .accessibilityHint("Visual representation of temperature over time") 85 + } 86 + 87 + if bleManager.connectionState.isConnected { 88 + connectedView 89 + } else { 90 + scanningView 91 + } 92 + } 93 + } 94 + 95 + // MARK: - iPad Body 96 + 97 + private var iPadBody: some View { 98 + ZStack { 99 + if bleManager.connectionState.isConnected { 100 + connectedViewRegular 101 + } else { 102 + scanningView 103 + } 104 + } 105 + } 106 + 107 + // MARK: - Connected View (iPhone) 77 108 78 109 private var connectedView: some View { 79 110 VStack(spacing: 0) { ··· 115 146 VStack(spacing: 0) { 116 147 // Main top bar (always visible) 117 148 Button { 118 - hapticLight() 149 + Haptics.light() 119 150 withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { 120 151 isTopBarExpanded.toggle() 121 152 } ··· 177 208 Divider() 178 209 .padding(.horizontal, 20) 179 210 180 - VStack(spacing: 8) { 181 - HStack { 182 - detailItem(label: String(localized: "detail_handle"), value: String(format: "%.1f°C", bleManager.liveData.handleTempC), alignment: .leading) 183 - Spacer() 184 - detailItem(label: String(localized: "detail_tip_resistance"), value: String(format: "%.2f Ω", bleManager.liveData.resistance), alignment: .trailing) 185 - } 186 - 187 - HStack { 188 - detailItem(label: String(localized: "detail_mode"), value: bleManager.liveData.mode?.displayName ?? String(localized: "common_unknown"), alignment: .leading) 189 - Spacer() 190 - detailItem(label: String(localized: "detail_power"), value: bleManager.liveData.power?.displayName ?? String(localized: "common_unknown"), alignment: .trailing) 191 - } 192 - 193 - if !bleManager.firmwareVersion.isEmpty { 194 - HStack { 195 - detailItem(label: String(localized: "detail_firmware"), value: bleManager.firmwareVersion, alignment: .leading) 196 - Spacer() 197 - } 198 - } 199 - } 200 - .padding(.horizontal, 20) 201 - .padding(.bottom, 8) 211 + deviceDetailStats 212 + .padding(.horizontal, 20) 213 + .padding(.bottom, 8) 202 214 203 215 // Settings button 204 216 Button { 205 - hapticLight() 217 + Haptics.light() 206 218 showingSettings = true 207 219 } label: { 208 220 HStack { ··· 276 288 onEditingChanged: { editing in 277 289 isEditingSlider = editing 278 290 if editing { 279 - hapticSelection() 291 + Haptics.selection() 280 292 bleManager.setSlowPolling() 281 293 } else { 282 294 // Only send if value changed 283 295 if abs(targetTemp - lastSentTemp) >= 5 { 284 - hapticLight() 296 + Haptics.light() 285 297 bleManager.setTemperature(UInt32(targetTemp)) 286 298 lastSentTemp = targetTemp 287 299 } ··· 298 310 .padding(.vertical, 14) 299 311 .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) 300 312 .accessibilityElement(children: .contain) 313 + } 314 + 315 + // MARK: - Device Detail Stats 316 + 317 + private var deviceDetailStats: some View { 318 + VStack(spacing: 8) { 319 + HStack { 320 + detailItem(label: String(localized: "detail_handle"), value: String(format: "%.1f°C", bleManager.liveData.handleTempC), alignment: .leading) 321 + Spacer() 322 + detailItem(label: String(localized: "detail_tip_resistance"), value: String(format: "%.2f Ω", bleManager.liveData.resistance), alignment: .trailing) 323 + } 324 + 325 + HStack { 326 + detailItem(label: String(localized: "detail_mode"), value: bleManager.liveData.mode?.displayName ?? String(localized: "common_unknown"), alignment: .leading) 327 + Spacer() 328 + detailItem(label: String(localized: "detail_power"), value: bleManager.liveData.power?.displayName ?? String(localized: "common_unknown"), alignment: .trailing) 329 + } 330 + 331 + if !bleManager.firmwareVersion.isEmpty { 332 + HStack { 333 + detailItem(label: String(localized: "detail_firmware"), value: bleManager.firmwareVersion, alignment: .leading) 334 + Spacer() 335 + } 336 + } 337 + } 301 338 } 302 339 303 340 // MARK: - Helpers ··· 384 421 .accessibilityAddTraits(.isHeader) 385 422 386 423 Button(String(localized: "connection_scan_again")) { 387 - hapticLight() 424 + Haptics.light() 388 425 bleManager.startScanning() 389 426 } 390 427 .buttonStyle(.borderedProminent) ··· 392 429 .accessibilityHint("Searches for nearby soldering iron") 393 430 394 431 Button("Try Demo") { 395 - hapticLight() 432 + Haptics.light() 396 433 bleManager.startDemoMode() 397 434 } 398 435 .font(.subheadline) ··· 408 445 } 409 446 } 410 447 411 - // MARK: - Haptic Feedback 448 + // MARK: - iPad Connected View 412 449 413 - private func hapticLight() { 414 - let generator = UIImpactFeedbackGenerator(style: .light) 415 - generator.impactOccurred() 416 - } 450 + private var connectedViewRegular: some View { 451 + HStack(spacing: 0) { 452 + // Left: large graph 453 + graphPanel 454 + .frame(maxWidth: .infinity) 455 + 456 + Color(.separator) 457 + .frame(width: 2) 458 + .ignoresSafeArea(.all) 417 459 418 - private func hapticSelection() { 419 - let generator = UISelectionFeedbackGenerator() 420 - generator.selectionChanged() 460 + // Right: controls 461 + controlPanel 462 + .frame(width: 420) 463 + .background(.ultraThinMaterial) 464 + 465 + // Settings panel (slides in from right) 466 + if showingSettingsPanel { 467 + Color(.separator) 468 + .frame(width: 2) 469 + .ignoresSafeArea(.all) 470 + 471 + SettingsPanelView(bleManager: bleManager) { 472 + withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { 473 + showingSettingsPanel = false 474 + } 475 + } 476 + .transition(.move(edge: .trailing)) 477 + } 478 + } 421 479 } 422 480 423 - private func hapticSuccess() { 424 - let generator = UINotificationFeedbackGenerator() 425 - generator.notificationOccurred(.success) 481 + private var graphPanel: some View { 482 + Group { 483 + if bleManager.temperatureHistory.count > 0 { 484 + TemperatureGraph( 485 + history: bleManager.temperatureHistoryArray, 486 + currentSetpoint: Int(targetTemp), 487 + windowSeconds: 15, 488 + showAxes: true, 489 + tempLineWidth: 3, 490 + setpointLineWidth: 2 491 + ) 492 + .padding(20) 493 + } else { 494 + ContentUnavailableView { 495 + Label("No Data", systemImage: "chart.xyaxis.line") 496 + } description: { 497 + Text("Temperature data will appear here") 498 + } 499 + } 500 + } 426 501 } 427 502 428 - private func hapticWarning() { 429 - let generator = UINotificationFeedbackGenerator() 430 - generator.notificationOccurred(.warning) 503 + private var iPadDeviceHeader: some View { 504 + HStack(spacing: 16) { 505 + Text(bleManager.deviceName) 506 + .font(.headline) 507 + .lineLimit(1) 508 + .truncationMode(.tail) 509 + 510 + Spacer(minLength: 8) 511 + 512 + HStack(spacing: 12) { 513 + statItem(value: String(format: "%.1f", bleManager.liveData.watts), unit: "W") 514 + statItem(value: String(format: "%.1f", bleManager.liveData.voltage), unit: "V") 515 + statItem(value: "\(bleManager.liveData.powerPercent)", unit: "%") 516 + } 517 + 518 + if let mode = bleManager.liveData.mode { 519 + Image(systemName: mode.icon) 520 + .font(.caption) 521 + .foregroundStyle(mode.isActive ? .orange : .secondary) 522 + } 523 + 524 + Button { 525 + Haptics.light() 526 + withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { 527 + showingSettingsPanel.toggle() 528 + } 529 + } label: { 530 + Image(systemName: "gear") 531 + .font(.body) 532 + .foregroundStyle(showingSettingsPanel ? Color.accentColor : Color.secondary) 533 + } 534 + .buttonStyle(.plain) 535 + .accessibilityLabel("Settings") 536 + } 537 + .padding(.horizontal, 20) 538 + .padding(.vertical, 12) 431 539 } 432 540 433 - private func hapticError() { 434 - let generator = UINotificationFeedbackGenerator() 435 - generator.notificationOccurred(.error) 541 + private var controlPanel: some View { 542 + VStack(spacing: 0) { 543 + iPadDeviceHeader 544 + 545 + ScrollView { 546 + VStack(spacing: 16) { 547 + deviceDetailStats 548 + .padding(.top, 12) 549 + 550 + temperatureDisplay 551 + 552 + if isHeating { 553 + HStack(spacing: 4) { 554 + Image(systemName: "arrow.right") 555 + .font(.caption) 556 + Text("\(bleManager.liveData.setpoint)°") 557 + .font(.title3.monospacedDigit()) 558 + } 559 + .foregroundStyle(.secondary) 560 + } 561 + 562 + sliderPanel 563 + } 564 + .padding(.horizontal, 20) 565 + .padding(.bottom, 20) 566 + } 567 + } 436 568 } 437 569 438 570 private func handleConnectionStateChange(from oldState: BLEManager.ConnectionState, to newState: BLEManager.ConnectionState) { 439 571 switch newState { 440 572 case .connected: 441 - hapticSuccess() 573 + Haptics.success() 442 574 case .disconnected: 443 575 if oldState.isConnected { 444 - hapticWarning() 576 + Haptics.warning() 445 577 } 446 578 case .scanning: 447 - hapticLight() 579 + Haptics.light() 448 580 default: 449 581 break 450 582 } ··· 456 588 457 589 // Haptic for entering/exiting active heating modes 458 590 if old.isActive != new.isActive { 459 - hapticLight() 591 + Haptics.light() 460 592 } 461 593 } 462 594 ··· 470 602 471 603 // Trigger success haptic when we reach target for the first time 472 604 if wasBelow && isNear { 473 - hapticSuccess() 605 + Haptics.success() 474 606 } 475 607 } 476 608 }
+28
ios/Tinkcil/Haptics.swift
··· 1 + // 2 + // Haptics.swift 3 + // Tinkcil 4 + // 5 + 6 + import UIKit 7 + 8 + enum Haptics { 9 + static func light() { 10 + UIImpactFeedbackGenerator(style: .light).impactOccurred() 11 + } 12 + 13 + static func selection() { 14 + UISelectionFeedbackGenerator().selectionChanged() 15 + } 16 + 17 + static func success() { 18 + UINotificationFeedbackGenerator().notificationOccurred(.success) 19 + } 20 + 21 + static func warning() { 22 + UINotificationFeedbackGenerator().notificationOccurred(.warning) 23 + } 24 + 25 + static func error() { 26 + UINotificationFeedbackGenerator().notificationOccurred(.error) 27 + } 28 + }
+12 -1
ios/Tinkcil/Localizable.xcstrings
··· 782 782 } 783 783 } 784 784 }, 785 + "Close settings" : { 786 + "comment" : "An accessibility label for the button that closes the settings panel.", 787 + "isCommentAutoGenerated" : true 788 + }, 785 789 "Closes the Bluetooth connection to your soldering iron" : { 786 790 "comment" : "An accessibility hint for the \"Disconnect\" button in the Diagnostics view.", 787 791 "isCommentAutoGenerated" : true, ··· 3444 3448 } 3445 3449 } 3446 3450 }, 3451 + "No Data" : { 3452 + "comment" : "A label describing a view that shows when there is no data to display.", 3453 + "isCommentAutoGenerated" : true 3454 + }, 3447 3455 "Off" : { 3448 3456 "comment" : "A value that indicates whether a toggle is on or off.", 3449 3457 "isCommentAutoGenerated" : true, ··· 6010 6018 }, 6011 6019 "Settings" : { 6012 6020 "comment" : "A tab label for the settings view.", 6013 - "extractionState" : "stale", 6014 6021 "isCommentAutoGenerated" : true, 6015 6022 "localizations" : { 6016 6023 "de" : { ··· 6452 6459 } 6453 6460 } 6454 6461 } 6462 + }, 6463 + "Temperature data will appear here" : { 6464 + "comment" : "A description text that appears when there is no temperature data available.", 6465 + "isCommentAutoGenerated" : true 6455 6466 }, 6456 6467 "Temperature history graph" : { 6457 6468 "comment" : "A label describing the temperature history graph.",
+57 -45
ios/Tinkcil/SettingsView.swift
··· 31 31 .toolbar { 32 32 ToolbarItem(placement: .topBarTrailing) { 33 33 Button(String(localized: "button_done")) { 34 - hapticLight() 34 + Haptics.light() 35 35 dismiss() 36 36 } 37 37 } 38 38 } 39 39 } 40 - } 41 - 42 - private func hapticLight() { 43 - let generator = UIImpactFeedbackGenerator(style: .light) 44 - generator.impactOccurred() 45 40 } 46 41 } 47 42 ··· 308 303 309 304 private func saveSettings() { 310 305 saveInProgress = true 311 - hapticLight() 306 + Haptics.light() 312 307 bleManager.saveSettings() 313 308 314 309 // Wait for a reasonable time for the write operation 315 310 Task { 316 311 try? await Task.sleep(for: .milliseconds(500)) 317 312 await MainActor.run { 318 - hapticSuccess() 313 + Haptics.success() 319 314 saveInProgress = false 320 315 } 321 316 } 322 317 } 323 318 324 - private func hapticLight() { 325 - let generator = UIImpactFeedbackGenerator(style: .light) 326 - generator.impactOccurred() 327 - } 328 - 329 - private func hapticSuccess() { 330 - let generator = UINotificationFeedbackGenerator() 331 - generator.notificationOccurred(.success) 332 - } 333 - 334 319 private func loadSettings() async { 335 320 // Load commonly used settings (will use cache if available) 336 321 let settingsToLoad: [UInt16] = [0, 1, 2, 6, 7, 11, 13, 14, 17, 22, 24, 25, 26, 27, 28, 33, 34] ··· 409 394 410 395 Section { 411 396 Button(role: .destructive) { 412 - hapticWarning() 397 + Haptics.warning() 413 398 bleManager.disconnect() 414 399 } label: { 415 400 HStack { ··· 453 438 } 454 439 } 455 440 456 - private func hapticWarning() { 457 - let generator = UINotificationFeedbackGenerator() 458 - generator.notificationOccurred(.warning) 459 - } 460 441 } 461 442 462 443 // MARK: - Setting Row Components ··· 489 470 step: Double(step), 490 471 onEditingChanged: { editing in 491 472 if editing { 492 - hapticSelection() 473 + Haptics.selection() 493 474 } else { 494 - hapticLight() 475 + Haptics.light() 495 476 onChange(value) 496 477 } 497 478 } ··· 502 483 } 503 484 .accessibilityElement(children: .contain) 504 485 } 505 - 506 - private func hapticLight() { 507 - let generator = UIImpactFeedbackGenerator(style: .light) 508 - generator.impactOccurred() 509 - } 510 - 511 - private func hapticSelection() { 512 - let generator = UISelectionFeedbackGenerator() 513 - generator.selectionChanged() 514 - } 515 486 } 516 487 517 488 struct ToggleSettingRow: View { ··· 523 494 Toggle(label, isOn: Binding( 524 495 get: { value }, 525 496 set: { newValue in 526 - hapticLight() 497 + Haptics.light() 527 498 value = newValue 528 499 onChange(newValue) 529 500 } ··· 532 503 .accessibilityValue(value ? "On" : "Off") 533 504 .accessibilityHint("Double tap to toggle") 534 505 } 535 - 536 - private func hapticLight() { 537 - let generator = UIImpactFeedbackGenerator(style: .light) 538 - generator.impactOccurred() 539 - } 540 506 } 541 507 542 508 struct PickerSettingRow: View { ··· 549 515 Picker(label, selection: Binding( 550 516 get: { value }, 551 517 set: { newValue in 552 - hapticSelection() 518 + Haptics.selection() 553 519 value = newValue 554 520 onChange(newValue) 555 521 } ··· 562 528 .accessibilityValue(options.first(where: { $0.0 == value })?.1 ?? "") 563 529 .accessibilityHint("Select an option from the list") 564 530 } 531 + } 565 532 566 - private func hapticSelection() { 567 - let generator = UISelectionFeedbackGenerator() 568 - generator.selectionChanged() 533 + // MARK: - iPad Settings Panel 534 + 535 + struct SettingsPanelView: View { 536 + let bleManager: BLEManager 537 + let onClose: () -> Void 538 + @State private var selectedTab = 0 539 + 540 + var body: some View { 541 + VStack(spacing: 0) { 542 + // Header 543 + HStack { 544 + Text(selectedTab == 0 ? String(localized: "settings_tab") : String(localized: "device_info_title")) 545 + .font(.headline) 546 + Spacer() 547 + Button { 548 + Haptics.light() 549 + onClose() 550 + } label: { 551 + Image(systemName: "xmark.circle.fill") 552 + .font(.title3) 553 + .foregroundStyle(.secondary) 554 + } 555 + .buttonStyle(.plain) 556 + .accessibilityLabel("Close settings") 557 + } 558 + .padding(.horizontal, 20) 559 + .padding(.top, 16) 560 + .padding(.bottom, 12) 561 + 562 + // Segmented picker 563 + Picker("", selection: $selectedTab) { 564 + Text(String(localized: "settings_tab")).tag(0) 565 + Text(String(localized: "info_tab")).tag(1) 566 + } 567 + .pickerStyle(.segmented) 568 + .padding(.horizontal, 20) 569 + .padding(.bottom, 8) 570 + 571 + // Content 572 + if selectedTab == 0 { 573 + ConfigurationView(bleManager: bleManager) 574 + } else { 575 + DiagnosticsView(bleManager: bleManager) 576 + } 577 + } 578 + .frame(width: 380) 579 + .background(.ultraThinMaterial) 580 + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) 569 581 } 570 582 } 571 583
+8 -5
ios/Tinkcil/TemperatureGraph.swift
··· 16 16 struct TemperatureGraph: View { 17 17 let history: [TemperaturePoint] 18 18 let currentSetpoint: Int 19 + var windowSeconds: TimeInterval = 6 20 + var showAxes: Bool = false 21 + var tempLineWidth: CGFloat = 2.5 22 + var setpointLineWidth: CGFloat = 1.5 19 23 20 24 private var chartData: [ChartDataPoint] { 21 25 var data: [ChartDataPoint] = [] ··· 89 93 var body: some View { 90 94 TimelineView(.animation(paused: false)) { timeline in 91 95 let now = timeline.date 92 - let windowSeconds: TimeInterval = 6 93 96 let xDomain = now.addingTimeInterval(-windowSeconds)...now.addingTimeInterval(1) 94 97 95 98 Chart(chartDataWithEdge(now: now, windowSeconds: windowSeconds)) { point in ··· 99 102 series: .value("Series", point.series) 100 103 ) 101 104 .foregroundStyle(point.series == "Setpoint" ? Color.gray.opacity(0.4) : lineColor) 102 - .lineStyle(StrokeStyle(lineWidth: point.series == "Setpoint" ? 1.5 : 2.5, lineCap: .round)) 105 + .lineStyle(StrokeStyle(lineWidth: point.series == "Setpoint" ? setpointLineWidth : tempLineWidth, lineCap: .round)) 103 106 } 104 - .chartXAxis(.hidden) 105 - .chartYAxis(.hidden) 107 + .chartXAxis(showAxes ? .automatic : .hidden) 108 + .chartYAxis(showAxes ? .automatic : .hidden) 106 109 .chartLegend(.hidden) 107 110 .chartYScale(domain: 0...500) 108 111 .chartXScale(domain: xDomain) 109 - .padding(.horizontal, -20) 112 + .padding(.horizontal, showAxes ? 0 : -20) 110 113 } 111 114 } 112 115 }