diff --git a/Apps/Attendi/AttendiApp.swift b/Apps/Attendi/App/AttendiApp.swift similarity index 53% rename from Apps/Attendi/AttendiApp.swift rename to Apps/Attendi/App/AttendiApp.swift index 071ef35..2622f1c 100644 --- a/Apps/Attendi/AttendiApp.swift +++ b/Apps/Attendi/App/AttendiApp.swift @@ -1,6 +1,9 @@ import SwiftUI /// The entry point of the Attendi sample app. +/// +/// The app showcases the Recording feature of the `Features` package, attached to its real audio services: it records audio from the +/// device's microphone, transcribes the recording into text on device, and presents the result in a modal sheet. @main struct AttendiApp: App { diff --git a/Apps/Attendi/Assets.xcassets/AccentColor.colorset/Contents.json b/Apps/Attendi/Catalogs/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from Apps/Attendi/Assets.xcassets/AccentColor.colorset/Contents.json rename to Apps/Attendi/Catalogs/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/Apps/Attendi/Assets.xcassets/AppIcon.appiconset/Contents.json b/Apps/Attendi/Catalogs/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from Apps/Attendi/Assets.xcassets/AppIcon.appiconset/Contents.json rename to Apps/Attendi/Catalogs/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/Apps/Attendi/Assets.xcassets/Contents.json b/Apps/Attendi/Catalogs/Assets.xcassets/Contents.json similarity index 100% rename from Apps/Attendi/Assets.xcassets/Contents.json rename to Apps/Attendi/Catalogs/Assets.xcassets/Contents.json diff --git a/Apps/Attendi/Catalogs/Localizable.xcstrings b/Apps/Attendi/Catalogs/Localizable.xcstrings new file mode 100644 index 0000000..92d4a6c --- /dev/null +++ b/Apps/Attendi/Catalogs/Localizable.xcstrings @@ -0,0 +1,27 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "view.recording.navigation.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recording" + } + } + } + }, + "view.transcription.navigation.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transcription" + } + } + } + } + }, + "version" : "1.2" +} \ No newline at end of file diff --git a/Apps/Attendi/ContentView.swift b/Apps/Attendi/ContentView.swift deleted file mode 100644 index fdc82a0..0000000 --- a/Apps/Attendi/ContentView.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Recording -import SwiftUI - -/// The root view of the Attendi sample app. -/// -/// The view hosts the `RecordingView` feature from the `Recording` target of the `Features` package, which drives the whole recording flow, -/// and presents the transcribed text of every processed recording in a modal sheet. -struct ContentView: View { - - // MARK: Properties - - /// The transcription currently presented in the modal sheet, or `nil` when none is shown. - @State private var transcription: Transcription? - - // MARK: Body - - /// The content of the view: the recording feature's view, attached to the microphone-backed recording service and the on-device speech - /// transcribing service, with the transcribed text of every processed recording presented in a modal sheet. - var body: some View { - RecordingView( - recorder: AudioRecordingService(), - transcriber: AudioTranscribingService() - ) { text in - transcription = .init(text: text) - } - .sheet(item: $transcription) { transcription in - NavigationStack { - ScrollView { - Text(transcription.text) - .frame( - maxWidth: .infinity, - alignment: .leading - ) - .padding() - } - .navigationTitle("Transcription") - #if !os(macOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - Button("Done") { - self.transcription = nil - } - } - } - .presentationDetents([.medium, .large]) - } - } - -} - -// MARK: - Models - -/// A transcription of a processed recording to present in the modal sheet. -private struct Transcription: Identifiable { - /// The unique identifier of the transcription. - let id = UUID() - /// The transcribed text of the processed recording. - let text: String -} - -// MARK: - Previews - -#Preview( - "Content view" -) { - ContentView() -} diff --git a/Apps/Attendi/Attendi-Info.plist b/Apps/Attendi/Resources/Attendi-Info.plist similarity index 100% rename from Apps/Attendi/Attendi-Info.plist rename to Apps/Attendi/Resources/Attendi-Info.plist diff --git a/Apps/Attendi/View Models/ContentViewModel.swift b/Apps/Attendi/View Models/ContentViewModel.swift new file mode 100644 index 0000000..16efd82 --- /dev/null +++ b/Apps/Attendi/View Models/ContentViewModel.swift @@ -0,0 +1,59 @@ +import Foundation +import Observation +import Recording + +extension ContentView { + + /// The observable model that drives ``ContentView``. + /// + /// The model owns the recording and transcribing services attached to the recording feature, and holds the transcription currently + /// presented in the view's modal sheet: ``received(_:)`` wraps the transcribed text of a processed recording for presentation, and + /// ``dismissed()`` clears it when the sheet closes. + @MainActor + @Observable + final class Model { + + // MARK: Properties + + /// The transcription currently presented in the modal sheet, or `nil` when none is shown. + var transcription: Transcription? + + /// The service that captures the audio from the device's microphone. + @ObservationIgnored + let recorder = AudioRecordingService() + + /// The service that transcribes the recorded audio into text on device. + @ObservationIgnored + let transcriber = AudioTranscribingService() + + // MARK: Methods + + /// Handles the transcribed text of a processed recording, presenting it in the modal sheet. + /// + /// - Parameter text: The transcribed text of the processed recording. + func received( + _ text: String + ) { + transcription = .init(text: text) + } + + /// Handles the dismissal of the modal sheet. + func dismissed() { + transcription = nil + } + + } + +} + +// MARK: - Models + +extension ContentView.Model { + /// A transcription of a processed recording to present in the modal sheet. + struct Transcription: Identifiable { + /// The unique identifier of the transcription. + let id = UUID() + /// The transcribed text of the processed recording. + let text: String + } +} diff --git a/Apps/Attendi/Views/ContentView.swift b/Apps/Attendi/Views/ContentView.swift new file mode 100644 index 0000000..f30e84c --- /dev/null +++ b/Apps/Attendi/Views/ContentView.swift @@ -0,0 +1,70 @@ +import Recording +import SwiftUI + +/// The root view of the Attendi sample app. +/// +/// The view hosts the `RecordingView` feature from the `Recording` target of the `Features` package, which drives the whole recording flow, +/// inside a navigation stack, and presents the transcribed text of every processed recording in a modal sheet of its own navigation stack. +/// +/// The services attached to the feature and the transcription shown in the sheet live in the view's ``Model``; the view itself only renders it and +/// forwards the feature's output and the sheet's dismissal. The navigation titles of the stacks are localized through the app's string catalog. +struct ContentView: View { + + // MARK: Properties + + /// The model that owns the attached services and the transcription presented in the modal sheet. + @State private var model = Model() + + // MARK: Body + + /// The content of the view: a navigation stack with the recording feature's view, attached to the model's services, and a modal sheet + /// presenting the transcribed text of every processed recording, closable through its toolbar button or a swipe. + var body: some View { + NavigationStack { + RecordingView( + recorder: model.recorder, + transcriber: model.transcriber + ) { text in + model.received(text) + } + .navigationTitle("view.recording.navigation.title") + #if !os(macOS) + .navigationBarTitleDisplayMode(.inline) + #endif + } + .sheet(item: $model.transcription) { transcription in + NavigationStack { + ScrollView { + Text(transcription.text) + .font(.body) + .fontWeight(.regular) + .foregroundStyle(.primary) + .frame( + maxWidth: .infinity, + alignment: .leading + ) + .padding() + } + .navigationTitle("view.transcription.navigation.title") + #if !os(macOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + Button(role: .close) { + model.dismissed() + } + } + } + .presentationDetents([.medium, .large]) + } + } + +} + +// MARK: - Previews + +#Preview( + "Content view" +) { + ContentView() +} diff --git a/Attendi.xcodeproj/project.pbxproj b/Attendi.xcodeproj/project.pbxproj index a08dd89..ad00b7e 100644 --- a/Attendi.xcodeproj/project.pbxproj +++ b/Attendi.xcodeproj/project.pbxproj @@ -21,9 +21,11 @@ 02870A1F2FF7EB680079EA3A /* Exceptions for "Apps" folder in "Attendi" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - Attendi/Assets.xcassets, - Attendi/AttendiApp.swift, - Attendi/ContentView.swift, + Attendi/App/AttendiApp.swift, + Attendi/Catalogs/Assets.xcassets, + Attendi/Catalogs/Localizable.xcstrings, + "Attendi/View Models/ContentViewModel.swift", + Attendi/Views/ContentView.swift, ); target = 02870A0C2FF7EB610079EA3A /* Attendi */; }; @@ -164,6 +166,7 @@ isa = XCBuildConfiguration; buildSettings = { DEVELOPMENT_TEAM = 7FMNM89WKG; + SWIFT_EMIT_LOC_STRINGS = YES; }; name = Debug; }; @@ -171,6 +174,7 @@ isa = XCBuildConfiguration; buildSettings = { DEVELOPMENT_TEAM = 7FMNM89WKG; + SWIFT_EMIT_LOC_STRINGS = YES; }; name = Release; }; @@ -243,9 +247,9 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = "Apps/Attendi/Attendi-Info.plist"; - INFOPLIST_KEY_CFBundleDisplayName = "Attendi (Sample)"; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.medical"; + INFOPLIST_FILE = "Apps/Attendi/Resources/Attendi-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = Attendi; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Attendi uses the microphone to record the audio it transcribes into text."; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; @@ -353,9 +357,9 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = "Apps/Attendi/Attendi-Info.plist"; - INFOPLIST_KEY_CFBundleDisplayName = "Attendi (Sample)"; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.medical"; + INFOPLIST_FILE = "Apps/Attendi/Resources/Attendi-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = Attendi; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Attendi uses the microphone to record the audio it transcribes into text."; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;