diff --git a/DJDX/Games/beatmania IIDX/Data Types/IIDXSongRecord.swift b/DJDX/Games/beatmania IIDX/Data Types/IIDXSongRecord.swift index c39b2f6..bf880ae 100644 --- a/DJDX/Games/beatmania IIDX/Data Types/IIDXSongRecord.swift +++ b/DJDX/Games/beatmania IIDX/Data Types/IIDXSongRecord.swift @@ -20,6 +20,11 @@ final class IIDXSongRecord: Equatable, Hashable, @unchecked Sendable { var importGroup: ImportGroup? + // Row id of the backing SQLite record, when fetched from PlayData.db. + // Used to target manual (INFINITAS) entries for edit/delete. Not persisted + // by SwiftData since storage is handled by the custom SQLite layer. + @Transient var databaseID: Int64? + init() { // Empty default initializer required by SwiftData } diff --git a/DJDX/Games/beatmania IIDX/Data Types/IIDXVersion.swift b/DJDX/Games/beatmania IIDX/Data Types/IIDXVersion.swift index 23a375a..b8d2bba 100644 --- a/DJDX/Games/beatmania IIDX/Data Types/IIDXVersion.swift +++ b/DJDX/Games/beatmania IIDX/Data Types/IIDXVersion.swift @@ -35,9 +35,19 @@ enum IIDXVersion: Int, Codable, CaseIterable { case epolis = 31 case pinkyCrush = 32 case sparkleShower = 33 + // Home (PC) version. Has no e-amusement export, so scores are entered manually. + // Sentinel raw value kept clearly outside the arcade numbering range. + case infinitas = 1000 static var supportedVersions: [IIDXVersion] { - [.epolis, .pinkyCrush, .sparkleShower] + // INFINITAS is placed first so that, after the picker reverses the list, + // it appears as the last (bottom) entry. + [.infinitas, .epolis, .pinkyCrush, .sparkleShower] + } + + // Whether this version is the manually-tracked home version (no import path). + var isManualEntry: Bool { + self == .infinitas } var marketingName: String { @@ -75,6 +85,7 @@ enum IIDXVersion: Int, Codable, CaseIterable { case .epolis: return "EPOLIS" case .pinkyCrush: return "Pinky Crush" case .sparkleShower: return "Sparkle Shower" + case .infinitas: return "INFINITAS" } } @@ -119,6 +130,7 @@ enum IIDXVersion: Int, Codable, CaseIterable { case .epolis: return UIColor(red: 50 / 255, green: 50 / 255, blue: 50 / 255, alpha: 1.0) case .pinkyCrush: return UIColor(red: 249 / 255, green: 87 / 255, blue: 142 / 255, alpha: 1.0) case .sparkleShower: return UIColor(red: 67 / 255, green: 143 / 255, blue: 82 / 255, alpha: 1.0) + case .infinitas: return UIColor(red: 88 / 255, green: 86 / 255, blue: 214 / 255, alpha: 1.0) } } @@ -157,6 +169,7 @@ enum IIDXVersion: Int, Codable, CaseIterable { case .epolis: return UIColor(red: 240 / 255, green: 254 / 255, blue: 0 / 255, alpha: 1.0) case .pinkyCrush: return UIColor(red: 1.0, green: 97 / 255, blue: 178 / 255, alpha: 1.0) case .sparkleShower: return UIColor(red: 173 / 255, green: 227 / 255, blue: 77 / 255, alpha: 1.0) + case .infinitas: return UIColor(red: 153 / 255, green: 151 / 255, blue: 1.0, alpha: 1.0) } } diff --git a/DJDX/Games/beatmania IIDX/IIDXImporter.swift b/DJDX/Games/beatmania IIDX/IIDXImporter.swift index f3240bb..7668c80 100644 --- a/DJDX/Games/beatmania IIDX/IIDXImporter.swift +++ b/DJDX/Games/beatmania IIDX/IIDXImporter.swift @@ -205,6 +205,46 @@ actor IIDXImporter { return newID } + // MARK: Manual Entry (INFINITAS) + + // Resolves (creating if needed) the single, date-agnostic import group that + // holds all manually-entered INFINITAS records. + func infinitasImportGroupID(database: Connection) -> String { + let col = IIDXPlayDataDatabase.self + let query = col.importGroupTable + .filter(col.igIIDXVersion == IIDXVersion.infinitas.rawValue) + .limit(1) + + if let row = try? database.pluck(query) { + return row[col.igID] + } + + let newID = UUID().uuidString + _ = try? database.run(col.importGroupTable.insert( + col.igID <- newID, + col.igImportDate <- Date.now.timeIntervalSince1970, + col.igIIDXVersion <- IIDXVersion.infinitas.rawValue + )) + return newID + } + + func addManualSongRecord(_ record: IIDXSongRecord) { + guard let database = try? IIDXPlayDataDatabase.shared.getWriteConnection() else { return } + let importGroupID = infinitasImportGroupID(database: database) + Self.insertSongRecord(database: database, record: record, importGroupID: importGroupID) + } + + func updateSongRecord(id: Int64, _ record: IIDXSongRecord) { + guard let database = try? IIDXPlayDataDatabase.shared.getWriteConnection() else { return } + Self.updateSongRecord(database: database, id: id, record: record) + } + + func deleteSongRecord(id: Int64) { + guard let database = try? IIDXPlayDataDatabase.shared.getWriteConnection() else { return } + let col = IIDXPlayDataDatabase.self + _ = try? database.run(col.songRecordTable.filter(col.srID == id).delete()) + } + // MARK: Insert Helpers static func insertSongRecord(database: Connection, record: IIDXSongRecord, importGroupID: String) { @@ -266,6 +306,66 @@ actor IIDXImporter { )) } + // swiftlint:disable:next function_body_length + static func updateSongRecord(database: Connection, id: Int64, record: IIDXSongRecord) { + let col = IIDXPlayDataDatabase.self + let row = col.songRecordTable.filter(col.srID == id) + _ = try? database.run(row.update( + col.srVersion <- record.version, + col.srTitle <- record.title, + col.srGenre <- record.genre, + col.srArtist <- record.artist, + col.srPlayCount <- record.playCount, + col.srPlayType <- record.playType.rawValue, + col.srLastPlayDate <- record.lastPlayDate.timeIntervalSince1970, + // Beginner + col.srBeginnerLevel <- record.beginnerScore.level.code(), + col.srBeginnerDifficulty <- record.beginnerScore.difficulty, + col.srBeginnerScore <- record.beginnerScore.score, + col.srBeginnerPerfectGreatCount <- record.beginnerScore.perfectGreatCount, + col.srBeginnerGreatCount <- record.beginnerScore.greatCount, + col.srBeginnerMissCount <- record.beginnerScore.missCount, + col.srBeginnerClearType <- record.beginnerScore.clearType, + col.srBeginnerDJLevel <- record.beginnerScore.djLevel, + // Normal + col.srNormalLevel <- record.normalScore.level.code(), + col.srNormalDifficulty <- record.normalScore.difficulty, + col.srNormalScore <- record.normalScore.score, + col.srNormalPerfectGreatCount <- record.normalScore.perfectGreatCount, + col.srNormalGreatCount <- record.normalScore.greatCount, + col.srNormalMissCount <- record.normalScore.missCount, + col.srNormalClearType <- record.normalScore.clearType, + col.srNormalDJLevel <- record.normalScore.djLevel, + // Hyper + col.srHyperLevel <- record.hyperScore.level.code(), + col.srHyperDifficulty <- record.hyperScore.difficulty, + col.srHyperScore <- record.hyperScore.score, + col.srHyperPerfectGreatCount <- record.hyperScore.perfectGreatCount, + col.srHyperGreatCount <- record.hyperScore.greatCount, + col.srHyperMissCount <- record.hyperScore.missCount, + col.srHyperClearType <- record.hyperScore.clearType, + col.srHyperDJLevel <- record.hyperScore.djLevel, + // Another + col.srAnotherLevel <- record.anotherScore.level.code(), + col.srAnotherDifficulty <- record.anotherScore.difficulty, + col.srAnotherScore <- record.anotherScore.score, + col.srAnotherPerfectGreatCount <- record.anotherScore.perfectGreatCount, + col.srAnotherGreatCount <- record.anotherScore.greatCount, + col.srAnotherMissCount <- record.anotherScore.missCount, + col.srAnotherClearType <- record.anotherScore.clearType, + col.srAnotherDJLevel <- record.anotherScore.djLevel, + // Leggendaria + col.srLeggendariaLevel <- record.leggendariaScore.level.code(), + col.srLeggendariaDifficulty <- record.leggendariaScore.difficulty, + col.srLeggendariaScore <- record.leggendariaScore.score, + col.srLeggendariaPerfectGreatCount <- record.leggendariaScore.perfectGreatCount, + col.srLeggendariaGreatCount <- record.leggendariaScore.greatCount, + col.srLeggendariaMissCount <- record.leggendariaScore.missCount, + col.srLeggendariaClearType <- record.leggendariaScore.clearType, + col.srLeggendariaDJLevel <- record.leggendariaScore.djLevel + )) + } + static func insertSongToBEMANIWiki(database: Connection, song: IIDXSong) { let col = BEMANIWikiDatabase.self _ = try? database.run(col.songTable.insert( diff --git a/DJDX/Games/beatmania IIDX/IIDXReader.swift b/DJDX/Games/beatmania IIDX/IIDXReader.swift index 1360f62..6de8b20 100644 --- a/DJDX/Games/beatmania IIDX/IIDXReader.swift +++ b/DJDX/Games/beatmania IIDX/IIDXReader.swift @@ -56,6 +56,11 @@ actor IIDXReader { // displayed data follows the selected version (matching the trends queries). func importGroup(for selectedDate: Date, version: IIDXVersion?) -> ImportGroup? { guard let version else { return importGroup(for: selectedDate) } + // INFINITAS is a manually-tracked persistent collection: always return its + // single import group regardless of the selected date. + if version == .infinitas { + return importGroups(for: .infinitas).first + } let groups = importGroups(for: version) guard !groups.isEmpty else { return nil } @@ -133,7 +138,9 @@ actor IIDXReader { return nil } - if importGroupID != importGroup.id { + // For INFINITAS the group id never changes across manual add/edit/delete, + // so always re-query to reflect the latest entries. + if importGroupID != importGroup.id || version == .infinitas { importGroupID = importGroup.id previousFilters = nil previousSortOptions = nil @@ -567,6 +574,7 @@ actor IIDXReader { static func songRecord(from row: Row) -> IIDXSongRecord { let record = IIDXSongRecord() + record.databaseID = row[IIDXPlayDataDatabase.srID] record.version = row[IIDXPlayDataDatabase.srVersion] record.title = row[IIDXPlayDataDatabase.srTitle] record.genre = row[IIDXPlayDataDatabase.srGenre] diff --git a/DJDX/Views/UnifiedView.swift b/DJDX/Views/UnifiedView.swift index 9c23681..5b9d3af 100644 --- a/DJDX/Views/UnifiedView.swift +++ b/DJDX/Views/UnifiedView.swift @@ -24,6 +24,7 @@ struct UnifiedView: View { @AppStorage(wrappedValue: 0, "Review.LaunchCount", store: .standard) var launchCount: Int @State var isPresentingImport: Bool = false + @State var isPresentingScoreEditor: Bool = false @State var isFirstStartCleanupComplete: Bool = false @State var isEditingAnalytics: Bool = false @@ -34,6 +35,11 @@ struct UnifiedView: View { @Namespace var towerNamespace @Namespace var importNamespace + // INFINITAS has no import path; scores are added manually via a `+` button. + var isManualEntryMode: Bool { + selectedGame == .iidxArcade && iidxVersion.isManualEntry + } + var body: some View { @Bindable var progressAlertManager = progressAlertManager NavigationStack(path: $navigationManager.path) { @@ -59,11 +65,18 @@ struct UnifiedView: View { gameMenu } ToolbarItemGroup(placement: .topBarLeading) { - Button("Shared.Import", systemImage: "arrow.down.circle.dotted") { - isPresentingImport = true + if isManualEntryMode { + Button("Scores.ManualEntry.Add.Title", systemImage: "plus") { + isPresentingScoreEditor = true + } + .automaticSheetMatchedTransitionSource(id: "Import", in: importNamespace) + } else { + Button("Shared.Import", systemImage: "arrow.down.circle.dotted") { + isPresentingImport = true + } + .popoverTip(ImportMovedTip(), arrowEdge: .top) + .automaticSheetMatchedTransitionSource(id: "Import", in: importNamespace) } - .popoverTip(ImportMovedTip(), arrowEdge: .top) - .automaticSheetMatchedTransitionSource(id: "Import", in: importNamespace) } ToolbarItemGroup(placement: .topBarTrailing) { Button { @@ -118,6 +131,11 @@ struct UnifiedView: View { .presentationDetents([.large]) .interactiveDismissDisabled() } + .sheet(isPresented: $isPresentingScoreEditor) { + IIDXInfinitasScoreEditor() + .automaticSheetNavigationTransition(id: "Import", in: importNamespace) + .presentationDetents([.large]) + } .overlay { if progressAlertManager.isShowing { ProgressAlert( diff --git a/DJDX/Views/beatmania IIDX/Scores/IIDXInfinitasScoreEditor.swift b/DJDX/Views/beatmania IIDX/Scores/IIDXInfinitasScoreEditor.swift new file mode 100644 index 0000000..174aa1f --- /dev/null +++ b/DJDX/Views/beatmania IIDX/Scores/IIDXInfinitasScoreEditor.swift @@ -0,0 +1,309 @@ +import SwiftUI + +// Manual add/edit sheet for INFINITAS scores. INFINITAS has no e-amusement +// export, so each chart/difficulty is entered by hand. One entry maps to a +// single populated level on an `IIDXSongRecord`. +struct IIDXInfinitasScoreEditor: View { + + @Environment(\.dismiss) var dismiss + + @AppStorage(wrappedValue: .single, "ScoresView.PlayTypeFilter") var playTypeToShow: IIDXPlayType + + // nil = add a new entry; non-nil = edit the existing entry. + var record: IIDXSongRecord? = nil + var onDeleted: () -> Void = {} + + @State private var title: String = "" + @State private var artist: String = "" + @State private var genre: String = "" + @State private var playType: IIDXPlayType = .single + @State private var level: IIDXLevel = .another + @State private var difficulty: Int = 12 + @State private var exScore: Int = 0 + @State private var pgreat: Int = 0 + @State private var great: Int = 0 + @State private var miss: Int = 0 + @State private var clearType: IIDXClearType = .clear + @State private var djLevel: IIDXDJLevel = .djAAA + @State private var lastPlayDate: Date = .now + @State private var playCount: Int = 1 + + @State private var songs: [IIDXSong] = [] + @State private var isShowingSongPicker: Bool = false + @State private var isShowingDeleteConfirmation: Bool = false + @State private var didLoadInitialValues: Bool = false + + private let reader = IIDXReader() + private let writer = IIDXImporter() + + var isEditing: Bool { record != nil } + + var body: some View { + NavigationStack { + Form { + songSection + chartSection + scoreSection + playSection + if isEditing { + Section { + Button("Scores.ManualEntry.Delete", systemImage: "trash", role: .destructive) { + isShowingDeleteConfirmation = true + } + } + } + } + .navigationTitle(isEditing ? "Scores.ManualEntry.Edit.Title" : "Scores.ManualEntry.Add.Title") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + if #available(iOS 26.0, *) { + Button(role: .cancel) { dismiss() } + } else { + Button("Shared.Cancel") { dismiss() } + } + } + ToolbarItem(placement: .topBarTrailing) { + if #available(iOS 26.0, *) { + Button(role: .confirm) { save() } + .disabled(title.isEmpty) + } else { + Button("Shared.Done") { save() } + .disabled(title.isEmpty) + } + } + } + .sheet(isPresented: $isShowingSongPicker) { + songPicker + } + .confirmationDialog( + "Scores.ManualEntry.Delete.Confirmation", + isPresented: $isShowingDeleteConfirmation, + titleVisibility: .visible + ) { + Button("Scores.ManualEntry.Delete", role: .destructive) { delete() } + } + .onAppear(perform: loadInitialValuesIfNeeded) + .task { + songs = await reader.fetchAllSongs() + } + } + } + + // MARK: Sections + + @ViewBuilder private var songSection: some View { + Section("Scores.ManualEntry.Section.Song") { + TextField("Scores.ManualEntry.Title", text: $title) + if !songs.isEmpty { + Button("Scores.ManualEntry.PickFromBEMANIWiki", systemImage: "magnifyingglass") { + isShowingSongPicker = true + } + } + TextField("Scores.ManualEntry.Artist", text: $artist) + TextField("Scores.ManualEntry.Genre", text: $genre) + } + } + + @ViewBuilder private var chartSection: some View { + Section("Scores.ManualEntry.Section.Chart") { + Picker("Shared.PlayType", selection: $playType) { + Text(verbatim: "SP").tag(IIDXPlayType.single) + Text(verbatim: "DP").tag(IIDXPlayType.double) + } + .pickerStyle(.segmented) + Picker("Scores.ManualEntry.Level", selection: $level) { + ForEach(IIDXLevel.sorted, id: \.self) { level in + Text(LocalizedStringKey(level.rawValue)).tag(level) + } + } + Stepper(value: $difficulty, in: 1...12) { + LabeledContent("Scores.ManualEntry.DifficultyRating") { + Text(verbatim: "\(difficulty)") + } + } + } + } + + @ViewBuilder private var scoreSection: some View { + Section("Scores.ManualEntry.Section.Score") { + numberField("Scores.ManualEntry.EXScore", value: $exScore) + numberField("Scores.ManualEntry.PGreat", value: $pgreat) + numberField("Scores.ManualEntry.Great", value: $great) + numberField("Scores.ManualEntry.Miss", value: $miss) + Picker("Shared.IIDX.ClearType", selection: $clearType) { + ForEach(IIDXClearType.sortedWithoutNoPlay, id: \.self) { clearType in + Text(LocalizedStringKey(clearType.rawValue)).tag(clearType) + } + Text(LocalizedStringKey(IIDXClearType.noPlay.rawValue)).tag(IIDXClearType.noPlay) + } + Picker("Shared.IIDX.DJLevel", selection: $djLevel) { + ForEach(IIDXDJLevel.sorted.reversed(), id: \.self) { djLevel in + Text(verbatim: djLevel.rawValue).tag(djLevel) + } + Text(verbatim: IIDXDJLevel.none.rawValue).tag(IIDXDJLevel.none) + } + } + } + + @ViewBuilder private var playSection: some View { + Section("Scores.ManualEntry.Section.Play") { + DatePicker("Scores.ManualEntry.LastPlayDate", + selection: $lastPlayDate, + in: ...Date.now, + displayedComponents: [.date, .hourAndMinute]) + Stepper(value: $playCount, in: 0...9999) { + LabeledContent("Scores.ManualEntry.PlayCount") { + Text(verbatim: "\(playCount)") + } + } + } + } + + @ViewBuilder private func numberField(_ titleKey: LocalizedStringKey, value: Binding) -> some View { + LabeledContent(titleKey) { + TextField(titleKey, value: value, format: .number) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + } + } + + @ViewBuilder private var songPicker: some View { + SongSearchPicker(songs: songs) { selectedTitle in + title = selectedTitle + isShowingSongPicker = false + } + } + + // MARK: Actions + + private func loadInitialValuesIfNeeded() { + guard !didLoadInitialValues else { return } + didLoadInitialValues = true + + guard let record else { + // New entry: default the play type to the currently displayed one so + // the entry appears in the list immediately. + playType = playTypeToShow + return + } + + title = record.title + artist = record.artist + genre = record.genre + playType = record.playType + playCount = record.playCount + lastPlayDate = record.lastPlayDate == .distantPast ? .now : record.lastPlayDate + + // Find the single populated level for this manual entry. + if let populated = record.scores().first { + level = populated.level + difficulty = populated.difficulty + exScore = populated.score + pgreat = populated.perfectGreatCount + great = populated.greatCount + miss = populated.missCount + clearType = IIDXClearType(rawValue: populated.clearType) ?? .clear + djLevel = populated.djLevelEnum() + } + } + + private func buildRecord() -> IIDXSongRecord { + let result = record ?? IIDXSongRecord() + result.version = IIDXVersion.infinitas.marketingName + result.title = title + result.artist = artist + result.genre = genre + result.playType = playType + result.playCount = playCount + result.lastPlayDate = lastPlayDate + + let score = IIDXLevelScore( + level: level, + difficulty: difficulty, + score: exScore, + perfectGreatCount: pgreat, + greatCount: great, + missCount: miss, + clearType: clearType.rawValue, + djLevel: djLevel.rawValue + ) + // Each manual entry holds exactly one populated level; the rest stay empty. + result.beginnerScore = level == .beginner ? score : IIDXLevelScore() + result.normalScore = level == .normal ? score : IIDXLevelScore() + result.hyperScore = level == .hyper ? score : IIDXLevelScore() + result.anotherScore = level == .another ? score : IIDXLevelScore() + result.leggendariaScore = level == .leggendaria ? score : IIDXLevelScore() + return result + } + + private func save() { + let builtRecord = buildRecord() + let databaseID = record?.databaseID + Task { + if let databaseID { + await writer.updateSongRecord(id: databaseID, builtRecord) + } else { + await writer.addManualSongRecord(builtRecord) + } + await MainActor.run { + NotificationCenter.default.post(name: .dataImported, object: nil) + dismiss() + } + } + } + + private func delete() { + guard let databaseID = record?.databaseID else { return } + Task { + await writer.deleteSongRecord(id: databaseID) + await MainActor.run { + NotificationCenter.default.post(name: .dataImported, object: nil) + onDeleted() + dismiss() + } + } + } +} + +// Searchable list of BEMANIWiki song titles used to fill in the song title. +private struct SongSearchPicker: View { + + @Environment(\.dismiss) var dismiss + + let songs: [IIDXSong] + var onSelect: (String) -> Void + + @State private var searchTerm: String = "" + + private var filteredSongs: [IIDXSong] { + let trimmed = searchTerm.lowercased().trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return songs } + return songs.filter { $0.title.lowercased().contains(trimmed) } + } + + var body: some View { + NavigationStack { + List(filteredSongs, id: \.title) { song in + Button { + onSelect(song.title) + } label: { + Text(song.title) + .foregroundStyle(.primary) + } + } + .searchable(text: $searchTerm, prompt: "Scores.ManualEntry.SongPicker.Search") + .navigationTitle("Scores.ManualEntry.SongPicker.Title") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if #available(iOS 26.0, *) { + Button(role: .close) { dismiss() } + } else { + Button("Shared.Cancel") { dismiss() } + } + } + } + } + } +} diff --git a/DJDX/Views/beatmania IIDX/Scores/IIDXScoresView.swift b/DJDX/Views/beatmania IIDX/Scores/IIDXScoresView.swift index ed44361..353cc33 100644 --- a/DJDX/Views/beatmania IIDX/Scores/IIDXScoresView.swift +++ b/DJDX/Views/beatmania IIDX/Scores/IIDXScoresView.swift @@ -223,10 +223,13 @@ struct IIDXScoresView: View { } .toolbar { if #available(iOS 26.0, *) { - ToolbarItemGroup(placement: .bottomBar) { - timeTravelButton + // INFINITAS is a date-agnostic manual collection, so hide time travel. + if iidxVersion != .infinitas { + ToolbarItemGroup(placement: .bottomBar) { + timeTravelButton + } + ToolbarSpacer(.fixed, placement: .bottomBar) } - ToolbarSpacer(.fixed, placement: .bottomBar) DefaultToolbarItem(kind: .search, placement: .bottomBar) ToolbarSpacer(.fixed, placement: .bottomBar) ToolbarItemGroup(placement: .bottomBar) { @@ -235,7 +238,9 @@ struct IIDXScoresView: View { } } else { ToolbarItemGroup(placement: .bottomBar) { - timeTravelButton + if iidxVersion != .infinitas { + timeTravelButton + } Spacer() sortControl filterControl diff --git a/DJDX/Views/beatmania IIDX/Scores/Viewer/IIDXScoreViewer.swift b/DJDX/Views/beatmania IIDX/Scores/Viewer/IIDXScoreViewer.swift index b743b43..7adb300 100644 --- a/DJDX/Views/beatmania IIDX/Scores/Viewer/IIDXScoreViewer.swift +++ b/DJDX/Views/beatmania IIDX/Scores/Viewer/IIDXScoreViewer.swift @@ -3,6 +3,7 @@ import SwiftUI struct IIDXScoreViewer: View { @Environment(\.colorScheme) var colorScheme: ColorScheme + @EnvironmentObject var navigationManager: NavigationManager @AppStorage(wrappedValue: false, "ScoresView.BeginnerLevelHidden") var isBeginnerLevelHidden: Bool @AppStorage(wrappedValue: IIDXVersion.sparkleShower, "Global.IIDX.Version") var iidxVersion: IIDXVersion @@ -12,6 +13,7 @@ struct IIDXScoreViewer: View { @State private var selectedLevel: IIDXLevel = .all @State private var radarDataByLevel: [String: ChartRadarData] = [:] + @State private var isPresentingEditor: Bool = false private let radarFetcher = IIDXReader() @@ -27,6 +29,12 @@ struct IIDXScoreViewer: View { return levels } + @ViewBuilder var editButton: some View { + Button("Shared.Edit", systemImage: "pencil") { + isPresentingEditor = true + } + } + var body: some View { List { if selectedLevel == .all || selectedLevel == .beginner, @@ -73,6 +81,30 @@ struct IIDXScoreViewer: View { Spacer() } versionNumberToolbarItem() + // INFINITAS entries are manually maintained, so offer an edit affordance. + if iidxVersion == .infinitas { + if #available(iOS 26.0, *) { + ToolbarSpacer(.flexible, placement: .bottomBar) + ToolbarItem(placement: .bottomBar) { + editButton + } + } else { + ToolbarItemGroup(placement: .bottomBar) { + Spacer() + editButton + } + } + } + } + .sheet(isPresented: $isPresentingEditor) { + // IIDXSongRecord is a reference type; the editor mutates this same + // instance in place, so dismissing the sheet re-renders with the + // updated values. Deleting pops back to the list. + IIDXInfinitasScoreEditor( + record: songRecord, + onDeleted: { navigationManager.popToRoot() } + ) + .presentationDetents([.large]) } .safeAreaInset(edge: .top, spacing: 0.0) { TabBarAccessory(placement: .top) { diff --git a/Shared/Localizable.xcstrings b/Shared/Localizable.xcstrings index 0741584..a5f1302 100644 --- a/Shared/Localizable.xcstrings +++ b/Shared/Localizable.xcstrings @@ -3999,7 +3999,381 @@ }, "YouTube" : { "shouldTranslate" : false + }, + "Scores.ManualEntry.Add.Title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add Score" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "スコアを追加" + } + } + } + }, + "Scores.ManualEntry.Edit.Title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit Score" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "スコアを編集" + } + } + } + }, + "Scores.ManualEntry.Section.Song" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Song" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "楽曲" + } + } + } + }, + "Scores.ManualEntry.Title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "タイトル" + } + } + } + }, + "Scores.ManualEntry.Artist" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Artist" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アーティスト" + } + } + } + }, + "Scores.ManualEntry.Genre" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genre" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ジャンル" + } + } + } + }, + "Scores.ManualEntry.PickFromBEMANIWiki" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pick from BEMANIWiki" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "BEMANIWikiから選択" + } + } + } + }, + "Scores.ManualEntry.Section.Chart" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chart" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "譜面" + } + } + } + }, + "Scores.ManualEntry.Level" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Difficulty" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "難易度" + } + } + } + }, + "Scores.ManualEntry.DifficultyRating" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Level" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "レベル" + } + } + } + }, + "Scores.ManualEntry.Section.Score" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Score" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "スコア" + } + } + } + }, + "Scores.ManualEntry.EXScore" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "EX Score" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "EXスコア" + } + } + } + }, + "Scores.ManualEntry.PGreat" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "PGreat" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "PGREAT" + } + } + } + }, + "Scores.ManualEntry.Great" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Great" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "GREAT" + } + } + } + }, + "Scores.ManualEntry.Miss" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Miss Count" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ミスカウント" + } + } + } + }, + "Scores.ManualEntry.Section.Play" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Play" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "プレー" + } + } + } + }, + "Scores.ManualEntry.LastPlayDate" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Last Played" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "最終プレー日時" + } + } + } + }, + "Scores.ManualEntry.PlayCount" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Play Count" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "プレー回数" + } + } + } + }, + "Scores.ManualEntry.Delete" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Entry" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "エントリーを削除" + } + } + } + }, + "Scores.ManualEntry.Delete.Confirmation" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete this entry?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このエントリーを削除しますか?" + } + } + } + }, + "Scores.ManualEntry.SongPicker.Title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select Song" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "楽曲を選択" + } + } + } + }, + "Scores.ManualEntry.SongPicker.Search" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search songs" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "楽曲を検索" + } + } + } } }, "version" : "1.0" -} \ No newline at end of file +}