Commit 48999737a3
Unsigned
Layout: unified · split
DomainDig/ContentView.swift −1143
| @@ -2703,1149 +2703,6 @@ private extension String { | ||
| 2703 | 2703 | } |
| 2704 | 2704 | } |
| 2705 | 2705 | |
| 2706 | struct SettingsView: View { | |
| 2707 | @Environment(\.appDensity) private var appDensity | |
| 2708 | @Bindable var viewModel: DomainViewModel | |
| 2709 | @State private var purchaseService = PurchaseService.shared | |
| 2710 | ||
| 2711 | var body: some View { | |
| 2712 | let _ = purchaseService.currentTier | |
| 2713 | ||
| 2714 | List { | |
| 2715 | Section("Tier") { | |
| 2716 | LabeledContent("Status", value: purchaseService.currentTier.title) | |
| 2717 | ||
| 2718 | if purchaseService.currentTier == .free { | |
| 2719 | Button("Upgrade") { | |
| 2720 | viewModel.isPaywallPresented = true | |
| 2721 | } | |
| 2722 | } else { | |
| 2723 | Button("Manage Subscription") { | |
| 2724 | Task { | |
| 2725 | await purchaseService.manageSubscription() | |
| 2726 | } | |
| 2727 | } | |
| 2728 | } | |
| 2729 | ||
| 2730 | Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") { | |
| 2731 | Task { | |
| 2732 | await purchaseService.restorePurchases() | |
| 2733 | } | |
| 2734 | } | |
| 2735 | .disabled(purchaseService.isRestoring || purchaseService.isPurchasing) | |
| 2736 | ||
| 2737 | if let statusMessage = purchaseService.statusMessage { | |
| 2738 | Text(statusMessage) | |
| 2739 | .font(appDensity.font(.caption, design: .default)) | |
| 2740 | .foregroundStyle(Color(.appTextSecondary)) | |
| 2741 | } | |
| 2742 | ||
| 2743 | if let errorMessage = purchaseService.errorMessage { | |
| 2744 | Text(errorMessage) | |
| 2745 | .font(appDensity.font(.caption, design: .default)) | |
| 2746 | .foregroundStyle(Color(.statusCritical)) | |
| 2747 | } | |
| 2748 | } | |
| 2749 | ||
| 2750 | Section("Preferences") { | |
| 2751 | NavigationLink("Tracked Domains") { | |
| 2752 | WatchlistView(viewModel: viewModel) | |
| 2753 | } | |
| 2754 | ||
| 2755 | NavigationLink("Workflows") { | |
| 2756 | WorkflowsView(viewModel: viewModel) | |
| 2757 | } | |
| 2758 | ||
| 2759 | NavigationLink("Display") { | |
| 2760 | DisplaySettingsView() | |
| 2761 | } | |
| 2762 | ||
| 2763 | NavigationLink("History & Network") { | |
| 2764 | HistoryNetworkSettingsView(viewModel: viewModel) | |
| 2765 | } | |
| 2766 | } | |
| 2767 | ||
| 2768 | Section("Services") { | |
| 2769 | NavigationLink("Monitoring Activity") { | |
| 2770 | MonitoringView(viewModel: viewModel) | |
| 2771 | } | |
| 2772 | ||
| 2773 | NavigationLink("Integrations") { | |
| 2774 | IntegrationsSettingsView() | |
| 2775 | } | |
| 2776 | ||
| 2777 | NavigationLink("Local API") { | |
| 2778 | LocalAPISettingsView() | |
| 2779 | } | |
| 2780 | ||
| 2781 | NavigationLink("iCloud Sync") { | |
| 2782 | CloudSyncSettingsView() | |
| 2783 | } | |
| 2784 | ||
| 2785 | NavigationLink("Monitoring") { | |
| 2786 | MonitoringSettingsView(viewModel: viewModel) | |
| 2787 | } | |
| 2788 | ||
| 2789 | NavigationLink("Scheduled Reports") { | |
| 2790 | ScheduledReportsView() | |
| 2791 | } | |
| 2792 | } | |
| 2793 | ||
| 2794 | Section("Data") { | |
| 2795 | NavigationLink("Import & Export") { | |
| 2796 | DataPortabilitySettingsView(viewModel: viewModel) | |
| 2797 | } | |
| 2798 | ||
| 2799 | NavigationLink("Data Management") { | |
| 2800 | DataManagementSettingsView(viewModel: viewModel) | |
| 2801 | } | |
| 2802 | } | |
| 2803 | ||
| 2804 | Section("About") { | |
| 2805 | NavigationLink("App Info") { | |
| 2806 | AboutSettingsView() | |
| 2807 | } | |
| 2808 | } | |
| 2809 | } | |
| 2810 | .navigationTitle("Settings") | |
| 2811 | } | |
| 2812 | } | |
| 2813 | ||
| 2814 | private struct DisplaySettingsView: View { | |
| 2815 | @AppStorage(AppDensity.userDefaultsKey) private var storedDensity = AppDensity.compact.rawValue | |
| 2816 | @AppStorage(AppAppearance.userDefaultsKey) private var storedAppearance = AppAppearance.system.rawValue | |
| 2817 | ||
| 2818 | var body: some View { | |
| 2819 | Form { | |
| 2820 | Section("Display") { | |
| 2821 | Picker("Appearance", selection: $storedAppearance) { | |
| 2822 | ForEach(AppAppearance.allCases) { appearance in | |
| 2823 | Text(appearance.title).tag(appearance.rawValue) | |
| 2824 | } | |
| 2825 | } | |
| 2826 | ||
| 2827 | Picker("Density", selection: $storedDensity) { | |
| 2828 | ForEach(AppDensity.allCases) { density in | |
| 2829 | Text(density.title).tag(density.rawValue) | |
| 2830 | } | |
| 2831 | } | |
| 2832 | } | |
| 2833 | } | |
| 2834 | .navigationTitle("Display") | |
| 2835 | } | |
| 2836 | } | |
| 2837 | ||
| 2838 | private struct HistoryNetworkSettingsView: View { | |
| 2839 | @Environment(\.appDensity) private var appDensity | |
| 2840 | @Bindable var viewModel: DomainViewModel | |
| 2841 | @AppStorage(DNSResolverOption.userDefaultsKey) private var storedResolverURL = DNSResolverOption.defaultURLString | |
| 2842 | @AppStorage(AppDensity.userDefaultsKey) private var storedDensity = AppDensity.compact.rawValue | |
| 2843 | ||
| 2844 | @State private var resolverOption: DNSResolverOption = .cloudflare | |
| 2845 | @State private var customResolverURL = DNSResolverOption.defaultURLString | |
| 2846 | ||
| 2847 | private var customResolverError: String? { | |
| 2848 | guard resolverOption == .custom else { return nil } | |
| 2849 | return DNSResolverOption.isValidCustomURL(customResolverURL) ? nil : "Resolver URL must start with https://" | |
| 2850 | } | |
| 2851 | ||
| 2852 | var body: some View { | |
| 2853 | Form { | |
| 2854 | Section("History") { | |
| 2855 | Picker( | |
| 2856 | "Auto-prune", | |
| 2857 | selection: Binding( | |
| 2858 | get: { viewModel.historyAutoPruneOption }, | |
| 2859 | set: { viewModel.setHistoryAutoPruneOption($0) } | |
| 2860 | ) | |
| 2861 | ) { | |
| 2862 | ForEach(HistoryAutoPruneOption.allCases) { option in | |
| 2863 | Text(option.title).tag(option) | |
| 2864 | } | |
| 2865 | } | |
| 2866 | ||
| 2867 | Text("History remains local-first. Auto-prune only trims older local snapshots on this device and defaults to unlimited.") | |
| 2868 | .font(appDensity.font(.caption, design: .default)) | |
| 2869 | .foregroundStyle(Color(.appTextSecondary)) | |
| 2870 | } | |
| 2871 | ||
| 2872 | Section("Network") { | |
| 2873 | Picker("Resolver", selection: $resolverOption) { | |
| 2874 | ForEach(DNSResolverOption.allCases) { option in | |
| 2875 | Text(option.title).tag(option) | |
| 2876 | } | |
| 2877 | } | |
| 2878 | ||
| 2879 | if resolverOption == .custom { | |
| 2880 | TextField("https://resolver.example/dns-query", text: $customResolverURL) | |
| 2881 | .textInputAutocapitalization(.never) | |
| 2882 | .autocorrectionDisabled() | |
| 2883 | .keyboardType(.URL) | |
| 2884 | ||
| 2885 | if let customResolverError { | |
| 2886 | Text(customResolverError) | |
| 2887 | .font(appDensity.font(.caption, design: .default)) | |
| 2888 | .foregroundStyle(Color(.statusCritical)) | |
| 2889 | } | |
| 2890 | } | |
| 2891 | } | |
| 2892 | } | |
| 2893 | .navigationTitle("History & Network") | |
| 2894 | .onAppear { | |
| 2895 | let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 2896 | resolverOption = DNSResolverOption.option(for: currentResolverURL) | |
| 2897 | customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString | |
| 2898 | } | |
| 2899 | .onChange(of: resolverOption) { _, newValue in | |
| 2900 | guard let presetURL = newValue.urlString else { | |
| 2901 | storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 2902 | viewModel.persistCurrentAppSettings( | |
| 2903 | resolverURLString: storedResolverURL, | |
| 2904 | appDensityRawValue: storedDensity | |
| 2905 | ) | |
| 2906 | return | |
| 2907 | } | |
| 2908 | storedResolverURL = presetURL | |
| 2909 | viewModel.persistCurrentAppSettings( | |
| 2910 | resolverURLString: storedResolverURL, | |
| 2911 | appDensityRawValue: storedDensity | |
| 2912 | ) | |
| 2913 | } | |
| 2914 | .onChange(of: customResolverURL) { _, newValue in | |
| 2915 | guard resolverOption == .custom else { return } | |
| 2916 | storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 2917 | viewModel.persistCurrentAppSettings( | |
| 2918 | resolverURLString: storedResolverURL, | |
| 2919 | appDensityRawValue: storedDensity | |
| 2920 | ) | |
| 2921 | } | |
| 2922 | .onChange(of: storedDensity) { _, newValue in | |
| 2923 | viewModel.persistCurrentAppSettings( | |
| 2924 | resolverURLString: storedResolverURL, | |
| 2925 | appDensityRawValue: newValue | |
| 2926 | ) | |
| 2927 | } | |
| 2928 | } | |
| 2929 | } | |
| 2930 | ||
| 2931 | private struct CloudSyncSettingsView: View { | |
| 2932 | @Environment(\.appDensity) private var appDensity | |
| 2933 | @State private var cloudSyncService = CloudSyncService.shared | |
| 2934 | ||
| 2935 | var body: some View { | |
| 2936 | Form { | |
| 2937 | Section("iCloud Sync") { | |
| 2938 | Toggle( | |
| 2939 | "Enable iCloud Sync", | |
| 2940 | isOn: Binding( | |
| 2941 | get: { cloudSyncService.isEnabled }, | |
| 2942 | set: { cloudSyncService.setSyncEnabled($0) } | |
| 2943 | ) | |
| 2944 | ) | |
| 2945 | ||
| 2946 | LabeledContent("Status", value: cloudSyncService.status.title) | |
| 2947 | LabeledContent( | |
| 2948 | "Last Sync", | |
| 2949 | value: cloudSyncService.lastSyncDate?.formatted(date: .abbreviated, time: .shortened) ?? "Not yet synced" | |
| 2950 | ) | |
| 2951 | ||
| 2952 | Button(cloudSyncService.status == .syncing ? "Syncing…" : "Sync Now") { | |
| 2953 | Task { | |
| 2954 | await cloudSyncService.syncNow(trigger: .manual) | |
| 2955 | } | |
| 2956 | } | |
| 2957 | .disabled(!cloudSyncService.isEnabled || cloudSyncService.status == .syncing) | |
| 2958 | ||
| 2959 | Text("iCloud Sync stores DomainDig data in your private iCloud account. DomainDig does not operate a sync server. Disabling sync keeps local data on this device.") | |
| 2960 | .font(appDensity.font(.caption, design: .default)) | |
| 2961 | .foregroundStyle(Color(.appTextSecondary)) | |
| 2962 | ||
| 2963 | Text(cloudSyncService.detailMessage) | |
| 2964 | .font(appDensity.font(.caption, design: .default)) | |
| 2965 | .foregroundStyle(Color(.appTextSecondary)) | |
| 2966 | ||
| 2967 | if let lastErrorMessage = cloudSyncService.lastErrorMessage { | |
| 2968 | Text(lastErrorMessage) | |
| 2969 | .font(appDensity.font(.caption, design: .default)) | |
| 2970 | .foregroundStyle(Color(.statusCritical)) | |
| 2971 | } | |
| 2972 | } | |
| 2973 | } | |
| 2974 | .navigationTitle("iCloud Sync") | |
| 2975 | .task { | |
| 2976 | await cloudSyncService.refreshAvailability() | |
| 2977 | } | |
| 2978 | } | |
| 2979 | } | |
| 2980 | ||
| 2981 | private struct LocalAPISettingsView: View { | |
| 2982 | @Environment(\.appDensity) private var appDensity | |
| 2983 | @State private var localAPIService = LocalAPIService.shared | |
| 2984 | @State private var portText = "" | |
| 2985 | ||
| 2986 | private var statusText: String { | |
| 2987 | if localAPIService.isRunning { return "Running" } | |
| 2988 | return localAPIService.config.isEnabled ? "Stopped" : "Disabled" | |
| 2989 | } | |
| 2990 | ||
| 2991 | var body: some View { | |
| 2992 | Form { | |
| 2993 | Section("Local API") { | |
| 2994 | Toggle( | |
| 2995 | "Enable Local API", | |
| 2996 | isOn: Binding( | |
| 2997 | get: { localAPIService.config.isEnabled }, | |
| 2998 | set: { localAPIService.setEnabled($0) } | |
| 2999 | ) | |
| 3000 | ) | |
| 3001 | ||
| 3002 | TextField( | |
| 3003 | "Port", | |
| 3004 | text: Binding( | |
| 3005 | get: { portText }, | |
| 3006 | set: { newValue in | |
| 3007 | portText = newValue | |
| 3008 | if let port = Int(newValue) { | |
| 3009 | localAPIService.setPort(port) | |
| 3010 | } | |
| 3011 | } | |
| 3012 | ) | |
| 3013 | ) | |
| 3014 | .keyboardType(.numberPad) | |
| 3015 | ||
| 3016 | LabeledContent("Address", value: localAPIService.address) | |
| 3017 | LabeledContent("Status", value: statusText) | |
| 3018 | LabeledContent("Token", value: localAPIService.maskedToken) | |
| 3019 | ||
| 3020 | if let statusMessage = localAPIService.statusMessage { | |
| 3021 | Text(statusMessage) | |
| 3022 | .font(appDensity.font(.caption, design: .default)) | |
| 3023 | .foregroundStyle(Color(.appTextSecondary)) | |
| 3024 | } | |
| 3025 | } | |
| 3026 | ||
| 3027 | Section("Authentication") { | |
| 3028 | Button("Copy Token") { | |
| 3029 | localAPIService.copyToken() | |
| 3030 | } | |
| 3031 | ||
| 3032 | Button("Copy cURL Command") { | |
| 3033 | localAPIService.copyCurlCommand() | |
| 3034 | } | |
| 3035 | ||
| 3036 | Button("Rotate Token") { | |
| 3037 | localAPIService.rotateToken() | |
| 3038 | } | |
| 3039 | ||
| 3040 | Text("Every request requires either `Authorization: Bearer <token>` or `X-API-Token`. DomainDig stores the token in Keychain and only binds the server to localhost.") | |
| 3041 | .font(appDensity.font(.caption, design: .default)) | |
| 3042 | .foregroundStyle(Color(.appTextSecondary)) | |
| 3043 | } | |
| 3044 | ||
| 3045 | Section("Request Logging") { | |
| 3046 | Toggle( | |
| 3047 | "Log Requests", | |
| 3048 | isOn: Binding( | |
| 3049 | get: { localAPIService.config.requestLoggingEnabled }, | |
| 3050 | set: { localAPIService.setRequestLoggingEnabled($0) } | |
| 3051 | ) | |
| 3052 | ) | |
| 3053 | ||
| 3054 | if localAPIService.requestLogs.isEmpty { | |
| 3055 | Text("No local API requests logged yet.") | |
| 3056 | .font(appDensity.font(.caption, design: .default)) | |
| 3057 | .foregroundStyle(Color(.appTextSecondary)) | |
| 3058 | } else { | |
| 3059 | ForEach(localAPIService.requestLogs.prefix(25)) { log in | |
| 3060 | VStack(alignment: .leading, spacing: 4) { | |
| 3061 | HStack { | |
| 3062 | Text("\(log.method) \(log.path)") | |
| 3063 | .font(appDensity.font(.callout, design: .monospaced)) | |
| 3064 | Spacer() | |
| 3065 | Text("\(log.statusCode)") | |
| 3066 | .font(appDensity.font(.caption, design: .default)) | |
| 3067 | .foregroundStyle(log.statusCode >= 400 ? Color(.statusCritical) : .secondary) | |
| 3068 | } | |
| 3069 | ||
| 3070 | Text(log.timestamp.formatted(date: .abbreviated, time: .standard)) | |
| 3071 | .font(appDensity.font(.caption2, design: .default)) | |
| 3072 | .foregroundStyle(Color(.appTextSecondary)) | |
| 3073 | ||
| 3074 | Text("\(Int(log.duration * 1000)) ms") | |
| 3075 | .font(appDensity.font(.caption2, design: .default)) | |
| 3076 | .foregroundStyle(Color(.appTextSecondary)) | |
| 3077 | } | |
| 3078 | } | |
| 3079 | } | |
| 3080 | ||
| 3081 | Button("Clear Logs", role: .destructive) { | |
| 3082 | localAPIService.clearRequestLogs() | |
| 3083 | } | |
| 3084 | } | |
| 3085 | ||
| 3086 | Section("Control") { | |
| 3087 | Button("Restart Server") { | |
| 3088 | localAPIService.setEnabled(false) | |
| 3089 | localAPIService.setEnabled(true) | |
| 3090 | } | |
| 3091 | .disabled(!localAPIService.config.isEnabled) | |
| 3092 | ||
| 3093 | Button("Stop Server") { | |
| 3094 | localAPIService.stopServer() | |
| 3095 | } | |
| 3096 | .disabled(!localAPIService.isRunning) | |
| 3097 | } | |
| 3098 | } | |
| 3099 | .navigationTitle("Local API") | |
| 3100 | .onAppear { | |
| 3101 | portText = String(localAPIService.config.port) | |
| 3102 | localAPIService.refresh() | |
| 3103 | } | |
| 3104 | } | |
| 3105 | } | |
| 3106 | ||
| 3107 | private struct MonitoringSettingsView: View { | |
| 3108 | @Environment(\.appDensity) private var appDensity | |
| 3109 | @Bindable var viewModel: DomainViewModel | |
| 3110 | ||
| 3111 | private var notificationAuthorizationLabel: String { | |
| 3112 | switch viewModel.monitoringNotificationStatus { | |
| 3113 | case .authorized, .provisional, .ephemeral: | |
| 3114 | return "Allowed" | |
| 3115 | case .denied: | |
| 3116 | return "Denied" | |
| 3117 | case .notDetermined: | |
| 3118 | return "Not Requested" | |
| 3119 | @unknown default: | |
| 3120 | return "Unknown" | |
| 3121 | } | |
| 3122 | } | |
| 3123 | ||
| 3124 | var body: some View { | |
| 3125 | Form { | |
| 3126 | Section("Monitoring") { | |
| 3127 | Toggle( | |
| 3128 | "Enable Background Monitoring", | |
| 3129 | isOn: Binding( | |
| 3130 | get: { viewModel.monitoringSettings.isEnabled }, | |
| 3131 | set: { viewModel.setMonitoringEnabled($0) } | |
| 3132 | ) | |
| 3133 | ) | |
| 3134 | ||
| 3135 | Picker( | |
| 3136 | "Base Interval", | |
| 3137 | selection: Binding( | |
| 3138 | get: { MonitoringBaseInterval.nearest(to: viewModel.monitoringSettings.baseInterval) }, | |
| 3139 | set: { viewModel.setMonitoringBaseInterval($0) } | |
| 3140 | ) | |
| 3141 | ) { | |
| 3142 | ForEach(MonitoringBaseInterval.allCases) { interval in | |
| 3143 | Text(interval.title).tag(interval) | |
| 3144 | } | |
| 3145 | } | |
| 3146 | ||
| 3147 | Toggle( | |
| 3148 | "Adaptive Monitoring", | |
| 3149 | isOn: Binding( | |
| 3150 | get: { viewModel.monitoringSettings.adaptiveEnabled }, | |
| 3151 | set: { viewModel.setMonitoringAdaptiveEnabled($0) } | |
| 3152 | ) | |
| 3153 | ) | |
| 3154 | ||
| 3155 | Picker( | |
| 3156 | "Sensitivity", | |
| 3157 | selection: Binding( | |
| 3158 | get: { viewModel.monitoringSettings.sensitivity }, | |
| 3159 | set: { viewModel.setMonitoringSensitivity($0) } | |
| 3160 | ) | |
| 3161 | ) { | |
| 3162 | ForEach(MonitoringSensitivity.allCases) { sensitivity in | |
| 3163 | Text(sensitivity.title).tag(sensitivity) | |
| 3164 | } | |
| 3165 | } | |
| 3166 | ||
| 3167 | let quietHoursStart = viewModel.monitoringSettings.quietHours?.startHour ?? 22 | |
| 3168 | let quietHoursEnd = viewModel.monitoringSettings.quietHours?.endHour ?? 7 | |
| 3169 | Toggle( | |
| 3170 | "Quiet Hours", | |
| 3171 | isOn: Binding( | |
| 3172 | get: { viewModel.monitoringSettings.quietHours != nil }, | |
| 3173 | set: { isEnabled in | |
| 3174 | viewModel.setMonitoringQuietHours( | |
| 3175 | startHour: quietHoursStart, | |
| 3176 | endHour: quietHoursEnd, | |
| 3177 | isEnabled: isEnabled | |
| 3178 | ) | |
| 3179 | } | |
| 3180 | ) | |
| 3181 | ) | |
| 3182 | ||
| 3183 | if viewModel.monitoringSettings.quietHours != nil { | |
| 3184 | Picker( | |
| 3185 | "Quiet Starts", | |
| 3186 | selection: Binding( | |
| 3187 | get: { quietHoursStart }, | |
| 3188 | set: { startHour in | |
| 3189 | viewModel.setMonitoringQuietHours( | |
| 3190 | startHour: startHour, | |
| 3191 | endHour: quietHoursEnd, | |
| 3192 | isEnabled: true | |
| 3193 | ) | |
| 3194 | } | |
| 3195 | ) | |
| 3196 | ) { | |
| 3197 | ForEach(0..<24, id: \.self) { hour in | |
| 3198 | Text(Self.monitoringHourLabel(for: hour)).tag(hour) | |
| 3199 | } | |
| 3200 | } | |
| 3201 | ||
| 3202 | Picker( | |
| 3203 | "Quiet Ends", | |
| 3204 | selection: Binding( | |
| 3205 | get: { quietHoursEnd }, | |
| 3206 | set: { endHour in | |
| 3207 | viewModel.setMonitoringQuietHours( | |
| 3208 | startHour: quietHoursStart, | |
| 3209 | endHour: endHour, | |
| 3210 | isEnabled: true | |
| 3211 | ) | |
| 3212 | } | |
| 3213 | ) | |
| 3214 | ) { | |
| 3215 | ForEach(0..<24, id: \.self) { hour in | |
| 3216 | Text(Self.monitoringHourLabel(for: hour)).tag(hour) | |
| 3217 | } | |
| 3218 | } | |
| 3219 | } | |
| 3220 | ||
| 3221 | Picker( | |
| 3222 | "Domains", | |
| 3223 | selection: Binding( | |
| 3224 | get: { viewModel.monitoringSettings.scope }, | |
| 3225 | set: { viewModel.setMonitoringScope($0) } | |
| 3226 | ) | |
| 3227 | ) { | |
| 3228 | ForEach(MonitoringScope.allCases) { scope in | |
| 3229 | Text(scope.title).tag(scope) | |
| 3230 | } | |
| 3231 | } | |
| 3232 | ||
| 3233 | if viewModel.monitoringSettings.scope == .selectedOnly { | |
| 3234 | ForEach(viewModel.trackedDomains) { trackedDomain in | |
| 3235 | Toggle( | |
| 3236 | trackedDomain.domain, | |
| 3237 | isOn: Binding( | |
| 3238 | get: { viewModel.monitoringSettings.selectedDomainIDs.contains(trackedDomain.id) }, | |
| 3239 | set: { viewModel.setMonitoringSelection(for: trackedDomain, isSelected: $0) } | |
| 3240 | ) | |
| 3241 | ) | |
| 3242 | } | |
| 3243 | } | |
| 3244 | ||
| 3245 | Toggle( | |
| 3246 | "Local Alerts", | |
| 3247 | isOn: Binding( | |
| 3248 | get: { viewModel.monitoringSettings.alertsEnabled }, | |
| 3249 | set: { isEnabled in | |
| 3250 | if isEnabled { | |
| 3251 | Task { | |
| 3252 | await viewModel.requestMonitoringNotificationAuthorization() | |
| 3253 | } | |
| 3254 | } else { | |
| 3255 | viewModel.setMonitoringAlertsEnabled(false) | |
| 3256 | } | |
| 3257 | } | |
| 3258 | ) | |
| 3259 | ) | |
| 3260 | ||
| 3261 | Picker( | |
| 3262 | "Notify For", | |
| 3263 | selection: Binding( | |
| 3264 | get: { viewModel.monitoringSettings.alertFilter }, | |
| 3265 | set: { viewModel.setMonitoringAlertFilter($0) } | |
| 3266 | ) | |
| 3267 | ) { | |
| 3268 | ForEach(MonitoringAlertFilter.allCases) { filter in | |
| 3269 | Text(filter.title).tag(filter) | |
| 3270 | } | |
| 3271 | } | |
| 3272 | ||
| 3273 | LabeledContent("Background Refresh", value: DomainMonitoringScheduler.shared.backgroundRefreshStatusDescription()) | |
| 3274 | LabeledContent("Notification Access", value: notificationAuthorizationLabel) | |
| 3275 | ||
| 3276 | if let monitoringStatusMessage = viewModel.monitoringStatusMessage { | |
| 3277 | Text(monitoringStatusMessage) | |
| 3278 | .font(appDensity.font(.caption, design: .default)) | |
| 3279 | .foregroundStyle(Color(.appTextSecondary)) | |
| 3280 | } | |
| 3281 | ||
| 3282 | if !FeatureAccessService.hasAccess(to: .automatedMonitoring) { | |
| 3283 | Text("Background monitoring and alerts are available in Pro.") | |
| 3284 | .font(appDensity.font(.caption, design: .default)) | |
| 3285 | .foregroundStyle(Color(.appTextSecondary)) | |
| 3286 | } | |
| 3287 | } | |
| 3288 | } | |
| 3289 | .navigationTitle("Monitoring") | |
| 3290 | .onAppear { | |
| 3291 | viewModel.refreshMonitoringState() | |
| 3292 | Task { | |
| 3293 | await viewModel.refreshMonitoringAuthorizationStatus() | |
| 3294 | } | |
| 3295 | } | |
| 3296 | } | |
| 3297 | ||
| 3298 | private static func monitoringHourLabel(for hour: Int) -> String { | |
| 3299 | let formatter = DateFormatter() | |
| 3300 | formatter.dateFormat = "h a" | |
| 3301 | let components = DateComponents(calendar: .current, hour: hour) | |
| 3302 | return components.date.map(formatter.string(from:)) ?? "\(hour):00" | |
| 3303 | } | |
| 3304 | } | |
| 3305 | ||
| 3306 | private struct DataPortabilitySettingsView: View { | |
| 3307 | private enum ImportTarget { | |
| 3308 | case backup | |
| 3309 | case trackedDomains | |
| 3310 | case workflows | |
| 3311 | ||
| 3312 | var expectedKind: DataPortabilityImportKind { | |
| 3313 | switch self { | |
| 3314 | case .backup: | |
| 3315 | return .backup | |
| 3316 | case .trackedDomains: | |
| 3317 | return .trackedDomains | |
| 3318 | case .workflows: | |
| 3319 | return .workflows | |
| 3320 | } | |
| 3321 | } | |
| 3322 | ||
| 3323 | var allowedContentTypes: [UTType] { | |
| 3324 | switch self { | |
| 3325 | case .backup: | |
| 3326 | return [UTType.json] | |
| 3327 | case .trackedDomains, .workflows: | |
| 3328 | return [UTType.json, UTType.commaSeparatedText] | |
| 3329 | } | |
| 3330 | } | |
| 3331 | } | |
| 3332 | ||
| 3333 | @Environment(\.appDensity) private var appDensity | |
| 3334 | @Bindable var viewModel: DomainViewModel | |
| 3335 | ||
| 3336 | @State private var importMode: DataPortabilityImportMode = .merge | |
| 3337 | @State private var activeImportTarget: ImportTarget? | |
| 3338 | @State private var pendingImportTarget: ImportTarget? | |
| 3339 | @State private var importDebugStatus: String? | |
| 3340 | @State private var pendingImportPreview: DataImportPreview? | |
| 3341 | @State private var pendingImportError: String? | |
| 3342 | @State private var showReplaceImportConfirmation = false | |
| 3343 | ||
| 3344 | var body: some View { | |
| 3345 | Form { | |
| 3346 | Section("Import & Export") { | |
| 3347 | Picker("Import Mode", selection: $importMode) { | |
| 3348 | ForEach(DataPortabilityImportMode.allCases) { mode in | |
| 3349 | Text(mode.title).tag(mode) | |
| 3350 | } | |
| 3351 | } | |
| 3352 | ||
| 3353 | Text(importMode.explanation) | |
| 3354 | .font(appDensity.font(.caption, design: .default)) | |
| 3355 | .foregroundStyle(Color(.appTextSecondary)) | |
| 3356 | ||
| 3357 | Button("Export Full Backup") { | |
| 3358 | exportFullBackup() | |
| 3359 | } | |
| 3360 | ||
| 3361 | Button("Import Backup") { | |
| 3362 | recordImportDebugStatus("Tapped Import Backup") | |
| 3363 | pendingImportTarget = .backup | |
| 3364 | activeImportTarget = .backup | |
| 3365 | } | |
| 3366 | ||
| 3367 | Menu("Export Tracked Domains") { | |
| 3368 | Button("JSON") { | |
| 3369 | exportPortableTrackedDomainsJSON() | |
| 3370 | } | |
| 3371 | Button("CSV") { | |
| 3372 | exportPortableTrackedDomainsCSV() | |
| 3373 | } | |
| 3374 | } | |
| 3375 | ||
| 3376 | Button("Import Tracked Domains") { | |
| 3377 | recordImportDebugStatus("Tapped Import Tracked Domains") | |
| 3378 | pendingImportTarget = .trackedDomains | |
| 3379 | activeImportTarget = .trackedDomains | |
| 3380 | } | |
| 3381 | ||
| 3382 | Menu("Export Workflows") { | |
| 3383 | Button("JSON") { | |
| 3384 | exportPortableWorkflowsJSON() | |
| 3385 | } | |
| 3386 | Button("CSV") { | |
| 3387 | exportPortableWorkflowsCSV() | |
| 3388 | } | |
| 3389 | } | |
| 3390 | ||
| 3391 | Button("Import Workflows") { | |
| 3392 | recordImportDebugStatus("Tapped Import Workflows") | |
| 3393 | pendingImportTarget = .workflows | |
| 3394 | activeImportTarget = .workflows | |
| 3395 | } | |
| 3396 | ||
| 3397 | Button("Export History") { | |
| 3398 | exportPortableHistoryJSON() | |
| 3399 | } | |
| 3400 | } | |
| 3401 | ||
| 3402 | Section("Local Data") { | |
| 3403 | LabeledContent("Tracked Domains", value: "\(viewModel.dataLifecycleSummary.trackedDomains)") | |
| 3404 | LabeledContent("History Snapshots", value: "\(viewModel.dataLifecycleSummary.historySnapshots)") | |
| 3405 | LabeledContent("Audit Sessions", value: "\(viewModel.dataLifecycleSummary.auditSessions)") | |
| 3406 | LabeledContent("Workflows", value: "\(viewModel.dataLifecycleSummary.workflows)") | |
| 3407 | LabeledContent("Cached Items", value: "\(viewModel.dataLifecycleSummary.cachedItems)") | |
| 3408 | LabeledContent("Monitoring Logs", value: "\(viewModel.dataLifecycleSummary.monitoringLogs)") | |
| 3409 | ||
| 3410 | Text("Data stays on this device unless you export it. Backup files can include domain history, monitoring settings, and notes. Imported files are processed on-device.") | |
| 3411 | .font(appDensity.font(.caption, design: .default)) | |
| 3412 | .foregroundStyle(Color(.appTextSecondary)) | |
| 3413 | ||
| 3414 | if let portabilityStatusMessage = viewModel.portabilityStatusMessage { | |
| 3415 | Text(portabilityStatusMessage) | |
| 3416 | .font(appDensity.font(.caption, design: .default)) | |
| 3417 | .foregroundStyle(Color(.appTextSecondary)) | |
| 3418 | } | |
| 3419 | } | |
| 3420 | ||
| 3421 | #if DEBUG | |
| 3422 | if let importDebugStatus { | |
| 3423 | Section("Import Debug") { | |
| 3424 | Text(importDebugStatus) | |
| 3425 | .font(appDensity.font(.caption, design: .default)) | |
| 3426 | .foregroundStyle(Color(.appTextSecondary)) | |
| 3427 | .textSelection(.enabled) | |
| 3428 | } | |
| 3429 | } | |
| 3430 | #endif | |
| 3431 | } | |
| 3432 | .navigationTitle("Import & Export") | |
| 3433 | .alert("Replace local data?", isPresented: $showReplaceImportConfirmation) { | |
| 3434 | Button("Replace", role: .destructive) { | |
| 3435 | applyPendingImport() | |
| 3436 | } | |
| 3437 | Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } | |
| 3438 | } message: { | |
| 3439 | Text("Replace mode overwrites local data covered by the imported file and may remove items that are only on this device.") | |
| 3440 | } | |
| 3441 | .alert("Import Error", isPresented: Binding( | |
| 3442 | get: { pendingImportError != nil }, | |
| 3443 | set: { if !$0 { pendingImportError = nil } } | |
| 3444 | )) { | |
| 3445 | Button("OK", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } | |
| 3446 | } message: { | |
| 3447 | Text(pendingImportError ?? "The import could not be completed.") | |
| 3448 | } | |
| 3449 | .sheet(isPresented: Binding( | |
| 3450 | get: { pendingImportPreview != nil }, | |
| 3451 | set: { if !$0 { pendingImportPreview = nil } } | |
| 3452 | )) { | |
| 3453 | if let pendingImportPreview { | |
| 3454 | DataImportPreviewSheet( | |
| 3455 | preview: pendingImportPreview, | |
| 3456 | mode: importMode, | |
| 3457 | onCancel: { | |
| 3458 | self.pendingImportPreview = nil | |
| 3459 | }, | |
| 3460 | onApply: { | |
| 3461 | if importMode == .replace { | |
| 3462 | showReplaceImportConfirmation = true | |
| 3463 | } else { | |
| 3464 | applyPendingImport() | |
| 3465 | } | |
| 3466 | } | |
| 3467 | ) | |
| 3468 | } | |
| 3469 | } | |
| 3470 | .fileImporter( | |
| 3471 | isPresented: Binding( | |
| 3472 | get: { activeImportTarget != nil }, | |
| 3473 | set: { if !$0 { activeImportTarget = nil } } | |
| 3474 | ), | |
| 3475 | allowedContentTypes: activeImportTarget?.allowedContentTypes ?? [UTType.json], | |
| 3476 | allowsMultipleSelection: false | |
| 3477 | ) { result in | |
| 3478 | guard let pendingImportTarget else { | |
| 3479 | recordImportDebugStatus("fileImporter returned with no active target") | |
| 3480 | return | |
| 3481 | } | |
| 3482 | recordImportDebugStatus("fileImporter returned for \(pendingImportTarget.expectedKind.rawValue)") | |
| 3483 | handleImportResult(result, expectedKind: pendingImportTarget.expectedKind) | |
| 3484 | self.pendingImportTarget = nil | |
| 3485 | self.activeImportTarget = nil | |
| 3486 | } | |
| 3487 | .onAppear { | |
| 3488 | viewModel.refreshDataLifecycleSummary() | |
| 3489 | } | |
| 3490 | } | |
| 3491 | ||
| 3492 | private func exportFullBackup() { | |
| 3493 | guard let data = viewModel.exportFullBackupData() else { return } | |
| 3494 | ExportPresenter.share(filename: portabilityFilename(suffix: "backup", fileExtension: "json"), data: data) | |
| 3495 | } | |
| 3496 | ||
| 3497 | private func exportPortableTrackedDomainsJSON() { | |
| 3498 | guard let data = viewModel.exportPortableTrackedDomainsJSONData() else { return } | |
| 3499 | ExportPresenter.share(filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "json"), data: data) | |
| 3500 | } | |
| 3501 | ||
| 3502 | private func exportPortableTrackedDomainsCSV() { | |
| 3503 | ExportPresenter.share( | |
| 3504 | filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "csv"), | |
| 3505 | contents: viewModel.exportPortableTrackedDomainsCSV() | |
| 3506 | ) | |
| 3507 | } | |
| 3508 | ||
| 3509 | private func exportPortableWorkflowsJSON() { | |
| 3510 | guard let data = viewModel.exportPortableWorkflowsJSONData() else { return } | |
| 3511 | ExportPresenter.share(filename: portabilityFilename(suffix: "workflows", fileExtension: "json"), data: data) | |
| 3512 | } | |
| 3513 | ||
| 3514 | private func exportPortableWorkflowsCSV() { | |
| 3515 | ExportPresenter.share( | |
| 3516 | filename: portabilityFilename(suffix: "workflows", fileExtension: "csv"), | |
| 3517 | contents: viewModel.exportPortableWorkflowsCSV() | |
| 3518 | ) | |
| 3519 | } | |
| 3520 | ||
| 3521 | private func exportPortableHistoryJSON() { | |
| 3522 | guard let data = viewModel.exportPortableHistoryJSONData() else { return } | |
| 3523 | ExportPresenter.share(filename: portabilityFilename(suffix: "history", fileExtension: "json"), data: data) | |
| 3524 | } | |
| 3525 | ||
| 3526 | private func handleImportResult( | |
| 3527 | _ result: Result<[URL], Error>, | |
| 3528 | expectedKind: DataPortabilityImportKind | |
| 3529 | ) { | |
| 3530 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult expectedKind=\(expectedKind.rawValue)") | |
| 3531 | recordImportDebugStatus("handleImportResult started for \(expectedKind.rawValue)") | |
| 3532 | do { | |
| 3533 | let urls = try result.get() | |
| 3534 | guard let url = urls.first else { | |
| 3535 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult noURLReturned") | |
| 3536 | recordImportDebugStatus("No URL returned from picker") | |
| 3537 | return | |
| 3538 | } | |
| 3539 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult selectedURL=\(url.absoluteString)") | |
| 3540 | recordImportDebugStatus("Selected \(url.lastPathComponent)") | |
| 3541 | let shouldStopAccessing = url.startAccessingSecurityScopedResource() | |
| 3542 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeGranted=\(shouldStopAccessing)") | |
| 3543 | recordImportDebugStatus("Security scope granted: \(shouldStopAccessing)") | |
| 3544 | defer { | |
| 3545 | if shouldStopAccessing { | |
| 3546 | url.stopAccessingSecurityScopedResource() | |
| 3547 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeReleased") | |
| 3548 | } | |
| 3549 | } | |
| 3550 | ||
| 3551 | let data = try Data(contentsOf: url) | |
| 3552 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult dataRead bytes=\(data.count) fileName=\(url.lastPathComponent)") | |
| 3553 | recordImportDebugStatus("Read \(data.count) bytes from \(url.lastPathComponent)") | |
| 3554 | let preview = try viewModel.prepareDataImport( | |
| 3555 | data: data, | |
| 3556 | fileName: url.lastPathComponent, | |
| 3557 | mode: importMode | |
| 3558 | ) | |
| 3559 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult previewReady previewKind=\(preview.kind.rawValue) expectedKind=\(expectedKind.rawValue)") | |
| 3560 | recordImportDebugStatus("Preview ready: \(preview.kind.rawValue)") | |
| 3561 | ||
| 3562 | guard preview.kind == expectedKind else { | |
| 3563 | let message = preview.kind == .backup | |
| 3564 | ? "That file is a full backup. Use Import Backup." | |
| 3565 | : "That file type does not match this import action." | |
| 3566 | DomainDebugLog.error("DataPortabilitySettingsView.handleImportResult kindMismatch message=\(message)") | |
| 3567 | recordImportDebugStatus("Kind mismatch: \(message)") | |
| 3568 | presentImportError(message) | |
| 3569 | return | |
| 3570 | } | |
| 3571 | ||
| 3572 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult presentingPreview kind=\(preview.kind.rawValue)") | |
| 3573 | recordImportDebugStatus("Presenting preview for \(preview.kind.rawValue)") | |
| 3574 | presentImportPreview(preview) | |
| 3575 | } catch { | |
| 3576 | DomainDebugLog.error("DataPortabilitySettingsView.handleImportResult failed error=\(error.localizedDescription)") | |
| 3577 | recordImportDebugStatus("Import failed: \(error.localizedDescription)") | |
| 3578 | presentImportError(error.localizedDescription) | |
| 3579 | } | |
| 3580 | } | |
| 3581 | ||
| 3582 | private func applyPendingImport() { | |
| 3583 | guard let pendingImportPreview else { return } | |
| 3584 | do { | |
| 3585 | _ = try viewModel.applyDataImport(pendingImportPreview, mode: importMode) | |
| 3586 | self.pendingImportPreview = nil | |
| 3587 | } catch { | |
| 3588 | pendingImportError = error.localizedDescription | |
| 3589 | } | |
| 3590 | } | |
| 3591 | ||
| 3592 | private func portabilityFilename(suffix: String, fileExtension: String) -> String { | |
| 3593 | let formatter = DateFormatter() | |
| 3594 | formatter.dateFormat = "yyyyMMdd_HHmmss" | |
| 3595 | return "\(formatter.string(from: Date()))_domaindig_\(suffix).\(fileExtension)" | |
| 3596 | } | |
| 3597 | ||
| 3598 | private func presentImportPreview(_ preview: DataImportPreview) { | |
| 3599 | Task { @MainActor in | |
| 3600 | try? await Task.sleep(for: .milliseconds(300)) | |
| 3601 | DomainDebugLog.debug("DataPortabilitySettingsView.presentImportPreview kind=\(preview.kind.rawValue) fileName=\(preview.fileName)") | |
| 3602 | recordImportDebugStatus("Preview presented for \(preview.fileName)") | |
| 3603 | pendingImportPreview = preview | |
| 3604 | } | |
| 3605 | } | |
| 3606 | ||
| 3607 | private func presentImportError(_ message: String) { | |
| 3608 | Task { @MainActor in | |
| 3609 | try? await Task.sleep(for: .milliseconds(300)) | |
| 3610 | DomainDebugLog.error("DataPortabilitySettingsView.presentImportError message=\(message)") | |
| 3611 | recordImportDebugStatus("Error presented: \(message)") | |
| 3612 | pendingImportError = message | |
| 3613 | } | |
| 3614 | } | |
| 3615 | ||
| 3616 | private func recordImportDebugStatus(_ message: String) { | |
| 3617 | #if DEBUG | |
| 3618 | let status = "[Import Debug] \(message)" | |
| 3619 | importDebugStatus = status | |
| 3620 | print(status) | |
| 3621 | #endif | |
| 3622 | } | |
| 3623 | } | |
| 3624 | ||
| 3625 | private struct DataManagementSettingsView: View { | |
| 3626 | @Bindable var viewModel: DomainViewModel | |
| 3627 | @Environment(\.accessibilityReduceTransparency) private var reduceTransparency | |
| 3628 | ||
| 3629 | @State private var showClearHistoryConfirmation = false | |
| 3630 | @State private var showClearCacheConfirmation = false | |
| 3631 | @State private var showClearWorkflowsConfirmation = false | |
| 3632 | @State private var showClearTrackedDomainsConfirmation = false | |
| 3633 | @State private var showDeleteAllConfirmation = false | |
| 3634 | @State private var deleteAllErrorMessage: String? | |
| 3635 | @State private var deleteAllSuccessMessage: String? | |
| 3636 | @State private var isDeletingAllData = false | |
| 3637 | ||
| 3638 | var body: some View { | |
| 3639 | Form { | |
| 3640 | Section("Data") { | |
| 3641 | Button("Clear History", role: .destructive) { | |
| 3642 | showClearHistoryConfirmation = true | |
| 3643 | } | |
| 3644 | ||
| 3645 | Button("Clear Cache", role: .destructive) { | |
| 3646 | showClearCacheConfirmation = true | |
| 3647 | } | |
| 3648 | ||
| 3649 | Button("Clear Workflows", role: .destructive) { | |
| 3650 | showClearWorkflowsConfirmation = true | |
| 3651 | } | |
| 3652 | ||
| 3653 | Button("Clear Tracked Domains", role: .destructive) { | |
| 3654 | showClearTrackedDomainsConfirmation = true | |
| 3655 | } | |
| 3656 | } | |
| 3657 | ||
| 3658 | Section { | |
| 3659 | Button(role: .destructive) { | |
| 3660 | showDeleteAllConfirmation = true | |
| 3661 | } label: { | |
| 3662 | HStack { | |
| 3663 | Text("Delete All Data") | |
| 3664 | Spacer() | |
| 3665 | if isDeletingAllData { | |
| 3666 | ProgressView() | |
| 3667 | .controlSize(.small) | |
| 3668 | } | |
| 3669 | } | |
| 3670 | } | |
| 3671 | .disabled(isDeletingAllData) | |
| 3672 | } header: { | |
| 3673 | Text("Danger Zone") | |
| 3674 | } footer: { | |
| 3675 | Text("Permanently removes all local DomainDig data from this device.") | |
| 3676 | } | |
| 3677 | } | |
| 3678 | .disabled(isDeletingAllData) | |
| 3679 | .navigationTitle("Data Management") | |
| 3680 | .alert("Clear history?", isPresented: $showClearHistoryConfirmation) { | |
| 3681 | Button("Clear", role: .destructive) { | |
| 3682 | viewModel.clearHistory() | |
| 3683 | } | |
| 3684 | Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } | |
| 3685 | } message: { | |
| 3686 | Text("This removes saved lookup snapshots and clears monitoring run history on this device.") | |
| 3687 | } | |
| 3688 | .alert("Clear cache?", isPresented: $showClearCacheConfirmation) { | |
| 3689 | Button("Clear", role: .destructive) { | |
| 3690 | viewModel.clearLookupCache() | |
| 3691 | } | |
| 3692 | Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } | |
| 3693 | } message: { | |
| 3694 | Text("This clears the in-memory lookup cache and cancels any cached in-flight work.") | |
| 3695 | } | |
| 3696 | .alert("Clear workflows?", isPresented: $showClearWorkflowsConfirmation) { | |
| 3697 | Button("Clear", role: .destructive) { | |
| 3698 | viewModel.clearWorkflows() | |
| 3699 | } | |
| 3700 | Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } | |
| 3701 | } message: { | |
| 3702 | Text("This removes saved workflows only. History, tracked domains, and saved reports stay intact.") | |
| 3703 | } | |
| 3704 | .alert("Clear tracked domains?", isPresented: $showClearTrackedDomainsConfirmation) { | |
| 3705 | Button("Clear", role: .destructive) { | |
| 3706 | viewModel.clearTrackedDomains() | |
| 3707 | } | |
| 3708 | Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } | |
| 3709 | } message: { | |
| 3710 | Text("This removes the watchlist and clears monitoring run history. History and workflows stay intact.") | |
| 3711 | } | |
| 3712 | .alert("Delete All Data?", isPresented: $showDeleteAllConfirmation) { | |
| 3713 | Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } | |
| 3714 | Button("Delete All Data", role: .destructive) { | |
| 3715 | deleteAllData() | |
| 3716 | } | |
| 3717 | } message: { | |
| 3718 | Text("This will permanently remove all saved DomainDig data from this device. This includes tracked domains, monitoring history, snapshots, exports, cached reports, and local settings. This action cannot be undone.") | |
| 3719 | } | |
| 3720 | .alert("Delete Failed", isPresented: Binding( | |
| 3721 | get: { deleteAllErrorMessage != nil }, | |
| 3722 | set: { if !$0 { deleteAllErrorMessage = nil } } | |
| 3723 | )) { | |
| 3724 | Button("OK", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } | |
| 3725 | } message: { | |
| 3726 | Text(deleteAllErrorMessage ?? "The local data reset could not be completed.") | |
| 3727 | } | |
| 3728 | .safeAreaInset(edge: .bottom) { | |
| 3729 | if let deleteAllSuccessMessage { | |
| 3730 | Text(deleteAllSuccessMessage) | |
| 3731 | .font(.footnote.weight(.medium)) | |
| 3732 | .foregroundStyle(Color(.appTextSecondary)) | |
| 3733 | .padding(.horizontal, 14) | |
| 3734 | .padding(.vertical, 10) | |
| 3735 | // Reduce Transparency swaps the blur for an opaque surface. | |
| 3736 | // On iOS 26+ the system also composites its own translucency | |
| 3737 | // that the app cannot declare — verify there too (Phase 6). | |
| 3738 | .background( | |
| 3739 | Capsule().fill(reduceTransparency ? AnyShapeStyle(Color(.appSurfaceElevated)) : AnyShapeStyle(.thinMaterial)) | |
| 3740 | ) | |
| 3741 | .padding(.bottom, 8) | |
| 3742 | .transition(.move(edge: .bottom).combined(with: .opacity)) | |
| 3743 | } | |
| 3744 | } | |
| 3745 | } | |
| 3746 | ||
| 3747 | private func deleteAllData() { | |
| 3748 | guard !isDeletingAllData else { return } | |
| 3749 | ||
| 3750 | isDeletingAllData = true | |
| 3751 | deleteAllErrorMessage = nil | |
| 3752 | deleteAllSuccessMessage = nil | |
| 3753 | ||
| 3754 | Task { | |
| 3755 | do { | |
| 3756 | try await DataResetService.wipeAllLocalData(viewModel: viewModel) | |
| 3757 | deleteAllSuccessMessage = "All local data removed." | |
| 3758 | try? await Task.sleep(for: .seconds(2)) | |
| 3759 | if deleteAllSuccessMessage == "All local data removed." { | |
| 3760 | deleteAllSuccessMessage = nil | |
| 3761 | } | |
| 3762 | } catch { | |
| 3763 | deleteAllErrorMessage = error.localizedDescription | |
| 3764 | } | |
| 3765 | ||
| 3766 | isDeletingAllData = false | |
| 3767 | } | |
| 3768 | } | |
| 3769 | } | |
| 3770 | ||
| 3771 | private struct AboutSettingsView: View { | |
| 3772 | @State private var cloudSyncService = CloudSyncService.shared | |
| 3773 | ||
| 3774 | private var appVersion: String { | |
| 3775 | AppVersion.current | |
| 3776 | } | |
| 3777 | ||
| 3778 | var body: some View { | |
| 3779 | Form { | |
| 3780 | Section("About") { | |
| 3781 | LabeledContent("Version", value: appVersion) | |
| 3782 | LabeledContent("Storage", value: cloudSyncService.isEnabled ? "Local-first + iCloud" : "Local-only") | |
| 3783 | LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)") | |
| 3784 | } | |
| 3785 | } | |
| 3786 | .navigationTitle("App Info") | |
| 3787 | .task { | |
| 3788 | await cloudSyncService.refreshAvailability() | |
| 3789 | } | |
| 3790 | } | |
| 3791 | } | |
| 3792 | ||
| 3793 | private struct DataImportPreviewSheet: View { | |
| 3794 | @Environment(\.dismiss) private var dismiss | |
| 3795 | ||
| 3796 | let preview: DataImportPreview | |
| 3797 | let mode: DataPortabilityImportMode | |
| 3798 | let onCancel: () -> Void | |
| 3799 | let onApply: () -> Void | |
| 3800 | ||
| 3801 | var body: some View { | |
| 3802 | NavigationStack { | |
| 3803 | List { | |
| 3804 | Section("Summary") { | |
| 3805 | ForEach(preview.summaryLines, id: \.self) { line in | |
| 3806 | Text(line) | |
| 3807 | } | |
| 3808 | } | |
| 3809 | ||
| 3810 | Section("Projected Counts") { | |
| 3811 | LabeledContent("Tracked Domains", value: "\(preview.projectedCounts.trackedDomains)") | |
| 3812 | LabeledContent("History Snapshots", value: "\(preview.projectedCounts.historySnapshots)") | |
| 3813 | LabeledContent("Audit Sessions", value: "\(preview.projectedCounts.auditSessions)") | |
| 3814 | LabeledContent("Workflows", value: "\(preview.projectedCounts.workflows)") | |
| 3815 | LabeledContent("Cached Items", value: "\(preview.projectedCounts.cachedItems)") | |
| 3816 | LabeledContent("Monitoring Logs", value: "\(preview.projectedCounts.monitoringLogs)") | |
| 3817 | } | |
| 3818 | ||
| 3819 | if !preview.warnings.isEmpty { | |
| 3820 | Section("Warnings") { | |
| 3821 | ForEach(preview.warnings, id: \.self) { warning in | |
| 3822 | Text(warning) | |
| 3823 | .foregroundStyle(Color(.appTextSecondary)) | |
| 3824 | } | |
| 3825 | } | |
| 3826 | } | |
| 3827 | } | |
| 3828 | .navigationTitle("Import Preview") | |
| 3829 | .toolbar { | |
| 3830 | ToolbarItem(placement: .cancellationAction) { | |
| 3831 | Button("Cancel") { | |
| 3832 | onCancel() | |
| 3833 | dismiss() | |
| 3834 | } | |
| 3835 | } | |
| 3836 | ToolbarItem(placement: .confirmationAction) { | |
| 3837 | Button(mode == .replace ? "Replace" : "Import") { | |
| 3838 | onApply() | |
| 3839 | if mode == .merge { | |
| 3840 | dismiss() | |
| 3841 | } | |
| 3842 | } | |
| 3843 | } | |
| 3844 | } | |
| 3845 | } | |
| 3846 | } | |
| 3847 | } | |
| 3848 | ||
| 3849 | 2706 | extension ChangeImpactClassification { |
| 3850 | 2707 | var tone: AppStatusTone { |
| 3851 | 2708 | switch self { |
DomainDig/SettingsViews.swift added +1149
| @@ -0,0 +1,1149 @@ | ||
| 1 | import SwiftUI | |
| 2 | import UniformTypeIdentifiers | |
| 3 | ||
| 4 | // Settings screens extracted from ContentView.swift. `SettingsView` is the | |
| 5 | // Settings tab root (presented from RootTabView); the per-section screens are | |
| 6 | // file-private, reached only through its navigation links. | |
| 7 | ||
| 8 | struct SettingsView: View { | |
| 9 | @Environment(\.appDensity) private var appDensity | |
| 10 | @Bindable var viewModel: DomainViewModel | |
| 11 | @State private var purchaseService = PurchaseService.shared | |
| 12 | ||
| 13 | var body: some View { | |
| 14 | let _ = purchaseService.currentTier | |
| 15 | ||
| 16 | List { | |
| 17 | Section("Tier") { | |
| 18 | LabeledContent("Status", value: purchaseService.currentTier.title) | |
| 19 | ||
| 20 | if purchaseService.currentTier == .free { | |
| 21 | Button("Upgrade") { | |
| 22 | viewModel.isPaywallPresented = true | |
| 23 | } | |
| 24 | } else { | |
| 25 | Button("Manage Subscription") { | |
| 26 | Task { | |
| 27 | await purchaseService.manageSubscription() | |
| 28 | } | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") { | |
| 33 | Task { | |
| 34 | await purchaseService.restorePurchases() | |
| 35 | } | |
| 36 | } | |
| 37 | .disabled(purchaseService.isRestoring || purchaseService.isPurchasing) | |
| 38 | ||
| 39 | if let statusMessage = purchaseService.statusMessage { | |
| 40 | Text(statusMessage) | |
| 41 | .font(appDensity.font(.caption, design: .default)) | |
| 42 | .foregroundStyle(Color(.appTextSecondary)) | |
| 43 | } | |
| 44 | ||
| 45 | if let errorMessage = purchaseService.errorMessage { | |
| 46 | Text(errorMessage) | |
| 47 | .font(appDensity.font(.caption, design: .default)) | |
| 48 | .foregroundStyle(Color(.statusCritical)) | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | Section("Preferences") { | |
| 53 | NavigationLink("Tracked Domains") { | |
| 54 | WatchlistView(viewModel: viewModel) | |
| 55 | } | |
| 56 | ||
| 57 | NavigationLink("Workflows") { | |
| 58 | WorkflowsView(viewModel: viewModel) | |
| 59 | } | |
| 60 | ||
| 61 | NavigationLink("Display") { | |
| 62 | DisplaySettingsView() | |
| 63 | } | |
| 64 | ||
| 65 | NavigationLink("History & Network") { | |
| 66 | HistoryNetworkSettingsView(viewModel: viewModel) | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 70 | Section("Services") { | |
| 71 | NavigationLink("Monitoring Activity") { | |
| 72 | MonitoringView(viewModel: viewModel) | |
| 73 | } | |
| 74 | ||
| 75 | NavigationLink("Integrations") { | |
| 76 | IntegrationsSettingsView() | |
| 77 | } | |
| 78 | ||
| 79 | NavigationLink("Local API") { | |
| 80 | LocalAPISettingsView() | |
| 81 | } | |
| 82 | ||
| 83 | NavigationLink("iCloud Sync") { | |
| 84 | CloudSyncSettingsView() | |
| 85 | } | |
| 86 | ||
| 87 | NavigationLink("Monitoring") { | |
| 88 | MonitoringSettingsView(viewModel: viewModel) | |
| 89 | } | |
| 90 | ||
| 91 | NavigationLink("Scheduled Reports") { | |
| 92 | ScheduledReportsView() | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | Section("Data") { | |
| 97 | NavigationLink("Import & Export") { | |
| 98 | DataPortabilitySettingsView(viewModel: viewModel) | |
| 99 | } | |
| 100 | ||
| 101 | NavigationLink("Data Management") { | |
| 102 | DataManagementSettingsView(viewModel: viewModel) | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | Section("About") { | |
| 107 | NavigationLink("App Info") { | |
| 108 | AboutSettingsView() | |
| 109 | } | |
| 110 | } | |
| 111 | } | |
| 112 | .navigationTitle("Settings") | |
| 113 | } | |
| 114 | } | |
| 115 | ||
| 116 | private struct DisplaySettingsView: View { | |
| 117 | @AppStorage(AppDensity.userDefaultsKey) private var storedDensity = AppDensity.compact.rawValue | |
| 118 | @AppStorage(AppAppearance.userDefaultsKey) private var storedAppearance = AppAppearance.system.rawValue | |
| 119 | ||
| 120 | var body: some View { | |
| 121 | Form { | |
| 122 | Section("Display") { | |
| 123 | Picker("Appearance", selection: $storedAppearance) { | |
| 124 | ForEach(AppAppearance.allCases) { appearance in | |
| 125 | Text(appearance.title).tag(appearance.rawValue) | |
| 126 | } | |
| 127 | } | |
| 128 | ||
| 129 | Picker("Density", selection: $storedDensity) { | |
| 130 | ForEach(AppDensity.allCases) { density in | |
| 131 | Text(density.title).tag(density.rawValue) | |
| 132 | } | |
| 133 | } | |
| 134 | } | |
| 135 | } | |
| 136 | .navigationTitle("Display") | |
| 137 | } | |
| 138 | } | |
| 139 | ||
| 140 | private struct HistoryNetworkSettingsView: View { | |
| 141 | @Environment(\.appDensity) private var appDensity | |
| 142 | @Bindable var viewModel: DomainViewModel | |
| 143 | @AppStorage(DNSResolverOption.userDefaultsKey) private var storedResolverURL = DNSResolverOption.defaultURLString | |
| 144 | @AppStorage(AppDensity.userDefaultsKey) private var storedDensity = AppDensity.compact.rawValue | |
| 145 | ||
| 146 | @State private var resolverOption: DNSResolverOption = .cloudflare | |
| 147 | @State private var customResolverURL = DNSResolverOption.defaultURLString | |
| 148 | ||
| 149 | private var customResolverError: String? { | |
| 150 | guard resolverOption == .custom else { return nil } | |
| 151 | return DNSResolverOption.isValidCustomURL(customResolverURL) ? nil : "Resolver URL must start with https://" | |
| 152 | } | |
| 153 | ||
| 154 | var body: some View { | |
| 155 | Form { | |
| 156 | Section("History") { | |
| 157 | Picker( | |
| 158 | "Auto-prune", | |
| 159 | selection: Binding( | |
| 160 | get: { viewModel.historyAutoPruneOption }, | |
| 161 | set: { viewModel.setHistoryAutoPruneOption($0) } | |
| 162 | ) | |
| 163 | ) { | |
| 164 | ForEach(HistoryAutoPruneOption.allCases) { option in | |
| 165 | Text(option.title).tag(option) | |
| 166 | } | |
| 167 | } | |
| 168 | ||
| 169 | Text("History remains local-first. Auto-prune only trims older local snapshots on this device and defaults to unlimited.") | |
| 170 | .font(appDensity.font(.caption, design: .default)) | |
| 171 | .foregroundStyle(Color(.appTextSecondary)) | |
| 172 | } | |
| 173 | ||
| 174 | Section("Network") { | |
| 175 | Picker("Resolver", selection: $resolverOption) { | |
| 176 | ForEach(DNSResolverOption.allCases) { option in | |
| 177 | Text(option.title).tag(option) | |
| 178 | } | |
| 179 | } | |
| 180 | ||
| 181 | if resolverOption == .custom { | |
| 182 | TextField("https://resolver.example/dns-query", text: $customResolverURL) | |
| 183 | .textInputAutocapitalization(.never) | |
| 184 | .autocorrectionDisabled() | |
| 185 | .keyboardType(.URL) | |
| 186 | ||
| 187 | if let customResolverError { | |
| 188 | Text(customResolverError) | |
| 189 | .font(appDensity.font(.caption, design: .default)) | |
| 190 | .foregroundStyle(Color(.statusCritical)) | |
| 191 | } | |
| 192 | } | |
| 193 | } | |
| 194 | } | |
| 195 | .navigationTitle("History & Network") | |
| 196 | .onAppear { | |
| 197 | let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 198 | resolverOption = DNSResolverOption.option(for: currentResolverURL) | |
| 199 | customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString | |
| 200 | } | |
| 201 | .onChange(of: resolverOption) { _, newValue in | |
| 202 | guard let presetURL = newValue.urlString else { | |
| 203 | storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 204 | viewModel.persistCurrentAppSettings( | |
| 205 | resolverURLString: storedResolverURL, | |
| 206 | appDensityRawValue: storedDensity | |
| 207 | ) | |
| 208 | return | |
| 209 | } | |
| 210 | storedResolverURL = presetURL | |
| 211 | viewModel.persistCurrentAppSettings( | |
| 212 | resolverURLString: storedResolverURL, | |
| 213 | appDensityRawValue: storedDensity | |
| 214 | ) | |
| 215 | } | |
| 216 | .onChange(of: customResolverURL) { _, newValue in | |
| 217 | guard resolverOption == .custom else { return } | |
| 218 | storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 219 | viewModel.persistCurrentAppSettings( | |
| 220 | resolverURLString: storedResolverURL, | |
| 221 | appDensityRawValue: storedDensity | |
| 222 | ) | |
| 223 | } | |
| 224 | .onChange(of: storedDensity) { _, newValue in | |
| 225 | viewModel.persistCurrentAppSettings( | |
| 226 | resolverURLString: storedResolverURL, | |
| 227 | appDensityRawValue: newValue | |
| 228 | ) | |
| 229 | } | |
| 230 | } | |
| 231 | } | |
| 232 | ||
| 233 | private struct CloudSyncSettingsView: View { | |
| 234 | @Environment(\.appDensity) private var appDensity | |
| 235 | @State private var cloudSyncService = CloudSyncService.shared | |
| 236 | ||
| 237 | var body: some View { | |
| 238 | Form { | |
| 239 | Section("iCloud Sync") { | |
| 240 | Toggle( | |
| 241 | "Enable iCloud Sync", | |
| 242 | isOn: Binding( | |
| 243 | get: { cloudSyncService.isEnabled }, | |
| 244 | set: { cloudSyncService.setSyncEnabled($0) } | |
| 245 | ) | |
| 246 | ) | |
| 247 | ||
| 248 | LabeledContent("Status", value: cloudSyncService.status.title) | |
| 249 | LabeledContent( | |
| 250 | "Last Sync", | |
| 251 | value: cloudSyncService.lastSyncDate?.formatted(date: .abbreviated, time: .shortened) ?? "Not yet synced" | |
| 252 | ) | |
| 253 | ||
| 254 | Button(cloudSyncService.status == .syncing ? "Syncing…" : "Sync Now") { | |
| 255 | Task { | |
| 256 | await cloudSyncService.syncNow(trigger: .manual) | |
| 257 | } | |
| 258 | } | |
| 259 | .disabled(!cloudSyncService.isEnabled || cloudSyncService.status == .syncing) | |
| 260 | ||
| 261 | Text("iCloud Sync stores DomainDig data in your private iCloud account. DomainDig does not operate a sync server. Disabling sync keeps local data on this device.") | |
| 262 | .font(appDensity.font(.caption, design: .default)) | |
| 263 | .foregroundStyle(Color(.appTextSecondary)) | |
| 264 | ||
| 265 | Text(cloudSyncService.detailMessage) | |
| 266 | .font(appDensity.font(.caption, design: .default)) | |
| 267 | .foregroundStyle(Color(.appTextSecondary)) | |
| 268 | ||
| 269 | if let lastErrorMessage = cloudSyncService.lastErrorMessage { | |
| 270 | Text(lastErrorMessage) | |
| 271 | .font(appDensity.font(.caption, design: .default)) | |
| 272 | .foregroundStyle(Color(.statusCritical)) | |
| 273 | } | |
| 274 | } | |
| 275 | } | |
| 276 | .navigationTitle("iCloud Sync") | |
| 277 | .task { | |
| 278 | await cloudSyncService.refreshAvailability() | |
| 279 | } | |
| 280 | } | |
| 281 | } | |
| 282 | ||
| 283 | private struct LocalAPISettingsView: View { | |
| 284 | @Environment(\.appDensity) private var appDensity | |
| 285 | @State private var localAPIService = LocalAPIService.shared | |
| 286 | @State private var portText = "" | |
| 287 | ||
| 288 | private var statusText: String { | |
| 289 | if localAPIService.isRunning { return "Running" } | |
| 290 | return localAPIService.config.isEnabled ? "Stopped" : "Disabled" | |
| 291 | } | |
| 292 | ||
| 293 | var body: some View { | |
| 294 | Form { | |
| 295 | Section("Local API") { | |
| 296 | Toggle( | |
| 297 | "Enable Local API", | |
| 298 | isOn: Binding( | |
| 299 | get: { localAPIService.config.isEnabled }, | |
| 300 | set: { localAPIService.setEnabled($0) } | |
| 301 | ) | |
| 302 | ) | |
| 303 | ||
| 304 | TextField( | |
| 305 | "Port", | |
| 306 | text: Binding( | |
| 307 | get: { portText }, | |
| 308 | set: { newValue in | |
| 309 | portText = newValue | |
| 310 | if let port = Int(newValue) { | |
| 311 | localAPIService.setPort(port) | |
| 312 | } | |
| 313 | } | |
| 314 | ) | |
| 315 | ) | |
| 316 | .keyboardType(.numberPad) | |
| 317 | ||
| 318 | LabeledContent("Address", value: localAPIService.address) | |
| 319 | LabeledContent("Status", value: statusText) | |
| 320 | LabeledContent("Token", value: localAPIService.maskedToken) | |
| 321 | ||
| 322 | if let statusMessage = localAPIService.statusMessage { | |
| 323 | Text(statusMessage) | |
| 324 | .font(appDensity.font(.caption, design: .default)) | |
| 325 | .foregroundStyle(Color(.appTextSecondary)) | |
| 326 | } | |
| 327 | } | |
| 328 | ||
| 329 | Section("Authentication") { | |
| 330 | Button("Copy Token") { | |
| 331 | localAPIService.copyToken() | |
| 332 | } | |
| 333 | ||
| 334 | Button("Copy cURL Command") { | |
| 335 | localAPIService.copyCurlCommand() | |
| 336 | } | |
| 337 | ||
| 338 | Button("Rotate Token") { | |
| 339 | localAPIService.rotateToken() | |
| 340 | } | |
| 341 | ||
| 342 | Text("Every request requires either `Authorization: Bearer <token>` or `X-API-Token`. DomainDig stores the token in Keychain and only binds the server to localhost.") | |
| 343 | .font(appDensity.font(.caption, design: .default)) | |
| 344 | .foregroundStyle(Color(.appTextSecondary)) | |
| 345 | } | |
| 346 | ||
| 347 | Section("Request Logging") { | |
| 348 | Toggle( | |
| 349 | "Log Requests", | |
| 350 | isOn: Binding( | |
| 351 | get: { localAPIService.config.requestLoggingEnabled }, | |
| 352 | set: { localAPIService.setRequestLoggingEnabled($0) } | |
| 353 | ) | |
| 354 | ) | |
| 355 | ||
| 356 | if localAPIService.requestLogs.isEmpty { | |
| 357 | Text("No local API requests logged yet.") | |
| 358 | .font(appDensity.font(.caption, design: .default)) | |
| 359 | .foregroundStyle(Color(.appTextSecondary)) | |
| 360 | } else { | |
| 361 | ForEach(localAPIService.requestLogs.prefix(25)) { log in | |
| 362 | VStack(alignment: .leading, spacing: 4) { | |
| 363 | HStack { | |
| 364 | Text("\(log.method) \(log.path)") | |
| 365 | .font(appDensity.font(.callout, design: .monospaced)) | |
| 366 | Spacer() | |
| 367 | Text("\(log.statusCode)") | |
| 368 | .font(appDensity.font(.caption, design: .default)) | |
| 369 | .foregroundStyle(log.statusCode >= 400 ? Color(.statusCritical) : .secondary) | |
| 370 | } | |
| 371 | ||
| 372 | Text(log.timestamp.formatted(date: .abbreviated, time: .standard)) | |
| 373 | .font(appDensity.font(.caption2, design: .default)) | |
| 374 | .foregroundStyle(Color(.appTextSecondary)) | |
| 375 | ||
| 376 | Text("\(Int(log.duration * 1000)) ms") | |
| 377 | .font(appDensity.font(.caption2, design: .default)) | |
| 378 | .foregroundStyle(Color(.appTextSecondary)) | |
| 379 | } | |
| 380 | } | |
| 381 | } | |
| 382 | ||
| 383 | Button("Clear Logs", role: .destructive) { | |
| 384 | localAPIService.clearRequestLogs() | |
| 385 | } | |
| 386 | } | |
| 387 | ||
| 388 | Section("Control") { | |
| 389 | Button("Restart Server") { | |
| 390 | localAPIService.setEnabled(false) | |
| 391 | localAPIService.setEnabled(true) | |
| 392 | } | |
| 393 | .disabled(!localAPIService.config.isEnabled) | |
| 394 | ||
| 395 | Button("Stop Server") { | |
| 396 | localAPIService.stopServer() | |
| 397 | } | |
| 398 | .disabled(!localAPIService.isRunning) | |
| 399 | } | |
| 400 | } | |
| 401 | .navigationTitle("Local API") | |
| 402 | .onAppear { | |
| 403 | portText = String(localAPIService.config.port) | |
| 404 | localAPIService.refresh() | |
| 405 | } | |
| 406 | } | |
| 407 | } | |
| 408 | ||
| 409 | private struct MonitoringSettingsView: View { | |
| 410 | @Environment(\.appDensity) private var appDensity | |
| 411 | @Bindable var viewModel: DomainViewModel | |
| 412 | ||
| 413 | private var notificationAuthorizationLabel: String { | |
| 414 | switch viewModel.monitoringNotificationStatus { | |
| 415 | case .authorized, .provisional, .ephemeral: | |
| 416 | return "Allowed" | |
| 417 | case .denied: | |
| 418 | return "Denied" | |
| 419 | case .notDetermined: | |
| 420 | return "Not Requested" | |
| 421 | @unknown default: | |
| 422 | return "Unknown" | |
| 423 | } | |
| 424 | } | |
| 425 | ||
| 426 | var body: some View { | |
| 427 | Form { | |
| 428 | Section("Monitoring") { | |
| 429 | Toggle( | |
| 430 | "Enable Background Monitoring", | |
| 431 | isOn: Binding( | |
| 432 | get: { viewModel.monitoringSettings.isEnabled }, | |
| 433 | set: { viewModel.setMonitoringEnabled($0) } | |
| 434 | ) | |
| 435 | ) | |
| 436 | ||
| 437 | Picker( | |
| 438 | "Base Interval", | |
| 439 | selection: Binding( | |
| 440 | get: { MonitoringBaseInterval.nearest(to: viewModel.monitoringSettings.baseInterval) }, | |
| 441 | set: { viewModel.setMonitoringBaseInterval($0) } | |
| 442 | ) | |
| 443 | ) { | |
| 444 | ForEach(MonitoringBaseInterval.allCases) { interval in | |
| 445 | Text(interval.title).tag(interval) | |
| 446 | } | |
| 447 | } | |
| 448 | ||
| 449 | Toggle( | |
| 450 | "Adaptive Monitoring", | |
| 451 | isOn: Binding( | |
| 452 | get: { viewModel.monitoringSettings.adaptiveEnabled }, | |
| 453 | set: { viewModel.setMonitoringAdaptiveEnabled($0) } | |
| 454 | ) | |
| 455 | ) | |
| 456 | ||
| 457 | Picker( | |
| 458 | "Sensitivity", | |
| 459 | selection: Binding( | |
| 460 | get: { viewModel.monitoringSettings.sensitivity }, | |
| 461 | set: { viewModel.setMonitoringSensitivity($0) } | |
| 462 | ) | |
| 463 | ) { | |
| 464 | ForEach(MonitoringSensitivity.allCases) { sensitivity in | |
| 465 | Text(sensitivity.title).tag(sensitivity) | |
| 466 | } | |
| 467 | } | |
| 468 | ||
| 469 | let quietHoursStart = viewModel.monitoringSettings.quietHours?.startHour ?? 22 | |
| 470 | let quietHoursEnd = viewModel.monitoringSettings.quietHours?.endHour ?? 7 | |
| 471 | Toggle( | |
| 472 | "Quiet Hours", | |
| 473 | isOn: Binding( | |
| 474 | get: { viewModel.monitoringSettings.quietHours != nil }, | |
| 475 | set: { isEnabled in | |
| 476 | viewModel.setMonitoringQuietHours( | |
| 477 | startHour: quietHoursStart, | |
| 478 | endHour: quietHoursEnd, | |
| 479 | isEnabled: isEnabled | |
| 480 | ) | |
| 481 | } | |
| 482 | ) | |
| 483 | ) | |
| 484 | ||
| 485 | if viewModel.monitoringSettings.quietHours != nil { | |
| 486 | Picker( | |
| 487 | "Quiet Starts", | |
| 488 | selection: Binding( | |
| 489 | get: { quietHoursStart }, | |
| 490 | set: { startHour in | |
| 491 | viewModel.setMonitoringQuietHours( | |
| 492 | startHour: startHour, | |
| 493 | endHour: quietHoursEnd, | |
| 494 | isEnabled: true | |
| 495 | ) | |
| 496 | } | |
| 497 | ) | |
| 498 | ) { | |
| 499 | ForEach(0..<24, id: \.self) { hour in | |
| 500 | Text(Self.monitoringHourLabel(for: hour)).tag(hour) | |
| 501 | } | |
| 502 | } | |
| 503 | ||
| 504 | Picker( | |
| 505 | "Quiet Ends", | |
| 506 | selection: Binding( | |
| 507 | get: { quietHoursEnd }, | |
| 508 | set: { endHour in | |
| 509 | viewModel.setMonitoringQuietHours( | |
| 510 | startHour: quietHoursStart, | |
| 511 | endHour: endHour, | |
| 512 | isEnabled: true | |
| 513 | ) | |
| 514 | } | |
| 515 | ) | |
| 516 | ) { | |
| 517 | ForEach(0..<24, id: \.self) { hour in | |
| 518 | Text(Self.monitoringHourLabel(for: hour)).tag(hour) | |
| 519 | } | |
| 520 | } | |
| 521 | } | |
| 522 | ||
| 523 | Picker( | |
| 524 | "Domains", | |
| 525 | selection: Binding( | |
| 526 | get: { viewModel.monitoringSettings.scope }, | |
| 527 | set: { viewModel.setMonitoringScope($0) } | |
| 528 | ) | |
| 529 | ) { | |
| 530 | ForEach(MonitoringScope.allCases) { scope in | |
| 531 | Text(scope.title).tag(scope) | |
| 532 | } | |
| 533 | } | |
| 534 | ||
| 535 | if viewModel.monitoringSettings.scope == .selectedOnly { | |
| 536 | ForEach(viewModel.trackedDomains) { trackedDomain in | |
| 537 | Toggle( | |
| 538 | trackedDomain.domain, | |
| 539 | isOn: Binding( | |
| 540 | get: { viewModel.monitoringSettings.selectedDomainIDs.contains(trackedDomain.id) }, | |
| 541 | set: { viewModel.setMonitoringSelection(for: trackedDomain, isSelected: $0) } | |
| 542 | ) | |
| 543 | ) | |
| 544 | } | |
| 545 | } | |
| 546 | ||
| 547 | Toggle( | |
| 548 | "Local Alerts", | |
| 549 | isOn: Binding( | |
| 550 | get: { viewModel.monitoringSettings.alertsEnabled }, | |
| 551 | set: { isEnabled in | |
| 552 | if isEnabled { | |
| 553 | Task { | |
| 554 | await viewModel.requestMonitoringNotificationAuthorization() | |
| 555 | } | |
| 556 | } else { | |
| 557 | viewModel.setMonitoringAlertsEnabled(false) | |
| 558 | } | |
| 559 | } | |
| 560 | ) | |
| 561 | ) | |
| 562 | ||
| 563 | Picker( | |
| 564 | "Notify For", | |
| 565 | selection: Binding( | |
| 566 | get: { viewModel.monitoringSettings.alertFilter }, | |
| 567 | set: { viewModel.setMonitoringAlertFilter($0) } | |
| 568 | ) | |
| 569 | ) { | |
| 570 | ForEach(MonitoringAlertFilter.allCases) { filter in | |
| 571 | Text(filter.title).tag(filter) | |
| 572 | } | |
| 573 | } | |
| 574 | ||
| 575 | LabeledContent("Background Refresh", value: DomainMonitoringScheduler.shared.backgroundRefreshStatusDescription()) | |
| 576 | LabeledContent("Notification Access", value: notificationAuthorizationLabel) | |
| 577 | ||
| 578 | if let monitoringStatusMessage = viewModel.monitoringStatusMessage { | |
| 579 | Text(monitoringStatusMessage) | |
| 580 | .font(appDensity.font(.caption, design: .default)) | |
| 581 | .foregroundStyle(Color(.appTextSecondary)) | |
| 582 | } | |
| 583 | ||
| 584 | if !FeatureAccessService.hasAccess(to: .automatedMonitoring) { | |
| 585 | Text("Background monitoring and alerts are available in Pro.") | |
| 586 | .font(appDensity.font(.caption, design: .default)) | |
| 587 | .foregroundStyle(Color(.appTextSecondary)) | |
| 588 | } | |
| 589 | } | |
| 590 | } | |
| 591 | .navigationTitle("Monitoring") | |
| 592 | .onAppear { | |
| 593 | viewModel.refreshMonitoringState() | |
| 594 | Task { | |
| 595 | await viewModel.refreshMonitoringAuthorizationStatus() | |
| 596 | } | |
| 597 | } | |
| 598 | } | |
| 599 | ||
| 600 | private static func monitoringHourLabel(for hour: Int) -> String { | |
| 601 | let formatter = DateFormatter() | |
| 602 | formatter.dateFormat = "h a" | |
| 603 | let components = DateComponents(calendar: .current, hour: hour) | |
| 604 | return components.date.map(formatter.string(from:)) ?? "\(hour):00" | |
| 605 | } | |
| 606 | } | |
| 607 | ||
| 608 | private struct DataPortabilitySettingsView: View { | |
| 609 | private enum ImportTarget { | |
| 610 | case backup | |
| 611 | case trackedDomains | |
| 612 | case workflows | |
| 613 | ||
| 614 | var expectedKind: DataPortabilityImportKind { | |
| 615 | switch self { | |
| 616 | case .backup: | |
| 617 | return .backup | |
| 618 | case .trackedDomains: | |
| 619 | return .trackedDomains | |
| 620 | case .workflows: | |
| 621 | return .workflows | |
| 622 | } | |
| 623 | } | |
| 624 | ||
| 625 | var allowedContentTypes: [UTType] { | |
| 626 | switch self { | |
| 627 | case .backup: | |
| 628 | return [UTType.json] | |
| 629 | case .trackedDomains, .workflows: | |
| 630 | return [UTType.json, UTType.commaSeparatedText] | |
| 631 | } | |
| 632 | } | |
| 633 | } | |
| 634 | ||
| 635 | @Environment(\.appDensity) private var appDensity | |
| 636 | @Bindable var viewModel: DomainViewModel | |
| 637 | ||
| 638 | @State private var importMode: DataPortabilityImportMode = .merge | |
| 639 | @State private var activeImportTarget: ImportTarget? | |
| 640 | @State private var pendingImportTarget: ImportTarget? | |
| 641 | @State private var importDebugStatus: String? | |
| 642 | @State private var pendingImportPreview: DataImportPreview? | |
| 643 | @State private var pendingImportError: String? | |
| 644 | @State private var showReplaceImportConfirmation = false | |
| 645 | ||
| 646 | var body: some View { | |
| 647 | Form { | |
| 648 | Section("Import & Export") { | |
| 649 | Picker("Import Mode", selection: $importMode) { | |
| 650 | ForEach(DataPortabilityImportMode.allCases) { mode in | |
| 651 | Text(mode.title).tag(mode) | |
| 652 | } | |
| 653 | } | |
| 654 | ||
| 655 | Text(importMode.explanation) | |
| 656 | .font(appDensity.font(.caption, design: .default)) | |
| 657 | .foregroundStyle(Color(.appTextSecondary)) | |
| 658 | ||
| 659 | Button("Export Full Backup") { | |
| 660 | exportFullBackup() | |
| 661 | } | |
| 662 | ||
| 663 | Button("Import Backup") { | |
| 664 | recordImportDebugStatus("Tapped Import Backup") | |
| 665 | pendingImportTarget = .backup | |
| 666 | activeImportTarget = .backup | |
| 667 | } | |
| 668 | ||
| 669 | Menu("Export Tracked Domains") { | |
| 670 | Button("JSON") { | |
| 671 | exportPortableTrackedDomainsJSON() | |
| 672 | } | |
| 673 | Button("CSV") { | |
| 674 | exportPortableTrackedDomainsCSV() | |
| 675 | } | |
| 676 | } | |
| 677 | ||
| 678 | Button("Import Tracked Domains") { | |
| 679 | recordImportDebugStatus("Tapped Import Tracked Domains") | |
| 680 | pendingImportTarget = .trackedDomains | |
| 681 | activeImportTarget = .trackedDomains | |
| 682 | } | |
| 683 | ||
| 684 | Menu("Export Workflows") { | |
| 685 | Button("JSON") { | |
| 686 | exportPortableWorkflowsJSON() | |
| 687 | } | |
| 688 | Button("CSV") { | |
| 689 | exportPortableWorkflowsCSV() | |
| 690 | } | |
| 691 | } | |
| 692 | ||
| 693 | Button("Import Workflows") { | |
| 694 | recordImportDebugStatus("Tapped Import Workflows") | |
| 695 | pendingImportTarget = .workflows | |
| 696 | activeImportTarget = .workflows | |
| 697 | } | |
| 698 | ||
| 699 | Button("Export History") { | |
| 700 | exportPortableHistoryJSON() | |
| 701 | } | |
| 702 | } | |
| 703 | ||
| 704 | Section("Local Data") { | |
| 705 | LabeledContent("Tracked Domains", value: "\(viewModel.dataLifecycleSummary.trackedDomains)") | |
| 706 | LabeledContent("History Snapshots", value: "\(viewModel.dataLifecycleSummary.historySnapshots)") | |
| 707 | LabeledContent("Audit Sessions", value: "\(viewModel.dataLifecycleSummary.auditSessions)") | |
| 708 | LabeledContent("Workflows", value: "\(viewModel.dataLifecycleSummary.workflows)") | |
| 709 | LabeledContent("Cached Items", value: "\(viewModel.dataLifecycleSummary.cachedItems)") | |
| 710 | LabeledContent("Monitoring Logs", value: "\(viewModel.dataLifecycleSummary.monitoringLogs)") | |
| 711 | ||
| 712 | Text("Data stays on this device unless you export it. Backup files can include domain history, monitoring settings, and notes. Imported files are processed on-device.") | |
| 713 | .font(appDensity.font(.caption, design: .default)) | |
| 714 | .foregroundStyle(Color(.appTextSecondary)) | |
| 715 | ||
| 716 | if let portabilityStatusMessage = viewModel.portabilityStatusMessage { | |
| 717 | Text(portabilityStatusMessage) | |
| 718 | .font(appDensity.font(.caption, design: .default)) | |
| 719 | .foregroundStyle(Color(.appTextSecondary)) | |
| 720 | } | |
| 721 | } | |
| 722 | ||
| 723 | #if DEBUG | |
| 724 | if let importDebugStatus { | |
| 725 | Section("Import Debug") { | |
| 726 | Text(importDebugStatus) | |
| 727 | .font(appDensity.font(.caption, design: .default)) | |
| 728 | .foregroundStyle(Color(.appTextSecondary)) | |
| 729 | .textSelection(.enabled) | |
| 730 | } | |
| 731 | } | |
| 732 | #endif | |
| 733 | } | |
| 734 | .navigationTitle("Import & Export") | |
| 735 | .alert("Replace local data?", isPresented: $showReplaceImportConfirmation) { | |
| 736 | Button("Replace", role: .destructive) { | |
| 737 | applyPendingImport() | |
| 738 | } | |
| 739 | Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } | |
| 740 | } message: { | |
| 741 | Text("Replace mode overwrites local data covered by the imported file and may remove items that are only on this device.") | |
| 742 | } | |
| 743 | .alert("Import Error", isPresented: Binding( | |
| 744 | get: { pendingImportError != nil }, | |
| 745 | set: { if !$0 { pendingImportError = nil } } | |
| 746 | )) { | |
| 747 | Button("OK", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } | |
| 748 | } message: { | |
| 749 | Text(pendingImportError ?? "The import could not be completed.") | |
| 750 | } | |
| 751 | .sheet(isPresented: Binding( | |
| 752 | get: { pendingImportPreview != nil }, | |
| 753 | set: { if !$0 { pendingImportPreview = nil } } | |
| 754 | )) { | |
| 755 | if let pendingImportPreview { | |
| 756 | DataImportPreviewSheet( | |
| 757 | preview: pendingImportPreview, | |
| 758 | mode: importMode, | |
| 759 | onCancel: { | |
| 760 | self.pendingImportPreview = nil | |
| 761 | }, | |
| 762 | onApply: { | |
| 763 | if importMode == .replace { | |
| 764 | showReplaceImportConfirmation = true | |
| 765 | } else { | |
| 766 | applyPendingImport() | |
| 767 | } | |
| 768 | } | |
| 769 | ) | |
| 770 | } | |
| 771 | } | |
| 772 | .fileImporter( | |
| 773 | isPresented: Binding( | |
| 774 | get: { activeImportTarget != nil }, | |
| 775 | set: { if !$0 { activeImportTarget = nil } } | |
| 776 | ), | |
| 777 | allowedContentTypes: activeImportTarget?.allowedContentTypes ?? [UTType.json], | |
| 778 | allowsMultipleSelection: false | |
| 779 | ) { result in | |
| 780 | guard let pendingImportTarget else { | |
| 781 | recordImportDebugStatus("fileImporter returned with no active target") | |
| 782 | return | |
| 783 | } | |
| 784 | recordImportDebugStatus("fileImporter returned for \(pendingImportTarget.expectedKind.rawValue)") | |
| 785 | handleImportResult(result, expectedKind: pendingImportTarget.expectedKind) | |
| 786 | self.pendingImportTarget = nil | |
| 787 | self.activeImportTarget = nil | |
| 788 | } | |
| 789 | .onAppear { | |
| 790 | viewModel.refreshDataLifecycleSummary() | |
| 791 | } | |
| 792 | } | |
| 793 | ||
| 794 | private func exportFullBackup() { | |
| 795 | guard let data = viewModel.exportFullBackupData() else { return } | |
| 796 | ExportPresenter.share(filename: portabilityFilename(suffix: "backup", fileExtension: "json"), data: data) | |
| 797 | } | |
| 798 | ||
| 799 | private func exportPortableTrackedDomainsJSON() { | |
| 800 | guard let data = viewModel.exportPortableTrackedDomainsJSONData() else { return } | |
| 801 | ExportPresenter.share(filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "json"), data: data) | |
| 802 | } | |
| 803 | ||
| 804 | private func exportPortableTrackedDomainsCSV() { | |
| 805 | ExportPresenter.share( | |
| 806 | filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "csv"), | |
| 807 | contents: viewModel.exportPortableTrackedDomainsCSV() | |
| 808 | ) | |
| 809 | } | |
| 810 | ||
| 811 | private func exportPortableWorkflowsJSON() { | |
| 812 | guard let data = viewModel.exportPortableWorkflowsJSONData() else { return } | |
| 813 | ExportPresenter.share(filename: portabilityFilename(suffix: "workflows", fileExtension: "json"), data: data) | |
| 814 | } | |
| 815 | ||
| 816 | private func exportPortableWorkflowsCSV() { | |
| 817 | ExportPresenter.share( | |
| 818 | filename: portabilityFilename(suffix: "workflows", fileExtension: "csv"), | |
| 819 | contents: viewModel.exportPortableWorkflowsCSV() | |
| 820 | ) | |
| 821 | } | |
| 822 | ||
| 823 | private func exportPortableHistoryJSON() { | |
| 824 | guard let data = viewModel.exportPortableHistoryJSONData() else { return } | |
| 825 | ExportPresenter.share(filename: portabilityFilename(suffix: "history", fileExtension: "json"), data: data) | |
| 826 | } | |
| 827 | ||
| 828 | private func handleImportResult( | |
| 829 | _ result: Result<[URL], Error>, | |
| 830 | expectedKind: DataPortabilityImportKind | |
| 831 | ) { | |
| 832 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult expectedKind=\(expectedKind.rawValue)") | |
| 833 | recordImportDebugStatus("handleImportResult started for \(expectedKind.rawValue)") | |
| 834 | do { | |
| 835 | let urls = try result.get() | |
| 836 | guard let url = urls.first else { | |
| 837 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult noURLReturned") | |
| 838 | recordImportDebugStatus("No URL returned from picker") | |
| 839 | return | |
| 840 | } | |
| 841 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult selectedURL=\(url.absoluteString)") | |
| 842 | recordImportDebugStatus("Selected \(url.lastPathComponent)") | |
| 843 | let shouldStopAccessing = url.startAccessingSecurityScopedResource() | |
| 844 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeGranted=\(shouldStopAccessing)") | |
| 845 | recordImportDebugStatus("Security scope granted: \(shouldStopAccessing)") | |
| 846 | defer { | |
| 847 | if shouldStopAccessing { | |
| 848 | url.stopAccessingSecurityScopedResource() | |
| 849 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeReleased") | |
| 850 | } | |
| 851 | } | |
| 852 | ||
| 853 | let data = try Data(contentsOf: url) | |
| 854 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult dataRead bytes=\(data.count) fileName=\(url.lastPathComponent)") | |
| 855 | recordImportDebugStatus("Read \(data.count) bytes from \(url.lastPathComponent)") | |
| 856 | let preview = try viewModel.prepareDataImport( | |
| 857 | data: data, | |
| 858 | fileName: url.lastPathComponent, | |
| 859 | mode: importMode | |
| 860 | ) | |
| 861 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult previewReady previewKind=\(preview.kind.rawValue) expectedKind=\(expectedKind.rawValue)") | |
| 862 | recordImportDebugStatus("Preview ready: \(preview.kind.rawValue)") | |
| 863 | ||
| 864 | guard preview.kind == expectedKind else { | |
| 865 | let message = preview.kind == .backup | |
| 866 | ? "That file is a full backup. Use Import Backup." | |
| 867 | : "That file type does not match this import action." | |
| 868 | DomainDebugLog.error("DataPortabilitySettingsView.handleImportResult kindMismatch message=\(message)") | |
| 869 | recordImportDebugStatus("Kind mismatch: \(message)") | |
| 870 | presentImportError(message) | |
| 871 | return | |
| 872 | } | |
| 873 | ||
| 874 | DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult presentingPreview kind=\(preview.kind.rawValue)") | |
| 875 | recordImportDebugStatus("Presenting preview for \(preview.kind.rawValue)") | |
| 876 | presentImportPreview(preview) | |
| 877 | } catch { | |
| 878 | DomainDebugLog.error("DataPortabilitySettingsView.handleImportResult failed error=\(error.localizedDescription)") | |
| 879 | recordImportDebugStatus("Import failed: \(error.localizedDescription)") | |
| 880 | presentImportError(error.localizedDescription) | |
| 881 | } | |
| 882 | } | |
| 883 | ||
| 884 | private func applyPendingImport() { | |
| 885 | guard let pendingImportPreview else { return } | |
| 886 | do { | |
| 887 | _ = try viewModel.applyDataImport(pendingImportPreview, mode: importMode) | |
| 888 | self.pendingImportPreview = nil | |
| 889 | } catch { | |
| 890 | pendingImportError = error.localizedDescription | |
| 891 | } | |
| 892 | } | |
| 893 | ||
| 894 | private func portabilityFilename(suffix: String, fileExtension: String) -> String { | |
| 895 | let formatter = DateFormatter() | |
| 896 | formatter.dateFormat = "yyyyMMdd_HHmmss" | |
| 897 | return "\(formatter.string(from: Date()))_domaindig_\(suffix).\(fileExtension)" | |
| 898 | } | |
| 899 | ||
| 900 | private func presentImportPreview(_ preview: DataImportPreview) { | |
| 901 | Task { @MainActor in | |
| 902 | try? await Task.sleep(for: .milliseconds(300)) | |
| 903 | DomainDebugLog.debug("DataPortabilitySettingsView.presentImportPreview kind=\(preview.kind.rawValue) fileName=\(preview.fileName)") | |
| 904 | recordImportDebugStatus("Preview presented for \(preview.fileName)") | |
| 905 | pendingImportPreview = preview | |
| 906 | } | |
| 907 | } | |
| 908 | ||
| 909 | private func presentImportError(_ message: String) { | |
| 910 | Task { @MainActor in | |
| 911 | try? await Task.sleep(for: .milliseconds(300)) | |
| 912 | DomainDebugLog.error("DataPortabilitySettingsView.presentImportError message=\(message)") | |
| 913 | recordImportDebugStatus("Error presented: \(message)") | |
| 914 | pendingImportError = message | |
| 915 | } | |
| 916 | } | |
| 917 | ||
| 918 | private func recordImportDebugStatus(_ message: String) { | |
| 919 | #if DEBUG | |
| 920 | let status = "[Import Debug] \(message)" | |
| 921 | importDebugStatus = status | |
| 922 | print(status) | |
| 923 | #endif | |
| 924 | } | |
| 925 | } | |
| 926 | ||
| 927 | private struct DataManagementSettingsView: View { | |
| 928 | @Bindable var viewModel: DomainViewModel | |
| 929 | @Environment(\.accessibilityReduceTransparency) private var reduceTransparency | |
| 930 | ||
| 931 | @State private var showClearHistoryConfirmation = false | |
| 932 | @State private var showClearCacheConfirmation = false | |
| 933 | @State private var showClearWorkflowsConfirmation = false | |
| 934 | @State private var showClearTrackedDomainsConfirmation = false | |
| 935 | @State private var showDeleteAllConfirmation = false | |
| 936 | @State private var deleteAllErrorMessage: String? | |
| 937 | @State private var deleteAllSuccessMessage: String? | |
| 938 | @State private var isDeletingAllData = false | |
| 939 | ||
| 940 | var body: some View { | |
| 941 | Form { | |
| 942 | Section("Data") { | |
| 943 | Button("Clear History", role: .destructive) { | |
| 944 | showClearHistoryConfirmation = true | |
| 945 | } | |
| 946 | ||
| 947 | Button("Clear Cache", role: .destructive) { | |
| 948 | showClearCacheConfirmation = true | |
| 949 | } | |
| 950 | ||
| 951 | Button("Clear Workflows", role: .destructive) { | |
| 952 | showClearWorkflowsConfirmation = true | |
| 953 | } | |
| 954 | ||
| 955 | Button("Clear Tracked Domains", role: .destructive) { | |
| 956 | showClearTrackedDomainsConfirmation = true | |
| 957 | } | |
| 958 | } | |
| 959 | ||
| 960 | Section { | |
| 961 | Button(role: .destructive) { | |
| 962 | showDeleteAllConfirmation = true | |
| 963 | } label: { | |
| 964 | HStack { | |
| 965 | Text("Delete All Data") | |
| 966 | Spacer() | |
| 967 | if isDeletingAllData { | |
| 968 | ProgressView() | |
| 969 | .controlSize(.small) | |
| 970 | } | |
| 971 | } | |
| 972 | } | |
| 973 | .disabled(isDeletingAllData) | |
| 974 | } header: { | |
| 975 | Text("Danger Zone") | |
| 976 | } footer: { | |
| 977 | Text("Permanently removes all local DomainDig data from this device.") | |
| 978 | } | |
| 979 | } | |
| 980 | .disabled(isDeletingAllData) | |
| 981 | .navigationTitle("Data Management") | |
| 982 | .alert("Clear history?", isPresented: $showClearHistoryConfirmation) { | |
| 983 | Button("Clear", role: .destructive) { | |
| 984 | viewModel.clearHistory() | |
| 985 | } | |
| 986 | Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } | |
| 987 | } message: { | |
| 988 | Text("This removes saved lookup snapshots and clears monitoring run history on this device.") | |
| 989 | } | |
| 990 | .alert("Clear cache?", isPresented: $showClearCacheConfirmation) { | |
| 991 | Button("Clear", role: .destructive) { | |
| 992 | viewModel.clearLookupCache() | |
| 993 | } | |
| 994 | Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } | |
| 995 | } message: { | |
| 996 | Text("This clears the in-memory lookup cache and cancels any cached in-flight work.") | |
| 997 | } | |
| 998 | .alert("Clear workflows?", isPresented: $showClearWorkflowsConfirmation) { | |
| 999 | Button("Clear", role: .destructive) { | |
| 1000 | viewModel.clearWorkflows() | |
| 1001 | } | |
| 1002 | Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } | |
| 1003 | } message: { | |
| 1004 | Text("This removes saved workflows only. History, tracked domains, and saved reports stay intact.") | |
| 1005 | } | |
| 1006 | .alert("Clear tracked domains?", isPresented: $showClearTrackedDomainsConfirmation) { | |
| 1007 | Button("Clear", role: .destructive) { | |
| 1008 | viewModel.clearTrackedDomains() | |
| 1009 | } | |
| 1010 | Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } | |
| 1011 | } message: { | |
| 1012 | Text("This removes the watchlist and clears monitoring run history. History and workflows stay intact.") | |
| 1013 | } | |
| 1014 | .alert("Delete All Data?", isPresented: $showDeleteAllConfirmation) { | |
| 1015 | Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } | |
| 1016 | Button("Delete All Data", role: .destructive) { | |
| 1017 | deleteAllData() | |
| 1018 | } | |
| 1019 | } message: { | |
| 1020 | Text("This will permanently remove all saved DomainDig data from this device. This includes tracked domains, monitoring history, snapshots, exports, cached reports, and local settings. This action cannot be undone.") | |
| 1021 | } | |
| 1022 | .alert("Delete Failed", isPresented: Binding( | |
| 1023 | get: { deleteAllErrorMessage != nil }, | |
| 1024 | set: { if !$0 { deleteAllErrorMessage = nil } } | |
| 1025 | )) { | |
| 1026 | Button("OK", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } | |
| 1027 | } message: { | |
| 1028 | Text(deleteAllErrorMessage ?? "The local data reset could not be completed.") | |
| 1029 | } | |
| 1030 | .safeAreaInset(edge: .bottom) { | |
| 1031 | if let deleteAllSuccessMessage { | |
| 1032 | Text(deleteAllSuccessMessage) | |
| 1033 | .font(.footnote.weight(.medium)) | |
| 1034 | .foregroundStyle(Color(.appTextSecondary)) | |
| 1035 | .padding(.horizontal, 14) | |
| 1036 | .padding(.vertical, 10) | |
| 1037 | // Reduce Transparency swaps the blur for an opaque surface. | |
| 1038 | // On iOS 26+ the system also composites its own translucency | |
| 1039 | // that the app cannot declare — verify there too (Phase 6). | |
| 1040 | .background( | |
| 1041 | Capsule().fill(reduceTransparency ? AnyShapeStyle(Color(.appSurfaceElevated)) : AnyShapeStyle(.thinMaterial)) | |
| 1042 | ) | |
| 1043 | .padding(.bottom, 8) | |
| 1044 | .transition(.move(edge: .bottom).combined(with: .opacity)) | |
| 1045 | } | |
| 1046 | } | |
| 1047 | } | |
| 1048 | ||
| 1049 | private func deleteAllData() { | |
| 1050 | guard !isDeletingAllData else { return } | |
| 1051 | ||
| 1052 | isDeletingAllData = true | |
| 1053 | deleteAllErrorMessage = nil | |
| 1054 | deleteAllSuccessMessage = nil | |
| 1055 | ||
| 1056 | Task { | |
| 1057 | do { | |
| 1058 | try await DataResetService.wipeAllLocalData(viewModel: viewModel) | |
| 1059 | deleteAllSuccessMessage = "All local data removed." | |
| 1060 | try? await Task.sleep(for: .seconds(2)) | |
| 1061 | if deleteAllSuccessMessage == "All local data removed." { | |
| 1062 | deleteAllSuccessMessage = nil | |
| 1063 | } | |
| 1064 | } catch { | |
| 1065 | deleteAllErrorMessage = error.localizedDescription | |
| 1066 | } | |
| 1067 | ||
| 1068 | isDeletingAllData = false | |
| 1069 | } | |
| 1070 | } | |
| 1071 | } | |
| 1072 | ||
| 1073 | private struct AboutSettingsView: View { | |
| 1074 | @State private var cloudSyncService = CloudSyncService.shared | |
| 1075 | ||
| 1076 | private var appVersion: String { | |
| 1077 | AppVersion.current | |
| 1078 | } | |
| 1079 | ||
| 1080 | var body: some View { | |
| 1081 | Form { | |
| 1082 | Section("About") { | |
| 1083 | LabeledContent("Version", value: appVersion) | |
| 1084 | LabeledContent("Storage", value: cloudSyncService.isEnabled ? "Local-first + iCloud" : "Local-only") | |
| 1085 | LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)") | |
| 1086 | } | |
| 1087 | } | |
| 1088 | .navigationTitle("App Info") | |
| 1089 | .task { | |
| 1090 | await cloudSyncService.refreshAvailability() | |
| 1091 | } | |
| 1092 | } | |
| 1093 | } | |
| 1094 | ||
| 1095 | private struct DataImportPreviewSheet: View { | |
| 1096 | @Environment(\.dismiss) private var dismiss | |
| 1097 | ||
| 1098 | let preview: DataImportPreview | |
| 1099 | let mode: DataPortabilityImportMode | |
| 1100 | let onCancel: () -> Void | |
| 1101 | let onApply: () -> Void | |
| 1102 | ||
| 1103 | var body: some View { | |
| 1104 | NavigationStack { | |
| 1105 | List { | |
| 1106 | Section("Summary") { | |
| 1107 | ForEach(preview.summaryLines, id: \.self) { line in | |
| 1108 | Text(line) | |
| 1109 | } | |
| 1110 | } | |
| 1111 | ||
| 1112 | Section("Projected Counts") { | |
| 1113 | LabeledContent("Tracked Domains", value: "\(preview.projectedCounts.trackedDomains)") | |
| 1114 | LabeledContent("History Snapshots", value: "\(preview.projectedCounts.historySnapshots)") | |
| 1115 | LabeledContent("Audit Sessions", value: "\(preview.projectedCounts.auditSessions)") | |
| 1116 | LabeledContent("Workflows", value: "\(preview.projectedCounts.workflows)") | |
| 1117 | LabeledContent("Cached Items", value: "\(preview.projectedCounts.cachedItems)") | |
| 1118 | LabeledContent("Monitoring Logs", value: "\(preview.projectedCounts.monitoringLogs)") | |
| 1119 | } | |
| 1120 | ||
| 1121 | if !preview.warnings.isEmpty { | |
| 1122 | Section("Warnings") { | |
| 1123 | ForEach(preview.warnings, id: \.self) { warning in | |
| 1124 | Text(warning) | |
| 1125 | .foregroundStyle(Color(.appTextSecondary)) | |
| 1126 | } | |
| 1127 | } | |
| 1128 | } | |
| 1129 | } | |
| 1130 | .navigationTitle("Import Preview") | |
| 1131 | .toolbar { | |
| 1132 | ToolbarItem(placement: .cancellationAction) { | |
| 1133 | Button("Cancel") { | |
| 1134 | onCancel() | |
| 1135 | dismiss() | |
| 1136 | } | |
| 1137 | } | |
| 1138 | ToolbarItem(placement: .confirmationAction) { | |
| 1139 | Button(mode == .replace ? "Replace" : "Import") { | |
| 1140 | onApply() | |
| 1141 | if mode == .merge { | |
| 1142 | dismiss() | |
| 1143 | } | |
| 1144 | } | |
| 1145 | } | |
| 1146 | } | |
| 1147 | } | |
| 1148 | } | |
| 1149 | } | |