440 lines
15 KiB
Swift
440 lines
15 KiB
Swift
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 showDeleteCapAlert = 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)
|
|
Button {
|
|
selectedCapId = cap.id
|
|
showDeleteCapAlert = true
|
|
} label: {
|
|
Label("Delete", systemSymbol: .trashCircleFill)
|
|
}
|
|
.tint(.red)
|
|
}
|
|
.swipeActions(edge: .leading) {
|
|
Button {
|
|
showAllImages(for: cap)
|
|
} label: {
|
|
Label("Images", systemSymbol: .photoStack)
|
|
}
|
|
.tint(.blue)
|
|
}
|
|
}
|
|
.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(Text("Delete cap"),
|
|
isPresented: $showDeleteCapAlert,
|
|
actions: {
|
|
Button("Delete", role: .destructive, action: saveNewCapName)
|
|
Button("Cancel", role: .cancel, action: {})
|
|
}, message: {
|
|
Text("Confirm the deletion of cap \(selectedCapId ?? 0)")
|
|
})
|
|
.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
|
|
}
|
|
|
|
private func startDeleteCap() {
|
|
guard let cap = selectedCapId else {
|
|
return
|
|
}
|
|
Task {
|
|
guard await database.delete(cap: cap) else {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ContentView_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
ContentView()
|
|
.environmentObject(Database.mock)
|
|
}
|
|
}
|