import SwiftUI // MARK: - Models enum UserRole: String, CaseIterable, Codable { case admin = "Admin" case manager = "Manager" case op = "Operator" case viewer = "Viewer" var tint: Color { switch self { case .admin: return Color(.systemRed) case .manager: return Color(.systemOrange) case .op: return Color(.systemBlue) case .viewer: return Color(.systemGreen) } } var icon: String { switch self { case .admin: return "crown.fill" case .manager: return "person.badge.shield.checkmark.fill" case .op: return "wrench.and.screwdriver.fill" case .viewer: return "eye.fill" } } } struct User: Identifiable, Hashable { var id: UUID var name: String var email: String var role: UserRole var isOnline: Bool var lastSeen: Date? var initials: String { name.components(separatedBy: " ") .prefix(2) .compactMap(\.first) .map(String.init) .joined() .uppercased() } var avatarTint: Color { let palette: [Color] = [ Color(.systemBlue), Color(.systemGreen), Color(.systemOrange), Color(.systemRed), Color(.systemPurple), Color(.systemTeal) ] return palette[abs(name.hashValue) % palette.count] } } extension User { static let samples: [User] = [ User(id: UUID(), name: "Anna Lindström", email: "anna@aamos.io", role: .admin, isOnline: true, lastSeen: nil), User(id: UUID(), name: "Bernt Johansson", email: "bernt@aamos.io", role: .manager, isOnline: true, lastSeen: nil), User(id: UUID(), name: "Carl Eriksson", email: "carl@aamos.io", role: .op, isOnline: false, lastSeen: Date().addingTimeInterval(-3_600)), User(id: UUID(), name: "Diana Svensson", email: "diana@aamos.io", role: .viewer, isOnline: false, lastSeen: Date().addingTimeInterval(-86_400)), User(id: UUID(), name: "Erik Nilsson", email: "erik@aamos.io", role: .op, isOnline: true, lastSeen: nil), User(id: UUID(), name: "Frida Karlsson", email: "frida@aamos.io", role: .manager, isOnline: false, lastSeen: Date().addingTimeInterval(-7_200)), ] } // MARK: - ViewModel @Observable final class UsersViewModel { var users: [User] = User.samples var searchText = "" var showInviteSheet = false var filtered: [User] { guard !searchText.isEmpty else { return users } return users.filter { $0.name.localizedCaseInsensitiveContains(searchText) || $0.email.localizedCaseInsensitiveContains(searchText) || $0.role.rawValue.localizedCaseInsensitiveContains(searchText) } } func delete(_ user: User) { users.removeAll { $0.id == user.id } } func update(_ updated: User) { guard let i = users.firstIndex(where: { $0.id == updated.id }) else { return } users[i] = updated } func invite(name: String, email: String, role: UserRole) { users.insert( User(id: UUID(), name: name, email: email, role: role, isOnline: false), at: 0 ) } } // MARK: - UsersView struct UsersView: View { @State private var vm = UsersViewModel() var body: some View { NavigationStack { // List gives native swipe-actions; row style matches LazyVStack card design. List { if vm.filtered.isEmpty { emptyState .listRowBackground(Color.clear) .listRowSeparator(.hidden) } else { ForEach(vm.filtered) { user in NavigationLink(value: user) { UserRow(user: user) } .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { withAnimation(.spring(duration: 0.3)) { vm.delete(user) } } label: { Label("Ta bort", systemImage: "trash") } } } } } .listStyle(.plain) .scrollContentBackground(.hidden) .background(Color(.systemGroupedBackground)) .navigationTitle("Användare") .navigationBarTitleDisplayMode(.large) .searchable( text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Sök namn, e-post eller roll" ) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { vm.showInviteSheet = true } label: { Label("Bjud in", systemImage: "person.badge.plus") } } } .navigationDestination(for: User.self) { user in UserDetailView(user: user, onSave: vm.update) } .sheet(isPresented: $vm.showInviteSheet) { InviteUserView(onInvite: vm.invite) } } } private var emptyState: some View { VStack(spacing: 16) { Image(systemName: vm.searchText.isEmpty ? "person.3" : "magnifyingglass") .font(.system(size: 48)) .foregroundStyle(Color(.systemGray3)) Text( vm.searchText.isEmpty ? "Inga användare" : "Inga träffar för \"\(vm.searchText)\"" ) .font(.headline) .foregroundStyle(Color(.secondaryLabel)) } .frame(maxWidth: .infinity) .padding(.top, 60) } } // MARK: - UserRow struct UserRow: View { let user: User var body: some View { HStack(spacing: 12) { AvatarView(user: user, size: 44) VStack(alignment: .leading, spacing: 4) { HStack(alignment: .center, spacing: 6) { Text(user.name) .font(.body.weight(.medium)) .foregroundStyle(Color(.label)) Spacer() RoleBadge(role: user.role) } Text(user.email) .font(.subheadline) .foregroundStyle(Color(.secondaryLabel)) .lineLimit(1) } Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .foregroundStyle(Color(.tertiaryLabel)) } .padding(12) .background(Color(.systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 2) } } // MARK: - AvatarView struct AvatarView: View { let user: User let size: CGFloat private var dotSize: CGFloat { size * 0.3 } var body: some View { ZStack(alignment: .bottomTrailing) { Circle() .fill(user.avatarTint.opacity(0.18)) .frame(width: size, height: size) .overlay { Text(user.initials) .font(.system(size: size * 0.36, weight: .semibold)) .foregroundStyle(user.avatarTint) } Circle() .fill(Color(.systemBackground)) .frame(width: dotSize + 4, height: dotSize + 4) .overlay { Circle() .fill(user.isOnline ? Color(.systemGreen) : Color(.systemGray4)) .frame(width: dotSize, height: dotSize) } .offset(x: 2, y: 2) } } } // MARK: - RoleBadge struct RoleBadge: View { let role: UserRole var body: some View { Label(role.rawValue, systemImage: role.icon) .font(.system(size: 11, weight: .semibold)) .labelStyle(.titleAndIcon) .padding(.horizontal, 7) .padding(.vertical, 3) .background(role.tint.opacity(0.14)) .foregroundStyle(role.tint) .clipShape(Capsule()) } } // MARK: - UserDetailView struct UserDetailView: View { @Environment(\.dismiss) private var dismiss @State private var draft: User @State private var isEditing = false @State private var confirmDelete = false let onSave: (User) -> Void init(user: User, onSave: @escaping (User) -> Void) { _draft = State(initialValue: user) self.onSave = onSave } var body: some View { ZStack { Color(.systemGroupedBackground).ignoresSafeArea() ScrollView { LazyVStack(spacing: 16) { avatarSection infoSection roleSection if isEditing { saveButton } dangerSection } .padding(.horizontal, 16) .padding(.top, 24) .padding(.bottom, 48) } } .navigationTitle(isEditing ? "Redigera" : draft.name) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button(isEditing ? "Klar" : "Redigera") { if isEditing { onSave(draft) } withAnimation(.spring(duration: 0.28)) { isEditing.toggle() } } .fontWeight(isEditing ? .semibold : .regular) .foregroundStyle(Color(.systemBlue)) } } .confirmationDialog( "Ta bort \(draft.name)?", isPresented: $confirmDelete, titleVisibility: .visible ) { Button("Ta bort", role: .destructive) { dismiss() } Button("Avbryt", role: .cancel) {} } message: { Text("Användaren förlorar omedelbart åtkomst till AAMOS.") } } // MARK: Avatar private var avatarSection: some View { VStack(spacing: 10) { AvatarView(user: draft, size: 80) statusLabel } } private var statusLabel: some View { Group { if draft.isOnline { Label("Online", systemImage: "circle.fill") .foregroundStyle(Color(.systemGreen)) } else if let seen = draft.lastSeen { let rel = RelativeDateTimeFormatter() Text("Senast \(rel.localizedString(for: seen, relativeTo: Date()))") .foregroundStyle(Color(.secondaryLabel)) } else { Text("Offline").foregroundStyle(Color(.secondaryLabel)) } } .font(.caption) } // MARK: Info private var infoSection: some View { Card { DetailField( icon: "person.fill", tint: Color(.systemBlue), label: "Namn", value: $draft.name, isEditing: isEditing, keyboardType: .default, capitalization: .words ) Divider().padding(.leading, 52) DetailField( icon: "envelope.fill", tint: Color(.systemBlue), label: "E-post", value: $draft.email, isEditing: isEditing, keyboardType: .emailAddress, capitalization: .never ) } } // MARK: Role private var roleSection: some View { Card { VStack(alignment: .leading, spacing: 12) { Label("Roll", systemImage: "shield.lefthalf.filled") .font(.subheadline) .foregroundStyle(Color(.secondaryLabel)) if isEditing { LazyVGrid( columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8 ) { ForEach(UserRole.allCases, id: \.self) { role in RoleOptionButton(role: role, selected: $draft.role) } } .transition(.opacity.combined(with: .scale(scale: 0.97))) } else { RoleBadge(role: draft.role) } } .animation(.spring(duration: 0.25), value: isEditing) } } // MARK: Save private var saveButton: some View { Button { onSave(draft) withAnimation { isEditing = false } } label: { Label("Spara ändringar", systemImage: "checkmark") .font(.body.weight(.semibold)) .frame(maxWidth: .infinity) .padding(.vertical, 14) .background(Color(.systemBlue)) .foregroundStyle(.white) .clipShape(RoundedRectangle(cornerRadius: 12)) } .transition(.opacity.combined(with: .move(edge: .bottom))) } // MARK: Danger private var dangerSection: some View { Card { Button(role: .destructive) { confirmDelete = true } label: { Label("Ta bort användare", systemImage: "trash") .frame(maxWidth: .infinity, alignment: .leading) } } } } // MARK: - RoleOptionButton private struct RoleOptionButton: View { let role: UserRole @Binding var selected: UserRole var isSelected: Bool { selected == role } var body: some View { Button { withAnimation(.spring(duration: 0.22)) { selected = role } } label: { VStack(spacing: 6) { Image(systemName: role.icon) .font(.system(size: 18)) Text(role.rawValue) .font(.system(size: 13, weight: .semibold)) } .frame(maxWidth: .infinity) .padding(.vertical, 12) .background(isSelected ? role.tint.opacity(0.15) : Color(.secondarySystemBackground)) .foregroundStyle(isSelected ? role.tint : Color(.secondaryLabel)) .clipShape(RoundedRectangle(cornerRadius: 10)) .overlay { if isSelected { RoundedRectangle(cornerRadius: 10) .strokeBorder(role.tint.opacity(0.5), lineWidth: 1.5) } } } .buttonStyle(.plain) } } // MARK: - DetailField private struct DetailField: View { let icon: String let tint: Color let label: String @Binding var value: String let isEditing: Bool let keyboardType: UIKeyboardType let capitalization: TextInputAutocapitalization var body: some View { HStack(spacing: 12) { Image(systemName: icon) .font(.system(size: 14)) .foregroundStyle(tint) .frame(width: 32, height: 32) .background(tint.opacity(0.12)) .clipShape(RoundedRectangle(cornerRadius: 8)) VStack(alignment: .leading, spacing: 2) { Text(label) .font(.caption) .foregroundStyle(Color(.secondaryLabel)) if isEditing { TextField(label, text: $value) .font(.body) .keyboardType(keyboardType) .textInputAutocapitalization(capitalization) .autocorrectionDisabled() } else { Text(value) .font(.body) .foregroundStyle(Color(.label)) } } Spacer() } .padding(.vertical, 4) } } // MARK: - Card private struct Card: View { @ViewBuilder let content: () -> Content var body: some View { VStack(spacing: 0) { content() } .padding(16) .background(Color(.systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 2) } } // MARK: - InviteUserView struct InviteUserView: View { @Environment(\.dismiss) private var dismiss @State private var name = "" @State private var email = "" @State private var selectedRole = UserRole.viewer @State private var isSending = false @FocusState private var focus: Field? enum Field { case name, email } let onInvite: (String, String, UserRole) -> Void private var isValid: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty && email.contains("@") && email.contains(".") } var body: some View { NavigationStack { ZStack { Color(.systemGroupedBackground).ignoresSafeArea() ScrollView { LazyVStack(spacing: 20) { header formCard roleGrid sendButton } .padding(.horizontal, 16) .padding(.top, 24) .padding(.bottom, 48) } } .navigationTitle("Bjud in användare") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Avbryt") { dismiss() } .foregroundStyle(Color(.systemBlue)) } } } } // MARK: Header private var header: some View { VStack(spacing: 8) { ZStack { Circle() .fill(Color(.systemBlue).opacity(0.12)) .frame(width: 72, height: 72) Image(systemName: "person.badge.plus") .font(.system(size: 28)) .foregroundStyle(Color(.systemBlue)) } Text("Nytt konto") .font(.title3.weight(.semibold)) Text("Användaren får en inbjudan via e-post.") .font(.subheadline) .foregroundStyle(Color(.secondaryLabel)) .multilineTextAlignment(.center) } } // MARK: Form private var formCard: some View { Card { HStack(spacing: 12) { iconBox("person.fill", tint: Color(.systemBlue)) TextField("Fullständigt namn", text: $name) .font(.body) .textInputAutocapitalization(.words) .autocorrectionDisabled() .focused($focus, equals: .name) .submitLabel(.next) .onSubmit { focus = .email } } .padding(.vertical, 2) Divider().padding(.leading, 52) HStack(spacing: 12) { iconBox("envelope.fill", tint: Color(.systemBlue)) TextField("E-postadress", text: $email) .font(.body) .keyboardType(.emailAddress) .textInputAutocapitalization(.never) .autocorrectionDisabled() .focused($focus, equals: .email) .submitLabel(.done) } .padding(.vertical, 2) } } private func iconBox(_ symbol: String, tint: Color) -> some View { Image(systemName: symbol) .font(.system(size: 14)) .foregroundStyle(tint) .frame(width: 32, height: 32) .background(tint.opacity(0.12)) .clipShape(RoundedRectangle(cornerRadius: 8)) } // MARK: Role grid private var roleGrid: some View { VStack(alignment: .leading, spacing: 12) { Label("Roll", systemImage: "shield.lefthalf.filled") .font(.subheadline.weight(.medium)) .foregroundStyle(Color(.secondaryLabel)) .padding(.leading, 4) LazyVGrid( columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8 ) { ForEach(UserRole.allCases, id: \.self) { role in RoleOptionButton(role: role, selected: $selectedRole) } } } } // MARK: Send button private var sendButton: some View { Button { guard isValid, !isSending else { return } isSending = true Task { try? await Task.sleep(for: .milliseconds(350)) onInvite( name.trimmingCharacters(in: .whitespaces), email.lowercased().trimmingCharacters(in: .whitespaces), selectedRole ) dismiss() } } label: { HStack(spacing: 8) { if isSending { ProgressView().tint(.white).scaleEffect(0.85) } else { Image(systemName: "paperplane.fill") } Text(isSending ? "Skickar…" : "Skicka inbjudan") .fontWeight(.semibold) } .frame(maxWidth: .infinity) .padding(.vertical, 14) .background(isValid ? Color(.systemBlue) : Color(.systemGray4)) .foregroundStyle(isValid ? .white : Color(.systemGray2)) .clipShape(RoundedRectangle(cornerRadius: 12)) } .disabled(!isValid || isSending) .animation(.easeInOut(duration: 0.15), value: isValid) } } // MARK: - Previews #Preview("UsersView") { UsersView() } #Preview("UserRow — online") { UserRow(user: User.samples[0]) .padding() .background(Color(.systemGroupedBackground)) } #Preview("UserRow — offline") { UserRow(user: User.samples[2]) .padding() .background(Color(.systemGroupedBackground)) } #Preview("UserDetailView") { NavigationStack { UserDetailView(user: User.samples[0], onSave: { _ in }) } } #Preview("InviteUserView") { InviteUserView(onInvite: { _, _, _ in }) }