diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml
index c7e7c1e20d..284544089a 100644
--- a/apps/android/app/src/main/AndroidManifest.xml
+++ b/apps/android/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
@@ -33,6 +34,15 @@
+
+
+
-
\ No newline at end of file
+
diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
index d91262a249..2a67c09296 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
@@ -191,6 +191,7 @@ data class User(
): NamedChat {
override val displayName: String get() = profile.displayName
override val fullName: String get() = profile.fullName
+ override val image: String? get() = profile.image
companion object {
val sampleData = User(
@@ -208,6 +209,7 @@ typealias ChatId = String
interface NamedChat {
val displayName: String
val fullName: String
+ val image: String?
val chatViewName: String
get() = displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName")
}
@@ -272,6 +274,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val createdAt get() = contact.createdAt
override val displayName get() = contact.displayName
override val fullName get() = contact.fullName
+ override val image get() = contact.image
companion object {
val sampleData = Direct(Contact.sampleData)
@@ -288,6 +291,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val createdAt get() = groupInfo.createdAt
override val displayName get() = groupInfo.displayName
override val fullName get() = groupInfo.fullName
+ override val image get() = groupInfo.image
companion object {
val sampleData = Group(GroupInfo.sampleData)
@@ -304,6 +308,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val createdAt get() = contactRequest.createdAt
override val displayName get() = contactRequest.displayName
override val fullName get() = contactRequest.fullName
+ override val image get() = contactRequest.image
companion object {
val sampleData = ContactRequest(UserContactRequest.sampleData)
@@ -326,6 +331,7 @@ class Contact(
override val ready get() = activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready"
override val displayName get() = profile.displayName
override val fullName get() = profile.fullName
+ override val image get() = profile.image
companion object {
val sampleData = Contact(
@@ -354,7 +360,8 @@ class Connection(val connStatus: String) {
@Serializable
class Profile(
val displayName: String,
- val fullName: String
+ val fullName: String,
+ val image: String? = null
) {
companion object {
val sampleData = Profile(
@@ -377,6 +384,7 @@ class GroupInfo (
override val ready get() = true
override val displayName get() = groupProfile.displayName
override val fullName get() = groupProfile.fullName
+ override val image get() = groupProfile.image
companion object {
val sampleData = GroupInfo(
@@ -391,7 +399,8 @@ class GroupInfo (
@Serializable
class GroupProfile (
override val displayName: String,
- override val fullName: String
+ override val fullName: String,
+ override val image: String? = null
): NamedChat {
companion object {
val sampleData = GroupProfile(
@@ -444,6 +453,7 @@ class UserContactRequest (
override val ready get() = true
override val displayName get() = profile.displayName
override val fullName get() = profile.fullName
+ override val image get() = profile.image
companion object {
val sampleData = UserContactRequest(
@@ -641,6 +651,7 @@ sealed class MsgContent {
}
object MsgContentSerializer : KSerializer {
+ @OptIn(InternalSerializationApi::class)
override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgContent", PolymorphicKind.SEALED) {
element("MCText", buildClassSerialDescriptor("MCText") {
element("text")
diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
index 820a955304..b8611633b1 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
@@ -203,7 +203,7 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap
}
suspend fun apiUpdateProfile(profile: Profile): Profile? {
- val r = sendCmd(CC.UpdateProfile(profile))
+ val r = sendCmd(CC.ApiUpdateProfile(profile))
if (r is CR.UserProfileNoChange) return profile
if (r is CR.UserProfileUpdated) return r.toProfile
Log.e(TAG, "apiUpdateProfile bad response: ${r.responseType} ${r.details}")
@@ -351,7 +351,7 @@ sealed class CC {
class AddContact: CC()
class Connect(val connReq: String): CC()
class ApiDeleteChat(val type: ChatType, val id: Long): CC()
- class UpdateProfile(val profile: Profile): CC()
+ class ApiUpdateProfile(val profile: Profile): CC()
class CreateMyAddress: CC()
class DeleteMyAddress: CC()
class ShowMyAddress: CC()
@@ -373,7 +373,7 @@ sealed class CC {
is AddContact -> "/connect"
is Connect -> "/connect $connReq"
is ApiDeleteChat -> "/_delete ${chatRef(type, id)}"
- is UpdateProfile -> "/profile ${profile.displayName} ${profile.fullName}"
+ is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}"
is CreateMyAddress -> "/address"
is DeleteMyAddress -> "/delete_address"
is ShowMyAddress -> "/show_address"
@@ -396,7 +396,7 @@ sealed class CC {
is AddContact -> "addContact"
is Connect -> "connect"
is ApiDeleteChat -> "apiDeleteChat"
- is UpdateProfile -> "updateProfile"
+ is ApiUpdateProfile -> "updateProfile"
is CreateMyAddress -> "createMyAddress"
is DeleteMyAddress -> "deleteMyAddress"
is ShowMyAddress -> "showMyAddress"
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt
index 721553f2c0..3c2e33adbc 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt
@@ -148,7 +148,7 @@ fun CreateProfilePanel(chatModel: ChatModel) {
Button(onClick = {
withApi {
val user = chatModel.controller.apiCreateActiveUser(
- Profile(displayName, fullName)
+ Profile(displayName, fullName, null)
)
chatModel.controller.startChat(user)
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
index 0d0e2b146d..b2ce920b74 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
@@ -3,18 +3,18 @@ package chat.simplex.app.views.chat
import android.content.res.Configuration
import android.util.Log
import androidx.activity.compose.BackHandler
-import androidx.compose.foundation.*
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.*
+import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.mapSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
@@ -23,7 +23,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.TAG
import chat.simplex.app.model.*
-import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.ChatItemView
import chat.simplex.app.views.helpers.*
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt
index cdc1f86039..d5d582e9ed 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt
@@ -1,6 +1,8 @@
package chat.simplex.app.views.helpers
+import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
@@ -8,6 +10,10 @@ import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.SupervisedUserCircle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -20,12 +26,32 @@ fun ChatInfoImage(chat: Chat, size: Dp) {
val icon =
if (chat.chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle
else Icons.Filled.AccountCircle
+ ProfileImage(size, chat.chatInfo.image, icon)
+}
+
+@Composable
+fun ProfileImage(
+ size: Dp,
+ image: String? = null,
+ icon: ImageVector = Icons.Filled.AccountCircle
+) {
Box(Modifier.size(size)) {
- Icon(icon,
- contentDescription = "Avatar Placeholder",
- tint = MaterialTheme.colors.secondary,
- modifier = Modifier.fillMaxSize()
- )
+ if (image == null) {
+ Icon(
+ icon,
+ contentDescription = "profile image placeholder",
+ tint = MaterialTheme.colors.secondary,
+ modifier = Modifier.fillMaxSize()
+ )
+ } else {
+ val imageBitmap = base64ToBitmap(image).asImageBitmap()
+ Image(
+ imageBitmap,
+ "profile image",
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.size(size).padding(size / 12).clip(CircleShape)
+ )
+ }
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt
new file mode 100644
index 0000000000..6ae0c384a6
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt
@@ -0,0 +1,187 @@
+package chat.simplex.app.views.helpers
+
+import android.Manifest
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.*
+import android.net.Uri
+import android.provider.MediaStore
+import android.util.Base64
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.compose.ManagedActivityResultLauncher
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContract
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.CallSuper
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Collections
+import androidx.compose.material.icons.outlined.PhotoCamera
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import androidx.core.content.FileProvider
+import chat.simplex.app.BuildConfig
+import chat.simplex.app.TAG
+import chat.simplex.app.views.newchat.ActionButton
+import java.io.ByteArrayOutputStream
+import java.io.File
+
+// Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery
+
+fun bitmapToBase64(bitmap: Bitmap, squareCrop: Boolean = true): String {
+ val size = 104
+ var height = size
+ var width = size
+ var xOffset = 0
+ var yOffset = 0
+ if (bitmap.height < bitmap.width) {
+ width = height * bitmap.width / bitmap.height
+ xOffset = (width - height) / 2
+ } else {
+ height = width * bitmap.height / bitmap.width
+ yOffset = (height - width) / 2
+ }
+ var image = bitmap
+ while (image.width / 2 > width) {
+ image = Bitmap.createScaledBitmap(image, image.width / 2, image.height / 2, true)
+ }
+ image = Bitmap.createScaledBitmap(image, width, height, true)
+ if (squareCrop) {
+ image = Bitmap.createBitmap(image, xOffset, yOffset, size, size)
+ }
+ val stream = ByteArrayOutputStream()
+ image.compress(Bitmap.CompressFormat.JPEG, 85, stream)
+ return "data:image/jpg;base64," + Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP)
+}
+
+fun base64ToBitmap(base64ImageString: String) : Bitmap {
+ val imageString = base64ImageString
+ .removePrefix("data:image/png;base64,")
+ .removePrefix("data:image/jpg;base64,")
+ val imageBytes = Base64.decode(imageString, Base64.NO_WRAP)
+ return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
+}
+
+class CustomTakePicturePreview : ActivityResultContract() {
+ private var uri: Uri? = null
+ private var tmpFile: File? = null
+ lateinit var externalContext: Context
+
+ @CallSuper
+ override fun createIntent(context: Context, input: Void?): Intent {
+ externalContext = context
+ tmpFile = File.createTempFile("image", ".bmp", context.filesDir)
+ uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", tmpFile!!)
+ return Intent(MediaStore.ACTION_IMAGE_CAPTURE)
+ .putExtra(MediaStore.EXTRA_OUTPUT, uri)
+ }
+
+ override fun getSynchronousResult(
+ context: Context,
+ input: Void?
+ ): SynchronousResult? = null
+
+ override fun parseResult(resultCode: Int, intent: Intent?): Bitmap? {
+ return if (resultCode == Activity.RESULT_OK && uri != null) {
+ val source = ImageDecoder.createSource(externalContext.contentResolver, uri!!)
+ val bitmap = ImageDecoder.decodeBitmap(source)
+ tmpFile?.delete()
+ bitmap
+ } else {
+ Log.e( TAG, "Getting image from camera cancelled or failed.")
+ tmpFile?.delete()
+ null
+ }
+ }
+}
+
+@Composable
+fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher =
+ rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent(), cb)
+
+@Composable
+fun rememberCameraLauncher(cb: (Bitmap?) -> Unit): ManagedActivityResultLauncher =
+ rememberLauncherForActivityResult(contract = CustomTakePicturePreview(), cb)
+
+@Composable
+fun rememberPermissionLauncher(cb: (Boolean) -> Unit): ManagedActivityResultLauncher =
+ rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission(), cb)
+
+@Composable
+fun GetImageBottomSheet(
+ profileImageStr: MutableState,
+ hideBottomSheet: () -> Unit
+) {
+ val context = LocalContext.current
+ val isCameraSelected = remember { mutableStateOf (false) }
+
+ val galleryLauncher = rememberGalleryLauncher { uri: Uri? ->
+ if (uri != null) {
+ val source = ImageDecoder.createSource(context.contentResolver, uri)
+ val bitmap = ImageDecoder.decodeBitmap(source)
+ profileImageStr.value = bitmapToBase64(bitmap)
+ }
+ }
+
+ val cameraLauncher = rememberCameraLauncher { bitmap: Bitmap? ->
+ if (bitmap != null) profileImageStr.value = bitmapToBase64(bitmap)
+ }
+
+ val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
+ if (isGranted) {
+ if (isCameraSelected.value) cameraLauncher.launch(null)
+ else galleryLauncher.launch("image/*")
+ hideBottomSheet()
+ } else {
+ Toast.makeText(context, "Permission Denied!", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ .onFocusChanged { focusState ->
+ if (!focusState.hasFocus) hideBottomSheet()
+ }
+ ) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 30.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ ActionButton(null, "Use Camera", icon = Icons.Outlined.PhotoCamera) {
+ when (PackageManager.PERMISSION_GRANTED) {
+ ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
+ cameraLauncher.launch(null)
+ hideBottomSheet()
+ }
+ else -> {
+ isCameraSelected.value = true
+ permissionLauncher.launch(Manifest.permission.CAMERA)
+ }
+ }
+ }
+ ActionButton(null, "From Gallery", icon = Icons.Outlined.Collections) {
+ when (PackageManager.PERMISSION_GRANTED) {
+ ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) -> {
+ galleryLauncher.launch("image/*")
+ hideBottomSheet()
+ }
+ else -> {
+ isCameraSelected.value = false
+ permissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt
index 65d882ae71..61d2400a01 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt
@@ -83,7 +83,7 @@ fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit) {
}
@Composable
-fun ActionButton(text: String, comment: String, icon: ImageVector, disabled: Boolean = false,
+fun ActionButton(text: String?, comment: String?, icon: ImageVector, disabled: Boolean = false,
click: () -> Unit = {}) {
Column(
Modifier
@@ -97,16 +97,22 @@ fun ActionButton(text: String, comment: String, icon: ImageVector, disabled: Boo
modifier = Modifier
.size(40.dp)
.padding(bottom = 8.dp))
- Text(text,
- textAlign = TextAlign.Center,
- fontWeight = FontWeight.Bold,
- color = tint,
- modifier = Modifier.padding(bottom = 4.dp)
- )
- Text(comment,
- textAlign = TextAlign.Center,
- style = MaterialTheme.typography.body2
- )
+ if (text != null) {
+ Text(
+ text,
+ textAlign = TextAlign.Center,
+ fontWeight = FontWeight.Bold,
+ color = tint,
+ modifier = Modifier.padding(bottom = 4.dp)
+ )
+ }
+ if (comment != null) {
+ Text(
+ comment,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.body2
+ )
+ }
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt
index 0f0a649f1e..ef6e29a6d4 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt
@@ -16,7 +16,7 @@ import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun MarkdownHelpView() {
- Column(Modifier.padding(horizontal = 16.dp)) {
+ Column {
Text(
"How to use markdown",
style = MaterialTheme.typography.h1,
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt
index 0734965898..581c9cf271 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt
@@ -23,6 +23,7 @@ import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.TerminalView
+import chat.simplex.app.views.helpers.ProfileImage
import chat.simplex.app.views.newchat.ModalManager
@Composable
@@ -32,6 +33,7 @@ fun SettingsView(chatModel: ChatModel) {
SettingsLayout(
profile = user.profile,
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
+ showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } }
)
}
@@ -44,6 +46,7 @@ val simplexTeamUri =
fun SettingsLayout(
profile: Profile,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
+ showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
showTerminal: () -> Unit
) {
val uriHandler = LocalUriHandler.current
@@ -66,11 +69,8 @@ fun SettingsLayout(
)
Spacer(Modifier.height(30.dp))
- SettingsSectionView(showModal { UserProfileView(it) }, 60.dp) {
- Icon(
- Icons.Outlined.AccountCircle,
- contentDescription = "Avatar Placeholder",
- )
+ SettingsSectionView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp) {
+ ProfileImage(size = 60.dp, profile.image)
Spacer(Modifier.padding(horizontal = 4.dp))
Column {
Text(
@@ -186,7 +186,7 @@ fun SettingsSectionView(click: () -> Unit, height: Dp = 48.dp, content: (@Compos
.height(height),
verticalAlignment = Alignment.CenterVertically
) {
- content.invoke()
+ content()
}
}
@@ -202,6 +202,7 @@ fun PreviewSettingsLayout() {
SettingsLayout(
profile = Profile.sampleData,
showModal = {{}},
+ showCustomModal = {{}},
showTerminal = {}
)
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt
index 4dd2ad0d44..f72e512bca 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt
@@ -1,15 +1,24 @@
package chat.simplex.app.views.usersettings
import android.content.res.Configuration
-import androidx.compose.foundation.clickable
+import android.widget.ScrollView
+import androidx.compose.foundation.*
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Text
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.material.icons.outlined.PhotoCamera
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
@@ -17,29 +26,32 @@ import androidx.compose.ui.unit.dp
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.ui.theme.SimpleXTheme
-import chat.simplex.app.views.helpers.withApi
+import chat.simplex.app.views.chat.CIListState
+import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.newchat.ModalView
+import com.google.accompanist.insets.ProvideWindowInsets
+import com.google.accompanist.insets.navigationBarsWithImePadding
+import kotlinx.coroutines.launch
@Composable
-fun UserProfileView(chatModel: ChatModel) {
+fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
val user = chatModel.currentUser.value
if (user != null) {
- var editProfile by remember { mutableStateOf(false) }
+ var editProfile = remember { mutableStateOf(false) }
var profile by remember { mutableStateOf(user.profile) }
UserProfileLayout(
+ close = close,
editProfile = editProfile,
profile = profile,
- editProfileOff = { editProfile = false },
- editProfileOn = { editProfile = true },
- saveProfile = { displayName: String, fullName: String ->
+ saveProfile = { displayName, fullName, image ->
withApi {
- val newProfile = chatModel.controller.apiUpdateProfile(
- profile = Profile(displayName, fullName)
- )
+ val p = Profile(displayName, fullName, image)
+ val newProfile = chatModel.controller.apiUpdateProfile(p)
if (newProfile != null) {
chatModel.updateUserProfile(newProfile)
profile = newProfile
}
- editProfile = false
+ editProfile.value = false
}
}
)
@@ -48,119 +60,192 @@ fun UserProfileView(chatModel: ChatModel) {
@Composable
fun UserProfileLayout(
- editProfile: Boolean,
+ close: () -> Unit,
+ editProfile: MutableState,
profile: Profile,
- editProfileOff: () -> Unit,
- editProfileOn: () -> Unit,
- saveProfile: (String, String) -> Unit,
+ saveProfile: (String, String, String?) -> Unit,
) {
- Column(horizontalAlignment = Alignment.Start) {
- Text(
- "Your chat profile",
- Modifier.padding(bottom = 24.dp),
- style = MaterialTheme.typography.h1,
- color = MaterialTheme.colors.onBackground
- )
- Text(
- "Your profile is stored on your device and shared only with your contacts.\n" +
- "SimpleX servers cannot see your profile.",
- Modifier.padding(bottom = 24.dp),
- color = MaterialTheme.colors.onBackground
- )
- if (editProfile) {
- var displayName by remember { mutableStateOf(profile.displayName) }
- var fullName by remember { mutableStateOf(profile.fullName) }
- Column(
- modifier = Modifier.fillMaxWidth(),
- horizontalAlignment = Alignment.Start
- ) {
- // TODO hints
- BasicTextField(
- value = displayName,
- onValueChange = { displayName = it },
- modifier = Modifier
- .padding(bottom = 24.dp)
- .fillMaxWidth(),
- textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
- keyboardOptions = KeyboardOptions(
- capitalization = KeyboardCapitalization.None,
- autoCorrect = false
- ),
- singleLine = true
- )
- BasicTextField(
- value = fullName,
- onValueChange = { fullName = it },
- modifier = Modifier
- .padding(bottom = 24.dp)
- .fillMaxWidth(),
- textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
- keyboardOptions = KeyboardOptions(
- capitalization = KeyboardCapitalization.None,
- autoCorrect = false
- ),
- singleLine = true
- )
- Row {
- Text(
- "Cancel",
- color = MaterialTheme.colors.primary,
- modifier = Modifier
- .clickable(onClick = editProfileOff),
- )
- Spacer(Modifier.padding(horizontal = 8.dp))
- Text(
- "Save (and notify contacts)",
- color = MaterialTheme.colors.primary,
- modifier = Modifier
- .clickable(onClick = { saveProfile(displayName, fullName) })
- )
- }
- }
- } else {
- Column(
- modifier = Modifier.fillMaxWidth(),
- horizontalAlignment = Alignment.Start
- ) {
- Row(
- Modifier.padding(bottom = 24.dp)
+ val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
+ val displayName = remember { mutableStateOf(profile.displayName) }
+ val fullName = remember { mutableStateOf(profile.fullName) }
+ val profileImage = remember { mutableStateOf(profile.image) }
+ val scope = rememberCoroutineScope()
+ val scrollState = rememberScrollState()
+ val keyboardState by getKeyboardState()
+ var savedKeyboardState by remember { mutableStateOf(keyboardState) }
+
+ ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
+ ModalBottomSheetLayout(
+ scrimColor = Color.Black.copy(alpha = 0.12F),
+ modifier = Modifier.navigationBarsWithImePadding(),
+ sheetContent = {
+ GetImageBottomSheet(profileImage, hideBottomSheet = {
+ scope.launch { bottomSheetModalState.hide() }
+ })
+ },
+ sheetState = bottomSheetModalState,
+ sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
+ ) {
+ ModalView(close = close) {
+ Column(
+ Modifier
+ .verticalScroll(scrollState)
+ .padding(bottom = 16.dp),
+ horizontalAlignment = Alignment.Start
) {
Text(
- "Display name:",
+ "Your chat profile",
+ Modifier.padding(bottom = 24.dp),
+ style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
- Spacer(Modifier.padding(horizontal = 4.dp))
Text(
- profile.displayName,
- fontWeight = FontWeight.Bold,
+ "Your profile is stored on your device and shared only with your contacts.\n\n" +
+ "SimpleX servers cannot see your profile.",
+ Modifier.padding(bottom = 24.dp),
color = MaterialTheme.colors.onBackground
)
+ if (editProfile.value) {
+ Column(
+ Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.Start
+ ) {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 24.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Box(contentAlignment = Alignment.TopEnd) {
+ Box(contentAlignment = Alignment.Center) {
+ ProfileImage(192.dp, profileImage.value)
+ EditImageButton { scope.launch { bottomSheetModalState.show() } }
+ }
+ if (profileImage.value != null) {
+ DeleteImageButton { profileImage.value = null }
+ }
+ }
+ }
+ ProfileNameTextField(displayName)
+ ProfileNameTextField(fullName)
+ Row {
+ TextButton("Cancel") {
+ displayName.value = profile.displayName
+ fullName.value = profile.fullName
+ profileImage.value = profile.image
+ editProfile.value = false
+ }
+ Spacer(Modifier.padding(horizontal = 8.dp))
+ TextButton("Save (and notify contacts)") {
+ saveProfile(displayName.value, fullName.value, profileImage.value)
+ }
+ }
+ }
+ } else {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.Start
+ ) {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 24.dp), contentAlignment = Alignment.Center
+ ) {
+ ProfileImage(192.dp, profile.image)
+ if (profile.image == null) {
+ EditImageButton {
+ editProfile.value = true
+ scope.launch { bottomSheetModalState.show() }
+ }
+ }
+ }
+ ProfileNameRow("Display name:", profile.displayName)
+ ProfileNameRow("Full name:", profile.fullName)
+ TextButton("Edit") { editProfile.value = true }
+ }
+ }
+ if (savedKeyboardState != keyboardState) {
+ LaunchedEffect(keyboardState) {
+ scope.launch {
+ savedKeyboardState = keyboardState
+ scrollState.animateScrollTo(scrollState.maxValue)
+ }
+ }
+ }
}
- Row(
- Modifier.padding(bottom = 24.dp)
- ) {
- Text(
- "Full name:",
- color = MaterialTheme.colors.onBackground
- )
- Spacer(Modifier.padding(horizontal = 4.dp))
- Text(
- profile.fullName,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colors.onBackground
- )
- }
- Text(
- "Edit",
- color = MaterialTheme.colors.primary,
- modifier = Modifier
- .clickable(onClick = editProfileOn)
- )
}
}
}
}
+@Composable
+private fun ProfileNameTextField(name: MutableState) {
+ BasicTextField(
+ value = name.value,
+ onValueChange = { name.value = it },
+ modifier = Modifier
+ .padding(bottom = 24.dp)
+ .fillMaxWidth(),
+ textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
+ keyboardOptions = KeyboardOptions(
+ capitalization = KeyboardCapitalization.None,
+ autoCorrect = false
+ ),
+ singleLine = true
+ )
+}
+
+@Composable
+private fun ProfileNameRow(label: String, text: String) {
+ Row(Modifier.padding(bottom = 24.dp)) {
+ Text(
+ label,
+ color = MaterialTheme.colors.onBackground
+ )
+ Spacer(Modifier.padding(horizontal = 4.dp))
+ Text(
+ text,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colors.onBackground
+ )
+ }
+}
+
+@Composable
+private fun TextButton(text: String, click: () -> Unit) {
+ Text(
+ text,
+ color = MaterialTheme.colors.primary,
+ modifier = Modifier.clickable(onClick = click),
+ )
+}
+
+@Composable
+fun EditImageButton(click: () -> Unit) {
+ IconButton(
+ onClick = click,
+ modifier = Modifier.background(Color(1f, 1f, 1f, 0.2f), shape = CircleShape)
+ ) {
+ Icon(
+ Icons.Outlined.PhotoCamera,
+ contentDescription = "Edit image",
+ tint = MaterialTheme.colors.primary,
+ modifier = Modifier.size(36.dp)
+ )
+ }
+}
+
+@Composable
+fun DeleteImageButton(click: () -> Unit) {
+ IconButton(onClick = click) {
+ Icon(
+ Icons.Outlined.Close,
+ contentDescription = "Delete image",
+ tint = MaterialTheme.colors.primary,
+ )
+ }
+}
+
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -171,11 +256,10 @@ fun UserProfileLayout(
fun PreviewUserProfileLayoutEditOff() {
SimpleXTheme {
UserProfileLayout(
+ close = {},
profile = Profile.sampleData,
- editProfile = false,
- editProfileOff = {},
- editProfileOn = {},
- saveProfile = { _, _ -> }
+ editProfile = remember { mutableStateOf(false) },
+ saveProfile = { _, _, _ -> }
)
}
}
@@ -190,11 +274,10 @@ fun PreviewUserProfileLayoutEditOff() {
fun PreviewUserProfileLayoutEditOn() {
SimpleXTheme {
UserProfileLayout(
+ close = {},
profile = Profile.sampleData,
- editProfile = true,
- editProfileOff = {},
- editProfileOn = {},
- saveProfile = { _, _ -> }
+ editProfile = remember { mutableStateOf(true) },
+ saveProfile = {_, _, _ ->}
)
}
}
diff --git a/apps/android/app/src/main/res/xml/file_paths.xml b/apps/android/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000000..5cb7c4876d
--- /dev/null
+++ b/apps/android/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift
index c553543b3a..3836026c04 100644
--- a/apps/ios/Shared/Model/ChatModel.swift
+++ b/apps/ios/Shared/Model/ChatModel.swift
@@ -190,8 +190,8 @@ struct User: Decodable, NamedChat {
var activeUser: Bool
var displayName: String { get { profile.displayName } }
-
var fullName: String { get { profile.fullName } }
+ var image: String? { get { profile.image } }
static let sampleData = User(
userId: 1,
@@ -209,6 +209,7 @@ typealias GroupName = String
struct Profile: Codable, NamedChat {
var displayName: String
var fullName: String
+ var image: String?
static let sampleData = Profile(
displayName: "alice",
@@ -225,6 +226,7 @@ enum ChatType: String {
protocol NamedChat {
var displayName: String { get }
var fullName: String { get }
+ var image: String? { get }
}
extension NamedChat {
@@ -270,6 +272,16 @@ enum ChatInfo: Identifiable, Decodable, NamedChat {
}
}
+ var image: String? {
+ get {
+ switch self {
+ case let .direct(contact): return contact.image
+ case let .group(groupInfo): return groupInfo.image
+ case let .contactRequest(contactRequest): return contactRequest.image
+ }
+ }
+ }
+
var id: ChatId {
get {
switch self {
@@ -420,6 +432,7 @@ struct Contact: Identifiable, Decodable, NamedChat {
var ready: Bool { get { activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready" } }
var displayName: String { get { profile.displayName } }
var fullName: String { get { profile.fullName } }
+ var image: String? { get { profile.image } }
static let sampleData = Contact(
contactId: 1,
@@ -452,6 +465,7 @@ struct UserContactRequest: Decodable, NamedChat {
var ready: Bool { get { true } }
var displayName: String { get { profile.displayName } }
var fullName: String { get { profile.fullName } }
+ var image: String? { get { profile.image } }
static let sampleData = UserContactRequest(
contactRequestId: 1,
@@ -472,6 +486,7 @@ struct GroupInfo: Identifiable, Decodable, NamedChat {
var ready: Bool { get { true } }
var displayName: String { get { groupProfile.displayName } }
var fullName: String { get { groupProfile.fullName } }
+ var image: String? { get { groupProfile.image } }
static let sampleData = GroupInfo(
groupId: 1,
@@ -484,6 +499,7 @@ struct GroupInfo: Identifiable, Decodable, NamedChat {
struct GroupProfile: Codable, NamedChat {
var displayName: String
var fullName: String
+ var image: String?
static let sampleData = GroupProfile(
displayName: "team",
diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift
index 60678b2fef..da985998c6 100644
--- a/apps/ios/Shared/Model/SimpleXAPI.swift
+++ b/apps/ios/Shared/Model/SimpleXAPI.swift
@@ -28,7 +28,7 @@ enum ChatCommand {
case addContact
case connect(connReq: String)
case apiDeleteChat(type: ChatType, id: Int64)
- case updateProfile(profile: Profile)
+ case apiUpdateProfile(profile: Profile)
case createMyAddress
case deleteMyAddress
case showMyAddress
@@ -52,7 +52,7 @@ enum ChatCommand {
case .addContact: return "/connect"
case let .connect(connReq): return "/connect \(connReq)"
case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))"
- case let .updateProfile(profile): return "/profile \(profile.displayName) \(profile.fullName)"
+ case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))"
case .createMyAddress: return "/address"
case .deleteMyAddress: return "/delete_address"
case .showMyAddress: return "/show_address"
@@ -79,7 +79,7 @@ enum ChatCommand {
case .addContact: return "addContact"
case .connect: return "connect"
case .apiDeleteChat: return "apiDeleteChat"
- case .updateProfile: return "updateProfile"
+ case .apiUpdateProfile: return "apiUpdateProfile"
case .createMyAddress: return "createMyAddress"
case .deleteMyAddress: return "deleteMyAddress"
case .showMyAddress: return "showMyAddress"
@@ -155,7 +155,7 @@ enum ChatResponse: Decodable, Error {
case .sentInvitation: return "sentInvitation"
case .contactDeleted: return "contactDeleted"
case .userProfileNoChange: return "userProfileNoChange"
- case .userProfileUpdated: return "userProfileNoChange"
+ case .userProfileUpdated: return "userProfileUpdated"
case .userContactLink: return "userContactLink"
case .userContactLinkCreated: return "userContactLinkCreated"
case .userContactLinkDeleted: return "userContactLinkDeleted"
@@ -427,7 +427,7 @@ func apiDeleteChat(type: ChatType, id: Int64) async throws {
}
func apiUpdateProfile(profile: Profile) async throws -> Profile? {
- let r = await chatSendCmd(.updateProfile(profile: profile))
+ let r = await chatSendCmd(.apiUpdateProfile(profile: profile))
switch r {
case .userProfileNoChange: return nil
case let .userProfileUpdated(_, toProfile): return toProfile
@@ -703,10 +703,13 @@ private func getJSONObject(_ cjson: UnsafePointer) -> NSDictionary? {
return try? JSONSerialization.jsonObject(with: d) as? NSDictionary
}
-private func encodeCJSON(_ value: T) -> [CChar] {
+private func encodeJSON(_ value: T) -> String {
let data = try! jsonEncoder.encode(value)
- let str = String(decoding: data, as: UTF8.self)
- return str.cString(using: .utf8)!
+ return String(decoding: data, as: UTF8.self)
+}
+
+private func encodeCJSON(_ value: T) -> [CChar] {
+ encodeJSON(value).cString(using: .utf8)!
}
enum ChatError: Decodable {
diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift
index 5860cfdf6a..bc67bb498e 100644
--- a/apps/ios/Shared/Views/Chat/ChatView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatView.swift
@@ -130,7 +130,9 @@ struct ChatView: View {
}
func sendMessage(_ msg: String) {
+ logger.debug("ChatView sendMessage")
Task {
+ logger.debug("ChatView sendMessage: in Task")
do {
let chatItem = try await apiSendMessage(
type: chat.chatInfo.chatType,
diff --git a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift
index 6f95a9be97..c1b9abc2f4 100644
--- a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift
+++ b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift
@@ -19,10 +19,11 @@ struct ChatInfoImage: View {
case .group: iconName = "person.2.circle.fill"
default: iconName = "circle.fill"
}
-
- return Image(systemName: iconName)
- .resizable()
- .foregroundColor(color)
+ return ProfileImage(
+ imageStr: chat.chatInfo.image,
+ iconName: iconName,
+ color: color
+ )
}
}
diff --git a/apps/ios/Shared/Views/Helpers/ImagePicker.swift b/apps/ios/Shared/Views/Helpers/ImagePicker.swift
new file mode 100644
index 0000000000..8786e40da0
--- /dev/null
+++ b/apps/ios/Shared/Views/Helpers/ImagePicker.swift
@@ -0,0 +1,48 @@
+//
+// ImagePicker.swift
+// SimpleX
+//
+// Created by Evgeny on 23/03/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+struct ImagePicker: UIViewControllerRepresentable {
+ @Environment(\.presentationMode) var presentationMode
+ var source: UIImagePickerController.SourceType
+ @Binding var image: UIImage?
+
+ class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
+ let parent: ImagePicker
+
+ init(_ parent: ImagePicker) {
+ self.parent = parent
+ }
+
+ func imagePickerController(_ picker: UIImagePickerController,
+ didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
+ if let uiImage = info[.originalImage] as? UIImage {
+ parent.image = uiImage
+ }
+ parent.presentationMode.wrappedValue.dismiss()
+ }
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(self)
+ }
+
+
+ func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIImagePickerController {
+ let picker = UIImagePickerController()
+ picker.sourceType = source
+ picker.allowsEditing = false
+ picker.delegate = context.coordinator
+ return picker
+ }
+
+ func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext) {
+
+ }
+}
diff --git a/apps/ios/Shared/Views/Helpers/ProfileImage.swift b/apps/ios/Shared/Views/Helpers/ProfileImage.swift
new file mode 100644
index 0000000000..74abaca4b9
--- /dev/null
+++ b/apps/ios/Shared/Views/Helpers/ProfileImage.swift
@@ -0,0 +1,45 @@
+//
+// ProfileImage.swift
+// SimpleX
+//
+// Created by Evgeny on 23/03/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+struct ProfileImage: View {
+ var imageStr: String? = nil
+ var iconName: String = "person.crop.circle.fill"
+ var color = Color(uiColor: .tertiarySystemGroupedBackground)
+
+ var body: some View {
+ if let image = imageStr,
+ let data = Data(base64Encoded: dropImagePrefix(image)),
+ let uiImage = UIImage(data: data) {
+ Image(uiImage: uiImage)
+ .resizable()
+ .clipShape(Circle())
+ } else {
+ Image(systemName: iconName)
+ .resizable()
+ .foregroundColor(color)
+ }
+ }
+
+ func dropPrefix(_ s: String, _ prefix: String) -> String {
+ s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
+ }
+
+ func dropImagePrefix(_ s: String) -> String {
+ dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,")
+ }
+}
+
+struct ProfileImage_Previews: PreviewProvider {
+ static var previews: some View {
+ ProfileImage(imageStr: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAKHGlDQ1BJQ0MgUHJvZmlsZQAASImFVgdUVNcWve9Nb7QZeu9NehtAem/Sq6gMQ28OQxWxgAQjEFFEREARNFQFg1KjiIhiIQgoYA9IEFBisCAq6OQNJNH4//r/zDpz9ttzz7n73ffWmg0A6QCDxYqD+QCIT0hmezlYywQEBsngngEYCAIy0AC6DGYSy8rDwxUg8Xf9d7wbAxC33tHgzvrP3/9nCISFJzEBgIIRTGey2MkILkawT1oyi4tnEUxjI6IQvMLFkauYqxjQQtewwuoaHy8bBNMBwJMZDHYkAERbhJdJZUYic4hhCNZOCItOQDB3vjkzioFwxLsIXhcRl5IOAImrRzs+fivCk7QRrIL0shAcwNUW+tX8yH/tFfrPXgxG5D84Pi6F+dc9ck+HHJ7g641UMSQlQATQBHEgBaQDGcACbLAVYaIRJhx5Dv+9j77aZ4OsZIFtSEc0iARRIBnpt/9qlvfqpGSQBhjImnCEcUU+NtxnujZy4fbqVEiU/wuXdQyA9S0cDqfzC+e2F4DzyLkSB79wyi0A8KoBcL2GmcJOXePQ3C8MIAJeQAOiQArIAxXuWwMMgSmwBHbAGbgDHxAINgMmojceUZUGMkEWyAX54AA4DMpAJTgJ6sAZ0ALawQVwGVwDt8AQGAUPwQSYBi/AAngHliEIwkEUiAqJQtKQIqQO6UJ0yByyg1whLygQCoEioQQoBcqE9kD5UBFUBlVB9dBPUCd0GboBDUP3oUloDnoNfYRRMBmmwZKwEqwF02Er2AX2gTfBkXAinAHnwPvhUrgaPg23wZfhW/AoPAG/gBdRAEVCCaFkURooOsoG5Y4KQkWg2KidqDxUCaoa1YTqQvWj7qAmUPOoD2gsmoqWQWugTdGOaF80E52I3okuQJeh69Bt6D70HfQkegH9GUPBSGDUMSYYJ0wAJhKThsnFlGBqMK2Yq5hRzDTmHRaLFcIqY42wjthAbAx2O7YAewzbjO3BDmOnsIs4HE4Up44zw7njGLhkXC7uKO407hJuBDeNe48n4aXxunh7fBA+AZ+NL8E34LvxI/gZ/DKBj6BIMCG4E8II2wiFhFOELsJtwjRhmchPVCaaEX2IMcQsYimxiXiV+Ij4hkQiyZGMSZ6kaNJuUinpLOk6aZL0gSxAViPbkIPJKeT95FpyD/k++Q2FQlGiWFKCKMmU/ZR6yhXKE8p7HiqPJo8TTxjPLp5ynjaeEZ6XvAReRV4r3s28GbwlvOd4b/PO8xH4lPhs+Bh8O/nK+Tr5xvkW+an8Ovzu/PH8BfwN/Df4ZwVwAkoCdgJhAjkCJwWuCExRUVR5qg2VSd1DPUW9Sp2mYWnKNCdaDC2fdoY2SFsQFBDUF/QTTBcsF7woOCGEElISchKKEyoUahEaE/ooLClsJRwuvE+4SXhEeElEXMRSJFwkT6RZZFTko6iMqJ1orOhB0XbRx2JoMTUxT7E0seNiV8XmxWnipuJM8TzxFvEHErCEmoSXxHaJkxIDEouSUpIOkizJo5JXJOelhKQspWKkiqW6peakqdLm0tHSxdKXpJ/LCMpYycTJlMr0ySzISsg6yqbIVskOyi7LKcv5ymXLNcs9lifK0+Uj5Ivle+UXFKQV3BQyFRoVHigSFOmKUYpHFPsVl5SUlfyV9iq1K80qiyg7KWcoNyo/UqGoWKgkqlSr3FXFqtJVY1WPqQ6pwWoGalFq5Wq31WF1Q/Vo9WPqw+sw64zXJayrXjeuQdaw0kjVaNSY1BTSdNXM1mzXfKmloBWkdVCrX+uztoF2nPYp7Yc6AjrOOtk6XTqvddV0mbrlunf1KHr2erv0OvRe6avrh+sf179nQDVwM9hr0GvwydDIkG3YZDhnpGAUYlRhNE6n0T3oBfTrxhhja+NdxheMP5gYmiSbtJj8YaphGmvaYDq7Xnl9+PpT66fM5MwYZlVmE+Yy5iHmJ8wnLGQtGBbVFk8t5S3DLGssZ6xUrWKsTlu9tNa2Zlu3Wi/ZmNjssOmxRdk62ObZDtoJ2Pnaldk9sZezj7RvtF9wMHDY7tDjiHF0cTzoOO4k6cR0qndacDZy3uHc50J28XYpc3nqqubKdu1yg92c3Q65PdqguCFhQ7s7cHdyP+T+2EPZI9HjZ0+sp4dnueczLx2vTK9+b6r3Fu8G73c+1j6FPg99VXxTfHv9eP2C/er9lvxt/Yv8JwK0AnYE3AoUC4wO7AjCBfkF1QQtbrTbeHjjdLBBcG7w2CblTembbmwW2xy3+eIW3i2MLedCMCH+IQ0hKwx3RjVjMdQptCJ0gWnDPMJ8EWYZVhw2F24WXhQ+E2EWURQxG2kWeShyLsoiqiRqPtomuiz6VYxjTGXMUqx7bG0sJ84/rjkeHx8S35kgkBCb0LdVamv61mGWOiuXNZFokng4cYHtwq5JgpI2JXUk05A/0oEUlZTvUiZTzVPLU9+n+aWdS+dPT0gf2Ka2bd+2mQz7jB+3o7czt/dmymZmZU7usNpRtRPaGbqzd5f8rpxd07sddtdlEbNis37J1s4uyn67x39PV45kzu6cqe8cvmvM5cll547vNd1b+T36++jvB/fp7Tu673NeWN7NfO38kvyVAmbBzR90fij9gbM/Yv9goWHh8QPYAwkHxg5aHKwr4i/KKJo65HaorVimOK/47eEth2+U6JdUHiEeSTkyUepa2nFU4eiBoytlUWWj5dblzRUSFfsqlo6FHRs5bnm8qVKyMr/y44noE/eqHKraqpWqS05iT6aefHbK71T/j/Qf62vEavJrPtUm1E7UedX11RvV1zdINBQ2wo0pjXOng08PnbE909Gk0VTVLNScfxacTTn7/KeQn8ZaXFp6z9HPNZ1XPF/RSm3Na4PatrUttEe1T3QEdgx3Onf2dpl2tf6s+XPtBdkL5RcFLxZ2E7tzujmXMi4t9rB65i9HXp7q3dL78ErAlbt9nn2DV12uXr9mf+1Kv1X/petm1y/cMLnReZN+s/2W4a22AYOB1l8MfmkdNBxsu210u2PIeKhreP1w94jFyOU7tneu3XW6e2t0w+jwmO/YvfHg8Yl7Yfdm78fdf/Ug9cHyw92PMI/yHvM9Lnki8aT6V9VfmycMJy5O2k4OPPV++nCKOfXit6TfVqZznlGelcxIz9TP6s5emLOfG3q+8fn0C9aL5fnc3/l/r3ip8vL8H5Z/DCwELEy/Yr/ivC54I/qm9q3+295Fj8Un7+LfLS/lvRd9X/eB/qH/o//HmeW0FdxK6SfVT12fXT4/4sRzOCwGm7FqBVBIwhERALyuBYASCAB1CPEPG9f8119+BvrK2fyNwVndL5jhvubRVsMQgCakeCFp04OsQ1LJEgAe5NodqT6WANbT+yf/iqQIPd21PXgaAcDJcjivtwJAQHLFgcNZ9uBwPlUgYhHf1z37f7V9g9e8ITewiP88wfWIYET6HPg21nzjV2fybQVcxfrg2/onng/F50lD/ccAAAA4ZVhJZk1NACoAAAAIAAGHaQAEAAAAAQAAABoAAAAAAAKgAgAEAAAAAQAAABigAwAEAAAAAQAAABgAAAAAwf1XlwAAAaNJREFUSA3FlT1LA0EQQBN/gYUYRTksJZVgEbCR/D+7QMr8ABtttBBCsLGzsLG2sxaxED/ie4d77u0dyaE5HHjczn7MzO7M7nU6/yXz+bwLhzCCjTQO+rZhDH3opuNLdRYN4RHe4RIKJ7R34Ro+4AEGSw2mE1iUwT18gpI74WvkGlccu4XNdH0jnYU7cAUacidn37qR23cOxc4aGU0nYUAn7iSWEHkz46w0ocdQu1X6B/AMQZ5o7KfBqNOfwRH8JB7FajGhnmcpKvQe3MEbvILiDm5gPXaCHnZr4vvFGMoEKudKn8YvQIOOe+YzCPop7dwJ3zRfJ7GDuso4YJGRa0yZgg4tUaNXdGrbuZWKKxzYYEJc2xp9AUUjGt8KC2jvgYadF8+10vJyDnNLXwbdiWUZi0fUK01Eoc+AZhCLZVzK4Vq6sDUdz+0dEcbbTTIOJmAyTVhx/WmvrExbv2jtPhWLKodjCtefZiEeZeVZWWSndgwj6fVf3XON8Qwq15++uoqrfYVrow6dGBpCq79ME291jaB0/Q2CPncyht/99MNO/vr9AqW/CGi8sJqbAAAAAElFTkSuQmCC")
+ .previewLayout(.fixed(width: 63, height: 63))
+ .background(.black)
+ }
+}
diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
index f16c8cf5a5..feb7ec85fc 100644
--- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift
+++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
@@ -30,15 +30,18 @@ struct SettingsView: View {
.navigationTitle("Your chat profile")
} label: {
HStack {
- Image(systemName: "person.crop.circle")
- .padding(.trailing, 8)
+ ProfileImage(imageStr: user.image)
+ .frame(width: 44, height: 44)
+ .padding(.trailing, 6)
+ .padding(.vertical, 6)
VStack(alignment: .leading) {
- Text(user.profile.displayName)
+ Text(user.displayName)
.fontWeight(.bold)
.font(.title2)
- Text(user.profile.fullName)
+ Text(user.fullName)
}
}
+ .padding(.leading, -8)
}
NavigationLink {
UserAddress()
diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift
index 79b33d03bc..7e92301383 100644
--- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift
+++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift
@@ -11,7 +11,11 @@ import SwiftUI
struct UserProfile: View {
@EnvironmentObject var chatModel: ChatModel
@State private var profile = Profile(displayName: "", fullName: "")
- @State private var editProfile: Bool = false
+ @State private var editProfile = false
+ @State private var showChooseSource = false
+ @State private var showImagePicker = false
+ @State private var imageSource: UIImagePickerController.SourceType = .photoLibrary
+ @State private var pickedImage: UIImage? = nil
var body: some View {
let user: User = chatModel.currentUser!
@@ -19,16 +23,30 @@ struct UserProfile: View {
return VStack(alignment: .leading) {
Text("Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile.")
.padding(.bottom)
+
if editProfile {
+ ZStack(alignment: .center) {
+ ZStack(alignment: .topTrailing) {
+ profileImageView(profile.image)
+ if user.image != nil {
+ Button {
+ profile.image = nil
+ } label: {
+ Image(systemName: "multiply")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 12)
+ }
+ }
+ }
+
+ editImageButton { showChooseSource = true }
+ }
+ .frame(maxWidth: .infinity, alignment: .center)
+
VStack(alignment: .leading) {
- TextField("Display name", text: $profile.displayName)
- .textInputAutocapitalization(.never)
- .disableAutocorrection(true)
- .padding(.bottom)
- TextField("Full name (optional)", text: $profile.fullName)
- .textInputAutocapitalization(.never)
- .disableAutocorrection(true)
- .padding(.bottom)
+ profileNameTextEdit("Display name", $profile.displayName)
+ profileNameTextEdit("Full name (optional)", $profile.fullName)
HStack(spacing: 20) {
Button("Cancel") { editProfile = false }
Button("Save (and notify contacts)") { saveProfile() }
@@ -36,19 +54,19 @@ struct UserProfile: View {
}
.frame(maxWidth: .infinity, minHeight: 120, alignment: .leading)
} else {
+ ZStack(alignment: .center) {
+ profileImageView(user.image)
+ .onTapGesture { startEditingImage(user) }
+
+ if user.image == nil {
+ editImageButton { startEditingImage(user) }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .center)
+
VStack(alignment: .leading) {
- HStack {
- Text("Display name:")
- Text(user.profile.displayName)
- .fontWeight(.bold)
- }
- .padding(.bottom)
- HStack {
- Text("Full name:")
- Text(user.profile.fullName)
- .fontWeight(.bold)
- }
- .padding(.bottom)
+ profileNameView("Display name:", user.profile.displayName)
+ profileNameView("Full name:", user.profile.fullName)
Button("Edit") {
profile = user.profile
editProfile = true
@@ -59,6 +77,70 @@ struct UserProfile: View {
}
.padding()
.frame(maxHeight: .infinity, alignment: .top)
+ .confirmationDialog("Profile image", isPresented: $showChooseSource, titleVisibility: .visible) {
+ Button("Take picture") {
+ imageSource = .camera
+ showImagePicker = true
+ }
+ Button("Choose from library") {
+ imageSource = .photoLibrary
+ showImagePicker = true
+ }
+ }
+ .sheet(isPresented: $showImagePicker) {
+ ImagePicker(source: imageSource, image: $pickedImage)
+ }
+ .onChange(of: pickedImage) { image in
+ if let image = image,
+ let data = resizeToSquare(image, 104).jpegData(compressionQuality: 0.85) {
+ let imageStr = "data:image/jpg;base64,\(data.base64EncodedString())"
+ if imageStr.count <= 12500 {
+ profile.image = imageStr
+ } else {
+ logger.error("UserProfile: resized image is too big \(imageStr.count)")
+ }
+ } else {
+ profile.image = nil
+ }
+ }
+ }
+
+ func profileNameTextEdit(_ label: String, _ name: Binding) -> some View {
+ TextField(label, text: name)
+ .textInputAutocapitalization(.never)
+ .disableAutocorrection(true)
+ .padding(.bottom)
+ }
+
+ func profileNameView(_ label: String, _ name: String) -> some View {
+ HStack {
+ Text(label)
+ Text(name).fontWeight(.bold)
+ }
+ .padding(.bottom)
+ }
+
+ func profileImageView(_ imageStr: String?) -> some View {
+ ProfileImage(imageStr: imageStr)
+ .aspectRatio(1, contentMode: .fit)
+ .frame(maxWidth: 192, maxHeight: 192)
+ }
+
+ func editImageButton(action: @escaping () -> Void) -> some View {
+ Button {
+ action()
+ } label: {
+ Image(systemName: "camera")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 48)
+ }
+ }
+
+ func startEditingImage(_ user: User) {
+ profile = user.profile
+ editProfile = true
+ showChooseSource = true
}
func saveProfile() {
@@ -78,11 +160,42 @@ struct UserProfile: View {
}
}
-struct UserProfile_Previews: PreviewProvider {
- static var previews: some View {
- let chatModel = ChatModel()
- chatModel.currentUser = User.sampleData
- return UserProfile()
- .environmentObject(chatModel)
+func resize(_ image: UIImage, to newSize: CGSize) -> UIImage {
+ let format = UIGraphicsImageRendererFormat()
+ format.scale = 1.0
+ format.opaque = true
+ return UIGraphicsImageRenderer(bounds: CGRect(origin: .zero, size: newSize), format: format).image { _ in
+ let size = image.size
+ let hScale = newSize.height / size.height
+ let vScale = newSize.width / size.width
+ let scale = max(hScale, vScale) // scaleToFill
+ let resizeSize = CGSize(width: size.width * scale, height: size.height * scale)
+ var middle = CGPoint.zero
+ if resizeSize.width > newSize.width {
+ middle.x -= (resizeSize.width - newSize.width) / 2
+ } else if resizeSize.height > newSize.height {
+ middle.y -= (resizeSize.height - newSize.height) / 2
+ }
+ image.draw(in: CGRect(origin: middle, size: resizeSize))
+ }
+}
+
+func resizeToSquare(_ image: UIImage, _ side: CGFloat) -> UIImage {
+ resize(image, to: CGSize(width: side, height: side))
+}
+
+struct UserProfile_Previews: PreviewProvider {
+ static var previews: some View {
+ let chatModel1 = ChatModel()
+ chatModel1.currentUser = User.sampleData
+ let chatModel2 = ChatModel()
+ chatModel2.currentUser = User.sampleData
+ chatModel2.currentUser?.profile.image = "data:image/jpg;base64,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"
+ return Group {
+ UserProfile()
+ .environmentObject(chatModel1)
+ UserProfile()
+ .environmentObject(chatModel2)
+ }
}
}
diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj
index fe75f1995a..e4598f1f1d 100644
--- a/apps/ios/SimpleX.xcodeproj/project.pbxproj
+++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj
@@ -43,6 +43,10 @@
5C5346A927B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; };
5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; };
5C577F7E27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; };
+ 5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */; };
+ 5C5F2B6E27EBC3FE006A9D5F /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */; };
+ 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */; };
+ 5C5F2B7127EBC704006A9D5F /* ProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */; };
5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; };
5C6AD81427A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; };
5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; };
@@ -151,6 +155,8 @@
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; };
5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = ""; };
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = ""; };
+ 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; };
+ 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileImage.swift; sourceTree = ""; };
5C6AD81227A834E300348BD7 /* NewChatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton.swift; sourceTree = ""; };
5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = ""; };
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = ""; };
@@ -310,6 +316,8 @@
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */,
5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */,
5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */,
+ 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */,
+ 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */,
);
path = Helpers;
sourceTree = "";
@@ -634,6 +642,7 @@
5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */,
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */,
5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */,
+ 5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */,
5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */,
5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */,
5CCD403727A5F9A200368C90 /* ConnectContactView.swift in Sources */,
@@ -644,6 +653,7 @@
5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */,
5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */,
5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */,
+ 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */,
5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */,
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */,
);
@@ -683,6 +693,7 @@
5C2E261027A30FDC00F70299 /* ChatView.swift in Sources */,
5C2E260C27A30CFA00F70299 /* ChatListView.swift in Sources */,
5C971E2227AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */,
+ 5C5F2B6E27EBC3FE006A9D5F /* ImagePicker.swift in Sources */,
5C577F7E27C83AA10006112D /* MarkdownHelp.swift in Sources */,
5CA059EC279559F40002BEB4 /* SimpleXApp.swift in Sources */,
5CCD403827A5F9A200368C90 /* ConnectContactView.swift in Sources */,
@@ -693,6 +704,7 @@
5CC1C99627A6CF7F000D9FF6 /* ShareSheet.swift in Sources */,
5C2E260827A2941F00F70299 /* SimpleXAPI.swift in Sources */,
5CB924D527A853F100ACCCDD /* SettingsButton.swift in Sources */,
+ 5C5F2B7127EBC704006A9D5F /* ProfileImage.swift in Sources */,
5CE4407327ADB1D0007B033A /* Emoji.swift in Sources */,
5C1A4C1F27A715B700EAD5AD /* ChatItemView.swift in Sources */,
);