import SwiftUI import SFSafeSymbols import BottomSheet struct ContentView: View { private let bottomIconSize: CGFloat = 25 private let bottomIconPadding: CGFloat = 7 private let capturedImageSize: CGFloat = 80 private let plusIconSize: CGFloat = 20 private var scale: CGFloat { UIScreen.main.scale } @EnvironmentObject var database: Database @State var searchString = "" @State var sortType: SortCriteria = .id @State var sortAscending = false @State var showSortPopover = false @State var showCameraSheet = false @State var showSettingsSheet = false @State var showGridView = false @State var showNewClassifierAlert = false @State var showUpdateCapNameAlert = false @State var updatedCapName = "" @State var isEnteringNewCapName = false @State private var selectedCapId: Int? @State var showImageOverviewForCap = false @State private var selectedCapToShowImages: Cap? var filteredCaps: [Cap] { let text = searchString .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() guard text != "" else { return Array(database.caps.values) } let textParts = text.components(separatedBy: " ").filter { $0 != "" } return database.caps.values.compactMap { cap -> Cap? in // For each part of text, check if name contains it for textItem in textParts { if !cap.cleanName.contains(textItem) { return nil } } return cap } } var shownCaps: [Cap] { let caps = filteredCaps if sortAscending { switch sortType { case .id: return caps.sorted { $0.id < $1.id } case .count: return caps.sorted { $0.imageCount < $1.imageCount } case .name: return caps.sorted { $0.name < $1.name } case .match: return caps.sorted { match(for: $0.id) ?? 0 < match(for: $1.id) ?? 0 } } } else { switch sortType { case .id: return caps.sorted { $0.id > $1.id } case .count: return caps.sorted { $0.imageCount > $1.imageCount } case .name: return caps.sorted { $0.name > $1.name } case .match: return caps.sorted { match(for: $0.id) ?? 0 > match(for: $1.id) ?? 0 } } } } func match(for cap: Int) -> Float? { database.matches[cap] } var body: some View { NavigationView { ZStack { List(shownCaps) { cap in CapRowView(cap: cap, match: match(for: cap.id)) .onTapGesture { didTap(cap: cap) } .swipeActions(edge: .trailing) { Button { showRenameWindow(for: cap) } label: { Label("Rename", systemSymbol: .pencil) } .tint(.purple) } .swipeActions(edge: .leading) { Button { showAllImages(for: cap) } label: { Label("Images", systemSymbol: .photoStack) } .tint(.purple) } } .refreshable { refresh() } .navigationTitle("Caps") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button(action: showSettings) { Image(systemSymbol: .gearshape) } } } VStack(spacing: 0) { Spacer() if let image = database.image { HStack(alignment: .bottom, spacing: 0) { Spacer() if isEnteringNewCapName { Text(String(format: "ID: %d", database.nextCapId)) .font(.headline) .padding(.vertical, 3) .padding(.horizontal, 8) .background(.regularMaterial) .cornerRadius(5) .padding(5) } Button(action: didTapClassifiedImage) { ZStack(alignment: Alignment.bottomTrailing) { Image(uiImage: image.resize(to: CGSize(width: capturedImageSize, height: capturedImageSize))) .frame(width: capturedImageSize, height: capturedImageSize) .background(.gray) .cornerRadius(capturedImageSize/2) .padding(5) .background(.regularMaterial) .cornerRadius(capturedImageSize/2 + 5) .padding(5) if !isEnteringNewCapName { Image(systemSymbol: .plus) .frame(width: plusIconSize, height: plusIconSize) .padding(6) .background(.regularMaterial) .cornerRadius(plusIconSize/2 + 6) .padding(5) } } } } } HStack(spacing: 0) { if isEnteringNewCapName { Button(action: removeCapturedImage) { Image(systemSymbol: .xmark) .resizable() .frame(width: bottomIconSize-6, height: bottomIconSize-6) .padding() .padding(3) } } else { Button(action: filter) { Image(systemSymbol: .line3HorizontalDecreaseCircle) .resizable() .frame(width: bottomIconSize, height: bottomIconSize) .padding() } } if isEnteringNewCapName { CapNameEntryView(name: $searchString) .disableAutocorrection(true) } else { SearchField(searchString: $searchString) .disableAutocorrection(true) } if isEnteringNewCapName { Button(action: saveNewCap) { Image(systemSymbol: .squareAndArrowDown) .resizable() .frame(width: bottomIconSize-3, height: bottomIconSize) .padding() .padding(.horizontal, (bottomIconPadding-3)/2) } } else if database.image != nil { Button(action: removeCapturedImage) { Image(systemSymbol: .xmark) .resizable() .frame(width: bottomIconSize-6, height: bottomIconSize-6) .padding(3) .padding(bottomIconPadding) } } else { Button(action: openCamera) { Image(systemSymbol: .camera) .resizable() .frame(width: bottomIconSize+bottomIconPadding, height: bottomIconSize) .padding() } } } .background(.regularMaterial) } } } .onAppear { UIScrollView.appearance().keyboardDismissMode = .onDrag } .bottomSheet(isPresented: $showSortPopover, height: 280) { SortSelectionView( hasMatches: !database.matches.isEmpty, isPresented: $showSortPopover, sortType: $sortType, sortAscending: $sortAscending, showGridView: $showGridView) } .sheet(isPresented: $showCameraSheet) { CameraView(isPresented: $showCameraSheet, image: $database.image, capId: $selectedCapId) } .sheet(isPresented: $showSettingsSheet) { SettingsView(isPresented: $showSettingsSheet) } .sheet(isPresented: $showGridView) { GridView(isPresented: $showGridView, database: database) } .bottomSheet(isPresented: $showImageOverviewForCap, height: 400) { CapImagesView(cap: $selectedCapToShowImages, database: database, isPresented: $showImageOverviewForCap) } .alert(isPresented: $showNewClassifierAlert) { Alert(title: Text("New classifier available"), message: Text("Classifier \(database.serverClassifierVersion) is available. You have version \(database.classifierVersion). Do you want to download it now?"), primaryButton: .default(Text("Download"), action: downloadClassifier), secondaryButton: .cancel()) } .alert("Update name", isPresented: $showUpdateCapNameAlert, actions: { TextField("Name", text: $updatedCapName) Button("Update", action: saveNewCapName) Button("Cancel", role: .cancel, action: {}) }, message: { Text("Please enter the new name for the cap") }) .onChange(of: database.image) { newImage in if newImage != nil { sortType = .id sortAscending = false return } }.onChange(of: database.matches) { newMatches in if newMatches.isEmpty { sortType = .id sortAscending = false } else { sortType = .match sortAscending = false } }.onAppear { database.startRegularUploads() } } private func refresh() { Task { await database.downloadCaps() let hasNewClassifier = await database.serverHasNewClassifier() guard hasNewClassifier else { return } DispatchQueue.main.async { self.showNewClassifierAlert = true } } } private func filter() { showSortPopover.toggle() } private func openCamera() { removeCapturedImage() showCameraSheet.toggle() } private func showSettings() { showSettingsSheet.toggle() } private func downloadClassifier() { Task { await database.downloadClassifier() } } private func didTapClassifiedImage() { isEnteringNewCapName = true } private func removeCapturedImage() { database.image = nil } private func didTap(cap: Cap) { guard let image = database.image else { selectedCapId = cap.id openCamera() return } database.save(image, for: cap.id) database.image = nil selectedCapId = nil } private func saveNewCap() { guard let image = database.image else { return } let name = searchString.trimmingCharacters(in: .whitespacesAndNewlines) let newCap = database.save(newCap: name) database.save(image, for: newCap.id) removeCapturedImage() isEnteringNewCapName = false } private func showRenameWindow(for cap: Cap) { updatedCapName = cap.name selectedCapId = cap.id showUpdateCapNameAlert = true } private func saveNewCapName() { defer { selectedCapId = nil updatedCapName = "" } guard let capId = selectedCapId else { return } let name = updatedCapName.trimmingCharacters(in: .whitespacesAndNewlines) guard name != "" else { return } guard database.update(name: name, for: capId) else { return } } private func showAllImages(for cap: Cap) { selectedCapToShowImages = cap showImageOverviewForCap = true } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() .environmentObject(Database.mock) } }