This commit is contained in:
zeus
2025-01-22 16:22:33 +08:00
parent 09b9cec8ec
commit 738c373a77
2534 changed files with 0 additions and 486292 deletions
@@ -1,235 +0,0 @@
import Foundation
import GRDB
import os.log
/// A database of players.
///
/// You create an `AppDatabase` with a connection to an SQLite database
/// (see <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections>).
///
/// Create those connections with a configuration returned from
/// `AppDatabase/makeConfiguration(_:)`.
///
/// For example:
///
/// ```swift
/// // Create an in-memory AppDatabase
/// let config = AppDatabase.makeConfiguration()
/// let dbQueue = try DatabaseQueue(configuration: config)
/// let appDatabase = try AppDatabase(dbQueue)
/// ```
struct AppDatabase {
/// Creates an `AppDatabase`, and makes sure the database schema
/// is ready.
///
/// - important: Create the `DatabaseWriter` with a configuration
/// returned by ``makeConfiguration(_:)``.
init(_ dbWriter: any DatabaseWriter) throws {
self.dbWriter = dbWriter
try migrator.migrate(dbWriter)
}
/// Provides access to the database.
///
/// Application can use a `DatabasePool`, while SwiftUI previews and tests
/// can use a fast in-memory `DatabaseQueue`.
///
/// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections>
private let dbWriter: any DatabaseWriter
}
// MARK: - Database Configuration
extension AppDatabase {
private static let sqlLogger = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "SQL")
/// Returns a database configuration suited for `PlayerRepository`.
///
/// SQL statements are logged if the `SQL_TRACE` environment variable
/// is set.
///
/// - parameter base: A base configuration.
public static func makeConfiguration(_ base: Configuration = Configuration()) -> Configuration {
var config = base
// An opportunity to add required custom SQL functions or
// collations, if needed:
// config.prepareDatabase { db in
// db.add(function: ...)
// }
// Log SQL statements if the `SQL_TRACE` environment variable is set.
// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/database/trace(options:_:)>
if ProcessInfo.processInfo.environment["SQL_TRACE"] != nil {
config.prepareDatabase { db in
db.trace {
// It's ok to log statements publicly. Sensitive
// information (statement arguments) are not logged
// unless config.publicStatementArguments is set
// (see below).
os_log("%{public}@", log: sqlLogger, type: .debug, String(describing: $0))
}
}
}
#if DEBUG
// Protect sensitive information by enabling verbose debugging in
// DEBUG builds only.
// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/configuration/publicstatementarguments>
config.publicStatementArguments = true
#endif
return config
}
}
// MARK: - Database Migrations
extension AppDatabase {
/// The DatabaseMigrator that defines the database schema.
///
/// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/migrations>
private var migrator: DatabaseMigrator {
var migrator = DatabaseMigrator()
#if DEBUG
// Speed up development by nuking the database when migrations change
// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/migrations>
migrator.eraseDatabaseOnSchemaChange = true
#endif
migrator.registerMigration("createPlayer") { db in
// Create a table
// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseschema>
try db.create(table: "player") { t in
t.autoIncrementedPrimaryKey("id")
t.column("name", .text).notNull()
t.column("score", .integer).notNull()
}
}
// Migrations for future application versions will be inserted here:
// migrator.registerMigration(...) { db in
// ...
// }
return migrator
}
}
// MARK: - Database Access: Writes
// The write methods execute invariant-preserving database transactions.
extension AppDatabase {
/// A validation error that prevents some players from being saved into
/// the database.
enum ValidationError: LocalizedError {
case missingName
var errorDescription: String? {
switch self {
case .missingName:
return "Please provide a name"
}
}
}
/// Saves (inserts or updates) a player. When the method returns, the
/// player is present in the database, and its id is not nil.
func savePlayer(_ player: inout Player) throws {
if player.name.isEmpty {
throw ValidationError.missingName
}
try dbWriter.write { db in
try player.save(db)
}
}
/// Delete the specified players
func deletePlayers(ids: [Int64]) throws {
try dbWriter.write { db in
_ = try Player.deleteAll(db, ids: ids)
}
}
/// Delete all players
func deleteAllPlayers() throws {
try dbWriter.write { db in
_ = try Player.deleteAll(db)
}
}
/// Refresh all players (by performing some random changes, for demo purpose).
func refreshPlayers() throws {
try dbWriter.write { db in
if try Player.all().isEmpty(db) {
// When database is empty, insert new random players
try createRandomPlayers(db)
} else {
// Insert a player
if Bool.random() {
_ = try Player.makeRandom().inserted(db) // insert but ignore inserted id
}
// Delete a random player
if Bool.random() {
try Player.order(sql: "RANDOM()").limit(1).deleteAll(db)
}
// Update some players
for var player in try Player.fetchAll(db) where Bool.random() {
try player.updateChanges(db) {
$0.score = Player.randomScore()
}
}
}
}
}
/// Create random players if the database is empty.
func createRandomPlayersIfEmpty() throws {
try dbWriter.write { db in
if try Player.all().isEmpty(db) {
try createRandomPlayers(db)
}
}
}
private static let uiTestPlayers = [
Player(id: nil, name: "Arthur", score: 5),
Player(id: nil, name: "Barbara", score: 6),
Player(id: nil, name: "Craig", score: 8),
Player(id: nil, name: "David", score: 4),
Player(id: nil, name: "Elena", score: 1),
Player(id: nil, name: "Frederik", score: 2),
Player(id: nil, name: "Gilbert", score: 7),
Player(id: nil, name: "Henriette", score: 3)]
func createPlayersForUITests() throws {
try dbWriter.write { db in
try AppDatabase.uiTestPlayers.forEach { player in
_ = try player.inserted(db) // insert but ignore inserted id
}
}
}
/// Support for `createRandomPlayersIfEmpty()` and `refreshPlayers()`.
private func createRandomPlayers(_ db: Database) throws {
for _ in 0..<8 {
_ = try Player.makeRandom().inserted(db) // insert but ignore inserted id
}
}
}
// MARK: - Database Access: Reads
// This demo app does not provide any specific reading method, and instead
// gives an unrestricted read-only access to the rest of the application.
// In your app, you are free to choose another path, and define focused
// reading methods.
extension AppDatabase {
/// Provides a read-only access to the database
var reader: DatabaseReader {
dbWriter
}
}
@@ -1,32 +0,0 @@
import GRDBQuery
import SwiftUI
@main
struct GRDBCombineDemoApp: App {
var body: some Scene {
WindowGroup {
AppView().appDatabase(.shared)
}
}
}
// MARK: - Give SwiftUI access to the database
private struct AppDatabaseKey: EnvironmentKey {
static var defaultValue: AppDatabase { .empty() }
}
extension EnvironmentValues {
var appDatabase: AppDatabase {
get { self[AppDatabaseKey.self] }
set { self[AppDatabaseKey.self] = newValue }
}
}
extension View {
func appDatabase(_ appDatabase: AppDatabase) -> some View {
self
.environment(\.appDatabase, appDatabase)
.databaseContext(.readOnly { appDatabase.reader })
}
}
@@ -1,50 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -1,77 +0,0 @@
import Foundation
import GRDB
extension AppDatabase {
/// The database for the application
static let shared = makeShared()
private static func makeShared() -> AppDatabase {
do {
// Apply recommendations from
// <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections>
//
// Create the "Application Support/Database" directory if needed
let fileManager = FileManager.default
let appSupportURL = try fileManager.url(
for: .applicationSupportDirectory, in: .userDomainMask,
appropriateFor: nil, create: true)
let directoryURL = appSupportURL.appendingPathComponent("Database", isDirectory: true)
// Support for tests: delete the database if requested
if CommandLine.arguments.contains("-reset") {
try? fileManager.removeItem(at: directoryURL)
}
// Create the database folder if needed
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
// Open or create the database
let databaseURL = directoryURL.appendingPathComponent("db.sqlite")
NSLog("Database stored at \(databaseURL.path)")
let dbPool = try DatabasePool(
path: databaseURL.path,
// Use default AppDatabase configuration
configuration: AppDatabase.makeConfiguration())
// Create the AppDatabase
let appDatabase = try AppDatabase(dbPool)
// Prepare the database with test fixtures if requested
if CommandLine.arguments.contains("-fixedTestData") {
try appDatabase.createPlayersForUITests()
} else {
// Otherwise, populate the database if it is empty, for better
// demo purpose.
try appDatabase.createRandomPlayersIfEmpty()
}
return appDatabase
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate.
//
// Typical reasons for an error here include:
// * The parent directory cannot be created, or disallows writing.
// * The database is not accessible, due to permissions or data protection when the device is locked.
// * The device is out of space.
// * The database could not be migrated to its latest schema version.
// Check the error message to determine what the actual problem was.
fatalError("Unresolved error \(error)")
}
}
/// Creates an empty database for SwiftUI previews
static func empty() -> AppDatabase {
// Connect to an in-memory database
// See https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections
let dbQueue = try! DatabaseQueue(configuration: AppDatabase.makeConfiguration())
return try! AppDatabase(dbQueue)
}
/// Creates a database full of random players for SwiftUI previews
static func random() -> AppDatabase {
let appDatabase = empty()
try! appDatabase.createRandomPlayersIfEmpty()
return appDatabase
}
}
@@ -1,105 +0,0 @@
import GRDB
/// The Player struct.
///
/// Identifiable conformance supports SwiftUI list animations, and type-safe
/// GRDB primary key methods.
/// Equatable conformance supports tests.
struct Player: Identifiable, Equatable {
/// The player id.
///
/// Int64 is the recommended type for auto-incremented database ids.
/// Use nil for players that are not inserted yet in the database.
var id: Int64?
var name: String
var score: Int
}
extension Player {
private static let names = [
"Arthur", "Anita", "Barbara", "Bernard", "Craig", "Chiara", "David",
"Dean", "Éric", "Elena", "Fatima", "Frederik", "Gilbert", "Georgette",
"Henriette", "Hassan", "Ignacio", "Irene", "Julie", "Jack", "Karl",
"Kristel", "Louis", "Liz", "Masashi", "Mary", "Noam", "Nicole",
"Ophelie", "Oleg", "Pascal", "Patricia", "Quentin", "Quinn", "Raoul",
"Rachel", "Stephan", "Susie", "Tristan", "Tatiana", "Ursule", "Urbain",
"Victor", "Violette", "Wilfried", "Wilhelmina", "Yvon", "Yann",
"Zazie", "Zoé"]
/// Creates a new player with empty name and zero score
static func new() -> Player {
Player(id: nil, name: "", score: 0)
}
/// Creates a new player with random name and random score
static func makeRandom() -> Player {
Player(id: nil, name: randomName(), score: randomScore())
}
/// Returns a random name
static func randomName() -> String {
names.randomElement()!
}
/// Returns a random score
static func randomScore() -> Int {
10 * Int.random(in: 0...100)
}
}
// MARK: - Persistence
/// Make Player a Codable Record.
///
/// See <https://github.com/groue/GRDB.swift/blob/master/README.md#records>
extension Player: Codable, FetchableRecord, MutablePersistableRecord {
// Define database columns from CodingKeys
fileprivate enum Columns {
static let name = Column(CodingKeys.name)
static let score = Column(CodingKeys.score)
}
/// Updates a player id after it has been inserted in the database.
mutating func didInsert(_ inserted: InsertionSuccess) {
id = inserted.rowID
}
}
// MARK: - Player Database Requests
/// Define some player requests used by the application.
///
/// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/recordrecommendedpractices>
extension DerivableRequest<Player> {
/// A request of players ordered by name.
///
/// For example:
///
/// let players: [Player] = try dbWriter.read { db in
/// try Player.all().orderedByName().fetchAll(db)
/// }
func orderedByName() -> Self {
// Sort by name in a localized case insensitive fashion
// See https://github.com/groue/GRDB.swift/blob/master/README.md#string-comparison
order(Player.Columns.name.collating(.localizedCaseInsensitiveCompare))
}
/// A request of players ordered by score.
///
/// For example:
///
/// let players: [Player] = try dbWriter.read { db in
/// try Player.all().orderedByScore().fetchAll(db)
/// }
/// let bestPlayer: Player? = try dbWriter.read { db in
/// try Player.all().orderedByScore().fetchOne(db)
/// }
func orderedByScore() -> Self {
// Sort by descending score, and then by name, in a
// localized case insensitive fashion
// See https://github.com/groue/GRDB.swift/blob/master/README.md#string-comparison
order(
Player.Columns.score.desc,
Player.Columns.name.collating(.localizedCaseInsensitiveCompare))
}
}
@@ -1,35 +0,0 @@
import GRDB
import GRDBQuery
/// A player request can be used with the `@Query` property wrapper in order to
/// feed a view with a list of players.
///
/// For example:
///
/// struct MyView: View {
/// @Query(PlayerRequest(ordering: .byName)) private var players: [Player]
///
/// var body: some View {
/// List(players) { player in ... )
/// }
/// }
struct PlayerRequest: ValueObservationQueryable {
enum Ordering {
case byScore
case byName
}
static var defaultValue: [Player] { [] }
/// The ordering used by the player request.
var ordering: Ordering
func fetch(_ db: Database) throws -> [Player] {
switch ordering {
case .byScore:
return try Player.all().orderedByScore().fetchAll(db)
case .byName:
return try Player.all().orderedByName().fetchAll(db)
}
}
}
@@ -1,11 +0,0 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,116 +0,0 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "icon_20pt@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "icon_20pt@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "icon_29pt@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "icon_29pt@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "icon_40pt@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "icon_40pt@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "icon_60pt@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "icon_60pt@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "icon_20pt.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "icon_20pt@2x-1.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "icon_29pt.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "icon_29pt@2x-1.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "icon_40pt.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "icon_40pt@2x-1.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "icon_76pt.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "icon_76pt@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "icon_83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "LaunchIcon.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,42 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17156" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17125"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="LaunchIcon" translatesAutoresizingMaskIntoConstraints="NO" id="Sgi-te-PKu">
<rect key="frame" x="123.5" y="334.5" width="167" height="237"/>
</imageView>
</subviews>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstItem="Sgi-te-PKu" firstAttribute="centerY" secondItem="6Tk-OE-BBY" secondAttribute="centerY" id="KT7-xd-gV4"/>
<constraint firstItem="Sgi-te-PKu" firstAttribute="centerX" secondItem="6Tk-OE-BBY" secondAttribute="centerX" id="feL-Vs-SeN"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchIcon" width="167" height="237"/>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>
@@ -1,42 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>%lld Players</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@VARIABLE@</string>
<key>VARIABLE</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>lld</string>
<key>zero</key>
<string>No Player</string>
<key>one</key>
<string>1 Player</string>
<key>other</key>
<string>%lld Players</string>
</dict>
</dict>
<key>%lld points</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@VARIABLE@</string>
<key>VARIABLE</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>lld</string>
<key>zero</key>
<string>0 point</string>
<key>one</key>
<string>1 point</string>
<key>other</key>
<string>%lld points</string>
</dict>
</dict>
</dict>
</plist>
@@ -1,145 +0,0 @@
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)`
try! appDatabase.deleteAllPlayers()
} label: {
Image(systemName: "trash").imageScale(.large)
}
Spacer()
Button {
stopEditing()
try! appDatabase.refreshPlayers()
} label: {
Image(systemName: "arrow.clockwise").imageScale(.large)
}
Spacer()
Button {
stopEditing()
// Perform 50 refreshes in parallel
for _ in 0..<50 {
DispatchQueue.global().async {
try! AppDatabase.shared.refreshPlayers()
}
}
} 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())
}
@@ -1,50 +0,0 @@
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 {
save()
} label: {
Text("Save")
})
}
}
private func save() {
do {
var player = Player(id: nil, name: "", score: 0)
form.apply(to: &player)
try appDatabase.savePlayer(&player)
dismiss()
} catch {
errorAlertTitle = (error as? LocalizedError)?.errorDescription ?? "An error occurred"
errorAlertIsPresented = true
}
}
}
// MARK: - Previews
#Preview {
PlayerCreationView()
}
@@ -1,38 +0,0 @@
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 {
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? appDatabase.savePlayer(&savedPlayer)
}
}
}
}
// MARK: - Previews
#Preview {
NavigationView {
PlayerEditionView(player: Player.makeRandom())
.navigationBarTitle("Player Edition")
}
}
@@ -1,48 +0,0 @@
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())")))
}
@@ -1,57 +0,0 @@
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 }
try? 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")
}
}