initial commit

This commit is contained in:
Filip
2026-03-11 16:06:00 +01:00
parent b3c69053f6
commit 5fd80e6dd6
127 changed files with 39684 additions and 0 deletions

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

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

View File

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

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

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

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

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

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

View File

@@ -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]
}
}

View File

@@ -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"
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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
}
}
}

View File

@@ -0,0 +1,4 @@
import SwiftUI
// Group creation is handled within NewConversationSheet via the isGroup toggle.
// This file exists for potential future separation.

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

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

View File

@@ -0,0 +1,4 @@
import SwiftUI
// Profile editing is handled within ProfileView when isOwnProfile = true.
// This file exists for potential future separation.

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