ios_client

This commit is contained in:
Filip
2026-03-14 12:43:56 +01:00
parent 5fd80e6dd6
commit 214da18779
74 changed files with 13136 additions and 284 deletions

View File

@@ -0,0 +1,82 @@
import SwiftUI
struct AuthorizeDeviceView: View {
var appState: AppState
@State private var code = ""
@State private var isAuthorizing = false
@State private var statusMessage: String?
@State private var isError = false
@State private var isDone = false
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
Image(systemName: "iphone.badge.checkmark")
.font(.system(size: 48))
.foregroundStyle(.blue)
Text("Authorize New Device")
.font(.title2.bold())
Text("Enter the 8-digit pairing code shown on the new device.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
TextField("Pairing Code", text: $code)
.font(.system(size: 24, weight: .bold, design: .monospaced))
.multilineTextAlignment(.center)
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
Button("Authorize") {
Task { await authorize() }
}
.buttonStyle(.borderedProminent)
.disabled(code.count < 8 || isAuthorizing || isDone)
if isAuthorizing {
ProgressView("Preparing history & sending keys...")
}
if let status = statusMessage {
Text(status)
.font(.caption)
.foregroundStyle(isError ? .red : .green)
.multilineTextAlignment(.center)
}
if isDone {
Button("Done") { dismiss() }
.buttonStyle(.bordered)
}
}
.padding(32)
}
.navigationTitle("Authorize Device")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
}
}
}
private func authorize() async {
isAuthorizing = true
isError = false
statusMessage = nil
let (success, msg) = await appState.chatClient.authorizeDevice(code: code)
isAuthorizing = false
statusMessage = msg
isError = !success
if success {
isDone = true
}
}
}

View File

