Skip to content

Commit 40f72e0

Browse files
committed
Update the sync between keyboard extension and the main app
1 parent d2324c3 commit 40f72e0

8 files changed

Lines changed: 176 additions & 28 deletions

File tree

VoiceInk-ios/AppGroupCoordinator.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,17 +69,32 @@ final class AppGroupCoordinator {
6969

7070
/// Get current recording state (for keyboard UI updates)
7171
var isRecording: Bool {
72-
return sharedDefaults?.bool(forKey: UserDefaultsKeys.isRecording) ?? false
72+
let storedState = sharedDefaults?.bool(forKey: UserDefaultsKeys.isRecording) ?? false
73+
let timestamp = sharedDefaults?.double(forKey: UserDefaultsKeys.lastRecordingTimestamp) ?? 0
74+
let currentTime = Date().timeIntervalSince1970
75+
76+
// If the stored state is more than 30 seconds old, consider it stale
77+
if storedState && (currentTime - timestamp) > 30 {
78+
print("⚠️ Recording state appears stale, clearing it")
79+
updateRecordingState(false)
80+
return false
81+
}
82+
83+
return storedState
7384
}
7485

7586
// MARK: - Public Interface for Main App
7687

7788
/// Call this from the main app to update recording state
7889
func updateRecordingState(_ isRecording: Bool) {
7990
sharedDefaults?.set(isRecording, forKey: UserDefaultsKeys.isRecording)
91+
// Update timestamp whenever state changes
92+
sharedDefaults?.set(Date().timeIntervalSince1970, forKey: UserDefaultsKeys.lastRecordingTimestamp)
8093

8194
// Notify keyboard of state change
8295
postDarwinNotification(NotificationNames.recordingStateChanged)
96+
97+
print("📡 Updated recording state: \(isRecording)")
8398
}
8499

85100
/// Check and consume start recording flag (returns true if should start)

VoiceInk-ios/AudioSessionManager.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,11 @@ final class AudioSessionManager: ObservableObject {
2929
let audioSession = AVAudioSession.sharedInstance()
3030

3131
do {
32-
// Configure session for foreground recording only
32+
// Configure session for recording with background support
3333
try audioSession.setCategory(
3434
.playAndRecord,
3535
mode: .spokenAudio,
36-
options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP]
36+
options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP, .mixWithOthers]
3737
)
3838

3939
// Activate the session

VoiceInk-ios/Info.plist

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,9 @@
1818
</array>
1919
</dict>
2020
</array>
21+
<key>UIBackgroundModes</key>
22+
<array>
23+
<string>audio</string>
24+
</array>
2125
</dict>
2226
</plist>

VoiceInk-ios/NotesListView.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ struct NotesListView: View {
8787
)
8888
}
8989
}
90+
.onReceive(NotificationCenter.default.publisher(for: .stopRecordingFromKeyboard)) { _ in
91+
if recordingManager.isRecording {
92+
recordingManager.stopRecording(modelContext: modelContext)
93+
}
94+
}
9095
}
9196
}
9297

VoiceInk-ios/RecordingManager.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import AVFoundation
44
import Combine
55
import UIKit
66

