diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 346df55..57b34e0 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -261,6 +261,23 @@ } } }, + "Copy email address" : { + "comment" : "Button that copies the feedback email address when Mail is unavailable.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "E-Mail-Adresse kopieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy email address" + } + } + } + }, "Could not create the project. Please try again." : { "localizations" : { "de" : { @@ -512,13 +529,19 @@ } } }, - "Text row highlight opacity" : { - "comment" : "A heading for the opacity of the row highlight for text-imported projects on the Reading Mode settings page.", + "Feedback email body %@ %@ %@" : { + "comment" : "Prefilled feedback email body. The first placeholder is the app version and build, the second is the iOS version, and the third is the device model.", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Deckkraft der Text-Reihenmarkierung" + "value" : "Hallo,\n\nich hätte folgenden Wunsch für LoopLine:\n\n[Bitte beschreibe deine Idee.]\n\n---\nApp-Version: %1$@\niOS-Version: %2$@\nGerät: %3$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hello,\n\nI have the following suggestion for LoopLine:\n\n[Please describe your idea.]\n\n---\nApp version: %1$@\niOS version: %2$@\nDevice: %3$@" } } } @@ -649,6 +672,17 @@ } } }, + "LoopLine – Product Improvement" : { + "comment" : "Subject line for an email suggesting an improvement to LoopLine.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "LoopLine – Produktverbesserung" + } + } + } + }, "Low" : { "comment" : "A label for the lowest value of a slider.", "isCommentAutoGenerated" : true, @@ -661,6 +695,23 @@ } } }, + "Mail is unavailable" : { + "comment" : "Alert title shown when the device cannot compose email.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mail ist nicht verfügbar" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mail is unavailable" + } + } + } + }, "Markup" : { "comment" : "A button that toggles markup on and off.", "isCommentAutoGenerated" : true, @@ -1011,6 +1062,24 @@ } } }, + "Privacy Policy" : { + "comment" : "A button that opens a privacy policy.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datenschutzrichtlinie" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy Policy" + } + } + } + }, "Progress" : { "comment" : "Label for the progress value in the project detail view.", "isCommentAutoGenerated" : true, @@ -1367,6 +1436,23 @@ } } }, + "Set up Mail to send feedback, or email us at %@." : { + "comment" : "Message shown when the device cannot compose email. The placeholder is the feedback email address.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Richte Mail ein, um Feedback zu senden, oder schreibe uns an %1$@." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Set up Mail to send feedback, or email us at %@." + } + } + } + }, "Sets this as the current row" : { "comment" : "A hint that describes the action of tapping a reading row to set it as the current row.", "isCommentAutoGenerated" : true, @@ -1451,6 +1537,23 @@ } } }, + "Suggest an improvement" : { + "comment" : "Button that opens a prefilled email for suggesting an app improvement.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbesserung vorschlagen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suggest an improvement" + } + } + } + }, "Text" : { "comment" : "The text label for a text-based source.", "isCommentAutoGenerated" : true, @@ -1475,6 +1578,17 @@ } } }, + "Text row highlight opacity" : { + "comment" : "A heading for the opacity of the row highlight for text-imported projects on the Reading Mode settings page.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deckkraft der Text-Reihenmarkierung" + } + } + } + }, "The imported image could not be found." : { "comment" : "A description of the error that occurs when the image is missing.", "isCommentAutoGenerated" : true, diff --git a/LoopLine/Models/FeedbackConfiguration.swift b/LoopLine/Models/FeedbackConfiguration.swift new file mode 100644 index 0000000..9a48069 --- /dev/null +++ b/LoopLine/Models/FeedbackConfiguration.swift @@ -0,0 +1,6 @@ +import Foundation + +enum FeedbackConfiguration { + static let privacyPolicyURL = URL(string: "https://dominikmoreira.github.io/LoopLine/") + static let feedbackEmailAddress = "dev@jodoko.de" +} diff --git a/LoopLine/ViewModels/SettingsViewModel.swift b/LoopLine/ViewModels/SettingsViewModel.swift index 16ae6bb..bbc29d7 100644 --- a/LoopLine/ViewModels/SettingsViewModel.swift +++ b/LoopLine/ViewModels/SettingsViewModel.swift @@ -1,6 +1,7 @@ import Foundation import Observation import SwiftData +import UIKit @MainActor @Observable @@ -29,6 +30,13 @@ final class SettingsViewModel { } } + var feedbackMailBody: String { + String( + localized: "Feedback email body \(appVersion) \(UIDevice.current.systemVersion) \(UIDevice.current.model)", + comment: "Prefilled feedback email body. The first placeholder is the app version and build, the second is the iOS version, and the third is the device model." + ) + } + private func save(_ modelContext: ModelContext) { try? modelContext.save() } diff --git a/LoopLine/Views/FeedbackMailComposer.swift b/LoopLine/Views/FeedbackMailComposer.swift new file mode 100644 index 0000000..e74c09c --- /dev/null +++ b/LoopLine/Views/FeedbackMailComposer.swift @@ -0,0 +1,40 @@ +import MessageUI +import SwiftUI + +struct FeedbackMailComposer: UIViewControllerRepresentable { + @Binding var isPresented: Bool + let recipient: String + let subject: String + let messageBody: String + + func makeUIViewController(context: Context) -> MFMailComposeViewController { + let composer = MFMailComposeViewController() + composer.mailComposeDelegate = context.coordinator + composer.setToRecipients([recipient]) + composer.setSubject(subject) + composer.setMessageBody(messageBody, isHTML: false) + return composer + } + + func updateUIViewController(_ uiViewController: MFMailComposeViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(isPresented: $isPresented) + } + + final class Coordinator: NSObject, MFMailComposeViewControllerDelegate { + private var isPresented: Binding + + init(isPresented: Binding) { + self.isPresented = isPresented + } + + func mailComposeController( + _ controller: MFMailComposeViewController, + didFinishWith result: MFMailComposeResult, + error: Error? + ) { + isPresented.wrappedValue = false + } + } +} diff --git a/LoopLine/Views/SettingsView.swift b/LoopLine/Views/SettingsView.swift index 940a34f..9219989 100644 --- a/LoopLine/Views/SettingsView.swift +++ b/LoopLine/Views/SettingsView.swift @@ -1,6 +1,8 @@ import Observation +import MessageUI import SwiftData import SwiftUI +import UIKit struct SettingsView: View { @Environment(\.modelContext) private var modelContext @@ -28,8 +30,11 @@ struct SettingsView: View { private struct SettingsForm: View { @Environment(\.modelContext) private var modelContext + @Environment(\.openURL) private var openURL @Bindable var settings: AppSettings let viewModel: SettingsViewModel + @State private var isShowingMailComposer = false + @State private var isShowingMailUnavailableAlert = false var body: some View { ScrollView { @@ -42,6 +47,25 @@ private struct SettingsForm: View { .padding(.bottom, 32) } .background(LoopLineTheme.appBackground.ignoresSafeArea()) + .sheet(isPresented: $isShowingMailComposer) { + FeedbackMailComposer( + isPresented: $isShowingMailComposer, + recipient: FeedbackConfiguration.feedbackEmailAddress, + subject: String( + localized: "LoopLine – Product Improvement", + comment: "Subject line for an email suggesting an improvement to LoopLine." + ), + messageBody: viewModel.feedbackMailBody + ) + } + .alert("Mail is unavailable", isPresented: $isShowingMailUnavailableAlert) { + Button("Copy email address") { + UIPasteboard.general.string = FeedbackConfiguration.feedbackEmailAddress + } + Button("OK", role: .cancel) {} + } message: { + Text("Set up Mail to send feedback, or email us at \(FeedbackConfiguration.feedbackEmailAddress).") + } } private var readingModeSection: some View { @@ -114,6 +138,26 @@ private struct SettingsForm: View { Text("Version \(viewModel.appVersion)") .font(.subheadline) .foregroundStyle(LoopLineTheme.secondaryText) + + Divider() + .padding(.leading, 16) + + SettingsNavigationRow(title: "Privacy Policy") { + if let privacyPolicyURL = FeedbackConfiguration.privacyPolicyURL { + openURL(privacyPolicyURL) + } + } + + Divider() + .padding(.leading, 16) + + SettingsNavigationRow(title: "Suggest an improvement") { + if MFMailComposeViewController.canSendMail() { + isShowingMailComposer = true + } else { + isShowingMailUnavailableAlert = true + } + } } .frame(maxWidth: .infinity, alignment: .leading) .padding(16) @@ -147,6 +191,30 @@ private struct SettingsToggleRow: View { } } +private struct SettingsNavigationRow: View { + let title: LocalizedStringResource + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + Text(title) + .font(.body) + .foregroundStyle(LoopLineTheme.primaryText) + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(LoopLineTheme.secondaryText) + } + .frame(maxWidth: .infinity, minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + #Preview { let container = PreviewModelContainer.make() container.mainContext.insert(AppSettings()) diff --git a/docs/index.html b/docs/index.html index 3219ddd..352266d 100644 --- a/docs/index.html +++ b/docs/index.html @@ -8,21 +8,24 @@