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:
Trey t
2025-11-08 10:51:51 -06:00
parent e271403d9b
commit 364f98a303
8 changed files with 686 additions and 8 deletions

View File

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

View File

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

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

View File

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

View File

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