Add CI/CD configuration and API documentation

This commit is contained in:
2026-07-01 21:40:53 +08:00
commit c590135d68
4168 changed files with 740252 additions and 0 deletions
@@ -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"
}
}
@@ -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])
}
}
}