You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

742 lines
26 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import SwiftUI
import CoreData
import Combine
struct HistoryView: View {
@EnvironmentObject var coreDataManager: CoreDataManager
@EnvironmentObject var languageManager: LanguageManager
@State private var searchText = ""
@State private var selectedFilter: HistoryFilter = .all
@State private var itemToDelete: HistoryItem?
@State private var showingDeleteAlert = false
@State private var showingClearConfirmSheet = false
@State private var allHistoryItems: [HistoryItem] = []
@State private var currentPage = 0
@State private var isLoadingMore = false
@State private var hasMoreData = true
@State private var isLoading = false
@State private var refreshTrigger = false
@State private var isBatchDeleteMode = false
@State private var selectedItemsForDelete: Set<UUID> = []
enum HistoryFilter: String, CaseIterable {
case all = "all"
case barcode = "barcode"
case qrcode = "qrcode"
case scanned = "scanned"
case created = "created"
case favorites = "favorites"
var displayName: String {
switch self {
case .all:
return "all".localized
case .barcode:
return "barcode".localized
case .qrcode:
return "qrcode".localized
case .scanned:
return "scanned".localized
case .created:
return "created".localized
case .favorites:
return "favorites".localized
}
}
var icon: String {
switch self {
case .all:
return "list.bullet"
case .barcode:
return "barcode"
case .qrcode:
return "qrcode"
case .scanned:
return "camera.viewfinder"
case .created:
return "plus.circle"
case .favorites:
return "heart.fill"
}
}
}
var filteredItems: [HistoryItem] {
// 使 refreshTrigger
let _ = refreshTrigger
let searchResults = allHistoryItems.filter { item in
if !searchText.isEmpty {
let content = item.content ?? ""
let barcodeType = item.barcodeType ?? ""
let qrCodeType = item.qrCodeType ?? ""
return content.localizedCaseInsensitiveContains(searchText) ||
barcodeType.localizedCaseInsensitiveContains(searchText) ||
qrCodeType.localizedCaseInsensitiveContains(searchText)
}
return true
}
switch selectedFilter {
case .all:
return searchResults
case .barcode:
return searchResults.filter { $0.dataType == DataType.barcode.rawValue }
case .qrcode:
return searchResults.filter { $0.dataType == DataType.qrcode.rawValue }
case .scanned:
return searchResults.filter { $0.dataSource == DataSource.scanned.rawValue }
case .created:
return searchResults.filter { $0.dataSource == DataSource.created.rawValue }
case .favorites:
return searchResults.filter { $0.isFavorite }
}
}
var body: some View {
VStack(spacing: 0) {
//
searchBar
//
filterBar
//
if filteredItems.isEmpty {
emptyStateView
} else {
historyList
}
}
.navigationTitle("history_records".localized)
.id(languageManager.refreshTrigger)
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: 16) {
if isBatchDeleteMode {
//
Button(action: {
// /
if selectedItemsForDelete.count == filteredItems.count {
selectedItemsForDelete.removeAll()
} else {
selectedItemsForDelete = Set(filteredItems.compactMap { $0.id })
}
}) {
Image(systemName: selectedItemsForDelete.count == filteredItems.count ? "checkmark.rectangle.fill" : "rectangle.on.rectangle")
.foregroundColor(.blue)
}
Button(action: {
if !selectedItemsForDelete.isEmpty {
deleteSelectedItems()
}
}) {
Image(systemName: "trash.fill")
.foregroundColor(.red)
}
.disabled(selectedItemsForDelete.isEmpty)
//
Button(action: {
exitBatchDeleteMode()
}) {
Image(systemName: "xmark.circle")
.foregroundColor(.gray)
}
} else {
//
//
if !allHistoryItems.isEmpty {
Button(action: {
enterBatchDeleteMode()
}) {
Image(systemName: "trash")
.foregroundColor(.red)
}
}
NavigationLink(destination: CodeTypeSelectionView()) {
Image(systemName: "plus")
}
}
}
}
}
.sheet(isPresented: $showingClearConfirmSheet) {
ClearHistoryConfirmView(
isPresented: $showingClearConfirmSheet,
onConfirm: clearHistory
)
}
.alert("delete_confirmation".localized, isPresented: $showingDeleteAlert) {
Button("cancel".localized, role: .cancel) { }
Button("delete".localized, role: .destructive) {
if let item = itemToDelete {
deleteHistoryItem(item)
itemToDelete = nil
}
}
} message: {
if let item = itemToDelete {
Text(String(format: "confirm_delete_record".localized, item.content ?? ""))
.id(languageManager.refreshTrigger)
}
}
.onAppear {
loadHistoryItems()
}
.onReceive(coreDataManager.objectWillChange) { _ in
// Core Data
loadHistoryItems()
}
}
// MARK: -
private func loadHistoryItems() {
isLoading = true
currentPage = 0
hasMoreData = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
allHistoryItems = coreDataManager.fetchHistoryItems(page: currentPage)
isLoading = false
}
}
private func loadMoreHistoryItems() {
guard !isLoadingMore && hasMoreData else { return }
isLoadingMore = true
currentPage += 1
DispatchQueue.global(qos: .userInitiated).async {
let newItems = coreDataManager.fetchHistoryItems(page: currentPage)
DispatchQueue.main.async {
if newItems.isEmpty {
self.hasMoreData = false
} else {
self.allHistoryItems.append(contentsOf: newItems)
}
self.isLoadingMore = false
}
}
}
// MARK: -
private func filterAction(for filter: HistoryFilter) {
//
selectedFilter = filter
}
// MARK: -
private func clearHistory() {
coreDataManager.clearAllHistory()
allHistoryItems.removeAll()
refreshTrigger.toggle()
}
// MARK: -
private func toggleFavorite(_ item: HistoryItem) {
// Core Data
item.isFavorite.toggle()
coreDataManager.save()
//
if let index = allHistoryItems.firstIndex(where: { $0.id == item.id }) {
allHistoryItems[index].isFavorite = item.isFavorite
}
//
refreshTrigger.toggle()
}
// MARK: -
private func deleteHistoryItem(_ item: HistoryItem) {
coreDataManager.deleteHistoryItem(item)
//
allHistoryItems.removeAll { $0.id == item.id }
refreshTrigger.toggle()
}
// MARK: -
private func showDeleteConfirmation(for item: HistoryItem) {
itemToDelete = item
showingDeleteAlert = true
}
// MARK: -
private func enterBatchDeleteMode() {
isBatchDeleteMode = true
//
selectedItemsForDelete = Set(filteredItems.compactMap { $0.id })
}
private func exitBatchDeleteMode() {
isBatchDeleteMode = false
selectedItemsForDelete.removeAll()
}
private func deleteSelectedItems() {
//
let itemsToDelete = allHistoryItems.filter { item in
guard let id = item.id else { return false }
return selectedItemsForDelete.contains(id)
}
//
for item in itemsToDelete {
coreDataManager.deleteHistoryItem(item)
}
//
allHistoryItems.removeAll { item in
guard let id = item.id else { return false }
return selectedItemsForDelete.contains(id)
}
// 退
selectedItemsForDelete.removeAll()
isBatchDeleteMode = false
//
DispatchQueue.main.async {
allHistoryItems = coreDataManager.fetchHistoryItems()
refreshTrigger.toggle()
}
}
// MARK: -
private var searchBar: some View {
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.gray)
TextField("search_history_records".localized, text: $searchText)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(Color(.systemBackground))
}
// MARK: -
private var filterBar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(HistoryFilter.allCases, id: \.self) { filter in
FilterChip(
filter: filter,
isSelected: selectedFilter == filter,
isLoading: false,
action: {
filterAction(for: filter)
}
)
}
}
.padding(.horizontal)
}
.padding(.vertical, 8)
.background(Color(.systemBackground))
}
// MARK: -
private var historyList: some View {
List {
if isLoading {
HStack {
Spacer()
VStack(spacing: 16) {
ProgressView()
.scaleEffect(1.2)
Text("loading".localized)
.font(.caption)
.foregroundColor(.secondary)
.id(languageManager.refreshTrigger)
}
.padding(.vertical, 40)
Spacer()
}
} else {
ForEach(filteredItems) { item in
HistoryItemRow(
item: item,
onToggleFavorite: {
toggleFavorite(item)
},
onDelete: {
showDeleteConfirmation(for: item)
},
isBatchDeleteMode: isBatchDeleteMode,
isSelected: selectedItemsForDelete.contains(item.id ?? UUID()),
onToggleSelection: {
if let id = item.id {
if selectedItemsForDelete.contains(id) {
selectedItemsForDelete.remove(id)
} else {
selectedItemsForDelete.insert(id)
}
}
}
)
}
//
if hasMoreData && !isLoadingMore {
Button(action: loadMoreHistoryItems) {
HStack {
Image(systemName: "arrow.down.circle")
Text("load_more".localized)
}
.foregroundColor(.blue)
.padding()
}
}
//
if isLoadingMore {
HStack {
Spacer()
ProgressView()
.scaleEffect(0.8)
Text("loading_more".localized)
.font(.caption)
.foregroundColor(.secondary)
Spacer()
}
.padding()
}
}
}
.listStyle(PlainListStyle())
}
// MARK: -
private var emptyStateView: some View {
VStack(spacing: 20) {
Image(systemName: "clock.arrow.circlepath")
.font(.system(size: 60))
.foregroundColor(.gray)
Text("no_history_records".localized)
.font(.title2)
.fontWeight(.medium)
.foregroundColor(.gray)
.id(languageManager.refreshTrigger)
Text("scan_or_create_to_start".localized)
.font(.body)
.foregroundColor(.gray)
.multilineTextAlignment(.center)
.id(languageManager.refreshTrigger)
NavigationLink(destination: CodeTypeSelectionView()) {
HStack {
Image(systemName: "plus.circle.fill")
Text("create_first_record".localized)
.id(languageManager.refreshTrigger)
}
.font(.headline)
.foregroundColor(.white)
.padding()
.background(Color.blue)
.cornerRadius(10)
}
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
#Preview {
NavigationView {
HistoryView()
.environmentObject(LanguageManager.shared)
}
}
// MARK: -
struct FilterChip: View {
let filter: HistoryView.HistoryFilter
let isSelected: Bool
let isLoading: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 6) {
Image(systemName: filter.icon)
.font(.system(size: 14))
Text(filter.displayName)
.font(.system(size: 14, weight: .medium))
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(isSelected ? Color.blue : Color(.systemGray5))
)
.foregroundColor(isSelected ? .white : .primary)
.scaleEffect(isSelected ? 1.05 : 1.0)
}
.buttonStyle(OptimizedFilterChipButtonStyle(isSelected: isSelected))
.animation(.easeInOut(duration: 0.2), value: isSelected)
}
}
// MARK: -
struct OptimizedFilterChipButtonStyle: ButtonStyle {
let isSelected: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
.opacity(configuration.isPressed ? 0.9 : 1.0)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
}
}
// MARK: -
struct FilterChipButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
.opacity(configuration.isPressed ? 0.8 : 1.0)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
}
}
// MARK: -
struct HistoryItemRow: View {
let item: HistoryItem
let onToggleFavorite: () -> Void
let onDelete: () -> Void
let isBatchDeleteMode: Bool
let isSelected: Bool
let onToggleSelection: () -> Void
var body: some View {
HStack(spacing: 12) {
//
if isBatchDeleteMode {
Button(action: onToggleSelection) {
Image(systemName: isSelected ? "checkmark.square.fill" : "square")
.font(.system(size: 20))
.foregroundColor(isSelected ? .blue : .gray)
}
.buttonStyle(PlainButtonStyle())
}
//
VStack {
if let dataTypeString = item.dataType,
let dataType = DataType(rawValue: dataTypeString) {
Image(systemName: dataType.icon)
.font(.system(size: 24))
.foregroundColor(.blue)
}
if let dataSourceString = item.dataSource,
let dataSource = DataSource(rawValue: dataSourceString) {
Image(systemName: dataSource.icon)
.font(.system(size: 12))
.foregroundColor(.gray)
}
}
.frame(width: 40)
//
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(item.content ?? "")
.font(.headline)
.lineLimit(2)
Spacer()
Button(action: onToggleFavorite) {
Image(systemName: item.isFavorite ? "heart.fill" : "heart")
.foregroundColor(item.isFavorite ? .red : .gray)
}
.buttonStyle(PlainButtonStyle())
}
HStack {
//
if let dataTypeString = item.dataType,
let dataType = DataType(rawValue: dataTypeString) {
HStack(spacing: 4) {
Image(systemName: dataType.icon)
.font(.system(size: 12))
Text(dataType.displayName)
.font(.caption)
}
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.blue.opacity(0.1))
.foregroundColor(.blue)
.cornerRadius(8)
}
//
if let barcodeTypeString = item.barcodeType,
let barcodeType = BarcodeType(rawValue: barcodeTypeString) {
HStack(spacing: 4) {
Image(systemName: barcodeType.icon)
.font(.system(size: 12))
Text(barcodeType.displayName)
.font(.caption)
}
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.green.opacity(0.1))
.foregroundColor(.green)
.cornerRadius(8)
}
if let qrCodeTypeString = item.qrCodeType,
let qrCodeType = QRCodeType(rawValue: qrCodeTypeString) {
HStack(spacing: 4) {
Image(systemName: qrCodeType.icon)
.font(.system(size: 12))
Text(qrCodeType.displayName)
.font(.caption)
}
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.orange.opacity(0.1))
.foregroundColor(.orange)
.cornerRadius(8)
}
Spacer()
//
if let createdAt = item.createdAt {
Text(formatDate(createdAt))
.font(.caption)
.foregroundColor(.gray)
}
}
}
Spacer()
}
.padding(.vertical, 8)
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button("delete".localized, role: .destructive) {
onDelete()
}
}
.background(
//
Group {
if item.dataType == DataType.qrcode.rawValue {
NavigationLink(
destination: QRCodeDetailView(historyItem: item),
label: { EmptyView() }
)
} else if item.dataType == DataType.barcode.rawValue {
NavigationLink(
destination: BarcodeDetailView(historyItem: item),
label: { EmptyView() }
)
}
}
)
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter.string(from: date)
}
}
// MARK: -
struct ClearHistoryConfirmView: View {
@EnvironmentObject var languageManager: LanguageManager
@Binding var isPresented: Bool
let onConfirm: () -> Void
var body: some View {
NavigationView {
VStack(spacing: 20) {
//
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 50))
.foregroundColor(.red)
//
Text("clear_history".localized)
.font(.title2)
.fontWeight(.bold)
.id(languageManager.refreshTrigger)
//
Text("clear_history_warning".localized)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.id(languageManager.refreshTrigger)
Spacer()
//
VStack(spacing: 12) {
//
Button(action: {
onConfirm()
isPresented = false
}) {
HStack {
Image(systemName: "trash.fill")
Text("confirm_delete".localized)
.id(languageManager.refreshTrigger)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.red)
.foregroundColor(.white)
.cornerRadius(10)
}
//
Button(action: {
isPresented = false
}) {
Text("cancel".localized)
.frame(maxWidth: .infinity)
.padding()
.background(Color(.systemGray5))
.foregroundColor(.primary)
.cornerRadius(10)
}
}
}
.padding(20)
.navigationTitle("confirm_delete".localized)
.id(languageManager.refreshTrigger)
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("close".localized) {
isPresented = false
}
.id(languageManager.refreshTrigger)
}
}
}
}
}