diff --git a/CLAUDE.md b/CLAUDE.md index d900a23..fc3cc0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,7 @@ swiftformat . ### MVVM with Observable Pattern The app uses iOS 17's `@Observable` macro for state management with clean separation between: -- **Views**: SwiftUI views (99% SwiftUI, UIKit only for mail view) +- **Views**: SwiftUI views - **ViewModels**: Observable state containers that bridge views and data - **Models**: Domain objects and data structures - **Repositories**: Data access layer implementing CRUD operations diff --git a/NativeAppTemplate.xcodeproj/project.pbxproj b/NativeAppTemplate.xcodeproj/project.pbxproj index e77cf83..935f07a 100644 --- a/NativeAppTemplate.xcodeproj/project.pbxproj +++ b/NativeAppTemplate.xcodeproj/project.pbxproj @@ -156,7 +156,6 @@ 01E0A5B725BD0FCD00298D35 /* OfflineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01E0A5B225BD0FC700298D35 /* OfflineView.swift */; }; 01E0A5B825BD0FCD00298D35 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01E0A5B325BD0FC700298D35 /* ErrorView.swift */; }; 01E0A60125BD149200298D35 /* MainButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01E0A5F925BD148800298D35 /* MainButtonView.swift */; }; - A1B2C3D401000001 /* GlassButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D401000002 /* GlassButtonStyle.swift */; }; A1B2C3D401000003 /* GlassCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D401000004 /* GlassCard.swift */; }; 01E0A60C25BD440300298D35 /* SignInEmailAndPasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01E0A60B25BD440300298D35 /* SignInEmailAndPasswordView.swift */; }; 01E0A63025BD53FD00298D35 /* Shop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01E0A62F25BD53FD00298D35 /* Shop.swift */; }; @@ -166,7 +165,6 @@ 01E727212B020ECC004AC043 /* Bundle+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01E727202B020ECC004AC043 /* Bundle+Extensions.swift */; }; 01ED197B2A037B9E00CD4735 /* AppTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01ED197A2A037B9E00CD4735 /* AppTabView.swift */; }; 01EE363E29A6DCEB009BCD9D /* ShopkeeperEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01EE363D29A6DCEB009BCD9D /* ShopkeeperEditView.swift */; }; - 01FA23A12B00CE5700F1D446 /* MailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01FA23A02B00CE5700F1D446 /* MailView.swift */; }; 01FC03E22B3329B700E6CD8E /* NeedAppUpdatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01FC03E12B3329B700E6CD8E /* NeedAppUpdatesView.swift */; }; 7249A60C06FE44338E16BC50 /* CertificatePinningDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C597C0551370446BB931F19B /* CertificatePinningDelegate.swift */; }; /* End PBXBuildFile section */ @@ -332,7 +330,6 @@ 01E0A5B325BD0FC700298D35 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; 01E0A5B525BD0FC700298D35 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; 01E0A5F925BD148800298D35 /* MainButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainButtonView.swift; sourceTree = ""; }; - A1B2C3D401000002 /* GlassButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassButtonStyle.swift; sourceTree = ""; }; A1B2C3D401000004 /* GlassCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassCard.swift; sourceTree = ""; }; 01E0A60B25BD440300298D35 /* SignInEmailAndPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInEmailAndPasswordView.swift; sourceTree = ""; }; 01E0A62F25BD53FD00298D35 /* Shop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shop.swift; sourceTree = ""; }; @@ -342,7 +339,6 @@ 01E727202B020ECC004AC043 /* Bundle+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Extensions.swift"; sourceTree = ""; }; 01ED197A2A037B9E00CD4735 /* AppTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTabView.swift; sourceTree = ""; }; 01EE363D29A6DCEB009BCD9D /* ShopkeeperEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopkeeperEditView.swift; sourceTree = ""; }; - 01FA23A02B00CE5700F1D446 /* MailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailView.swift; sourceTree = ""; }; 01FC03E12B3329B700E6CD8E /* NeedAppUpdatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NeedAppUpdatesView.swift; sourceTree = ""; }; C597C0551370446BB931F19B /* CertificatePinningDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificatePinningDelegate.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -492,14 +488,6 @@ path = "Shop List"; sourceTree = ""; }; - 016EF40325E1E6630038195C /* UIKit */ = { - isa = PBXGroup; - children = ( - 01FA23A02B00CE5700F1D446 /* MailView.swift */, - ); - path = UIKit; - sourceTree = ""; - }; 0172030625A9642D008FD63B /* Networking */ = { isa = PBXGroup; children = ( @@ -674,7 +662,6 @@ 01011B542864434900B70D04 /* Shop Detail */, 0158B9F525C16366008EC9D5 /* Shop List */, 01467355299901E50005423D /* Shop Settings */, - 016EF40325E1E6630038195C /* UIKit */, ); path = UI; sourceTree = ""; @@ -832,7 +819,6 @@ isa = PBXGroup; children = ( 0172788A2D7D936E00CE424F /* Tags */, - A1B2C3D401000002 /* GlassButtonStyle.swift */, A1B2C3D401000004 /* GlassCard.swift */, 01E0A5F925BD148800298D35 /* MainButtonView.swift */, ); @@ -1017,7 +1003,6 @@ 017278832D7D935700CE424F /* ImageSaver.swift in Sources */, 01D8AE8B2AB453C1009AFFBA /* ShopBasicSettingsView.swift in Sources */, 01E0A60125BD149200298D35 /* MainButtonView.swift in Sources */, - A1B2C3D401000001 /* GlassButtonStyle.swift in Sources */, A1B2C3D401000003 /* GlassCard.swift in Sources */, 0182D39A25B4424B001E881D /* LoggedInShopkeeperKeychainStore.swift in Sources */, 01ED197B2A037B9E00CD4735 /* AppTabView.swift in Sources */, @@ -1070,7 +1055,6 @@ 0172787B2D7D903500CE424F /* ItemTagAdapter.swift in Sources */, 010F86AE2621A2A900B6C62A /* ShopDetailView.swift in Sources */, 011F6DF1259EF16400BED22E /* App.swift in Sources */, - 01FA23A12B00CE5700F1D446 /* MailView.swift in Sources */, 017278092D7D4F7400CE424F /* Onboarding.swift in Sources */, 01467357299902230005423D /* ShopSettingsView.swift in Sources */, 017278792D7D900100CE424F /* ItemTagsRequest.swift in Sources */, diff --git a/NativeAppTemplate/Constants.swift b/NativeAppTemplate/Constants.swift index 9bf19a0..b898d3e 100644 --- a/NativeAppTemplate/Constants.swift +++ b/NativeAppTemplate/Constants.swift @@ -215,7 +215,6 @@ extension String { static let supportWebsiteUrl: String = "https://nativeapptemplate.com" static let howToUseUrl: String = "https://myturntag.com/how" static let faqsUrl: String = "https://nativeapptemplate.com/faqs" - static let discussionsUrl: String = "https://github.com/nativeapptemplate/NativeAppTemplate-Free-iOS/discussions" static let privacyPolicyUrl: String = "https://nativeapptemplate.com/privacy" static let termsOfUseUrl: String = "https://nativeapptemplate.com/terms" @@ -225,9 +224,7 @@ extension String { static let supportWebsite = "Support Website" static let howToUse = "How To Use" static let faqs = "FAQs" - static let discussions = "Discussions" static let rateApp = "Rate or Review the App" - static let emailUs = "Email Us" static let contact = "Contact" static let privacyPolicy = "Privacy Policy" static let termsOfUse = "Terms of Use" diff --git a/NativeAppTemplate/UI/Settings/SettingsView.swift b/NativeAppTemplate/UI/Settings/SettingsView.swift index 361bf80..4fc6c13 100644 --- a/NativeAppTemplate/UI/Settings/SettingsView.swift +++ b/NativeAppTemplate/UI/Settings/SettingsView.swift @@ -3,13 +3,14 @@ // NativeAppTemplate // -import MessageUI +import StoreKit import SwiftUI struct SettingsView: View { @Environment(DataManager.self) private var dataManager @Environment(MessageBus.self) private var messageBus @Environment(TabViewModel.self) private var tabViewModel + @Environment(\.requestReview) private var requestReview @State private var viewModel: SettingsViewModel init( @@ -64,18 +65,13 @@ struct SettingsView: View { Label(String.faqs, systemImage: "questionmark") } - Link(destination: URL(string: String.discussionsUrl)!) { - Label(String.discussions, systemImage: "bubble.left.and.bubble.right") + Link(destination: supportEmailURL) { + Label(String.contact, systemImage: "envelope") } Button { - MFMailComposeViewController.canSendMail() ? viewModel.isShowingMailView.toggle() : viewModel - .alertNoMail.toggle() + requestReview() } label: { - Label(String.contact, systemImage: "envelope") - } - - Link(destination: URL(string: "\(String.appStoreUrl)?action=write-review")!) { Label(String.rateApp, systemImage: "hand.thumbsup") } @@ -111,22 +107,29 @@ struct SettingsView: View { } .navigationTitle(String.settings) .navigationBarTitleDisplayMode(.inline) - .sheet(isPresented: $viewModel.isShowingMailView) { - let systemVersion = UIDevice.current.systemVersion - let device = Utility.deviceModel - - MailView( - result: $viewModel.result, - recipients: [String.supportMail], - subject: "\(Bundle.main.displayName) for iPhone support", - messageBody: "\n\n\n-----\n\(Bundle.main.displayName) " + - "\(Bundle.main.appVersionLong)\n\(device) " + - "(\(systemVersion))\n\(Locale.preferredLanguages[0])" - ) - } - .alert( - "NO MAIL SETUP", - isPresented: $viewModel.alertNoMail - ) {} + } + + var supportEmailURL: URL { + let appName = Bundle.main.displayName + let appVersion = "\(Bundle.main.appVersionLong)" + let device = Utility.deviceModel + let systemVersion = UIDevice.current.systemVersion + let locale = Locale.current + let region = locale.region?.identifier ?? "Unknown" + let language = locale.language.languageCode?.identifier ?? "Unknown" + + let body = """ + + + --- + App: \(appName) \(appVersion) + Device: \(device) + iOS: \(systemVersion) + Region: \(region) + Locale: \(language)-\(region) + """ + + let encodedBody = body.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + return URL(string: "mailto:\(String.supportMail)?body=\(encodedBody)")! } } diff --git a/NativeAppTemplate/UI/Settings/SettingsViewModel.swift b/NativeAppTemplate/UI/Settings/SettingsViewModel.swift index f027829..31424aa 100644 --- a/NativeAppTemplate/UI/Settings/SettingsViewModel.swift +++ b/NativeAppTemplate/UI/Settings/SettingsViewModel.swift @@ -3,16 +3,11 @@ // NativeAppTemplate // -import MessageUI import Observation -import SwiftUI @Observable @MainActor final class SettingsViewModel { - var isShowingMailView = false - var alertNoMail = false - var result: Result? private(set) var messageBus: MessageBus private let sessionController: SessionControllerProtocol diff --git a/NativeAppTemplate/UI/Shared/GlassButtonStyle.swift b/NativeAppTemplate/UI/Shared/GlassButtonStyle.swift deleted file mode 100644 index 88d5da6..0000000 --- a/NativeAppTemplate/UI/Shared/GlassButtonStyle.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// GlassButtonStyle.swift -// NativeAppTemplate -// - -import SwiftUI - -struct PrimaryGlassButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .font(.uiButtonLabelLarge) - .foregroundStyle(.glassForeground) - .padding(.vertical, NativeAppTemplateConstants.Spacing.sm) - .padding(.horizontal, NativeAppTemplateConstants.Spacing.md) - .frame(maxWidth: .infinity) - .background( - LinearGradient( - colors: [Color.accent, Color.accent.opacity(0.8)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .clipShape(RoundedRectangle(cornerRadius: NativeAppTemplateConstants.CornerRadius.sm)) - .shadow( - color: Color.accent.opacity(NativeAppTemplateConstants.Glass.shadowOpacity), - radius: NativeAppTemplateConstants.Layout.shadowRadius - ) - .scaleEffect(configuration.isPressed ? 0.97 : 1.0) - .animation(.easeInOut(duration: NativeAppTemplateConstants.Animation.fast), value: configuration.isPressed) - } -} - -struct SecondaryGlassButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .font(.uiButtonLabelLarge) - .foregroundStyle(.accent) - .padding(.vertical, NativeAppTemplateConstants.Spacing.sm) - .padding(.horizontal, NativeAppTemplateConstants.Spacing.md) - .frame(maxWidth: .infinity) - .background(.ultraThinMaterial) - .clipShape(RoundedRectangle(cornerRadius: NativeAppTemplateConstants.CornerRadius.sm)) - .overlay( - RoundedRectangle(cornerRadius: NativeAppTemplateConstants.CornerRadius.sm) - .stroke(Color.accent, lineWidth: NativeAppTemplateConstants.Layout.borderWidth) - ) - .scaleEffect(configuration.isPressed ? 0.97 : 1.0) - .animation(.easeInOut(duration: NativeAppTemplateConstants.Animation.fast), value: configuration.isPressed) - } -} diff --git a/NativeAppTemplate/UI/UIKit/MailView.swift b/NativeAppTemplate/UI/UIKit/MailView.swift deleted file mode 100644 index 200ed74..0000000 --- a/NativeAppTemplate/UI/UIKit/MailView.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// MailView.swift -// NativeAppTemplate -// - -import AVFoundation -import Foundation -import MessageUI -import SwiftUI -import UIKit - -struct MailView: UIViewControllerRepresentable { - @Environment(\.presentationMode) var presentation - @Binding var result: Result? - var recipients = [String]() - var subject = "" - var messageBody = "" - var isHTML = false - - class Coordinator: NSObject, MFMailComposeViewControllerDelegate { - @Binding var presentation: PresentationMode - @Binding var result: Result? - - init( - presentation: Binding, - result: Binding?> - ) { - _presentation = presentation - _result = result - } - - func mailComposeController( - _: MFMailComposeViewController, - didFinishWith result: MFMailComposeResult, - error: Error? - ) { - defer { - $presentation.wrappedValue.dismiss() - } - guard error == nil else { - self.result = .failure(error!) - return - } - self.result = .success(result) - - if result == .sent { - AudioServicesPlayAlertSound(SystemSoundID(1_001)) - } - } - } - - func makeCoordinator() -> Coordinator { - Coordinator( - presentation: presentation, - result: $result - ) - } - - func makeUIViewController(context: UIViewControllerRepresentableContext) -> MFMailComposeViewController { - let mfMailComposeViewController = MFMailComposeViewController() - mfMailComposeViewController.setToRecipients(recipients) - mfMailComposeViewController.setSubject(subject) - mfMailComposeViewController.setMessageBody(messageBody, isHTML: isHTML) - mfMailComposeViewController.mailComposeDelegate = context.coordinator - return mfMailComposeViewController - } - - func updateUIViewController( - _: MFMailComposeViewController, - context _: UIViewControllerRepresentableContext - ) {} -} diff --git a/NativeAppTemplateTests/UI/Settings/SettingsViewModelTest.swift b/NativeAppTemplateTests/UI/Settings/SettingsViewModelTest.swift index 8754806..474ea01 100644 --- a/NativeAppTemplateTests/UI/Settings/SettingsViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Settings/SettingsViewModelTest.swift @@ -22,9 +22,6 @@ struct SettingsViewModelTest { messageBus: messageBus ) - #expect(viewModel.isShowingMailView == false) - #expect(viewModel.alertNoMail == false) - #expect(viewModel.result == nil) #expect(viewModel.messageBus === messageBus) } @@ -130,25 +127,6 @@ struct SettingsViewModelTest { #expect(tabViewModel.selectedTab == .shops) } - @Test - func statePropertiesAreObservable() { - let viewModel = SettingsViewModel( - sessionController: sessionController, - tabViewModel: tabViewModel, - messageBus: messageBus - ) - - // Test that properties can be set (indicating they're observable) - viewModel.isShowingMailView = true - #expect(viewModel.isShowingMailView == true) - - viewModel.alertNoMail = true - #expect(viewModel.alertNoMail == true) - - viewModel.result = .success(.sent) - #expect(viewModel.result != nil) - } - @Test func messageBusIsAccessible() { let viewModel = SettingsViewModel(