Merge branch 'dla/feature/room_list_decoration' of https://github.com/vector-im/element-x-android into dla/feature/room_list_decoration

This commit is contained in:
David Langley 2023-09-18 15:05:50 +01:00
commit c160a37b2c
140 changed files with 2173 additions and 340 deletions

View file

@ -1,4 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_prompt_data_usage">"Wir zeichnen keine persönlichen Daten auf und erstellen keine Profile."</string>
<string name="screen_analytics_prompt_help_us_improve">"Teilen Sie anonyme Nutzungsdaten, um uns bei der Identifizierung von Problemen zu helfen."</string>
<string name="screen_analytics_prompt_read_terms">"Sie können alle unsere Bedingungen lesen%1$s."</string>
<string name="screen_analytics_prompt_read_terms_content_link">"hier"</string>
<string name="screen_analytics_prompt_settings">"Sie können diese Funktion jederzeit deaktivieren"</string>
<string name="screen_analytics_prompt_third_party_sharing">"Wir geben Ihre Daten nicht an Dritte weiter"</string>
<string name="screen_analytics_prompt_title">"Hilf uns %1$s zu verbessern"</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Laufender Anruf"</string>
<string name="call_foreground_service_message_android">"Tippen, um zum Anruf zurückzukehren"</string>
<string name="call_foreground_service_title_android">"☎️ Anruf läuft"</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Prebiehajúci hovor"</string>
<string name="call_foreground_service_message_android">"Ťuknutím sa vrátite k hovoru"</string>
<string name="call_foreground_service_title_android">"☎️ Prebieha hovor"</string>
</resources>

View file

@ -1,4 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Neuer Raum"</string>
<string name="screen_create_room_action_invite_people">"Freunde zu Element einladen"</string>
<string name="screen_create_room_add_people_title">"Personen einladen"</string>
<string name="screen_create_room_error_creating_room">"Beim Erstellen des Raums ist ein Fehler aufgetreten"</string>
<string name="screen_create_room_private_option_description">"Die Nachrichten in diesem Raum sind verschlüsselt. Die Verschlüsselung kann nicht nachträglich deaktiviert werden."</string>
<string name="screen_create_room_private_option_title">"Privater Raum (nur auf Einladung)"</string>
<string name="screen_create_room_public_option_description">"Die Nachrichten sind nicht verschlüsselt und können von jedem gelesen werden. Die Verschlüsselung kann zu einem späteren Zeitpunkt aktiviert werden."</string>
<string name="screen_create_room_public_option_title">"Öffentlicher Raum (für alle)"</string>
<string name="screen_create_room_room_name_label">"Raumname"</string>
<string name="screen_create_room_topic_label">"Thema (optional)"</string>
<string name="screen_start_chat_error_starting_chat">"Beim Versuch, einen Chat zu starten, ist ein Fehler aufgetreten"</string>
<string name="screen_create_room_title">"Raum erstellen"</string>
</resources>

View file

@ -1,5 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_notification_optin_subtitle">"Du kannst deine Einstellungen später ändern."</string>
<string name="screen_migration_message">"Dies ist ein einmaliger Vorgang, danke fürs Warten."</string>
<string name="screen_migration_title">"Richten Sie Ihr Konto ein."</string>
<string name="screen_notification_optin_subtitle">"Sie können Ihre Einstellungen später ändern."</string>
<string name="screen_notification_optin_title">"Erlaube Benachrichtigungen und verpasse keine Nachricht"</string>
<string name="screen_welcome_bullet_1">"Anrufe, Umfragen, Suchfunktionen und mehr werden im Laufe des Jahres hinzugefügt."</string>
<string name="screen_welcome_bullet_2">"Der Nachrichtenverlauf für verschlüsselte Räume wird in diesem Update nicht verfügbar sein."</string>
<string name="screen_welcome_bullet_3">"Wir würden uns freuen, von Ihnen zu hören. Teilen Sie uns Ihre Meinung über die Einstellungsseite mit."</string>
<string name="screen_welcome_button">"Los geht\'s!"</string>
<string name="screen_welcome_subtitle">"Folgendes müssen Sie wissen:"</string>
<string name="screen_welcome_title">"Willkommen bei %1$s!"</string>
</resources>

View file

@ -141,7 +141,7 @@ class InviteListPresenter @Inject constructor(
suspend {
client.getRoom(roomId)?.use {
it.join().getOrThrow()
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId)
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true)
analyticsService.capture(it.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite))
}
roomId
@ -152,7 +152,7 @@ class InviteListPresenter @Inject constructor(
suspend {
client.getRoom(roomId)?.use {
it.leave().getOrThrow()
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId)
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true)
}.let { }
}.runCatchingUpdatingState(declinedAction)
}

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_decline_chat_message">"Möchten Sie die Einladung zum Betreten von %1$s wirklich ablehnen?"</string>
<string name="screen_invites_decline_chat_title">"Einladung ablehnen"</string>
<string name="screen_invites_decline_direct_chat_message">"Sind Sie sicher, dass Sie diesen privaten Chat mit %1$s ablehnen möchten?"</string>
<string name="screen_invites_decline_direct_chat_title">"Chat ablehnen"</string>
<string name="screen_invites_empty_list">"Keine Einladungen"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) hat dich eingeladen"</string>
</resources>

View file

