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,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") + .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 */, );