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>
334 lines
14 KiB
Swift
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()
|
|
}
|