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:
Trey t
2025-11-29 14:01:35 -06:00
parent 5a1a87fe8d
commit c748f792d0
21 changed files with 1032 additions and 38 deletions

View File

@@ -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"
}

View 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()
}

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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)")
}

View File

@@ -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 {