Add CI/CD configuration and API documentation
@@ -0,0 +1,200 @@
|
||||
import GRDB
|
||||
import os.log
|
||||
|
||||
/// `AppDatabase` lets the application access the database.
|
||||
///
|
||||
/// 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`, 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 {
|
||||
/// 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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import UIKit
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: UISceneSession Lifecycle
|
||||
|
||||
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||
// Called when a new scene session is being created.
|
||||
// Use this method to select a configuration to create the new scene with.
|
||||
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
|
||||
// Called when the user discards a scene session.
|
||||
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
|
||||
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?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>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>Default Configuration</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</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>
|
||||
@@ -0,0 +1,49 @@
|
||||
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)
|
||||
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)
|
||||
|
||||
// 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import GRDB
|
||||
|
||||
/// The Player struct.
|
||||
///
|
||||
/// Identifiable conformance supports type-safe GRDB primary key methods.
|
||||
/// Hashable conformance supports table view updates
|
||||
struct Player: Identifiable, Hashable {
|
||||
/// 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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 329 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "LaunchIcon.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="16097" 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="16087"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.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="10v-qH-Qer">
|
||||
<rect key="frame" x="123.5" y="334.5" width="167" height="237"/>
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="10v-qH-Qer" firstAttribute="centerX" secondItem="5Q0-b7-anu" secondAttribute="centerX" id="CgU-Ob-iCK"/>
|
||||
<constraint firstItem="10v-qH-Qer" firstAttribute="centerY" secondItem="5Q0-b7-anu" secondAttribute="centerY" id="l2h-yP-koC"/>
|
||||
</constraints>
|
||||
<viewLayoutGuide key="safeArea" id="5Q0-b7-anu"/>
|
||||
</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"/>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,213 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="19455" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="UAh-8N-CM8">
|
||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19454"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Player Edition View Controller-->
|
||||
<scene sceneID="Juk-Ad-tZ2">
|
||||
<objects>
|
||||
<tableViewController id="iNr-Oe-muq" customClass="PlayerEditionViewController" customModule="GRDBDemoiOS" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" dataMode="static" style="insetGrouped" separatorStyle="none" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="-1" estimatedSectionHeaderHeight="-1" sectionFooterHeight="-1" estimatedSectionFooterHeight="-1" id="vlV-lc-eQA">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<sections>
|
||||
<tableViewSection headerTitle="Name" id="Nzq-eO-jT3">
|
||||
<cells>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" selectionStyle="none" indentationWidth="10" id="2gb-gB-918">
|
||||
<rect key="frame" x="20" y="32" width="374" height="40.5"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="2gb-gB-918" id="iyM-TJ-JB4">
|
||||
<rect key="frame" x="0.0" y="0.0" width="374" height="40.5"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<textField opaque="NO" clipsSubviews="YES" contentMode="scaleToFill" horizontalCompressionResistancePriority="250" contentHorizontalAlignment="left" contentVerticalAlignment="center" adjustsFontForContentSizeCategory="YES" minimumFontSize="17" clearButtonMode="whileEditing" translatesAutoresizingMaskIntoConstraints="NO" id="m5m-Zs-j0z">
|
||||
<rect key="frame" x="20" y="11" width="334" height="18.5"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
||||
<textInputTraits key="textInputTraits" autocapitalizationType="words" autocorrectionType="no" spellCheckingType="no" returnKeyType="next"/>
|
||||
<connections>
|
||||
<action selector="textFieldDidChange:" destination="iNr-Oe-muq" eventType="editingChanged" id="EmC-Rp-f6S"/>
|
||||
<outlet property="delegate" destination="iNr-Oe-muq" id="K2p-7d-teD"/>
|
||||
</connections>
|
||||
</textField>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="m5m-Zs-j0z" firstAttribute="leading" secondItem="iyM-TJ-JB4" secondAttribute="leadingMargin" id="9cK-nj-70V"/>
|
||||
<constraint firstAttribute="trailingMargin" secondItem="m5m-Zs-j0z" secondAttribute="trailing" id="JsE-8v-XAS"/>
|
||||
<constraint firstAttribute="bottomMargin" secondItem="m5m-Zs-j0z" secondAttribute="bottom" id="KbI-Yl-Las"/>
|
||||
<constraint firstItem="m5m-Zs-j0z" firstAttribute="top" secondItem="iyM-TJ-JB4" secondAttribute="topMargin" id="v2S-en-21g"/>
|
||||
</constraints>
|
||||
</tableViewCellContentView>
|
||||
</tableViewCell>
|
||||
</cells>
|
||||
</tableViewSection>
|
||||
<tableViewSection headerTitle="Score" id="Urb-uF-sj1">
|
||||
<cells>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" selectionStyle="none" indentationWidth="10" id="vp3-Ft-Gth">
|
||||
<rect key="frame" x="20" y="122" width="374" height="40.5"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="vp3-Ft-Gth" id="ccO-0O-u72">
|
||||
<rect key="frame" x="0.0" y="0.0" width="374" height="40.5"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<textField opaque="NO" clipsSubviews="YES" contentMode="scaleToFill" horizontalCompressionResistancePriority="250" contentHorizontalAlignment="left" contentVerticalAlignment="center" placeholder="0" adjustsFontForContentSizeCategory="YES" minimumFontSize="17" clearButtonMode="whileEditing" translatesAutoresizingMaskIntoConstraints="NO" id="rZ9-Fa-cyr">
|
||||
<rect key="frame" x="20" y="11" width="334" height="18.5"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
||||
<textInputTraits key="textInputTraits" autocorrectionType="no" spellCheckingType="no" keyboardType="numberPad" returnKeyType="done"/>
|
||||
<connections>
|
||||
<action selector="textFieldDidChange:" destination="iNr-Oe-muq" eventType="editingChanged" id="ebo-2z-Z9P"/>
|
||||
</connections>
|
||||
</textField>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstAttribute="bottomMargin" secondItem="rZ9-Fa-cyr" secondAttribute="bottom" id="3Ya-Po-TQ3"/>
|
||||
<constraint firstAttribute="trailingMargin" secondItem="rZ9-Fa-cyr" secondAttribute="trailing" id="Gso-FU-Ct1"/>
|
||||
<constraint firstItem="rZ9-Fa-cyr" firstAttribute="leading" secondItem="ccO-0O-u72" secondAttribute="leadingMargin" id="WrY-St-72v"/>
|
||||
<constraint firstItem="rZ9-Fa-cyr" firstAttribute="top" secondItem="ccO-0O-u72" secondAttribute="topMargin" id="mmE-f2-e9r"/>
|
||||
</constraints>
|
||||
</tableViewCellContentView>
|
||||
</tableViewCell>
|
||||
</cells>
|
||||
</tableViewSection>
|
||||
</sections>
|
||||
<connections>
|
||||
<outlet property="dataSource" destination="iNr-Oe-muq" id="67O-XE-MeV"/>
|
||||
<outlet property="delegate" destination="iNr-Oe-muq" id="vLD-QQ-zR2"/>
|
||||
</connections>
|
||||
</tableView>
|
||||
<toolbarItems/>
|
||||
<navigationItem key="navigationItem" id="U4g-xG-W8b">
|
||||
<barButtonItem key="leftBarButtonItem" systemItem="cancel" id="VIN-Hi-Twt">
|
||||
<connections>
|
||||
<segue destination="lTz-K0-aRY" kind="unwind" unwindAction="cancelPlayerEdition:" id="fOs-ol-qJL"/>
|
||||
</connections>
|
||||
</barButtonItem>
|
||||
<barButtonItem key="rightBarButtonItem" style="done" systemItem="done" id="2um-u5-BhV">
|
||||
<connections>
|
||||
<segue destination="lTz-K0-aRY" kind="unwind" identifier="Commit" unwindAction="commitPlayerEdition:" id="Y1E-dI-wcl"/>
|
||||
</connections>
|
||||
</barButtonItem>
|
||||
</navigationItem>
|
||||
<simulatedToolbarMetrics key="simulatedBottomBarMetrics"/>
|
||||
<connections>
|
||||
<outlet property="cancelButtonItem" destination="VIN-Hi-Twt" id="dET-mF-ZXe"/>
|
||||
<outlet property="nameCell" destination="2gb-gB-918" id="ch2-MB-uRm"/>
|
||||
<outlet property="nameTextField" destination="m5m-Zs-j0z" id="A6a-pw-q8s"/>
|
||||
<outlet property="saveButtonItem" destination="2um-u5-BhV" id="Jym-us-zdP"/>
|
||||
<outlet property="scoreCell" destination="vp3-Ft-Gth" id="b8A-42-8Dw"/>
|
||||
<outlet property="scoreTextField" destination="rZ9-Fa-cyr" id="4kw-gV-uih"/>
|
||||
</connections>
|
||||
</tableViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="fNC-Ls-uRw" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
<exit id="lTz-K0-aRY" userLabel="Exit" sceneMemberID="exit"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="1134" y="2891"/>
|
||||
</scene>
|
||||
<!--Navigation Controller-->
|
||||
<scene sceneID="2KX-IU-Z5Q">
|
||||
<objects>
|
||||
<navigationController id="ein-Sr-i8w" sceneMemberID="viewController">
|
||||
<navigationBar key="navigationBar" contentMode="scaleToFill" id="zIT-dB-Mfo">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="56"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
</navigationBar>
|
||||
<connections>
|
||||
<segue destination="iNr-Oe-muq" kind="relationship" relationship="rootViewController" destinationCreationSelector="makePlayerCreationViewController:" id="e5b-3U-9Vz"/>
|
||||
</connections>
|
||||
</navigationController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="kNr-vk-I5l" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="1134" y="2071"/>
|
||||
</scene>
|
||||
<!--Navigation Controller-->
|
||||
<scene sceneID="N1X-4j-Re4">
|
||||
<objects>
|
||||
<navigationController id="UAh-8N-CM8" sceneMemberID="viewController">
|
||||
<navigationBar key="navigationBar" contentMode="scaleToFill" largeTitles="YES" id="U7d-1C-GnL">
|
||||
<rect key="frame" x="0.0" y="44" width="414" height="96"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
</navigationBar>
|
||||
<connections>
|
||||
<segue destination="PJp-Eq-ZjA" kind="relationship" relationship="rootViewController" id="klT-ZP-gtX"/>
|
||||
</connections>
|
||||
</navigationController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="sVt-e9-ghQ" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="-459" y="2071"/>
|
||||
</scene>
|
||||
<!--Hall Of Fame-->
|
||||
<scene sceneID="9su-ms-vdJ">
|
||||
<objects>
|
||||
<tableViewController id="PJp-Eq-ZjA" customClass="PlayerListViewController" customModule="GRDBDemoiOS" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="-1" estimatedSectionHeaderHeight="-1" sectionFooterHeight="-1" estimatedSectionFooterHeight="-1" id="tX0-ON-WSI">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<prototypes>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" reuseIdentifier="Player" textLabel="G8y-VE-YRu" detailTextLabel="NNc-J4-xiE" style="IBUITableViewCellStyleValue1" id="bFj-Hd-fwd">
|
||||
<rect key="frame" x="0.0" y="44.5" width="414" height="43.5"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="bFj-Hd-fwd" id="kDf-TZ-xhb">
|
||||
<rect key="frame" x="0.0" y="0.0" width="384.5" height="43.5"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" id="G8y-VE-YRu">
|
||||
<rect key="frame" x="20" y="14" width="28.5" height="17"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" text="Detail" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" id="NNc-J4-xiE">
|
||||
<rect key="frame" x="339" y="14" width="37.5" height="17"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
||||
<color key="textColor" systemColor="secondaryLabelColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
</tableViewCellContentView>
|
||||
<connections>
|
||||
<segue destination="iNr-Oe-muq" kind="show" identifier="" destinationCreationSelector="makePlayerEditionViewController:" id="Lgu-Yd-HSy"/>
|
||||
</connections>
|
||||
</tableViewCell>
|
||||
</prototypes>
|
||||
<connections>
|
||||
<outlet property="dataSource" destination="PJp-Eq-ZjA" id="MkQ-WQ-sTh"/>
|
||||
<outlet property="delegate" destination="PJp-Eq-ZjA" id="quj-Sp-DuL"/>
|
||||
</connections>
|
||||
</tableView>
|
||||
<toolbarItems>
|
||||
<barButtonItem title="Item" id="hMF-Rv-dAe"/>
|
||||
</toolbarItems>
|
||||
<navigationItem key="navigationItem" title="Hall Of Fame" id="QIv-M6-kT1">
|
||||
<barButtonItem key="leftBarButtonItem" systemItem="add" id="2bK-Fl-55Q">
|
||||
<connections>
|
||||
<segue destination="ein-Sr-i8w" kind="presentation" identifier="" id="ehP-ZN-Nt4"/>
|
||||
</connections>
|
||||
</barButtonItem>
|
||||
</navigationItem>
|
||||
<simulatedToolbarMetrics key="simulatedBottomBarMetrics"/>
|
||||
<connections>
|
||||
<outlet property="newPlayerButtonItem" destination="2bK-Fl-55Q" id="O9m-8s-oRL"/>
|
||||
</connections>
|
||||
</tableViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="af5-Qo-AkD" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="348" y="2071"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<inferredMetricsTieBreakers>
|
||||
<segue reference="Lgu-Yd-HSy"/>
|
||||
</inferredMetricsTieBreakers>
|
||||
<resources>
|
||||
<systemColor name="secondaryLabelColor">
|
||||
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</systemColor>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,40 @@
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
|
||||
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
|
||||
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
|
||||
guard let _ = (scene as? UIWindowScene) else { return }
|
||||
}
|
||||
|
||||
func sceneDidDisconnect(_ scene: UIScene) {
|
||||
// Called as the scene is being released by the system.
|
||||
// This occurs shortly after the scene enters the background, or when its session is discarded.
|
||||
// Release any resources associated with this scene that can be re-created the next time the scene connects.
|
||||
// The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead).
|
||||
}
|
||||
|
||||
func sceneDidBecomeActive(_ scene: UIScene) {
|
||||
// Called when the scene has moved from an inactive state to an active state.
|
||||
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
|
||||
}
|
||||
|
||||
func sceneWillResignActive(_ scene: UIScene) {
|
||||
// Called when the scene will move from an active state to an inactive state.
|
||||
// This may occur due to temporary interruptions (ex. an incoming phone call).
|
||||
}
|
||||
|
||||
func sceneWillEnterForeground(_ scene: UIScene) {
|
||||
// Called as the scene transitions from the background to the foreground.
|
||||
// Use this method to undo the changes made on entering the background.
|
||||
}
|
||||
|
||||
func sceneDidEnterBackground(_ scene: UIScene) {
|
||||
// Called as the scene transitions from the foreground to the background.
|
||||
// Use this method to save data, release shared resources, and store enough scene-specific state information
|
||||
// to restore the scene back to its current state.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import UIKit
|
||||
|
||||
class PlayerEditionViewController: UITableViewController {
|
||||
enum Mode {
|
||||
/// Edition ends with the "Commit" unwind segue.
|
||||
case creation
|
||||
|
||||
/// Edition ends when user hits the back button.
|
||||
case edition
|
||||
}
|
||||
|
||||
/// The edited player
|
||||
private(set) var player: Player
|
||||
|
||||
/// The presentation mode
|
||||
let mode: Mode
|
||||
|
||||
@IBOutlet private weak var cancelButtonItem: UIBarButtonItem!
|
||||
@IBOutlet private weak var saveButtonItem: UIBarButtonItem!
|
||||
@IBOutlet private weak var nameCell: UITableViewCell!
|
||||
@IBOutlet private weak var nameTextField: UITextField!
|
||||
@IBOutlet private weak var scoreCell: UITableViewCell!
|
||||
@IBOutlet private weak var scoreTextField: UITextField!
|
||||
|
||||
init?(_ coder: NSCoder, mode: Mode, player: Player) {
|
||||
self.mode = mode
|
||||
self.player = player
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
configureNavigationItem()
|
||||
configureForm()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
extension PlayerEditionViewController {
|
||||
override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool {
|
||||
// Force keyboard to dismiss early
|
||||
view.endEditing(true)
|
||||
return true
|
||||
}
|
||||
|
||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
||||
if segue.identifier == "Commit" {
|
||||
saveChanges()
|
||||
}
|
||||
}
|
||||
|
||||
override func willMove(toParent parent: UIViewController?) {
|
||||
super.willMove(toParent: parent)
|
||||
|
||||
if mode == .edition, parent == nil {
|
||||
// Self is popping from its navigation controller
|
||||
saveChanges()
|
||||
}
|
||||
}
|
||||
|
||||
private func configureNavigationItem() {
|
||||
switch mode {
|
||||
case .creation:
|
||||
navigationItem.title = "New Player"
|
||||
navigationItem.leftBarButtonItem = cancelButtonItem
|
||||
navigationItem.rightBarButtonItem = saveButtonItem
|
||||
case .edition:
|
||||
navigationItem.title = player.name
|
||||
navigationItem.leftBarButtonItem = nil
|
||||
navigationItem.rightBarButtonItem = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Form
|
||||
|
||||
extension PlayerEditionViewController: UITextFieldDelegate {
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
nameTextField.becomeFirstResponder()
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: false)
|
||||
let cell = tableView.cellForRow(at: indexPath)
|
||||
if cell === nameCell {
|
||||
nameTextField.becomeFirstResponder()
|
||||
} else if cell === scoreCell {
|
||||
scoreTextField.becomeFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
if textField == nameTextField {
|
||||
scoreTextField.becomeFirstResponder()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@IBAction func textFieldDidChange(_ textField: UITextField) {
|
||||
// User has edited the player: prevent interactive dismissal
|
||||
isModalInPresentation = true
|
||||
}
|
||||
|
||||
private func configureForm() {
|
||||
nameTextField.text = player.name
|
||||
|
||||
if player.score == 0 && player.id == nil {
|
||||
scoreTextField.text = ""
|
||||
} else {
|
||||
scoreTextField.text = "\(player.score)"
|
||||
}
|
||||
}
|
||||
|
||||
private func saveChanges() {
|
||||
var player = self.player
|
||||
player.name = nameTextField.text ?? ""
|
||||
player.score = scoreTextField.text.flatMap { Int($0) } ?? 0
|
||||
try! AppDatabase.shared.savePlayer(&player)
|
||||
self.player = player
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import UIKit
|
||||
import GRDB
|
||||
|
||||
/// PlayerListViewController displays the list of players.
|
||||
class PlayerListViewController: UITableViewController {
|
||||
private enum PlayerOrdering {
|
||||
case byName
|
||||
case byScore
|
||||
}
|
||||
|
||||
@IBOutlet private weak var newPlayerButtonItem: UIBarButtonItem!
|
||||
private var dataSource: PlayerDataSource!
|
||||
private var playersCancellable: DatabaseCancellable?
|
||||
private var playerOrdering: PlayerOrdering = .byScore {
|
||||
didSet {
|
||||
configureOrderingBarButtonItem()
|
||||
observePlayers()
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
configureToolbar()
|
||||
configureNavigationItem()
|
||||
configureDataSource()
|
||||
observePlayers()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
navigationController?.isToolbarHidden = false
|
||||
}
|
||||
|
||||
private func configureToolbar() {
|
||||
toolbarItems = [
|
||||
UIBarButtonItem(systemItem: .trash, primaryAction: UIAction { [unowned self] _ in
|
||||
setEditing(false, animated: true)
|
||||
try! AppDatabase.shared.deleteAllPlayers()
|
||||
}),
|
||||
UIBarButtonItem(systemItem: .flexibleSpace),
|
||||
UIBarButtonItem(systemItem: .refresh, primaryAction: UIAction { [unowned self] _ in
|
||||
setEditing(false, animated: true)
|
||||
try! AppDatabase.shared.refreshPlayers()
|
||||
}),
|
||||
UIBarButtonItem(systemItem: .flexibleSpace),
|
||||
UIBarButtonItem(image: UIImage(systemName: "tornado"), primaryAction: UIAction { [unowned self] _ in
|
||||
setEditing(false, animated: true)
|
||||
for _ in 0..<50 {
|
||||
DispatchQueue.global().async {
|
||||
try! AppDatabase.shared.refreshPlayers()
|
||||
}
|
||||
}
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
private func configureNavigationItem() {
|
||||
navigationItem.backBarButtonItem = UIBarButtonItem(title: "Players")
|
||||
navigationItem.leftBarButtonItems = [editButtonItem, newPlayerButtonItem]
|
||||
configureOrderingBarButtonItem()
|
||||
}
|
||||
|
||||
private func configureOrderingBarButtonItem() {
|
||||
switch playerOrdering {
|
||||
case .byScore:
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "Score ▼",
|
||||
primaryAction: UIAction { [unowned self] _ in
|
||||
setEditing(false, animated: true)
|
||||
playerOrdering = .byName
|
||||
})
|
||||
case .byName:
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "Name ▲",
|
||||
primaryAction: UIAction { [unowned self] _ in
|
||||
setEditing(false, animated: true)
|
||||
playerOrdering = .byScore
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func configureDataSource() {
|
||||
dataSource = PlayerDataSource(tableView: tableView) { (tableView, indexPath, player) in
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "Player", for: indexPath)
|
||||
if player.name.isEmpty {
|
||||
cell.textLabel?.text = "(anonymous)"
|
||||
} else {
|
||||
cell.textLabel?.text = player.name
|
||||
}
|
||||
cell.detailTextLabel?.text = abs(player.score) > 1 ? "\(player.score) points" : "0 point"
|
||||
return cell
|
||||
}
|
||||
dataSource.defaultRowAnimation = .fade
|
||||
tableView.dataSource = dataSource
|
||||
}
|
||||
|
||||
private func configureTitle(from players: [Player]) {
|
||||
switch players.count {
|
||||
case 0:
|
||||
navigationItem.title = "No Player"
|
||||
case 1:
|
||||
navigationItem.title = "1 Player"
|
||||
case let count:
|
||||
navigationItem.title = "\(count) Players"
|
||||
}
|
||||
}
|
||||
|
||||
private func configureDataSource(from players: [Player]) {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Int, Player>()
|
||||
snapshot.appendSections([0])
|
||||
snapshot.appendItems(players, toSection: 0)
|
||||
|
||||
// Remember selection
|
||||
let selectedPlayerId = tableView.indexPathForSelectedRow.flatMap {
|
||||
dataSource.itemIdentifier(for: $0)?.id
|
||||
}
|
||||
|
||||
// Avoid a UIKit warning; don't animate when popping from edition
|
||||
let animated = view.window != nil
|
||||
|
||||
dataSource.apply(snapshot, animatingDifferences: animated, completion: {
|
||||
// Restore selection
|
||||
if let index = players.firstIndex(where: { $0.id == selectedPlayerId }) {
|
||||
self.tableView.selectRow(at: IndexPath(row: index, section: 0), animated: false, scrollPosition: .none)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func observePlayers() {
|
||||
let request: QueryInterfaceRequest<Player>
|
||||
switch playerOrdering {
|
||||
case .byName:
|
||||
request = Player.all().orderedByName()
|
||||
case .byScore:
|
||||
request = Player.all().orderedByScore()
|
||||
}
|
||||
|
||||
playersCancellable = ValueObservation
|
||||
.tracking(request.fetchAll(_:))
|
||||
.start(
|
||||
in: AppDatabase.shared.reader,
|
||||
// Immediate scheduling feeds the data source right on subscription,
|
||||
// and avoids an undesired animation when the application starts.
|
||||
scheduling: .immediate,
|
||||
onError: { error in fatalError("Unexpected error: \(error)") },
|
||||
onChange: { [weak self] players in
|
||||
guard let self else { return }
|
||||
self.configureTitle(from: players)
|
||||
self.configureDataSource(from: players)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
extension PlayerListViewController {
|
||||
@IBSegueAction func makePlayerEditionViewController(_ coder: NSCoder) -> PlayerEditionViewController? {
|
||||
guard let indexPath = tableView.indexPathForSelectedRow,
|
||||
let player = dataSource.itemIdentifier(for: indexPath)
|
||||
else { return nil }
|
||||
return PlayerEditionViewController(coder, mode: .edition, player: player)
|
||||
}
|
||||
|
||||
@IBSegueAction func makePlayerCreationViewController(_ coder: NSCoder) -> PlayerEditionViewController? {
|
||||
let player = Player(id: nil, name: "", score: 0)
|
||||
return PlayerEditionViewController(coder, mode: .creation, player: player)
|
||||
}
|
||||
|
||||
@IBAction func cancelPlayerEdition(_ segue: UIStoryboardSegue) {
|
||||
// Player creation cancelled
|
||||
}
|
||||
|
||||
@IBAction func commitPlayerEdition(_ segue: UIStoryboardSegue) {
|
||||
// Player creation committed
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
|
||||
/// Subclass of UITableViewDiffableDataSource that supports row deletion
|
||||
private class PlayerDataSource: UITableViewDiffableDataSource<Int, Player> {
|
||||
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
|
||||
// Delete the player
|
||||
if let player = itemIdentifier(for: indexPath), let id = player.id {
|
||||
try! AppDatabase.shared.deletePlayers(ids: [id])
|
||||
}
|
||||
}
|
||||
}
|
||||