initial commit
This commit is contained in:
134
ios_client/EncryptedChat/Views/Auth/LoginView.swift
Normal file
134
ios_client/EncryptedChat/Views/Auth/LoginView.swift
Normal file
@@ -0,0 +1,134 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LoginView: View {
|
||||
@Bindable var viewModel: AuthViewModel
|
||||
var appState: AppState
|
||||
|
||||
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)
|
||||
Toggle("Use TLS", isOn: $viewModel.useTLS)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
TextField("Email", text: $viewModel.email)
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
SecureField("Password", text: $viewModel.password)
|
||||
.textContentType(.password)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
if viewModel.mode == .register {
|
||||
TextField("Username", text: $viewModel.username)
|
||||
.textContentType(.username)
|
||||
.autocapitalization(.none)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
SecureField("Confirm Password", text: $viewModel.confirmPassword)
|
||||
.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)
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showConfirmation) {
|
||||
ConfirmationSheet(viewModel: viewModel, appState: appState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
49
ios_client/EncryptedChat/Views/Auth/PairingView.swift
Normal file
49
ios_client/EncryptedChat/Views/Auth/PairingView.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PairingView: View {
|
||||
var appState: AppState
|
||||
@State private var pairingCode = ""
|
||||
@State private var isWaiting = false
|
||||
@State private var statusMessage: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "iphone.and.arrow.forward")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
Text("Device Pairing")
|
||||
.font(.title2.bold())
|
||||
|
||||
Text("Enter the 8-digit pairing code shown on your other device.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
TextField("Pairing Code", text: $pairingCode)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.keyboardType(.numberPad)
|
||||
.frame(maxWidth: 200)
|
||||
|
||||
if let status = statusMessage {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(status.contains("Error") ? .red : .secondary)
|
||||
}
|
||||
|
||||
if isWaiting {
|
||||
ProgressView("Waiting for authorization...")
|
||||
}
|
||||
|
||||
Button("Start Pairing") {
|
||||
Task {
|
||||
// Pairing implementation would go here
|
||||
statusMessage = "Pairing not yet implemented"
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(pairingCode.count != 8 || isWaiting)
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
}
|
||||
4
ios_client/EncryptedChat/Views/Auth/RegisterView.swift
Normal file
4
ios_client/EncryptedChat/Views/Auth/RegisterView.swift
Normal file
@@ -0,0 +1,4 @@
|
||||
import SwiftUI
|
||||
|
||||
// Registration is handled within LoginView via mode toggle.
|
||||
// This file exists for potential future separation.
|
||||
164
ios_client/EncryptedChat/Views/Chat/ChatView.swift
Normal file
164
ios_client/EncryptedChat/Views/Chat/ChatView.swift
Normal file
@@ -0,0 +1,164 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChatView: View {
|
||||
let conversation: Conversation
|
||||
var appState: AppState
|
||||
@State private var viewModel = ChatViewModel()
|
||||
@State private var inputText = ""
|
||||
@State private var replyTo: Message?
|
||||
@State private var showGroupInfo = false
|
||||
@State private var showSearch = false
|
||||
@State private var showDeleteConfirm = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Search bar
|
||||
if showSearch {
|
||||
SearchOverlayView(
|
||||
query: $viewModel.searchQuery,
|
||||
matchCount: viewModel.searchResults.count,
|
||||
currentIndex: viewModel.currentSearchIndex,
|
||||
onSearch: { viewModel.search(query: $0) },
|
||||
onNext: { viewModel.nextSearchResult() },
|
||||
onPrev: { viewModel.prevSearchResult() },
|
||||
onClose: { showSearch = false; viewModel.search(query: "") }
|
||||
)
|
||||
}
|
||||
|
||||
// Messages
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 8) {
|
||||
if viewModel.messages.count >= 50 {
|
||||
Button("Load older messages") {
|
||||
Task {
|
||||
await viewModel.loadOlderMessages(convId: conversation.id, chatClient: appState.chatClient)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.padding()
|
||||
}
|
||||
|
||||
ForEach(viewModel.messages) { message in
|
||||
MessageBubbleView(
|
||||
message: message,
|
||||
isMine: message.isMine(currentUserId: appState.currentUser?.id ?? ""),
|
||||
isHighlighted: viewModel.searchResults.contains(message.id),
|
||||
isCurrentSearchResult: viewModel.searchResults.indices.contains(viewModel.currentSearchIndex) &&
|
||||
viewModel.searchResults[viewModel.currentSearchIndex] == message.id,
|
||||
onReply: { replyTo = message },
|
||||
onDelete: {
|
||||
Task {
|
||||
await viewModel.deleteMessage(messageId: message.id, convId: conversation.id, chatClient: appState.chatClient)
|
||||
}
|
||||
}
|
||||
)
|
||||
.id(message.id)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.onChange(of: viewModel.messages.count) {
|
||||
if let lastId = viewModel.messages.last?.id {
|
||||
withAnimation {
|
||||
proxy.scrollTo(lastId, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reply preview
|
||||
if let reply = replyTo {
|
||||
HStack {
|
||||
Rectangle()
|
||||
.fill(.blue)
|
||||
.frame(width: 3)
|
||||
VStack(alignment: .leading) {
|
||||
Text(reply.senderUsername)
|
||||
.font(.caption.bold())
|
||||
Text(reply.text ?? "")
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
Button(action: { replyTo = nil }) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
|
||||
// Input
|
||||
MessageInputView(
|
||||
text: $inputText,
|
||||
isSending: viewModel.isSending,
|
||||
onSend: {
|
||||
Task {
|
||||
let text = inputText
|
||||
inputText = ""
|
||||
let reply = replyTo?.id
|
||||
replyTo = nil
|
||||
await viewModel.sendMessage(
|
||||
convId: conversation.id,
|
||||
text: text,
|
||||
members: conversation.members,
|
||||
chatClient: appState.chatClient,
|
||||
replyTo: reply
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.navigationTitle(conversation.displayName(currentUserId: appState.currentUser?.id ?? ""))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack(spacing: 16) {
|
||||
Button(action: { showSearch.toggle() }) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
}
|
||||
|
||||
if conversation.isGroup {
|
||||
Button(action: { showGroupInfo = true }) {
|
||||
Image(systemName: "info.circle")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete button
|
||||
if !conversation.isGroup || conversation.createdBy == appState.currentUser?.id {
|
||||
Button(action: { showDeleteConfirm = true }) {
|
||||
Image(systemName: "trash")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Delete Conversation?", isPresented: $showDeleteConfirm) {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Delete", role: .destructive) {
|
||||
Task {
|
||||
await appState.chatClient.deleteConversation(convId: conversation.id)
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text(conversation.isGroup
|
||||
? "This will remove all members and delete the conversation."
|
||||
: "This will remove you from the conversation.")
|
||||
}
|
||||
.sheet(isPresented: $showGroupInfo) {
|
||||
GroupInfoView(conversation: conversation, appState: appState)
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadMessages(convId: conversation.id, chatClient: appState.chatClient)
|
||||
viewModel.startNotificationListener(convId: conversation.id, chatClient: appState.chatClient)
|
||||
}
|
||||
.onDisappear {
|
||||
viewModel.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
43
ios_client/EncryptedChat/Views/Chat/ImageViewerView.swift
Normal file
43
ios_client/EncryptedChat/Views/Chat/ImageViewerView.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ImageViewerView: View {
|
||||
let imageData: Data
|
||||
@State private var scale: CGFloat = 1.0
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
GeometryReader { geo in
|
||||
if let uiImage = UIImage(data: imageData) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.scaleEffect(scale)
|
||||
.gesture(
|
||||
MagnifyGesture()
|
||||
.onChanged { value in
|
||||
scale = value.magnification
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation {
|
||||
scale = max(1.0, min(scale, 5.0))
|
||||
}
|
||||
}
|
||||
)
|
||||
.onTapGesture(count: 2) {
|
||||
withAnimation {
|
||||
scale = scale > 1 ? 1 : 2
|
||||
}
|
||||
}
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
.background(.black)
|
||||
}
|
||||
}
|
||||
}
|
||||
123
ios_client/EncryptedChat/Views/Chat/MessageBubbleView.swift
Normal file
123
ios_client/EncryptedChat/Views/Chat/MessageBubbleView.swift
Normal file
@@ -0,0 +1,123 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MessageBubbleView: View {
|
||||
let message: Message
|
||||
let isMine: Bool
|
||||
var isHighlighted: Bool = false
|
||||
var isCurrentSearchResult: Bool = false
|
||||
var onReply: (() -> Void)?
|
||||
var onDelete: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if isMine { Spacer(minLength: 60) }
|
||||
|
||||
VStack(alignment: isMine ? .trailing : .leading, spacing: 4) {
|
||||
if !isMine {
|
||||
Text(message.senderUsername)
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if message.isDeleted {
|
||||
Text("Message deleted")
|
||||
.font(.body.italic())
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(12)
|
||||
.background(Color(.systemGray6))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
} else {
|
||||
// Reply reference
|
||||
if let replyTo = message.replyTo {
|
||||
HStack(spacing: 4) {
|
||||
Rectangle()
|
||||
.fill(.blue.opacity(0.5))
|
||||
.frame(width: 2)
|
||||
Text("Reply to message")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
|
||||
// File card
|
||||
if let file = message.file {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Image(systemName: "paperclip")
|
||||
Text(file.filename)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.font(.subheadline)
|
||||
|
||||
Text(formatFileSize(file.size))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(.systemGray5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
// Text content
|
||||
if let text = message.text {
|
||||
Text(text)
|
||||
.padding(12)
|
||||
.background(
|
||||
isMine ? Color.blue : Color(.systemGray5)
|
||||
)
|
||||
.foregroundStyle(isMine ? .white : .primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
|
||||
// Timestamp
|
||||
Text(formatTime(message.createdAt))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.background(
|
||||
isCurrentSearchResult ? Color.orange.opacity(0.3) :
|
||||
isHighlighted ? Color.yellow.opacity(0.2) : Color.clear
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.contextMenu {
|
||||
if !message.isDeleted {
|
||||
Button(action: { onReply?() }) {
|
||||
Label("Reply", systemImage: "arrowshape.turn.up.left")
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
UIPasteboard.general.string = message.text ?? ""
|
||||
}) {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
if isMine {
|
||||
Button(role: .destructive, action: { onDelete?() }) {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isMine { Spacer(minLength: 60) }
|
||||
}
|
||||
}
|
||||
|
||||
private func formatTime(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
if Calendar.current.isDateInToday(date) {
|
||||
formatter.dateFormat = "HH:mm"
|
||||
} else {
|
||||
formatter.dateFormat = "MMM d, HH:mm"
|
||||
}
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func formatFileSize(_ bytes: Int) -> String {
|
||||
if bytes < 1024 { return "\(bytes) B" }
|
||||
if bytes < 1024 * 1024 { return "\(bytes / 1024) KB" }
|
||||
return String(format: "%.1f MB", Double(bytes) / (1024 * 1024))
|
||||
}
|
||||
}
|
||||
55
ios_client/EncryptedChat/Views/Chat/MessageInputView.swift
Normal file
55
ios_client/EncryptedChat/Views/Chat/MessageInputView.swift
Normal file
@@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
struct MessageInputView: View {
|
||||
@Binding var text: String
|
||||
let isSending: Bool
|
||||
let onSend: () -> Void
|
||||
|
||||
@State private var showAttachMenu = false
|
||||
@State private var selectedPhoto: PhotosPickerItem?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
// Attach button
|
||||
Menu {
|
||||
Button(action: {}) {
|
||||
Label("Photo", systemImage: "photo")
|
||||
}
|
||||
Button(action: {}) {
|
||||
Label("File", systemImage: "doc")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
|
||||
// Text field
|
||||
TextField("Message", text: $text, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1...5)
|
||||
.onSubmit {
|
||||
if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
onSend()
|
||||
}
|
||||
}
|
||||
|
||||
// Send button
|
||||
Button(action: onSend) {
|
||||
if isSending {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
} else {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
}
|
||||
46
ios_client/EncryptedChat/Views/Chat/SearchOverlayView.swift
Normal file
46
ios_client/EncryptedChat/Views/Chat/SearchOverlayView.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SearchOverlayView: View {
|
||||
@Binding var query: String
|
||||
let matchCount: Int
|
||||
let currentIndex: Int
|
||||
let onSearch: (String) -> Void
|
||||
let onNext: () -> Void
|
||||
let onPrev: () -> Void
|
||||
let onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextField("Search messages", text: $query)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onChange(of: query) { _, newValue in
|
||||
onSearch(newValue)
|
||||
}
|
||||
|
||||
if matchCount > 0 {
|
||||
Text("\(currentIndex + 1)/\(matchCount)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
|
||||
Button(action: onPrev) {
|
||||
Image(systemName: "chevron.up")
|
||||
}
|
||||
Button(action: onNext) {
|
||||
Image(systemName: "chevron.down")
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: onClose) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CircularAvatarView: View {
|
||||
let name: String
|
||||
var imageData: Data?
|
||||
var size: CGFloat = 32
|
||||
var isGroup: Bool = false
|
||||
|
||||
var body: some View {
|
||||
if let imageData = imageData, let uiImage = UIImage(data: imageData) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
// Default: colored circle with initial letter
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(avatarColor)
|
||||
.frame(width: size, height: size)
|
||||
|
||||
Text(initial)
|
||||
.font(.system(size: size * 0.4, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var initial: String {
|
||||
String(name.prefix(1)).uppercased()
|
||||
}
|
||||
|
||||
/// Deterministic color from name hash (matching Python gui_client behavior)
|
||||
private var avatarColor: Color {
|
||||
let colors: [Color] = [
|
||||
.red, .orange, .yellow, .green, .mint,
|
||||
.teal, .cyan, .blue, .indigo, .purple, .pink
|
||||
]
|
||||
var hash = 0
|
||||
for char in name.unicodeScalars {
|
||||
hash = hash &* 31 &+ Int(char.value)
|
||||
}
|
||||
return colors[abs(hash) % colors.count]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ConnectionIndicator: View {
|
||||
let status: ConnectionStatus
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(statusColor)
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
if status != .connected {
|
||||
Text(statusText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
switch status {
|
||||
case .connected: return .green
|
||||
case .connecting: return .orange
|
||||
case .disconnected: return .red
|
||||
}
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
switch status {
|
||||
case .connected: return ""
|
||||
case .connecting: return "Connecting..."
|
||||
case .disconnected: return "Disconnected"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OnlineDotOverlay: View {
|
||||
var size: CGFloat = 12
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(.green)
|
||||
.frame(width: size, height: size)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(.white, lineWidth: 2)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ConversationListView: View {
|
||||
var appState: AppState
|
||||
@Bindable var viewModel: ConversationListVM
|
||||
@State private var showNewConversation = false
|
||||
@State private var showProfile = false
|
||||
@State private var selectedConversation: Conversation?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
// Invitations section
|
||||
if !viewModel.invitations.isEmpty {
|
||||
Section {
|
||||
ForEach(viewModel.invitations) { invitation in
|
||||
InvitationBanner(
|
||||
invitation: invitation,
|
||||
onAccept: {
|
||||
Task {
|
||||
let (success, _) = await appState.chatClient.acceptInvitation(convId: invitation.conversationId)
|
||||
if success {
|
||||
await viewModel.refresh(chatClient: appState.chatClient)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDecline: {
|
||||
Task {
|
||||
await appState.chatClient.declineInvitation(convId: invitation.conversationId)
|
||||
await viewModel.refresh(chatClient: appState.chatClient)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} header: {
|
||||
Text("Invitations")
|
||||
}
|
||||
}
|
||||
|
||||
// Conversations section
|
||||
Section {
|
||||
ForEach(viewModel.conversations) { conversation in
|
||||
NavigationLink(value: conversation) {
|
||||
ConversationRowView(
|
||||
conversation: conversation,
|
||||
currentUserId: appState.currentUser?.id ?? "",
|
||||
isOnline: conversation.dmPartnerId(currentUserId: appState.currentUser?.id ?? "")
|
||||
.map { viewModel.onlineUsers.contains($0) } ?? false,
|
||||
unreadCount: viewModel.unreadCounts[conversation.id] ?? 0
|
||||
)
|
||||
}
|
||||
.contextMenu {
|
||||
Button(conversation.isFavorite ? "Remove from Favorites" : "Add to Favorites") {
|
||||
viewModel.toggleFavorite(convId: conversation.id, email: appState.email)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Chats")
|
||||
.navigationDestination(for: Conversation.self) { conversation in
|
||||
ChatView(
|
||||
conversation: conversation,
|
||||
appState: appState
|
||||
)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
ConnectionIndicator(status: appState.connectionStatus)
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack {
|
||||
Button(action: { showProfile = true }) {
|
||||
Image(systemName: "person.circle")
|
||||
}
|
||||
Button(action: { showNewConversation = true }) {
|
||||
Image(systemName: "square.and.pencil")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.refresh(chatClient: appState.chatClient)
|
||||
}
|
||||
.sheet(isPresented: $showNewConversation) {
|
||||
NewConversationSheet(appState: appState) { convId in
|
||||
showNewConversation = false
|
||||
await viewModel.refresh(chatClient: appState.chatClient)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showProfile) {
|
||||
ProfileView(appState: appState, isOwnProfile: true)
|
||||
}
|
||||
.task {
|
||||
await viewModel.load(chatClient: appState.chatClient, email: appState.email)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ConversationRowView: View {
|
||||
let conversation: Conversation
|
||||
let currentUserId: String
|
||||
let isOnline: Bool
|
||||
let unreadCount: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Avatar
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
CircularAvatarView(
|
||||
name: conversation.displayName(currentUserId: currentUserId),
|
||||
size: 44,
|
||||
isGroup: conversation.isGroup
|
||||
)
|
||||
|
||||
if isOnline && !conversation.isGroup {
|
||||
OnlineDotOverlay(size: 12)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
if conversation.isFavorite {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
|
||||
Text(conversation.displayName(currentUserId: currentUserId))
|
||||
.font(unreadCount > 0 ? .body.bold() : .body)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if conversation.isGroup {
|
||||
Text("\(conversation.members.count) members")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if unreadCount > 0 {
|
||||
Text("\(unreadCount)")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.blue)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NewConversationSheet: View {
|
||||
var appState: AppState
|
||||
var onCreated: (String) async -> Void
|
||||
|
||||
@State private var email = ""
|
||||
@State private var groupName = ""
|
||||
@State private var isGroup = false
|
||||
@State private var memberEmails: [String] = [""]
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("Create Group", isOn: $isGroup)
|
||||
|
||||
if isGroup {
|
||||
TextField("Group Name", text: $groupName)
|
||||
}
|
||||
}
|
||||
|
||||
Section(isGroup ? "Members" : "Recipient") {
|
||||
if isGroup {
|
||||
ForEach(memberEmails.indices, id: \.self) { index in
|
||||
TextField("Email", text: $memberEmails[index])
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
}
|
||||
Button("Add Member") {
|
||||
memberEmails.append("")
|
||||
}
|
||||
} else {
|
||||
TextField("Email", text: $email)
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
}
|
||||
}
|
||||
|
||||
if let error = errorMessage {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("New Conversation")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Create") {
|
||||
Task { await create() }
|
||||
}
|
||||
.disabled(isLoading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func create() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
let emails: [String]
|
||||
if isGroup {
|
||||
emails = memberEmails.map { $0.trimmed }.filter { !$0.isEmpty }
|
||||
guard !emails.isEmpty else {
|
||||
errorMessage = "Add at least one member"
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
} else {
|
||||
guard !email.trimmed.isEmpty else {
|
||||
errorMessage = "Enter an email address"
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
emails = [email.trimmed]
|
||||
}
|
||||
|
||||
let name = isGroup && !groupName.trimmed.isEmpty ? groupName.trimmed : nil
|
||||
let (convId, message) = await appState.chatClient.createConversation(emails: emails, name: name)
|
||||
|
||||
isLoading = false
|
||||
|
||||
if let convId = convId {
|
||||
await onCreated(convId)
|
||||
} else {
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import SwiftUI
|
||||
|
||||
// Group creation is handled within NewConversationSheet via the isGroup toggle.
|
||||
// This file exists for potential future separation.
|
||||
123
ios_client/EncryptedChat/Views/Groups/GroupInfoView.swift
Normal file
123
ios_client/EncryptedChat/Views/Groups/GroupInfoView.swift
Normal file
@@ -0,0 +1,123 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GroupInfoView: View {
|
||||
let conversation: Conversation
|
||||
var appState: AppState
|
||||
@State private var showRenameSheet = false
|
||||
@State private var showLeaveConfirm = false
|
||||
@State private var newName = ""
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var isCreator: Bool {
|
||||
conversation.createdBy == appState.currentUser?.id
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
// Avatar section
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack(spacing: 8) {
|
||||
CircularAvatarView(
|
||||
name: conversation.name ?? "Group",
|
||||
size: 64,
|
||||
isGroup: true
|
||||
)
|
||||
|
||||
Text(conversation.name ?? "Group")
|
||||
.font(.title2.bold())
|
||||
|
||||
Text("\(conversation.members.count) members")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
// Actions
|
||||
if isCreator {
|
||||
Section {
|
||||
Button("Rename Group") {
|
||||
newName = conversation.name ?? ""
|
||||
showRenameSheet = true
|
||||
}
|
||||
|
||||
Button("Change Avatar") {
|
||||
// Photo picker would go here
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Members
|
||||
Section("Members") {
|
||||
ForEach(conversation.members) { member in
|
||||
HStack {
|
||||
CircularAvatarView(name: member.username, size: 32, isGroup: false)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(member.username)
|
||||
.font(.body)
|
||||
Text(member.email)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if member.userId == conversation.createdBy {
|
||||
Text("Admin")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Leave / Delete
|
||||
Section {
|
||||
Button("Leave Group", role: .destructive) {
|
||||
showLeaveConfirm = true
|
||||
}
|
||||
|
||||
if isCreator {
|
||||
Button("Delete Group", role: .destructive) {
|
||||
Task {
|
||||
await appState.chatClient.deleteConversation(convId: conversation.id)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Group Info")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
.alert("Leave Group?", isPresented: $showLeaveConfirm) {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Leave", role: .destructive) {
|
||||
Task {
|
||||
await appState.chatClient.leaveGroup(convId: conversation.id)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Rename Group", isPresented: $showRenameSheet) {
|
||||
TextField("Group Name", text: $newName)
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Rename") {
|
||||
Task {
|
||||
await appState.chatClient.renameConversation(convId: conversation.id, name: newName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
ios_client/EncryptedChat/Views/Groups/InvitationBanner.swift
Normal file
41
ios_client/EncryptedChat/Views/Groups/InvitationBanner.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import SwiftUI
|
||||
|
||||
struct InvitationBanner: View {
|
||||
let invitation: Invitation
|
||||
let onAccept: () -> Void
|
||||
let onDecline: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Image(systemName: "envelope.badge")
|
||||
.foregroundStyle(.orange)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(invitation.conversationName)
|
||||
.font(.body.bold())
|
||||
Text("Invited by \(invitation.invitedByUsername)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button("Accept") {
|
||||
onAccept()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
|
||||
Button("Decline") {
|
||||
onDecline()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import SwiftUI
|
||||
|
||||
// Profile editing is handled within ProfileView when isOwnProfile = true.
|
||||
// This file exists for potential future separation.
|
||||
111
ios_client/EncryptedChat/Views/Profile/ProfileView.swift
Normal file
111
ios_client/EncryptedChat/Views/Profile/ProfileView.swift
Normal file
@@ -0,0 +1,111 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ProfileView: View {
|
||||
var appState: AppState
|
||||
var isOwnProfile: Bool
|
||||
var userId: String?
|
||||
@State private var viewModel = ProfileViewModel()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// Avatar
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack(spacing: 8) {
|
||||
if let avatarData = viewModel.avatarData,
|
||||
let uiImage = UIImage(data: avatarData) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
CircularAvatarView(
|
||||
name: viewModel.profile?.username ?? "?",
|
||||
size: 80,
|
||||
isGroup: false
|
||||
)
|
||||
}
|
||||
|
||||
if isOwnProfile {
|
||||
Button("Change Photo") {
|
||||
// Photo picker would go here
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
// Info
|
||||
Section("Info") {
|
||||
if let username = viewModel.profile?.username {
|
||||
LabeledContent("Username", value: username)
|
||||
}
|
||||
if let email = viewModel.profile?.email {
|
||||
LabeledContent("Email", value: email)
|
||||
}
|
||||
}
|
||||
|
||||
if isOwnProfile {
|
||||
// Editable fields
|
||||
Section("Contact") {
|
||||
TextField("Phone", text: $viewModel.phone)
|
||||
.keyboardType(.phonePad)
|
||||
Toggle("Phone visible to contacts", isOn: $viewModel.phoneVisible)
|
||||
|
||||
TextField("Location", text: $viewModel.location)
|
||||
Toggle("Location visible to contacts", isOn: $viewModel.locationVisible)
|
||||
}
|
||||
} else {
|
||||
// Read-only view
|
||||
if let phone = viewModel.profile?.phone, viewModel.profile?.phoneVisible == true {
|
||||
Section("Contact") {
|
||||
LabeledContent("Phone", value: phone)
|
||||
}
|
||||
}
|
||||
if let location = viewModel.profile?.location, viewModel.profile?.locationVisible == true {
|
||||
Section("Location") {
|
||||
LabeledContent("Location", value: location)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let error = viewModel.errorMessage {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(isOwnProfile ? "My Profile" : "Profile")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if isOwnProfile {
|
||||
Button("Save") {
|
||||
Task {
|
||||
await viewModel.saveProfile(chatClient: appState.chatClient)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isSaving)
|
||||
} else {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadProfile(userId: userId, chatClient: appState.chatClient)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user