Files
ios-gitea-prs/GiteaPR/Settings/SettingsView.swift
T
Hermes bae531f850
Build iOS App / Build .ipa and publish source (push) Successful in 31s
Add push-equivalent notifications via background polling; bump to 3.6
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.
2026-08-24 08:18:29 +00:00

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()
}
}