Add CI/CD configuration and API documentation
This commit is contained in:
+154
@@ -0,0 +1,154 @@
|
||||
import GRDBQuery
|
||||
import SwiftUI
|
||||
|
||||
/// The main application view
|
||||
struct AppView: View {
|
||||
/// Write access to the database
|
||||
@Environment(\.appDatabase) private var appDatabase
|
||||
|
||||
/// The `players` property is automatically updated when the database changes
|
||||
@Query(PlayerRequest(ordering: .byScore)) private var players: [Player]
|
||||
|
||||
/// We'll need to leave edit mode in several occasions.
|
||||
@State private var editMode = EditMode.inactive
|
||||
|
||||
/// Tracks the presentation of the player creation sheet.
|
||||
@State private var newPlayerIsPresented = false
|
||||
|
||||
// If you want to define the query on initialization, you will prefer:
|
||||
//
|
||||
// @Query<PlayerRequest> private var players: [Player]
|
||||
//
|
||||
// init(initialOrdering: PlayerRequest.Ordering) {
|
||||
// _players = Query(PlayerRequest(ordering: initialOrdering))
|
||||
// }
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
PlayerList(players: players)
|
||||
.navigationBarTitle(Text("\(players.count) Players"))
|
||||
.navigationBarItems(
|
||||
leading: HStack {
|
||||
EditButton()
|
||||
newPlayerButton
|
||||
},
|
||||
trailing: ToggleOrderingButton(
|
||||
ordering: $players.ordering,
|
||||
willChange: {
|
||||
// onChange(of: $players.wrappedValue.ordering)
|
||||
// is not able to leave the editing mode during
|
||||
// the animation of the list content.
|
||||
// Workaround: stop editing before the ordering
|
||||
// is changed, and the list content is updated.
|
||||
stopEditing()
|
||||
}))
|
||||
.toolbar { toolbarContent }
|
||||
.onChange(of: players) {
|
||||
if players.isEmpty {
|
||||
stopEditing()
|
||||
}
|
||||
}
|
||||
.environment(\.editMode, $editMode)
|
||||
}
|
||||
}
|
||||
|
||||
private var toolbarContent: some ToolbarContent {
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
Button {
|
||||
// Don't stopEditing() here because this is
|
||||
// performed `onChange(of: players)`
|
||||
Task {
|
||||
try? await appDatabase.deleteAllPlayers()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "trash").imageScale(.large)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
stopEditing()
|
||||
Task {
|
||||
try? await appDatabase.refreshPlayers()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise").imageScale(.large)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
stopEditing()
|
||||
// Perform 50 refreshes in parallel
|
||||
Task {
|
||||
try? await withThrowingTaskGroup(of: Void.self) { group in
|
||||
for _ in 0..<50 {
|
||||
_ = group.addTaskUnlessCancelled {
|
||||
try await appDatabase.refreshPlayers()
|
||||
}
|
||||
}
|
||||
try await group.waitForAll()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "tornado").imageScale(.large)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The button that presents the player creation sheet.
|
||||
private var newPlayerButton: some View {
|
||||
Button {
|
||||
stopEditing()
|
||||
newPlayerIsPresented = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibility(label: Text("New Player"))
|
||||
.sheet(isPresented: $newPlayerIsPresented) {
|
||||
PlayerCreationView()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopEditing() {
|
||||
withAnimation {
|
||||
editMode = .inactive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToggleOrderingButton: View {
|
||||
@Binding var ordering: PlayerRequest.Ordering
|
||||
let willChange: () -> Void
|
||||
|
||||
var body: some View {
|
||||
switch ordering {
|
||||
case .byName:
|
||||
Button {
|
||||
willChange()
|
||||
ordering = .byScore
|
||||
} label: {
|
||||
Label("Name", systemImage: "arrowtriangle.up.fill").labelStyle(.titleAndIcon)
|
||||
}
|
||||
case .byScore:
|
||||
Button {
|
||||
willChange()
|
||||
ordering = .byName
|
||||
} label: {
|
||||
Label("Score", systemImage: "arrowtriangle.down.fill").labelStyle(.titleAndIcon)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Empty") {
|
||||
// Preview the default, empty database
|
||||
AppView()
|
||||
}
|
||||
|
||||
#Preview("Populated") {
|
||||
// Preview a database of random players
|
||||
AppView().appDatabase(.random())
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The view that creates a new player.
|
||||
struct PlayerCreationView: View {
|
||||
/// Write access to the database
|
||||
@Environment(\.appDatabase) private var appDatabase
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var form = PlayerForm(name: "", score: "")
|
||||
@State private var errorAlertIsPresented = false
|
||||
@State private var errorAlertTitle = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
PlayerFormView(form: $form)
|
||||
.alert(
|
||||
isPresented: $errorAlertIsPresented,
|
||||
content: { Alert(title: Text(errorAlertTitle)) })
|
||||
.navigationBarTitle("New Player")
|
||||
.navigationBarItems(
|
||||
leading: Button(role: .cancel) {
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("Cancel")
|
||||
},
|
||||
trailing: Button {
|
||||
Task { await save() }
|
||||
} label: {
|
||||
Text("Save")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
do {
|
||||
var player = Player(id: nil, name: "", score: 0)
|
||||
form.apply(to: &player)
|
||||
try await appDatabase.savePlayer(&player)
|
||||
dismiss()
|
||||
} catch {
|
||||
errorAlertTitle = (error as? LocalizedError)?.errorDescription ?? "An error occurred"
|
||||
errorAlertIsPresented = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview {
|
||||
PlayerCreationView()
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The view that edits an existing player.
|
||||
struct PlayerEditionView: View {
|
||||
/// Write access to the database
|
||||
@Environment(\.appDatabase) private var appDatabase
|
||||
@Environment(\.isPresented) private var isPresented
|
||||
private let player: Player
|
||||
@State private var form: PlayerForm
|
||||
|
||||
init(player: Player) {
|
||||
self.player = player
|
||||
self.form = PlayerForm(player)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
PlayerFormView(form: $form)
|
||||
.onChange(of: isPresented) {
|
||||
// Save when back button is pressed
|
||||
if !isPresented {
|
||||
Task {
|
||||
var savedPlayer = player
|
||||
form.apply(to: &savedPlayer)
|
||||
// Ignore error because I don't know how to cancel the
|
||||
// back button and present the error
|
||||
try? await appDatabase.savePlayer(&savedPlayer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
PlayerEditionView(player: Player.makeRandom())
|
||||
.navigationBarTitle("Player Edition")
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The Player editing form, embedded in both
|
||||
/// `PlayerCreationView` and `PlayerEditionView`.
|
||||
struct PlayerFormView: View {
|
||||
@Binding var form: PlayerForm
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
TextField("Name", text: $form.name)
|
||||
.accessibility(label: Text("Player Name"))
|
||||
TextField("Score", text: $form.score).keyboardType(.numberPad)
|
||||
.accessibility(label: Text("Player Score"))
|
||||
}
|
||||
.listStyle(InsetGroupedListStyle())
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayerForm {
|
||||
var name: String
|
||||
var score: String
|
||||
}
|
||||
|
||||
extension PlayerForm {
|
||||
init(_ player: Player) {
|
||||
self.name = player.name
|
||||
self.score = "\(player.score)"
|
||||
}
|
||||
|
||||
func apply(to player: inout Player) {
|
||||
player.name = name
|
||||
player.score = Int(score) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Empty") {
|
||||
PlayerFormView(form: .constant(PlayerForm(
|
||||
name: "",
|
||||
score: "")))
|
||||
}
|
||||
|
||||
#Preview("Prefilled") {
|
||||
PlayerFormView(form: .constant(PlayerForm(
|
||||
name: Player.randomName(),
|
||||
score: "\(Player.randomScore())")))
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PlayerList: View {
|
||||
/// Write access to the database
|
||||
@Environment(\.appDatabase) private var appDatabase
|
||||
|
||||
/// The players in the list
|
||||
var players: [Player]
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(players) { player in
|
||||
NavigationLink(destination: editionView(for: player)) {
|
||||
PlayerRow(player: player)
|
||||
// Don't animate player update
|
||||
.animation(nil, value: player)
|
||||
}
|
||||
}
|
||||
.onDelete { offsets in
|
||||
let playerIds = offsets.compactMap { players[$0].id }
|
||||
Task {
|
||||
try? await appDatabase.deletePlayers(ids: playerIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Animate list updates
|
||||
.animation(.default, value: players)
|
||||
.listStyle(.plain)
|
||||
}
|
||||
|
||||
/// The view that edits a player in the list.
|
||||
private func editionView(for player: Player) -> some View {
|
||||
PlayerEditionView(player: player).navigationBarTitle(player.name)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PlayerRow: View {
|
||||
var player: Player
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(player.name)
|
||||
Spacer()
|
||||
Text("\(player.score) points").foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
PlayerList(players: [
|
||||
Player(id: 1, name: "Arthur", score: 100),
|
||||
Player(id: 2, name: "Barbara", score: 1000),
|
||||
])
|
||||
.navigationTitle("Preview")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user