@ -1,8 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_account_provider_change">"Kontoanbieter ändern"</string>
<string name="screen_account_provider_form_hint">"Homeserver-Adresse"</string>
<string name="screen_account_provider_form_notice">"Geben Sie einen Suchbegriff oder eine Domainadresse ein."</string>
<string name="screen_account_provider_form_subtitle">"Suchen Sie nach einem Unternehmen, einer Community oder einem privaten Server."</string>
<string name="screen_account_provider_form_title">"Kontoanbieter finden"</string>
<string name="screen_account_provider_signin_subtitle">"Hier werden Ihre Gespräche gespeichert genau so, wie Sie einen E-Mail-Anbieter nutzen würden, um Ihre E-Mails aufzubewahren."</string>
<string name="screen_account_provider_signin_title">"Sie sind dabei, sich bei %s anzumelden"</string>
<string name="screen_account_provider_signup_subtitle">"Hier werden Ihre Gespräche gespeichert genau so, wie Sie einen E-Mail-Anbieter nutzen würden, um Ihre E-Mails aufzubewahren."</string>
<string name="screen_account_provider_signup_title">"Sie sind dabei, ein Konto bei %s zu erstellen"</string>
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org ist ein großer, kostenloser Server im öffentlichen Matrix-Netzwerk für eine sichere, dezentralisierte Kommunikation, der von der Matrix.org Foundation betrieben wird."</string>
<string name="screen_change_account_provider_other">"Sonstige"</string>
<string name="screen_change_account_provider_subtitle">"Verwenden Sie einen anderen Kontoanbieter, z. B. Ihren eigenen privaten Server oder ein Geschäftskonto."</string>
<string name="screen_change_account_provider_title">"Kontoanbieter wechseln"</string>
<string name="screen_change_server_error_invalid_homeserver">"Wir konnten diesen Homeserver nicht erreichen. Bitte überprüfen Sie, ob Sie die Homeserver-URL korrekt eingegeben haben. Wenn die URL korrekt ist, wenden Sie sich an Ihren Homeserver-Administrator, um weitere Hilfe zu erhalten."</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Dieser Server unterstützt derzeit kein Sliding Sync."</string>
<string name="screen_change_server_form_header">"Homeserver-URL"</string>
<string name="screen_change_server_form_notice">"Sie können nur eine Verbindung zu einem vorhandenen Server herstellen, der Sliding Sync unterstützt. Ihr Homeserver-Administrator muss das konfigurieren. %1$s"</string>
<string name="screen_change_server_subtitle">"Wie lautet die Adresse Ihres Servers?"</string>
<string name="screen_login_error_deactivated_account">"Dieses Konto wurde deaktiviert."</string>
<string name="screen_login_error_invalid_credentials">"Falscher Benutzername und/oder Passwort"</string>
<string name="screen_login_error_invalid_user_id">"Dies ist keine gültige Benutzerkennung. Erwartetes Format: \'@user:homeserver.org\'"</string>
<string name="screen_login_error_unsupported_authentication">"Der ausgewählte Homeserver unterstützt weder den Login per Passwort noch per OIDC. Bitte kontaktieren Sie Ihren Admin oder wählen Sie einen anderen Homeserver."</string>
<string name="screen_login_form_header">"Geben Sie Ihre Daten ein"</string>
<string name="screen_login_title">"Willkommen zurück!"</string>
<string name="screen_login_title_with_homeserver">"Anmelden bei %1$s"</string>
<string name="screen_server_confirmation_change_server">"Kontoanbieter wechseln"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Ein privater Server für die Mitarbeiter von Element."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix ist ein offenes Netzwerk für eine sichere, dezentrale Kommunikation."</string>
<string name="screen_server_confirmation_message_register">"Hier werden Ihre Gespräche gespeichert - so wie Sie Ihre E-Mails bei einem E-Mail-Anbieter aufbewahren würden."</string>
<string name="screen_server_confirmation_title_login">"Sie sind dabei, sich bei %1$s anzumelden"</string>
<string name="screen_server_confirmation_title_register">"Sie sind dabei, ein Konto auf %1$s zu erstellen"</string>
<string name="screen_waitlist_message">"Derzeit besteht eine hohe Nachfrage nach %1$s auf %2$s. Kehren Sie in ein paar Tagen zur App zurück und versuchen Sie es erneut.
Danke für Ihre Geduld!"</string>
<string name="screen_waitlist_message_success">"Willkommen bei %1$s!"</string>
<string name="screen_waitlist_title">"Sie sind fast am Ziel."</string>
<string name="screen_waitlist_title_success">"Sie sind dabei."</string>
<string name="screen_account_provider_continue">"Weiter"</string>
<string name="screen_change_server_submit">"Weiter"</string>
<string name="screen_change_server_title">"Wählen Sie Ihren Server aus"</string>
<string name="screen_login_password_hint">"Passwort"</string>
<string name="screen_login_submit">"Weiter"</string>
<string name="screen_login_subtitle">"Matrix ist ein offenes Netzwerk für eine sichere, dezentrale Kommunikation."</string>
<string name="screen_login_username_hint">"Benutzername"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Sind Sie sicher, dass Sie sich abmelden wollen?"</string>
<string name="screen_signout_confirmation_dialog_title">"Abmelden"</string>
<string name="screen_signout_in_progress_dialog_content">"Abmelden…"</string>
<string name="screen_signout_confirmation_dialog_submit">"Abmelden"</string>
<string name="screen_signout_preference_item">"Abmelden"</string>
</resources>

View file

@ -1,5 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="room_timeline_state_changes">
<item quantity="one">"%1$d Raumänderung"</item>
<item quantity="other">"%1$d Raumänderungen"</item>
</plurals>
<string name="screen_room_attachment_source_camera">"Kamera"</string>
<string name="screen_room_attachment_source_camera_photo">"Foto machen"</string>
<string name="screen_room_attachment_source_camera_video">"Video aufnehmen"</string>
<string name="screen_room_attachment_source_files">"Anhang"</string>
<string name="screen_room_attachment_source_gallery">"Foto- und Videobibliothek"</string>
<string name="screen_room_attachment_source_location">"Standort"</string>
<string name="screen_room_attachment_source_poll">"Umfrage"</string>
<string name="screen_room_attachment_text_formatting">"Textformatierung"</string>
<string name="screen_room_encrypted_history_banner">"Der Nachrichtenverlauf ist derzeit in diesem Raum nicht verfügbar"</string>
<string name="screen_room_error_failed_retrieving_user_details">"Benutzerdetails konnten nicht abgerufen werden"</string>
<string name="screen_room_invite_again_alert_message">"Möchten Sie sie wieder einladen?"</string>
<string name="screen_room_invite_again_alert_title">"Sie sind allein in diesem Chat"</string>
<string name="screen_room_message_copied">"Nachricht wurde kopiert"</string>
<string name="screen_room_no_permission_to_post">"Sie sind nicht berechtigt, in diesem Raum zu posten"</string>
<string name="screen_room_notification_settings_allow_custom">"Benutzerdefinierte Einstellung zulassen"</string>
<string name="screen_room_notification_settings_allow_custom_footnote">"Wenn Sie diese Option aktivieren, wird Ihre Standardeinstellung außer Kraft gesetzt."</string>
<string name="screen_room_notification_settings_custom_settings_title">"Benachrichtigen Sie mich in diesem Chat bei"</string>
<string name="screen_room_notification_settings_default_setting_footnote">"Sie können das in Ihrem %1$s ändern."</string>
<string name="screen_room_notification_settings_default_setting_footnote_content_link">"Globale Einstellungen"</string>
<string name="screen_room_notification_settings_default_setting_title">"Standardeinstellung"</string>
<string name="screen_room_notification_settings_edit_remove_setting">"Benutzerdefinierte Einstellung entfernen"</string>
<string name="screen_room_notification_settings_error_loading_settings">"Beim Laden der Benachrichtigungseinstellungen ist ein Fehler aufgetreten."</string>
<string name="screen_room_notification_settings_error_restoring_default">"Fehler beim Wiederherstellen des Standardmodus. Bitte versuchen Sie es erneut."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Fehler beim Einstellen des Modus. Bitte versuchen Sie es erneut."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Alle Nachrichten"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Nur Erwähnungen und Schlüsselwörter"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"Benachrichtigen Sie mich in diesem Raum bei"</string>
<string name="screen_room_reactions_show_less">"Weniger anzeigen"</string>
<string name="screen_room_reactions_show_more">"Mehr anzeigen"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Erneut senden"</string>
<string name="screen_room_retry_send_menu_title">"Ihre Nachricht konnte nicht gesendet werden"</string>
<string name="screen_room_timeline_add_reaction">"Emoji hinzufügen"</string>
<string name="screen_room_timeline_less_reactions">"Weniger anzeigen"</string>
<string name="screen_room_error_failed_processing_media">"Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuchen Sie es erneut."</string>
<string name="screen_room_retry_send_menu_remove_action">"Entfernen"</string>
</resources>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_onboarding_sign_in_manually">"Manuell anmelden"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Mit QR-Code anmelden"</string>
<string name="screen_onboarding_sign_up">"Konto erstellen"</string>
<string name="screen_onboarding_subtitle">"Sicher kommunizieren und zusammenarbeiten"</string>
<string name="screen_onboarding_welcome_message">"Willkommen beim schnellsten Element aller Zeiten. Optimiert für Geschwindigkeit und Einfachheit."</string>
<string name="screen_onboarding_welcome_subtitle">"Willkommen zu %1$s. Aufgeladen, für Geschwindigkeit und Einfachheit."</string>
<string name="screen_onboarding_welcome_title">"Seien Sie in Ihrem Element"</string>
</resources>

