Files
honeyDueKMP/iosApp/iosApp/Profile/NotificationPreferencesView.swift
Trey t c726320c1e Add comprehensive i18n localization for KMM and iOS
KMM (Android/Shared):
- Add strings.xml with 200+ localized strings
- Add translation files for es, fr, de, pt languages
- Update all screens to use stringResource() for i18n
- Add Accept-Language header to API client for all platforms

iOS:
- Add L10n.swift helper with type-safe string accessors
- Add Localizable.xcstrings with translations for all 5 languages
- Update all SwiftUI views to use L10n.* for localized strings
- Localize Auth, Residence, Task, Contractor, Document, and Profile views

Supported languages: English, Spanish, French, German, Portuguese

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 02:02:00 -06:00

334 lines
14 KiB
Swift

import SwiftUI
import ComposeApp
struct NotificationPreferencesView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = NotificationPreferencesViewModelWrapper()
var body: some View {
NavigationStack {
Form {
// Header Section
Section {
VStack(spacing: 16) {
Image(systemName: "bell.badge.fill")
.font(.system(size: 60))
.foregroundStyle(Color.appPrimary.gradient)
Text(L10n.Profile.notificationPreferences)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary)
Text(L10n.Profile.notificationPreferencesSubtitle)
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical)
}
.listRowBackground(Color.clear)
if viewModel.isLoading {
Section {
HStack {
Spacer()
ProgressView()
.tint(Color.appPrimary)
Spacer()
}
.padding(.vertical, 20)
}
.listRowBackground(Color.appBackgroundSecondary)
} else if let errorMessage = viewModel.errorMessage {
Section {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(Color.appError)
Text(errorMessage)
.foregroundColor(Color.appError)
.font(.subheadline)
}
Button(L10n.Common.retry) {
viewModel.loadPreferences()
}
.foregroundColor(Color.appPrimary)
}
.listRowBackground(Color.appBackgroundSecondary)
} else {
// Task Notifications
Section {
Toggle(isOn: $viewModel.taskDueSoon) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.Profile.taskDueSoon)
.foregroundColor(Color.appTextPrimary)
Text(L10n.Profile.taskDueSoonDescription)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
} icon: {
Image(systemName: "clock.fill")
.foregroundColor(Color.appAccent)
}
}
.tint(Color.appPrimary)
.onChange(of: viewModel.taskDueSoon) { _, newValue in
viewModel.updatePreference(taskDueSoon: newValue)
}
Toggle(isOn: $viewModel.taskOverdue) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.Profile.taskOverdue)
.foregroundColor(Color.appTextPrimary)
Text(L10n.Profile.taskOverdueDescription)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
} icon: {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
}
}
.tint(Color.appPrimary)
.onChange(of: viewModel.taskOverdue) { _, newValue in
viewModel.updatePreference(taskOverdue: newValue)
}
Toggle(isOn: $viewModel.taskCompleted) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.Profile.taskCompleted)
.foregroundColor(Color.appTextPrimary)
Text(L10n.Profile.taskCompletedDescription)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
} icon: {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color.appPrimary)
}
}
.tint(Color.appPrimary)
.onChange(of: viewModel.taskCompleted) { _, newValue in
viewModel.updatePreference(taskCompleted: newValue)
}
Toggle(isOn: $viewModel.taskAssigned) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.Profile.taskAssigned)
.foregroundColor(Color.appTextPrimary)
Text(L10n.Profile.taskAssignedDescription)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
} icon: {
Image(systemName: "person.badge.plus.fill")
.foregroundColor(Color.appSecondary)
}
}
.tint(Color.appPrimary)
.onChange(of: viewModel.taskAssigned) { _, newValue in
viewModel.updatePreference(taskAssigned: newValue)
}
} header: {
Text(L10n.Profile.taskNotifications)
}
.listRowBackground(Color.appBackgroundSecondary)
// Other Notifications
Section {
Toggle(isOn: $viewModel.residenceShared) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.Profile.propertyShared)
.foregroundColor(Color.appTextPrimary)
Text(L10n.Profile.propertySharedDescription)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
} icon: {
Image("house_outline")
.resizable()
.frame(width: 22, height: 22)
.foregroundColor(Color.appTextOnPrimary)
.background(content: {
RoundedRectangle(cornerRadius: AppRadius.sm)
.fill(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
.frame(width: 22, height: 22)
.shadow(color: Color.appPrimary.opacity(0.3), radius: 6, y: 3)
})
}
}
.tint(Color.appPrimary)
.onChange(of: viewModel.residenceShared) { _, newValue in
viewModel.updatePreference(residenceShared: newValue)
}
Toggle(isOn: $viewModel.warrantyExpiring) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.Profile.warrantyExpiring)
.foregroundColor(Color.appTextPrimary)
Text(L10n.Profile.warrantyExpiringDescription)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
} icon: {
Image(systemName: "doc.badge.clock.fill")
.foregroundColor(Color.appAccent)
}
}
.tint(Color.appPrimary)
.onChange(of: viewModel.warrantyExpiring) { _, newValue in
viewModel.updatePreference(warrantyExpiring: newValue)
}
} header: {
Text(L10n.Profile.otherNotifications)
}
.listRowBackground(Color.appBackgroundSecondary)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle(L10n.Profile.notifications)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.Common.done) {
dismiss()
}
.foregroundColor(Color.appPrimary)
}
}
}
.onAppear {
viewModel.loadPreferences()
}
}
}
// MARK: - ViewModel Wrapper
@MainActor
class NotificationPreferencesViewModelWrapper: ObservableObject {
@Published var taskDueSoon: Bool = true
@Published var taskOverdue: Bool = true
@Published var taskCompleted: Bool = true
@Published var taskAssigned: Bool = true
@Published var residenceShared: Bool = true
@Published var warrantyExpiring: Bool = true
@Published var isLoading: Bool = false
@Published var errorMessage: String?
@Published var isSaving: Bool = false
private let sharedViewModel = ComposeApp.NotificationPreferencesViewModel()
private var preferencesTask: Task<Void, Never>?
private var updateTask: Task<Void, Never>?
func loadPreferences() {
preferencesTask?.cancel()
isLoading = true
errorMessage = nil
sharedViewModel.loadPreferences()
preferencesTask = Task {
for await state in sharedViewModel.preferencesState {
if Task.isCancelled { break }
await MainActor.run {
switch state {
case let success as ApiResultSuccess<NotificationPreference>:
if let prefs = success.data {
self.taskDueSoon = prefs.taskDueSoon
self.taskOverdue = prefs.taskOverdue
self.taskCompleted = prefs.taskCompleted
self.taskAssigned = prefs.taskAssigned
self.residenceShared = prefs.residenceShared
self.warrantyExpiring = prefs.warrantyExpiring
}
self.isLoading = false
self.errorMessage = nil
case let error as ApiResultError:
self.errorMessage = error.message
self.isLoading = false
case is ApiResultLoading:
self.isLoading = true
default:
break
}
}
// Break after success or error
if state is ApiResultSuccess<NotificationPreference> || state is ApiResultError {
break
}
}
}
}
func updatePreference(
taskDueSoon: Bool? = nil,
taskOverdue: Bool? = nil,
taskCompleted: Bool? = nil,
taskAssigned: Bool? = nil,
residenceShared: Bool? = nil,
warrantyExpiring: Bool? = nil
) {
updateTask?.cancel()
isSaving = true
sharedViewModel.updatePreference(
taskDueSoon: taskDueSoon.map { KotlinBoolean(bool: $0) },
taskOverdue: taskOverdue.map { KotlinBoolean(bool: $0) },
taskCompleted: taskCompleted.map { KotlinBoolean(bool: $0) },
taskAssigned: taskAssigned.map { KotlinBoolean(bool: $0) },
residenceShared: residenceShared.map { KotlinBoolean(bool: $0) },
warrantyExpiring: warrantyExpiring.map { KotlinBoolean(bool: $0) }
)
updateTask = Task {
for await state in sharedViewModel.updateState {
if Task.isCancelled { break }
await MainActor.run {
switch state {
case is ApiResultSuccess<NotificationPreference>:
self.isSaving = false
self.sharedViewModel.resetUpdateState()
case let error as ApiResultError:
self.errorMessage = error.message
self.isSaving = false
self.sharedViewModel.resetUpdateState()
case is ApiResultLoading:
self.isSaving = true
default:
break
}
}
// Break after success or error
if state is ApiResultSuccess<NotificationPreference> || state is ApiResultError {
break
}
}
}
}
deinit {
preferencesTask?.cancel()
updateTask?.cancel()
}
}
#Preview {
NotificationPreferencesView()
}