ui: smaller QR code for verify code view, change iOS layout (#5948)

* ui: smaller QR code for verify code view, change iOS layout

* ios: fix layout for editing group profile
This commit is contained in:
Evgeny 2025-05-26 16:57:18 +01:00 committed by GitHub
parent cbaab06975
commit 686145ba36
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 167 additions and 180 deletions

View file

@ -633,9 +633,6 @@ struct GroupChatInfoView: View {
groupInfo: $groupInfo, groupInfo: $groupInfo,
groupProfile: groupInfo.groupProfile groupProfile: groupInfo.groupProfile
) )
.navigationBarTitle("Group profile")
.modifier(ThemedBackground())
.navigationBarTitleDisplayMode(.large)
} label: { } label: {
Label("Edit group profile", systemImage: "pencil") Label("Edit group profile", systemImage: "pencil")
} }

View file

@ -26,6 +26,7 @@ struct GroupProfileView: View {
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@Binding var groupInfo: GroupInfo @Binding var groupInfo: GroupInfo
@State var groupProfile: GroupProfile @State var groupProfile: GroupProfile
@State private var currentProfileHash: Int?
@State private var showChooseSource = false @State private var showChooseSource = false
@State private var showImagePicker = false @State private var showImagePicker = false
@State private var showTakePhoto = false @State private var showTakePhoto = false
@ -34,60 +35,40 @@ struct GroupProfileView: View {
@FocusState private var focusDisplayName @FocusState private var focusDisplayName
var body: some View { var body: some View {
return VStack(alignment: .leading) { List {
Text("Group profile is stored on members' devices, not on the servers.") EditProfileImage(profileImage: $groupProfile.image, showChooseSource: $showChooseSource)
.padding(.vertical) .if(!focusDisplayName) { $0.padding(.top) }
ZStack(alignment: .center) { Section {
ZStack(alignment: .topTrailing) { HStack {
profileImageView(groupProfile.image) TextField("Group display name", text: $groupProfile.displayName)
if groupProfile.image != nil { .focused($focusDisplayName)
Button { if !validNewProfileName {
groupProfile.image = nil
} label: {
Image(systemName: "multiply")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 12)
}
}
}
editImageButton { showChooseSource = true }
}
.frame(maxWidth: .infinity, alignment: .center)
VStack(alignment: .leading) {
ZStack(alignment: .topLeading) {
if !validNewProfileName() {
Button { Button {
alert = .invalidName(validName: mkValidName(groupProfile.displayName)) alert = .invalidName(validName: mkValidName(groupProfile.displayName))
} label: { } label: {
Image(systemName: "exclamationmark.circle").foregroundColor(.red) Image(systemName: "exclamationmark.circle").foregroundColor(.red)
} }
} else {
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
} }
profileNameTextEdit("Group display name", $groupProfile.displayName)
.focused($focusDisplayName)
} }
.padding(.bottom)
let fullName = groupInfo.groupProfile.fullName let fullName = groupInfo.groupProfile.fullName
if fullName != "" && fullName != groupProfile.displayName { if fullName != "" && fullName != groupProfile.displayName {
profileNameTextEdit("Group full name (optional)", $groupProfile.fullName) TextField("Group full name (optional)", text: $groupProfile.fullName)
.padding(.bottom)
} }
HStack(spacing: 20) { } footer: {
Button("Cancel") { dismiss() } Text("Group profile is stored on members' devices, not on the servers.")
Button("Save group profile") { saveProfile() }
.disabled(!canUpdateProfile())
} }
}
.frame(maxWidth: .infinity, minHeight: 120, alignment: .leading)
Section {
Button("Reset") {
groupProfile = groupInfo.groupProfile
currentProfileHash = groupProfile.hashValue
}
.disabled(currentProfileHash == groupProfile.hashValue)
Button("Save group profile", action: saveProfile)
.disabled(!canUpdateProfile)
}
} }
.padding()
.frame(maxHeight: .infinity, alignment: .top)
.confirmationDialog("Group image", isPresented: $showChooseSource, titleVisibility: .visible) { .confirmationDialog("Group image", isPresented: $showChooseSource, titleVisibility: .visible) {
Button("Take picture") { Button("Take picture") {
showTakePhoto = true showTakePhoto = true
@ -95,6 +76,11 @@ struct GroupProfileView: View {
Button("Choose from library") { Button("Choose from library") {
showImagePicker = true showImagePicker = true
} }
if UIPasteboard.general.hasImages {
Button("Paste image") {
chosenImage = UIPasteboard.general.image
}
}
} }
.fullScreenCover(isPresented: $showTakePhoto) { .fullScreenCover(isPresented: $showTakePhoto) {
ZStack { ZStack {
@ -120,8 +106,20 @@ struct GroupProfileView: View {
} }
} }
.onAppear { .onAppear {
currentProfileHash = groupProfile.hashValue
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
focusDisplayName = true withAnimation { focusDisplayName = true }
}
}
.onDisappear {
if canUpdateProfile {
showAlert(
title: NSLocalizedString("Save group profile?", comment: "alert title"),
message: NSLocalizedString("Group profile was changed. If you save it, the updated profile will be sent to group members.", comment: "alert message"),
buttonTitle: NSLocalizedString("Save (and notify members)", comment: "alert button"),
buttonAction: saveProfile,
cancelButton: true
)
} }
} }
.alert(item: $alert) { a in .alert(item: $alert) { a in
@ -135,30 +133,30 @@ struct GroupProfileView: View {
return createInvalidNameAlert(name, $groupProfile.displayName) return createInvalidNameAlert(name, $groupProfile.displayName)
} }
} }
.contentShape(Rectangle()) .navigationBarTitle("Group profile")
.onTapGesture { hideKeyboard() } .modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(focusDisplayName ? .inline : .large)
} }
private func canUpdateProfile() -> Bool { private var canUpdateProfile: Bool {
groupProfile.displayName.trimmingCharacters(in: .whitespaces) != "" && validNewProfileName() currentProfileHash != groupProfile.hashValue &&
groupProfile.displayName.trimmingCharacters(in: .whitespaces) != "" &&
validNewProfileName
} }
private func validNewProfileName() -> Bool { private var validNewProfileName: Bool {
groupProfile.displayName == groupInfo.groupProfile.displayName groupProfile.displayName == groupInfo.groupProfile.displayName
|| validDisplayName(groupProfile.displayName.trimmingCharacters(in: .whitespaces)) || validDisplayName(groupProfile.displayName.trimmingCharacters(in: .whitespaces))
} }
func profileNameTextEdit(_ label: LocalizedStringKey, _ name: Binding<String>) -> some View {
TextField(label, text: name)
.padding(.leading, 32)
}
func saveProfile() { func saveProfile() {
Task { Task {
do { do {
groupProfile.displayName = groupProfile.displayName.trimmingCharacters(in: .whitespaces) groupProfile.displayName = groupProfile.displayName.trimmingCharacters(in: .whitespaces)
groupProfile.fullName = groupProfile.fullName.trimmingCharacters(in: .whitespaces)
let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile) let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile)
await MainActor.run { await MainActor.run {
currentProfileHash = groupProfile.hashValue
groupInfo = gInfo groupInfo = gInfo
chatModel.updateGroup(gInfo) chatModel.updateGroup(gInfo)
dismiss() dismiss()

View file

@ -24,45 +24,37 @@ struct VerifyCodeView: View {
} }
private func verifyCodeView(_ code: String) -> some View { private func verifyCodeView(_ code: String) -> some View {
ScrollView {
let splitCode = splitToParts(code, length: 24) let splitCode = splitToParts(code, length: 24)
VStack(alignment: .leading) { return List {
Group { Section {
HStack { QRCode(uri: code, small: true)
if connectionVerified {
Image(systemName: "checkmark.shield")
.foregroundColor(theme.colors.secondary)
Text("\(displayName) is verified")
} else {
Text("\(displayName) is not verified")
}
}
.frame(height: 24)
QRCode(uri: code)
.padding(.horizontal)
Text(splitCode) Text(splitCode)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.font(.body.monospaced()) .font(.body.monospaced())
.lineLimit(20) .lineLimit(20)
.padding(.bottom, 8)
}
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
} header: {
if connectionVerified {
HStack {
Image(systemName: "checkmark.shield").foregroundColor(theme.colors.secondary)
Text("\(displayName) is verified").textCase(.none)
}
} else {
Text("\(displayName) is not verified").textCase(.none)
}
} footer: {
Text("To verify end-to-end encryption with your contact compare (or scan) the code on your devices.") Text("To verify end-to-end encryption with your contact compare (or scan) the code on your devices.")
.padding(.bottom) }
Group { Section {
if connectionVerified { if connectionVerified {
Button { Button {
verifyCode(nil) verifyCode(nil)
} label: { } label: {
Label("Clear verification", systemImage: "shield") Label("Clear verification", systemImage: "shield")
} }
.padding()
} else { } else {
HStack {
NavigationLink { NavigationLink {
ScanCodeView(connectionVerified: $connectionVerified, verify: verify) ScanCodeView(connectionVerified: $connectionVerified, verify: verify)
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
@ -71,7 +63,6 @@ struct VerifyCodeView: View {
} label: { } label: {
Label("Scan code", systemImage: "qrcode") Label("Scan code", systemImage: "qrcode")
} }
.padding()
Button { Button {
verifyCode(code) { verified in verifyCode(code) { verified in
if !verified { showCodeError = true } if !verified { showCodeError = true }
@ -79,17 +70,12 @@ struct VerifyCodeView: View {
} label: { } label: {
Label("Mark verified", systemImage: "checkmark.shield") Label("Mark verified", systemImage: "checkmark.shield")
} }
.padding()
.alert(isPresented: $showCodeError) { .alert(isPresented: $showCodeError) {
Alert(title: Text("Incorrect security code!")) Alert(title: Text("Incorrect security code!"))
} }
} }
} }
} }
.frame(maxWidth: .infinity, alignment: .center)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button { Button {
@ -103,7 +89,6 @@ struct VerifyCodeView: View {
if connectionVerified { dismiss() } if connectionVerified { dismiss() }
} }
} }
}
private func verifyCode(_ code: String?, _ cb: ((Bool) -> Void)? = nil) { private func verifyCode(_ code: String?, _ cb: ((Bool) -> Void)? = nil) {
if let (verified, existingCode) = verify(code) { if let (verified, existingCode) = verify(code) {

View file

@ -21,7 +21,7 @@ enum DatabaseAlert: Identifiable {
case deleteLegacyDatabase case deleteLegacyDatabase
case deleteFilesAndMedia case deleteFilesAndMedia
case setChatItemTTL(ttl: ChatItemTTL) case setChatItemTTL(ttl: ChatItemTTL)
case error(title: LocalizedStringKey, error: String = "") case error(title: String, error: String = "")
var id: String { var id: String {
switch self { switch self {
@ -456,7 +456,7 @@ struct DatabaseView: View {
} }
} catch let error { } catch let error {
await MainActor.run { await MainActor.run {
alert = .error(title: "Error exporting chat database", error: responseError(error)) alert = .error(title: NSLocalizedString("Error exporting chat database", comment: "alert title"), error: responseError(error))
progressIndicator = false progressIndicator = false
} }
} }
@ -492,10 +492,10 @@ struct DatabaseView: View {
return migration return migration
} }
} catch let error { } catch let error {
await operationEnded(.error(title: "Error importing chat database", error: responseError(error)), progressIndicator, alert) await operationEnded(.error(title: NSLocalizedString("Error importing chat database", comment: "alert title"), error: responseError(error)), progressIndicator, alert)
} }
} catch let error { } catch let error {
await operationEnded(.error(title: "Error deleting chat database", error: responseError(error)), progressIndicator, alert) await operationEnded(.error(title: NSLocalizedString("Error deleting chat database", comment: "alert title"), error: responseError(error)), progressIndicator, alert)
} }
} else { } else {
showAlert("Error accessing database file") showAlert("Error accessing database file")
@ -513,7 +513,7 @@ struct DatabaseView: View {
await DatabaseView.operationEnded(.chatDeleted, $progressIndicator, $alert) await DatabaseView.operationEnded(.chatDeleted, $progressIndicator, $alert)
return true return true
} catch let error { } catch let error {
await DatabaseView.operationEnded(.error(title: "Error deleting database", error: responseError(error)), $progressIndicator, $alert) await DatabaseView.operationEnded(.error(title: NSLocalizedString("Error deleting database", comment: "alert title"), error: responseError(error)), $progressIndicator, $alert)
return false return false
} }
} }
@ -522,7 +522,7 @@ struct DatabaseView: View {
if removeLegacyDatabaseAndFiles() { if removeLegacyDatabaseAndFiles() {
legacyDatabase = false legacyDatabase = false
} else { } else {
alert = .error(title: "Error deleting old database") alert = .error(title: NSLocalizedString("Error deleting old database", comment: "alert title"))
} }
} }
@ -546,7 +546,7 @@ struct DatabaseView: View {
let (title, message) = chatDeletedAlertText() let (title, message) = chatDeletedAlertText()
showAlert(title, message: message, actions: { [okAlertActionWaiting] }) showAlert(title, message: message, actions: { [okAlertActionWaiting] })
} else if case let .error(title, error) = dbAlert { } else if case let .error(title, error) = dbAlert {
showAlert("\(title)", message: error, actions: { [okAlertActionWaiting] }) showAlert(title, message: error, actions: { [okAlertActionWaiting] })
} else { } else {
alert.wrappedValue = dbAlert alert.wrappedValue = dbAlert
cont.resume() cont.resume()
@ -567,7 +567,7 @@ struct DatabaseView: View {
} }
} catch { } catch {
await MainActor.run { await MainActor.run {
alert = .error(title: "Error changing setting", error: responseError(error)) alert = .error(title: NSLocalizedString("Error changing setting", comment: "alert title"), error: responseError(error))
chatItemTTL = currentChatItemTTL chatItemTTL = currentChatItemTTL
afterSetCiTTL() afterSetCiTTL()
} }

View file

@ -125,7 +125,7 @@ struct NewChatSheet: View {
} }
NavigationLink { NavigationLink {
AddGroupView() AddGroupView()
.navigationTitle("Create secret group") .navigationTitle("Create group")
.modifier(ThemedBackground(grouped: true)) .modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
} label: { } label: {

View file

@ -25,28 +25,8 @@ struct UserProfile: View {
var body: some View { var body: some View {
List { List {
Group { EditProfileImage(profileImage: $profile.image, showChooseSource: $showChooseSource)
if profile.image != nil {
ZStack(alignment: .bottomTrailing) {
ZStack(alignment: .topTrailing) {
profileImageView(profile.image)
.onTapGesture { showChooseSource = true }
overlayButton("multiply", edge: .top) { profile.image = nil }
}
overlayButton("camera", edge: .bottom) { showChooseSource = true }
}
} else {
ZStack(alignment: .center) {
profileImageView(profile.image)
editImageButton { showChooseSource = true }
}
}
}
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.padding(.top) .padding(.top)
.contentShape(Rectangle())
Section { Section {
HStack { HStack {
@ -133,25 +113,6 @@ struct UserProfile: View {
.alert(item: $alert) { a in userProfileAlert(a, $profile.displayName) } .alert(item: $alert) { a in userProfileAlert(a, $profile.displayName) }
} }
private func overlayButton(
_ systemName: String,
edge: Edge.Set,
action: @escaping () -> Void
) -> some View {
Image(systemName: systemName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 12)
.foregroundColor(theme.colors.primary)
.padding(6)
.frame(width: 36, height: 36, alignment: .center)
.background(radius >= 20 ? Color.clear : theme.colors.background.opacity(0.5))
.clipShape(Circle())
.contentShape(Circle())
.padding([.trailing, edge], -12)
.onTapGesture(perform: action)
}
private func showFullName(_ user: User) -> Bool { private func showFullName(_ user: User) -> Bool {
user.profile.fullName != "" && user.profile.fullName != user.profile.displayName user.profile.fullName != "" && user.profile.fullName != user.profile.displayName
} }
@ -189,8 +150,54 @@ struct UserProfile: View {
} }
} }
func profileImageView(_ imageStr: String?) -> some View { struct EditProfileImage: View {
ProfileImage(imageStr: imageStr, size: 192) @EnvironmentObject var theme: AppTheme
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
@Binding var profileImage: String?
@Binding var showChooseSource: Bool
var body: some View {
Group {
if profileImage != nil {
ZStack(alignment: .bottomTrailing) {
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: profileImage, size: 160)
.onTapGesture { showChooseSource = true }
overlayButton("multiply", edge: .top) { profileImage = nil }
}
overlayButton("camera", edge: .bottom) { showChooseSource = true }
}
} else {
ZStack(alignment: .center) {
ProfileImage(imageStr: profileImage, size: 160)
editImageButton { showChooseSource = true }
}
}
}
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.contentShape(Rectangle())
}
private func overlayButton(
_ systemName: String,
edge: Edge.Set,
action: @escaping () -> Void
) -> some View {
Image(systemName: systemName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 12)
.foregroundColor(theme.colors.primary)
.padding(6)
.frame(width: 36, height: 36, alignment: .center)
.background(radius >= 20 ? Color.clear : theme.colors.background.opacity(0.5))
.clipShape(Circle())
.contentShape(Circle())
.padding([.trailing, edge], -12)
.onTapGesture(perform: action)
}
} }
func editImageButton(action: @escaping () -> Void) -> some View { func editImageButton(action: @escaping () -> Void) -> some View {

View file

@ -1596,13 +1596,13 @@ set passcode view */
/* delete after time /* delete after time
pref value */ pref value */
"default (%@)" = "по умолчанию (%@)"; "default (%@)" = "базовый (%@)";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"default (no)" = "по умолчанию (нет)"; "default (no)" = "базовый (нет)";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"default (yes)" = "по умолчанию (да)"; "default (yes)" = "базовый (да)";
/* alert action /* alert action
swipe action */ swipe action */
@ -1705,7 +1705,7 @@ swipe action */
"Delete messages" = "Удалить сообщения"; "Delete messages" = "Удалить сообщения";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Delete messages after" = "Удалять сообщения через"; "Delete messages after" = "Удалять сообщения";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Delete old database" = "Удалить предыдущую версию данных"; "Delete old database" = "Удалить предыдущую версию данных";
@ -4633,7 +4633,7 @@ chat item action */
"Send questions and ideas" = "Отправьте вопросы и идеи"; "Send questions and ideas" = "Отправьте вопросы и идеи";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Send receipts" = "Отправлять отчёты о доставке"; "Send receipts" = "Отчёты о доставке";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Send them from gallery or custom keyboards." = "Отправьте из галереи или из дополнительных клавиатур."; "Send them from gallery or custom keyboards." = "Отправьте из галереи или из дополнительных клавиатур.";

View file

@ -68,7 +68,7 @@ private fun VerifyCodeLayout(
} }
} }
QRCode(connectionCode, padding = PaddingValues(vertical = DEFAULT_PADDING_HALF)) QRCode(connectionCode, small = true, padding = PaddingValues(vertical = DEFAULT_PADDING_HALF))
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.weight(2f)) Spacer(Modifier.weight(2f))

View file

@ -84,7 +84,7 @@ fun QRCode(
Modifier Modifier
.padding(padding) .padding(padding)
.widthIn(max = 400.dp) .widthIn(max = 400.dp)
.fillMaxWidth(if (small) 0.67f else 1f) .fillMaxWidth(if (small) 0.63f else 1f)
.aspectRatio(1f) .aspectRatio(1f)
.then(modifier) .then(modifier)
.clickable { .clickable {