Skip to content

Commit 8e908d4

Browse files
feat(ios): add Swift 6 iOS app with Bible reader, TTS, lexicon, and CI
- 35 Swift files: models, database (GRDB), services, screens, theme - Bible reader with 3-step navigation (books → chapters → verses) - AVSpeechSynthesizer TTS, lexicon/interlinear, reading progress - Tokyo Night theme, deep link support, search - XcodeGen project.yml, GitHub Actions release workflow
1 parent b3038c7 commit 8e908d4

37 files changed

Lines changed: 4964 additions & 0 deletions

.github/workflows/release-ios.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: release-ios
2+
3+
on:
4+
push:
5+
tags: ["20*.*.*"]
6+
7+
jobs:
8+
build-ios:
9+
runs-on: macos-latest
10+
permissions:
11+
contents: write
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- name: Select Xcode
16+
run: sudo xcode-select -s /Applications/Xcode_16.app/Contents/Developer
17+
18+
- name: Install XcodeGen
19+
run: brew install xcodegen
20+
21+
- name: Generate Xcode project
22+
run: cd ios/Metanoia && xcodegen generate
23+
24+
- name: Build iOS app
25+
run: |
26+
cd ios/Metanoia
27+
xcodebuild -project Metanoia.xcodeproj -scheme Metanoia -sdk iphoneos -configuration Release -archivePath build/Metanoia.xcarchive archive
28+
29+
- name: Create IPA
30+
run: |
31+
cd ios/Metanoia/build
32+
mkdir -p Payload
33+
mv Metanoia.xcarchive/Products/Applications/Metanoia.app Payload/
34+
zip -r Metanoia.ipa Payload
35+
36+
- name: Release
37+
uses: softprops/action-gh-release@v2
38+
with:
39+
name: "${{ github.ref_name }}"
40+
body: "iOS build (unsigned — requires manual signing for device install)"
41+
files: ios/Metanoia/build/Metanoia.ipa
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import SwiftUI
2+
3+
struct BookCard: View {
4+
let book: BibleBook
5+
let completedChapters: Set<Int>
6+
let onTap: () -> Void
7+
8+
private var isCompleted: Bool {
9+
completedChapters.count >= book.chapterCount
10+
}
11+
12+
private var progressFraction: Double {
13+
guard book.chapterCount > 0 else { return 0 }
14+
return Double(completedChapters.count) / Double(book.chapterCount)
15+
}
16+
17+
var body: some View {
18+
Button(action: onTap) {
19+
VStack(spacing: MetanoiaTheme.Spacing.sm) {
20+
HStack {
21+
if isCompleted {
22+
Circle()
23+
.fill(MetanoiaTheme.secondary)
24+
.frame(width: 8, height: 8)
25+
}
26+
Spacer()
27+
}
28+
.frame(height: 8)
29+
30+
Text(book.name)
31+
.font(.system(size: MetanoiaTheme.FontSize.body, weight: .semibold))
32+
.foregroundStyle(MetanoiaTheme.text)
33+
.lineLimit(2)
34+
.multilineTextAlignment(.center)
35+
36+
Text("\(book.chapterCount) ch")
37+
.font(.system(size: MetanoiaTheme.FontSize.caption, weight: .medium))
38+
.foregroundStyle(MetanoiaTheme.textDim)
39+
40+
if !completedChapters.isEmpty && !isCompleted {
41+
GeometryReader { geometry in
42+
ZStack(alignment: .leading) {
43+
Capsule()
44+
.fill(MetanoiaTheme.surfaceBright)
45+
.frame(height: 3)
46+
Capsule()
47+
.fill(MetanoiaTheme.primary)
48+
.frame(width: geometry.size.width * progressFraction, height: 3)
49+
}
50+
}
51+
.frame(height: 3)
52+
}
53+
}
54+
.padding(MetanoiaTheme.Spacing.md)
55+
.background(MetanoiaTheme.surface)
56+
.clipShape(RoundedRectangle(cornerRadius: MetanoiaTheme.CornerRadius.md))
57+
.overlay(
58+
RoundedRectangle(cornerRadius: MetanoiaTheme.CornerRadius.md)
59+
.stroke(isCompleted ? MetanoiaTheme.secondary.opacity(0.3) : Color.clear, lineWidth: 1)
60+
)
61+
}
62+
.buttonStyle(.plain)
63+
}
64+
}
65+
66+
#Preview {
67+
ZStack {
68+
MetanoiaTheme.background.ignoresSafeArea()
69+
BookCard(
70+
book: BibleBook.allBooks[0],
71+
completedChapters: Set(1...10),
72+
onTap: {}
73+
)
74+
.frame(width: 120, height: 120)
75+
}
76+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import SwiftUI
2+
3+
struct ChapterCard: View {
4+
let chapter: Int
5+
let isRead: Bool
6+
let isDownloaded: Bool
7+
let onTap: () -> Void
8+
9+
var body: some View {
10+
Button(action: onTap) {
11+
VStack(spacing: MetanoiaTheme.Spacing.xs) {
12+
ZStack {
13+
Circle()
14+
.fill(isRead ? MetanoiaTheme.secondary.opacity(0.2) : MetanoiaTheme.surfaceBright)
15+
.frame(width: 52, height: 52)
16+
17+
Text("\(chapter)")
18+
.font(.system(size: MetanoiaTheme.FontSize.title3, weight: .semibold))
19+
.foregroundStyle(isRead ? MetanoiaTheme.secondary : MetanoiaTheme.text)
20+
}
21+
22+
if isDownloaded {
23+
Circle()
24+
.fill(MetanoiaTheme.primary)
25+
.frame(width: 4, height: 4)
26+
} else {
27+
Circle()
28+
.fill(Color.clear)
29+
.frame(width: 4, height: 4)
30+
}
31+
}
32+
}
33+
.buttonStyle(.plain)
34+
}
35+
}
36+
37+
#Preview {
38+
ZStack {
39+
MetanoiaTheme.background.ignoresSafeArea()
40+
HStack(spacing: 20) {
41+
ChapterCard(chapter: 1, isRead: true, isDownloaded: true, onTap: {})
42+
ChapterCard(chapter: 2, isRead: false, isDownloaded: false, onTap: {})
43+
ChapterCard(chapter: 3, isRead: false, isDownloaded: true, onTap: {})
44+
}
45+
}
46+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import SwiftUI
2+
3+
struct ContentView: View {
4+
@State private var selectedTab: Tab = .bible
5+
6+
enum Tab: String {
7+
case bible
8+
case collection
9+
case settings
10+
}
11+
12+
var body: some View {
13+
TabView(selection: $selectedTab) {
14+
BibleScreen()
15+
.tabItem {
16+
Label("Bible", systemImage: "book.fill")
17+
}
18+
.tag(Tab.bible)
19+
20+
CollectionScreen()
21+
.tabItem {
22+
Label("Collection", systemImage: "star.fill")
23+
}
24+
.tag(Tab.collection)
25+
26+
SettingsScreen()
27+
.tabItem {
28+
Label("Settings", systemImage: "gearshape.fill")
29+
}
30+
.tag(Tab.settings)
31+
}
32+
}
33+
}
34+
35+
#Preview {
36+
ContentView()
37+
.environment(AppSettings())
38+
.environment(TTSService())
39+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import Foundation
2+
import GRDB
3+
4+
final class DatabaseManager {
5+
private let writer: DatabaseWriter
6+
private let reader: DatabaseReader
7+
8+
private init(writer: DatabaseWriter, reader: DatabaseReader) {
9+
self.writer = writer
10+
self.reader = reader
11+
}
12+
13+
static func open(readOnly: Bool = false) throws -> DatabaseManager {
14+
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
15+
let bundleDB = Bundle.main.url(forResource: "bible", withExtension: "db")!
16+
let destDB = appSupport.appendingPathComponent("bible.db")
17+
18+
if !FileManager.default.fileExists(atPath: destDB.path) {
19+
try FileManager.default.copyItem(at: bundleDB, to: destDB)
20+
}
21+
22+
var config = Configuration()
23+
config.readonly = readOnly
24+
config.foreignKeysEnabled = true
25+
26+
if readOnly {
27+
let db = try Database(path: destDB.path, configuration: config)
28+
return DatabaseManager(writer: db, reader: db)
29+
} else {
30+
let db = try DatabaseQueue(path: destDB.path, configuration: config)
31+
try migrate(db)
32+
return DatabaseManager(writer: db, reader: db)
33+
}
34+
}
35+
36+
var verseDAO: VerseDAO { .init(db: reader, writer: writer) }
37+
var favoritesDAO: FavoritesDAO { .init(db: writer) }
38+
var highlightsDAO: HighlightsDAO { .init(db: writer) }
39+
var notesDAO: NotesDAO { .init(db: writer) }
40+
var lexiconDAO: LexiconDAO { .init(db: writer) }
41+
var interlinearDAO: InterlinearDAO { .init(db: writer) }
42+
var readingAnalyticsDAO: ReadingAnalyticsDAO { .init(db: writer) }
43+
44+
var dbSizeMb: Double {
45+
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
46+
let dbPath = appSupport.appendingPathComponent("bible.db")
47+
guard let attrs = try? FileManager.default.attributesOfItem(atPath: dbPath.path),
48+
let size = attrs[.size] as? Double else { return 0 }
49+
return size / (1024.0 * 1024.0)
50+
}
51+
52+
func getStats() -> LibraryStats {
53+
verseDAO.getStats(dbSizeMb: dbSizeMb)
54+
}
55+
56+
// MARK: - Migrations
57+
58+
private static func migrate(_ db: DatabaseQueue) throws {
59+
var migrator = DatabaseMigrator()
60+
61+
migrator.registerMigration("v1") { db in
62+
try db.create(table: "verses", ifNotExists: true) { t in
63+
t.column("book", .text).notNull()
64+
t.column("chapter", .integer).notNull()
65+
t.column("verse", .integer).notNull()
66+
t.column("text", .text).notNull()
67+
t.column("version", .text)
68+
t.primaryKey(["book", "chapter", "verse"])
69+
}
70+
71+
try db.create(table: "favorites", ifNotExists: true) { t in
72+
t.column("strongs", .text).notNull().primaryKey()
73+
t.column("lemma", .text)
74+
t.column("definition", .text)
75+
}
76+
77+
try db.create(table: "highlights", ifNotExists: true) { t in
78+
t.column("book", .text).notNull()
79+
t.column("chapter", .integer).notNull()
80+
t.column("verse", .integer).notNull()
81+
t.column("color", .integer)
82+
t.primaryKey(["book", "chapter", "verse"])
83+
}
84+
85+
try db.create(table: "notes", ifNotExists: true) { t in
86+
t.column("id", .integer).primaryKey(autoincrement: true)
87+
t.column("book", .text).notNull()
88+
t.column("chapter", .integer).notNull()
89+
t.column("verse", .integer).notNull()
90+
t.column("content", .text).notNull()
91+
t.column("timestamp", .datetime).defaults(to: SQL("CURRENT_TIMESTAMP"))
92+
}
93+
94+
try db.create(table: "lexicon", ifNotExists: true) { t in
95+
t.column("strongs", .text).notNull().primaryKey()
96+
t.column("language", .text)
97+
t.column("lemma", .text)
98+
t.column("transliteration", .text)
99+
t.column("definition", .text)
100+
}
101+
102+
try db.create(table: "interlinear", ifNotExists: true) { t in
103+
t.column("book", .text).notNull()
104+
t.column("chapter", .integer).notNull()
105+
t.column("verse", .integer).notNull()
106+
t.column("word_index", .integer).notNull()
107+
t.column("original_text", .text)
108+
t.column("translation", .text)
109+
t.column("strongs", .text)
110+
t.primaryKey(["book", "chapter", "verse", "word_index"])
111+
}
112+
113+
try db.create(table: "reading_progress", ifNotExists: true) { t in
114+
t.column("book", .text).notNull()
115+
t.column("chapter", .integer).notNull()
116+
t.column("first_read_at", .integer)
117+
t.column("last_read_at", .integer)
118+
t.column("read_count", .integer).defaults(to: 0)
119+
t.column("reading_time_seconds", .integer).defaults(to: 0)
120+
t.primaryKey(["book", "chapter"])
121+
}
122+
123+
try db.create(table: "reading_events", ifNotExists: true) { t in
124+
t.column("id", .integer).primaryKey(autoincrement: true)
125+
t.column("book", .text).notNull()
126+
t.column("chapter", .integer).notNull()
127+
t.column("timestamp", .integer).notNull()
128+
}
129+
130+
// FTS4 virtual table for verse search
131+
try db.execute(sql: """
132+
CREATE VIRTUAL TABLE IF NOT EXISTS verses_fts USING fts4(book, chapter, verse, text)
133+
""")
134+
135+
// Triggers to keep FTS in sync
136+
try db.execute(sql: """
137+
CREATE TRIGGER IF NOT EXISTS verses_ai AFTER INSERT ON verses BEGIN
138+
INSERT INTO verses_fts(docid, book, chapter, verse, text)
139+
VALUES (new.rowid, new.book, new.chapter, new.verse, new.text);
140+
END
141+
""")
142+
143+
try db.execute(sql: """
144+
CREATE TRIGGER IF NOT EXISTS verses_ad AFTER DELETE ON verses BEGIN
145+
INSERT INTO verses_fts(verses_fts, docid, book, chapter, verse, text)
146+
VALUES('delete', old.rowid, old.book, old.chapter, old.verse, old.text);
147+
END
148+
""")
149+
150+
try db.execute(sql: """
151+
CREATE TRIGGER IF NOT EXISTS verses_au AFTER UPDATE ON verses BEGIN
152+
INSERT INTO verses_fts(verses_fts, docid, book, chapter, verse, text)
153+
VALUES('delete', old.rowid, old.book, old.chapter, old.verse, old.text);
154+
INSERT INTO verses_fts(docid, book, chapter, verse, text)
155+
VALUES (new.rowid, new.book, new.chapter, new.verse, new.text);
156+
END
157+
""")
158+
}
159+
160+
try migrator.migrate(db)
161+
}
162+
}

0 commit comments

Comments
 (0)