Add extensible Russian and English interface languages on Android
This commit is contained in:
1 parent
df346fc097
commit
66afcc8625
40 files changed
+1354
-412
No files matched your search
@@ -1,6 +1,8 @@
|
||||
package com.utun.chat
|
||||
|
||||
import android.app.Application
|
||||
import android.content.res.Configuration
|
||||
import com.utun.chat.data.AppLanguage
|
||||
import android.util.Log
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -59,4 +61,9 @@ class ChatApplication : Application() {
|
||||
companion object {
|
||||
lateinit var instance: ChatApplication
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
AppLanguage.applied()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.utun.chat
|
||||
|
||||
import com.utun.chat.R
|
||||
import com.utun.chat.data.AppLanguage
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
@@ -14,7 +16,7 @@ import android.view.KeyEvent
|
||||
import android.view.WindowManager
|
||||
import com.utun.chat.data.CallController
|
||||
import com.utun.chat.data.CallPhase
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -29,6 +31,7 @@ import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -50,7 +53,7 @@ import com.utun.chat.ui.screens.*
|
||||
import com.utun.chat.viewmodel.ChatViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
class MainActivity : AppCompatActivity() {
|
||||
enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, VideoRecord, PhotoRecord, Call, Dm }
|
||||
|
||||
private var overLock = false
|
||||
@@ -74,7 +77,7 @@ class MainActivity : ComponentActivity() {
|
||||
LogManager.addLog(if (granted) "INFO" else "WARN", "PERMISSION", "Microphone result=$granted reason=$microphoneReason")
|
||||
if (granted) action?.invoke()
|
||||
else {
|
||||
Toast.makeText(this, "Для звонков и PTT разрешите доступ к микрофону", Toast.LENGTH_LONG).show()
|
||||
Toast.makeText(this, AppLanguage.text(R.string.microphone_permission_calls), Toast.LENGTH_LONG).show()
|
||||
if (!shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO)) showMicrophoneSettings()
|
||||
}
|
||||
}
|
||||
@@ -93,26 +96,31 @@ class MainActivity : ComponentActivity() {
|
||||
} catch (e: Exception) {
|
||||
microphoneAction = null
|
||||
LogManager.addLog("ERROR", "PERMISSION", "Cannot request microphone permission: $e")
|
||||
Toast.makeText(this, "Не удалось запросить доступ к микрофону", Toast.LENGTH_LONG).show()
|
||||
Toast.makeText(this, AppLanguage.text(R.string.microphone_request_failed), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showMicrophoneSettings() {
|
||||
android.app.AlertDialog.Builder(this)
|
||||
.setMessage("Доступ к микрофону запрещён. Разрешите его в настройках приложения, затем повторите звонок или нажатие PTT.")
|
||||
.setPositiveButton("Настройки") { _, _ ->
|
||||
.setMessage(AppLanguage.text(R.string.microphone_permission_settings))
|
||||
.setPositiveButton(AppLanguage.text(R.string.settings)) { _, _ ->
|
||||
try {
|
||||
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName")))
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "PERMISSION", "Cannot open app settings: $e")
|
||||
}
|
||||
}
|
||||
.setNegativeButton("Отмена", null).show()
|
||||
.setNegativeButton(AppLanguage.text(R.string.cancel), null).show()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
LogManager.addLog("INFO", "LANGUAGE",
|
||||
"UI language applied: selected=${AppLanguage.selectedTag().ifEmpty { "system" }} " +
|
||||
"effective=${resources.configuration.locales.toLanguageTags()}")
|
||||
AppLanguage.applied()
|
||||
|
||||
requestNotificationPermission()
|
||||
|
||||
/* Входящий звонок: showWhenLocked/turnScreenOn ДО setContent, иначе полноэкранный
|
||||
@@ -158,19 +166,37 @@ class MainActivity : ComponentActivity() {
|
||||
onStopOrDispose { vm.setForeground(false) }
|
||||
}
|
||||
|
||||
var screen by remember { mutableStateOf(Screen.ChannelList) }
|
||||
var showJoinDialog by remember { mutableStateOf(false) }
|
||||
var inviteChannelId by remember { mutableStateOf<String?>(null) }
|
||||
var screen by rememberSaveable { mutableStateOf(Screen.ChannelList) }
|
||||
var showJoinDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var inviteChannelId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var videoMessage by remember { mutableStateOf<Message?>(null) }
|
||||
var videoTarget by remember { mutableStateOf<ConversationTarget?>(null) }
|
||||
var videoTargetId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val videoTarget = videoTargetId?.let {
|
||||
if (it.startsWith("dm:")) ConversationTarget.Pm(it.removePrefix("dm:")) else ConversationTarget.Channel(it)
|
||||
}
|
||||
var photoMessage by remember { mutableStateOf<Message?>(null) }
|
||||
var hadCall by remember { mutableStateOf(false) }
|
||||
var screenBeforeCall by remember { mutableStateOf<Screen?>(null) }
|
||||
var hadCall by rememberSaveable { mutableStateOf(false) }
|
||||
var screenBeforeCall by rememberSaveable { mutableStateOf<Screen?>(null) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val callState by vm.callState.collectAsState()
|
||||
|
||||
// После смерти процесса ViewModel уже новый; сохранённый экран может потерять свою беседу или звонок.
|
||||
LaunchedEffect(screen) {
|
||||
val unavailable = when (screen) {
|
||||
Screen.Chat, Screen.MemberList, Screen.PhotoRecord -> vm.currentChannel.value == null
|
||||
Screen.Dm -> vm.currentDm.value == null
|
||||
Screen.VideoRecord -> videoTarget == null
|
||||
Screen.Call -> callState == null
|
||||
else -> false
|
||||
}
|
||||
if (unavailable) {
|
||||
LogManager.addLog("WARN", "MAIN", "Restored screen=$screen has no conversation/call; returning to channel list")
|
||||
screen = Screen.ChannelList
|
||||
}
|
||||
}
|
||||
|
||||
/* После звонка снимаем showWhenLocked входящего. Экран может гаснуть:
|
||||
аудио и CPU удерживает CallService независимо от Activity. */
|
||||
LaunchedEffect(callState) {
|
||||
@@ -187,8 +213,12 @@ class MainActivity : ComponentActivity() {
|
||||
/* Звонок управляет навигацией: OUTGOING/RINGING/ACTIVE → экран Call (запоминаем
|
||||
откуда пришли, чтобы свернуться обратно). ENDED не навигирует — CallScreen сам
|
||||
показывает «Завершён», если пользователь уже на нём. INCOMING → диалог поверх. */
|
||||
var lastCallPhase by rememberSaveable { mutableStateOf<CallPhase?>(null) }
|
||||
LaunchedEffect(callState?.phase) {
|
||||
when (callState?.phase) {
|
||||
val phase = callState?.phase
|
||||
if (phase == lastCallPhase) return@LaunchedEffect
|
||||
lastCallPhase = phase
|
||||
when (phase) {
|
||||
CallPhase.OUTGOING, CallPhase.RINGING,
|
||||
CallPhase.ACTIVE -> {
|
||||
if (screen != Screen.Call) screenBeforeCall = screen
|
||||
@@ -212,7 +242,7 @@ class MainActivity : ComponentActivity() {
|
||||
val showInvite: (Channel) -> Unit = { ch ->
|
||||
if (ch.id.toULongOrNull() == null) {
|
||||
LogManager.addLog("ERROR", "INVITE", "channel id is not numeric: ${ch.id}")
|
||||
scope.launch { snackbarHostState.showSnackbar("Invalid channel id") }
|
||||
scope.launch { snackbarHostState.showSnackbar(AppLanguage.text(R.string.invalid_channel_id)) }
|
||||
} else {
|
||||
inviteChannelId = ch.id
|
||||
vm.requestInvite(ch.id)
|
||||
@@ -300,7 +330,7 @@ class MainActivity : ComponentActivity() {
|
||||
onBack = { screen = Screen.ChannelList },
|
||||
onPlayVideo = { msg -> videoMessage = msg },
|
||||
onViewPhoto = { msg -> photoMessage = msg },
|
||||
onRecordVideo = { videoTarget = ConversationTarget.Channel(channel.id); screen = Screen.VideoRecord },
|
||||
onRecordVideo = { videoTargetId = ConversationTarget.Channel(channel.id).nativeId; screen = Screen.VideoRecord },
|
||||
onCapturePhoto = { screen = Screen.PhotoRecord }
|
||||
)
|
||||
}
|
||||
@@ -328,7 +358,7 @@ class MainActivity : ComponentActivity() {
|
||||
messages = dmMessages,
|
||||
viewModel = vm,
|
||||
onSendMessage = { vm.sendDmMessage(it) },
|
||||
onRecordVideo = { videoTarget = ConversationTarget.Pm(dm.convId); screen = Screen.VideoRecord },
|
||||
onRecordVideo = { videoTargetId = ConversationTarget.Pm(dm.convId).nativeId; screen = Screen.VideoRecord },
|
||||
onPlayVideo = { videoMessage = it },
|
||||
onBack = { vm.closeDm(); screen = Screen.ChannelList }
|
||||
)
|
||||
@@ -421,7 +451,7 @@ class MainActivity : ComponentActivity() {
|
||||
intent.removeExtra(MicrophonePermission.EXTRA_REQUEST)
|
||||
requestMicrophone("PTT") {
|
||||
RadioOverlayService.sync(this)
|
||||
Toast.makeText(this, "Микрофон разрешён. Нажмите PTT снова", Toast.LENGTH_LONG).show()
|
||||
Toast.makeText(this, AppLanguage.text(R.string.microphone_ptt_retry), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
CallController.setActivityVisible(true)
|
||||
@@ -444,7 +474,7 @@ class MainActivity : ComponentActivity() {
|
||||
intent.removeExtra(MicrophonePermission.EXTRA_REQUEST)
|
||||
requestMicrophone("PTT") {
|
||||
RadioOverlayService.sync(this)
|
||||
Toast.makeText(this, "Микрофон разрешён. Нажмите PTT снова", Toast.LENGTH_LONG).show()
|
||||
Toast.makeText(this, AppLanguage.text(R.string.microphone_ptt_retry), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
if (incomingCallId(intent) != 0L) {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.utun.chat.ChatApplication
|
||||
import com.utun.chat.R
|
||||
import com.utun.chat.headless.HeadlessService
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.util.Locale
|
||||
|
||||
/** Android/AppCompat хранит выбор; XML задаёт общий список для приложения и системных настроек. */
|
||||
object AppLanguage {
|
||||
private var appliedLocales: String? = null
|
||||
val supportedTags: List<String> by lazy {
|
||||
ChatApplication.instance.resources.getXml(R.xml.locales_config).use { parser ->
|
||||
buildList {
|
||||
while (parser.next() != XmlPullParser.END_DOCUMENT) {
|
||||
if (parser.eventType == XmlPullParser.START_TAG && parser.name == "locale") {
|
||||
add(parser.getAttributeValue("http://schemas.android.com/apk/res/android", "name"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectedTag(): String = AppCompatDelegate.getApplicationLocales().toLanguageTags()
|
||||
|
||||
fun select(tag: String) {
|
||||
if (tag.isNotEmpty() && tag !in supportedTags) {
|
||||
LogManager.addLog("ERROR", "LANGUAGE", "Unsupported language tag=$tag supported=$supportedTags")
|
||||
return
|
||||
}
|
||||
val previous = selectedTag()
|
||||
if (previous == tag) return
|
||||
LogManager.addLog("INFO", "LANGUAGE", "Language selected: ${previous.ifEmpty { "system" }} -> ${tag.ifEmpty { "system" }}")
|
||||
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(tag))
|
||||
}
|
||||
|
||||
fun nativeName(tag: String): String {
|
||||
val locale = Locale.forLanguageTag(tag)
|
||||
return locale.getDisplayName(locale).replaceFirstChar { it.titlecase(locale) }
|
||||
}
|
||||
|
||||
// Application/Service также получают сохранённый язык, включая холодный запуск без Activity.
|
||||
fun context(): Context = ContextCompat.getContextForLanguage(ChatApplication.instance)
|
||||
|
||||
fun text(@StringRes id: Int, vararg args: Any): String = context().getString(id, *args)
|
||||
|
||||
/** Вызывается после применения конфигурации; обновляет только текст действующих уведомлений. */
|
||||
fun applied() {
|
||||
val locales = context().resources.configuration.locales.toLanguageTags()
|
||||
if (locales == appliedLocales) return
|
||||
appliedLocales = locales
|
||||
LogManager.addLog("INFO", "LANGUAGE", "Application language applied: locales=$locales")
|
||||
val updates = listOf(
|
||||
"network" to { HeadlessService.refreshLanguage() },
|
||||
"incoming call" to { IncomingCallManager.refreshLanguage() },
|
||||
"ongoing call" to { CallController.refreshLanguage() },
|
||||
"radio" to { RadioOverlayService.refreshLanguage() }
|
||||
)
|
||||
updates.forEach { (name, update) ->
|
||||
try { update() }
|
||||
catch (e: Exception) { LogManager.addLog("ERROR", "LANGUAGE", "Notification language refresh failed: $name: $e") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ object CallController : TelecomCallManager.Listener {
|
||||
val proximityNear: StateFlow<Boolean> = proximity.near
|
||||
|
||||
private var service: CallService? = null
|
||||
|
||||
fun refreshLanguage() { service?.refreshLanguage() }
|
||||
private var serviceRequested = false
|
||||
private var accepting = false
|
||||
private var acceptSent = false
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import com.utun.chat.R
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
@@ -56,10 +57,16 @@ class CallService : Service() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannel()
|
||||
audio.onFailure = { id, detail -> CallController.audioFailed(id, detail) }
|
||||
LogManager.addLog("INFO", "CallService", "created")
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL, "Текущий звонок", NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||
description = "Собеседник, длительность и завершение звонка на экране блокировки"
|
||||
NotificationChannel(CHANNEL, AppLanguage.text(R.string.ongoing_call), NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||
description = AppLanguage.text(R.string.ongoing_call_description)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
@@ -68,10 +75,10 @@ class CallService : Service() {
|
||||
LogManager.addLog("INFO", "CallService",
|
||||
"notification channel=$CHANNEL enabled=${manager.areNotificationsEnabled()} " +
|
||||
"importance=${channel.importance} lockscreenVisibility=${channel.lockscreenVisibility}")
|
||||
audio.onFailure = { id, detail -> CallController.audioFailed(id, detail) }
|
||||
LogManager.addLog("INFO", "CallService", "created")
|
||||
}
|
||||
|
||||
fun refreshLanguage() { createChannel(); updateNotification() }
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val id = intent?.getLongExtra(EXTRA_ID, 0L) ?: 0L
|
||||
if (intent?.action == ACTION_END) {
|
||||
@@ -161,13 +168,13 @@ class CallService : Service() {
|
||||
Intent(this, CallService::class.java).setAction(ACTION_END)
|
||||
.setData(Uri.parse("utun-call:$callId")).putExtra(EXTRA_ID, callId),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
val builder = NotificationCompat.Builder(this, CHANNEL)
|
||||
val builder = NotificationCompat.Builder(AppLanguage.context(), CHANNEL)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_call)
|
||||
.setContentTitle(person.name)
|
||||
.setContentText(when (st?.phase) {
|
||||
CallPhase.ACTIVE -> "Разговор"
|
||||
CallPhase.RINGING -> "Звоним…"
|
||||
else -> "Установка звонка"
|
||||
CallPhase.ACTIVE -> AppLanguage.text(R.string.call_talking)
|
||||
CallPhase.RINGING -> AppLanguage.text(R.string.call_ringing)
|
||||
else -> AppLanguage.text(R.string.call_setup)
|
||||
})
|
||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import com.utun.chat.R
|
||||
|
||||
/** Состояние единственной звонковой сессии; не зависит от Activity или ViewModel. */
|
||||
enum class CallPhase { NONE, OUTGOING, RINGING, INCOMING, ACTIVE, ENDED }
|
||||
|
||||
/** Значения CONN_TYPE_* общего ядра. */
|
||||
enum class CallConnectionType(val label: String) {
|
||||
NONE("нет подключения"), DIRECT("прямое"), REVERSE("reverse"), INDIRECT("indirect")
|
||||
enum class CallConnectionType(val labelRes: Int) {
|
||||
NONE(R.string.connection_none), DIRECT(R.string.connection_direct),
|
||||
REVERSE(R.string.connection_reverse), INDIRECT(R.string.connection_indirect);
|
||||
|
||||
val label: String get() = AppLanguage.text(labelRes)
|
||||
}
|
||||
|
||||
data class CallStats(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import com.utun.chat.R
|
||||
import android.graphics.ImageFormat
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.hardware.camera2.CameraMetadata
|
||||
@@ -20,11 +21,18 @@ enum class LensType { SELFIE, MAIN, ULTRA_WIDE, MACRO, TELE }
|
||||
|
||||
data class LensOption(
|
||||
val type: LensType,
|
||||
val label: String,
|
||||
val mm: String,
|
||||
val cameraId: String,
|
||||
val eqFocal: Float
|
||||
) {
|
||||
val label: String get() = AppLanguage.text(when (type) {
|
||||
LensType.SELFIE -> R.string.lens_selfie
|
||||
LensType.MAIN -> R.string.lens_main
|
||||
LensType.ULTRA_WIDE -> R.string.lens_wide
|
||||
LensType.MACRO -> R.string.lens_macro
|
||||
LensType.TELE -> R.string.lens_tele
|
||||
})
|
||||
val mm: String get() = if (eqFocal > 0f) AppLanguage.text(R.string.focal_mm, round(eqFocal).toInt()) else ""
|
||||
|
||||
fun selector(): CameraSelector = CameraSelector.Builder()
|
||||
.addCameraFilter { infos -> infos.filter { Camera2CameraInfo.from(it).cameraId == cameraId } }
|
||||
.build()
|
||||
@@ -52,7 +60,7 @@ object CameraLensManager {
|
||||
|
||||
if (facing == CameraMetadata.LENS_FACING_FRONT) {
|
||||
val eq = eqFocal35(c2)
|
||||
result.add(LensOption(LensType.SELFIE, "Селфи", mmLabel(eq), cameraId, eq))
|
||||
result.add(LensOption(LensType.SELFIE, cameraId, eq))
|
||||
continue
|
||||
}
|
||||
if (facing != CameraMetadata.LENS_FACING_BACK) continue
|
||||
@@ -83,17 +91,9 @@ object CameraLensManager {
|
||||
else -> LensType.MAIN
|
||||
}
|
||||
|
||||
fun typeLabel(type: LensType): String = when (type) {
|
||||
LensType.SELFIE -> "Селфи"
|
||||
LensType.MAIN -> "Обычная"
|
||||
LensType.ULTRA_WIDE -> "Широкая"
|
||||
LensType.MACRO -> "Макро"
|
||||
LensType.TELE -> "Теле"
|
||||
}
|
||||
|
||||
val backOptions = backs.sortedBy { it.eqFocal }.map { l ->
|
||||
val type = typeOf(l)
|
||||
LensOption(type, typeLabel(type), mmLabel(l.eqFocal), l.cameraId, l.eqFocal)
|
||||
LensOption(type, l.cameraId, l.eqFocal)
|
||||
}
|
||||
result.addAll(backOptions)
|
||||
|
||||
@@ -101,9 +101,6 @@ object CameraLensManager {
|
||||
return result
|
||||
}
|
||||
|
||||
private fun mmLabel(eqFocal: Float): String =
|
||||
if (eqFocal > 0f) "${round(eqFocal).toInt()}мм" else ""
|
||||
|
||||
private fun eqFocal35(c2: Camera2CameraInfo): Float {
|
||||
val focal = (c2.getCameraCharacteristic(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)
|
||||
?: floatArrayOf()).firstOrNull() ?: 0f
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import com.utun.chat.R
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
@@ -66,7 +67,7 @@ data class Message(val id: Long, val author: String, val text: String, val ts: L
|
||||
val imageWidth: Int = 0, val imageHeight: Int = 0,
|
||||
val videoWidth: Int = 0, val videoHeight: Int = 0, val videoDurationMs: Int = 0,
|
||||
val downloadState: String = "", /* "", "downloading", "downloaded", "error" */
|
||||
val deliveryStatus: String = "")
|
||||
val deliveryStatusRes: Int = 0)
|
||||
|
||||
data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = false,
|
||||
val isAdmin: Boolean = false, val isSuper: Boolean = false,
|
||||
@@ -95,7 +96,8 @@ data class MemberDetail(val nodeId: Long, val name: String = "",
|
||||
data class MemberAddr(val family: Int = 0, val protocol: String = "", val address: String = "",
|
||||
val port: Int = 0, val socketId: Int = 0, val addrType: Int = 0, val rtt: Int = 0) {
|
||||
val roleLabel: String get() = when (addrType) {
|
||||
0 -> "local"; 1 -> "public"; 2 -> "nat_eim"; 3 -> "nat_strict"; 4 -> "nat"; else -> "?$addrType"
|
||||
0 -> AppLanguage.text(R.string.address_local); 1 -> AppLanguage.text(R.string.address_public)
|
||||
2 -> "nat_eim"; 3 -> "nat_strict"; 4 -> "nat"; else -> "?$addrType"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +105,9 @@ data class LinkStatus(val linkId: Int = 0, val localFamily: Int = 0, val localAd
|
||||
val remoteFamily: Int = 0, val remoteAddr: String = "", val remotePort: Int = 0,
|
||||
val linkState: Int = 0, val linkStatus: Int = 0,
|
||||
val isServer: Boolean = false, val socketName: String = "", val protocol: String = "") {
|
||||
val stateLabel: String get() = when (linkState) { 2 -> "UP"; 1 -> "CONNECTING"; else -> "DOWN" }
|
||||
val stateLabel: String get() = AppLanguage.text(when (linkState) {
|
||||
2 -> R.string.connection_up; 1 -> R.string.connection_connecting; else -> R.string.connection_down
|
||||
})
|
||||
}
|
||||
|
||||
data class MemberLinks(val connected: Boolean = false, val initialized: Boolean = false,
|
||||
@@ -114,7 +118,9 @@ data class LocalSocketState(
|
||||
val port: Int = 0, val protocol: String = "", val state: Int = 0,
|
||||
val linksUp: Int = 0, val linksDown: Int = 0
|
||||
) {
|
||||
val stateLabel: String get() = when (state) { 2 -> "UP"; 1 -> "CONNECTING"; else -> "DOWN" }
|
||||
val stateLabel: String get() = AppLanguage.text(when (state) {
|
||||
2 -> R.string.connection_up; 1 -> R.string.connection_connecting; else -> R.string.connection_down
|
||||
})
|
||||
}
|
||||
|
||||
data class ConnInfo(
|
||||
@@ -567,15 +573,15 @@ class ChatRepository {
|
||||
val status = obj.optString("media_status")
|
||||
val path = obj.optString("media_path")
|
||||
val delivery = if (file) when {
|
||||
status == "delivered" -> "Доставлено"
|
||||
status == "expired" -> "Срок хранения истёк"
|
||||
outgoing -> "Ожидает доставки"
|
||||
path.isNotEmpty() -> "Получено"
|
||||
else -> "Загрузка…"
|
||||
status == "delivered" -> R.string.delivery_delivered
|
||||
status == "expired" -> R.string.delivery_expired
|
||||
outgoing -> R.string.delivery_pending
|
||||
path.isNotEmpty() -> R.string.delivery_received
|
||||
else -> R.string.delivery_loading
|
||||
} else if (outgoing) {
|
||||
if (obj.optBoolean("pending")) "Ожидает доставки" else "Доставлено"
|
||||
} else ""
|
||||
val author = if (outgoing) "Вы" else peerName.ifBlank { "0x%016x".format(obj.optLong("author", 0)) }
|
||||
if (obj.optBoolean("pending")) R.string.delivery_pending else R.string.delivery_delivered
|
||||
} else 0
|
||||
val author = peerName.ifBlank { "0x%016x".format(obj.optLong("author", 0)) }
|
||||
list.add(Message(
|
||||
id = obj.optLong("seq", 0),
|
||||
author = author,
|
||||
@@ -591,7 +597,7 @@ class ChatRepository {
|
||||
videoWidth = obj.optInt("width"),
|
||||
videoHeight = obj.optInt("height"),
|
||||
downloadState = if (!file) "" else if (path.isNotEmpty()) "downloaded" else if (status == "expired") "error" else "downloading",
|
||||
deliveryStatus = delivery
|
||||
deliveryStatusRes = delivery
|
||||
))
|
||||
}
|
||||
} catch (e: Exception) { Log.w("utun-gui", "getDmMessages error", e) }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import com.utun.chat.R
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
@@ -62,6 +63,14 @@ object IncomingCallManager {
|
||||
showNotification(callId, channelId, peerNodeId)
|
||||
}
|
||||
|
||||
fun refreshLanguage() {
|
||||
createChannel(AppLanguage.context())
|
||||
val st = CallController.state.value ?: return
|
||||
if (activeCallId != 0L && st.callId == activeCallId) {
|
||||
showNotification(activeCallId, st.channelId, st.peerNodeId, alert = false)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── пробуждение устройства ── */
|
||||
|
||||
private fun acquireWakeLock() {
|
||||
@@ -91,8 +100,8 @@ object IncomingCallManager {
|
||||
|
||||
private fun createChannel(ctx: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val ch = NotificationChannel(CHANNEL_CALL, "Входящие звонки", NotificationManager.IMPORTANCE_HIGH).apply {
|
||||
description = "Уведомление о входящем звонке"
|
||||
val ch = NotificationChannel(CHANNEL_CALL, AppLanguage.text(R.string.incoming_calls), NotificationManager.IMPORTANCE_HIGH).apply {
|
||||
description = AppLanguage.text(R.string.incoming_call_notification)
|
||||
enableVibration(false)
|
||||
setSound(null, null)
|
||||
}
|
||||
@@ -100,8 +109,8 @@ object IncomingCallManager {
|
||||
}
|
||||
}
|
||||
|
||||
private fun showNotification(callId: Long, channelId: String, peerNodeId: Long) {
|
||||
val ctx = ChatApplication.instance
|
||||
private fun showNotification(callId: Long, channelId: String, peerNodeId: Long, alert: Boolean = true) {
|
||||
val ctx = AppLanguage.context()
|
||||
if (!NotificationManagerCompat.from(ctx).areNotificationsEnabled()) {
|
||||
LogManager.addLog("WARN", "InCall", "notifications disabled — skip (still ringing)")
|
||||
return
|
||||
@@ -125,19 +134,20 @@ object IncomingCallManager {
|
||||
|
||||
val builder = NotificationCompat.Builder(ctx, CHANNEL_CALL)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle("Входящий звонок")
|
||||
.setContentTitle(AppLanguage.text(R.string.incoming_call))
|
||||
.setContentText("0x%016x".format(peerNodeId))
|
||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(contentPi)
|
||||
.addAction(0, "Завершить", declinePi)
|
||||
.addAction(0, AppLanguage.text(R.string.hang_up), declinePi)
|
||||
|
||||
val nm = ctx.getSystemService(NotificationManager::class.java)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE || nm.canUseFullScreenIntent()) {
|
||||
if (alert && (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE || nm.canUseFullScreenIntent())) {
|
||||
builder.setFullScreenIntent(contentPi, true)
|
||||
} else {
|
||||
} else if (alert) {
|
||||
LogManager.addLog("WARN", "InCall", "full-screen intent not allowed — heads-up only")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import com.utun.chat.R
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
@@ -517,7 +518,7 @@ class RadioAudioEngine {
|
||||
if (!MicrophonePermission.granted()) {
|
||||
MicrophonePermission.request("PTT") {
|
||||
RadioOverlayService.sync(ChatApplication.instance)
|
||||
android.widget.Toast.makeText(ChatApplication.instance, "Микрофон разрешён. Нажмите PTT снова",
|
||||
android.widget.Toast.makeText(ChatApplication.instance, AppLanguage.text(R.string.microphone_ptt_retry),
|
||||
android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import com.utun.chat.R
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
@@ -55,6 +56,13 @@ class RadioOverlayService : Service() {
|
||||
private var defaultCenter: Point? = null
|
||||
private var currentService: RadioOverlayService? = null
|
||||
|
||||
fun refreshLanguage() {
|
||||
currentService?.let { service ->
|
||||
service.createChannel()
|
||||
service.getSystemService(NotificationManager::class.java).notify(NOTIF_ID, service.buildNotification())
|
||||
}
|
||||
}
|
||||
|
||||
// Координаты центра обычной PTT в пикселях экрана; обновляются из layout чата.
|
||||
fun setDefaultCenter(x: Int, y: Int) {
|
||||
if (defaultCenter?.x == x && defaultCenter?.y == y) return
|
||||
@@ -79,13 +87,13 @@ class RadioOverlayService : Service() {
|
||||
private var activityVisible = false
|
||||
private var waitingOverlayPermission = false
|
||||
var overlayPermissionRequester: (() -> Unit)? = null
|
||||
private val _status = MutableStateFlow<String?>(null)
|
||||
val status: StateFlow<String?> = _status
|
||||
private val _status = MutableStateFlow<Int?>(null)
|
||||
val status: StateFlow<Int?> = _status
|
||||
|
||||
private fun setStatus(message: String?) {
|
||||
private fun setStatus(message: Int?) {
|
||||
if (_status.value == message) return
|
||||
_status.value = message
|
||||
LogManager.addLog("INFO", "RadioOverlay", "state=${message ?: "disabled"} shown=${_isShown.value}")
|
||||
LogManager.addLog("INFO", "RadioOverlay", "state=${message?.let { ChatApplication.instance.resources.getResourceEntryName(it) } ?: "disabled"} shown=${_isShown.value}")
|
||||
}
|
||||
|
||||
suspend fun setEnabled(enabled: Boolean) {
|
||||
@@ -107,7 +115,7 @@ class RadioOverlayService : Service() {
|
||||
val ctx = ChatApplication.instance
|
||||
val provider = ctx.configProvider
|
||||
if (provider.getRadioOverlay() && !canDrawOverlays(ctx)) {
|
||||
setStatus("Нужно разрешение поверх приложений")
|
||||
setStatus(R.string.overlay_permission_required)
|
||||
if (waitingOverlayPermission) return
|
||||
val request = overlayPermissionRequester
|
||||
if (request == null) {
|
||||
@@ -120,7 +128,7 @@ class RadioOverlayService : Service() {
|
||||
return
|
||||
}
|
||||
if ((provider.getRadioOverlay() || provider.getInt("radio.vad_enabled") != 0) && !MicrophonePermission.granted()) {
|
||||
setStatus("Нужен доступ к микрофону")
|
||||
setStatus(R.string.microphone_access_required)
|
||||
MicrophonePermission.request("Floating PTT / VAD") { sync(ctx) }
|
||||
return
|
||||
}
|
||||
@@ -147,9 +155,9 @@ class RadioOverlayService : Service() {
|
||||
val micAllowed = MicrophonePermission.granted()
|
||||
when {
|
||||
!enabled -> setStatus(null)
|
||||
!overlayAllowed -> setStatus("Нужно разрешение поверх приложений")
|
||||
!micAllowed -> setStatus("Нужен доступ к микрофону")
|
||||
!radio.isActive() -> setStatus("Кнопка появится при включении рации")
|
||||
!overlayAllowed -> setStatus(R.string.overlay_permission_required)
|
||||
!micAllowed -> setStatus(R.string.microphone_access_required)
|
||||
!radio.isActive() -> setStatus(R.string.overlay_when_radio_on)
|
||||
}
|
||||
if (!radio.isActive() || (!vad && (!enabled || !overlayAllowed)) || !micAllowed) {
|
||||
if (!micAllowed && running) {
|
||||
@@ -170,11 +178,11 @@ class RadioOverlayService : Service() {
|
||||
}
|
||||
if (starting) { pendingStop = false; return }
|
||||
if (!activityVisible) {
|
||||
if (enabled && overlayAllowed) setStatus("Для запуска кнопки откройте приложение")
|
||||
if (enabled && overlayAllowed) setStatus(R.string.overlay_open_app)
|
||||
LogManager.addLog("DEBUG", "RadioOverlay", "Service start deferred until Activity resume")
|
||||
return
|
||||
}
|
||||
if (enabled && overlayAllowed) setStatus("Запуск плавающей кнопки…")
|
||||
if (enabled && overlayAllowed) setStatus(R.string.overlay_starting)
|
||||
pendingStop = false
|
||||
starting = true
|
||||
try {
|
||||
@@ -182,7 +190,7 @@ class RadioOverlayService : Service() {
|
||||
LogManager.addLog("INFO", "RadioOverlay", "Microphone service requested from visible Activity overlay=$enabled vad=$vad")
|
||||
} catch (e: Exception) {
|
||||
starting = false
|
||||
if (enabled) setStatus("Не удалось запустить плавающую кнопку")
|
||||
if (enabled) setStatus(R.string.overlay_start_failed)
|
||||
LogManager.addLog("ERROR", "RadioOverlay", "Service start failed: $e")
|
||||
}
|
||||
}
|
||||
@@ -225,7 +233,7 @@ class RadioOverlayService : Service() {
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "RadioOverlay", "Foreground microphone start failed: $e")
|
||||
starting = false
|
||||
setStatus("Не удалось запустить сервис микрофона")
|
||||
setStatus(R.string.microphone_service_failed)
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
@@ -266,12 +274,12 @@ class RadioOverlayService : Service() {
|
||||
val wantOverlay = provider.getRadioOverlay() && canDrawOverlays(this)
|
||||
if (wantOverlay && overlayView == null) {
|
||||
try { addOverlay() } catch (e: Exception) {
|
||||
setStatus("Не удалось показать плавающую кнопку")
|
||||
setStatus(R.string.overlay_show_failed)
|
||||
LogManager.addLog("ERROR", "RadioOverlay", "Overlay creation failed: $e")
|
||||
}
|
||||
}
|
||||
if (!wantOverlay) removeOverlay()
|
||||
if (overlayView != null) setStatus("Плавающая кнопка активна")
|
||||
if (overlayView != null) setStatus(R.string.overlay_active)
|
||||
if (!wantOverlay && provider.getInt("radio.vad_enabled") == 0) stopSelf()
|
||||
}
|
||||
|
||||
@@ -290,18 +298,19 @@ class RadioOverlayService : Service() {
|
||||
|
||||
private fun createChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val ch = NotificationChannel(CHANNEL_ID, "Рация PTT", NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = "Оверлей PTT рации"
|
||||
val ch = NotificationChannel(CHANNEL_ID, AppLanguage.text(R.string.radio_ptt), NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = AppLanguage.text(R.string.radio_overlay_description)
|
||||
}
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(ch)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification() = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
private fun buildNotification() = NotificationCompat.Builder(AppLanguage.context(), CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_btn_speak_now)
|
||||
.setContentTitle("Рация активна")
|
||||
.setContentText("Рация: PTT / автоматическая передача по голосу")
|
||||
.setContentTitle(AppLanguage.text(R.string.radio_active))
|
||||
.setContentText(AppLanguage.text(R.string.radio_notification))
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
|
||||
private fun addOverlay() {
|
||||
@@ -472,8 +481,8 @@ class RadioOverlayService : Service() {
|
||||
pendingStop = false
|
||||
if (currentService === this) currentService = null
|
||||
_isShown.value = false
|
||||
if (_status.value == "Плавающая кнопка активна" || _status.value == "Запуск плавающей кнопки…") {
|
||||
setStatus(if (ChatApplication.instance.configProvider.getRadioOverlay()) "Плавающая кнопка остановлена" else null)
|
||||
if (_status.value == R.string.overlay_active || _status.value == R.string.overlay_starting) {
|
||||
setStatus(if (ChatApplication.instance.configProvider.getRadioOverlay()) R.string.overlay_stopped else null)
|
||||
}
|
||||
LogManager.addLog("INFO", "RadioOverlay", "overlay destroyed")
|
||||
super.onDestroy()
|
||||
|
||||
@@ -9,6 +9,8 @@ import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.utun.chat.ChatApplication
|
||||
import com.utun.chat.R
|
||||
import com.utun.chat.data.AppLanguage
|
||||
import com.utun.chat.MainActivity
|
||||
import com.utun.chat.data.LogManager
|
||||
import com.utun.chat.data.NativeLib
|
||||
@@ -16,23 +18,22 @@ import com.utun.chat.data.NetworkActivityMonitor
|
||||
import com.utun.chat.data.NetworkChangeMonitor
|
||||
|
||||
class HeadlessService : Service() {
|
||||
companion object {
|
||||
private var currentService: HeadlessService? = null
|
||||
|
||||
fun refreshLanguage() {
|
||||
currentService?.let { service ->
|
||||
service.createNotificationChannel()
|
||||
service.getSystemService(NotificationManager::class.java).notify(1, service.notification())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
currentService = this
|
||||
createNotificationChannel()
|
||||
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(this, 0, intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
|
||||
val notification = NotificationCompat.Builder(this, "utun_channel")
|
||||
.setContentTitle("vibechat")
|
||||
.setContentText("Service running...")
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
|
||||
startForeground(1, notification)
|
||||
startForeground(1, notification())
|
||||
|
||||
val provider = ChatApplication.instance.configProvider
|
||||
val dbDir = provider.getString("db.path") ?: filesDir.absolutePath
|
||||
@@ -55,6 +56,7 @@ class HeadlessService : Service() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (currentService === this) currentService = null
|
||||
LogManager.addLog("INFO", "SRV", "uTun service stopping")
|
||||
NetworkChangeMonitor.stop()
|
||||
NetworkActivityMonitor.stop()
|
||||
@@ -66,12 +68,26 @@ class HeadlessService : Service() {
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY
|
||||
|
||||
private fun notification(): android.app.Notification {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(this, 0, intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
return NotificationCompat.Builder(AppLanguage.context(), "utun_channel")
|
||||
.setContentTitle("vibechat")
|
||||
.setContentText(AppLanguage.text(R.string.service_notification))
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
"utun_channel", "vibechat",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply { description = "vibechat background service" }
|
||||
).apply { description = AppLanguage.text(R.string.service_description) }
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-5
@@ -1,5 +1,7 @@
|
||||
package com.utun.chat.ui.components
|
||||
|
||||
import com.utun.chat.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -43,9 +45,9 @@ fun ActiveCallBanner(
|
||||
|
||||
val name = st.peerName.ifEmpty { "0x%016x".format(st.peerNodeId) }
|
||||
val status = when (phase) {
|
||||
CallPhase.ACTIVE -> "Разговор " + fmtDuration(st.durationSeconds)
|
||||
CallPhase.RINGING -> "Звоним…"
|
||||
else -> "Пытаемся связаться…"
|
||||
CallPhase.ACTIVE -> stringResource(R.string.call_talking_prefix) + fmtDuration(st.durationSeconds)
|
||||
CallPhase.RINGING -> stringResource(R.string.call_ringing)
|
||||
else -> stringResource(R.string.call_connecting)
|
||||
}
|
||||
|
||||
Row(
|
||||
@@ -59,7 +61,7 @@ fun ActiveCallBanner(
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.PhoneInTalk,
|
||||
contentDescription = "Звонок",
|
||||
contentDescription = stringResource(R.string.call_noun),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
@@ -87,7 +89,7 @@ fun ActiveCallBanner(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
IconButton(onClick = onHangup) {
|
||||
Icon(Icons.Filled.CallEnd, "Завершить", tint = Color.White, modifier = Modifier.size(22.dp))
|
||||
Icon(Icons.Filled.CallEnd, stringResource(R.string.hang_up), tint = Color.White, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
-13
@@ -1,5 +1,8 @@
|
||||
package com.utun.chat.ui.components
|
||||
|
||||
import com.utun.chat.R
|
||||
import com.utun.chat.data.AppLanguage
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -8,6 +11,7 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -28,7 +32,7 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val activeTarget by rememberUpdatedState(target)
|
||||
var text by remember(target) { mutableStateOf("") }
|
||||
var text by rememberSaveable(target.nativeId) { mutableStateOf("") }
|
||||
var selected by remember(target) { mutableStateOf<SelectedAttachment?>(null) }
|
||||
var pickerTarget by remember { mutableStateOf<ConversationTarget?>(null) }
|
||||
var preparing by remember(target) { mutableStateOf(false) }
|
||||
@@ -63,13 +67,16 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o
|
||||
if (pinned == activeTarget) { selected = attachment; sendAsVideo = false; chooseVideo = attachment.video }
|
||||
else LogManager.addLog("INFO", "Attachment", "picker discarded after target change")
|
||||
} catch (e: CancellationException) { throw e }
|
||||
catch (e: Exception) { LogManager.addLog("ERROR", "Attachment", "picker failed: ${e.message}"); error = "Не удалось открыть вложение" }
|
||||
catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "Attachment", "picker failed: ${e.message}")
|
||||
error = AppLanguage.text(R.string.attachment_open_failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column {
|
||||
if (error.isNotEmpty()) Text(error, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp))
|
||||
if (preparing || preparation?.jobs?.isNotEmpty() == true) Text("Подготовка медиа…", modifier = Modifier.padding(8.dp))
|
||||
else if (preparation?.failed == true) Text("Не удалось подготовить вложение", color = MaterialTheme.colorScheme.error,
|
||||
if (preparing || preparation?.jobs?.isNotEmpty() == true) Text(stringResource(R.string.media_preparing), modifier = Modifier.padding(8.dp))
|
||||
else if (preparation?.failed == true) Text(stringResource(R.string.attachment_prepare_failed), color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(8.dp))
|
||||
InputBar(
|
||||
text = text, onTextChange = { text = it }, enabled = !preparing,
|
||||
@@ -82,12 +89,15 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o
|
||||
var cache: String? = null
|
||||
try {
|
||||
withContext(Dispatchers.IO) { cache = viewModel.copyUriToCache(context, attachment.uri, attachment.name) }
|
||||
val path = cache ?: throw IllegalStateException("Не удалось скопировать вложение")
|
||||
val path = cache ?: throw IllegalStateException(AppLanguage.text(R.string.attachment_copy_failed))
|
||||
if (video) viewModel.sendVideoMessage(path, attachment.target)
|
||||
else viewModel.sendAttachment(path, attachment.target, attachment.name)
|
||||
cache = null // Native/backend принял собственный cache-файл.
|
||||
} catch (e: CancellationException) { throw e }
|
||||
catch (e: Exception) { LogManager.addLog("ERROR", "Attachment", "send failed: ${e.message}"); error = e.message ?: "Ошибка вложения" }
|
||||
catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "Attachment", "send failed: ${e.message}")
|
||||
error = e.message ?: AppLanguage.text(R.string.attachment_send_failed)
|
||||
}
|
||||
finally {
|
||||
val abandoned = cache
|
||||
if (abandoned != null) withContext(NonCancellable + Dispatchers.IO) { File(abandoned).delete() }
|
||||
@@ -108,18 +118,18 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o
|
||||
)
|
||||
}
|
||||
if (menu) ModalBottomSheet(onDismissRequest = { menu = false }) {
|
||||
ListItem(headlineContent = { Text("Выбрать файл") }, modifier = Modifier.clickable {
|
||||
ListItem(headlineContent = { Text(stringResource(R.string.select_file)) }, modifier = Modifier.clickable {
|
||||
menu = false; pickerTarget = target; picker.launch(arrayOf("*/*"))
|
||||
})
|
||||
ListItem(headlineContent = { Text("Записать видео") }, modifier = Modifier.clickable { menu = false; onRecordVideo() })
|
||||
if (onCapturePhoto != null) ListItem(headlineContent = { Text("Сфотографировать") },
|
||||
ListItem(headlineContent = { Text(stringResource(R.string.record_video)) }, modifier = Modifier.clickable { menu = false; onRecordVideo() })
|
||||
if (onCapturePhoto != null) ListItem(headlineContent = { Text(stringResource(R.string.take_photo)) },
|
||||
modifier = Modifier.clickable { menu = false; onCapturePhoto() })
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
if (chooseVideo) AlertDialog(
|
||||
onDismissRequest = { chooseVideo = false }, title = { Text("Отправить видео") },
|
||||
text = { Text("Выбери способ отображения вложения") },
|
||||
confirmButton = { TextButton(onClick = { sendAsVideo = true; chooseVideo = false }) { Text("Как видео") } },
|
||||
dismissButton = { TextButton(onClick = { sendAsVideo = false; chooseVideo = false }) { Text("Как файл") } }
|
||||
onDismissRequest = { chooseVideo = false }, title = { Text(stringResource(R.string.send_video)) },
|
||||
text = { Text(stringResource(R.string.attachment_display_choice)) },
|
||||
confirmButton = { TextButton(onClick = { sendAsVideo = true; chooseVideo = false }) { Text(stringResource(R.string.as_video)) } },
|
||||
dismissButton = { TextButton(onClick = { sendAsVideo = false; chooseVideo = false }) { Text(stringResource(R.string.as_file)) } }
|
||||
)
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.utun.chat.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.utun.chat.R
|
||||
import com.utun.chat.data.AppLanguage
|
||||
import com.utun.chat.data.LogManager
|
||||
|
||||
@Composable
|
||||
fun LanguageSetting() {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val selected = AppLanguage.selectedTag()
|
||||
val systemLabel = stringResource(R.string.language_system)
|
||||
val options = listOf("" to systemLabel) + AppLanguage.supportedTags.map { it to AppLanguage.nativeName(it) }
|
||||
val selectedName = options.firstOrNull { it.first == selected }?.second ?: AppLanguage.nativeName(selected)
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(onClick = {
|
||||
expanded = true
|
||||
LogManager.addLog("DEBUG", "LANGUAGE", "Language menu opened: selected=$selected options=${options.map { it.first }}")
|
||||
}, modifier = Modifier.fillMaxWidth()) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.interface_language), style = MaterialTheme.typography.labelMedium)
|
||||
Text(selectedName, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
Icon(Icons.Default.ArrowDropDown, contentDescription = null)
|
||||
}
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
options.forEach { (tag, name) ->
|
||||
DropdownMenuItem(text = { Text(name) }, onClick = { expanded = false; AppLanguage.select(tag) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-18
@@ -1,5 +1,7 @@
|
||||
package com.utun.chat.ui.components
|
||||
|
||||
import com.utun.chat.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
@@ -75,6 +77,10 @@ private fun detectEmojiOnly(text: String): Int {
|
||||
return if (count in 1..3) count else 0
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun deliveryLabel(message: Message): String =
|
||||
if (message.deliveryStatusRes == 0) "" else stringResource(message.deliveryStatusRes)
|
||||
|
||||
@Composable
|
||||
fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = ChatViewModel.PlaybackState(),
|
||||
onPlayVoice: (String, Int) -> Unit = { _, _ -> },
|
||||
@@ -121,7 +127,8 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
|
||||
.background(bgColor)
|
||||
.padding(12.dp)
|
||||
) {
|
||||
if (!isVoice) Text(message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ChatColors.Secondary)
|
||||
if (!isVoice) Text(if (message.isOutgoing) stringResource(R.string.you) else message.author,
|
||||
fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ChatColors.Secondary)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
|
||||
when {
|
||||
@@ -137,7 +144,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
|
||||
if (!isVoice) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
listOf(formatTime(message.ts), message.deliveryStatus).filter { it.isNotEmpty() }.joinToString(" · "),
|
||||
listOf(formatTime(message.ts), deliveryLabel(message)).filter { it.isNotEmpty() }.joinToString(" · "),
|
||||
fontSize = 11.sp,
|
||||
color = ChatColors.Secondary,
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
@@ -236,8 +243,8 @@ private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean,
|
||||
) {
|
||||
Text(
|
||||
when {
|
||||
downloading -> "Downloading..."
|
||||
notDownloaded -> "Tap to download"
|
||||
downloading -> stringResource(R.string.downloading)
|
||||
notDownloaded -> stringResource(R.string.tap_download)
|
||||
playing || paused -> "${formatDurationMs(elapsedMs)} / ${formatDurationMs(durMs)}"
|
||||
else -> formatDurationMs(durMs)
|
||||
},
|
||||
@@ -245,7 +252,7 @@ private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean,
|
||||
color = ChatColors.Secondary
|
||||
)
|
||||
Text(
|
||||
listOf(formatTime(message.ts), message.deliveryStatus).filter { it.isNotEmpty() }.joinToString(" · "),
|
||||
listOf(formatTime(message.ts), deliveryLabel(message)).filter { it.isNotEmpty() }.joinToString(" · "),
|
||||
fontSize = 11.sp,
|
||||
color = ChatColors.Secondary,
|
||||
modifier = Modifier.align(Alignment.CenterVertically).padding(start = 8.dp)
|
||||
@@ -265,10 +272,10 @@ private fun FileBubble(message: Message, textColor: Color, onDownload: (Message)
|
||||
else -> "\u2B07\uFE0F"
|
||||
}
|
||||
val stateText = when {
|
||||
state == "downloaded" -> "Tap to save"
|
||||
state == "downloading" -> "Downloading..."
|
||||
state == "error" -> "Download failed, tap to retry"
|
||||
else -> "Tap to download"
|
||||
state == "downloaded" -> stringResource(R.string.tap_save)
|
||||
state == "downloading" -> stringResource(R.string.downloading)
|
||||
state == "error" -> stringResource(R.string.download_failed_retry)
|
||||
else -> stringResource(R.string.tap_download)
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -381,10 +388,10 @@ private fun VideoBubble(message: Message, textColor: Color,
|
||||
if (message.fileSize > 0 && message.videoDurationMs > 0)
|
||||
append("${formatBitrate(message.fileSize, message.videoDurationMs)} · ")
|
||||
append(when {
|
||||
downloading -> "Downloading..."
|
||||
isError -> "Failed, tap to retry"
|
||||
!downloaded -> "Tap to download"
|
||||
else -> "Tap to play"
|
||||
downloading -> stringResource(R.string.downloading)
|
||||
isError -> stringResource(R.string.failed_retry)
|
||||
!downloaded -> stringResource(R.string.tap_download)
|
||||
else -> stringResource(R.string.tap_play)
|
||||
})
|
||||
}.trimEnd(),
|
||||
fontSize = 12.sp, color = ChatColors.Secondary
|
||||
@@ -443,10 +450,10 @@ private fun ImageBubble(message: Message, textColor: Color,
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
resText + formatFileSize(message.fileSize) + " · " + when {
|
||||
downloading -> "Downloading..."
|
||||
isError -> "Failed, tap to retry"
|
||||
!downloaded -> "Tap to download"
|
||||
else -> "Tap to open"
|
||||
downloading -> stringResource(R.string.downloading)
|
||||
isError -> stringResource(R.string.failed_retry)
|
||||
!downloaded -> stringResource(R.string.tap_download)
|
||||
else -> stringResource(R.string.tap_open)
|
||||
},
|
||||
fontSize = 12.sp, color = ChatColors.Secondary
|
||||
)
|
||||
@@ -600,7 +607,7 @@ fun InputBar(
|
||||
onValueChange = onTextChange,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = { Text("Message...") },
|
||||
placeholder = { Text(stringResource(R.string.message_placeholder)) },
|
||||
maxLines = 4
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import com.utun.chat.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
@@ -59,11 +61,12 @@ private fun fmtDuration(sec: Int): String {
|
||||
return "%02d:%02d".format(m, s)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun callStatusText(phase: CallPhase): String = when (phase) {
|
||||
CallPhase.OUTGOING -> "Пытаемся связаться…"
|
||||
CallPhase.RINGING -> "Звоним…"
|
||||
CallPhase.ACTIVE -> "Разговор"
|
||||
CallPhase.ENDED -> "Завершён"
|
||||
CallPhase.OUTGOING -> stringResource(R.string.call_connecting)
|
||||
CallPhase.RINGING -> stringResource(R.string.call_ringing)
|
||||
CallPhase.ACTIVE -> stringResource(R.string.call_talking)
|
||||
CallPhase.ENDED -> stringResource(R.string.call_ended)
|
||||
else -> ""
|
||||
}
|
||||
|
||||
@@ -170,7 +173,7 @@ fun CallScreen(
|
||||
fontSize = 16.sp
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Подключение: ${st?.connectionType?.label ?: "нет подключения"}",
|
||||
Text(stringResource(R.string.connection_label, st?.connectionType?.label ?: stringResource(R.string.connection_none)),
|
||||
color = Color(0xFFAEBCC8), fontSize = 13.sp)
|
||||
val bgpPath = st?.bgpPath ?: ""
|
||||
if (bgpPath.isNotEmpty()) {
|
||||
@@ -185,7 +188,7 @@ fun CallScreen(
|
||||
|
||||
if (phase == CallPhase.ENDED) {
|
||||
Button(onClick = { viewModel.dismissCall(); onClose() }) {
|
||||
Text("Закрыть")
|
||||
Text(stringResource(R.string.close))
|
||||
}
|
||||
} else {
|
||||
if (phase == CallPhase.ACTIVE) {
|
||||
@@ -193,24 +196,24 @@ fun CallScreen(
|
||||
if (earpieceAvailable) {
|
||||
CallControlButton(
|
||||
icon = Icons.Filled.PhoneInTalk,
|
||||
contentDescription = "Динамик",
|
||||
label = "Динамик",
|
||||
contentDescription = stringResource(R.string.earpiece),
|
||||
label = stringResource(R.string.earpiece),
|
||||
active = route == AudioRoute.EARPIECE,
|
||||
onClick = { viewModel.setAudioRoute(AudioRoute.EARPIECE) }
|
||||
)
|
||||
}
|
||||
CallControlButton(
|
||||
icon = Icons.AutoMirrored.Filled.VolumeUp,
|
||||
contentDescription = "Громкая связь",
|
||||
label = "Громкая",
|
||||
contentDescription = stringResource(R.string.speakerphone),
|
||||
label = stringResource(R.string.speaker_short),
|
||||
active = route == AudioRoute.SPEAKER,
|
||||
onClick = { viewModel.setAudioRoute(AudioRoute.SPEAKER) }
|
||||
)
|
||||
if (headsetAvailable) {
|
||||
CallControlButton(
|
||||
icon = Icons.Filled.Headset,
|
||||
contentDescription = "Гарнитура",
|
||||
label = "Гарнитура",
|
||||
contentDescription = stringResource(R.string.headset),
|
||||
label = stringResource(R.string.headset),
|
||||
active = route == AudioRoute.HEADSET,
|
||||
onClick = {
|
||||
if (hasBtPermission) {
|
||||
@@ -229,8 +232,8 @@ fun CallScreen(
|
||||
if (phase == CallPhase.ACTIVE) {
|
||||
CallControlButton(
|
||||
icon = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic,
|
||||
contentDescription = if (muted) "Включить микрофон" else "Выключить микрофон",
|
||||
label = if (muted) "Вкл. микрофон" else "Mute",
|
||||
contentDescription = stringResource(if (muted) R.string.microphone_enable else R.string.microphone_disable),
|
||||
label = stringResource(if (muted) R.string.microphone_on_short else R.string.microphone_mute_short),
|
||||
active = muted,
|
||||
activeColor = Color(0xFFB32632),
|
||||
onClick = { viewModel.toggleMute() }
|
||||
@@ -242,11 +245,11 @@ fun CallScreen(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
IconButton(onClick = { viewModel.hangupCall() }) {
|
||||
Icon(Icons.Filled.CallEnd, "Завершить", tint = Color.White, modifier = Modifier.size(36.dp))
|
||||
Icon(Icons.Filled.CallEnd, stringResource(R.string.hang_up), tint = Color.White, modifier = Modifier.size(36.dp))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text("Завершить", color = Color(0xFFAEBCC8), fontSize = 12.sp)
|
||||
Text(stringResource(R.string.hang_up), color = Color(0xFFAEBCC8), fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,10 +258,8 @@ fun CallScreen(
|
||||
st?.stats?.let { stats ->
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
"Буфер: ${stats.bufferMs} мс · Запас: ${stats.reserveMs} мс\n" +
|
||||
"За 10 с: минимум ${stats.minMs} · максимум ${stats.maxMs} мс\n" +
|
||||
"Скорость: ${"%.2f".format(stats.tempoX100 / 100.0)}× · RTT: ${stats.rttMs} мс\n" +
|
||||
"Потери буфера: ${stats.dropped} · Недостачи: ${stats.underruns}",
|
||||
stringResource(R.string.call_stats, stats.bufferMs, stats.reserveMs, stats.minMs, stats.maxMs,
|
||||
"%.2f".format(stats.tempoX100 / 100.0), stats.rttMs, stats.dropped, stats.underruns),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = Color(0xFFAEBCC8),
|
||||
fontSize = 12.sp,
|
||||
@@ -275,7 +276,7 @@ fun CallScreen(
|
||||
.statusBarsPadding()
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Свернуть звонок", tint = Color.White, modifier = Modifier.size(24.dp))
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.minimize_call), tint = Color.White, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
|
||||
if (locked) {
|
||||
@@ -291,11 +292,11 @@ fun CallScreen(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(Icons.Filled.Lock, "Экран заблокирован", tint = Color.White, modifier = Modifier.size(48.dp))
|
||||
Icon(Icons.Filled.Lock, stringResource(R.string.screen_locked), tint = Color.White, modifier = Modifier.size(48.dp))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("Экран заблокирован", color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.screen_locked), color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Medium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Отведите телефон от уха, чтобы разблокировать", color = Color(0xFFAEBCC8), fontSize = 14.sp)
|
||||
Text(stringResource(R.string.move_phone_away), color = Color(0xFFAEBCC8), fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-19
@@ -1,5 +1,7 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import com.utun.chat.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -62,15 +64,15 @@ fun ChannelListScreen(
|
||||
.background(if (isStuck) Color(0xFFFFB4AB) else Color(0xFF81C784), CircleShape)
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
IconButton(onClick = onJoinChannel) { Icon(Icons.Default.QrCode, "Join") }
|
||||
IconButton(onClick = onLogs) { Icon(Icons.Default.Build, "Logs") }
|
||||
IconButton(onClick = onSettings) { Icon(Icons.Default.Settings, "Settings") }
|
||||
IconButton(onClick = onJoinChannel) { Icon(Icons.Default.QrCode, stringResource(R.string.join)) }
|
||||
IconButton(onClick = onLogs) { Icon(Icons.Default.Build, stringResource(R.string.logs)) }
|
||||
IconButton(onClick = onSettings) { Icon(Icons.Default.Settings, stringResource(R.string.settings)) }
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { showCreateDialog = true }) {
|
||||
Icon(Icons.Default.Add, "New channel")
|
||||
Icon(Icons.Default.Add, stringResource(R.string.new_channel))
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
@@ -78,7 +80,7 @@ fun ChannelListScreen(
|
||||
if (channels.isEmpty() && dmConversations.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().weight(1f)) {
|
||||
Text(
|
||||
"No channels yet.\nTap + to create one.",
|
||||
stringResource(R.string.no_channels),
|
||||
modifier = Modifier.padding(32.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -106,15 +108,15 @@ fun ChannelListScreen(
|
||||
onDismissRequest = { menuChannelId = null }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Invite link") },
|
||||
text = { Text(stringResource(R.string.invite_link)) },
|
||||
onClick = { menuChannelId = null; onInviteClick(ch) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Options") },
|
||||
text = { Text(stringResource(R.string.options)) },
|
||||
onClick = { menuChannelId = null; optionsChannel = ch }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Delete channel", color = MaterialTheme.colorScheme.error) },
|
||||
text = { Text(stringResource(R.string.delete_channel), color = MaterialTheme.colorScheme.error) },
|
||||
onClick = { menuChannelId = null; deleteConfirmChannel = ch }
|
||||
)
|
||||
}
|
||||
@@ -154,7 +156,7 @@ fun ChannelListScreen(
|
||||
if (dmConversations.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"Личные",
|
||||
stringResource(R.string.private_chats),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -204,16 +206,16 @@ fun ChannelListScreen(
|
||||
deleteConfirmChannel?.let { ch ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { deleteConfirmChannel = null },
|
||||
title = { Text("Delete channel") },
|
||||
text = { Text("Delete \"${ch.name}\" and all its local messages and files? Other members keep the channel.") },
|
||||
title = { Text(stringResource(R.string.delete_channel)) },
|
||||
text = { Text(stringResource(R.string.delete_channel_confirm, ch.name)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { deleteConfirmChannel = null; onDeleteChannel(ch) },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)) {
|
||||
Text("Delete")
|
||||
Text(stringResource(R.string.delete))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { deleteConfirmChannel = null }) { Text("Cancel") }
|
||||
TextButton(onClick = { deleteConfirmChannel = null }) { Text(stringResource(R.string.cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -230,7 +232,7 @@ fun ChannelOptionsDialog(channel: Channel, onDismiss: () -> Unit) {
|
||||
text = {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Sound", modifier = Modifier.weight(1f))
|
||||
Text(stringResource(R.string.sound), modifier = Modifier.weight(1f))
|
||||
Switch(
|
||||
checked = soundOn,
|
||||
onCheckedChange = {
|
||||
@@ -245,7 +247,7 @@ fun ChannelOptionsDialog(channel: Channel, onDismiss: () -> Unit) {
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Close") }
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.close)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -255,22 +257,22 @@ fun CreateChannelDialog(onDismiss: () -> Unit, onCreate: (String) -> Unit) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("New Channel") },
|
||||
title = { Text(stringResource(R.string.new_channel)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Channel name") },
|
||||
label = { Text(stringResource(R.string.channel_name)) },
|
||||
singleLine = true
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = { if (name.isNotBlank()) onCreate(name.trim()) }, enabled = name.isNotBlank()) {
|
||||
Text("Create")
|
||||
Text(stringResource(R.string.create))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import com.utun.chat.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import android.os.SystemClock
|
||||
import android.view.ViewConfiguration
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -115,7 +117,7 @@ fun ChatScreen(
|
||||
TopAppBar(
|
||||
title = { Text(channel.name) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) }
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.toggleSound() }) {
|
||||
@@ -126,10 +128,11 @@ fun ChatScreen(
|
||||
modifier = Modifier.background(
|
||||
if (radioActive) Color(0xFF27AE60) else Color.Transparent, CircleShape)
|
||||
) {
|
||||
PortableRadioIcon("Рация: ${if (radioActive) "выключить" else "включить"}")
|
||||
val action = stringResource(if (radioActive) R.string.radio_turn_off else R.string.radio_turn_on)
|
||||
PortableRadioIcon(stringResource(R.string.radio_toggle, action))
|
||||
}
|
||||
IconButton(onClick = onInvite) { Icon(Icons.Filled.Link, "Invite") }
|
||||
IconButton(onClick = onMembersClick) { Icon(Icons.Filled.People, "Members") }
|
||||
IconButton(onClick = onInvite) { Icon(Icons.Filled.Link, stringResource(R.string.invite)) }
|
||||
IconButton(onClick = onMembersClick) { Icon(Icons.Filled.People, stringResource(R.string.members)) }
|
||||
}
|
||||
)
|
||||
if (radioActive) {
|
||||
@@ -138,7 +141,7 @@ fun ChatScreen(
|
||||
if (radioTalking != null) {
|
||||
Surface(color = MaterialTheme.colorScheme.primaryContainer) {
|
||||
Text(
|
||||
text = "\uD83C\uDF99 ${viewModel.radioName(radioTalking!!)} говорит",
|
||||
text = stringResource(R.string.radio_speaking, viewModel.radioName(radioTalking!!)),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
@@ -273,29 +276,30 @@ private fun RadioSourceBar(viewModel: ChatViewModel) {
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val currentDeviceIcon = if (radioRoute == AudioRoute.HEADSET) Icons.Default.HeadsetMic else Icons.AutoMirrored.Filled.VolumeUp
|
||||
RadioControlButton(Icons.Default.AutoMode, "Авто: ${if (radioRoute == AudioRoute.HEADSET) "гарнитура" else "динамик"}",
|
||||
val routeName = stringResource(if (radioRoute == AudioRoute.HEADSET) R.string.radio_headset else R.string.radio_speaker)
|
||||
RadioControlButton(Icons.Default.AutoMode, stringResource(R.string.radio_auto_route, routeName),
|
||||
radioAuto, centerIcon = currentDeviceIcon) {
|
||||
viewModel.setRadioAuto()
|
||||
}
|
||||
RadioControlButton(Icons.AutoMirrored.Filled.VolumeUp, "Микрофон и динамик телефона",
|
||||
RadioControlButton(Icons.AutoMirrored.Filled.VolumeUp, stringResource(R.string.radio_phone_audio),
|
||||
!radioAuto && radioInputSource == RadioAudioEngine.INPUT_BUILTIN && radioRoute == AudioRoute.SPEAKER) {
|
||||
viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BUILTIN)
|
||||
}
|
||||
if (wiredInputAvailable) {
|
||||
RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук проводной гарнитуры",
|
||||
RadioControlButton(Icons.Default.HeadsetMic, stringResource(R.string.radio_wired_audio),
|
||||
!radioAuto && radioInputSource == RadioAudioEngine.INPUT_WIRED && radioRoute == AudioRoute.HEADSET) {
|
||||
viewModel.setRadioInputSource(RadioAudioEngine.INPUT_WIRED)
|
||||
}
|
||||
}
|
||||
if (btInputAvailable) {
|
||||
RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук Bluetooth-гарнитуры",
|
||||
RadioControlButton(Icons.Default.HeadsetMic, stringResource(R.string.radio_bluetooth_audio),
|
||||
!radioAuto && radioInputSource == RadioAudioEngine.INPUT_BT && radioRoute == AudioRoute.HEADSET, bluetooth = true) {
|
||||
viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BT)
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalDivider(modifier = Modifier.height(32.dp), color = Color(0xFF344550))
|
||||
RadioControlButton(null, "Плавающая PTT-кнопка", overlayShown, floating = true) {
|
||||
RadioControlButton(null, stringResource(R.string.floating_ptt), overlayShown, floating = true) {
|
||||
scope.launch {
|
||||
if (overlayEnabled && !overlayShown) RadioOverlayService.requestPermissions()
|
||||
else RadioOverlayService.setEnabled(!overlayEnabled)
|
||||
@@ -303,7 +307,7 @@ private fun RadioSourceBar(viewModel: ChatViewModel) {
|
||||
}
|
||||
}
|
||||
if (overlayEnabled && !overlayShown) overlayStatus?.let { message ->
|
||||
Text(message, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
Text(stringResource(message), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp).clickable { RadioOverlayService.requestPermissions() })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import com.utun.chat.R
|
||||
import com.utun.chat.data.AppLanguage
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -41,10 +44,10 @@ fun DmChatScreen(conv: DmConversation, messages: List<Message>, viewModel: ChatV
|
||||
withContext(Dispatchers.IO) {
|
||||
context.contentResolver.openOutputStream(uri)?.use { output ->
|
||||
File(path).inputStream().use { it.copyTo(output) }
|
||||
} ?: throw IllegalStateException("Не удалось открыть файл")
|
||||
} ?: throw IllegalStateException(AppLanguage.text(R.string.file_open_failed))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "PM", "save failed: ${e.message}"); error = "Не удалось сохранить файл"
|
||||
LogManager.addLog("ERROR", "PM", "save failed: ${e.message}"); error = AppLanguage.text(R.string.file_save_failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,7 +56,7 @@ fun DmChatScreen(conv: DmConversation, messages: List<Message>, viewModel: ChatV
|
||||
}
|
||||
Column(Modifier.fillMaxSize().imePadding()) {
|
||||
TopAppBar(title = { Text(conv.displayName) }, navigationIcon = {
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Назад") }
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) }
|
||||
})
|
||||
ConversationMessages(target, messages, state, playback, Modifier.weight(1f).fillMaxWidth(),
|
||||
onPlayVoice = { path, duration -> viewModel.playVoiceMessage(path, duration) },
|
||||
|
||||
+11
-5
@@ -1,5 +1,8 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import com.utun.chat.R
|
||||
import com.utun.chat.data.AppLanguage
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -10,6 +13,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.utun.chat.data.LogManager
|
||||
import com.utun.chat.ui.components.LanguageSetting
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -28,14 +32,16 @@ fun FirstLaunchScreen(initialName: String, onComplete: suspend (String) -> Unit)
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("Добро пожаловать", style = MaterialTheme.typography.headlineMedium)
|
||||
Text(stringResource(R.string.welcome), style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("Задайте имя устройства (ник), которое увидят другие участники.")
|
||||
Text(stringResource(R.string.device_name_hint))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
LanguageSetting()
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it; error = null },
|
||||
label = { Text("Имя устройства (ник)") },
|
||||
label = { Text(stringResource(R.string.device_nickname)) },
|
||||
singleLine = true,
|
||||
enabled = !saving,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
@@ -57,14 +63,14 @@ fun FirstLaunchScreen(initialName: String, onComplete: suspend (String) -> Unit)
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "SETUP", "Failed to save device name: $e")
|
||||
error = "Не удалось сохранить имя. Попробуйте ещё раз."
|
||||
error = AppLanguage.text(R.string.device_name_save_failed)
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(if (saving) "Сохранение…" else "Продолжить")
|
||||
Text(stringResource(if (saving) R.string.saving else R.string.continue_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-6
@@ -1,5 +1,7 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import com.utun.chat.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@@ -49,9 +51,9 @@ fun IncomingCallDialog(
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(name, color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Medium)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text("Входящий звонок…", color = Color(0xFFAEBCC8), fontSize = 14.sp)
|
||||
Text(stringResource(R.string.incoming_call_waiting), color = Color(0xFFAEBCC8), fontSize = 14.sp)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text("Подключение: ${st.connectionType.label}", color = Color(0xFFAEBCC8), fontSize = 13.sp)
|
||||
Text(stringResource(R.string.connection_label, st.connectionType.label), color = Color(0xFFAEBCC8), fontSize = 13.sp)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
|
||||
@@ -61,10 +63,10 @@ fun IncomingCallDialog(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
IconButton(onClick = { viewModel.declineCall() }) {
|
||||
Icon(Icons.Filled.CallEnd, "Отклонить", tint = Color.White, modifier = Modifier.size(30.dp))
|
||||
Icon(Icons.Filled.CallEnd, stringResource(R.string.decline), tint = Color.White, modifier = Modifier.size(30.dp))
|
||||
}
|
||||
}
|
||||
Text("Отклонить", color = Color(0xFFAEBCC8), fontSize = 12.sp)
|
||||
Text(stringResource(R.string.decline), color = Color(0xFFAEBCC8), fontSize = 12.sp)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(
|
||||
@@ -72,10 +74,10 @@ fun IncomingCallDialog(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
IconButton(onClick = { viewModel.acceptCall() }) {
|
||||
Icon(Icons.Filled.Call, "Принять", tint = Color.White, modifier = Modifier.size(30.dp))
|
||||
Icon(Icons.Filled.Call, stringResource(R.string.accept), tint = Color.White, modifier = Modifier.size(30.dp))
|
||||
}
|
||||
}
|
||||
Text("Принять", color = Color(0xFFAEBCC8), fontSize = 12.sp)
|
||||
Text(stringResource(R.string.accept), color = Color(0xFFAEBCC8), fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-9
@@ -1,5 +1,7 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import com.utun.chat.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
@@ -23,10 +25,11 @@ import com.google.zxing.common.BitMatrix
|
||||
import com.utun.chat.data.InviteCandidate
|
||||
import com.utun.chat.data.LogManager
|
||||
|
||||
@Composable
|
||||
private fun candidateLabel(c: InviteCandidate): String =
|
||||
c.name + when {
|
||||
c.isAuto -> " (auto)"
|
||||
c.isSelf -> " (self)"
|
||||
c.isAuto -> stringResource(R.string.candidate_auto)
|
||||
c.isSelf -> stringResource(R.string.candidate_self)
|
||||
else -> ""
|
||||
}
|
||||
|
||||
@@ -50,13 +53,13 @@ fun InviteLinkDialog(
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Invite Link", fontWeight = FontWeight.Bold) },
|
||||
title = { Text(stringResource(R.string.invite_link), fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box {
|
||||
OutlinedButton(onClick = { menuOpen = true }) {
|
||||
Text(
|
||||
effectiveSelected?.let { candidateLabel(it) } ?: "Выбор узла...",
|
||||
effectiveSelected?.let { candidateLabel(it) } ?: stringResource(R.string.select_node),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
@@ -79,17 +82,17 @@ fun InviteLinkDialog(
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
when {
|
||||
pending -> Text("Генерация ссылки...", fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
pending -> Text(stringResource(R.string.invite_generating), fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
link != null -> {
|
||||
qrBitmap?.let {
|
||||
Image(bitmap = it.asImageBitmap(), contentDescription = "QR",
|
||||
Image(bitmap = it.asImageBitmap(), contentDescription = stringResource(R.string.qr_code),
|
||||
modifier = Modifier.size(200.dp))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
Text(link, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
|
||||
modifier = Modifier.padding(8.dp))
|
||||
}
|
||||
else -> Text("Не удалось собрать ссылку", fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
else -> Text(stringResource(R.string.invite_build_failed), fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -104,10 +107,10 @@ fun InviteLinkDialog(
|
||||
LogManager.addLog("INFO", "INVITE", "Link copied to clipboard")
|
||||
}
|
||||
}
|
||||
) { Text(if (copied) "Copied!" else "Copy Link") }
|
||||
) { Text(stringResource(if (copied) R.string.copied else R.string.copy_link)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Close") }
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.close)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+9
-6
@@ -1,5 +1,8 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import com.utun.chat.R
|
||||
import com.utun.chat.data.AppLanguage
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -37,7 +40,7 @@ fun JoinChannelDialog(onConnect: (InviteData) -> Unit, onDismiss: () -> Unit) {
|
||||
val invite = InviteLink.decode(text)
|
||||
if (invite == null) {
|
||||
LogManager.addLog("WARN", "INVITE", "Invalid invite link in join dialog")
|
||||
error = "Invalid invite link"
|
||||
error = AppLanguage.text(R.string.invalid_invite_link)
|
||||
} else {
|
||||
connecting = true
|
||||
LogManager.addLog("INFO", "INVITE", "connect: ch=0x%016x node=0x%016x addrs=%d"
|
||||
@@ -48,7 +51,7 @@ fun JoinChannelDialog(onConnect: (InviteData) -> Unit, onDismiss: () -> Unit) {
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Join Channel") },
|
||||
title = { Text(stringResource(R.string.join_channel)) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
@@ -63,11 +66,11 @@ fun JoinChannelDialog(onConnect: (InviteData) -> Unit, onDismiss: () -> Unit) {
|
||||
OutlinedTextField(
|
||||
value = link,
|
||||
onValueChange = { link = it },
|
||||
label = { Text("Invite link") },
|
||||
label = { Text(stringResource(R.string.invite_link)) },
|
||||
placeholder = { Text("utun://...") },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { link = clipboard.getText()?.text.orEmpty() }, enabled = !connecting) {
|
||||
Icon(Icons.Filled.ContentPaste, "Paste invite link")
|
||||
Icon(Icons.Filled.ContentPaste, stringResource(R.string.paste_invite_link))
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
@@ -76,10 +79,10 @@ fun JoinChannelDialog(onConnect: (InviteData) -> Unit, onDismiss: () -> Unit) {
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
if (connecting) Text("Connecting to peer...", color = MaterialTheme.colorScheme.primary)
|
||||
if (connecting) Text(stringResource(R.string.connecting_peer), color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import com.utun.chat.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -45,8 +47,8 @@ fun LogScreen(onBack: () -> Unit) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Logs") },
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } },
|
||||
title = { Text(stringResource(R.string.logs)) },
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, stringResource(R.string.back)) } },
|
||||
actions = {
|
||||
for ((lvl, label) in arrayOf("" to "ALL", "ERROR" to "ERR", "WARN" to "WRN", "INFO" to "INF", "DEBUG" to "DBG", "TRACE" to "TRC")) {
|
||||
val selected = filterLevel == lvl
|
||||
|
||||
+53
-52
@@ -1,5 +1,8 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import com.utun.chat.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
@@ -71,9 +74,9 @@ fun MemberListScreen(
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().navigationBarsPadding()) {
|
||||
TopAppBar(
|
||||
title = { Text(if (channelOwner) "Members 🔑 (${members.size})" else "Members (${members.size})") },
|
||||
title = { Text(stringResource(if (channelOwner) R.string.members_owner_count else R.string.members_count, members.size)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -184,23 +187,23 @@ private fun MemberListItem(
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (member.isSuper) {
|
||||
Icon(Icons.Filled.Bolt, "Supernode", tint = Color(0xFFFFC107), modifier = Modifier.size(16.dp))
|
||||
Icon(Icons.Filled.Bolt, stringResource(R.string.supernode), tint = Color(0xFFFFC107), modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
if (member.isAdmin) {
|
||||
Icon(Icons.Filled.Star, "Admin", tint = Color(0xFFFFD700), modifier = Modifier.size(16.dp))
|
||||
Icon(Icons.Filled.Star, stringResource(R.string.admin), tint = Color(0xFFFFD700), modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
if (member.isModer) {
|
||||
Icon(Icons.Filled.Shield, "Moderator", tint = Color(0xFF79B8FF), modifier = Modifier.size(16.dp))
|
||||
Icon(Icons.Filled.Shield, stringResource(R.string.moderator), tint = Color(0xFF79B8FF), modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
if (member.isStorage) {
|
||||
Icon(Icons.Filled.Storage, "Storage", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
|
||||
Icon(Icons.Filled.Storage, stringResource(R.string.storage), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
if (!member.isVerified) {
|
||||
Icon(Icons.Filled.Close, "Not verified", tint = Color.Red, modifier = Modifier.size(16.dp))
|
||||
Icon(Icons.Filled.Close, stringResource(R.string.not_verified), tint = Color.Red, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
Text(
|
||||
@@ -215,12 +218,12 @@ private fun MemberListItem(
|
||||
)
|
||||
}
|
||||
val connectionLabel = when {
|
||||
member.isSelf -> "self"
|
||||
member.directUp -> "D" + if (member.rtt > 0) ":${member.rtt / 10}ms" else ""
|
||||
member.bgpPresence -> "B" + if (member.bgpRtt > 0) ":${member.bgpRtt / 10}ms" else ""
|
||||
member.indirectUp -> "indirect↑" + if (member.rtt > 0) ":${member.rtt / 10}ms" else ""
|
||||
member.connecting -> "connecting…"
|
||||
else -> "offline"
|
||||
member.isSelf -> stringResource(R.string.self)
|
||||
member.directUp -> "D" + if (member.rtt > 0) stringResource(R.string.rtt_suffix, member.rtt / 10) else ""
|
||||
member.bgpPresence -> "B" + if (member.bgpRtt > 0) stringResource(R.string.rtt_suffix, member.bgpRtt / 10) else ""
|
||||
member.indirectUp -> stringResource(R.string.indirect_up) + if (member.rtt > 0) stringResource(R.string.rtt_suffix, member.rtt / 10) else ""
|
||||
member.connecting -> stringResource(R.string.connecting)
|
||||
else -> stringResource(R.string.offline)
|
||||
}
|
||||
Text(
|
||||
text = connectionLabel, fontSize = 11.sp,
|
||||
@@ -229,8 +232,8 @@ private fun MemberListItem(
|
||||
}
|
||||
|
||||
if (!member.isSelf) {
|
||||
IconButton(onClick = onCall, enabled = member.online) { Icon(Icons.Filled.Call, "Call") }
|
||||
IconButton(onClick = onMessage) { Icon(Icons.AutoMirrored.Filled.Message, "Message") }
|
||||
IconButton(onClick = onCall, enabled = member.online) { Icon(Icons.Filled.Call, stringResource(R.string.call)) }
|
||||
IconButton(onClick = onMessage) { Icon(Icons.AutoMirrored.Filled.Message, stringResource(R.string.message)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,26 +294,26 @@ private fun MemberDetailCard(
|
||||
if (!isSelf) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
if (detail.directPresence) {
|
||||
Text("Direct: ${if (detail.directUp) "UP" else "DOWN"}" +
|
||||
(if (detail.directUp && detail.bestRtt > 0) " ${detail.bestRtt / 10}ms" else ""),
|
||||
Text(stringResource(R.string.direct_state, if (detail.directUp) "UP" else "DOWN") +
|
||||
(if (detail.directUp && detail.bestRtt > 0) stringResource(R.string.rtt_spaced, detail.bestRtt / 10) else ""),
|
||||
fontSize = 13.sp, lineHeight = 16.sp, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
if (detail.indirectPresence) {
|
||||
Text("Indirect: ${if (detail.indirectUp) "UP" else "DOWN"}",
|
||||
Text(stringResource(R.string.indirect_state, if (detail.indirectUp) "UP" else "DOWN"),
|
||||
fontSize = 13.sp, lineHeight = 16.sp, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
if (detail.bgpPresence && detail.bgpNodes > 0) {
|
||||
Text("BGP: ${detail.bgpNodes} nodes" +
|
||||
(if (detail.bestRtt > 0) " ${detail.bestRtt / 10}ms" else ""),
|
||||
Text(pluralStringResource(R.plurals.bgp_nodes, detail.bgpNodes, detail.bgpNodes) +
|
||||
(if (detail.bestRtt > 0) stringResource(R.string.rtt_spaced, detail.bestRtt / 10) else ""),
|
||||
fontSize = 13.sp, lineHeight = 16.sp, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
if (!detail.directPresence && !detail.indirectPresence && !detail.bgpPresence) {
|
||||
Text("offline", fontSize = 13.sp, lineHeight = 16.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(stringResource(R.string.offline), fontSize = 13.sp, lineHeight = 16.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isSelf) TextButton(onClick = onConnect, enabled = !detail.directUp) { Text("Connect") }
|
||||
TextButton(onClick = onEdit, enabled = canEdit) { Text("Edit") }
|
||||
if (!isSelf) TextButton(onClick = onConnect, enabled = !detail.directUp) { Text(stringResource(R.string.connect)) }
|
||||
TextButton(onClick = onEdit, enabled = canEdit) { Text(stringResource(R.string.edit)) }
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
@@ -318,20 +321,20 @@ private fun MemberDetailCard(
|
||||
if (detail.lastSeen > 0) {
|
||||
val sec = System.currentTimeMillis() / 1000 - detail.lastSeen
|
||||
val ago = when {
|
||||
sec < 60 -> "${sec}s"
|
||||
sec < 3600 -> "${sec / 60}m"
|
||||
sec < 86400 -> "${sec / 3600}h"
|
||||
else -> "${sec / 86400}d"
|
||||
sec < 60 -> stringResource(R.string.seconds_short, sec)
|
||||
sec < 3600 -> stringResource(R.string.minutes_short, sec / 60)
|
||||
sec < 86400 -> stringResource(R.string.hours_short, sec / 3600)
|
||||
else -> stringResource(R.string.days_short, sec / 86400)
|
||||
}
|
||||
headerParts.add("seen $ago")
|
||||
headerParts.add(stringResource(R.string.last_seen_ago, ago))
|
||||
}
|
||||
if (detail.created > 0) {
|
||||
val sec = System.currentTimeMillis() / 1000 - detail.created
|
||||
val ago = when {
|
||||
sec < 604800 -> "${sec / 86400}d"
|
||||
else -> "${sec / 2592000}mo"
|
||||
sec < 604800 -> stringResource(R.string.days_short, sec / 86400)
|
||||
else -> stringResource(R.string.months_short, sec / 2592000)
|
||||
}
|
||||
headerParts.add("created $ago")
|
||||
headerParts.add(stringResource(R.string.created_ago, ago))
|
||||
}
|
||||
Text(
|
||||
text = headerParts.joinToString(" · "),
|
||||
@@ -343,7 +346,7 @@ private fun MemberDetailCard(
|
||||
if (isSelf) {
|
||||
if (localSocketStates.isNotEmpty()) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text("Listen sockets:", fontSize = 13.sp, fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.listen_sockets), fontSize = 13.sp, fontWeight = FontWeight.Medium)
|
||||
localSocketStates.forEach { sk ->
|
||||
val dot = if (sk.state == 2) Color(0xFF81C784) else Color(0xFFAEBCC8)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -361,7 +364,7 @@ private fun MemberDetailCard(
|
||||
} else {
|
||||
if (detail.addresses.isNotEmpty()) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text("Addresses:", fontSize = 13.sp, fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.addresses), fontSize = 13.sp, fontWeight = FontWeight.Medium)
|
||||
detail.addresses.forEachIndexed { idx, a ->
|
||||
Text(
|
||||
text = "[sock${a.socketId}] ${a.protocol} ${a.address}:${a.port} ${a.roleLabel}",
|
||||
@@ -376,7 +379,7 @@ private fun MemberDetailCard(
|
||||
|
||||
if (memberLinks.links.isNotEmpty()) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text("Connections:", fontSize = 13.sp, fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.connections), fontSize = 13.sp, fontWeight = FontWeight.Medium)
|
||||
memberLinks.links.forEachIndexed { idx, lk ->
|
||||
val dcol = if (lk.linkState == 3 && lk.linkStatus == 1) Color(0xFF81C784) else Color(0xFFFFB4AB)
|
||||
val arrow = if (lk.isServer) "◀" else "▶"
|
||||
@@ -440,13 +443,13 @@ private fun MemberEditDialog(
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Edit ${member.name}") },
|
||||
title = { Text(stringResource(R.string.edit_member, member.name)) },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = nick,
|
||||
onValueChange = { nick = it },
|
||||
label = { Text("Nickname") },
|
||||
label = { Text(stringResource(R.string.nickname)) },
|
||||
enabled = !member.isSelf,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
@@ -454,27 +457,27 @@ private fun MemberEditDialog(
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = admin, onCheckedChange = { admin = it }, enabled = isOwner)
|
||||
Text("Admin", modifier = Modifier.clickable(enabled = isOwner) { admin = !admin })
|
||||
Text(stringResource(R.string.admin), modifier = Modifier.clickable(enabled = isOwner) { admin = !admin })
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = supernode, onCheckedChange = { supernode = it }, enabled = isOwner)
|
||||
Text("Supernode", modifier = Modifier.clickable(enabled = isOwner) { supernode = !supernode })
|
||||
Text(stringResource(R.string.supernode), modifier = Modifier.clickable(enabled = isOwner) { supernode = !supernode })
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = moder, onCheckedChange = { moder = it }, enabled = isOwner)
|
||||
Text("Moderator", modifier = Modifier.clickable(enabled = isOwner) { moder = !moder })
|
||||
Text(stringResource(R.string.moderator), modifier = Modifier.clickable(enabled = isOwner) { moder = !moder })
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = storage, onCheckedChange = { storage = it }, enabled = isOwner)
|
||||
Text("Storage", modifier = Modifier.clickable(enabled = isOwner) { storage = !storage })
|
||||
Text(stringResource(R.string.storage), modifier = Modifier.clickable(enabled = isOwner) { storage = !storage })
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = verified, onCheckedChange = { verified = it }, enabled = isOwner)
|
||||
Text("Verified", modifier = Modifier.clickable(enabled = isOwner) { verified = !verified })
|
||||
Text(stringResource(R.string.verified), modifier = Modifier.clickable(enabled = isOwner) { verified = !verified })
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = deleted, onCheckedChange = { deleted = it }, enabled = isOwner)
|
||||
Text("Deleted", modifier = Modifier.clickable(enabled = isOwner) { deleted = !deleted })
|
||||
Text(stringResource(R.string.deleted), modifier = Modifier.clickable(enabled = isOwner) { deleted = !deleted })
|
||||
}
|
||||
if (isOwner && !member.isSelf) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
@@ -482,12 +485,12 @@ private fun MemberEditDialog(
|
||||
onClick = { showTransferConfirm = true },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Transfer admin rights")
|
||||
Text(stringResource(R.string.transfer_admin_rights))
|
||||
}
|
||||
} else if (!isOwner) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Admin/Super require channel private key",
|
||||
stringResource(R.string.admin_requires_key),
|
||||
fontSize = 11.sp,
|
||||
color = Color(0xFFAEBCC8)
|
||||
)
|
||||
@@ -495,29 +498,27 @@ private fun MemberEditDialog(
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onSave(nick, admin, supernode, moder, storage, verified, deleted) }) { Text("Save") }
|
||||
TextButton(onClick = { onSave(nick, admin, supernode, moder, storage, verified, deleted) }) { Text(stringResource(R.string.save)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
|
||||
}
|
||||
)
|
||||
|
||||
if (showTransferConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showTransferConfirm = false },
|
||||
title = { Text("Transfer admin rights") },
|
||||
title = { Text(stringResource(R.string.transfer_admin_rights)) },
|
||||
text = {
|
||||
Text(
|
||||
"Send the channel private key to \"${member.name}\"?\n\n" +
|
||||
"The key is transferred encrypted and signed. The recipient will become a co-owner " +
|
||||
"and will be able to manage members."
|
||||
stringResource(R.string.transfer_key_confirm, member.name)
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showTransferConfirm = false; onTransferAdmin() }) { Text("Send") }
|
||||
TextButton(onClick = { showTransferConfirm = false; onTransferAdmin() }) { Text(stringResource(R.string.send)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showTransferConfirm = false }) { Text("Cancel") }
|
||||
TextButton(onClick = { showTransferConfirm = false }) { Text(stringResource(R.string.cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+11
-8
@@ -1,5 +1,7 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import com.utun.chat.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
@@ -38,6 +40,7 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.utun.chat.data.CameraLensManager
|
||||
import com.utun.chat.data.LensType
|
||||
import com.utun.chat.data.LensOption
|
||||
import com.utun.chat.data.LogManager
|
||||
import com.utun.chat.data.PhotoResolution
|
||||
@@ -187,7 +190,7 @@ fun PhotoRecordScreen(
|
||||
providerRef.value = provider
|
||||
val list = CameraLensManager.enumerate(provider)
|
||||
lenses = list
|
||||
val default = list.firstOrNull { it.type.name == "SELFIE" } ?: list.firstOrNull()
|
||||
val default = list.firstOrNull { it.type == LensType.SELFIE } ?: list.firstOrNull()
|
||||
if (default != null) {
|
||||
selectLens(default)
|
||||
}
|
||||
@@ -206,9 +209,9 @@ fun PhotoRecordScreen(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Фото") },
|
||||
title = { Text(stringResource(R.string.photo)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { discardAndReset(); onClose() }) { Icon(Icons.Filled.Close, "Закрыть") }
|
||||
IconButton(onClick = { discardAndReset(); onClose() }) { Icon(Icons.Filled.Close, stringResource(R.string.close)) }
|
||||
},
|
||||
actions = {
|
||||
if (boundCamera?.cameraInfo?.hasFlashUnit() == true) {
|
||||
@@ -217,7 +220,7 @@ fun PhotoRecordScreen(
|
||||
try { boundCamera?.cameraControl?.enableTorch(next) } catch (_: Exception) {}
|
||||
torchOn = next
|
||||
}) {
|
||||
Icon(if (torchOn) Icons.Filled.FlashOn else Icons.Filled.FlashOff, "Подсветка", tint = if (torchOn) Color(0xFFFFD600) else Color.White)
|
||||
Icon(if (torchOn) Icons.Filled.FlashOn else Icons.Filled.FlashOff, stringResource(R.string.camera_light), tint = if (torchOn) Color(0xFFFFD600) else Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,9 +230,9 @@ fun PhotoRecordScreen(
|
||||
Box(Modifier.fillMaxSize().background(Color.Black).padding(padding)) {
|
||||
if (!hasCamera) {
|
||||
Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Нужно разрешение камеры", color = Color.White, fontSize = 16.sp)
|
||||
Text(stringResource(R.string.camera_permission_required), color = Color.White, fontSize = 16.sp)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(onClick = { camLauncher.launch(Manifest.permission.CAMERA) }) { Text("Камера") }
|
||||
Button(onClick = { camLauncher.launch(Manifest.permission.CAMERA) }) { Text(stringResource(R.string.camera)) }
|
||||
}
|
||||
} else if (phase == PhotoPhase.CAPTURED) {
|
||||
Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
@@ -312,7 +315,7 @@ fun PhotoRecordScreen(
|
||||
.clickable { discardAndReset() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.Filled.Delete, "Переснять", tint = Color.White, modifier = Modifier.size(26.dp))
|
||||
Icon(Icons.Filled.Delete, stringResource(R.string.retake), tint = Color.White, modifier = Modifier.size(26.dp))
|
||||
}
|
||||
Spacer(Modifier.width(28.dp))
|
||||
Box(
|
||||
@@ -323,7 +326,7 @@ fun PhotoRecordScreen(
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.Send, "Отправить", tint = Color.White, modifier = Modifier.size(26.dp))
|
||||
Icon(Icons.AutoMirrored.Filled.Send, stringResource(R.string.send), tint = Color.White, modifier = Modifier.size(26.dp))
|
||||
}
|
||||
} else {
|
||||
/* shutter */
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import com.utun.chat.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
@@ -76,7 +78,7 @@ fun PhotoViewerScreen(filePath: String, onClose: () -> Unit) {
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Filled.Close, "Закрыть", tint = Color.White)
|
||||
Icon(Icons.Filled.Close, stringResource(R.string.close), tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-5
@@ -1,5 +1,8 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import com.utun.chat.R
|
||||
import com.utun.chat.data.AppLanguage
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.ContextWrapper
|
||||
@@ -83,7 +86,7 @@ fun QrScannerPreview(modifier: Modifier = Modifier, onResult: (String) -> Unit)
|
||||
Box(modifier, contentAlignment = Alignment.Center) {
|
||||
if (!hasPermission) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Camera permission required")
|
||||
Text(stringResource(R.string.camera_permission_required))
|
||||
TextButton(onClick = {
|
||||
if (permanentDenial) {
|
||||
try {
|
||||
@@ -93,7 +96,7 @@ fun QrScannerPreview(modifier: Modifier = Modifier, onResult: (String) -> Unit)
|
||||
LogManager.addLog("ERROR", "QR", "Cannot open camera permission settings: $e")
|
||||
}
|
||||
} else launcher.launch(Manifest.permission.CAMERA)
|
||||
}) { Text(if (permanentDenial) "Settings" else "Grant") }
|
||||
}) { Text(stringResource(if (permanentDenial) R.string.settings else R.string.grant)) }
|
||||
}
|
||||
} else {
|
||||
val previewView = remember { PreviewView(context).apply { implementationMode = PreviewView.ImplementationMode.COMPATIBLE } }
|
||||
@@ -127,7 +130,7 @@ fun QrScannerPreview(modifier: Modifier = Modifier, onResult: (String) -> Unit)
|
||||
.addOnFailureListener(mainExecutor) { e ->
|
||||
if (!disposed.get()) {
|
||||
LogManager.addLog("ERROR", "QR", "QR scan failed: $e")
|
||||
cameraError = "QR scanning failed. Paste an invite link."
|
||||
cameraError = AppLanguage.text(R.string.qr_scan_failed)
|
||||
analysis.clearAnalyzer()
|
||||
}
|
||||
}
|
||||
@@ -137,7 +140,7 @@ fun QrScannerPreview(modifier: Modifier = Modifier, onResult: (String) -> Unit)
|
||||
imageProxy.close()
|
||||
mainExecutor.execute {
|
||||
if (!disposed.get()) {
|
||||
cameraError = "QR scanning failed. Paste an invite link."
|
||||
cameraError = AppLanguage.text(R.string.qr_scan_failed)
|
||||
analysis.clearAnalyzer()
|
||||
}
|
||||
}
|
||||
@@ -153,7 +156,7 @@ fun QrScannerPreview(modifier: Modifier = Modifier, onResult: (String) -> Unit)
|
||||
LogManager.addLog("INFO", "QR", "Join dialog camera started")
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "QR", "Camera initialization failed: $e")
|
||||
cameraError = "Camera unavailable. Paste an invite link."
|
||||
cameraError = AppLanguage.text(R.string.camera_unavailable)
|
||||
}
|
||||
}
|
||||
}, mainExecutor)
|
||||
|
||||
+103
-97
@@ -1,5 +1,7 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import com.utun.chat.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -10,6 +12,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -26,6 +29,7 @@ import com.utun.chat.data.LogManager
|
||||
import com.utun.chat.data.ServerEntry
|
||||
import com.utun.chat.data.LocalSocketState
|
||||
import com.utun.chat.headless.HeadlessService
|
||||
import com.utun.chat.ui.components.LanguageSetting
|
||||
import com.utun.chat.viewmodel.ChatViewModel
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
@@ -55,10 +59,10 @@ private val PROTOCOLS = listOf("tcp", "udp")
|
||||
fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = {}) {
|
||||
val provider = ChatApplication.instance.configProvider
|
||||
val scope = rememberCoroutineScope()
|
||||
var dirty by remember { mutableStateOf(false) }
|
||||
var dirty by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
var tabIndex by remember { mutableStateOf(0) }
|
||||
val tabs = listOf("Settings", "Audio", "Storage", "Radio")
|
||||
var tabIndex by rememberSaveable { mutableStateOf(0) }
|
||||
val tabs = listOf(R.string.settings, R.string.audio, R.string.storage, R.string.radio)
|
||||
|
||||
val handleBack = {
|
||||
if (dirty && NativeLib.isReady()) {
|
||||
@@ -75,15 +79,15 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Settings") },
|
||||
navigationIcon = { IconButton(onClick = handleBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }
|
||||
title = { Text(stringResource(R.string.settings)) },
|
||||
navigationIcon = { IconButton(onClick = handleBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) } }
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.padding(padding)) {
|
||||
TabRow(selectedTabIndex = tabIndex) {
|
||||
ScrollableTabRow(selectedTabIndex = tabIndex, edgePadding = 0.dp) {
|
||||
tabs.forEachIndexed { idx, name ->
|
||||
Tab(selected = tabIndex == idx, onClick = { tabIndex = idx }, text = { Text(name) })
|
||||
Tab(selected = tabIndex == idx, onClick = { tabIndex = idx }, text = { Text(stringResource(name), maxLines = 1) })
|
||||
}
|
||||
}
|
||||
when (tabIndex) {
|
||||
@@ -187,12 +191,14 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
|
||||
modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
LanguageSetting()
|
||||
|
||||
/* Node */
|
||||
Text("Node", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.node), style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(nodeName, { v -> onDirty(true); nodeName = v; scope.launch { provider.setValue("node.name", v) } },
|
||||
label = { Text("Node Name") }, singleLine = true, modifier = Modifier.heightIn(min = 40.dp))
|
||||
label = { Text(stringResource(R.string.node_name)) }, singleLine = true, modifier = Modifier.heightIn(min = 40.dp))
|
||||
OutlinedTextField(listenPort, { v -> onDirty(true); listenPort = v; scope.launch { provider.setValue("node.listen_port", v) } },
|
||||
label = { Text("Listen Port") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
label = { Text(stringResource(R.string.listen_port)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true, modifier = Modifier.heightIn(min = 40.dp))
|
||||
|
||||
/* Control interface */
|
||||
@@ -201,7 +207,7 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ExposedDropdownMenuBox(expanded = ctrlExpanded, onExpandedChange = { ctrlExpanded = it }, modifier = Modifier.weight(1f)) {
|
||||
OutlinedTextField(ctrlBindLabel, {}, readOnly = true, singleLine = true,
|
||||
label = { Text("Control Bind IP") },
|
||||
label = { Text(stringResource(R.string.control_bind_ip)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(ctrlExpanded) },
|
||||
modifier = Modifier.menuAnchor().heightIn(min = 40.dp))
|
||||
ExposedDropdownMenu(ctrlExpanded, onDismissRequest = { ctrlExpanded = false }) {
|
||||
@@ -215,37 +221,37 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
OutlinedTextField(controlPort, { v -> onDirty(true); controlPort = v; scope.launch { provider.setValue("control.port", v) } },
|
||||
label = { Text("Port") }, modifier = Modifier.width(80.dp).heightIn(min = 40.dp),
|
||||
label = { Text(stringResource(R.string.port)) }, modifier = Modifier.width(80.dp).heightIn(min = 40.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
|
||||
}
|
||||
|
||||
/* Keys */
|
||||
Text("Keys", style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(pubKey, {}, label = { Text("Public Key") }, readOnly = true, singleLine = true)
|
||||
Text(stringResource(R.string.keys), style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(pubKey, {}, label = { Text(stringResource(R.string.public_key)) }, readOnly = true, singleLine = true)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { if (pubKey.length == 64) showOverwriteKeys = true else { onDirty(true); vm.requestRegenerateKeys() } },
|
||||
enabled = !generatingKeys) { Text(if (generatingKeys) "Generating..." else "Generate Keys") }
|
||||
enabled = !generatingKeys) { Text(stringResource(if (generatingKeys) R.string.generating else R.string.generate_keys)) }
|
||||
Button(onClick = { showClearDb = true },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) {
|
||||
Icon(Icons.Default.Delete, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text("Clear DB")
|
||||
Icon(Icons.Default.Delete, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text(stringResource(R.string.clear_db))
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
/* ── Connection Mode ── */
|
||||
Text("Network Interfaces", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.network_interfaces), style = MaterialTheme.typography.titleMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(selected = connectionMode == "auto", onClick = { if (connectionMode != "auto") switchMode("auto") },
|
||||
label = { Text("Auto") })
|
||||
label = { Text(stringResource(R.string.automatic)) })
|
||||
FilterChip(selected = connectionMode == "manual", onClick = { if (connectionMode != "manual") switchMode("manual") },
|
||||
label = { Text("Manual") })
|
||||
label = { Text(stringResource(R.string.manual)) })
|
||||
}
|
||||
|
||||
if (connectionMode == "auto") {
|
||||
/* Auto mode: read-only live socket display */
|
||||
if (localSocketStates.isEmpty()) {
|
||||
Text("No sockets detected", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(stringResource(R.string.no_sockets), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
} else {
|
||||
localSocketStates.forEach { s ->
|
||||
val stateColor = when (s.state) { 2 -> Color(0xFF81C784); 1 -> Color(0xFFF1C40F); else -> Color(0xFFFFB4AB) }
|
||||
@@ -292,7 +298,7 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
|
||||
Text(srv.bindIp, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1, modifier = Modifier.width(105.dp))
|
||||
IconButton(onClick = { save(servers.toMutableList().also { it.removeAt(idx) }) }, modifier = Modifier.size(28.dp)) {
|
||||
Icon(Icons.Default.Close, "Remove", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error)
|
||||
Icon(Icons.Default.Close, stringResource(R.string.remove), modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -323,42 +329,42 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
|
||||
val iface = availableIfaces.firstOrNull() ?: ("lo" to "127.0.0.1")
|
||||
val rndPort = (2000..9999).random()
|
||||
save(servers + ServerEntry(UUID.randomUUID().toString(), iface.first, iface.second, "tcp", rndPort))
|
||||
}, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Default.Add, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text("Add interface") }
|
||||
}, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Default.Add, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text(stringResource(R.string.add_interface)) }
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
/* ── Standby (background duty-cycle) ── */
|
||||
Text("Standby", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.standby), style = MaterialTheme.typography.titleMedium)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(standbyActiveSec,
|
||||
{ v -> standbyActiveSec = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..3600) {
|
||||
scope.launch { provider.setValue("chat.standby_active_sec", v) }; NativeLib.setChatSetting("standby_active_sec", v) } },
|
||||
label = { Text("Active (sec)") }, modifier = Modifier.weight(1f).heightIn(min = 40.dp),
|
||||
label = { Text(stringResource(R.string.standby_active_seconds)) }, modifier = Modifier.weight(1f).heightIn(min = 40.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
|
||||
OutlinedTextField(standbySleepSec,
|
||||
{ v -> standbySleepSec = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..86400) {
|
||||
scope.launch { provider.setValue("chat.standby_sleep_sec", v) }; NativeLib.setChatSetting("standby_sleep_sec", v) } },
|
||||
label = { Text("Sleep (sec)") }, modifier = Modifier.weight(1f).heightIn(min = 40.dp),
|
||||
label = { Text(stringResource(R.string.standby_sleep_seconds)) }, modifier = Modifier.weight(1f).heightIn(min = 40.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
|
||||
}
|
||||
OutlinedTextField(standbyMinSleepSec,
|
||||
{ v -> standbyMinSleepSec = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..86400) {
|
||||
scope.launch { provider.setValue("chat.standby_min_sleep_sec", v) }; NativeLib.setChatSetting("standby_min_sleep_sec", v) } },
|
||||
label = { Text("Min sleep (sec)") }, modifier = Modifier.heightIn(min = 40.dp),
|
||||
label = { Text(stringResource(R.string.standby_min_sleep_seconds)) }, modifier = Modifier.heightIn(min = 40.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
|
||||
OutlinedTextField(standbyKeepaliveMs,
|
||||
{ v -> standbyKeepaliveMs = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1000..600000) {
|
||||
scope.launch { provider.setValue("chat.standby_keepalive_ms", v) }; NativeLib.setChatSetting("standby_keepalive_ms", v) } },
|
||||
label = { Text("Keepalive in sleep (ms)") }, modifier = Modifier.heightIn(min = 40.dp),
|
||||
label = { Text(stringResource(R.string.standby_keepalive_ms)) }, modifier = Modifier.heightIn(min = 40.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
/* ── Chat ── */
|
||||
Text("Chat", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.chat), style = MaterialTheme.typography.titleMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Auto-connect to group nodes", modifier = Modifier.weight(1f))
|
||||
Text(stringResource(R.string.group_autoconnect), modifier = Modifier.weight(1f))
|
||||
Switch(checked = autoConnect, onCheckedChange = {
|
||||
autoConnect = it
|
||||
val s = if (it) "1" else "0"
|
||||
@@ -370,31 +376,31 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
/* Debug */
|
||||
Text("Debug", style = MaterialTheme.typography.titleMedium)
|
||||
DebugLevelDropdown(label = "Global Level", current = debugLevel.uppercase(), onSelect = setDebug)
|
||||
Text(stringResource(R.string.debug), style = MaterialTheme.typography.titleMedium)
|
||||
DebugLevelDropdown(label = stringResource(R.string.global_level), current = debugLevel.uppercase(), onSelect = setDebug)
|
||||
OutlinedTextField(logFile, { v -> logFile = v; scope.launch { provider.setValue("debug.log_file", v) } },
|
||||
label = { Text("Log File") }, singleLine = true)
|
||||
label = { Text(stringResource(R.string.log_file)) }, singleLine = true)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(logUdpIp, { v ->
|
||||
logUdpIp = v; scope.launch { provider.setValue("log_udp.ip", v) }
|
||||
val p = logUdpPort.toIntOrNull() ?: 0
|
||||
if (v.isNotEmpty() && p > 0) NativeLib.setUdpLogTarget(v, p) else NativeLib.setUdpLogTarget("", 0)
|
||||
}, label = { Text("UDP Log IP") }, modifier = Modifier.weight(1f).heightIn(min = 40.dp), singleLine = true)
|
||||
}, label = { Text(stringResource(R.string.udp_log_ip)) }, modifier = Modifier.weight(1f).heightIn(min = 40.dp), singleLine = true)
|
||||
OutlinedTextField(logUdpPort, { v ->
|
||||
logUdpPort = v; val p = v.toIntOrNull() ?: 0; scope.launch { provider.setValue("log_udp.port", v) }
|
||||
if (logUdpIp.isNotEmpty() && p > 0 && p <= 65535) NativeLib.setUdpLogTarget(logUdpIp, p) else NativeLib.setUdpLogTarget("", 0)
|
||||
}, label = { Text("Port") }, modifier = Modifier.width(80.dp).heightIn(min = 40.dp),
|
||||
}, label = { Text(stringResource(R.string.port)) }, modifier = Modifier.width(80.dp).heightIn(min = 40.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth().clickable { showDebugCategories = !showDebugCategories },
|
||||
horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text("Categories", style = MaterialTheme.typography.titleMedium)
|
||||
Icon(Icons.Default.ArrowDropDown, if (showDebugCategories) "Collapse" else "Expand", modifier = Modifier.padding(end = 4.dp))
|
||||
Text(stringResource(R.string.categories), style = MaterialTheme.typography.titleMedium)
|
||||
Icon(Icons.Default.ArrowDropDown, stringResource(if (showDebugCategories) R.string.collapse else R.string.expand), modifier = Modifier.padding(end = 4.dp))
|
||||
}
|
||||
if (showDebugCategories) {
|
||||
CATEGORIES.forEach { cat ->
|
||||
val current = catLevels[cat] ?: ""
|
||||
DebugLevelDropdown(label = cat, current = current.ifEmpty { "(global)" },
|
||||
DebugLevelDropdown(label = cat, current = current.ifEmpty { stringResource(R.string.global_level_hint) },
|
||||
onSelect = { setCatLevel(cat, if (it == "NONE") "" else it) }, compact = true)
|
||||
}
|
||||
}
|
||||
@@ -402,21 +408,21 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
|
||||
|
||||
/* ── Background service ── */
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
Text("Service", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.service), style = MaterialTheme.typography.titleMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(if (serviceEnabled) "Running" else "Stopped", modifier = Modifier.weight(1f),
|
||||
Text(stringResource(if (serviceEnabled) R.string.service_running else R.string.service_stopped), modifier = Modifier.weight(1f),
|
||||
color = if (serviceEnabled) Color(0xFF81C784) else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
Button(onClick = {
|
||||
runBlocking { provider.setServiceEnabled(true) }
|
||||
ContextCompat.startForegroundService(ChatApplication.instance,
|
||||
Intent(ChatApplication.instance, HeadlessService::class.java))
|
||||
}, enabled = !serviceEnabled) { Text("Start service") }
|
||||
}, enabled = !serviceEnabled) { Text(stringResource(R.string.service_start)) }
|
||||
Button(onClick = {
|
||||
runBlocking { provider.setServiceEnabled(false) }
|
||||
ChatApplication.instance.stopService(Intent(ChatApplication.instance, HeadlessService::class.java))
|
||||
}, enabled = serviceEnabled,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text("Stop service") }
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text(stringResource(R.string.service_stop)) }
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
@@ -429,7 +435,7 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
|
||||
}
|
||||
}
|
||||
}, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)) {
|
||||
Icon(Icons.Default.Refresh, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text("Restart instance")
|
||||
Icon(Icons.Default.Refresh, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.instance_restart))
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(onClick = {
|
||||
@@ -438,21 +444,21 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
|
||||
onExit()
|
||||
}, modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) {
|
||||
Icon(Icons.Default.PowerSettingsNew, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text("Stop All & Exit")
|
||||
Icon(Icons.Default.PowerSettingsNew, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.stop_all_exit))
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
/* Dialogs */
|
||||
if (showOverwriteKeys) AlertDialog(
|
||||
onDismissRequest = { showOverwriteKeys = false }, title = { Text("Overwrite Keys?") },
|
||||
text = { Text("Keys already exist. Generating new keys will invalidate all existing connections and channels.") },
|
||||
confirmButton = { Button(onClick = { showOverwriteKeys = false; onDirty(true); vm.requestRegenerateKeys() }) { Text("Overwrite") } },
|
||||
dismissButton = { TextButton(onClick = { showOverwriteKeys = false }) { Text("Cancel") } })
|
||||
onDismissRequest = { showOverwriteKeys = false }, title = { Text(stringResource(R.string.overwrite_keys_question)) },
|
||||
text = { Text(stringResource(R.string.overwrite_keys_warning)) },
|
||||
confirmButton = { Button(onClick = { showOverwriteKeys = false; onDirty(true); vm.requestRegenerateKeys() }) { Text(stringResource(R.string.overwrite)) } },
|
||||
dismissButton = { TextButton(onClick = { showOverwriteKeys = false }) { Text(stringResource(R.string.cancel)) } })
|
||||
|
||||
if (showClearDb) AlertDialog(
|
||||
onDismissRequest = { showClearDb = false }, title = { Text("Clear Database?") },
|
||||
text = { Text("This will delete all channels, messages, and peers.\nThis cannot be undone.") },
|
||||
onDismissRequest = { showClearDb = false }, title = { Text(stringResource(R.string.clear_db_question)) },
|
||||
text = { Text(stringResource(R.string.clear_db_warning)) },
|
||||
confirmButton = { Button(onClick = {
|
||||
showClearDb = false; clearingDb = true
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -465,8 +471,8 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
|
||||
}
|
||||
withContext(Dispatchers.Main) { clearingDb = false }
|
||||
}
|
||||
}, enabled = !clearingDb, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text("Delete All") } },
|
||||
dismissButton = { TextButton(onClick = { showClearDb = false }) { Text("Cancel") } })
|
||||
}, enabled = !clearingDb, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text(stringResource(R.string.delete_all)) } },
|
||||
dismissButton = { TextButton(onClick = { showClearDb = false }) { Text(stringResource(R.string.cancel)) } })
|
||||
}
|
||||
|
||||
/* ── Radio Settings Tab ── */
|
||||
@@ -485,9 +491,9 @@ private fun RadioSettingsTab() {
|
||||
var vadThreshold by remember { mutableStateOf(provider.getInt("radio.vad_threshold")) }
|
||||
var vadHangover by remember { mutableStateOf(provider.getInt("radio.vad_hangover_ms")) }
|
||||
val vadActive by RadioAudioEngine.instance().vadActiveFlow.collectAsState()
|
||||
Text("Автоматическая передача по голосу (VAD)", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.radio_vad), style = MaterialTheme.typography.titleMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Включить VAD", modifier = Modifier.weight(1f))
|
||||
Text(stringResource(R.string.enable_vad), modifier = Modifier.weight(1f))
|
||||
Switch(checked = vadEnabled, onCheckedChange = { enabled ->
|
||||
vadEnabled = enabled
|
||||
scope.launch {
|
||||
@@ -498,11 +504,11 @@ private fun RadioSettingsTab() {
|
||||
}
|
||||
})
|
||||
}
|
||||
Text(if (vadActive) "VAD активен: микрофон включён" else "VAD неактивен",
|
||||
Text(stringResource(if (vadActive) R.string.vad_active else R.string.vad_inactive),
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
Text("Работает 15 минут при включённом экране. При следующем включении экрана запускается автоматически.",
|
||||
Text(stringResource(R.string.vad_screen_hint),
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
LabeledSlider("Порог речи:", vadThreshold.toFloat(), 10f..95f, 84, "$vadThreshold%",
|
||||
LabeledSlider(stringResource(R.string.speech_threshold), vadThreshold.toFloat(), 10f..95f, 84, "$vadThreshold%",
|
||||
{ vadThreshold = it.toInt() }, onValueChangeFinished = {
|
||||
scope.launch {
|
||||
provider.setValue("radio.vad_threshold", vadThreshold.toString())
|
||||
@@ -510,7 +516,7 @@ private fun RadioSettingsTab() {
|
||||
RadioAudioEngine.instance().reconfigure()
|
||||
}
|
||||
})
|
||||
LabeledSlider("Задержка после тишины:", vadHangover.toFloat(), 50f..2000f, 38, "$vadHangover мс",
|
||||
LabeledSlider(stringResource(R.string.silence_delay), vadHangover.toFloat(), 50f..2000f, 38, stringResource(R.string.milliseconds, vadHangover),
|
||||
{ vadHangover = it.toInt() }, onValueChangeFinished = {
|
||||
scope.launch {
|
||||
provider.setValue("radio.vad_hangover_ms", vadHangover.toString())
|
||||
@@ -523,7 +529,7 @@ private fun RadioSettingsTab() {
|
||||
/* ── Volume-кнопки PTT ── */
|
||||
var volumePtt by remember { mutableStateOf(provider.getRadioVolumePtt()) }
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Кнопки громкости (удержание) — передача", modifier = Modifier.weight(1f))
|
||||
Text(stringResource(R.string.volume_ptt), modifier = Modifier.weight(1f))
|
||||
Switch(checked = volumePtt, onCheckedChange = {
|
||||
volumePtt = it
|
||||
scope.launch { provider.setRadioVolumePtt(it) }
|
||||
@@ -538,9 +544,9 @@ private fun RadioSettingsTab() {
|
||||
val overlayStatus by RadioOverlayService.status.collectAsState()
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("PTT-кнопка поверх приложений")
|
||||
Text(stringResource(R.string.ptt_overlay))
|
||||
if (overlayEnabled) Text(
|
||||
overlayStatus ?: if (overlayShown) "Плавающая кнопка активна" else "Ожидание включения",
|
||||
overlayStatus?.let { stringResource(it) } ?: stringResource(if (overlayShown) R.string.overlay_active else R.string.waiting_enable),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable { RadioOverlayService.requestPermissions() }
|
||||
@@ -555,11 +561,11 @@ private fun RadioSettingsTab() {
|
||||
|
||||
/* ── Источник микрофона ── */
|
||||
val micSources = listOf(
|
||||
"Голос (VOICE_COMMUNICATION)", "Микрофон (MIC)",
|
||||
"Без обработки (UNPROCESSED)", "Камера/видео (CAMCORDER)")
|
||||
stringResource(R.string.mic_source_voice), stringResource(R.string.mic_source_mic),
|
||||
stringResource(R.string.mic_source_raw), stringResource(R.string.mic_source_camera))
|
||||
var micSource by remember { mutableStateOf(provider.getRadioMicSource().coerceIn(0, micSources.size - 1)) }
|
||||
var micExpanded by remember { mutableStateOf(false) }
|
||||
Text("Источник микрофона", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.microphone_source), style = MaterialTheme.typography.titleMedium)
|
||||
ExposedDropdownMenuBox(expanded = micExpanded, onExpandedChange = { micExpanded = it }) {
|
||||
OutlinedTextField(micSources[micSource], {}, readOnly = true, singleLine = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(micExpanded) },
|
||||
@@ -584,8 +590,8 @@ private fun RadioSettingsTab() {
|
||||
var captureStereo by remember { mutableStateOf(provider.getRadioCaptureStereo()) }
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Захват стерео")
|
||||
Text("Воспроизведение всегда стерео; моно-пиры апмиксуются",
|
||||
Text(stringResource(R.string.stereo_capture))
|
||||
Text(stringResource(R.string.stereo_playback_hint),
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Switch(checked = captureStereo, onCheckedChange = {
|
||||
@@ -603,9 +609,9 @@ private fun RadioSettingsTab() {
|
||||
/* ── Компрессор (AGC) рации ── */
|
||||
var radioCompOn by remember { mutableStateOf(provider.getInt("radio.compressor_enabled") != 0) }
|
||||
var radioCompMaxGain by remember { mutableStateOf(provider.getInt("radio.compressor_max_gain_db")) }
|
||||
Text("Компрессор (AGC) рации", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.radio_compressor), style = MaterialTheme.typography.titleMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Enable compressor", modifier = Modifier.weight(1f))
|
||||
Text(stringResource(R.string.enable_compressor), modifier = Modifier.weight(1f))
|
||||
Switch(checked = radioCompOn, onCheckedChange = {
|
||||
radioCompOn = it
|
||||
scope.launch { provider.setValue("radio.compressor_enabled", if (it) "1" else "0") }
|
||||
@@ -613,7 +619,7 @@ private fun RadioSettingsTab() {
|
||||
RadioAudioEngine.instance().reconfigure()
|
||||
})
|
||||
}
|
||||
LabeledSlider("Max gain:", radioCompMaxGain.toFloat(), 1f..45f, 44, "${radioCompMaxGain} dB",
|
||||
LabeledSlider(stringResource(R.string.max_gain), radioCompMaxGain.toFloat(), 1f..45f, 44, "${radioCompMaxGain} dB",
|
||||
{ v ->
|
||||
radioCompMaxGain = v.toInt()
|
||||
NativeLib.setChatSetting("radio_compressor_max_gain_db", radioCompMaxGain.toString())
|
||||
@@ -626,13 +632,13 @@ private fun RadioSettingsTab() {
|
||||
HorizontalDivider(Modifier.padding(vertical = 4.dp))
|
||||
|
||||
/* ── Opus для рации: режим + битрейт ── */
|
||||
val opusModes = listOf("Голос (VOIP)", "Музыка (AUDIO)")
|
||||
val opusModes = listOf(stringResource(R.string.opus_voice), stringResource(R.string.opus_music))
|
||||
val opusBitrates = listOf(16, 32, 64, 96, 128)
|
||||
var opusMode by remember { mutableStateOf(provider.getInt("radio.opus_mode").coerceIn(0, 1)) }
|
||||
var opusModeExpanded by remember { mutableStateOf(false) }
|
||||
var opusBitrate by remember { mutableStateOf(provider.getInt("radio.opus_bitrate")) }
|
||||
var opusBitrateExpanded by remember { mutableStateOf(false) }
|
||||
Text("Opus (рация)", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.radio_opus), style = MaterialTheme.typography.titleMedium)
|
||||
ExposedDropdownMenuBox(expanded = opusModeExpanded, onExpandedChange = { opusModeExpanded = it }) {
|
||||
OutlinedTextField(opusModes[opusMode], {}, readOnly = true, singleLine = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(opusModeExpanded) },
|
||||
@@ -650,12 +656,12 @@ private fun RadioSettingsTab() {
|
||||
}
|
||||
}
|
||||
ExposedDropdownMenuBox(expanded = opusBitrateExpanded, onExpandedChange = { opusBitrateExpanded = it }) {
|
||||
OutlinedTextField("${opusBitrate} kbps", {}, readOnly = true, singleLine = true,
|
||||
OutlinedTextField(stringResource(R.string.bitrate_kbps, opusBitrate), {}, readOnly = true, singleLine = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(opusBitrateExpanded) },
|
||||
modifier = Modifier.menuAnchor().heightIn(min = 40.dp))
|
||||
ExposedDropdownMenu(opusBitrateExpanded, onDismissRequest = { opusBitrateExpanded = false }) {
|
||||
opusBitrates.forEach { b ->
|
||||
DropdownMenuItem(text = { Text("$b kbps") }, onClick = {
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.bitrate_kbps, b)) }, onClick = {
|
||||
opusBitrate = b
|
||||
scope.launch { provider.setValue("radio.opus_bitrate", b.toString()) }
|
||||
NativeLib.setChatSetting("radio_opus_bitrate", b.toString())
|
||||
@@ -690,7 +696,7 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
|
||||
}
|
||||
|
||||
/* Codec preset */
|
||||
val presets = listOf("Low (16 kbps)", "Standard (32 kbps)", "High (64 kbps)")
|
||||
val presets = listOf(stringResource(R.string.codec_low), stringResource(R.string.codec_standard), stringResource(R.string.codec_high))
|
||||
var preset by remember { mutableStateOf(vm.getVoicePreset()) }
|
||||
var presetExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -725,9 +731,9 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
/* ── Notification sound ── */
|
||||
Text("Notification sound", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.notification_sound), style = MaterialTheme.typography.titleMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Play sound on incoming message", modifier = Modifier.weight(1f))
|
||||
Text(stringResource(R.string.incoming_message_sound), modifier = Modifier.weight(1f))
|
||||
Switch(checked = soundEnabled, onCheckedChange = {
|
||||
soundEnabled = it
|
||||
NotificationSound.enabled = it
|
||||
@@ -738,7 +744,7 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
|
||||
HorizontalDivider(Modifier.padding(vertical = 4.dp))
|
||||
|
||||
/* ── Входящие звонки поверх блокировки ── */
|
||||
Text("Incoming calls", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.incoming_calls), style = MaterialTheme.typography.titleMedium)
|
||||
var fsiEnabled by remember { mutableStateOf(IncomingCallManager.canFullScreenIntent()) }
|
||||
LifecycleResumeEffect(Unit) {
|
||||
fsiEnabled = IncomingCallManager.canFullScreenIntent()
|
||||
@@ -761,12 +767,12 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Show over lock screen")
|
||||
Text(stringResource(R.string.show_over_lock_screen))
|
||||
Text(
|
||||
when {
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> "Always allowed"
|
||||
fsiEnabled -> "Allowed"
|
||||
else -> "Disabled — tap to enable"
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> stringResource(R.string.always_allowed)
|
||||
fsiEnabled -> stringResource(R.string.allowed)
|
||||
else -> stringResource(R.string.disabled_tap_enable)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (fsiEnabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error
|
||||
@@ -777,7 +783,7 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
|
||||
HorizontalDivider(Modifier.padding(vertical = 4.dp))
|
||||
|
||||
/* ── Microphone test ── */
|
||||
Text("Microphone test", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.microphone_test), style = MaterialTheme.typography.titleMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val lvlPct = micLevel.coerceIn(0f, 1f)
|
||||
LinearProgressIndicator(
|
||||
@@ -792,13 +798,13 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
|
||||
if (vm.audioRecorder.startMicTest()) { micTesting = true; micLevel = 0f }
|
||||
}
|
||||
apply()
|
||||
}) { Text(if (micTesting) "Stop" else "Test") }
|
||||
}) { Text(stringResource(if (micTesting) R.string.stop else R.string.test)) }
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 4.dp))
|
||||
|
||||
/* ── Voice codec quality ── */
|
||||
Text("Voice codec quality", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.voice_codec_quality), style = MaterialTheme.typography.titleMedium)
|
||||
ExposedDropdownMenuBox(expanded = presetExpanded, onExpandedChange = { presetExpanded = it }) {
|
||||
OutlinedTextField(presets[preset], {}, readOnly = true, singleLine = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(presetExpanded) },
|
||||
@@ -812,8 +818,8 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 4.dp))
|
||||
|
||||
Text("Звонки: максимальный запас джиттер-буфера", style = MaterialTheme.typography.titleMedium)
|
||||
Text("${"%.1f".format(jitterReserveMs / 1000.0)} с · применяется к следующему звонку")
|
||||
Text(stringResource(R.string.call_max_jitter_reserve), style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.call_jitter_hint, "%.1f".format(jitterReserveMs / 1000.0)))
|
||||
Slider(
|
||||
value = jitterReserveMs.toFloat(),
|
||||
onValueChange = { jitterReserveMs = (it / 100).toInt() * 100 },
|
||||
@@ -826,21 +832,21 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
|
||||
)
|
||||
|
||||
/* ── Voice Compressor ── */
|
||||
Text("Voice Compressor", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.voice_compressor), style = MaterialTheme.typography.titleMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Enable compressor", modifier = Modifier.weight(1f))
|
||||
Text(stringResource(R.string.enable_compressor), modifier = Modifier.weight(1f))
|
||||
Switch(checked = compressorOn, onCheckedChange = { compressorOn = it; apply() })
|
||||
}
|
||||
|
||||
LabeledSlider("Max gain:", maxGainDb.toFloat(), 0f..30f, 30, "${maxGainDb} dB",
|
||||
LabeledSlider(stringResource(R.string.max_gain), maxGainDb.toFloat(), 0f..30f, 30, "${maxGainDb} dB",
|
||||
{ maxGainDb = it.toInt(); apply() })
|
||||
LabeledSlider("Lookback time:", lookbackMs.toFloat(), 50f..500f, 45, "${lookbackMs} ms",
|
||||
LabeledSlider(stringResource(R.string.lookback_time), lookbackMs.toFloat(), 50f..500f, 45, stringResource(R.string.milliseconds, lookbackMs),
|
||||
{ lookbackMs = it.toInt(); apply() })
|
||||
LabeledSlider("Lookahead time:", lookaheadMs.toFloat(), 20f..500f, 48, "${lookaheadMs} ms",
|
||||
LabeledSlider(stringResource(R.string.lookahead_time), lookaheadMs.toFloat(), 20f..500f, 48, stringResource(R.string.milliseconds, lookaheadMs),
|
||||
{ lookaheadMs = it.toInt(); apply() })
|
||||
LabeledSlider("Rise rate (x/sec):", riseRate.toFloat(), 2f..10f, 8, "${riseRate}x",
|
||||
LabeledSlider(stringResource(R.string.rise_rate), riseRate.toFloat(), 2f..10f, 8, "${riseRate}x",
|
||||
{ riseRate = it.toInt(); apply() })
|
||||
LabeledSlider("Target level:", targetLevelDb.toFloat(), -40f..0f, 40, "${targetLevelDb} dBFS",
|
||||
LabeledSlider(stringResource(R.string.target_level), targetLevelDb.toFloat(), -40f..0f, 40, "${targetLevelDb} dBFS",
|
||||
{ targetLevelDb = it.toInt(); apply() })
|
||||
}
|
||||
}
|
||||
@@ -866,9 +872,9 @@ private fun StorageSettingsTab() {
|
||||
modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text("Media Storage", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.media_storage), style = MaterialTheme.typography.titleMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Autoload media", modifier = Modifier.weight(1f))
|
||||
Text(stringResource(R.string.autoload_media), modifier = Modifier.weight(1f))
|
||||
Switch(checked = autoload, onCheckedChange = {
|
||||
autoload = it
|
||||
val s = if (it) "1" else "0"
|
||||
@@ -881,13 +887,13 @@ private fun StorageSettingsTab() {
|
||||
|
||||
OutlinedTextField(autoloadMaxSizeMb,
|
||||
{ v -> autoloadMaxSizeMb = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..500) { scope.launch { provider.setValue("chat.storage_autoload_maxsize_mb", v) }; NativeLib.setChatSetting("storage_autoload_maxsize_mb", v) } },
|
||||
label = { Text("Autoload max size (MB)") },
|
||||
label = { Text(stringResource(R.string.autoload_max_size)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true, modifier = Modifier.padding(top = 4.dp))
|
||||
|
||||
OutlinedTextField(maxStorageGb,
|
||||
{ v -> maxStorageGb = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..100) { scope.launch { provider.setValue("chat.storage_maxsize_gb", v) }; NativeLib.setChatSetting("storage_maxsize_gb", v) } },
|
||||
label = { Text("Max storage size (GB)") },
|
||||
label = { Text(stringResource(R.string.max_storage_size)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true)
|
||||
}
|
||||
@@ -937,7 +943,7 @@ private fun DebugLevelDropdown(label: String, current: String, onSelect: (String
|
||||
}
|
||||
} else {
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||
OutlinedTextField(current, {}, readOnly = true, label = { Text("$label level") },
|
||||
OutlinedTextField(current, {}, readOnly = true, label = { Text(stringResource(R.string.debug_level_label, label)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
|
||||
modifier = Modifier.menuAnchor(), singleLine = true)
|
||||
ExposedDropdownMenu(expanded, onDismissRequest = { expanded = false }) {
|
||||
|
||||
+9
-6
@@ -1,5 +1,8 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import com.utun.chat.R
|
||||
import com.utun.chat.data.AppLanguage
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
@@ -183,7 +186,7 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) {
|
||||
}
|
||||
player.setOnErrorListener { _, what, extra ->
|
||||
LogManager.addLog("ERROR", "VideoPlayer", "MediaPlayer error what=$what extra=$extra file=$filePath")
|
||||
error = "Ошибка воспроизведения видео"
|
||||
error = AppLanguage.text(R.string.video_playback_failed)
|
||||
true
|
||||
}
|
||||
try {
|
||||
@@ -191,7 +194,7 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) {
|
||||
player.prepareAsync()
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "VideoPlayer", "setDataSource/prepare failed: ${e.message}")
|
||||
error = "Не удалось открыть видео"
|
||||
error = AppLanguage.text(R.string.video_open_failed)
|
||||
}
|
||||
|
||||
onDispose {
|
||||
@@ -375,7 +378,7 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) {
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Filled.Close, "Закрыть", tint = Color.White)
|
||||
Icon(Icons.Filled.Close, stringResource(R.string.close), tint = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,7 +394,7 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) {
|
||||
) {
|
||||
Icon(
|
||||
if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||
contentDescription = if (isPlaying) "Пауза" else "Плей",
|
||||
contentDescription = stringResource(if (isPlaying) R.string.pause else R.string.play),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
@@ -413,7 +416,7 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) {
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.ScreenRotation,
|
||||
contentDescription = "Перевернуть",
|
||||
contentDescription = stringResource(R.string.flip),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
@@ -431,7 +434,7 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) {
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.ScreenRotation,
|
||||
contentDescription = "Перевернуть",
|
||||
contentDescription = stringResource(R.string.flip),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
|
||||
+12
-9
@@ -1,5 +1,7 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import com.utun.chat.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -41,6 +43,7 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.utun.chat.data.CameraLensManager
|
||||
import com.utun.chat.data.LensType
|
||||
import com.utun.chat.data.LensOption
|
||||
import com.utun.chat.data.LogManager
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -209,7 +212,7 @@ fun VideoRecordScreen(
|
||||
providerRef.value = provider
|
||||
val list = CameraLensManager.enumerate(provider)
|
||||
lenses = list
|
||||
val default = list.firstOrNull { it.type.name == "SELFIE" } ?: list.firstOrNull()
|
||||
val default = list.firstOrNull { it.type == LensType.SELFIE } ?: list.firstOrNull()
|
||||
if (default != null) {
|
||||
selectedLens = default
|
||||
bindCamera(default.selector())
|
||||
@@ -229,9 +232,9 @@ fun VideoRecordScreen(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Видеосообщение") },
|
||||
title = { Text(stringResource(R.string.video_message)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { discardAndReset(); onClose() }) { Icon(Icons.Filled.Close, "Закрыть") }
|
||||
IconButton(onClick = { discardAndReset(); onClose() }) { Icon(Icons.Filled.Close, stringResource(R.string.close)) }
|
||||
},
|
||||
actions = {
|
||||
if (boundCamera?.cameraInfo?.hasFlashUnit() == true) {
|
||||
@@ -240,7 +243,7 @@ fun VideoRecordScreen(
|
||||
try { boundCamera?.cameraControl?.enableTorch(next) } catch (_: Exception) {}
|
||||
torchOn = next
|
||||
}) {
|
||||
Icon(if (torchOn) Icons.Filled.FlashOn else Icons.Filled.FlashOff, "Подсветка", tint = if (torchOn) Color(0xFFFFD600) else Color.White)
|
||||
Icon(if (torchOn) Icons.Filled.FlashOn else Icons.Filled.FlashOff, stringResource(R.string.camera_light), tint = if (torchOn) Color(0xFFFFD600) else Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -250,11 +253,11 @@ fun VideoRecordScreen(
|
||||
Box(Modifier.fillMaxSize().background(Color.Black).padding(padding)) {
|
||||
if (!hasCamera || !hasAudio) {
|
||||
Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Нужны разрешения камеры и микрофона", color = Color.White, fontSize = 16.sp)
|
||||
Text(stringResource(R.string.camera_microphone_permission), color = Color.White, fontSize = 16.sp)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row {
|
||||
if (!hasCamera) Button(onClick = { camLauncher.launch(Manifest.permission.CAMERA) }) { Text("Камера") }
|
||||
if (hasCamera && !hasAudio) Button(onClick = { audLauncher.launch(Manifest.permission.RECORD_AUDIO) }) { Text("Микрофон") }
|
||||
if (!hasCamera) Button(onClick = { camLauncher.launch(Manifest.permission.CAMERA) }) { Text(stringResource(R.string.camera)) }
|
||||
if (hasCamera && !hasAudio) Button(onClick = { audLauncher.launch(Manifest.permission.RECORD_AUDIO) }) { Text(stringResource(R.string.microphone)) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -350,7 +353,7 @@ fun VideoRecordScreen(
|
||||
.clickable { if (phase == Phase.IDLE) onClose() else discardAndReset() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.Filled.Delete, if (phase == Phase.IDLE) "Закрыть" else "Удалить", tint = Color.White, modifier = Modifier.size(26.dp))
|
||||
Icon(Icons.Filled.Delete, if (phase == Phase.IDLE) stringResource(R.string.close) else stringResource(R.string.delete), tint = Color.White, modifier = Modifier.size(26.dp))
|
||||
}
|
||||
Spacer(Modifier.width(28.dp))
|
||||
|
||||
@@ -400,7 +403,7 @@ fun VideoRecordScreen(
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.Send, "Отправить", tint = Color.White, modifier = Modifier.size(26.dp))
|
||||
Icon(Icons.AutoMirrored.Filled.Send, stringResource(R.string.send), tint = Color.White, modifier = Modifier.size(26.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.utun.chat.viewmodel
|
||||
|
||||
import com.utun.chat.R
|
||||
import com.utun.chat.data.AppLanguage
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.media.MediaMetadataRetriever
|
||||
@@ -1024,9 +1026,9 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun sendAttachment(filePath: String, target: ConversationTarget, name: String = File(filePath).name) {
|
||||
val r = repo ?: throw IllegalStateException("Сервис чата остановлен")
|
||||
val r = repo ?: throw IllegalStateException(AppLanguage.text(R.string.chat_service_stopped))
|
||||
if (target is ConversationTarget.Pm) {
|
||||
if (!NativeLib.dmSendFile(target.id, filePath, name)) throw IllegalStateException("Не удалось отправить вложение")
|
||||
if (!NativeLib.dmSendFile(target.id, filePath, name)) throw IllegalStateException(AppLanguage.text(R.string.attachment_send_failed))
|
||||
return
|
||||
}
|
||||
val chId = target.id
|
||||
@@ -1067,13 +1069,13 @@ class ChatViewModel : ViewModel() {
|
||||
viewModelScope.launch {
|
||||
var accepted = false
|
||||
try {
|
||||
val r = repo ?: throw IllegalStateException("Сервис чата остановлен")
|
||||
val r = repo ?: throw IllegalStateException(AppLanguage.text(R.string.chat_service_stopped))
|
||||
val meta = withContext(Dispatchers.IO) {
|
||||
val mmr = MediaMetadataRetriever()
|
||||
try {
|
||||
mmr.setDataSource(filePath)
|
||||
if (mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE) != "video/mp4")
|
||||
throw IllegalStateException("На Android отправка как видео поддерживает MP4; другой формат отправьте как файл")
|
||||
throw IllegalStateException(AppLanguage.text(R.string.video_mp4_only))
|
||||
Triple(
|
||||
mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toIntOrNull() ?: 0,
|
||||
mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0,
|
||||
@@ -1082,7 +1084,7 @@ class ChatViewModel : ViewModel() {
|
||||
} finally { mmr.release() }
|
||||
}
|
||||
accepted = r.sendVideo(target.nativeId, filePath, meta.first, meta.second, meta.third)
|
||||
if (!accepted) throw IllegalStateException("Некорректное видео или сервис остановлен")
|
||||
if (!accepted) throw IllegalStateException(AppLanguage.text(R.string.video_invalid_or_stopped))
|
||||
LogManager.addLog("INFO", "VM", "video queued target=${target.nativeId} dur=${meta.first} ${meta.second}x${meta.third}")
|
||||
} catch (e: kotlinx.coroutines.CancellationException) { throw e }
|
||||
catch (e: Exception) { LogManager.addLog("ERROR", "VM", "video failed target=${target.nativeId}: ${e.message}") }
|
||||
@@ -1170,9 +1172,9 @@ class ChatViewModel : ViewModel() {
|
||||
var cacheFile: File? = null
|
||||
try {
|
||||
val name = fileName.substringAfterLast('/').substringAfterLast('\\')
|
||||
require(name.isNotBlank() && name != "." && name != "..") { "Неверное имя файла" }
|
||||
require(name.isNotBlank() && name != "." && name != "..") { AppLanguage.text(R.string.invalid_file_name) }
|
||||
val cacheDir = File(context.cacheDir, "attachments/${java.util.UUID.randomUUID()}")
|
||||
check(cacheDir.mkdirs()) { "Не удалось создать каталог вложения" }
|
||||
check(cacheDir.mkdirs()) { AppLanguage.text(R.string.attachment_directory_failed) }
|
||||
val outputFile = File(cacheDir, name)
|
||||
cacheFile = outputFile
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
@@ -1181,11 +1183,11 @@ class ChatViewModel : ViewModel() {
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n < 0) break
|
||||
size += n; check(size <= 1024L * 1024 * 1024) { "Файл больше 1 ГиБ" }
|
||||
size += n; check(size <= 1024L * 1024 * 1024) { AppLanguage.text(R.string.file_too_large) }
|
||||
output.write(buffer, 0, n)
|
||||
}
|
||||
}
|
||||
} ?: throw IllegalStateException("Не удалось открыть вложение")
|
||||
} ?: throw IllegalStateException(AppLanguage.text(R.string.attachment_open_failed))
|
||||
LogManager.addLog("INFO", "VM", "copied attachment to cache: ${outputFile.absolutePath}")
|
||||
return outputFile.absolutePath
|
||||
} catch (e: Exception) {
|
||||
|
||||
Reference in new issue
Block a user