Fix logout, share code display, loading states, and multi-user support
iOS Logout Fix: - Use @EnvironmentObject in MainTabView and ProfileTabView - Pass loginViewModel from LoginView to MainTabView - Handle logout by dismissing main tab when isAuthenticated becomes false - Logout button now properly returns user to login screen Share Code UX: - Clear share code on ManageUsers screen open (iOS & Android) - Remove auto-loading of share codes - User must explicitly generate code each time - Improves security with fresh codes Loading State Improvements: - iOS ResidenceDetailView shows loading immediately on navigation - Android ResidenceDetailScreen enhanced with "Loading residence..." text - Better user feedback during API calls Multi-User Support: - Add isPrimaryOwner and userCount to ResidenceWithTasks model - Update iOS toResidences() extension to include new fields - Sync with backend API changes for shared user access 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -133,6 +133,11 @@ struct LoginView: View {
|
||||
} else {
|
||||
showVerification = true
|
||||
}
|
||||
} else {
|
||||
// User logged out, dismiss main tab
|
||||
print("isAuthenticated changed to false, dismissing main tab")
|
||||
showMainTab = false
|
||||
showVerification = false
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.isVerified) { _, isVerified in
|
||||
@@ -144,6 +149,7 @@ struct LoginView: View {
|
||||
}
|
||||
.fullScreenCover(isPresented: $showMainTab) {
|
||||
MainTabView()
|
||||
.environmentObject(viewModel)
|
||||
}
|
||||
.fullScreenCover(isPresented: $showVerification) {
|
||||
VerifyEmailView(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MainTabView: View {
|
||||
@StateObject private var loginViewModel = LoginViewModel()
|
||||
@EnvironmentObject var loginViewModel: LoginViewModel
|
||||
@State private var selectedTab = 0
|
||||
|
||||
var body: some View {
|
||||
@@ -34,7 +34,7 @@ struct MainTabView: View {
|
||||
}
|
||||
|
||||
struct ProfileTabView: View {
|
||||
@StateObject private var loginViewModel = LoginViewModel()
|
||||
@EnvironmentObject var loginViewModel: LoginViewModel
|
||||
@State private var showingProfileEdit = false
|
||||
|
||||
var body: some View {
|
||||
|
||||
272
iosApp/iosApp/Residence/ManageUsersView.swift
Normal file
272
iosApp/iosApp/Residence/ManageUsersView.swift
Normal file
@@ -0,0 +1,272 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
struct ManageUsersView: View {
|
||||
let residenceId: Int32
|
||||
let residenceName: String
|
||||
let isPrimaryOwner: Bool
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var users: [ResidenceUser] = []
|
||||
@State private var ownerId: Int32?
|
||||
@State private var shareCode: ResidenceShareCode?
|
||||
@State private var isLoading = true
|
||||
@State private var errorMessage: String?
|
||||
@State private var isGeneratingCode = false
|
||||
|
||||
private let residenceApi = ResidenceApi(client: ApiClient_iosKt.createHttpClient())
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ZStack {
|
||||
Color(.systemGroupedBackground)
|
||||
.ignoresSafeArea()
|
||||
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
} else if let error = errorMessage {
|
||||
ErrorView(message: error) {
|
||||
loadUsers()
|
||||
}
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
// Share code section (primary owner only)
|
||||
if isPrimaryOwner {
|
||||
ShareCodeCard(
|
||||
shareCode: shareCode,
|
||||
residenceName: residenceName,
|
||||
isGeneratingCode: isGeneratingCode,
|
||||
onGenerateCode: generateShareCode
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.padding(.top)
|
||||
}
|
||||
|
||||
// Users list
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Users (\(users.count))")
|
||||
.font(.headline)
|
||||
.padding(.horizontal)
|
||||
|
||||
ForEach(users, id: \.id) { user in
|
||||
UserListItem(
|
||||
user: user,
|
||||
isOwner: user.id == ownerId,
|
||||
isPrimaryOwner: isPrimaryOwner,
|
||||
onRemove: {
|
||||
removeUser(userId: user.id)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Manage Users")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// Clear share code on appear so it's always blank
|
||||
shareCode = nil
|
||||
loadUsers()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadUsers() {
|
||||
guard let token = TokenStorage.shared.getToken() else {
|
||||
errorMessage = "Not authenticated"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
residenceApi.getResidenceUsers(token: token, residenceId: residenceId) { result, error in
|
||||
if let successResult = result as? ApiResultSuccess<ResidenceUsersResponse>,
|
||||
let responseData = successResult.data as? ResidenceUsersResponse {
|
||||
self.users = Array(responseData.users)
|
||||
self.ownerId = responseData.ownerId as? Int32
|
||||
self.isLoading = false
|
||||
|
||||
// Don't auto-load share code - user must generate it explicitly
|
||||
} else if let errorResult = result as? ApiResultError {
|
||||
self.errorMessage = errorResult.message
|
||||
self.isLoading = false
|
||||
} else if let error = error {
|
||||
self.errorMessage = error.localizedDescription
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadShareCode() {
|
||||
guard let token = TokenStorage.shared.getToken() else { return }
|
||||
|
||||
residenceApi.getShareCode(token: token, residenceId: residenceId) { result, error in
|
||||
if let successResult = result as? ApiResultSuccess<ResidenceShareCode> {
|
||||
self.shareCode = successResult.data
|
||||
}
|
||||
// It's okay if there's no active share code
|
||||
}
|
||||
}
|
||||
|
||||
private func generateShareCode() {
|
||||
guard let token = TokenStorage.shared.getToken() else { return }
|
||||
|
||||
isGeneratingCode = true
|
||||
|
||||
residenceApi.generateShareCode(token: token, residenceId: residenceId) { result, error in
|
||||
if let successResult = result as? ApiResultSuccess<ResidenceShareCode> {
|
||||
self.shareCode = successResult.data
|
||||
self.isGeneratingCode = false
|
||||
} else if let errorResult = result as? ApiResultError {
|
||||
self.errorMessage = errorResult.message
|
||||
self.isGeneratingCode = false
|
||||
} else if let error = error {
|
||||
self.errorMessage = error.localizedDescription
|
||||
self.isGeneratingCode = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeUser(userId: Int32) {
|
||||
guard let token = TokenStorage.shared.getToken() else { return }
|
||||
|
||||
residenceApi.removeUser(token: token, residenceId: residenceId, userId: userId) { result, error in
|
||||
if result is ApiResultSuccess<RemoveUserResponse> {
|
||||
// Remove user from local list
|
||||
self.users.removeAll { $0.id == userId }
|
||||
} else if let errorResult = result as? ApiResultError {
|
||||
self.errorMessage = errorResult.message
|
||||
} else if let error = error {
|
||||
self.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Share Code Card
|
||||
struct ShareCodeCard: View {
|
||||
let shareCode: ResidenceShareCode?
|
||||
let residenceName: String
|
||||
let isGeneratingCode: Bool
|
||||
let onGenerateCode: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Share Code")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if let shareCode = shareCode {
|
||||
Text(shareCode.code)
|
||||
.font(.title)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.blue)
|
||||
} else {
|
||||
Text("No active code")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: onGenerateCode) {
|
||||
HStack {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
Text(shareCode != nil ? "New Code" : "Generate")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(isGeneratingCode)
|
||||
}
|
||||
|
||||
if shareCode != nil {
|
||||
Text("Share this code with others to give them access to \(residenceName)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User List Item
|
||||
struct UserListItem: View {
|
||||
let user: ResidenceUser
|
||||
let isOwner: Bool
|
||||
let isPrimaryOwner: Bool
|
||||
let onRemove: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(user.username)
|
||||
.font(.body)
|
||||
.fontWeight(.medium)
|
||||
|
||||
if isOwner {
|
||||
Text("Owner")
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.blue)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
|
||||
if !user.email.isEmpty {
|
||||
Text(user.email)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
let fullName = [user.firstName, user.lastName]
|
||||
.compactMap { $0 }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: " ")
|
||||
|
||||
if !fullName.isEmpty {
|
||||
Text(fullName)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isPrimaryOwner && !isOwner {
|
||||
Button(action: onRemove) {
|
||||
Image(systemName: "trash")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(12)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ManageUsersView(residenceId: 1, residenceName: "My Home", isPrimaryOwner: true)
|
||||
}
|
||||
@@ -11,16 +11,23 @@ struct ResidenceDetailView: View {
|
||||
@State private var showAddTask = false
|
||||
@State private var showEditResidence = false
|
||||
@State private var showEditTask = false
|
||||
@State private var showManageUsers = false
|
||||
@State private var selectedTaskForEdit: TaskDetail?
|
||||
@State private var selectedTaskForComplete: TaskDetail?
|
||||
@State private var hasAppeared = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(.systemGroupedBackground)
|
||||
.ignoresSafeArea()
|
||||
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
if !hasAppeared || viewModel.isLoading {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
Text("Loading residence...")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else if let error = viewModel.errorMessage {
|
||||
ErrorView(message: error) {
|
||||
loadResidenceData()
|
||||
@@ -85,7 +92,6 @@ struct ResidenceDetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Property Details")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
@@ -98,7 +104,16 @@ struct ResidenceDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||
// Manage Users button - only show for primary owners
|
||||
if let residence = viewModel.selectedResidence, residence.isPrimaryOwner {
|
||||
Button(action: {
|
||||
showManageUsers = true
|
||||
}) {
|
||||
Image(systemName: "person.2")
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
showAddTask = true
|
||||
}) {
|
||||
@@ -125,6 +140,15 @@ struct ResidenceDetailView: View {
|
||||
loadResidenceTasks()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showManageUsers) {
|
||||
if let residence = viewModel.selectedResidence {
|
||||
ManageUsersView(
|
||||
residenceId: residence.id,
|
||||
residenceName: residence.name,
|
||||
isPrimaryOwner: residence.isPrimaryOwner
|
||||
)
|
||||
}
|
||||
}
|
||||
.onChange(of: showAddTask) { isShowing in
|
||||
if !isShowing {
|
||||
loadResidenceTasks()
|
||||
@@ -143,6 +167,11 @@ struct ResidenceDetailView: View {
|
||||
.onAppear {
|
||||
loadResidenceData()
|
||||
}
|
||||
.onChange(of: viewModel.selectedResidence) { _, residence in
|
||||
if residence != nil {
|
||||
hasAppeared = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadResidenceData() {
|
||||
|
||||
@@ -462,6 +462,8 @@ extension Array where Element == ResidenceWithTasks {
|
||||
id: item.id,
|
||||
owner: KotlinInt(value: item.owner),
|
||||
ownerUsername: item.ownerUsername,
|
||||
isPrimaryOwner: item.isPrimaryOwner,
|
||||
userCount: item.userCount,
|
||||
name: item.name,
|
||||
propertyType: item.propertyType,
|
||||
streetAddress: item.streetAddress,
|
||||
|
||||
Reference in New Issue
Block a user