View file

@ -21,24 +21,21 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconToggleButton
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor
@ -47,41 +44,33 @@ import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonPlurals
@Composable
fun PollAnswerView(
internal fun PollAnswerView(
answerItem: PollAnswerItem,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier
.fillMaxWidth()
.selectable(
selected = answerItem.isSelected,
enabled = answerItem.isEnabled,
onClick = onClick,
role = Role.RadioButton,
)
modifier = modifier.fillMaxWidth(),
) {
IconToggleButton(
modifier = Modifier.size(22.dp),
checked = answerItem.isSelected,
enabled = answerItem.isEnabled,
colors = IconButtonDefaults.iconToggleButtonColors(
contentColor = ElementTheme.colors.iconSecondary,
checkedContentColor = ElementTheme.colors.iconPrimary,
disabledContentColor = ElementTheme.colors.iconDisabled,
),
onCheckedChange = { onClick() },
) {
Icon(
imageVector = if (answerItem.isSelected) {
Icons.Default.CheckCircle
Icon(
imageVector = if (answerItem.isSelected) {
Icons.Default.CheckCircle
} else {
Icons.Default.RadioButtonUnchecked
},
contentDescription = null,
modifier = Modifier
.padding(0.5.dp)
.size(22.dp),
tint = if (answerItem.isEnabled) {
if (answerItem.isSelected) {
ElementTheme.colors.iconPrimary
} else {
Icons.Default.RadioButtonUnchecked
},
contentDescription = null,
)
}
ElementTheme.colors.iconSecondary
}
} else {
ElementTheme.colors.iconDisabled
},
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Row {
@ -119,65 +108,58 @@ fun PollAnswerView(
}
}
@Preview
@DayNightPreviews
@Composable
internal fun PollAnswerDisclosedNotSelectedPreview() = ElementThemedPreview {
internal fun PollAnswerDisclosedNotSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = false),
onClick = { },
)
}
@Preview
@DayNightPreviews
@Composable
internal fun PollAnswerDisclosedSelectedPreview() = ElementThemedPreview {
internal fun PollAnswerDisclosedSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = true),
onClick = { }
)
}
@Preview
@DayNightPreviews
@Composable
internal fun PollAnswerUndisclosedNotSelectedPreview() = ElementThemedPreview {
internal fun PollAnswerUndisclosedNotSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = false, isSelected = false),
onClick = { },
)
}
@Preview
@DayNightPreviews
@Composable
internal fun PollAnswerUndisclosedSelectedPreview() = ElementThemedPreview {
internal fun PollAnswerUndisclosedSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = false, isSelected = true),
onClick = { }
)
}
@Preview
@DayNightPreviews
@Composable
internal fun PollAnswerEndedWinnerNotSelectedPreview() = ElementThemedPreview {
internal fun PollAnswerEndedWinnerNotSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = false, isEnabled = false, isWinner = true),
onClick = { }
)
}
@Preview
@DayNightPreviews
@Composable
internal fun PollAnswerEndedWinnerSelectedPreview() = ElementThemedPreview {
internal fun PollAnswerEndedWinnerSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = true, isEnabled = false, isWinner = true),
onClick = { }
)
}
@Preview
@DayNightPreviews
@Composable
internal fun PollAnswerEndedSelectedPreview() = ElementThemedPreview {
internal fun PollAnswerEndedSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = true, isEnabled = false, isWinner = false),
onClick = { }
)
}

View file

