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
729 lines
24 KiB
Swift
729 lines
24 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - Models
|
|
|
|
enum AuditAction: String, CaseIterable, Identifiable, Codable {
|
|
case create = "create"
|
|
case update = "update"
|
|
case delete = "delete"
|
|
case login = "login"
|
|
case logout = "logout"
|
|
case export = "export"
|
|
case import_ = "import"
|
|
case assign = "assign"
|
|
|
|
var id: String { rawValue }
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .create: return "Skapad"
|
|
case .update: return "Uppdaterad"
|
|
case .delete: return "Raderad"
|
|
case .login: return "Inloggning"
|
|
case .logout: return "Utloggning"
|
|
case .export: return "Exporterad"
|
|
case .import_: return "Importerad"
|
|
case .assign: return "Tilldelad"
|
|
}
|
|
}
|
|
|
|
var symbol: String {
|
|
switch self {
|
|
case .create: return "plus.circle.fill"
|
|
case .update: return "pencil.circle.fill"
|
|
case .delete: return "trash.circle.fill"
|
|
case .login: return "person.crop.circle.badge.checkmark"
|
|
case .logout: return "person.crop.circle.badge.xmark"
|
|
case .export: return "arrow.up.circle.fill"
|
|
case .import_: return "arrow.down.circle.fill"
|
|
case .assign: return "person.2.circle.fill"
|
|
}
|
|
}
|
|
|
|
var color: Color {
|
|
switch self {
|
|
case .create: return Color(.systemGreen)
|
|
case .update: return Color(.systemBlue)
|
|
case .delete: return Color(.systemRed)
|
|
case .login: return Color(.systemGreen)
|
|
case .logout: return Color(.systemOrange)
|
|
case .export: return Color(.systemBlue)
|
|
case .import_: return Color(.systemBlue)
|
|
case .assign: return Color(.systemOrange)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct AuditEntry: Identifiable, Equatable {
|
|
let id: UUID
|
|
let action: AuditAction
|
|
let userName: String
|
|
let userInitials: String
|
|
let resource: String
|
|
let resourceType: String
|
|
let detail: String?
|
|
let timestamp: Date
|
|
|
|
static func == (lhs: AuditEntry, rhs: AuditEntry) -> Bool {
|
|
lhs.id == rhs.id
|
|
}
|
|
}
|
|
|
|
struct AuditFilter: Equatable {
|
|
var selectedActions: Set<AuditAction> = []
|
|
var fromDate: Date?
|
|
var toDate: Date?
|
|
|
|
var isActive: Bool {
|
|
!selectedActions.isEmpty || fromDate != nil || toDate != nil
|
|
}
|
|
}
|
|
|
|
// MARK: - View Model
|
|
|
|
@MainActor
|
|
final class AuditViewModel: ObservableObject {
|
|
@Published var entries: [AuditEntry] = []
|
|
@Published var isLoading = false
|
|
@Published var isRefreshing = false
|
|
@Published var hasMore = true
|
|
@Published var filter = AuditFilter()
|
|
@Published var error: String?
|
|
|
|
private var currentPage = 0
|
|
private let pageSize = 25
|
|
private var allSeeds: [AuditEntry] = []
|
|
|
|
init() {
|
|
allSeeds = Self.generateSeeds(count: 200)
|
|
}
|
|
|
|
func loadInitial() async {
|
|
guard !isLoading else { return }
|
|
isLoading = true
|
|
currentPage = 0
|
|
entries = []
|
|
hasMore = true
|
|
await fetchPage()
|
|
isLoading = false
|
|
}
|
|
|
|
func refresh() async {
|
|
isRefreshing = true
|
|
currentPage = 0
|
|
entries = []
|
|
hasMore = true
|
|
await fetchPage()
|
|
isRefreshing = false
|
|
}
|
|
|
|
func loadMore() async {
|
|
guard !isLoading, !isRefreshing, hasMore else { return }
|
|
isLoading = true
|
|
await fetchPage()
|
|
isLoading = false
|
|
}
|
|
|
|
private func fetchPage() async {
|
|
// Simulate network latency
|
|
try? await Task.sleep(for: .milliseconds(400))
|
|
|
|
let filtered = filteredSeeds()
|
|
let start = currentPage * pageSize
|
|
guard start < filtered.count else {
|
|
hasMore = false
|
|
return
|
|
}
|
|
let end = min(start + pageSize, filtered.count)
|
|
let page = Array(filtered[start..<end])
|
|
entries.append(contentsOf: page)
|
|
currentPage += 1
|
|
hasMore = end < filtered.count
|
|
}
|
|
|
|
private func filteredSeeds() -> [AuditEntry] {
|
|
allSeeds.filter { entry in
|
|
if !filter.selectedActions.isEmpty,
|
|
!filter.selectedActions.contains(entry.action) { return false }
|
|
if let from = filter.fromDate, entry.timestamp < from { return false }
|
|
if let to = filter.toDate, entry.timestamp > to { return false }
|
|
return true
|
|
}
|
|
}
|
|
|
|
func applyFilter(_ newFilter: AuditFilter) {
|
|
filter = newFilter
|
|
Task { await loadInitial() }
|
|
}
|
|
|
|
// MARK: Seed data
|
|
|
|
private static func generateSeeds(count: Int) -> [AuditEntry] {
|
|
let users: [(String, String)] = [
|
|
("Anna Lindström", "AL"),
|
|
("Erik Svensson", "ES"),
|
|
("Maria Karlsson", "MK"),
|
|
("Johan Persson", "JP"),
|
|
("Sara Nilsson", "SN"),
|
|
("Lars Eriksson", "LE"),
|
|
]
|
|
let resources: [(String, String)] = [
|
|
("Ärende #4821", "case"),
|
|
("Klient Bäckström AB", "company"),
|
|
("Rapport Q1-2026", "document"),
|
|
("Faktura INV-00291", "invoice"),
|
|
("Kontrakt #7743", "contract"),
|
|
("Användare admin@aamos.se", "user"),
|
|
]
|
|
let actions = AuditAction.allCases
|
|
var results: [AuditEntry] = []
|
|
|
|
for i in 0..<count {
|
|
let user = users[i % users.count]
|
|
let resource = resources[i % resources.count]
|
|
let action = actions[i % actions.count]
|
|
let ago = TimeInterval(i) * -247 - Double.random(in: 0...180)
|
|
results.append(AuditEntry(
|
|
id: UUID(),
|
|
action: action,
|
|
userName: user.0,
|
|
userInitials: user.1,
|
|
resource: resource.0,
|
|
resourceType: resource.1,
|
|
detail: i % 4 == 0 ? "Fältet 'status' ändrades" : nil,
|
|
timestamp: Date(timeIntervalSinceNow: ago)
|
|
))
|
|
}
|
|
return results
|
|
}
|
|
}
|
|
|
|
// MARK: - Relative time
|
|
|
|
extension Date {
|
|
var relativeShort: String {
|
|
let diff = Date().timeIntervalSince(self)
|
|
switch diff {
|
|
case ..<60: return "Nu"
|
|
case ..<3600: return "\(Int(diff / 60)) min"
|
|
case ..<86400: return "\(Int(diff / 3600)) tim"
|
|
case ..<604800: return "\(Int(diff / 86400)) d"
|
|
default:
|
|
let f = DateFormatter()
|
|
f.dateFormat = "d MMM"
|
|
f.locale = Locale(identifier: "sv_SE")
|
|
return f.string(from: self)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - AuditRow
|
|
|
|
struct AuditRow: View {
|
|
let entry: AuditEntry
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(entry.action.color.opacity(0.12))
|
|
.frame(width: 40, height: 40)
|
|
Image(systemName: entry.action.symbol)
|
|
.font(.system(size: 18, weight: .medium))
|
|
.foregroundStyle(entry.action.color)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 6) {
|
|
Text(entry.userName)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(Color(.label))
|
|
|
|
ActionBadge(action: entry.action)
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
Text(entry.timestamp.relativeShort)
|
|
.font(.system(size: 12, weight: .regular))
|
|
.foregroundStyle(Color(.tertiaryLabel))
|
|
}
|
|
|
|
HStack(spacing: 4) {
|
|
Image(systemName: resourceSymbol(for: entry.resourceType))
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(Color(.secondaryLabel))
|
|
Text(entry.resource)
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(Color(.secondaryLabel))
|
|
.lineLimit(1)
|
|
}
|
|
|
|
if let detail = entry.detail {
|
|
Text(detail)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(Color(.tertiaryLabel))
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
.background(Color(.systemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
|
|
private func resourceSymbol(for type: String) -> String {
|
|
switch type {
|
|
case "case": return "briefcase"
|
|
case "company": return "building.2"
|
|
case "document": return "doc.text"
|
|
case "invoice": return "doc.badge.gearshape"
|
|
case "contract": return "signature"
|
|
case "user": return "person"
|
|
default: return "square.grid.2x2"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - ActionBadge
|
|
|
|
struct ActionBadge: View {
|
|
let action: AuditAction
|
|
|
|
var body: some View {
|
|
Text(action.label)
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundStyle(action.color)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(action.color.opacity(0.12))
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
|
|
// MARK: - FilterSheet
|
|
|
|
struct FilterSheet: View {
|
|
@Binding var filter: AuditFilter
|
|
let onApply: (AuditFilter) -> Void
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State private var draft: AuditFilter
|
|
@State private var showFromPicker = false
|
|
@State private var showToPicker = false
|
|
|
|
init(filter: Binding<AuditFilter>, onApply: @escaping (AuditFilter) -> Void) {
|
|
self._filter = filter
|
|
self.onApply = onApply
|
|
self._draft = State(initialValue: filter.wrappedValue)
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 24) {
|
|
actionSection
|
|
dateSection
|
|
}
|
|
.padding(16)
|
|
}
|
|
.background(Color(.secondarySystemBackground))
|
|
.navigationTitle("Filter")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Avbryt") { dismiss() }
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Tillämpa") {
|
|
onApply(draft)
|
|
dismiss()
|
|
}
|
|
.fontWeight(.semibold)
|
|
}
|
|
ToolbarItem(placement: .bottomBar) {
|
|
Button(role: .destructive) {
|
|
draft = AuditFilter()
|
|
} label: {
|
|
Label("Rensa filter", systemImage: "xmark.circle")
|
|
}
|
|
.disabled(!draft.isActive)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var actionSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Åtgärdstyp")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(Color(.secondaryLabel))
|
|
.textCase(.uppercase)
|
|
|
|
VStack(spacing: 0) {
|
|
ForEach(AuditAction.allCases) { action in
|
|
let selected = draft.selectedActions.contains(action)
|
|
Button {
|
|
if selected {
|
|
draft.selectedActions.remove(action)
|
|
} else {
|
|
draft.selectedActions.insert(action)
|
|
}
|
|
} label: {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: action.symbol)
|
|
.font(.system(size: 17))
|
|
.foregroundStyle(action.color)
|
|
.frame(width: 28)
|
|
Text(action.label)
|
|
.font(.system(size: 16))
|
|
.foregroundStyle(Color(.label))
|
|
Spacer()
|
|
if selected {
|
|
Image(systemName: "checkmark")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(Color(.systemBlue))
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
}
|
|
.buttonStyle(.plain)
|
|
if action != AuditAction.allCases.last {
|
|
Divider().padding(.leading, 56)
|
|
}
|
|
}
|
|
}
|
|
.background(Color(.systemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
}
|
|
|
|
private var dateSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Tidsperiod")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(Color(.secondaryLabel))
|
|
.textCase(.uppercase)
|
|
|
|
VStack(spacing: 0) {
|
|
DatePickerRow(
|
|
label: "Från",
|
|
symbol: "calendar.badge.clock",
|
|
date: $draft.fromDate,
|
|
maximumDate: draft.toDate ?? Date()
|
|
)
|
|
Divider().padding(.leading, 56)
|
|
DatePickerRow(
|
|
label: "Till",
|
|
symbol: "calendar.badge.checkmark",
|
|
date: $draft.toDate,
|
|
minimumDate: draft.fromDate,
|
|
maximumDate: Date()
|
|
)
|
|
}
|
|
.background(Color(.systemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
}
|
|
}
|
|
|
|
struct DatePickerRow: View {
|
|
let label: String
|
|
let symbol: String
|
|
@Binding var date: Date?
|
|
var minimumDate: Date? = nil
|
|
var maximumDate: Date? = nil
|
|
|
|
@State private var expanded = false
|
|
|
|
private var displayDate: String {
|
|
guard let d = date else { return "Välj datum" }
|
|
let f = DateFormatter()
|
|
f.dateStyle = .medium
|
|
f.locale = Locale(identifier: "sv_SE")
|
|
return f.string(from: d)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
Button {
|
|
withAnimation(.spring(duration: 0.25)) {
|
|
expanded.toggle()
|
|
if expanded && date == nil { date = Date() }
|
|
if !expanded { }
|
|
}
|
|
} label: {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: symbol)
|
|
.font(.system(size: 17))
|
|
.foregroundStyle(Color(.systemBlue))
|
|
.frame(width: 28)
|
|
Text(label)
|
|
.font(.system(size: 16))
|
|
.foregroundStyle(Color(.label))
|
|
Spacer()
|
|
Text(displayDate)
|
|
.font(.system(size: 15))
|
|
.foregroundStyle(date == nil ? Color(.tertiaryLabel) : Color(.secondaryLabel))
|
|
if date != nil {
|
|
Button {
|
|
date = nil
|
|
expanded = false
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundStyle(Color(.tertiaryLabel))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(Color(.tertiaryLabel))
|
|
.rotationEffect(.degrees(expanded ? 90 : 0))
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
if expanded {
|
|
Divider().padding(.leading, 56)
|
|
DatePicker(
|
|
"",
|
|
selection: Binding(
|
|
get: { date ?? Date() },
|
|
set: { date = $0 }
|
|
),
|
|
in: (minimumDate ?? .distantPast)...(maximumDate ?? .distantFuture),
|
|
displayedComponents: .date
|
|
)
|
|
.datePickerStyle(.graphical)
|
|
.padding(.horizontal, 8)
|
|
.environment(\.locale, Locale(identifier: "sv_SE"))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - AuditView
|
|
|
|
struct AuditView: View {
|
|
@StateObject private var vm = AuditViewModel()
|
|
@State private var showFilter = false
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
Color(.secondarySystemBackground).ignoresSafeArea()
|
|
content
|
|
}
|
|
.navigationTitle("Granskningslogg")
|
|
.navigationBarTitleDisplayMode(.large)
|
|
.toolbar { toolbarContent }
|
|
.sheet(isPresented: $showFilter) {
|
|
FilterSheet(filter: $vm.filter) { newFilter in
|
|
vm.applyFilter(newFilter)
|
|
}
|
|
.presentationDetents([.medium, .large])
|
|
.presentationDragIndicator(.visible)
|
|
}
|
|
.task { await vm.loadInitial() }
|
|
}
|
|
}
|
|
|
|
// MARK: Content
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
if vm.isLoading && vm.entries.isEmpty {
|
|
loadingPlaceholder
|
|
} else if vm.entries.isEmpty {
|
|
emptyState
|
|
} else {
|
|
entryList
|
|
}
|
|
}
|
|
|
|
private var entryList: some View {
|
|
ScrollView {
|
|
LazyVStack(spacing: 8, pinnedViews: []) {
|
|
if vm.filter.isActive {
|
|
filterBanner
|
|
}
|
|
|
|
ForEach(vm.entries) { entry in
|
|
AuditRow(entry: entry)
|
|
.padding(.horizontal, 16)
|
|
.onAppear {
|
|
if entry == vm.entries.last {
|
|
Task { await vm.loadMore() }
|
|
}
|
|
}
|
|
}
|
|
|
|
if vm.isLoading && !vm.entries.isEmpty {
|
|
ProgressView()
|
|
.padding(.vertical, 16)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
if !vm.hasMore && !vm.entries.isEmpty {
|
|
Text("Inga fler händelser")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(Color(.tertiaryLabel))
|
|
.padding(.vertical, 20)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
.refreshable { await vm.refresh() }
|
|
}
|
|
|
|
private var filterBanner: some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "line.3.horizontal.decrease.circle.fill")
|
|
.foregroundStyle(Color(.systemBlue))
|
|
Text(filterSummary)
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(Color(.secondaryLabel))
|
|
Spacer()
|
|
Button {
|
|
vm.applyFilter(AuditFilter())
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundStyle(Color(.tertiaryLabel))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
.background(Color(.systemBlue).opacity(0.08))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.padding(.horizontal, 16)
|
|
}
|
|
|
|
private var filterSummary: String {
|
|
var parts: [String] = []
|
|
if !vm.filter.selectedActions.isEmpty {
|
|
parts.append(vm.filter.selectedActions.map(\.label).joined(separator: ", "))
|
|
}
|
|
let df = DateFormatter()
|
|
df.dateStyle = .short
|
|
df.locale = Locale(identifier: "sv_SE")
|
|
if let from = vm.filter.fromDate { parts.append("från \(df.string(from: from))") }
|
|
if let to = vm.filter.toDate { parts.append("till \(df.string(from: to))") }
|
|
return parts.joined(separator: " · ")
|
|
}
|
|
|
|
// MARK: Loading / Empty
|
|
|
|
private var loadingPlaceholder: some View {
|
|
LazyVStack(spacing: 8) {
|
|
ForEach(0..<8, id: \.self) { _ in
|
|
ShimmerRow()
|
|
.padding(.horizontal, 16)
|
|
}
|
|
}
|
|
.padding(.top, 8)
|
|
.allowsHitTesting(false)
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
VStack(spacing: 16) {
|
|
Image(systemName: "doc.text.magnifyingglass")
|
|
.font(.system(size: 48, weight: .light))
|
|
.foregroundStyle(Color(.tertiaryLabel))
|
|
Text("Inga händelser hittades")
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(Color(.label))
|
|
if vm.filter.isActive {
|
|
Text("Prova att justera ditt filter")
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(Color(.secondaryLabel))
|
|
Button("Rensa filter") {
|
|
vm.applyFilter(AuditFilter())
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.regular)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
// MARK: Toolbar
|
|
|
|
@ToolbarContentBuilder
|
|
private var toolbarContent: some ToolbarContent {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button {
|
|
showFilter = true
|
|
} label: {
|
|
ZStack(alignment: .topTrailing) {
|
|
Image(systemName: "line.3.horizontal.decrease.circle")
|
|
.font(.system(size: 20))
|
|
.foregroundStyle(Color(.systemBlue))
|
|
if vm.filter.isActive {
|
|
Circle()
|
|
.fill(Color(.systemRed))
|
|
.frame(width: 8, height: 8)
|
|
.offset(x: 4, y: -2)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Shimmer placeholder
|
|
|
|
struct ShimmerRow: View {
|
|
@State private var phase: CGFloat = -1
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Circle()
|
|
.fill(Color(.systemFill))
|
|
.frame(width: 40, height: 40)
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(Color(.systemFill))
|
|
.frame(width: 180, height: 14)
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(Color(.systemFill))
|
|
.frame(width: 120, height: 12)
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
.background(Color(.systemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.overlay {
|
|
GeometryReader { geo in
|
|
let w = geo.size.width
|
|
LinearGradient(
|
|
colors: [.clear, Color(.systemBackground).opacity(0.6), .clear],
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
)
|
|
.frame(width: w * 0.4)
|
|
.offset(x: phase * w)
|
|
.blendMode(.overlay)
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
.onAppear {
|
|
withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) {
|
|
phase = 1.6
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
AuditView()
|
|
}
|