@@ -0,0 +1,179 @@
import SwiftUI
struct LoginView: View {
@Bindable var viewModel: AuthViewModel
var appState: AppState
@State private var showPairing = false
@State private var didAttemptBiometric = false
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
Image(systemName: "lock.shield.fill")
.font(.system(size: 60))
.foregroundStyle(.blue)
.padding(.top, 40)
Text("Encrypted Chat")
.font(.largeTitle.bold())
Text("End-to-end encrypted messaging")
.font(.subheadline)
.foregroundStyle(.secondary)
VStack(spacing: 16) {
// Server config
DisclosureGroup("Server") {
TextField("Host", text: $viewModel.serverHost)
.textContentType(.URL)
.autocapitalization(.none)
TextField("Port", text: $viewModel.serverPort)
.keyboardType(.numberPad)
}
.padding(.horizontal)
if viewModel.mode == .register {
TextField("Username", text: $viewModel.username)
.textContentType(.username)
.autocapitalization(.none)
.textFieldStyle(.roundedBorder)
}
TextField("Email", text: $viewModel.email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
.textFieldStyle(.roundedBorder)
SecureField("Password", text: $viewModel.password)
.textContentType(viewModel.mode == .login ? .password : .oneTimeCode)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textFieldStyle(.roundedBorder)
if viewModel.mode == .register {
SecureField("Confirm Password", text: $viewModel.confirmPassword)
.textContentType(.oneTimeCode)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textFieldStyle(.roundedBorder)
}
if let error = viewModel.errorMessage {
Text(error)
.foregroundStyle(.red)
.font(.caption)
.multilineTextAlignment(.center)
}
Button(action: {
Task {
if viewModel.mode == .login {
await viewModel.login(appState: appState)
} else {
await viewModel.register(appState: appState)
}
}
}) {
if viewModel.isLoading {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Text(viewModel.mode == .login ? "Login" : "Register")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isLoading)
Button(viewModel.mode == .login ? "Don't have an account? Register" : "Already have an account? Login") {
viewModel.mode = viewModel.mode == .login ? .register : .login
viewModel.errorMessage = nil
}
.font(.caption)
if viewModel.hasSavedCredentials && viewModel.mode == .login {
Divider()
.padding(.vertical, 4)
Button {
Task { await viewModel.biometricLogin(appState: appState) }
} label: {
if viewModel.isBiometricLoading {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Label("Sign in with Face ID", systemImage: "faceid")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.bordered)
.disabled(viewModel.isLoading || viewModel.isBiometricLoading)
}
Divider()
.padding(.vertical, 4)
Button("Pair from existing device") {
showPairing = true
}
.font(.caption)
}
.padding(.horizontal, 32)
}
}
.task {
viewModel.checkSavedCredentials()
if viewModel.hasSavedCredentials && !didAttemptBiometric {
didAttemptBiometric = true
await viewModel.biometricLogin(appState: appState)
}
}
.sheet(isPresented: $viewModel.showConfirmation) {
ConfirmationSheet(viewModel: viewModel, appState: appState)
}
.sheet(isPresented: $showPairing) {
PairingView(appState: appState, authViewModel: viewModel)
}
}
}
}
struct ConfirmationSheet: View {
@Bindable var viewModel: AuthViewModel
var appState: AppState
var body: some View {
VStack(spacing: 20) {
Text("Confirm Registration")
.font(.title2.bold())
if let msg = viewModel.registrationMessage {
Text(msg)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
TextField("Confirmation Code", text: $viewModel.confirmationCode)
.textFieldStyle(.roundedBorder)
.keyboardType(.numberPad)
if let error = viewModel.errorMessage {
Text(error)
.foregroundStyle(.red)
.font(.caption)
}
Button("Confirm") {
Task {
await viewModel.confirmRegistration(appState: appState)
}
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isLoading)
}
.padding(32)
}
}

View File

@@ -0,0 +1,175 @@
import SwiftUI
struct PairingView: View {
var appState: AppState
@Bindable var authViewModel: AuthViewModel
@State private var email = ""
@State private var password = ""
@State private var pairingCode: String?
@State private var isStarting = false
@State private var isWaiting = false
@State private var statusMessage: String?
@State private var isError = false
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
Image(systemName: "iphone.and.arrow.forward")
.font(.system(size: 48))
.foregroundStyle(.blue)
Text("Device Pairing")
.font(.title2.bold())
Text("Transfer your keys from an existing device to this one.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if pairingCode == nil {
// Phase 1: Enter email and start pairing
VStack(spacing: 16) {
// Server config
DisclosureGroup("Server") {
TextField("Host", text: $authViewModel.serverHost)
.textContentType(.URL)
.autocapitalization(.none)
TextField("Port", text: $authViewModel.serverPort)
.keyboardType(.numberPad)
}
TextField("Email", text: $email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
.textFieldStyle(.roundedBorder)
SecureField("Password (for key encryption)", text: $password)
.textContentType(.password)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textFieldStyle(.roundedBorder)
Button("Start Pairing") {
Task { await startPairing() }
}
.buttonStyle(.borderedProminent)
.disabled(email.isEmpty || password.isEmpty || isStarting)
if isStarting {
ProgressView("Connecting...")
}
}
} else {
// Phase 2: Show code and wait for authorization
VStack(spacing: 16) {
Text("Pairing Code")
.font(.headline)
Text(pairingCode!)
.font(.system(size: 36, weight: .bold, design: .monospaced))
.padding()
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 12))
Text("Enter this code on your already logged-in device\nto authorize this device.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if isWaiting {
ProgressView("Waiting for authorization...")
.padding()
}
}
}
if let status = statusMessage {
Text(status)
.font(.caption)
.foregroundStyle(isError ? .red : .green)
.multilineTextAlignment(.center)
}
}
.padding(32)
}
.navigationTitle("Pair Device")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
}
}
}
private func startPairing() async {
isStarting = true
isError = false
statusMessage = nil
// Connect to server
if await !appState.chatClient.isConnected {
do {
let port = UInt16(authViewModel.serverPort) ?? Constants.defaultPort
try await appState.chatClient.connect(
host: authViewModel.serverHost, port: port
)
} catch {
isStarting = false
statusMessage = "Connection failed: \(error.localizedDescription)"
isError = true
return
}
}
let (success, codeOrMsg) = await appState.chatClient.pairingStart(email: email)
isStarting = false
if success {
pairingCode = codeOrMsg
// Start waiting for authorization
isWaiting = true
Task { await waitForAuthorization() }
} else {
statusMessage = codeOrMsg
isError = true
}
}
private func waitForAuthorization() async {
let (success, msg) = await appState.chatClient.pairingWait(
code: pairingCode!, email: email, password: password
)
isWaiting = false
if success {
statusMessage = msg
isError = false
// Auto-login
let (loginOk, loginMsg) = await appState.chatClient.login(email: email, password: password)
if loginOk {
appState.email = email
appState.isLoggedIn = true
appState.connectionStatus = .connected
appState.startConnectionMonitor()
if let userId = await appState.chatClient.userId {
appState.currentUser = User(
id: userId,
username: await appState.chatClient.username,
email: email
)
}
dismiss()
} else {
statusMessage = "Keys imported but login failed: \(loginMsg)"
isError = true
}
} else {
statusMessage = msg
isError = true
}
}
}

View File

@@ -0,0 +1,4 @@
import SwiftUI
// Registration is handled within LoginView via mode toggle.
// This file exists for potential future separation.