gighiveConvert the “View in Database” button from opening external browser to displaying a native iPhone SwiftUI view that consumes the GigHive web server API.
AuthSession.baseURLAuthSession.credentialsAuthSession.allowInsecureTLS.upload (handled via alert + user action).Create native SwiftUI views to display database contents using the new JSON API.
baseURL, credentials, allowInsecureTLS, and a derived role (viewer/admin). Optional “Remember on this device” persists credentials in Keychain per host.GHButtonStyle).MediaPlayerView.logWithTimestamp(_:) for all new user-interactive pages (Splash, Login, View Database, Upload variants, Detail).[time] [Login] Sign in started[time] [Login] Auth success[time] [DB] Loaded 123 entries[time] [DB] Error: HTTP 401AuthSession (ObservableObject) with:
baseURL: URL?credentials: (user, pass)?allowInsecureTLS: Boolrole: .unknown | .viewer | .adminintendedRoute: .viewDatabase | .upload@EnvironmentObject at app entry. No persistence.SplashView with buttons:
intendedRoute = .viewDatabaseintendedRoute = .uploadLoginViewLoginView first; after login, continue to intended route.LoginView with:
baseURL, credentials, allowInsecureTLS, role = .unknown; route to intendedRoute.MediaEntry and MediaListResponse models.DatabaseAPIClient using /db/database.php?format=json, applying BasicAuth from session, respecting insecure TLS, throwing DatabaseError.DatabaseView uses session for baseURL, credentials, allowInsecureTLS.DatabaseDetailView with metadata, open-in-browser, and ShareLink.DetailRow helper for labeled values..upload.session.credentials == nil, redirect to Login before View/Upload. After login, navigate to intendedRoute.AuthSession, NEW SplashView, NEW LoginView.DatabaseAPIClient.DatabaseView, NEW DatabaseDetailView; refactor UploadView.NavigationStack on iOS 16+, fallback to NavigationView on earlier iOS..searchable/.refreshable used on iOS 15+; iOS 14 uses a manual search TextField.@Environment(\.dismiss) on iOS 15+; compatible approaches used on earlier iOS.ShareLink on iOS 16+, with a UIActivityViewController fallback helper.AuthSession and inject as EnvironmentObject. No UI changes; existing screens keep working.SplashView (bee logo header, three buttons) and LoginView (Base URL, Username, Password, Disable certificate checking).MediaEntry, MediaListResponse, and DatabaseAPIClient.DatabaseView (list, search, refresh, detail open/share, errors) using session creds/TLS.UploadView to use session (remove auth UI). Add viewer-only messaging with link to re-login as admin.GigHive/Sources/App/AuthSession.swiftimport Foundation
import SwiftUI
final class AuthSession: ObservableObject {
@Published var baseURL: URL?
@Published var credentials: (user: String, pass: String)?
@Published var allowInsecureTLS: Bool = false
@Published var role: UserRole = .unknown
@Published var intendedRoute: AppRoute? = nil // .viewDatabase or .upload
}
enum UserRole { case unknown, viewer, admin }
enum AppRoute { case viewDatabase, upload }
GigHive/Sources/App/MediaPlayerView.swift// SwiftUI view that plays media in-app using AVPlayer. Accepts BasicAuth via AVURLAsset headers.
// iOS 15-compatible Close control: wraps content in NavigationView and dismisses via presentationMode.wrappedValue.dismiss().
GigHive/Sources/App/SplashView.swiftimport SwiftUI
struct SplashView: View {
@EnvironmentObject var session: AuthSession
var body: some View {
VStack(spacing: 24) {
// Reuse existing title header with bee logo and brand font/style
TitleHeaderView() // existing component used on the current page
Button("View the Database") {
session.intendedRoute = .viewDatabase
}
.buttonStyle(GHButtonStyle(color: .blue))
Button("Upload a File") {
session.intendedRoute = .upload
}
.buttonStyle(GHButtonStyle(color: .green))
Button("Login") { /* present LoginView */ }
.buttonStyle(GHButtonStyle(color: .orange))
}
.padding()
}
}
GigHive/Sources/App/LoginView.swiftimport SwiftUI
struct LoginView: View {
@EnvironmentObject var session: AuthSession
@State private var base: String = ""
@State private var username: String = ""
@State private var password: String = ""
@State private var disableCertChecking: Bool = false
@State private var isLoading = false
@State private var errorMessage: String?
var body: some View {
VStack(spacing: 16) {
TitleHeaderView()
TextField("Base URL (e.g., https://dev.gighive.app)", text: $base)
.textContentType(.URL)
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
TextField("Username", text: $username)
.textContentType(.username)
.autocapitalization(.none)
.disableAutocorrection(true)
SecureField("Password", text: $password)
.textContentType(.password)
Toggle("Disable certificate checking", isOn: $disableCertChecking)
if let error = errorMessage { Text(error).foregroundColor(.red) }
Button(isLoading ? "Signing In…" : "Sign In") { Task { await signIn() } }
.buttonStyle(GHButtonStyle(color: .orange))
.disabled(isLoading)
}
.padding()
}
private func signIn() async {
errorMessage = nil
isLoading = true
defer { isLoading = false }
guard let url = URL(string: base) else { errorMessage = "Invalid URL"; return }
session.baseURL = url
session.credentials = (username, password)
session.allowInsecureTLS = disableCertChecking
// Role can be determined lazily by endpoint responses; set unknown initially
session.role = .unknown
// Navigation to intended route is handled by parent once credentials are set
}
}
GigHive/Sources/App/DatabaseModels.swiftimport Foundation
struct MediaEntry: Codable, Identifiable {
let id: Int
let index: Int
let date: String
let orgName: String
let duration: String
let durationSeconds: Int
let songTitle: String
let fileType: String
let fileName: String
let url: String
enum CodingKeys: String, CodingKey {
case id, index, date, duration
case orgName = "org_name"
case durationSeconds = "duration_seconds"
case songTitle = "song_title"
case fileType = "file_type"
case fileName = "file_name"
case url
}
}
struct MediaListResponse: Codable {
let entries: [MediaEntry]
}
GigHive/Sources/App/DatabaseAPIClient.swiftimport Foundation
final class DatabaseAPIClient {
let baseURL: URL
let basicAuth: (user: String, pass: String)?
let allowInsecure: Bool
init(baseURL: URL, basicAuth: (String, String)?, allowInsecure: Bool = false) {
self.baseURL = baseURL
self.basicAuth = basicAuth
self.allowInsecure = allowInsecure
}
func fetchMediaList() async throws -> [MediaEntry] {
// Use /db/database.php?format=json
var components = URLComponents(url: baseURL.appendingPathComponent("db/database.php"),
resolvingAgainstBaseURL: false)
components?.queryItems = [URLQueryItem(name: "format", value: "json")]
guard let url = components?.url else {
throw DatabaseError.invalidURL
}
var request = URLRequest(url: url)
// Add BasicAuth from user input
if let auth = basicAuth {
let credentials = "\(auth.user):\(auth.pass)"
let base64 = Data(credentials.utf8).base64EncodedString()
request.setValue("Basic \(base64)", forHTTPHeaderField: "Authorization")
}
let session: URLSession
if allowInsecure {
let config = URLSessionConfiguration.ephemeral
session = URLSession(configuration: config,
delegate: InsecureTrustDelegate.shared,
delegateQueue: nil)
} else {
session = URLSession.shared
}
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw DatabaseError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
throw DatabaseError.httpError(httpResponse.statusCode)
}
let decoded = try JSONDecoder().decode(MediaListResponse.self, from: data)
return decoded.entries
}
}
enum DatabaseError: Error, LocalizedError {
case invalidURL
case invalidResponse
case httpError(Int)
var errorDescription: String? {
switch self {
case .invalidURL:
return "Invalid database URL"
case .invalidResponse:
return "Invalid server response"
case .httpError(let code):
return "HTTP Error \(code)"
}
}
}
GigHive/Sources/App/DatabaseView.swiftimport SwiftUI
struct DatabaseView: View {
// Consume session instead of passing creds directly
@EnvironmentObject var session: AuthSession
@State private var entries: [MediaEntry] = []
@State private var filteredEntries: [MediaEntry] = []
@State private var searchText = ""
@State private var isLoading = false
@State private var errorMessage: String?
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
var body: some View {
NavigationView {
VStack {
if isLoading {
ProgressView("Loading database...")
.padding()
} else if let error = errorMessage {
VStack(spacing: 16) {
Text("Error")
.font(.headline)
Text(error)
.foregroundColor(.red)
.multilineTextAlignment(.center)
.padding()
Button("Retry") {
Task { await loadData() }
}
.buttonStyle(GHButtonStyle(color: .blue))
}
.padding()
} else if filteredEntries.isEmpty {
VStack(spacing: 16) {
Image(systemName: "tray")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text("No media found")
.font(.headline)
.foregroundColor(.secondary)
}
.padding()
} else {
List {
ForEach(filteredEntries) { entry in
NavigationLink(destination: DatabaseDetailView(entry: entry,
baseURL: session.baseURL ?? URL(string: "https://example.com")!)) {
MediaEntryRow(entry: entry)
}
}
}
.searchable(text: $searchText, prompt: "Search by band, song, or date")
.refreshable {
await loadData()
}
}
}
.navigationTitle("Media Database")
.navigationBarTitleDisplayMode(.inline)
.task {
await loadData()
}
.onChange(of: searchText) { _ in
filterEntries()
}
}
}
private func loadData() async {
isLoading = true
errorMessage = nil
do {
guard let baseURL = session.baseURL else { errorMessage = "Missing base URL"; isLoading = false; return }
let client = DatabaseAPIClient(baseURL: baseURL,
basicAuth: session.credentials,
allowInsecure: session.allowInsecureTLS)
entries = try await client.fetchMediaList()
filteredEntries = entries
isLoading = false
} catch {
errorMessage = error.localizedDescription
isLoading = false
}
}
private func filterEntries() {
if searchText.isEmpty {
filteredEntries = entries
} else {
let query = searchText.lowercased()
filteredEntries = entries.filter { entry in
entry.orgName.lowercased().contains(query) ||
entry.songTitle.lowercased().contains(query) ||
entry.date.contains(query) ||
entry.fileType.lowercased().contains(query)
}
}
}
}
struct MediaEntryRow: View {
let entry: MediaEntry
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(entry.date)
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text(entry.fileType.uppercased())
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(entry.fileType == "video" ? Color.blue.opacity(0.2) : Color.green.opacity(0.2))
.cornerRadius(4)
}
Text(entry.orgName)
.font(.headline)
HStack {
Text(entry.songTitle)
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
Text(entry.duration)
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 4)
}
}
GigHive/Sources/App/DatabaseDetailView.swiftimport SwiftUI
struct DatabaseDetailView: View {
let entry: MediaEntry
let baseURL: URL
@Environment(\.openURL) private var openURL
var body: some View {
List {
Section("Media Info") {
DetailRow(label: "Date", value: entry.date)
DetailRow(label: "Band/Event", value: entry.orgName)
DetailRow(label: "Song Title", value: entry.songTitle)
DetailRow(label: "Duration", value: entry.duration)
DetailRow(label: "File Type", value: entry.fileType)
DetailRow(label: "File Name", value: entry.fileName)
}
Section {
Button(action: {
// In-app playback via MediaPlayerView
// See In-App Playback section
}) {
HStack {
Image(systemName: entry.fileType == "video" ? "play.circle.fill" : "music.note")
Text(entry.fileType == "video" ? "Play Video" : "Play Audio")
Spacer()
Image(systemName: "play.rectangle")
}
}
if let url = URL(string: entry.url, relativeTo: baseURL) {
if #available(iOS 16.0, *) {
ShareLink(item: url) {
HStack {
Image(systemName: "square.and.arrow.up")
Text("Share")
}
}
} else {
Button(action: { ShareHelper.present(url) }) {
HStack {
Image(systemName: "square.and.arrow.up")
Text("Share")
}
}
}
}
}
}
.navigationTitle("Media Details")
.navigationBarTitleDisplayMode(.inline)
}
}
struct DetailRow: View {
let label: String
let value: String
var body: some View {
HStack {
Text(label)
.foregroundColor(.secondary)
Spacer()
Text(value)
.multilineTextAlignment(.trailing)
}
}
}
GigHive/Sources/App/UploadView.swiftAuthSession for baseURL, credentials, and allowInsecureTLS.GHButtonStyle).MediaPlayerView handles in-app playback for video and audio files.DatabaseDetailView uses MediaPlayerView for playback (no external browser).MediaResourceLoader.swift (delegate + URLSession proxy)MediaPlayerView.swift (creates AVURLAsset with custom scheme and sets loader delegate when bypass is enabled)[Player] Close tapped.If issues arise:
Revert to the previous single-view flow: remove session-based guards and LoginView, restore UploadView’s embedded auth UI and external browser behavior for viewing if needed.
/db/database.phpAuthSession, SplashView, and LoginView (TLS toggle).DatabaseView (Phase 1 JSON) and UploadView to use session; keep existing styling.If you encounter any issues during implementation, check:
DatabaseAPIClient is correctKeep current minimal iOS 14 fallbacks for now. If/when we raise the deployment target to iOS 15+, we can simplify code and polish UX:
NavigationView branches; standardize on iOS 15+ APIs (and NavigationStack on iOS 16+).presentationMode usages with @Environment(\.dismiss) everywhere..searchable universally..refreshable universally and drop any iOS 14 “Refresh” button fallback.ShareLink and keep ShareHelper only for non-ShareLink contexts, or remove fallback where appropriate.Theme.swift (use .foregroundStyle, .tint, and .background(.ultraThinMaterial) without fallbacks).gh* conditional wrappers that exist solely for iOS 14.#unavailable(iOS 15) or #available branches that only serve iOS 14.NavigationStack and path-based navigation/deep linking.ShareLink everywhere and consider PhotosPicker/newer media APIs where beneficial.DatabaseView, LoginView, MediaPlayerView, and shared modifiers.Theme.swift by removing compatibility code paths not needed on iOS 15+.