@ -23,11 +23,14 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.VectorIcons
import io.element.android.libraries.designsystem.preview.DayNightPreviews
@ -56,24 +59,24 @@ fun PollContentView(
}
Column(
modifier = modifier
.selectableGroup()
.fillMaxWidth(),
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
PollTitle(title = question, isPollEnded = isPollEnded)
PollAnswers(answerItems = answerItems, onAnswerSelected = ::onAnswerSelected)
when {
isPollEnded || pollKind == PollKind.Disclosed -> DisclosedPollBottomNotice(answerItems)
pollKind == PollKind.Undisclosed -> UndisclosedPollBottomNotice()
if (isPollEnded || pollKind == PollKind.Disclosed) {
val votesCount = remember(answerItems) { answerItems.sumOf { it.votesCount } }
DisclosedPollBottomNotice(votesCount = votesCount)
} else {
UndisclosedPollBottomNotice()
}
}
}
@Composable
internal fun PollTitle(
private fun PollTitle(
title: String,
isPollEnded: Boolean,
modifier: Modifier = Modifier
@ -85,13 +88,13 @@ internal fun PollTitle(
if (isPollEnded) {
Icon(
resourceId = VectorIcons.PollEnd,
contentDescription = null,
contentDescription = stringResource(id = CommonStrings.a11y_poll_end),
modifier = Modifier.size(22.dp)
)
} else {
Icon(
resourceId = VectorIcons.Poll,
contentDescription = null,
contentDescription = stringResource(id = CommonStrings.a11y_poll),
modifier = Modifier.size(22.dp)
)
}
@ -103,27 +106,35 @@ internal fun PollTitle(
}
@Composable
internal fun PollAnswers(
private fun PollAnswers(
answerItems: ImmutableList<PollAnswerItem>,
onAnswerSelected: (PollAnswer) -> Unit,
modifier: Modifier = Modifier,
) {
answerItems.forEach { answerItem ->
PollAnswerView(
modifier = modifier,
answerItem = answerItem,
onClick = { onAnswerSelected(answerItem.answer) }
)
Column(
modifier = modifier.selectableGroup(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
answerItems.forEach {
PollAnswerView(
answerItem = it,
modifier = Modifier
.selectable(
selected = it.isSelected,
enabled = it.isEnabled,
onClick = { onAnswerSelected(it.answer) },
role = Role.RadioButton,
),
)
}
}
}
@Composable
internal fun ColumnScope.DisclosedPollBottomNotice(
answerItems: ImmutableList<PollAnswerItem>,
private fun ColumnScope.DisclosedPollBottomNotice(
votesCount: Int,
modifier: Modifier = Modifier
) {
val votesCount = answerItems.sumOf { it.votesCount }
Text(
modifier = modifier.align(Alignment.End),
style = ElementTheme.typography.fontBodyXsRegular,
@ -133,7 +144,9 @@ internal fun ColumnScope.DisclosedPollBottomNotice(
}
@Composable
fun ColumnScope.UndisclosedPollBottomNotice(modifier: Modifier = Modifier) {
private fun ColumnScope.UndisclosedPollBottomNotice(
modifier: Modifier = Modifier
) {
Text(
modifier = modifier
.align(Alignment.Start)

View file

@ -4,8 +4,9 @@
<string name="screen_create_poll_anonymous_desc">"Ergebnisse erst nach Ende der Umfrage anzeigen"</string>
<string name="screen_create_poll_anonymous_headline">"Anonyme Umfrage"</string>
<string name="screen_create_poll_answer_hint">"Option %1$d"</string>
<string name="screen_create_poll_discard_confirmation">"Bist du sicher, dass du diese Umfrage verwerfen willst?"</string>
<string name="screen_create_poll_discard_confirmation">"Sind Sie sicher, dass Sie diese Umfrage verwerfen wollen?"</string>
<string name="screen_create_poll_discard_confirmation_title">"Umfrage verwerfen"</string>
<string name="screen_create_poll_question_desc">"Frage oder Thema"</string>
<string name="screen_create_poll_question_hint">"Worum geht es bei der Umfrage?"</string>
<string name="screen_create_poll_title">"Umfrage erstellen"</string>
</resources>

View file

@ -44,10 +44,12 @@ dependencies {
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api)
implementation(projects.features.rageshake.api)
implementation(projects.features.analytics.api)
implementation(projects.features.ftue.api)
implementation(projects.libraries.matrixui)
implementation(projects.features.logout.api)
implementation(projects.services.analytics.api)
implementation(projects.services.toolbox.api)
@ -64,8 +66,11 @@ dependencies {
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.mockk)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.features.rageshake.test)

View file

@ -38,10 +38,12 @@ import io.element.android.features.preferences.impl.developer.tracing.ConfigureT
import io.element.android.features.preferences.impl.notifications.NotificationSettingsNode
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingNode
import io.element.android.features.preferences.impl.root.PreferencesRootNode
import io.element.android.features.preferences.impl.user.editprofile.EditUserProfileNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@ -81,6 +83,9 @@ class PreferencesFlowNode @AssistedInject constructor(
@Parcelize
data class EditDefaultNotificationSetting(val isOneToOne: Boolean) : NavTarget
@Parcelize
data class UserProfile(val matrixUser: MatrixUser) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -114,6 +119,10 @@ class PreferencesFlowNode @AssistedInject constructor(
override fun onOpenAdvancedSettings() {
backstack.push(NavTarget.AdvancedSettings)
}
override fun onOpenUserProfile(matrixUser: MatrixUser) {
backstack.push(NavTarget.UserProfile(matrixUser))
}
}
createNode<PreferencesRootNode>(buildContext, plugins = listOf(callback))
}
@ -149,6 +158,10 @@ class PreferencesFlowNode @AssistedInject constructor(
NavTarget.AdvancedSettings -> {
createNode<AdvancedSettingsNode>(buildContext)
}
is NavTarget.UserProfile -> {
val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser)
createNode<EditUserProfileNode>(buildContext, listOf(inputs))
}
}
}

View file

@ -29,6 +29,7 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.theme.ElementTheme
import timber.log.Timber
@ -47,6 +48,7 @@ class PreferencesRootNode @AssistedInject constructor(
fun onOpenDeveloperSettings()
fun onOpenNotificationSettings()
fun onOpenAdvancedSettings()
fun onOpenUserProfile(matrixUser: MatrixUser)
}
private fun onOpenBugReport() {
@ -91,6 +93,10 @@ class PreferencesRootNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onOpenNotificationSettings() }
}
private fun onOpenUserProfile(matrixUser: MatrixUser) {
plugins<Callback>().forEach { it.onOpenUserProfile(matrixUser) }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
@ -108,7 +114,8 @@ class PreferencesRootNode @AssistedInject constructor(
onOpenAdvancedSettings = this::onOpenAdvancedSettings,
onSuccessLogout = { onSuccessLogout(activity, it) },
onManageAccountClicked = { onManageAccountClicked(activity, it, isDark) },
onOpenNotificationSettings = this::onOpenNotificationSettings
onOpenNotificationSettings = this::onOpenNotificationSettings,
onOpenUserProfile = this::onOpenUserProfile,
)
}

View file

@ -16,6 +16,7 @@
package io.element.android.features.preferences.impl.root
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
@ -62,6 +63,7 @@ fun PreferencesRootView(
onOpenAdvancedSettings: () -> Unit,
onSuccessLogout: (logoutUrlResult: String?) -> Unit,
onOpenNotificationSettings: () -> Unit,
onOpenUserProfile: (MatrixUser) -> Unit,
modifier: Modifier = Modifier,
) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
@ -73,7 +75,12 @@ fun PreferencesRootView(
title = stringResource(id = CommonStrings.common_settings),
snackbarHost = { SnackbarHost(snackbarHostState) }
) {
UserPreferences(state.myUser)
UserPreferences(
modifier = Modifier.clickable {
state.myUser?.let(onOpenUserProfile)
},
user = state.myUser,
)
if (state.showCompleteVerification) {
PreferenceText(
title = stringResource(id = CommonStrings.action_complete_verification),
@ -181,5 +188,6 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
onSuccessLogout = {},
onManageAccountClicked = {},
onOpenNotificationSettings = {},
onOpenUserProfile = {},
)
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.user.editprofile
import io.element.android.libraries.matrix.ui.media.AvatarAction
sealed interface EditUserProfileEvents {
data class HandleAvatarAction(val action: AvatarAction) : EditUserProfileEvents
data class UpdateDisplayName(val name: String) : EditUserProfileEvents
data object Save : EditUserProfileEvents
data object CancelSaveChanges : EditUserProfileEvents
}

View file

@ -0,0 +1,56 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.user.editprofile
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.user.MatrixUser
@ContributesNode(SessionScope::class)
class EditUserProfileNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: EditUserProfilePresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val matrixUser: MatrixUser
) : NodeInputs
val matrixUser = inputs<Inputs>().matrixUser
val presenter = presenterFactory.create(matrixUser)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
EditUserProfileView(
state = state,
onBackPressed = ::navigateUp,
onProfileEdited = ::navigateUp,
modifier = modifier
)
}
}

View file

@ -0,0 +1,149 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.user.editprofile
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.core.net.toUri
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
class EditUserProfilePresenter @AssistedInject constructor(
@Assisted private val matrixUser: MatrixUser,
private val matrixClient: MatrixClient,
private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor,
) : Presenter<EditUserProfileState> {
@AssistedFactory
interface Factory {
fun create(matrixUser: MatrixUser): EditUserProfilePresenter
}
@Composable
override fun present(): EditUserProfileState {
var userAvatarUri by rememberSaveable { mutableStateOf(matrixUser.avatarUrl?.let { Uri.parse(it) }) }
var userDisplayName by rememberSaveable { mutableStateOf(matrixUser.displayName) }
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
onResult = { uri -> if (uri != null) userAvatarUri = uri }
)
val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
onResult = { uri -> if (uri != null) userAvatarUri = uri }
)
val avatarActions by remember(userAvatarUri) {
derivedStateOf {
listOfNotNull(
AvatarAction.TakePhoto,
AvatarAction.ChoosePhoto,
AvatarAction.Remove.takeIf { userAvatarUri != null },
).toImmutableList()
}
}
val saveAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
val localCoroutineScope = rememberCoroutineScope()
fun handleEvents(event: EditUserProfileEvents) {
when (event) {
is EditUserProfileEvents.Save -> localCoroutineScope.saveChanges(userDisplayName, userAvatarUri, matrixUser, saveAction)
is EditUserProfileEvents.HandleAvatarAction -> {
when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
AvatarAction.TakePhoto -> cameraPhotoPicker.launch()
AvatarAction.Remove -> userAvatarUri = null
}
}
is EditUserProfileEvents.UpdateDisplayName -> userDisplayName = event.name
EditUserProfileEvents.CancelSaveChanges -> saveAction.value = Async.Uninitialized
}
}
val canSave = remember(userDisplayName, userAvatarUri) {
val hasProfileChanged = hasDisplayNameChanged(userDisplayName, matrixUser) ||
hasAvatarUrlChanged(userAvatarUri, matrixUser)
!userDisplayName.isNullOrBlank() && hasProfileChanged
}
return EditUserProfileState(
userId = matrixUser.userId,
displayName = userDisplayName.orEmpty(),
userAvatarUrl = userAvatarUri,
avatarActions = avatarActions,
saveButtonEnabled = canSave && saveAction.value !is Async.Loading,
saveAction = saveAction.value,
eventSink = { handleEvents(it) },
)
}
private fun hasDisplayNameChanged(name: String?, currentUser: MatrixUser) =
name?.trim() != currentUser.displayName?.trim()
private fun hasAvatarUrlChanged(avatarUri: Uri?, currentUser: MatrixUser) =
// Need to call `toUri()?.toString()` to make the test pass (we mockk Uri)
avatarUri?.toString()?.trim() != currentUser.avatarUrl?.toUri()?.toString()?.trim()
private fun CoroutineScope.saveChanges(name: String?, avatarUri: Uri?, currentUser: MatrixUser, action: MutableState<Async<Unit>>) = launch {
val results = mutableListOf<Result<Unit>>()
suspend {
if (!name.isNullOrEmpty() && name.trim() != currentUser.displayName.orEmpty().trim()) {
results.add(matrixClient.setDisplayName(name).onFailure {
Timber.e(it, "Failed to set user's display name")
})
}
if (avatarUri?.toString()?.trim() != currentUser.avatarUrl?.trim()) {
results.add(updateAvatar(avatarUri).onFailure {
Timber.e(it, "Failed to update user's avatar")
})
}
if (results.all { it.isSuccess }) Unit else results.first { it.isFailure }.getOrThrow()
}.runCatchingUpdatingState(action)
}
private suspend fun updateAvatar(avatarUri: Uri?): Result<Unit> {
return runCatching {
if (avatarUri != null) {
val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow()
matrixClient.uploadAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow()
} else {
matrixClient.removeAvatar().getOrThrow()
}
}.onFailure { Timber.e(it, "Unable to update avatar") }
}
}

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.user.editprofile
import android.net.Uri
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.media.AvatarAction
import kotlinx.collections.immutable.ImmutableList
data class EditUserProfileState(
val userId: UserId?,
val displayName: String,
val userAvatarUrl: Uri?,
val avatarActions: ImmutableList<AvatarAction>,
val saveButtonEnabled: Boolean,
val saveAction: Async<Unit>,
val eventSink: (EditUserProfileEvents) -> Unit
)

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.user.editprofile
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.persistentListOf
open class EditUserProfileStateProvider : PreviewParameterProvider<EditUserProfileState> {
override val values: Sequence<EditUserProfileState>
get() = sequenceOf(
aEditUserProfileState(),
// Add other states here
)
}
fun aEditUserProfileState() = EditUserProfileState(
userId = UserId("@john.doe:matrix.org"),
displayName = "John Doe",
userAvatarUrl = null,
avatarActions = persistentListOf(),
saveAction = Async.Uninitialized,
saveButtonEnabled = true,
eventSink = {}
)

View file

@ -0,0 +1,190 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.user.editprofile
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.LabelledOutlinedTextField
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.EditableAvatarView
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Composable
fun EditUserProfileView(
state: EditUserProfileState,
onBackPressed: () -> Unit,
onProfileEdited: () -> Unit,
modifier: Modifier = Modifier,
) {
val coroutineScope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
val itemActionsBottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
)
fun onAvatarClicked() {
focusManager.clearFocus()
coroutineScope.launch {
itemActionsBottomSheetState.show()
}
}
Scaffold(
modifier = modifier.clearFocusOnTap(focusManager),
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.screen_edit_profile_title),
style = ElementTheme.typography.aliasScreenTitle,
)
},
navigationIcon = { BackButton(onClick = onBackPressed) },
actions = {
TextButton(
text = stringResource(CommonStrings.action_save),
enabled = state.saveButtonEnabled,
onClick = {
focusManager.clearFocus()
state.eventSink(EditUserProfileEvents.Save)
},
)
}
)
},
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.padding(horizontal = 16.dp)
.navigationBarsPadding()
.imePadding()
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(24.dp))
EditableAvatarView(
userId = state.userId?.value,
displayName = state.displayName,
avatarUrl = state.userAvatarUrl,
avatarSize = AvatarSize.RoomHeader,
onAvatarClicked = { onAvatarClicked() },
modifier = Modifier.align(Alignment.CenterHorizontally),
)
Spacer(modifier = Modifier.height(16.dp))
state.userId?.let {
Text(
modifier = Modifier.fillMaxWidth(),
text = it.value,
style = ElementTheme.typography.fontBodyLgRegular,
textAlign = TextAlign.Center,
)
}
Spacer(modifier = Modifier.height(40.dp))
LabelledOutlinedTextField(
label = stringResource(R.string.screen_edit_profile_display_name),
value = state.displayName,
placeholder = stringResource(CommonStrings.common_room_name_placeholder),
singleLine = true,
onValueChange = { state.eventSink(EditUserProfileEvents.UpdateDisplayName(it)) },
)
}
AvatarActionBottomSheet(
actions = state.avatarActions,
modalBottomSheetState = itemActionsBottomSheetState,
onActionSelected = { state.eventSink(EditUserProfileEvents.HandleAvatarAction(it)) }
)
when (state.saveAction) {
is Async.Loading -> {
ProgressDialog(text = stringResource(R.string.screen_edit_profile_updating_details))
}
is Async.Failure -> {
ErrorDialog(
title = stringResource(R.string.screen_edit_profile_error_title),
content = stringResource(R.string.screen_edit_profile_error),
onDismiss = { state.eventSink(EditUserProfileEvents.CancelSaveChanges) },
)
}
is Async.Success -> {
LaunchedEffect(state.saveAction) {
onProfileEdited()
}
}
else -> Unit
}
}
}
private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
pointerInput(Unit) {
detectTapGestures(onTap = {
focusManager.clearFocus()
})
}
@DayNightPreviews
@Composable
internal fun EditUserProfileViewPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) =
ElementPreview {
EditUserProfileView(
onBackPressed = {},
onProfileEdited = {},
state = state,
)
}

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_edit_profile_display_name">"Display name"</string>
<string name="screen_edit_profile_display_name_placeholder">"Your display name"</string>
<string name="screen_edit_profile_error">"An unknown error was encountered and the information couldn\'t be changed."</string>
<string name="screen_edit_profile_error_title">"Unable to update profile"</string>
<string name="screen_edit_profile_title">"Edit profile"</string>
<string name="screen_edit_profile_updating_details">"Updating profile…"</string>
</resources>

