Files
boc/aamos-admin-upgrade/ios/AamosAdmin/Views/UsersView.swift
T
Bernt 6de2455917 v1.2.0: Add Global Markets footer, translated to 9 languages
- Added GLOBAL_MARKETS_TITLE to all translation files
- Updated footer with 12 markets (4 active + 8 upcoming)
- Translated market section to: zh-cn, zh-tw, ja, ko, th, vi, id, ms, hi
- Built and deployed to production
- CloudFront invalidation: I3RTMXVFDJXWLG3SYX208OP1CC
2026-07-08 19:56:03 +00:00

711 lines
22 KiB
Swift

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