diff --git a/V2rayNG/app/build.gradle.kts b/V2rayNG/app/build.gradle.kts index 0d70153..c07989b 100644 --- a/V2rayNG/app/build.gradle.kts +++ b/V2rayNG/app/build.gradle.kts @@ -16,7 +16,7 @@ android { minSdk = 24 targetSdk = 37 versionCode = 749 - versionName = "2.3.9-cosmo.16" + versionName = "2.3.9-cosmo.17" val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';') splits { diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/CosmoMainScreen.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/CosmoMainScreen.kt index ea445a4..08cbf26 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/CosmoMainScreen.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/CosmoMainScreen.kt @@ -144,8 +144,21 @@ fun CosmoMainScreen( else onAction(MainAction.UpdateSubscriptions) } - // No bottom bar: settings (gear, top-left) and "My subscription" (ⓘ in the card) open as separate screens. - Scaffold { padding -> + // Bottom bar: Home + My subscription; Settings opens from the gear (top-left) as a separate screen. + Scaffold( + bottomBar = { + NavigationBar { + CosmoTab.entries.forEachIndexed { index, item -> + NavigationBarItem( + selected = tab == index, + onClick = { tab = index }, + icon = { Icon(painterResource(item.icon), contentDescription = null) }, + label = { Text(stringResource(item.label)) }, + ) + } + } + } + ) { padding -> Box(Modifier.padding(padding).fillMaxSize()) { when (tab) { 0 -> CosmoHomeTab( @@ -156,14 +169,12 @@ fun CosmoMainScreen( onOpenSubscription = { tab = 1 }, onOpenSettings = { tab = 2 }, ) - 1 -> CosmoSubPage(onBack = { tab = 0 }) { - CosmoSubscriptionTab( - ui = ui, - serverCount = group.rows.size, - onAction = onAction, - onRenew = { showRenew = true }, - ) - } + 1 -> CosmoSubscriptionTab( + ui = ui, + serverCount = group.rows.size, + onAction = onAction, + onRenew = { showRenew = true }, + ) else -> CosmoSubPage(onBack = { tab = 0 }) { CosmoSettingsTab( onAction = onAction, @@ -176,7 +187,12 @@ fun CosmoMainScreen( } } -/** Secondary screen with a back arrow (Home has no bottom bar). */ +private enum class CosmoTab(@DrawableRes val icon: Int, @StringRes val label: Int) { + Home(R.drawable.ic_cosmo_home_24dp, R.string.cosmo_tab_home), + Subscription(R.drawable.ic_subscriptions_24dp, R.string.cosmo_tab_subscription), +} + +/** Secondary screen with a back arrow (Settings, opened from the gear). */ @Composable private fun CosmoSubPage(onBack: () -> Unit, content: @Composable () -> Unit) { Column(Modifier.fillMaxSize()) { @@ -335,26 +351,44 @@ private fun CosmoHomeTab( color = if (state == CosmoConnState.Connected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), ) + // Make it obvious that the rocket circle is the on/off button. + Text( + stringResource( + when (state) { + CosmoConnState.Connected -> R.string.cosmo_tap_to_disconnect + CosmoConnState.Connecting -> R.string.cosmo_hint_connecting + CosmoConnState.Disconnected -> R.string.cosmo_tap_to_connect + } + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) } } // ---- One card: header (name + update/test icons), info line, fastest, servers ---- item { Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)).background(cardBg)) { - Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 4.dp), verticalAlignment = Alignment.CenterVertically) { - Text( - stringResource(R.string.app_name), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, + Row( + Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + CosmoSmallButton( + icon = R.drawable.ic_cosmo_refresh_24dp, + text = stringResource(R.string.cosmo_update_sub), + onClick = { onAction(MainAction.UpdateSubscriptions) }, + modifier = Modifier.weight(1f), + ) + CosmoSmallButton( + icon = R.drawable.ic_cosmo_speed_24dp, + text = stringResource(R.string.cosmo_check_servers), + busy = ui.isTesting && !findingFastest, + enabled = !ui.isTesting, + onClick = { onAction(MainAction.TestRealAllServers) }, modifier = Modifier.weight(1f), ) - IconButton(onClick = { onAction(MainAction.UpdateSubscriptions) }) { - Icon(painterResource(R.drawable.ic_cosmo_refresh_24dp), contentDescription = stringResource(R.string.cosmo_update_sub), tint = MaterialTheme.colorScheme.primary) - } - IconButton(onClick = { onAction(MainAction.TestRealAllServers) }, enabled = !ui.isTesting) { - if (ui.isTesting && !findingFastest) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) - else Icon(painterResource(R.drawable.ic_cosmo_speed_24dp), contentDescription = stringResource(R.string.cosmo_check_servers), tint = MaterialTheme.colorScheme.primary) - } IconButton(onClick = { listExpanded = !listExpanded }) { Icon( painterResource(R.drawable.ic_expand_more_24dp), @@ -363,7 +397,7 @@ private fun CosmoHomeTab( ) } } - CosmoSubscriptionInfoRow(subInfo, onOpenSubscription) + CosmoSubscriptionInfoRow(subInfo) } } if (listExpanded) { @@ -422,18 +456,46 @@ private fun CosmoHomeTab( } } -/** Compact line under the card header: traffic | expiry | details (ⓘ) | support. */ +/** Small outlined button with an icon and a short label (card header). */ @Composable -private fun CosmoSubscriptionInfoRow(info: CosmoSubscriptionInfo?, onOpenSubscription: () -> Unit) { - val context = LocalContext.current - Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 12.dp, bottom = 10.dp), verticalAlignment = Alignment.CenterVertically) { +private fun CosmoSmallButton( + @DrawableRes icon: Int, + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + busy: Boolean = false, +) { + OutlinedButton( + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(12.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp), + modifier = modifier.height(40.dp), + ) { + if (busy) CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + else Icon(painterResource(icon), contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text(text, style = MaterialTheme.typography.labelLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +/** Compact line under the card buttons: used traffic / limit (∞) on the left, expiry date on the right. */ +@Composable +private fun CosmoSubscriptionInfoRow(info: CosmoSubscriptionInfo?) { + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { val traffic = when { info == null -> "—" - info.isUnlimitedTraffic -> stringResource(R.string.cosmo_sub_unlimited) - else -> stringResource(R.string.cosmo_sub_traffic_left, info.trafficLeftBytes.toTrafficString()) + info.isUnlimitedTraffic -> "${(info.upload + info.download).toTrafficString()} / ∞" + else -> "${(info.upload + info.download).toTrafficString()} / ${info.total.toTrafficString()}" } - Text(traffic, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) - Spacer(Modifier.width(10.dp)) + Box( + Modifier.border(BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), RoundedCornerShape(8.dp)) + .padding(horizontal = 10.dp, vertical = 3.dp), + ) { + Text(traffic, style = MaterialTheme.typography.bodyMedium, maxLines = 1) + } + Spacer(Modifier.weight(1f)) val expire = info?.expire?.takeIf { it > 0 }?.times(1000) val expired = info?.isExpired() == true if (expire != null && expire - System.currentTimeMillis() < NO_EXPIRY_THRESHOLD_DAYS * 86_400_000L) { @@ -442,23 +504,8 @@ private fun CosmoSubscriptionInfoRow(info: CosmoSubscriptionInfo?, onOpenSubscri style = MaterialTheme.typography.bodyMedium, color = if (expired) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, - modifier = Modifier.weight(1f), ) - } else { - Spacer(Modifier.weight(1f)) } - Icon( - painterResource(R.drawable.ic_cosmo_info_24dp), - contentDescription = stringResource(R.string.cosmo_tab_subscription), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(36.dp).clip(CircleShape).clickable(onClick = onOpenSubscription).padding(6.dp), - ) - Icon( - painterResource(R.drawable.ic_telegram_24dp), - contentDescription = stringResource(R.string.cosmo_mysub_support), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(36.dp).clip(CircleShape).clickable { Utils.openUri(context, "https://t.me/cosmonetvpn_bot") }.padding(6.dp), - ) } } @@ -544,15 +591,27 @@ private fun CosmoStatusPill(state: CosmoConnState, large: Boolean = false) { CosmoConnState.Connecting -> Triple( MaterialTheme.colorScheme.secondaryContainer, MaterialTheme.colorScheme.onSecondaryContainer, R.string.cosmo_state_connecting ) - CosmoConnState.Connected -> Triple(Color(0xFFC8F5DC), Color(0xFF0F4A2C), R.string.cosmo_state_connected) + CosmoConnState.Connected -> Triple(Color(0xFF1FA35C), Color.White, R.string.cosmo_state_connected) + } + // Status dot: green = connected, red = not connected, amber = connecting. + val dot = when (state) { + CosmoConnState.Connected -> Color(0xFF7CFFB0) + CosmoConnState.Connecting -> Color(0xFFFFC14D) + CosmoConnState.Disconnected -> Color(0xFFFF5252) } Surface(color = bg, contentColor = fg, shape = RoundedCornerShape(50)) { - Text( - stringResource(label), - style = if (large) MaterialTheme.typography.titleSmall else MaterialTheme.typography.labelMedium, - modifier = if (large) Modifier.padding(horizontal = 16.dp, vertical = 7.dp) - else Modifier.padding(horizontal = 10.dp, vertical = 4.dp), - ) + Row( + Modifier.padding(horizontal = if (large) 16.dp else 10.dp, vertical = if (large) 7.dp else 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(if (large) 10.dp else 8.dp).clip(CircleShape).background(dot)) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(label), + style = if (large) MaterialTheme.typography.titleSmall else MaterialTheme.typography.labelMedium, + fontWeight = if (state == CosmoConnState.Connected) FontWeight.SemiBold else FontWeight.Normal, + ) + } } } diff --git a/V2rayNG/app/src/main/res/values-ru/strings_cosmo.xml b/V2rayNG/app/src/main/res/values-ru/strings_cosmo.xml index 6627ea9..38f4cbf 100644 --- a/V2rayNG/app/src/main/res/values-ru/strings_cosmo.xml +++ b/V2rayNG/app/src/main/res/values-ru/strings_cosmo.xml @@ -3,11 +3,13 @@ Главная Страны Моя подписка + Нажмите на ракету, чтобы подключиться + Нажмите на ракету, чтобы отключиться Свернуть список Показать серверы Истекает: %1$s Обновить подписку - Проверить доступность + Проверить серверы Выберите сервер из списка ниже Подписка активна Подписка закончилась diff --git a/V2rayNG/app/src/main/res/values/strings_cosmo.xml b/V2rayNG/app/src/main/res/values/strings_cosmo.xml index c3cc88e..4d68747 100644 --- a/V2rayNG/app/src/main/res/values/strings_cosmo.xml +++ b/V2rayNG/app/src/main/res/values/strings_cosmo.xml @@ -4,6 +4,8 @@ Home Countries My subscription + Tap the rocket to connect + Tap the rocket to disconnect Collapse list Show servers Expires: %1$s