View file

@ -0,0 +1,429 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.user.editprofile
import android.net.Uri
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.io.File
@ExperimentalCoroutinesApi
class EditUserProfilePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
private lateinit var fakePickerProvider: FakePickerProvider
private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor
private val userAvatarUri: Uri = mockk()
private val anotherAvatarUri: Uri = mockk()
private val fakeFileContents = ByteArray(2)
@Before
fun setup() {
fakePickerProvider = FakePickerProvider()
fakeMediaPreProcessor = FakeMediaPreProcessor()
mockkStatic(Uri::class)
every { Uri.parse(AN_AVATAR_URL) } returns userAvatarUri
every { Uri.parse(ANOTHER_AVATAR_URL) } returns anotherAvatarUri
}
@After
fun tearDown() {
unmockkAll()
}
private fun createEditUserProfilePresenter(
matrixClient: MatrixClient = FakeMatrixClient(),
matrixUser: MatrixUser = aMatrixUser(),
): EditUserProfilePresenter {
return EditUserProfilePresenter(
matrixClient = matrixClient,
matrixUser = matrixUser,
mediaPickerProvider = fakePickerProvider,
mediaPreProcessor = fakeMediaPreProcessor,
)
}
@Test
fun `present - initial state is created from user info`() = runTest {
val user = aMatrixUser(avatarUrl = AN_AVATAR_URL)
val presenter = createEditUserProfilePresenter(matrixUser = user)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.userId).isEqualTo(user.userId)
assertThat(initialState.displayName).isEqualTo(user.displayName)
assertThat(initialState.userAvatarUrl).isEqualTo(userAvatarUri)
assertThat(initialState.avatarActions).containsExactly(
AvatarAction.ChoosePhoto,
AvatarAction.TakePhoto,
AvatarAction.Remove
)
assertThat(initialState.saveButtonEnabled).isFalse()
assertThat(initialState.saveAction).isInstanceOf(Async.Uninitialized::class.java)
}
}
@Test
fun `present - updates state in response to changes`() = runTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
val presenter = createEditUserProfilePresenter(matrixUser = user)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.displayName).isEqualTo("Name")
assertThat(initialState.userAvatarUrl).isEqualTo(userAvatarUri)
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II"))
awaitItem().apply {
assertThat(displayName).isEqualTo("Name II")
assertThat(userAvatarUrl).isEqualTo(userAvatarUri)
}
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name III"))
awaitItem().apply {
assertThat(displayName).isEqualTo("Name III")
assertThat(userAvatarUrl).isEqualTo(userAvatarUri)
}
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
awaitItem().apply {
assertThat(displayName).isEqualTo("Name III")
assertThat(userAvatarUrl).isNull()
}
}
}
@Test
fun `present - obtains avatar uris from gallery`() = runTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
fakePickerProvider.givenResult(anotherAvatarUri)
val presenter = createEditUserProfilePresenter(matrixUser = user)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.userAvatarUrl).isEqualTo(userAvatarUri)
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
awaitItem().apply {
assertThat(userAvatarUrl).isEqualTo(anotherAvatarUri)
}
}
}
@Test
fun `present - obtains avatar uris from camera`() = runTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
fakePickerProvider.givenResult(anotherAvatarUri)
val presenter = createEditUserProfilePresenter(matrixUser = user)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.userAvatarUrl).isEqualTo(userAvatarUri)
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto))
awaitItem().apply {
assertThat(userAvatarUrl).isEqualTo(anotherAvatarUri)
}
}
}
@Test
fun `present - updates save button state`() = runTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
fakePickerProvider.givenResult(userAvatarUri)
val presenter = createEditUserProfilePresenter(matrixUser = user)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.saveButtonEnabled).isFalse()
// Once a change is made, the save button is enabled
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II"))
awaitItem().apply {
assertThat(saveButtonEnabled).isTrue()
}
// If it's reverted then the save disables again
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name"))
awaitItem().apply {
assertThat(saveButtonEnabled).isFalse()
}
// Make a change...
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
awaitItem().apply {
assertThat(saveButtonEnabled).isTrue()
}
// Revert it...
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
awaitItem().apply {
assertThat(saveButtonEnabled).isFalse()
}
}
}
@Test
fun `present - updates save button state when initial values are null`() = runTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = null)
fakePickerProvider.givenResult(userAvatarUri)
val presenter = createEditUserProfilePresenter(matrixUser = user)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.saveButtonEnabled).isFalse()
// Once a change is made, the save button is enabled
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II"))
awaitItem().apply {
assertThat(saveButtonEnabled).isTrue()
}
// If it's reverted then the save disables again
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name"))
awaitItem().apply {
assertThat(saveButtonEnabled).isFalse()
}
// Make a change...
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
awaitItem().apply {
assertThat(saveButtonEnabled).isTrue()
}
// Revert it...
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
awaitItem().apply {
assertThat(saveButtonEnabled).isFalse()
}
}
}
@Test
fun `present - save changes room details if different`() = runTest {
val matrixClient = FakeMatrixClient()
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
val presenter = createEditUserProfilePresenter(
matrixClient = matrixClient,
matrixUser = user
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("New name"))
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
initialState.eventSink(EditUserProfileEvents.Save)
consumeItemsUntilPredicate { matrixClient.setDisplayNameCalled && matrixClient.removeAvatarCalled && !matrixClient.uploadAvatarCalled }
assertThat(matrixClient.setDisplayNameCalled).isTrue()
assertThat(matrixClient.removeAvatarCalled).isTrue()
assertThat(matrixClient.uploadAvatarCalled).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - save does not change room details if they're the same trimmed`() = runTest {
val matrixClient = FakeMatrixClient()
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
val presenter = createEditUserProfilePresenter(
matrixClient = matrixClient,
matrixUser = user
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName(" Name "))
initialState.eventSink(EditUserProfileEvents.Save)
consumeItemsUntilPredicate { matrixClient.setDisplayNameCalled && !matrixClient.removeAvatarCalled && !matrixClient.uploadAvatarCalled }
assertThat(matrixClient.setDisplayNameCalled).isFalse()
assertThat(matrixClient.uploadAvatarCalled).isFalse()
assertThat(matrixClient.removeAvatarCalled).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - save does not change name if it's now empty`() = runTest {
val matrixClient = FakeMatrixClient()
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
val presenter = createEditUserProfilePresenter(
matrixClient = matrixClient,
matrixUser = user
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName(""))
initialState.eventSink(EditUserProfileEvents.Save)
assertThat(matrixClient.setDisplayNameCalled).isFalse()
assertThat(matrixClient.uploadAvatarCalled).isFalse()
assertThat(matrixClient.removeAvatarCalled).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - save processes and sets avatar when processor returns successfully`() = runTest {
val matrixClient = FakeMatrixClient()
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
givenPickerReturnsFile()
val presenter = createEditUserProfilePresenter(
matrixClient = matrixClient,
matrixUser = user
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
initialState.eventSink(EditUserProfileEvents.Save)
consumeItemsUntilPredicate { matrixClient.uploadAvatarCalled }
assertThat(matrixClient.uploadAvatarCalled).isTrue()
}
}
@Test
fun `present - save does not set avatar data if processor fails`() = runTest {
val matrixClient = FakeMatrixClient()
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
val presenter = createEditUserProfilePresenter(
matrixClient = matrixClient,
matrixUser = user
)
fakePickerProvider.givenResult(anotherAvatarUri)
fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no")))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
initialState.eventSink(EditUserProfileEvents.Save)
skipItems(2)
assertThat(matrixClient.uploadAvatarCalled).isFalse()
assertThat(awaitItem().saveAction).isInstanceOf(Async.Failure::class.java)
}
}
@Test
fun `present - sets save action to failure if name update fails`() = runTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
val matrixClient = FakeMatrixClient().apply {
givenSetDisplayNameResult(Result.failure(Throwable("!")))
}
saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.UpdateDisplayName("New name"))
}
@Test
fun `present - sets save action to failure if removing avatar fails`() = runTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
val matrixClient = FakeMatrixClient().apply {
givenRemoveAvatarResult(Result.failure(Throwable("!")))
}
saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
}
@Test
fun `present - sets save action to failure if setting avatar fails`() = runTest {
givenPickerReturnsFile()
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
val matrixClient = FakeMatrixClient().apply {
givenUploadAvatarResult(Result.failure(Throwable("!")))
}
saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
}
@Test
fun `present - CancelSaveChanges resets save action state`() = runTest {
givenPickerReturnsFile()
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
val matrixClient = FakeMatrixClient().apply {
givenSetDisplayNameResult(Result.failure(Throwable("!")))
}
val presenter = createEditUserProfilePresenter(matrixUser = user, matrixClient = matrixClient)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("foo"))
initialState.eventSink(EditUserProfileEvents.Save)
skipItems(2)
assertThat(awaitItem().saveAction).isInstanceOf(Async.Failure::class.java)
initialState.eventSink(EditUserProfileEvents.CancelSaveChanges)
assertThat(awaitItem().saveAction).isInstanceOf(Async.Uninitialized::class.java)
}
}
private suspend fun saveAndAssertFailure(matrixUser: MatrixUser, matrixClient: MatrixClient, event: EditUserProfileEvents) {
val presenter = createEditUserProfilePresenter(matrixUser = matrixUser, matrixClient = matrixClient)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(event)
initialState.eventSink(EditUserProfileEvents.Save)
skipItems(1)
assertThat(awaitItem().saveAction).isInstanceOf(Async.Loading::class.java)
assertThat(awaitItem().saveAction).isInstanceOf(Async.Failure::class.java)
}
}
private fun givenPickerReturnsFile() {
mockkStatic(File::readBytes)
val processedFile: File = mockk {
every { readBytes() } returns fakeFileContents
}
fakePickerProvider.givenResult(anotherAvatarUri)
fakeMediaPreProcessor.givenResult(
Result.success(
MediaUploadInfo.AnyFile(
file = processedFile,
fileInfo = mockk(),
)
)
)
}
companion object {
private const val ANOTHER_AVATAR_URL = "example://camera/foo.jpg"
}
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s ist bei der letzten Nutzung abgestürzt. Möchten Sie einen Absturzbericht mit uns teilen?"</string>
<string name="rageshake_detection_dialog_content">"Sie scheinen das Telefon aus Frustration zu schütteln. Möchten Sie den Bildschirm für den Fehlerbericht öffnen?"</string>
</resources>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bug_report_attach_screenshot">"Bildschirmfoto anhängen"</string>
<string name="screen_bug_report_contact_me">"Sie können mich kontaktieren, wenn Sie weitere Fragen haben."</string>
<string name="screen_bug_report_contact_me_title">"Kontaktieren Sie mich"</string>
<string name="screen_bug_report_edit_screenshot">"Bildschirmfoto bearbeiten"</string>
<string name="screen_bug_report_editor_description">"Bitte beschreiben Sie den Fehler. Was haben Sie getan? Was haben Sie erwartet, was passiert? Was ist tatsächlich passiert. Bitte gehen Sie so detailliert wie möglich vor."</string>
<string name="screen_bug_report_editor_placeholder">"Beschreiben Sie den Fehler…"</string>
<string name="screen_bug_report_editor_supporting">"Wenn möglich, verfassen Sie die Beschreibung bitte auf Englisch."</string>
<string name="screen_bug_report_include_crash_logs">"Absturzprotokolle senden"</string>
<string name="screen_bug_report_include_logs">"Protokolle zulassen"</string>
<string name="screen_bug_report_include_screenshot">"Bildschirmfoto senden"</string>
<string name="screen_bug_report_logs_description">"Die Protokolle werden Ihrer Nachricht beigefügt, um sicherzustellen, dass alles ordnungsgemäß funktioniert. Um Ihre Nachricht ohne Protokolle zu senden, deaktivieren Sie diese Einstellung."</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$s ist bei der letzten Nutzung abgestürzt. Möchten Sie einen Absturzbericht mit uns teilen?"</string>
</resources>

View file

@ -18,37 +18,27 @@
package io.element.android.features.roomdetails.impl.edit
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddAPhoto
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalFocusManager
@ -61,21 +51,18 @@ import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.LabelledTextField
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
import io.element.android.libraries.matrix.ui.components.EditableAvatarView
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@ -134,7 +121,14 @@ fun RoomDetailsEditView(
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(24.dp))
EditableAvatarView(state, ::onAvatarClicked)
EditableAvatarView(
userId = state.roomId,
displayName = state.roomName,
avatarUrl = state.roomAvatarUrl,
avatarSize = AvatarSize.EditRoomDetails,
onAvatarClicked = ::onAvatarClicked,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(60.dp))
if (state.canChangeName) {
@ -202,56 +196,6 @@ fun RoomDetailsEditView(
}
}
@Composable
private fun EditableAvatarView(
state: RoomDetailsEditState,
onAvatarClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(70.dp)
.clickable(onClick = onAvatarClicked, enabled = state.canChangeAvatar)
) {
// TODO this might be able to be simplified into a single component once send/receive media is done
when (state.roomAvatarUrl?.scheme) {
null, "mxc" -> {
Avatar(
avatarData = AvatarData(state.roomId, state.roomName, state.roomAvatarUrl?.toString(), size = AvatarSize.RoomHeader),
modifier = Modifier.fillMaxSize(),
)
}
else -> {
UnsavedAvatar(
avatarUri = state.roomAvatarUrl,
modifier = Modifier.fillMaxSize(),
)
}
}
if (state.canChangeAvatar) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.size(24.dp),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(16.dp),
imageVector = Icons.Outlined.AddAPhoto,
contentDescription = "",
tint = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
}
}
@Composable
private fun LabelledReadOnlyField(
title: String,

View file

@ -1,6 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="screen_room_member_list_header_title">
<item quantity="one">"%1$d Person"</item>
<item quantity="other">"%1$d Personen"</item>
</plurals>
<string name="screen_room_details_add_topic_title">"Thema hinzufügen"</string>
<string name="screen_room_details_already_a_member">"Bereits Mitglied"</string>
<string name="screen_room_details_already_invited">"Bereits eingeladen"</string>
<string name="screen_room_details_edit_room_title">"Raum bearbeiten"</string>
<string name="screen_room_details_edition_error">"Es ist ein unbekannter Fehler aufgetreten und die Informationen konnten nicht geändert werden."</string>
<string name="screen_room_details_edition_error_title">"Raum kann nicht aktualisiert werden"</string>
<string name="screen_room_details_encryption_enabled_subtitle">"Nachrichten sind mit Schlössern gesichert. Nur Sie und die Empfänger haben die eindeutigen Schlüssel, um sie zu entsperren."</string>
<string name="screen_room_details_encryption_enabled_title">"Nachrichtenverschlüsselung aktiviert"</string>
<string name="screen_room_details_error_loading_notification_settings">"Beim Laden der Benachrichtigungseinstellungen ist ein Fehler aufgetreten."</string>
<string name="screen_room_details_error_muting">"Die Stummschaltung dieses Raums ist fehlgeschlagen, bitte versuchen Sie es erneut."</string>
<string name="screen_room_details_error_unmuting">"Die Deaktivierung der Stummschaltung dieses Raums ist fehlgeschlagen, bitte versuchen Sie es erneut."</string>
<string name="screen_room_details_invite_people_title">"Personen einladen"</string>
<string name="screen_room_details_notification_mode_custom">"Benutzerdefiniert"</string>
<string name="screen_room_details_notification_mode_default">"Standard"</string>
<string name="screen_room_details_notification_title">"Benachrichtigungen"</string>
<string name="screen_room_details_room_name_label">"Raumname"</string>
<string name="screen_room_details_share_room_title">"Raum teilen"</string>
<string name="screen_room_details_updating_room">"Raum wird aktualisiert…"</string>
<string name="screen_room_member_list_pending_header_title">"Ausstehend"</string>
<string name="screen_room_member_list_room_members_header_title">"Raummitglieder"</string>
<string name="screen_room_notification_settings_allow_custom">"Benutzerdefinierte Einstellung zulassen"</string>
<string name="screen_room_notification_settings_allow_custom_footnote">"Wenn Sie diese Option aktivieren, wird Ihre Standardeinstellung außer Kraft gesetzt."</string>
<string name="screen_room_notification_settings_custom_settings_title">"Benachrichtigen Sie mich in diesem Chat bei"</string>
<string name="screen_room_notification_settings_default_setting_footnote">"Sie können das in Ihrem %1$s ändern."</string>
<string name="screen_room_notification_settings_default_setting_footnote_content_link">"Globale Einstellungen"</string>
<string name="screen_room_notification_settings_default_setting_title">"Standardeinstellung"</string>
<string name="screen_room_notification_settings_edit_remove_setting">"Benutzerdefinierte Einstellung entfernen"</string>
<string name="screen_room_notification_settings_error_loading_settings">"Beim Laden der Benachrichtigungseinstellungen ist ein Fehler aufgetreten."</string>
<string name="screen_room_notification_settings_error_restoring_default">"Fehler beim Wiederherstellen des Standardmodus. Bitte versuchen Sie es erneut."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Fehler beim Einstellen des Modus. Bitte versuchen Sie es erneut."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Alle Nachrichten"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Nur Erwähnungen und Schlüsselwörter"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"Benachrichtigen Sie mich in diesem Raum bei"</string>
<string name="screen_dm_details_block_alert_action">"Sperren"</string>
<string name="screen_dm_details_block_alert_description">"Gesperrte Benutzer können Ihnen keine Nachrichten senden und alle ihre Nachrichten werden ausgeblendet. Sie können sie jederzeit entsperren."</string>
<string name="screen_dm_details_block_user">"Benutzer sperren"</string>
<string name="screen_dm_details_unblock_alert_action">"Entsperren"</string>
<string name="screen_dm_details_unblock_alert_description">"Sie können dann wieder alle Nachrichten von ihnen sehen."</string>
<string name="screen_dm_details_unblock_user">"Benutzer entsperren"</string>
<string name="screen_room_details_leave_room_title">"Raum verlassen"</string>
<string name="screen_room_details_people_title">"Menschen"</string>
<string name="screen_room_details_people_title">"Personen"</string>
<string name="screen_room_details_security_title">"Sicherheit"</string>
<string name="screen_room_details_topic_title">"Thema"</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_roomlist_a11y_create_message">"Eine neue Unterhaltung oder einen neuen Raum erstellen"</string>
<string name="screen_roomlist_empty_message">"Beginnen Sie, indem Sie jemandem eine Nachricht senden."</string>
<string name="screen_roomlist_empty_title">"Noch keine Chats."</string>
<string name="screen_roomlist_main_space_title">"Alle Chats"</string>
<string name="session_verification_banner_message">"Es sieht aus, als würden Sie ein neues Gerät verwenden. Verifizieren Sie es mit einem anderen Gerät, damit Sie auf Ihre verschlüsselten Nachrichten zugreifen können."</string>
<string name="session_verification_banner_title">"Bestätigen Sie Ihre Identität"</string>
</resources>

View file

@ -1,4 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_session_verification_cancelled_subtitle">"Etwas scheint nicht zu stimmen. Entweder ist das Zeitlimit für die Anfrage abgelaufen oder die Anfrage wurde abgelehnt."</string>
<string name="screen_session_verification_compare_emojis_subtitle">"Vergewissern Sie sich, dass die folgenden Emojis mit denen in Ihrer anderen Session übereinstimmen."</string>
<string name="screen_session_verification_compare_emojis_title">"Emojis vergleichen"</string>
<string name="screen_session_verification_complete_subtitle">"Ihre neue Session ist nun verifiziert. Sie hat Zugriff auf Ihre verschlüsselten Nachrichten und wird von anderen Benutzern als vertrauenswürdig eingestuft."</string>
<string name="screen_session_verification_open_existing_session_subtitle">"Beweisen Sie Ihre Identität, um auf Ihren verschlüsselten Nachrichtenverlauf zuzugreifen."</string>
<string name="screen_session_verification_open_existing_session_title">"Öffnen Sie eine bestehende Sitzung"</string>
<string name="screen_session_verification_positive_button_canceled">"Verifizierung wiederholen"</string>
<string name="screen_session_verification_positive_button_initial">"Ich bin bereit"</string>
<string name="screen_session_verification_positive_button_verifying_ongoing">"Warten auf eine Übereinstimmung"</string>
<string name="screen_session_verification_request_accepted_subtitle">"Vergleichen Sie die einzelnen Emojis und stellen Sie sicher, dass sie in der gleichen Reihenfolge erscheinen."</string>
<string name="screen_session_verification_they_dont_match">"Sie stimmen nicht überein"</string>
<string name="screen_session_verification_they_match">"Sie stimmen überein"</string>
<string name="screen_session_verification_waiting_to_accept_subtitle">"Akzeptieren Sie die Anfrage, um den Verifizierungsprozess in Ihrer anderen Session zu starten, um fortzufahren."</string>
<string name="screen_session_verification_waiting_to_accept_title">"Warten auf die Annahme der Anfrage"</string>
<string name="screen_session_verification_cancelled_title">"Verifizierung abgebrochen"</string>
<string name="screen_session_verification_positive_button_ready">"Start"</string>
</resources>