Add notification preferences UI and subscription verification on launch
- Add NotificationPreferencesScreen (Android) and NotificationPreferencesView (iOS) - Add NotificationPreferencesViewModel for shared business logic - Wire up notification preferences from ProfileScreen on both platforms - Add subscription verification on app launch for iOS (StoreKit) and Android (Google Play Billing) - Update SubscriptionApi to match Go backend endpoints (/subscription/purchase/) - Update StoreKit Configuration with correct product IDs and pricing ($2.99/month, $27.99/year) - Update Android placeholder prices to match App Store pricing - Fix NotificationPreference model to match Go backend schema 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -76,7 +76,7 @@
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "4.99",
|
||||
"displayPrice" : "2.99",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 1,
|
||||
"internalID" : "6738711291",
|
||||
@@ -89,13 +89,13 @@
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Unlock unlimited properties, tasks, contractors, and documents",
|
||||
"displayName" : "MyCrib Pro Monthly",
|
||||
"displayName" : "Casera Pro Monthly",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "com.example.mycrib.pro.monthly",
|
||||
"productID" : "com.example.casera.pro.monthly",
|
||||
"recurringSubscriptionPeriod" : "P1M",
|
||||
"referenceName" : "MyCrib Pro Monthly",
|
||||
"referenceName" : "Casera Pro Monthly",
|
||||
"subscriptionGroupID" : "21517970",
|
||||
"type" : "RecurringSubscription"
|
||||
},
|
||||
@@ -106,7 +106,7 @@
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "49.99",
|
||||
"displayPrice" : "27.99",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 2,
|
||||
"internalID" : "6738711458",
|
||||
@@ -118,14 +118,14 @@
|
||||
},
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Unlock unlimited properties, tasks, contractors, and documents - Save 17% with annual billing",
|
||||
"displayName" : "MyCrib Pro Annual",
|
||||
"description" : "Unlock unlimited properties, tasks, contractors, and documents - Save 22% with annual billing",
|
||||
"displayName" : "Casera Pro Annual",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "com.example.mycrib.pro.annual",
|
||||
"productID" : "com.example.casera.pro.annual",
|
||||
"recurringSubscriptionPeriod" : "P1Y",
|
||||
"referenceName" : "MyCrib Pro Annual",
|
||||
"referenceName" : "Casera Pro Annual",
|
||||
"subscriptionGroupID" : "21517970",
|
||||
"type" : "RecurringSubscription"
|
||||
}
|
||||
|
||||
325
iosApp/iosApp/Profile/NotificationPreferencesView.swift
Normal file
325
iosApp/iosApp/Profile/NotificationPreferencesView.swift
Normal file
@@ -0,0 +1,325 @@
|
||||
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("Notification Preferences")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text("Choose which notifications you'd like to receive")
|
||||
.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("Retry") {
|
||||
viewModel.loadPreferences()
|
||||
}
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
.listRowBackground(Color.appBackgroundSecondary)
|
||||
} else {
|
||||
// Task Notifications
|
||||
Section {
|
||||
Toggle(isOn: $viewModel.taskDueSoon) {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Task Due Soon")
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
Text("Reminders for upcoming tasks")
|
||||
.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("Task Overdue")
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
Text("Alerts for overdue tasks")
|
||||
.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("Task Completed")
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
Text("When someone completes a task")
|
||||
.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("Task Assigned")
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
Text("When a task is assigned to you")
|
||||
.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("Task Notifications")
|
||||
}
|
||||
.listRowBackground(Color.appBackgroundSecondary)
|
||||
|
||||
// Other Notifications
|
||||
Section {
|
||||
Toggle(isOn: $viewModel.residenceShared) {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Property Shared")
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
Text("When someone shares a property with you")
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "house.fill")
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
.tint(Color.appPrimary)
|
||||
.onChange(of: viewModel.residenceShared) { _, newValue in
|
||||
viewModel.updatePreference(residenceShared: newValue)
|
||||
}
|
||||
|
||||
Toggle(isOn: $viewModel.warrantyExpiring) {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Warranty Expiring")
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
Text("Reminders for expiring warranties")
|
||||
.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("Other Notifications")
|
||||
}
|
||||
.listRowBackground(Color.appBackgroundSecondary)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.appBackgroundPrimary)
|
||||
.navigationTitle("Notifications")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("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()
|
||||
}
|
||||
@@ -10,6 +10,7 @@ struct ProfileTabView: View {
|
||||
@State private var showingThemeSelection = false
|
||||
@State private var showUpgradePrompt = false
|
||||
@State private var showRestoreSuccess = false
|
||||
@State private var showingNotificationPreferences = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@@ -42,8 +43,17 @@ struct ProfileTabView: View {
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
}
|
||||
|
||||
NavigationLink(destination: Text("Notifications")) {
|
||||
Label("Notifications", systemImage: "bell")
|
||||
Button(action: {
|
||||
showingNotificationPreferences = true
|
||||
}) {
|
||||
HStack {
|
||||
Label("Notifications", systemImage: "bell")
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
NavigationLink(destination: Text("Privacy")) {
|
||||
@@ -163,6 +173,9 @@ struct ProfileTabView: View {
|
||||
.sheet(isPresented: $showingThemeSelection) {
|
||||
ThemeSelectionView()
|
||||
}
|
||||
.sheet(isPresented: $showingNotificationPreferences) {
|
||||
NotificationPreferencesView()
|
||||
}
|
||||
.alert("Log Out", isPresented: $showingLogoutAlert) {
|
||||
Button("Cancel", role: .cancel) { }
|
||||
Button("Log Out", role: .destructive) {
|
||||
|
||||
@@ -63,10 +63,20 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
print("⚠️ No auth token available, will register device after login")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Get unique device identifier
|
||||
let deviceId = await MainActor.run {
|
||||
UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
|
||||
}
|
||||
let deviceName = await MainActor.run {
|
||||
UIDevice.current.name
|
||||
}
|
||||
|
||||
let request = DeviceRegistrationRequest(
|
||||
deviceId: deviceId,
|
||||
registrationId: token,
|
||||
platform: "ios"
|
||||
platform: "ios",
|
||||
name: deviceName
|
||||
)
|
||||
|
||||
do {
|
||||
|
||||
@@ -40,6 +40,9 @@ class AuthenticationManager: ObservableObject {
|
||||
if self.isVerified {
|
||||
_ = try await APILayer.shared.initializeLookups()
|
||||
print("✅ Lookups initialized on app launch for verified user")
|
||||
|
||||
// Verify subscription entitlements with backend
|
||||
await StoreKitManager.shared.verifyEntitlementsOnLaunch()
|
||||
}
|
||||
} else if result is ApiResultError {
|
||||
// Token is invalid, clear it
|
||||
@@ -72,6 +75,9 @@ class AuthenticationManager: ObservableObject {
|
||||
do {
|
||||
_ = try await APILayer.shared.initializeLookups()
|
||||
print("✅ Lookups initialized after email verification")
|
||||
|
||||
// Verify subscription entitlements with backend
|
||||
await StoreKitManager.shared.verifyEntitlementsOnLaunch()
|
||||
} catch {
|
||||
print("❌ Failed to initialize lookups after verification: \(error)")
|
||||
}
|
||||
|
||||
@@ -135,6 +135,64 @@ class StoreKitManager: ObservableObject {
|
||||
print("🔄 StoreKit: Subscription status refreshed")
|
||||
}
|
||||
|
||||
/// Verify current entitlements with backend on app launch
|
||||
/// This ensures the backend has up-to-date subscription info
|
||||
func verifyEntitlementsOnLaunch() async {
|
||||
print("🔄 StoreKit: Verifying entitlements on launch...")
|
||||
|
||||
// Get current entitlements from StoreKit
|
||||
for await result in Transaction.currentEntitlements {
|
||||
guard case .verified(let transaction) = result else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if revoked
|
||||
if transaction.revocationDate != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only process subscription products
|
||||
if transaction.productType == .autoRenewable {
|
||||
print("📦 StoreKit: Found active subscription: \(transaction.productID)")
|
||||
|
||||
// Verify this transaction with backend
|
||||
await verifyTransactionWithBackend(transaction)
|
||||
|
||||
// Update local purchased products
|
||||
await MainActor.run {
|
||||
purchasedProductIDs.insert(transaction.productID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After verifying all entitlements, refresh subscription status from backend
|
||||
await refreshSubscriptionFromBackend()
|
||||
|
||||
print("✅ StoreKit: Launch verification complete")
|
||||
}
|
||||
|
||||
/// Fetch latest subscription status from backend and update cache
|
||||
private func refreshSubscriptionFromBackend() async {
|
||||
guard let token = TokenStorage.shared.getToken() else {
|
||||
print("⚠️ StoreKit: No auth token, skipping backend status refresh")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let statusResult = try await subscriptionApi.getSubscriptionStatus(token: token)
|
||||
|
||||
if let statusSuccess = statusResult as? ApiResultSuccess<ComposeApp.SubscriptionStatus>,
|
||||
let subscription = statusSuccess.data {
|
||||
await MainActor.run {
|
||||
SubscriptionCacheWrapper.shared.updateSubscription(subscription)
|
||||
print("✅ StoreKit: Backend subscription status updated - Tier: \(subscription.limits)")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("❌ StoreKit: Failed to refresh subscription from backend: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Update purchased product IDs
|
||||
@MainActor
|
||||
private func updatePurchasedProducts() async {
|
||||
|
||||
Reference in New Issue
Block a user