Build iOS App / Build .ipa and publish source (push) Successful in 31s
Sideloaded apps can't use APNs (no push entitlement without Apple signing), so deliver notifications via an iOS BGAppRefreshTask that polls the monitored repos every ~15 min and fires local notifications (no Apple push needed): - new PRs that appear in the open set - CI status changes on existing PRs Persistence: NotificationState tracks announced PR ids + last-known CI status so we only fire on actual changes, not every poll. AppDelegate registers the background task and reschedules the next one. Info.plist gets UIBackgroundModes fetch + the BGTask identifier. Settings gains a Notifications toggle; also poll after a foreground inbox load so state seeds and changes are caught promptly. Bump to 3.6.
164 lines
6.3 KiB
Swift
164 lines
6.3 KiB
Swift
import SwiftUI
|
|
|
|
struct SettingsView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var instanceURL: String
|
|
@State private var token: String
|
|
@State private var mergeGateMode: MergeGateMode
|
|
@State private var deleteBranchAfterMerge: Bool
|
|
@State private var monitoredRepos: [String]
|
|
@State private var notificationsEnabled: Bool
|
|
@State private var testResult: TestResult?
|
|
@State private var testing = false
|
|
@State private var showLogoutConfirm = false
|
|
|
|
enum TestResult: Equatable {
|
|
case success(String)
|
|
case failure(String)
|
|
}
|
|
|
|
init() {
|
|
_instanceURL = State(initialValue: SettingsStore.shared.instanceURL)
|
|
_token = State(initialValue: SettingsStore.shared.token)
|
|
_mergeGateMode = State(initialValue: SettingsStore.shared.mergeGateMode)
|
|
_deleteBranchAfterMerge = State(initialValue: SettingsStore.shared.deleteBranchAfterMerge)
|
|
_monitoredRepos = State(initialValue: SettingsStore.shared.monitoredRepos)
|
|
_notificationsEnabled = State(initialValue: SettingsStore.shared.notificationsEnabled)
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
Section("Server") {
|
|
TextField("Instance URL", text: $instanceURL)
|
|
.keyboardType(.URL)
|
|
.autocapitalization(.none)
|
|
.autocorrectionDisabled()
|
|
.textInputAutocapitalization(.never)
|
|
SecureField("Personal access token", text: $token)
|
|
Button(testing ? "Testing…" : "Test connection") {
|
|
test()
|
|
}
|
|
.disabled(testing || token.isEmpty)
|
|
if let testResult {
|
|
testResultView(testResult)
|
|
}
|
|
}
|
|
|
|
Section("Merge gating") {
|
|
Picker("Merge behavior", selection: $mergeGateMode) {
|
|
ForEach(MergeGateMode.allCases) { m in
|
|
Text(m.rawValue).tag(m)
|
|
}
|
|
}
|
|
.pickerStyle(.inline)
|
|
Text("Warn mode shows CI status and allows merging a red PR after confirmation. Block mode disables merge while any check is failing.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
Toggle("Delete branch after merge", isOn: $deleteBranchAfterMerge)
|
|
}
|
|
|
|
Section {
|
|
NavigationLink("Monitored repos") {
|
|
MonitoredReposView(selection: $monitoredRepos)
|
|
}
|
|
} header: {
|
|
Text("Inbox")
|
|
} footer: {
|
|
Text("Limit the landing page to specific repos to speed it up. Leave empty to monitor all accessible repos.")
|
|
}
|
|
|
|
Section {
|
|
Toggle("Notifications", isOn: $notificationsEnabled)
|
|
} header: {
|
|
Text("Notifications")
|
|
} footer: {
|
|
Text("Check periodically for new PRs and CI status changes, then send a local notification. Best-effort: iOS schedules this opportunistically (~15 min) and only when the app was used recently.")
|
|
}
|
|
|
|
Section("Diagnostics") {
|
|
NavigationLink("View logs") {
|
|
LogsView()
|
|
}
|
|
}
|
|
|
|
Section {
|
|
Button("Log out", role: .destructive) {
|
|
showLogoutConfirm = true
|
|
}
|
|
.disabled(token.isEmpty)
|
|
}
|
|
}
|
|
.navigationTitle("Settings")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") { dismiss() }
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Save") { save() }
|
|
.disabled(instanceURL.trimmingCharacters(in: .whitespaces)
|
|
.isEmpty)
|
|
}
|
|
}
|
|
.confirmationDialog("Log out?",
|
|
isPresented: $showLogoutConfirm,
|
|
titleVisibility: .visible) {
|
|
Button("Log out", role: .destructive) {
|
|
SettingsStore.shared.logout()
|
|
testResult = nil
|
|
dismiss()
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func testResultView(_ result: TestResult) -> some View {
|
|
switch result {
|
|
case .success(let login):
|
|
Label("Connected as \(login)", systemImage: "checkmark.circle.fill")
|
|
.font(.callout)
|
|
.foregroundColor(.green)
|
|
case .failure(let message):
|
|
Label(message, systemImage: "xmark.circle.fill")
|
|
.font(.callout)
|
|
.foregroundColor(.red)
|
|
}
|
|
}
|
|
|
|
private func test() {
|
|
testing = true
|
|
testResult = nil
|
|
let trimmed = instanceURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let url = URL(string: trimmed) else {
|
|
testResult = .failure("Invalid URL")
|
|
testing = false
|
|
return
|
|
}
|
|
let client = GiteaClient(instanceURL: url, token: token)
|
|
Task {
|
|
do {
|
|
let user = try await client.currentUser()
|
|
AppSession.currentLogin = user.login
|
|
testResult = .success(user.login)
|
|
} catch {
|
|
testResult = .failure(error.localizedDescription)
|
|
}
|
|
testing = false
|
|
}
|
|
}
|
|
|
|
private func save() {
|
|
SettingsStore.shared.instanceURL =
|
|
instanceURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
SettingsStore.shared.token = token
|
|
SettingsStore.shared.mergeGateMode = mergeGateMode
|
|
SettingsStore.shared.deleteBranchAfterMerge = deleteBranchAfterMerge
|
|
SettingsStore.shared.monitoredRepos = monitoredRepos
|
|
SettingsStore.shared.notificationsEnabled = notificationsEnabled
|
|
dismiss()
|
|
}
|
|
}
|