7+
extension Notification.Name {
8+
static let stopRecordingFromKeyboard = Notification.Name("stopRecordingFromKeyboard")
9+
}
10+
711
enum RecordingState: Equatable {
812
case idle
913
case recording
@@ -45,6 +49,7 @@ final class RecordingManager: ObservableObject {
4549
private var durationTimer: Timer?
4650

4751
private let sessionManager = AudioSessionManager.shared
52+
private let coordinator = AppGroupCoordinator.shared
4853

4954
var isRecording: Bool {
5055
recordingState == .recording
@@ -54,12 +59,24 @@ final class RecordingManager: ObservableObject {
5459
init() {
5560
// Simplified initialization - no complex keyboard coordination needed
5661
print("🎙️ RecordingManager initialized")
62+
setupCoordinatorCallbacks()
5763
}
5864

5965
deinit {
6066
durationTimer?.invalidate()
6167
}
6268

69+
// MARK: - Coordinator Setup
70+
private func setupCoordinatorCallbacks() {
71+
coordinator.onStopRecordingRequested = { [weak self] in
72+
guard let self = self, self.isRecording else { return }
73+
// This will be called when keyboard extension requests stop
74+
print("🛑 Stop recording requested from keyboard extension")
75+
// We need modelContext, so we'll handle this via a notification instead
76+
NotificationCenter.default.post(name: .stopRecordingFromKeyboard, object: nil)
77+
}
78+
}
79+
6380
// MARK: - Recording Flow (Simplified)
6481

6582

@@ -86,6 +103,9 @@ final class RecordingManager: ObservableObject {
86103
recordingState = .recording
87104
animate = true
88105

106+
// Update coordinator state
107+
coordinator.updateRecordingState(true)
108+
89109
// Auto-select first mode if none is selected
90110
if settings.selectedModeId == nil && !settings.modes.isEmpty {
91111
settings.selectedModeId = settings.modes.first?.id
@@ -99,6 +119,8 @@ final class RecordingManager: ObservableObject {
99119
activeRecordingAlert = .generic(error)
100120
recordingState = .idle
101121
animate = false
122+
// Update coordinator state on error
123+
coordinator.updateRecordingState(false)
102124
}
103125
}
104126

@@ -128,6 +150,9 @@ final class RecordingManager: ObservableObject {
128150
currentRecordingNote = note
129151
isRecordingSheetPresented = false
130152

153+
// Update coordinator state
154+
coordinator.updateRecordingState(false)
155+
131156
// Start background transcription
132157
transcribeInBackground(note: note, audioFileName: audioFileName, recordingDuration: recordingDuration, modelContext: modelContext)
133158
}
@@ -139,6 +164,9 @@ final class RecordingManager: ObservableObject {
139164
animate = false
140165
isRecordingSheetPresented = false
141166
currentDuration = 0
167+
168+
// Update coordinator state
169+
coordinator.updateRecordingState(false)
142170
}
143171

144172
// MARK: - Permissions

VoiceInk-ios/VoiceInk_iosApp.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ struct VoiceInk_iosApp: App {
1313
@State private var hasCompletedOnboarding = UserDefaults.standard.bool(forKey: "hasCompletedOnboarding")
1414
@StateObject private var recordingManager = RecordingManager()
1515

16+
init() {
17+
// Clear any stale recording state on app launch
18+
AppGroupCoordinator.shared.updateRecordingState(false)
19+
print("🧹 Cleared stale recording state on app launch")
20+
}
21+
1622
var sharedModelContainer: ModelContainer = {
1723
let schema = Schema([
1824
Transcription.self,

VoiceInkKeyboard/KeyboardViewController.swift

Lines changed: 115 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,18 @@ import KeyboardKit
1111
class KeyboardViewController: KeyboardInputViewController {
1212

1313
var recordButton: UIButton!
14+
private let coordinator = AppGroupCoordinator.shared
15+
private var recordingStatusTimer: Timer?
16+
17+
deinit {
18+
recordingStatusTimer?.invalidate()
19+
recordingStatusTimer = nil
20+
}
1421

1522
override func viewDidLoad() {
1623
super.viewDidLoad()
1724
setupKeyboard()
25+
setupRecordingStatusMonitoring()
1826
}
1927

2028
private func setupKeyboard() {
@@ -47,38 +55,77 @@ class KeyboardViewController: KeyboardInputViewController {
4755
}
4856

4957
private func setupRecordButton() {
50-
// Create the capsule-shaped record button
58+
// Create the native iOS-style record button
5159
recordButton = UIButton(type: .system)
52-
recordButton.setTitle("🎤 Record", for: .normal)
53-
recordButton.titleLabel?.font = UIFont.systemFont(ofSize: 12, weight: .medium)
54-
recordButton.backgroundColor = UIColor.systemRed
55-
recordButton.setTitleColor(.white, for: .normal)
56-
recordButton.layer.cornerRadius = 14 // Will be adjusted to make it capsule-shaped
5760
recordButton.translatesAutoresizingMaskIntoConstraints = false
5861
recordButton.addTarget(self, action: #selector(recordButtonTapped), for: .touchUpInside)
5962

60-
// Add some padding and styling for better appearance
61-
recordButton.contentEdgeInsets = UIEdgeInsets(top: 4, left: 10, bottom: 4, right: 10)
63+
// Configure for idle state initially
64+
configureButtonForIdleState()
65+
66+
// Add native iOS styling
67+
recordButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .semibold)
68+
recordButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 6, right: 16)
69+
70+
// Native iOS shadow and styling
6271
recordButton.layer.shadowColor = UIColor.black.cgColor
6372
recordButton.layer.shadowOffset = CGSize(width: 0, height: 1)
64-
recordButton.layer.shadowOpacity = 0.15
65-
recordButton.layer.shadowRadius = 1.5
73+
recordButton.layer.shadowOpacity = 0.2
74+
recordButton.layer.shadowRadius = 2
75+
76+
// Add subtle border for better definition
77+
recordButton.layer.borderWidth = 0.5
78+
recordButton.layer.borderColor = UIColor.separator.cgColor
6679

6780
// Add button to main view
6881
view.addSubview(recordButton)
6982

7083
// Set up constraints - position in top center with safe margins
7184
NSLayoutConstraint.activate([
7285
recordButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
73-
recordButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 4),
74-
recordButton.heightAnchor.constraint(equalToConstant: 28),
75-
recordButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 80)
86+
recordButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 6),
87+
recordButton.heightAnchor.constraint(equalToConstant: 32),
88+
recordButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 120)
7689
])
7790

7891
// Ensure button stays on top
7992
view.bringSubviewToFront(recordButton)
8093
}
8194

95+
private func configureButtonForIdleState() {
96+
// Use SF Symbol for microphone
97+
let microphoneConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
98+
let microphoneImage = UIImage(systemName: "mic.fill", withConfiguration: microphoneConfig)
99+
100+
recordButton.setImage(microphoneImage, for: .normal)
101+
recordButton.setTitle(" Record", for: .normal)
102+
recordButton.backgroundColor = UIColor.systemBlue
103+
recordButton.setTitleColor(.white, for: .normal)
104+
recordButton.tintColor = .white
105+
106+
// Ensure image and text are properly aligned
107+
recordButton.semanticContentAttribute = .forceLeftToRight
108+
recordButton.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 4)
109+
recordButton.titleEdgeInsets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: 0)
110+
}
111+
112+
private func configureButtonForRecordingState() {
113+
// Use SF Symbol for stop
114+
let stopConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
115+
let stopImage = UIImage(systemName: "stop.fill", withConfiguration: stopConfig)
116+
117+
recordButton.setImage(stopImage, for: .normal)
118+
recordButton.setTitle(" Stop", for: .normal)
119+
recordButton.backgroundColor = UIColor.systemRed
120+
recordButton.setTitleColor(.white, for: .normal)
121+
recordButton.tintColor = .white
122+
123+
// Ensure image and text are properly aligned
124+
recordButton.semanticContentAttribute = .forceLeftToRight
125+
recordButton.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 4)
126+
recordButton.titleEdgeInsets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: 0)
127+
}
128+
82129
override func viewDidAppear(_ animated: Bool) {
83130
super.viewDidAppear(animated)
84131

@@ -120,14 +167,32 @@ class KeyboardViewController: KeyboardInputViewController {
120167
}
121168

122169
@objc private func recordButtonTapped() {
170+
// Add native iOS button press animation
171+
addButtonPressAnimation()
123172

124173
// Provide haptic feedback
125174
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
126175
impactFeedback.impactOccurred()
127176

128-
// Simply open the main app for recording
129-
// No more complex coordination - just switch to main app
130-
openMainAppForRecording()
177+
if coordinator.isRecording {
178+
// Stop recording
179+
coordinator.requestStopRecording()
180+
updateButtonAppearanceBasedOnState()
181+
} else {
182+
// Start recording by opening main app
183+
openMainAppForRecording()
184+
}
185+
}
186+
187+
private func addButtonPressAnimation() {
188+
// Native iOS button press animation - scale down then back up
189+
UIView.animate(withDuration: 0.1, delay: 0, options: [.curveEaseInOut], animations: {
190+
self.recordButton.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
191+
}) { _ in
192+
UIView.animate(withDuration: 0.1, delay: 0, options: [.curveEaseInOut], animations: {
193+
self.recordButton.transform = CGAffineTransform.identity
194+
})
195+
}
131196
}
132197

133198
private func openMainAppForRecording() {
@@ -194,22 +259,47 @@ class KeyboardViewController: KeyboardInputViewController {
194259

195260
private func showUserMessage() {
196261
// Last resort: Update button to show user should open main app manually
197-
recordButton.setTitle("📱 Open VoiceInk", for: .normal)
198-
recordButton.backgroundColor = UIColor.systemBlue
262+
let appConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
263+
let appImage = UIImage(systemName: "app", withConfiguration: appConfig)
264+
265+
recordButton.setImage(appImage, for: .normal)
266+
recordButton.setTitle(" Open VoiceInk", for: .normal)
267+
recordButton.backgroundColor = UIColor.systemOrange
268+
recordButton.setTitleColor(.white, for: .normal)
269+
recordButton.tintColor = .white
199270

200271
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
201-
self.recordButton.setTitle("🎤 Record", for: .normal)
202-
self.recordButton.backgroundColor = UIColor.systemRed
272+
self.configureButtonForIdleState()
273+
}
274+
}
275+
276+
private func setupRecordingStatusMonitoring() {
277+
// Monitor recording status every 0.5 seconds
278+
recordingStatusTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
279+
self?.updateButtonAppearanceBasedOnState()
203280
}
281+
282+
// Initial state update
283+
updateButtonAppearanceBasedOnState()
204284
}
205285

206286
private func updateButtonAppearanceBasedOnState() {
207-
// Simplified: Always show "Record" since we just open the main app
208-
recordButton.backgroundColor = UIColor.systemRed
209-
recordButton.setTitle("🎤 Record", for: .normal)
287+
let isRecording = coordinator.isRecording
210288

211-
// Ensure capsule shape is maintained
212-
recordButton.layer.cornerRadius = recordButton.frame.height / 2
289+
DispatchQueue.main.async { [weak self] in
290+
guard let self = self, let button = self.recordButton else { return }
291+
292+
if isRecording {
293+
// Configure for recording state
294+
self.configureButtonForRecordingState()
295+
} else {
296+
// Configure for idle state
297+
self.configureButtonForIdleState()
298+
}
299+
300+
// Ensure capsule shape is maintained
301+
button.layer.cornerRadius = button.frame.height / 2
302+
}
213303
}
214304

215305
override func textWillChange(_ textInput: UITextInput?) {

0 commit comments

Comments
 (0)