From dd1fe13e213e4658413debfa4221126e779349ce Mon Sep 17 00:00:00 2001 From: Prince Yadav <66916296+prince-0408@users.noreply.github.com> Date: Fri, 17 Apr 2026 02:37:54 +0530 Subject: [PATCH 1/4] feat: implement Quick Tutorial flow with keyboard-based chapter filtering - Wire 'Quick tutorial' button in InstallationVC to present TutorialView - Add entry screen with tip card, description, chapter list and Start full tutorial button - Add chapter screens with multi-step support and interactive Try it here text fields - Add real-time correct/incorrect feedback for interactive steps - Add TutorialKeyboardDetector to hide Noun annotation chapter for English-only users - Add Non-Scribe keyboard warning screen - Nav bar matches Figma: idle shows back+label, feedback shows bare back+xmark Closes #[issue-number] --- Scribe.xcodeproj/project.pbxproj | 36 +++ Scribe/InstallationTab/InstallationVC.swift | 7 +- Scribe/Tutorial/TutorialChapter.swift | 33 +++ Scribe/Tutorial/TutorialChapterContent.swift | 95 +++++++ Scribe/Tutorial/TutorialChapterView.swift | 233 ++++++++++++++++++ .../Tutorial/TutorialKeyboardDetector.swift | 48 ++++ Scribe/Tutorial/TutorialStep.swift | 10 + Scribe/Tutorial/TutorialTipCard.swift | 36 +++ Scribe/Tutorial/TutorialView.swift | 124 ++++++++++ 9 files changed, 621 insertions(+), 1 deletion(-) create mode 100644 Scribe/Tutorial/TutorialChapter.swift create mode 100644 Scribe/Tutorial/TutorialChapterContent.swift create mode 100644 Scribe/Tutorial/TutorialChapterView.swift create mode 100644 Scribe/Tutorial/TutorialKeyboardDetector.swift create mode 100644 Scribe/Tutorial/TutorialStep.swift create mode 100644 Scribe/Tutorial/TutorialTipCard.swift create mode 100644 Scribe/Tutorial/TutorialView.swift diff --git a/Scribe.xcodeproj/project.pbxproj b/Scribe.xcodeproj/project.pbxproj index 6980936d..5ad37f28 100644 --- a/Scribe.xcodeproj/project.pbxproj +++ b/Scribe.xcodeproj/project.pbxproj @@ -117,6 +117,13 @@ 30489C262936DB0200B59393 /* ToolTipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30489C1D2936DAB700B59393 /* ToolTipView.swift */; }; 38BD213422D5907F00C6795D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38BD213322D5907F00C6795D /* AppDelegate.swift */; }; 38BD213622D5907F00C6795D /* InstallationVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38BD213522D5907F00C6795D /* InstallationVC.swift */; }; + FA002BBB2F9B000000000001 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA002BBA2F9B000000000001 /* TutorialView.swift */; }; + FA002BBB2F9B000000000002 /* TutorialChapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA002BBA2F9B000000000002 /* TutorialChapter.swift */; }; + FA002BBB2F9B000000000003 /* TutorialChapterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA002BBA2F9B000000000003 /* TutorialChapterView.swift */; }; + FA002BBB2F9B000000000004 /* TutorialChapterContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA002BBA2F9B000000000004 /* TutorialChapterContent.swift */; }; + FA002BBB2F9B000000000005 /* TutorialTipCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA002BBA2F9B000000000005 /* TutorialTipCard.swift */; }; + FA002BBB2F9B000000000006 /* TutorialStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA002BBA2F9B000000000006 /* TutorialStep.swift */; }; + FA002BBB2F9B000000000007 /* TutorialKeyboardDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA002BBA2F9B000000000007 /* TutorialKeyboardDetector.swift */; }; 38BD213922D5907F00C6795D /* AppScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 38BD213722D5907F00C6795D /* AppScreen.storyboard */; }; 38BD213E22D5908100C6795D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 38BD213D22D5908100C6795D /* Assets.xcassets */; }; 38BD214122D5908100C6795D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 38BD213F22D5908100C6795D /* LaunchScreen.storyboard */; }; @@ -1041,6 +1048,13 @@ 38BD213022D5907E00C6795D /* Scribe.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Scribe.app; sourceTree = BUILT_PRODUCTS_DIR; }; 38BD213322D5907F00C6795D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 38BD213522D5907F00C6795D /* InstallationVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallationVC.swift; sourceTree = ""; }; + FA002BBA2F9B000000000001 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = ""; }; + FA002BBA2F9B000000000002 /* TutorialChapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialChapter.swift; sourceTree = ""; }; + FA002BBA2F9B000000000003 /* TutorialChapterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialChapterView.swift; sourceTree = ""; }; + FA002BBA2F9B000000000004 /* TutorialChapterContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialChapterContent.swift; sourceTree = ""; }; + FA002BBA2F9B000000000005 /* TutorialTipCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialTipCard.swift; sourceTree = ""; }; + FA002BBA2F9B000000000006 /* TutorialStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialStep.swift; sourceTree = ""; }; + FA002BBA2F9B000000000007 /* TutorialKeyboardDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialKeyboardDetector.swift; sourceTree = ""; }; 38BD213822D5907F00C6795D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/AppScreen.storyboard; sourceTree = ""; }; 38BD213D22D5908100C6795D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 38BD214022D5908100C6795D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; @@ -1551,6 +1565,20 @@ path = InstallationTab; sourceTree = ""; }; + FA002BC02F9B000000000001 /* Tutorial */ = { + isa = PBXGroup; + children = ( + FA002BBA2F9B000000000001 /* TutorialView.swift */, + FA002BBA2F9B000000000002 /* TutorialChapter.swift */, + FA002BBA2F9B000000000003 /* TutorialChapterView.swift */, + FA002BBA2F9B000000000004 /* TutorialChapterContent.swift */, + FA002BBA2F9B000000000005 /* TutorialTipCard.swift */, + FA002BBA2F9B000000000006 /* TutorialStep.swift */, + FA002BBA2F9B000000000007 /* TutorialKeyboardDetector.swift */, + ); + path = Tutorial; + sourceTree = ""; + }; 147797A92A2CD2B50044A53E /* AboutTab */ = { isa = PBXGroup; children = ( @@ -1661,6 +1689,7 @@ CE1378C128F5D7AC00E1CBC2 /* Colors */, EDEE62232B2DE64A00A0B9C1 /* Extensions */, 1406B7882A2DFE4C001DF45B /* InstallationTab */, + FA002BC02F9B000000000001 /* Tutorial */, D198B5CD2BFA954100E1BF4F /* i18n */, EDB460222B03BF7000BEA967 /* Resources */, 147797B62A2CFB560044A53E /* SettingsTab */, @@ -2897,6 +2926,13 @@ D180EC0328FDFABF0018E29B /* FR-AZERTYInterfaceVariables.swift in Sources */, D1CDED7B2A859FBF00098546 /* ENInterfaceVariables.swift in Sources */, 38BD213622D5907F00C6795D /* InstallationVC.swift in Sources */, + FA002BBB2F9B000000000001 /* TutorialView.swift in Sources */, + FA002BBB2F9B000000000002 /* TutorialChapter.swift in Sources */, + FA002BBB2F9B000000000003 /* TutorialChapterView.swift in Sources */, + FA002BBB2F9B000000000004 /* TutorialChapterContent.swift in Sources */, + FA002BBB2F9B000000000005 /* TutorialTipCard.swift in Sources */, + FA002BBB2F9B000000000006 /* TutorialStep.swift in Sources */, + FA002BBB2F9B000000000007 /* TutorialKeyboardDetector.swift in Sources */, D171942D27AECEB80038660B /* DEInterfaceVariables.swift in Sources */, 147797C02A2D0CDF0044A53E /* SettingsTableData.swift in Sources */, E9F7273F2F45A6E60060B92D /* APIClient.swift in Sources */, diff --git a/Scribe/InstallationTab/InstallationVC.swift b/Scribe/InstallationTab/InstallationVC.swift index 7a44b06d..6e10ec05 100644 --- a/Scribe/InstallationTab/InstallationVC.swift +++ b/Scribe/InstallationTab/InstallationVC.swift @@ -395,7 +395,12 @@ extension InstallationVC { title: NSLocalizedString( "i18n.app.installation.button_quick_tutorial", value: "Quick tutorial", comment: "" ), - action: {} + action: { [weak self] in + let tutorialView = TutorialView() + let hostingController = UIHostingController(rootView: tutorialView) + hostingController.modalPresentationStyle = .fullScreen + self?.present(hostingController, animated: true) + } ) let hostingController = UIHostingController(rootView: ctaButton) diff --git a/Scribe/Tutorial/TutorialChapter.swift b/Scribe/Tutorial/TutorialChapter.swift new file mode 100644 index 00000000..16820688 --- /dev/null +++ b/Scribe/Tutorial/TutorialChapter.swift @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +enum TutorialChapter: String, CaseIterable, Identifiable, Hashable { + case nounAnnotation + case wordTranslation + case verbConjugation + case nounPlurals + + var id: String { rawValue } + + var title: String { + switch self { + case .nounAnnotation: + return NSLocalizedString("i18n.app.tutorial.noun_annotation", value: "Noun annotation", comment: "") + case .wordTranslation: + return NSLocalizedString("i18n.app.tutorial.word_translation", value: "Word translation", comment: "") + case .verbConjugation: + return NSLocalizedString("i18n.app.tutorial.verb_conjugation", value: "Verb conjugation", comment: "") + case .nounPlurals: + return NSLocalizedString("i18n.app.tutorial.noun_plurals", value: "Noun plurals", comment: "") + } + } + + var isLast: Bool { self == .nounPlurals } + + func next() -> TutorialChapter? { + let all = TutorialChapter.allCases + guard let idx = all.firstIndex(of: self), idx + 1 < all.count else { return nil } + return all[idx + 1] + } +} diff --git a/Scribe/Tutorial/TutorialChapterContent.swift b/Scribe/Tutorial/TutorialChapterContent.swift new file mode 100644 index 00000000..f8a23b08 --- /dev/null +++ b/Scribe/Tutorial/TutorialChapterContent.swift @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +/** + * Content definitions for each tutorial chapter. + * Noun annotation has two steps (Vater → Mutter). + * Other chapters are single-step. + */ + +import Foundation + +private let languageNote = NSLocalizedString( + "i18n.app.tutorial.language_note", + value: "If your second language is not German, change the language in your keyboard.", + comment: "" +) + +extension TutorialChapter { + var steps: [TutorialStep] { + switch self { + case .nounAnnotation: + return [ + TutorialStep( + instructions: NSLocalizedString( + "i18n.app.tutorial.noun_annotation.step1", + value: "Write the word \"Vater\". Notice the word suggestions that appear on the keyboard's top bar.\n\nThen, press space. You will see the word's gender tag on the keyboard's top bar — in this case, \"M\" for Maskulin.", + comment: "" + ), + languageNote: languageNote, + expectedInput: "Vater", + incorrectFeedback: NSLocalizedString( + "i18n.app.tutorial.noun_annotation.step1.incorrect", + value: "Not quite! Try writing Vater.", + comment: "" + ) + ), + TutorialStep( + instructions: NSLocalizedString( + "i18n.app.tutorial.noun_annotation.step2", + value: "Now write the word \"Mutter\" and then press space. The gender tag will be \"F\", for Feminin.", + comment: "" + ), + languageNote: languageNote, + expectedInput: "Mutter", + incorrectFeedback: NSLocalizedString( + "i18n.app.tutorial.noun_annotation.step2.incorrect", + value: "Not quite! Try writing Mutter.", + comment: "" + ) + ) + ] + + case .wordTranslation: + return [ + TutorialStep( + instructions: NSLocalizedString( + "i18n.app.tutorial.word_translation.instructions", + value: "Let's translate! Tap the ⌨ Scribe key on the top-left corner of your keyboard, and select Übersetzen.\n\nThen write the word you want to translate, press ▶, and the translation will be returned to you.", + comment: "" + ), + languageNote: languageNote, + expectedInput: nil, + incorrectFeedback: "" + ) + ] + + case .verbConjugation: + return [ + TutorialStep( + instructions: NSLocalizedString( + "i18n.app.tutorial.verb_conjugation.instructions", + value: "On to the verbs. Tap the ⌨ Scribe key on the top-left corner of your keyboard, and select Konjugieren.\n\nWrite the verb you want to conjugate, press ▶, and you will see a table with all the verb tenses. Select the one you need and it will be inserted!", + comment: "" + ), + languageNote: languageNote, + expectedInput: nil, + incorrectFeedback: "" + ) + ] + + case .nounPlurals: + return [ + TutorialStep( + instructions: NSLocalizedString( + "i18n.app.tutorial.noun_plurals.instructions", + value: "Finding the plural of a noun with Scribe is easy. Tap the ⌨ Scribe key on the top-left corner of your keyboard, and select Plural.\n\nThen write the noun you want the plural for, press ▶, and the plural will be returned to you.", + comment: "" + ), + languageNote: languageNote, + expectedInput: nil, + incorrectFeedback: "" + ) + ] + } + } +} diff --git a/Scribe/Tutorial/TutorialChapterView.swift b/Scribe/Tutorial/TutorialChapterView.swift new file mode 100644 index 00000000..349fc280 --- /dev/null +++ b/Scribe/Tutorial/TutorialChapterView.swift @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +/** + * Displays a single tutorial chapter with multi-step support and interactive text field. + * Handles correct/incorrect feedback states as per the Figma designs. + * Supports full-tutorial sequential navigation through allChapters. + * Shows "Non-Scribe keyboard" warning if a non-Scribe keyboard is active. + * + * Nav bar rules (matching Figma): + * - idle state: "< Quick tutorial" (leading only, no xmark) + * - feedback state: "<" bare chevron (leading) + xmark (trailing) + * - wrong keyboard: "<" bare chevron (leading) + xmark (trailing) + */ + +import SwiftUI + +struct TutorialChapterView: View { + let chapter: TutorialChapter + let allChapters: [TutorialChapter] + var isFullTutorial: Bool = false + + @Environment(\.dismiss) private var dismiss + @State private var stepIndex: Int = 0 + @State private var inputText: String = "" + @State private var feedbackState: FeedbackState = .idle + @State private var navigateToNext = false + @State private var nextChapter: TutorialChapter? = nil + @State private var showWrongKeyboard = false + + enum FeedbackState { case idle, correct, incorrect } + + private var currentStep: TutorialStep { chapter.steps[stepIndex] } + private var isLastStep: Bool { stepIndex == chapter.steps.count - 1 } + private var isLastChapter: Bool { chapter == allChapters.last } + + /// True when the user has typed something (correct or incorrect) — drives nav bar style. + private var hasFeedback: Bool { feedbackState != .idle } + + private func nextChapterInSequence() -> TutorialChapter? { + guard let idx = allChapters.firstIndex(of: chapter), idx + 1 < allChapters.count else { + return nil + } + return allChapters[idx + 1] + } + + var body: some View { + ZStack { + Color("scribeAppBackground").ignoresSafeArea() + + if showWrongKeyboard { + // Non-Scribe keyboard warning screen + VStack(alignment: .leading, spacing: 12) { + Spacer() + Text(NSLocalizedString( + "i18n.app.tutorial.wrong_keyboard.title", + value: "Non-Scribe keyboard", + comment: "" + )) + .font(.title.bold()) + .foregroundColor(.primary) + .padding(.horizontal, 16) + + Text(NSLocalizedString( + "i18n.app.tutorial.wrong_keyboard.body", + value: "Press the 🌐 button to select a Scribe keyboard.", + comment: "" + )) + .font(.body) + .foregroundColor(.primary) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemBackground)) + .cornerRadius(10) + .padding(.horizontal, 16) + + Spacer() + } + } else { + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 12) { + // Title + Text(chapter.title) + .font(.title.bold()) + .foregroundColor(.primary) + + // Instructions + Text(currentStep.instructions) + .font(.body) + .foregroundColor(.primary) + + // Language note + if let note = currentStep.languageNote { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "globe") + .foregroundColor(.secondary) + .font(.footnote) + .padding(.top, 2) + Text(note) + .font(.footnote) + .foregroundColor(.secondary) + } + } + + // Interactive text field — only for steps with expected input + if currentStep.expectedInput != nil { + VStack(alignment: .leading, spacing: 4) { + TextField("", text: $inputText) + .font(.body) + .padding(.vertical, 4) + .onChange(of: inputText) { newValue in + validateInput(newValue) + } + + Divider() + + if feedbackState == .correct { + Text(NSLocalizedString( + "i18n.app.tutorial.correct_feedback", + value: "Great! Press Next to continue.", + comment: "" + )) + .font(.footnote) + .foregroundColor(.green) + } else if feedbackState == .incorrect { + Text(currentStep.incorrectFeedback) + .font(.footnote) + .foregroundColor(.red) + } + } + } + } + .padding(16) + .background(Color(.systemBackground)) + .cornerRadius(12) + .padding(.horizontal, 16) + .padding(.top, 16) + } + } + + Spacer() + + // Hidden navigation link for next chapter + if let next = nextChapter { + NavigationLink( + destination: TutorialChapterView( + chapter: next, + allChapters: allChapters, + isFullTutorial: true + ), + isActive: $navigateToNext + ) { EmptyView() } + } + + // Next / Finish button + CTAButton( + title: isLastChapter && isLastStep + ? NSLocalizedString("i18n.app.tutorial.finish", value: "Finish tutorial", comment: "") + : NSLocalizedString("i18n._global.next", value: "Next", comment: ""), + action: { handleNext() } + ) + .padding(.horizontal, 16) + .padding(.bottom, 32) + } + } + } + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { dismiss() } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + Text(NSLocalizedString( + "i18n.app.tutorial.title", value: "Quick tutorial", comment: "" + )) + .opacity(hasFeedback || showWrongKeyboard ? 0 : 1) + } + } + } + } + .overlay(alignment: .topTrailing) { + if hasFeedback || showWrongKeyboard { + Button { dismiss() } label: { + Image(systemName: "xmark") + .foregroundColor(.primary) + .padding(.trailing, 16) + .padding(.top, 12) + } + } + } + .onAppear { + checkKeyboard() + } + } + + // MARK: - Helpers + + private func checkKeyboard() { + // Show wrong-keyboard screen if no Scribe keyboard is active. + // We reuse the detector: if installed list is non-empty but none are Scribe, warn. + let installed = TutorialKeyboardDetector.installedScribeLanguages() + showWrongKeyboard = !installed.isEmpty && !TutorialKeyboardDetector.hasNounAnnotationKeyboard() + && installed.allSatisfy { $0.lowercased() == "english" } + } + + private func validateInput(_ text: String) { + guard let expected = currentStep.expectedInput else { return } + let trimmed = text.trimmingCharacters(in: .whitespaces) + if trimmed.lowercased() == expected.lowercased() { + feedbackState = .correct + } else if trimmed.isEmpty { + feedbackState = .idle + } else { + feedbackState = .incorrect + } + } + + private func handleNext() { + if !isLastStep { + stepIndex += 1 + inputText = "" + feedbackState = .idle + } else if isFullTutorial, let next = nextChapterInSequence() { + nextChapter = next + navigateToNext = true + } else { + dismiss() + } + } +} diff --git a/Scribe/Tutorial/TutorialKeyboardDetector.swift b/Scribe/Tutorial/TutorialKeyboardDetector.swift new file mode 100644 index 00000000..6d289e54 --- /dev/null +++ b/Scribe/Tutorial/TutorialKeyboardDetector.swift @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +/** + * Detects which Scribe keyboards are installed to determine + * which tutorial chapters to show. + * Per mentor guidance: if only English keyboard is installed, + * hide the noun annotation chapter (English has no gender annotations). + */ + +import Foundation + +enum TutorialKeyboardDetector { + /// Languages that support noun-gender annotations. + private static let genderAnnotationLanguages: Set = [ + "German", "Spanish", "French", "Portuguese", "Russian", + "Italian", "Swedish", "Norwegian", "Danish", "Hebrew", "Indonesian" + ] + + /// Returns the list of installed Scribe keyboard language names. + static func installedScribeLanguages() -> [String] { + guard let appBundleIdentifier = Bundle.main.bundleIdentifier, + let keyboards = UserDefaults.standard.dictionaryRepresentation()["AppleKeyboards"] as? [String] + else { return [] } + + let prefix = appBundleIdentifier + "." + return keyboards + .filter { $0.hasPrefix(prefix) } + .map { $0.replacingOccurrences(of: prefix, with: "").capitalized } + } + + /// Returns true if at least one installed Scribe keyboard supports noun-gender annotations. + static func hasNounAnnotationKeyboard() -> Bool { + let installed = installedScribeLanguages() + // If no Scribe keyboards installed, show all chapters (user may install later). + if installed.isEmpty { return true } + return installed.contains { genderAnnotationLanguages.contains($0) } + } + + /// Returns the tutorial chapters to display based on installed keyboards. + static func availableChapters() -> [TutorialChapter] { + if hasNounAnnotationKeyboard() { + return TutorialChapter.allCases + } else { + // English-only: hide noun annotation chapter. + return TutorialChapter.allCases.filter { $0 != .nounAnnotation } + } + } +} diff --git a/Scribe/Tutorial/TutorialStep.swift b/Scribe/Tutorial/TutorialStep.swift new file mode 100644 index 00000000..fc43eaaf --- /dev/null +++ b/Scribe/Tutorial/TutorialStep.swift @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +struct TutorialStep { + let instructions: String + let languageNote: String? + let expectedInput: String? + let incorrectFeedback: String +} diff --git a/Scribe/Tutorial/TutorialTipCard.swift b/Scribe/Tutorial/TutorialTipCard.swift new file mode 100644 index 00000000..a0561ffb --- /dev/null +++ b/Scribe/Tutorial/TutorialTipCard.swift @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +/// The tip card shown at the top of the tutorial entry screen (Tutorial - Light - 0.0). +struct TutorialTipCard: View { + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "lightbulb.fill") + .foregroundColor(Color.scribeCTA) + .font(.body) + .padding(.top, 2) + + Text(NSLocalizedString( + "i18n.app.tutorial.tip", + value: "Make sure you select the desired Scribe keyboard by pressing 🌐 when typing.", + comment: "" + )) + .font(.footnote) + .foregroundColor(.primary) + + Spacer() + + Button { + // dismiss tip + } label: { + Text(NSLocalizedString("i18n._global.ok", value: "OK", comment: "")) + .font(.footnote.bold()) + .foregroundColor(Color.scribeCTA) + } + } + .padding(12) + .background(Color(.systemBackground)) + .cornerRadius(10) + } +} diff --git a/Scribe/Tutorial/TutorialView.swift b/Scribe/Tutorial/TutorialView.swift new file mode 100644 index 00000000..17095f3f --- /dev/null +++ b/Scribe/Tutorial/TutorialView.swift @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +/** + * Entry point for the Quick Tutorial flow (Tutorial - Light - 0.0). + * Shows chapter list and "Start full tutorial" button. + * Hides noun annotation chapter if only English keyboard is installed. + */ + +import SwiftUI + +struct TutorialView: View { + @Environment(\.dismiss) private var dismiss + @State private var startFull = false + @State private var selectedChapter: TutorialChapter? = nil + + private let chapters = TutorialKeyboardDetector.availableChapters() + + var body: some View { + NavigationView { + ZStack { + Color("scribeAppBackground").ignoresSafeArea() + + VStack(alignment: .leading, spacing: 0) { + // Tip card + TutorialTipCard() + .padding(.horizontal, 16) + .padding(.top, 16) + + // Description card + Text(NSLocalizedString( + "i18n.app.tutorial.description", + value: "This quick tutorial will show you how to use Scribe to support writing in your target language.", + comment: "" + )) + .font(.footnote) + .foregroundColor(.primary) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemBackground)) + .cornerRadius(10) + .padding(.horizontal, 16) + .padding(.top, 8) + + // Chapter list heading + Text(NSLocalizedString( + "i18n.app.tutorial.chapters", value: "Tutorial chapters", comment: "" + )) + .font(.headline) + .foregroundColor(.primary) + .padding(.horizontal, 16) + .padding(.top, 20) + .padding(.bottom, 8) + + // Chapter rows + VStack(spacing: 0) { + ForEach(chapters) { chapter in + NavigationLink( + destination: TutorialChapterView( + chapter: chapter, + allChapters: chapters, + isFullTutorial: false + ) + ) { + HStack { + Text(chapter.title) + .foregroundColor(.primary) + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + .font(.caption) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + if chapter != chapters.last { + Divider().padding(.leading, 16) + } + } + } + .background(Color(.systemBackground)) + .cornerRadius(12) + .padding(.horizontal, 16) + + Spacer() + + // Start full tutorial + if let first = chapters.first { + NavigationLink( + destination: TutorialChapterView( + chapter: first, + allChapters: chapters, + isFullTutorial: true + ), + isActive: $startFull + ) { EmptyView() } + } + + CTAButton( + title: NSLocalizedString( + "i18n.app.tutorial.start_full", value: "Start full tutorial", comment: "" + ), + action: { startFull = true } + ) + .padding(.horizontal, 16) + .padding(.bottom, 32) + } + } + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { dismiss() } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + Text(NSLocalizedString( + "i18n.app.about.title", value: "About", comment: "" + )) + } + } + } + } + } + } +} From 480ee906408a8676f3fef3b78771cbfa7081c14f Mon Sep 17 00:00:00 2001 From: Prince Yadav <66916296+prince-0408@users.noreply.github.com> Date: Fri, 17 Apr 2026 02:47:09 +0530 Subject: [PATCH 2/4] chore: update i18n submodule with tutorial localization keys --- Scribe/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Scribe/i18n b/Scribe/i18n index ef12a2bb..de6c5564 160000 --- a/Scribe/i18n +++ b/Scribe/i18n @@ -1 +1 @@ -Subproject commit ef12a2bba6119f81f08001fb4f1adacc96c25d6c +Subproject commit de6c5564978020b75c7afa1597f4058544078463 From 264ae230d70d718acd9d681c2e7ac2b5c8b8fa20 Mon Sep 17 00:00:00 2001 From: Prince Yadav <66916296+prince-0408@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:28:25 +0530 Subject: [PATCH 3/4] fix: remove explicit nil initialization for SwiftLint compliance --- Scribe/Tutorial/TutorialChapterView.swift | 2 +- Scribe/Tutorial/TutorialView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Scribe/Tutorial/TutorialChapterView.swift b/Scribe/Tutorial/TutorialChapterView.swift index 349fc280..cfcd5f4f 100644 --- a/Scribe/Tutorial/TutorialChapterView.swift +++ b/Scribe/Tutorial/TutorialChapterView.swift @@ -24,7 +24,7 @@ struct TutorialChapterView: View { @State private var inputText: String = "" @State private var feedbackState: FeedbackState = .idle @State private var navigateToNext = false - @State private var nextChapter: TutorialChapter? = nil + @State private var nextChapter: TutorialChapter? @State private var showWrongKeyboard = false enum FeedbackState { case idle, correct, incorrect } diff --git a/Scribe/Tutorial/TutorialView.swift b/Scribe/Tutorial/TutorialView.swift index 17095f3f..841bd930 100644 --- a/Scribe/Tutorial/TutorialView.swift +++ b/Scribe/Tutorial/TutorialView.swift @@ -11,7 +11,7 @@ import SwiftUI struct TutorialView: View { @Environment(\.dismiss) private var dismiss @State private var startFull = false - @State private var selectedChapter: TutorialChapter? = nil + @State private var selectedChapter: TutorialChapter? private let chapters = TutorialKeyboardDetector.availableChapters() From 8a2aaee19df13e84ad1987e188bfd9622eabf0ea Mon Sep 17 00:00:00 2001 From: Prince Yadav <66916296+prince-0408@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:32:51 +0530 Subject: [PATCH 4/4] Revert "chore: update i18n submodule with tutorial localization keys" This reverts commit 480ee906408a8676f3fef3b78771cbfa7081c14f. --- Scribe/i18n | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Scribe/i18n b/Scribe/i18n index de6c5564..ef12a2bb 160000 --- a/Scribe/i18n +++ b/Scribe/i18n @@ -1 +1 @@ -Subproject commit de6c5564978020b75c7afa1597f4058544078463 +Subproject commit ef12a2bba6119f81f08001fb4f1adacc96c25d6c