6de2455917
- 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
711 lines
22 KiB
Swift
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 })
|
|
}
|