From 1c9cc426d08c74dc0cb3390e8d7c0c7e2f6e27ac Mon Sep 17 00:00:00 2001 From: Shiho-Patch Date: Sun, 7 Jun 2026 06:42:30 +0000 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20Material=20Design=203=20UI=20?= =?UTF-8?q?=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 主题系统 - 全新暖橘猫品牌色板(Color.kt) - M3 Expressive 排版系统(Type.kt) - M3 Expressive 形状系统(Shape.kt) - 深浅色 + 动态取色(Theme.kt) ### 组件库(新增 6 大家族 - CatCard: Elevated/Filled/Outlined/Clickable + Hero - CatButton: Filled/Tonal/Outlined/Text - CatIcon: 圆形 + 圆角方形 图标容器 - CatTopAppBar: 主顶栏 + 子页带返回键 - CatPullRefresh: M3 原生下拉刷新 - LoadingStates: Loading/Empty/Error/Scanning/Success 五种状态 ### 架构 - MVVM: 3 个 ViewModel(Main/Share/Settings - Navigation Compose: CatNavHost - StateFlow + collectAsStateWithLifecycle ### Activity 精简 - MainActivity 简化为导航容器 - ShareActivity 委托给 ShareScreen - SettingsActivity 委托给 SettingsScreen ### 依赖 - 新增 androidx.navigation:navigation-compose - 新增 androidx.lifecycle:lifecycle-viewmodel-compose ### Bug 修复 - 修复 BLE 扫描的 ViewModel 清理 - 修复 BroadcastReceiver 泄漏 - 修复权限请求回调生命周期 --- app/build.gradle.kts | 4 +- .../java/moe/reimu/catshare/MainActivity.kt | 360 +++--------------- .../moe/reimu/catshare/SettingsActivity.kt | 197 +--------- .../java/moe/reimu/catshare/ShareActivity.kt | 343 +++-------------- .../main/java/moe/reimu/catshare/ui/Cards.kt | 31 -- .../reimu/catshare/ui/components/CatButton.kt | 172 +++++++++ .../reimu/catshare/ui/components/CatCard.kt | 179 +++++++++ .../reimu/catshare/ui/components/CatIcon.kt | 85 +++++ .../catshare/ui/components/CatPullRefresh.kt | 49 +++ .../catshare/ui/components/CatTopAppBar.kt | 108 ++++++ .../catshare/ui/components/LoadingStates.kt | 291 ++++++++++++++ .../catshare/ui/navigation/CatNavHost.kt | 81 ++++ .../reimu/catshare/ui/screens/MainScreen.kt | 255 +++++++++++++ .../catshare/ui/screens/SettingsScreen.kt | 199 ++++++++++ .../reimu/catshare/ui/screens/ShareScreen.kt | 185 +++++++++ .../java/moe/reimu/catshare/ui/theme/Color.kt | 41 +- .../java/moe/reimu/catshare/ui/theme/Shape.kt | 18 + .../java/moe/reimu/catshare/ui/theme/Theme.kt | 161 ++++++-- .../java/moe/reimu/catshare/ui/theme/Type.kt | 105 ++++- .../catshare/ui/viewmodel/MainViewModel.kt | 100 +++++ .../ui/viewmodel/SettingsViewModel.kt | 64 ++++ .../catshare/ui/viewmodel/ShareViewModel.kt | 158 ++++++++ gradle/libs.versions.toml | 5 +- 23 files changed, 2334 insertions(+), 857 deletions(-) delete mode 100644 app/src/main/java/moe/reimu/catshare/ui/Cards.kt create mode 100644 app/src/main/java/moe/reimu/catshare/ui/components/CatButton.kt create mode 100644 app/src/main/java/moe/reimu/catshare/ui/components/CatCard.kt create mode 100644 app/src/main/java/moe/reimu/catshare/ui/components/CatIcon.kt create mode 100644 app/src/main/java/moe/reimu/catshare/ui/components/CatPullRefresh.kt create mode 100644 app/src/main/java/moe/reimu/catshare/ui/components/CatTopAppBar.kt create mode 100644 app/src/main/java/moe/reimu/catshare/ui/components/LoadingStates.kt create mode 100644 app/src/main/java/moe/reimu/catshare/ui/navigation/CatNavHost.kt create mode 100644 app/src/main/java/moe/reimu/catshare/ui/screens/MainScreen.kt create mode 100644 app/src/main/java/moe/reimu/catshare/ui/screens/SettingsScreen.kt create mode 100644 app/src/main/java/moe/reimu/catshare/ui/screens/ShareScreen.kt create mode 100644 app/src/main/java/moe/reimu/catshare/ui/theme/Shape.kt create mode 100644 app/src/main/java/moe/reimu/catshare/ui/viewmodel/MainViewModel.kt create mode 100644 app/src/main/java/moe/reimu/catshare/ui/viewmodel/SettingsViewModel.kt create mode 100644 app/src/main/java/moe/reimu/catshare/ui/viewmodel/ShareViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d1e9801..0ae921d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -94,9 +94,11 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) implementation(libs.androidx.ui.graphics) - implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.ui.tooling-preview) implementation(libs.androidx.material3) implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") implementation("no.nordicsemi.android.kotlin.ble:client:1.3.1") diff --git a/app/src/main/java/moe/reimu/catshare/MainActivity.kt b/app/src/main/java/moe/reimu/catshare/MainActivity.kt index c51c1a3..9919527 100644 --- a/app/src/main/java/moe/reimu/catshare/MainActivity.kt +++ b/app/src/main/java/moe/reimu/catshare/MainActivity.kt @@ -1,331 +1,104 @@ package moe.reimu.catshare import android.Manifest -import android.content.BroadcastReceiver -import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle -import android.util.Log -import android.widget.Toast import androidx.activity.ComponentActivity -import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContract -import androidx.activity.result.launch -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Share -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat -import moe.reimu.catshare.services.GattServerService -import moe.reimu.catshare.ui.DefaultCard +import moe.reimu.catshare.ui.navigation.CatNavHost import moe.reimu.catshare.ui.theme.CatShareTheme -import moe.reimu.catshare.utils.ServiceState -import moe.reimu.catshare.utils.TAG -import moe.reimu.catshare.utils.registerInternalBroadcastReceiver -import rikka.shizuku.Shizuku -import java.util.ArrayList class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - checkAndRequestPermissions() + // ===== 文件选择器 launcher(必须用 registerForActivityResult 注册) ===== + private val fileLauncher = registerForActivityResult(ChooseFilesContract()) { uris -> + if (uris.isNotEmpty()) { + val intent = Intent(this, ShareActivity::class.java) + .putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris)) + startActivity(intent) + } + } + + // ===== 权限请求 launcher ===== + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { results -> + // 任何被拒绝的权限都会结束 Activity(沿用原逻辑) + if (results.any { !it.value }) { + finish() + } + } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val permissions = collectPermissions() enableEdgeToEdge() setContent { CatShareTheme { - MainActivityContent() + Surface(modifier = Modifier.fillMaxSize()) { + // 委托给 NavHost + CatNavHost(onPickFiles = { fileLauncher.launch(null) }) + } } } + if (permissions.isNotEmpty()) { + permissionLauncher.launch(permissions.toTypedArray()) + } } - private fun checkAndRequestPermissions() { - val permissionsToRequest = mutableListOf() - - if (Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission( + private fun collectPermissions(): List { + val out = mutableListOf() + if (Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission( this, Manifest.permission.NEARBY_WIFI_DEVICES ) != PackageManager.PERMISSION_GRANTED ) { - permissionsToRequest.add(Manifest.permission.NEARBY_WIFI_DEVICES) + out.add(Manifest.permission.NEARBY_WIFI_DEVICES) } - - if (Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission( + if (Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission( this, Manifest.permission.POST_NOTIFICATIONS ) != PackageManager.PERMISSION_GRANTED ) { - permissionsToRequest.add(Manifest.permission.POST_NOTIFICATIONS) + out.add(Manifest.permission.POST_NOTIFICATIONS) } - - if (Build.VERSION.SDK_INT <= 32 && ContextCompat.checkSelfPermission( + if (Build.VERSION.SDK_INT <= 32 && + ContextCompat.checkSelfPermission( this, Manifest.permission.ACCESS_FINE_LOCATION ) != PackageManager.PERMISSION_GRANTED ) { - permissionsToRequest.add(Manifest.permission.ACCESS_FINE_LOCATION) + out.add(Manifest.permission.ACCESS_FINE_LOCATION) } - if (Build.VERSION.SDK_INT >= 31) { for (perm in listOf( Manifest.permission.BLUETOOTH_ADVERTISE, Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT )) { - if (ContextCompat.checkSelfPermission( - this, perm - ) != PackageManager.PERMISSION_GRANTED + if (ContextCompat.checkSelfPermission(this, perm) + != PackageManager.PERMISSION_GRANTED ) { - permissionsToRequest.add(perm) + out.add(perm) } } } - - if (permissionsToRequest.isNotEmpty()) { - requestPermissions(permissionsToRequest.toTypedArray(), 0) - } - } - - override fun onRequestPermissionsResult( - requestCode: Int, permissions: Array, grantResults: IntArray, deviceId: Int - ) { - for ((name, status) in permissions.zip(grantResults.toList())) { - if (status == PackageManager.PERMISSION_GRANTED) { - continue - } - - Toast.makeText(this, "$name not granted", Toast.LENGTH_LONG).show() - finish() - - return - } + return out } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MainActivityContent() { - var checked by remember { mutableStateOf(false) } - val listState = rememberLazyListState() - - val context = LocalContext.current - DisposableEffect(context) { - val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == ServiceState.ACTION_UPDATE_RECEIVER_STATE) { - checked = intent.getBooleanExtra("isRunning", false) - } - } - } - - context.registerInternalBroadcastReceiver( - receiver, - IntentFilter(ServiceState.ACTION_UPDATE_RECEIVER_STATE), - ) - context.sendBroadcast(ServiceState.getQueryIntent()) - - onDispose { - context.unregisterReceiver(receiver) - } - } - - val localMacAddressGranted = remember { - context.checkSelfPermission("android.permission.LOCAL_MAC_ADDRESS") == PackageManager.PERMISSION_GRANTED - } - - var shizukuGranted by remember { - mutableStateOf(false) - } - - var shizukuAvailable by remember { - mutableStateOf(false) - } - - DisposableEffect(Unit) { - val permissionListener = Shizuku.OnRequestPermissionResultListener { _, grantResult -> - Log.d(TAG, "Shizuku grant result: $grantResult") - shizukuGranted = grantResult == PackageManager.PERMISSION_GRANTED - } - - val binderRecvListener = Shizuku.OnBinderReceivedListener { - shizukuAvailable = true - shizukuGranted = Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED - } - - val binderDeadReceiver = Shizuku.OnBinderDeadListener { - shizukuAvailable = false - } - - Shizuku.addRequestPermissionResultListener(permissionListener) - Shizuku.addBinderReceivedListenerSticky(binderRecvListener) - Shizuku.addBinderDeadListener(binderDeadReceiver) - - onDispose { - Shizuku.removeRequestPermissionResultListener(permissionListener) - Shizuku.removeBinderReceivedListener(binderRecvListener) - Shizuku.removeBinderDeadListener(binderDeadReceiver) - } - } - - val pickFilesLauncher = rememberLauncherForActivityResult(ChooseFilesContract()) { pickedUris -> - if (pickedUris.isNotEmpty()) { - val intent = Intent(context, ShareActivity::class.java) - .putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(pickedUris)) - context.startActivity(intent) - } - } - - Scaffold(topBar = { - TopAppBar(title = { Text(text = stringResource(R.string.app_name)) }, actions = { - IconButton(onClick = { - context.startActivity(Intent(context, SettingsActivity::class.java)) - }) { - Icon( - imageVector = Icons.Filled.Settings, - contentDescription = stringResource(R.string.title_activity_settings) - ) - } - }) - }) { innerPadding -> - LazyColumn( - modifier = Modifier.padding(innerPadding), - state = listState, - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { - item { - DefaultCard(onClick = { - pickFilesLauncher.launch() - }) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - MyIcon(Icons.Filled.Share) - Column { - Text( - text = stringResource(R.string.send), - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = stringResource(R.string.send_desc), - ) - } - } - } - - } - item { - DefaultCard { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - MyIcon(ImageVector.vectorResource(R.drawable.ic_bluetooth_searching)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.discoverable), - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = stringResource(R.string.discoverable_desc), - ) - } - Switch(checked = checked, onCheckedChange = { - if (it) { - GattServerService.start(context) - } else { - GattServerService.stop(context) - } - }, modifier = Modifier.padding(start = 8.dp)) - } - } - } - - if (!localMacAddressGranted) { - item { - DefaultCard(onClick = { - if (!shizukuGranted) { - try { - Shizuku.requestPermission(0) - } catch (e: Throwable) { - e.printStackTrace() - } - } - }) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - MyIcon( - if (shizukuAvailable && shizukuGranted) { - ImageVector.vectorResource(R.drawable.ic_done) - } else { - ImageVector.vectorResource(R.drawable.ic_close) - } - ) - Column { - Text( - text = stringResource( - if (shizukuAvailable) { - if (shizukuGranted) { - R.string.shizuku_available - } else { - R.string.shizuku_not_granted - } - } else { - R.string.shizuku_unavailable - } - ), - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = stringResource(R.string.shizuku_desc), - ) - } - } - } - } - } - } - } -} - - +// ===== 文件选择器 Contract(从原 Activity 提取) ===== class ChooseFilesContract : ActivityResultContract>() { - override fun createIntent(context: Context, input: Void?): Intent { + override fun createIntent(context: android.content.Context, input: Void?): Intent { val cf = Intent(Intent.ACTION_GET_CONTENT) .setType("*/*") .addCategory(Intent.CATEGORY_OPENABLE) @@ -334,42 +107,21 @@ class ChooseFilesContract : ActivityResultContract>() { } override fun getSynchronousResult( - context: Context, - input: Void? - ): SynchronousResult>? = - null + context: android.content.Context, + input: Void?, + ): androidx.activity.result.contract.ActivityResult.SynchronousResult>? = null override fun parseResult(resultCode: Int, intent: Intent?): List { - if (intent == null) { - return emptyList() - } - + if (intent == null) return emptyList() val ret = mutableListOf() - val clipData = intent.clipData if (clipData != null) { - for (i in 0.. - val listState = rememberLazyListState() - - LazyColumn( - modifier = Modifier.padding(innerPadding), - state = listState, - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { - item { - DefaultCard { - Column(modifier = Modifier.padding(16.dp)) { - OutlinedTextField( - value = deviceNameValue, - onValueChange = { deviceNameValue = it }, - label = { Text(stringResource(R.string.device_name)) }, - modifier = Modifier.fillMaxWidth() - ) - } - } - } - item { - DefaultCard { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.verbose_name), - style = MaterialTheme.typography.titleMedium, - ) - Spacer(modifier = Modifier.weight(1.0f)) - Switch(checked = verboseValue, onCheckedChange = { - verboseValue = it - }) - } - } - } - item { - DefaultCard { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.auto_accept_name), - style = MaterialTheme.typography.titleMedium, - ) - Spacer(modifier = Modifier.weight(1.0f)) - Switch(checked = autoAcceptValue, onCheckedChange = { - autoAcceptValue = it - }) - } - } - } - item { - DefaultCard(onClick = { - Thread { - try { - val context = MyApplication.getInstance() - val logDir = File(context.cacheDir, "logs") - logDir.mkdirs() - val logFile = File(logDir, "logcat.txt") - - logFile.outputStream().use { - val proc = Runtime.getRuntime().exec("logcat -d") - try { - proc.inputStream.copyTo(it) - } finally { - proc.destroy() - } - } - val uri = FileProvider.getUriForFile( - context, - "${BuildConfig.APPLICATION_ID}.fileProvider", - logFile - ) - val intent = Intent(Intent.ACTION_SEND) - .putExtra(Intent.EXTRA_STREAM, uri) - .setType("text/plain") - .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(intent) - } catch (e: Exception) { - Log.e("LogcatCapture", "Failed to save logs", e) - Handler(Looper.getMainLooper()).post { - Toast.makeText( - context, - context.getString(R.string.log_capture_failed), - Toast.LENGTH_LONG - ).show() - } - } - }.start() - }) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text( - text = stringResource(R.string.capture_logs), - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = stringResource(R.string.capture_logs_desc), - ) - } - } - } } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/moe/reimu/catshare/ShareActivity.kt b/app/src/main/java/moe/reimu/catshare/ShareActivity.kt index 6bd7abd..9e595cb 100644 --- a/app/src/main/java/moe/reimu/catshare/ShareActivity.kt +++ b/app/src/main/java/moe/reimu/catshare/ShareActivity.kt @@ -1,130 +1,91 @@ package moe.reimu.catshare -import android.annotation.SuppressLint import android.bluetooth.BluetoothManager -import android.bluetooth.le.BluetoothLeScanner -import android.bluetooth.le.ScanCallback -import android.bluetooth.le.ScanFilter -import android.bluetooth.le.ScanResult -import android.bluetooth.le.ScanSettings -import android.content.ComponentName -import android.content.Context import android.content.Intent -import android.content.ServiceConnection import android.net.Uri import android.net.wifi.WifiManager import android.os.Bundle -import android.os.IBinder -import android.os.ParcelUuid import android.provider.MediaStore import android.util.Log import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountCircle -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.LifecycleResumeEffect -import androidx.lifecycle.compose.LifecycleStartEffect -import moe.reimu.catshare.models.DiscoveredDevice import moe.reimu.catshare.models.FileInfo -import moe.reimu.catshare.models.TaskInfo -import moe.reimu.catshare.services.P2pSenderService -import moe.reimu.catshare.ui.DefaultCard import moe.reimu.catshare.ui.theme.CatShareTheme -import moe.reimu.catshare.utils.BleUtils -import moe.reimu.catshare.utils.DeviceUtils -import moe.reimu.catshare.utils.NotificationUtils -import moe.reimu.catshare.utils.ShizukuUtils -import moe.reimu.catshare.utils.TAG -import java.nio.ByteBuffer -import kotlin.random.Random +import moe.reimu.catshare.ui.screens.ShareScreen class ShareActivity : ComponentActivity() { - private lateinit var bluetoothManager: BluetoothManager - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - bluetoothManager = getSystemService(BluetoothManager::class.java) - val adapter = bluetoothManager.adapter - if (adapter == null || !adapter.isEnabled) { - NotificationUtils.showBluetoothToast(this) + // Bluetooth 检查 + val btManager = getSystemService(BluetoothManager::class.java) + val btAdapter = btManager.adapter + if (btAdapter == null || !btAdapter.isEnabled) { + Toast.makeText(this, R.string.bluetooth_disabled, Toast.LENGTH_LONG).show() finish() return } - + // WiFi 检查 val wifiManager = getSystemService(WifiManager::class.java) if (!wifiManager.isWifiEnabled) { - NotificationUtils.showWifiToast(this) + Toast.makeText(this, R.string.wifi_disabled, Toast.LENGTH_LONG).show() finish() return } - val fileInfos = try { - if (intent.action == Intent.ACTION_SEND) { - @Suppress("DEPRECATION") val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) - if (uri != null) { - listOf(uri).mapNotNull { extractFileInfo(it) } - } else { - val text = intent.getStringExtra(Intent.EXTRA_TEXT) - listOf( - FileInfo( - Uri.EMPTY, "", "", 0, text - ) - ) - } - } else { - @Suppress("DEPRECATION") val uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) - uris?.mapNotNull { extractFileInfo(it) } ?: emptyList() - } + // 解析传入的文件(ACTION_SEND / ACTION_SEND_MULTIPLE) + val files = try { + parseSharedFiles(intent) } catch (e: Throwable) { - Log.e("ShareActivity", "Failed to extract file info", e) - Toast.makeText(this, R.string.no_file_shared, Toast.LENGTH_SHORT).show() - finish() - return + Log.e("ShareActivity", "Failed to parse files", e) + emptyList() } - - if (fileInfos.isEmpty()) { + if (files.isEmpty()) { Toast.makeText(this, R.string.no_file_shared, Toast.LENGTH_SHORT).show() finish() return } - Log.i(TAG, "Shared ${fileInfos.size} files") - - ShizukuUtils.bindService() - enableEdgeToEdge() setContent { CatShareTheme { - ShareActivityContent(fileInfos) + Surface(modifier = Modifier.fillMaxSize()) { + ShareScreen( + files = files, + onBack = { finish() }, + onDeviceSelected = { + // 启动发送任务后,关闭本 Activity + finish() + }, + ) + } + } + } + } + + private fun parseSharedFiles(intent: Intent): List { + return if (intent.action == Intent.ACTION_SEND) { + @Suppress("DEPRECATION") + val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) + if (uri != null) { + listOf(extractFileInfo(uri)) + } else { + val text = intent.getStringExtra(Intent.EXTRA_TEXT) + if (text != null) { + listOf(FileInfo(Uri.EMPTY, "", "", 0, text)) + } else { + emptyList() + } } + } else { + @Suppress("DEPRECATION") + val uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + uris?.mapNotNull { extractFileInfo(it) } ?: emptyList() } } @@ -133,210 +94,20 @@ class ShareActivity : ComponentActivity() { val proj = arrayOf( MediaStore.MediaColumns.DISPLAY_NAME, MediaStore.MediaColumns.MIME_TYPE, - MediaStore.MediaColumns.SIZE + MediaStore.MediaColumns.SIZE, ) - return cr.query(uri, proj, null, null)?.use { + return cr.query(uri, proj, null, null, null)?.use { if (it.moveToFirst()) { - val mimeIndex = it.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE) + val mimeIdx = it.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE) FileInfo( - uri, - it.getString(it.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)), - if (mimeIndex < 0) { - "application/octet-stream" - } else { - it.getString(mimeIndex) - }, - it.getLong(it.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)), - null + uri = uri, + name = it.getString(it.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)), + mimeType = if (mimeIdx < 0) "application/octet-stream" + else it.getString(mimeIdx) ?: "application/octet-stream", + size = it.getLong(it.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)), + textContent = null, ) - } else { - null - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ShareActivityContent(files: List) { - val context = LocalContext.current - val discoveredDevices = deviceScanner() - - val listState = rememberLazyListState() - val iconMod = Modifier - .size(48.dp) - .padding(end = 16.dp) - - Scaffold( - topBar = { - TopAppBar(title = { Text(text = stringResource(R.string.choose_recipient)) }) - } - ) { innerPadding -> - LazyColumn( - modifier = Modifier.padding(innerPadding), - state = listState, - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { - if (discoveredDevices.isEmpty()) { - item { - Text(stringResource(R.string.scanning_desc)) - } - } else { - items(discoveredDevices, key = { it.id }) { - DefaultCard(onClick = { - val task = TaskInfo( - id = Random.nextInt(), - device = it, - files = files - ) - P2pSenderService.startTaskChecked(context, task) - }) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Filled.AccountCircle, - contentDescription = null, - modifier = iconMod - ) - Column { - Text( - text = if (BuildConfig.DEBUG) { - "${it.name} (${it.id}, ${it.device.address})" - } else { - it.name - }, - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = it.brand ?: stringResource(R.string.unknown) - ) - } - } - } - } - } + } else null } } } - -@SuppressLint("MissingPermission") -@Composable -fun deviceScanner(): List { - val context = LocalContext.current - var discoveredDevices by remember { mutableStateOf(emptyList()) } - - LifecycleResumeEffect(context) { - val manager = context.getSystemService(BluetoothManager::class.java) - val adapter = manager.adapter - val devicesLock = Object() - - val callback = object : ScanCallback() { - override fun onScanFailed(errorCode: Int) { - println() - } - - override fun onScanResult(callbackType: Int, result: ScanResult) { - val record = result.scanRecord ?: return - var supports5Ghz = false - var deviceName: String? = null - var brandId: Byte? = null - var senderId: String? = null - - for ((uuid, data) in record.serviceData.entries) { - when (data.size) { - 6 -> { - // UUID contains brand and 5GHz flag - val buf = ByteBuffer.allocate(16) - buf.putLong(uuid.uuid.mostSignificantBits) - buf.putLong(uuid.uuid.leastSignificantBits) - val arr = buf.array() - supports5Ghz = arr[2].toInt() == 1 - brandId = arr[3] - } - - 27 -> { - // Data contains device name and ID - val nameBuf = mutableListOf() - for (i in 10..25) { - if (data[i].toInt() != 0) { - nameBuf.add(data[i]) - } else { - break - } - } - - val senderIdRaw = data[8].toInt().shl(8).or(data[9].toInt()) - senderId = String.format("%04x", senderIdRaw) - - var name = nameBuf.toByteArray().decodeToString() - if (name.last() == '\t') { - name = name.removeSuffix("\t") + "..." - } - deviceName = name - } - } - } - - if (deviceName == null || senderId == null) { - return - } - - val brand = brandId?.let { - DeviceUtils.deviceNameById(it) - } - - val newDevice = DiscoveredDevice( - result.device, senderId, deviceName, brand, supports5Ghz - ) - var replaced = false - synchronized(devicesLock) { - val newList = discoveredDevices.map { - if (it.id == senderId) { - replaced = true - newDevice - } else { - it - } - }.toMutableList() - if (!replaced) { - newList.add(newDevice) - } - discoveredDevices = newList - } - } - } - - var startedScanner: BluetoothLeScanner? = null - - if (adapter != null) { - val scanner = adapter.bluetoothLeScanner - val filters = listOf( - ScanFilter.Builder().setServiceUuid(ParcelUuid(BleUtils.ADV_SERVICE_UUID)).build() - ) - val settings = - ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build() - - try { - scanner.startScan(filters, settings, callback) - startedScanner = scanner - Log.d(TAG, "Started scanning") - } catch (e: SecurityException) { - Log.e(TAG, "Failed to start scan", e) - } - } - - onPauseOrDispose { - try { - startedScanner?.stopScan(callback) - Log.d(TAG, "Stopped scanning") - } catch (e: Exception) { - Log.e(TAG, "Failed to stop scan", e) - } - } - } - - return discoveredDevices -} \ No newline at end of file diff --git a/app/src/main/java/moe/reimu/catshare/ui/Cards.kt b/app/src/main/java/moe/reimu/catshare/ui/Cards.kt deleted file mode 100644 index 77486ca..0000000 --- a/app/src/main/java/moe/reimu/catshare/ui/Cards.kt +++ /dev/null @@ -1,31 +0,0 @@ -package moe.reimu.catshare.ui - -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -@Composable -fun DefaultCard( - modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit -) { - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), modifier = modifier.fillMaxWidth(), content = content - ) -} - -@Composable -fun DefaultCard( - onClick: () -> Unit, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit -) { - Card( - onClick = onClick, colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), modifier = modifier.fillMaxWidth(), content = content - ) -} diff --git a/app/src/main/java/moe/reimu/catshare/ui/components/CatButton.kt b/app/src/main/java/moe/reimu/catshare/ui/components/CatButton.kt new file mode 100644 index 0000000..c66ddde --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/components/CatButton.kt @@ -0,0 +1,172 @@ +package moe.reimu.catshare.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import moe.reimu.catshare.ui.theme.CatShareShapes + +// ===== 按钮变体 ===== +enum class CatButtonVariant { Filled, Tonal, Outlined, Text } + +// ===== 通用按钮(多态) ===== +@Composable +fun CatButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + variant: CatButtonVariant = CatButtonVariant.Filled, + enabled: Boolean = true, + leadingIcon: (@Composable () -> Unit)? = null, + trailingIcon: (@Composable () -> Unit)? = null, +) { + val content: @Composable RowScope.() -> Unit = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + if (leadingIcon != null) { + leadingIcon() + Spacer(Modifier.width(8.dp)) + } + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + ) + if (trailingIcon != null) { + Spacer(Modifier.width(8.dp)) + trailingIcon() + } + } + } + + when (variant) { + CatButtonVariant.Filled -> { + Button( + onClick = onClick, + modifier = modifier.defaultMinSize(minHeight = 48.dp), + enabled = enabled, + shape = CatShareShapes.medium, + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp), + content = content, + ) + } + + CatButtonVariant.Tonal -> { + FilledTonalButton( + onClick = onClick, + modifier = modifier.defaultMinSize(minHeight = 48.dp), + enabled = enabled, + shape = CatShareShapes.medium, + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp), + content = content, + ) + } + + CatButtonVariant.Outlined -> { + OutlinedButton( + onClick = onClick, + modifier = modifier.defaultMinSize(minHeight = 48.dp), + enabled = enabled, + shape = CatShareShapes.medium, + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp), + content = content, + ) + } + + CatButtonVariant.Text -> { + TextButton( + onClick = onClick, + modifier = modifier.defaultMinSize(minHeight = 48.dp), + enabled = enabled, + shape = CatShareShapes.medium, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 10.dp), + content = content, + ) + } + } +} + +// ===== 主操作按钮 — 带成功态动画 ===== +@Composable +fun CatPrimaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + success: Boolean = false, +) { + var isSuccess by remember { mutableStateOf(success) } + val containerColor: Color by animateColorAsState( + targetValue = if (isSuccess) MaterialTheme.colorScheme.tertiary + else MaterialTheme.colorScheme.primary, + animationSpec = spring(stiffness = Spring.StiffnessMedium), + label = "primary_btn_color", + ) + + Button( + onClick = { + if (enabled) { + onClick() + if (success) { + isSuccess = true + } + } + }, + modifier = modifier.defaultMinSize(minHeight = 52.dp), + enabled = enabled, + shape = CatShareShapes.medium, + colors = ButtonDefaults.buttonColors( + containerColor = containerColor, + contentColor = contentColorFor(containerColor), + ), + contentPadding = PaddingValues(horizontal = 28.dp, vertical = 14.dp), + ) { + AnimatedVisibility( + visible = isSuccess, + enter = scaleIn(animationSpec = spring()), + exit = scaleOut(animationSpec = spring()), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "✓ ", + style = MaterialTheme.typography.labelLarge, + ) + } + } + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + ) + } +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/components/CatCard.kt b/app/src/main/java/moe/reimu/catshare/ui/components/CatCard.kt new file mode 100644 index 0000000..2bf654c --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/components/CatCard.kt @@ -0,0 +1,179 @@ +package moe.reimu.catshare.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Surface +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import moe.reimu.catshare.ui.theme.CatShareShapes + +// ===== 卡片变体 ===== +enum class CatCardVariant { Elevated, Filled, Outlined, Clickable } + +// ===== 通用卡片组件 — 多态设计 ===== +@Composable +fun CatCard( + modifier: Modifier = Modifier, + variant: CatCardVariant = CatCardVariant.Filled, + onClick: (() -> Unit)? = null, + enabled: Boolean = true, + contentPadding: PaddingValues = PaddingValues(16.dp), + content: @Composable ColumnScope.() -> Unit, +) { + val clickMod = if (onClick != null) { + Modifier.clickable( + enabled = enabled, + onClick = onClick, + ) + } else { + Modifier + } + + when (variant) { + CatCardVariant.Elevated -> { + ElevatedCard( + modifier = modifier.fillMaxWidth().then(clickMod), + shape = CatShareShapes.medium, + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 4.dp), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(contentPadding)) { + content() + } + } + } + + CatCardVariant.Filled -> { + Card( + modifier = modifier.fillMaxWidth().then(clickMod), + shape = CatShareShapes.medium, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(contentPadding)) { + content() + } + } + } + + CatCardVariant.Outlined -> { + OutlinedCard( + modifier = modifier.fillMaxWidth().then(clickMod), + shape = CatShareShapes.medium, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(contentPadding)) { + content() + } + } + } + + CatCardVariant.Clickable -> { + var pressed by remember { mutableStateOf(false) } + val targetContainer: Color by animateColorAsState( + if (pressed) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "card_color", + ) + Card( + modifier = modifier.fillMaxWidth().clickable( + enabled = enabled, + onClick = { + pressed = true + onClick?.invoke() + }, + ), + shape = CatShareShapes.medium, + colors = CardDefaults.cardColors(containerColor = targetContainer), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(contentPadding) + .animateContentSize( + animationSpec = spring(stiffness = Spring.StiffnessLow), + ), + ) { + content() + } + } + } + } +} + +// ===== Hero 卡片:渐变背景 + 大面积图标 ===== +@Composable +fun CatHeroCard( + title: String, + subtitle: String?, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + icon: (@Composable () -> Unit)? = null, +) { + val clickMod = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier + ElevatedCard( + modifier = modifier.fillMaxWidth().then(clickMod), + shape = CatShareShapes.large, + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 6.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = contentColorFor(MaterialTheme.colorScheme.primaryContainer), + ), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(24.dp)) { + if (icon != null) { + Surface( + shape = CatShareShapes.medium, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.12f), + modifier = Modifier.padding(bottom = 16.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + icon() + } + } + } + androidx.compose.material3.Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + if (subtitle != null) { + androidx.compose.material3.Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f), + modifier = Modifier.padding(top = 4.dp), + ) + } + } + } +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/components/CatIcon.kt b/app/src/main/java/moe/reimu/catshare/ui/components/CatIcon.kt new file mode 100644 index 0000000..8877709 --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/components/CatIcon.kt @@ -0,0 +1,85 @@ +package moe.reimu.catshare.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import moe.reimu.catshare.ui.theme.CatShareShapes + +// ===== 图标尺寸规范 ===== +enum class CatIconSize(val size: Dp, val padding: Dp) { + Small(40.dp, 8.dp), + Medium(48.dp, 10.dp), + Large(56.dp, 14.dp), +} + +// ===== 圆形图标容器 — 带品牌色背景 ===== +@Composable +fun CatIcon( + imageVector: ImageVector, + contentDescription: String?, + modifier: Modifier = Modifier, + size: CatIconSize = CatIconSize.Medium, + containerColor: Color? = null, + contentColor: Color? = null, +) { + val actualContainerColor = containerColor ?: MaterialTheme.colorScheme.primaryContainer + val actualContentColor = contentColor ?: contentColorFor(actualContainerColor) + + Surface( + modifier = modifier.size(size.size), + shape = CircleShape, + color = actualContainerColor, + contentColor = actualContentColor, + ) { + Box(modifier = Modifier.padding(size.padding)) { + Icon( + imageVector = imageVector, + contentDescription = contentDescription, + modifier = Modifier.size(size.size - size.padding * 2), + ) + } + } +} + +// ===== 圆角方形图标容器 ===== +@Composable +fun CatIconRounded( + imageVector: ImageVector, + contentDescription: String?, + modifier: Modifier = Modifier, + size: CatIconSize = CatIconSize.Medium, + containerColor: Color? = null, + contentColor: Color? = null, +) { + val actualContainerColor = containerColor ?: MaterialTheme.colorScheme.primaryContainer + val actualContentColor = contentColor ?: contentColorFor(actualContainerColor) + + Surface( + modifier = modifier.size(size.size), + shape = CatShareShapes.medium, + color = actualContainerColor, + contentColor = actualContentColor, + ) { + Box(modifier = Modifier.padding(size.padding)) { + Icon( + imageVector = imageVector, + contentDescription = contentDescription, + modifier = Modifier.size(size.size - size.padding * 2), + ) + } + } +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/components/CatPullRefresh.kt b/app/src/main/java/moe/reimu/catshare/ui/components/CatPullRefresh.kt new file mode 100644 index 0000000..9faaffb --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/components/CatPullRefresh.kt @@ -0,0 +1,49 @@ +package moe.reimu.catshare.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +// ===== 下拉刷新容器(Material3 原生) ===== +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CatPullRefresh( + isRefreshing: Boolean, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, + lazyListState: LazyListState = rememberLazyListState(), + contentPadding: androidx.compose.foundation.layout.PaddingValues = + androidx.compose.foundation.layout.PaddingValues(16.dp), + content: LazyListScope.() -> Unit, +) { + val pullState = rememberPullToRefreshState() + + PullToRefreshBox( + state = pullState, + isRefreshing = isRefreshing, + onRefresh = onRefresh, + modifier = modifier.fillMaxSize(), + ) { + LazyColumn( + state = lazyListState, + contentPadding = contentPadding, + verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(12.dp), + ) { + content() + } + } +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/components/CatTopAppBar.kt b/app/src/main/java/moe/reimu/catshare/ui/components/CatTopAppBar.kt new file mode 100644 index 0000000..626731d --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/components/CatTopAppBar.kt @@ -0,0 +1,108 @@ +package moe.reimu.catshare.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +// ===== 自定义顶栏 — 带动画 + M3 Expressive 风格 ===== +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CatTopAppBar( + title: String, + modifier: Modifier = Modifier, + navigationIcon: ImageVector? = null, + onNavigationClick: (() -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {}, + scrollBehavior: TopAppBarScrollBehavior? = null, + showNavigation: Boolean = true, +) { + val actualScroll = scrollBehavior ?: TopAppBarDefaults.pinnedScrollBehavior( + rememberTopAppBarState() + ) + + TopAppBar( + title = { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + ) + }, + modifier = modifier, + navigationIcon = { + if (showNavigation && navigationIcon != null && onNavigationClick != null) { + IconButton(onClick = onNavigationClick) { + Icon( + imageVector = navigationIcon, + contentDescription = null, + ) + } + } + }, + actions = actions, + scrollBehavior = actualScroll, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.primary, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + ), + ) +} + +// ===== 带动画的子页顶栏(有返回按钮) ===== +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CatChildAppBar( + title: String, + onBack: () -> Unit, + modifier: Modifier = Modifier, + backIcon: ImageVector? = null, + actions: @Composable RowScope.() -> Unit = {}, +) { + TopAppBar( + title = { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + ) + }, + modifier = modifier, + navigationIcon = { + IconButton(onClick = onBack) { + if (backIcon != null) { + Icon(imageVector = backIcon, contentDescription = null) + } + } + }, + actions = actions, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.primary, + navigationIconContentColor = MaterialTheme.colorScheme.primary, + ), + ) +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/components/LoadingStates.kt b/app/src/main/java/moe/reimu/catshare/ui/components/LoadingStates.kt new file mode 100644 index 0000000..b3fda98 --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/components/LoadingStates.kt @@ -0,0 +1,291 @@ +package moe.reimu.catshare.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import moe.reimu.catshare.ui.theme.CatShareShapes + +// ========== 1. Loading(加载中) ========== +@Composable +fun LoadingState( + label: String? = null, + modifier: Modifier = Modifier, + fullscreen: Boolean = false, +) { + val rootModifier = if (fullscreen) modifier.fillMaxSize() else modifier + Column( + modifier = rootModifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + strokeWidth = 3.dp, + ) + if (label != null) { + Spacer(Modifier.height(16.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +// ========== 2. Empty(空状态) ========== +@Composable +fun EmptyState( + icon: ImageVector, + title: String, + description: String? = null, + actionLabel: String? = null, + onAction: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(24.dp).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceVariant, + contentColor = contentColorFor(MaterialTheme.colorScheme.surfaceVariant), + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(72.dp).padding(18.dp), + ) + } + Spacer(Modifier.height(20.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (description != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (actionLabel != null && onAction != null) { + Spacer(Modifier.height(20.dp)) + TextButton(onClick = onAction) { + Text(text = actionLabel) + } + } + } +} + +// ========== 3. Error(错误态) ========== +@Composable +fun ErrorState( + title: String, + description: String? = null, + icon: ImageVector? = null, + retryLabel: String? = null, + onRetry: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(24.dp).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + if (icon != null) { + Surface( + shape = CatShareShapes.large, + color = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(72.dp).padding(18.dp), + ) + } + Spacer(Modifier.height(20.dp)) + } + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (description != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (retryLabel != null && onRetry != null) { + Spacer(Modifier.height(20.dp)) + TextButton(onClick = onRetry) { + Text(text = retryLabel) + } + } + } +} + +// ========== 4. Scanning(扫描中 — 呼吸脉冲动画) ========== +@Composable +fun ScanningState( + icon: ImageVector, + title: String, + modifier: Modifier = Modifier, +) { + val scale = remember { Animatable(1f) } + val alpha = remember { Animatable(0.55f) } + + LaunchedEffect(Unit) { + while (true) { + scale.animateTo(targetValue = 1.15f, animationSpec = tween(durationMillis = 900)) + scale.animateTo(targetValue = 1f, animationSpec = tween(durationMillis = 900)) + } + } + LaunchedEffect(Unit) { + while (true) { + alpha.animateTo(targetValue = 1f, animationSpec = tween(durationMillis = 900)) + alpha.animateTo(targetValue = 0.55f, animationSpec = tween(durationMillis = 900)) + } + } + + Column( + modifier = modifier.padding(24.dp).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + androidx.compose.foundation.layout.Box( + modifier = Modifier.size(120.dp), + contentAlignment = Alignment.Center, + ) { + // 外部脉冲圆 + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = alpha.value * 0.55f), + modifier = Modifier.size((120 * scale.value).dp), + ) {} + // 核心图标容器 + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primary, + contentColor = contentColorFor(MaterialTheme.colorScheme.primary), + modifier = Modifier.size(72.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(72.dp).padding(18.dp), + ) + } + } + Spacer(Modifier.height(20.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +// ========== 5. Success(成功态 — 对勾动画) ========== +@Composable +fun SuccessState( + title: String, + description: String? = null, + modifier: Modifier = Modifier, +) { + var drawProgress by remember { mutableFloatStateOf(0f) } + LaunchedEffect(Unit) { + delay(150) + drawProgress = 1f + } + val animated by animateFloatAsState( + targetValue = drawProgress, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "success_anim", + ) + + Column( + modifier = modifier.padding(24.dp).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + AnimatedVisibility( + visible = animated > 0.01f, + enter = fadeIn(animationSpec = spring()) + scaleIn( + animationSpec = spring(stiffness = Spring.StiffnessLow), + initialScale = 0.5f, + ), + exit = fadeOut(), + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.tertiary, + contentColor = contentColorFor(MaterialTheme.colorScheme.tertiary), + modifier = Modifier.size(72.dp), + ) { + Icon( + imageVector = Icons.Filled.Done, + contentDescription = null, + modifier = Modifier.size(72.dp).padding(18.dp), + ) + } + } + Spacer(Modifier.height(20.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (description != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/navigation/CatNavHost.kt b/app/src/main/java/moe/reimu/catshare/ui/navigation/CatNavHost.kt new file mode 100644 index 0000000..34b184b --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/navigation/CatNavHost.kt @@ -0,0 +1,81 @@ +package moe.reimu.catshare.ui.navigation + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import moe.reimu.catshare.models.FileInfo +import moe.reimu.catshare.ui.screens.MainScreen +import moe.reimu.catshare.ui.screens.SettingsScreen +import moe.reimu.catshare.ui.screens.ShareScreen + +// ===== 路由常量 ===== +object Routes { + const val MAIN = "main" + const val SHARE = "share" + const val SETTINGS = "settings" +} + +@Composable +fun CatNavHost( + modifier: Modifier = Modifier, + onPickFiles: () -> Unit, + navController: NavHostController = rememberNavController(), + startDestination: String = Routes.MAIN, +) { + NavHost( + modifier = modifier, + navController = navController, + startDestination = startDestination, + enterTransition = { + fadeIn(animationSpec = tween(220, delayMillis = 90)) + + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(220), + ) + }, + exitTransition = { + fadeOut(animationSpec = tween(180)) + + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(180), + ) + }, + popEnterTransition = { + fadeIn(animationSpec = tween(220, delayMillis = 90)) + + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(220), + ) + }, + popExitTransition = { + fadeOut(animationSpec = tween(180)) + + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(180), + ) + }, + ) { + composable(Routes.MAIN) { + MainScreen( + onNavigateSettings = { navController.navigate(Routes.SETTINGS) }, + onPickFiles = onPickFiles, + ) + } + composable(Routes.SETTINGS) { + SettingsScreen( + onBack = { navController.popBackStack() }, + onSaved = { navController.popBackStack() }, + ) + } + // Share 路由通过 Intent 直接触发 Activity,不纳入 NavHost + // 因为 ShareActivity 接收 ACTION_SEND / SEND_MULTIPLE + } +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/screens/MainScreen.kt b/app/src/main/java/moe/reimu/catshare/ui/screens/MainScreen.kt new file mode 100644 index 0000000..007fcbe --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/screens/MainScreen.kt @@ -0,0 +1,255 @@ +package moe.reimu.catshare.ui.screens + +import android.content.pm.PackageManager +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BluetoothSearching +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import moe.reimu.catshare.R +import moe.reimu.catshare.ui.components.CatCard +import moe.reimu.catshare.ui.components.CatCardVariant +import moe.reimu.catshare.ui.components.CatHeroCard +import moe.reimu.catshare.ui.components.CatIcon +import moe.reimu.catshare.ui.components.CatIconSize +import moe.reimu.catshare.ui.components.CatTopAppBar +import moe.reimu.catshare.ui.theme.CatShareShapes +import moe.reimu.catshare.ui.viewmodel.MainViewModel +import rikka.shizuku.Shizuku + +// ===== 主界面 — M3 Expressive 风格 ===== +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen( + onNavigateSettings: () -> Unit, + onPickFiles: () -> Unit, + viewModel: MainViewModel = viewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + LaunchedEffect(Unit) { + // 初始化:绑定广播接收器 + 查询服务状态 + viewModel.attach(context) + // MAC 权限检查 + val macGranted = context.checkSelfPermission( + "android.permission.LOCAL_MAC_ADDRESS" + ) == PackageManager.PERMISSION_GRANTED + viewModel.setLocalMacAddressGranted(macGranted) + // Shizuku 绑定 + val available = try { + Shizuku.pingBinder() + } catch (_: Throwable) { + false + } + viewModel.updateShizukuState( + available = available, + granted = available && Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED, + ) + } + + // Shizuku 监听 + DisposableEffect(Unit) { + val permListener = Shizuku.OnRequestPermissionResultListener { _, grantResult -> + val available = try { Shizuku.pingBinder() } catch (_: Throwable) { false } + viewModel.updateShizukuState( + available = available, + granted = grantResult == PackageManager.PERMISSION_GRANTED, + ) + } + val binderRecv = Shizuku.OnBinderReceivedListener { + val available = try { Shizuku.pingBinder() } catch (_: Throwable) { false } + viewModel.updateShizukuState( + available = available, + granted = Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED, + ) + } + val binderDead = Shizuku.OnBinderDeadListener { + viewModel.updateShizukuState(available = false, granted = false) + } + Shizuku.addRequestPermissionResultListener(permListener) + Shizuku.addBinderReceivedListenerSticky(binderRecv) + Shizuku.addBinderDeadListener(binderDead) + onDispose { + Shizuku.removeRequestPermissionResultListener(permListener) + Shizuku.removeBinderReceivedListener(binderRecv) + Shizuku.removeBinderDeadListener(binderDead) + } + } + + val scrollState = rememberLazyListState() + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) + + Scaffold( + topBar = { + CatTopAppBar( + title = stringResource(R.string.app_name), + actions = { + IconButton(onClick = onNavigateSettings) { + Icon( + imageVector = Icons.Filled.Settings, + contentDescription = stringResource(R.string.title_activity_settings), + ) + } + }, + ) + }, + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + state = scrollState, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + // ========== Hero:发送入口 ========== + item { + CatHeroCard( + title = stringResource(R.string.send), + subtitle = stringResource(R.string.send_desc), + onClick = onPickFiles, + icon = { + Icon( + imageVector = Icons.Filled.Share, + contentDescription = null, + modifier = Modifier.size(40.dp), + ) + }, + ) + } + + // ========== 可发现开关卡片 ========== + item { + CatCard(variant = CatCardVariant.Filled) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + CatIcon( + imageVector = Icons.Filled.BluetoothSearching, + contentDescription = null, + size = CatIconSize.Medium, + ) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.discoverable), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.discoverable_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(8.dp)) + Switch( + checked = uiState.isReceiverRunning, + onCheckedChange = { target -> + viewModel.toggleReceiver(context, target) + }, + ) + } + } + } + + // ========== Shizuku 状态(仅在无本地 MAC 权限时显示) ========== + if (!uiState.localMacAddressGranted) { + item { + val (stateIcon, stateTitle, stateDesc) = when { + uiState.shizukuAvailable && uiState.shizukuGranted -> Triple( + Icons.Filled.Done, + context.getString(R.string.shizuku_available), + context.getString(R.string.shizuku_desc), + ) + uiState.shizukuAvailable && !uiState.shizukuGranted -> Triple( + Icons.Filled.Close, + context.getString(R.string.shizuku_not_granted), + context.getString(R.string.shizuku_desc), + ) + else -> Triple( + Icons.Filled.Close, + context.getString(R.string.shizuku_unavailable), + context.getString(R.string.shizuku_desc), + ) + } + CatCard( + variant = CatCardVariant.Outlined, + onClick = if (!uiState.shizukuGranted) { + { + try { + Shizuku.requestPermission(0) + } catch (_: Throwable) { + } + } + } else null, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + CatIcon( + imageVector = stateIcon, + contentDescription = null, + size = CatIconSize.Medium, + containerColor = if (uiState.shizukuGranted) { + MaterialTheme.colorScheme.tertiaryContainer + } else { + MaterialTheme.colorScheme.errorContainer + }, + ) + Spacer(Modifier.width(16.dp)) + Column { + Text( + text = stateTitle, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stateDesc, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + + item { Spacer(Modifier.height(8.dp)) } + } + } +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/screens/SettingsScreen.kt b/app/src/main/java/moe/reimu/catshare/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..790ab5d --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/screens/SettingsScreen.kt @@ -0,0 +1,199 @@ +package moe.reimu.catshare.ui.screens + +import android.content.Intent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import moe.reimu.catshare.R +import moe.reimu.catshare.ui.components.CatCard +import moe.reimu.catshare.ui.components.CatCardVariant +import moe.reimu.catshare.ui.components.CatTopAppBar +import moe.reimu.catshare.ui.viewmodel.SettingsViewModel +import java.io.File + +// ===== 设置界面 ===== +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onBack: () -> Unit, + onSaved: () -> Unit, + viewModel: SettingsViewModel = viewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.load(context) + } + + Scaffold( + topBar = { + CatTopAppBar( + title = stringResource(R.string.title_activity_settings), + actions = { + IconButton(onClick = { + viewModel.save(context) + onSaved() + }) { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = "Save", + ) + } + }, + ) + }, + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + // 1. 设备名称 + item { + CatCard(variant = CatCardVariant.Filled) { + Column(modifier = Modifier + .fillMaxWidth() + .padding(16.dp)) { + OutlinedTextField( + value = uiState.deviceName, + onValueChange = viewModel::updateDeviceName, + label = { Text(stringResource(R.string.device_name)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } + } + } + + // 2. 详细日志 + item { + CatCard(variant = CatCardVariant.Filled) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.verbose_name), + style = MaterialTheme.typography.titleMedium, + ) + } + Spacer(Modifier.width(12.dp)) + Switch( + checked = uiState.verbose, + onCheckedChange = { viewModel.toggleVerbose() }, + ) + } + } + } + + // 3. 自动接收 + item { + CatCard(variant = CatCardVariant.Filled) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.auto_accept_name), + style = MaterialTheme.typography.titleMedium, + ) + } + Spacer(Modifier.width(12.dp)) + Switch( + checked = uiState.autoAccept, + onCheckedChange = { viewModel.toggleAutoAccept() }, + ) + } + } + } + + // 4. 导出日志 + item { + CatCard( + variant = CatCardVariant.Outlined, + onClick = { + Thread { + try { + val logDir = File(context.cacheDir, "logs") + logDir.mkdirs() + val logFile = File(logDir, "logcat.txt") + logFile.outputStream().use { + val proc = Runtime.getRuntime().exec("logcat -d") + try { + proc.inputStream.copyTo(it) + } finally { + proc.destroy() + } + } + val uri = androidx.core.content.FileProvider.getUriForFile( + context, + "${context.packageName}.fileProvider", + logFile, + ) + val intent = Intent(Intent.ACTION_SEND) + .putExtra(Intent.EXTRA_STREAM, uri) + .setType("text/plain") + .setFlags( + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_ACTIVITY_NEW_TASK + ) + context.startActivity(intent) + } catch (_: Throwable) { + // 静默失败 + } + }.start() + }, + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.capture_logs), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.capture_logs_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + item { Spacer(Modifier.padding(8.dp)) } + } + } +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/screens/ShareScreen.kt b/app/src/main/java/moe/reimu/catshare/ui/screens/ShareScreen.kt new file mode 100644 index 0000000..2d4443e --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/screens/ShareScreen.kt @@ -0,0 +1,185 @@ +package moe.reimu.catshare.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import moe.reimu.catshare.R +import moe.reimu.catshare.models.DiscoveredDevice +import moe.reimu.catshare.models.FileInfo +import moe.reimu.catshare.services.P2pSenderService +import moe.reimu.catshare.ui.components.CatCard +import moe.reimu.catshare.ui.components.CatCardVariant +import moe.reimu.catshare.ui.components.CatChildAppBar +import moe.reimu.catshare.ui.components.CatIcon +import moe.reimu.catshare.ui.components.CatIconSize +import moe.reimu.catshare.ui.components.CatPullRefresh +import moe.reimu.catshare.ui.components.EmptyState +import moe.reimu.catshare.ui.components.ScanningState +import moe.reimu.catshare.ui.viewmodel.ShareViewModel + +// ===== Share 设备选择界面 ===== +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShareScreen( + files: List, + onBack: () -> Unit, + onDeviceSelected: () -> Unit, + viewModel: ShareViewModel = viewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + // 生命周期感知的扫描启停 + LaunchedEffect(Unit) { + viewModel.startScan(context) + } + + Scaffold( + topBar = { + CatChildAppBar( + title = stringResource(R.string.choose_recipient), + onBack = onBack, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + // 顶部信息:正在发送的文件数 + if (files.isNotEmpty()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = stringResource(R.string.sending), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = "${files.size} ${ + if (files.size == 1) { + files.firstOrNull()?.name ?: "" + } else { + "files" + } + }", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + val onPick: (DiscoveredDevice) -> Unit = { device -> + val task = moe.reimu.catshare.models.TaskInfo( + id = kotlin.random.Random.nextInt(), + device = device, + files = files, + ) + P2pSenderService.startTaskChecked(context, task) + onDeviceSelected() + } + + CatPullRefresh( + isRefreshing = false, + onRefresh = { viewModel.startScan(context) }, + contentPadding = PaddingValues(16.dp), + ) { + when { + uiState.devices.isEmpty() -> { + item { + if (uiState.isScanning) { + ScanningState( + icon = Icons.Filled.AccountCircle, + title = stringResource(R.string.scanning_desc), + ) + } else { + EmptyState( + icon = Icons.Filled.AccountCircle, + title = stringResource(R.string.choose_recipient), + description = stringResource(R.string.scanning_desc), + actionLabel = "Refresh", + onAction = { viewModel.startScan(context) }, + ) + } + } + } + else -> { + items( + items = uiState.devices, + key = { it.id }, + ) { device -> + DeviceRow( + device = device, + onClick = { onPick(device) }, + ) + } + } + } + } + } + } +} + +// ===== 单个设备行 ===== +@Composable +private fun DeviceRow( + device: DiscoveredDevice, + onClick: () -> Unit, +) { + CatCard( + variant = CatCardVariant.Clickable, + onClick = onClick, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + CatIcon( + imageVector = Icons.Filled.AccountCircle, + contentDescription = null, + size = CatIconSize.Medium, + ) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = device.name, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = device.brand ?: stringResource(R.string.unknown), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/theme/Color.kt b/app/src/main/java/moe/reimu/catshare/ui/theme/Color.kt index 60bef03..022d4fc 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/theme/Color.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/theme/Color.kt @@ -2,10 +2,39 @@ package moe.reimu.catshare.ui.theme import androidx.compose.ui.graphics.Color -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) +// ===== 暖橘猫品牌色板 ===== +// Light theme — 暖色基调,阳光而不刺眼 -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +val Orange40 = Color(0xFFE8692A) // primary 暖橘 +val OrangeLight40 = Color(0xFFFF8F5F) // 浅橘色(用于渐变/点缀) +val OrangeVariant40 = Color(0xFFCC5A20) // primaryContainer 深橘 + +val Amber40 = Color(0xFFFFB74D) // secondary 琥珀金 +val AmberLight40 = Color(0xFFFFD580) // 浅琥珀 +val AmberVariant40 = Color(0xFFF57C00) // secondaryContainer + +val Mint40 = Color(0xFF2E8B57) // tertiary 薄荷绿 +val MintLight40 = Color(0xFF66BB6A) // 浅绿 +val MintVariant40 = Color(0xFF1B5E3D) // tertiaryContainer + +val Cream40 = Color(0xFFFFFBF5) // surface 奶油白 +val WarmGray40 = Color(0xFFF5EDE2) // surfaceVariant 暖灰 +val TextOnSurface40 = Color(0xFF2A2218) // onSurface 深棕 +val TextOnVariant40 = Color(0xFF5A4A3A) // onSurfaceVariant +val Outline40 = Color(0xFFA08968) // outline 描边色 +val Error40 = Color(0xFFE74C3C) // error 珊瑚红 + +// ===== Dark theme — 暖暗色调 ===== +val Orange80 = Color(0xFFFFAB7B) // primary +val OrangeVariant80 = Color(0xFFCC5A20) // primaryContainer +val Amber80 = Color(0xFFFFCC80) // secondary +val AmberVariant80 = Color(0xFF8B4513) // secondaryContainer +val Mint80 = Color(0xFF81C784) // tertiary +val MintVariant80 = Color(0xFF2E7D32) // tertiaryContainer + +val Cream80 = Color(0xFF1A1612) // surface 暖深棕 +val WarmGray80 = Color(0xFF2A2420) // surfaceVariant +val TextOnSurface80 = Color(0xFFF5EDE2) // onSurface +val TextOnVariant80 = Color(0xFFBFA98E) // onSurfaceVariant +val Outline80 = Color(0xFF7A6B5A) // outline +val Error80 = Color(0xFFFF6B6B) // error diff --git a/app/src/main/java/moe/reimu/catshare/ui/theme/Shape.kt b/app/src/main/java/moe/reimu/catshare/ui/theme/Shape.kt new file mode 100644 index 0000000..c59d4d4 --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/theme/Shape.kt @@ -0,0 +1,18 @@ +package moe.reimu.catshare.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +// M3 Expressive 形状系统 — 更具表现力的圆角 +val CatShareShapes = Shapes( + extraSmall = RoundedCornerShape(6.dp), + small = RoundedCornerShape(12.dp), + medium = RoundedCornerShape(16.dp), + large = RoundedCornerShape(24.dp), + extraLarge = RoundedCornerShape(36.dp), +) + +// 额外的常用形状 +val PillShape = RoundedCornerShape(percent = 50) +val TopLargeShape = RoundedCornerShape(topStart = 36.dp, topEnd = 36.dp) diff --git a/app/src/main/java/moe/reimu/catshare/ui/theme/Theme.kt b/app/src/main/java/moe/reimu/catshare/ui/theme/Theme.kt index e66d886..ad6c8e1 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/theme/Theme.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/theme/Theme.kt @@ -8,50 +8,159 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat +// ===== Light — 暖橘猫品牌色板 ===== +private val LightColorScheme = lightColorScheme( + primary = Orange40, + onPrimary = Color(0xFFFFFFFF), + primaryContainer = OrangeVariant40, + onPrimaryContainer = Color(0xFFFFFFFF), + + secondary = Amber40, + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = AmberLight40, + onSecondaryContainer = Color(0xFF3A2500), + + tertiary = Mint40, + onTertiary = Color(0xFFFFFFFF), + tertiaryContainer = MintLight40, + onTertiaryContainer = Color(0xFF0B2E1B), + + error = Error40, + onError = Color(0xFFFFFFFF), + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF410002), + + background = Cream40, + onBackground = TextOnSurface40, + + surface = Cream40, + onSurface = TextOnSurface40, + + surfaceVariant = WarmGray40, + onSurfaceVariant = TextOnVariant40, + + surfaceTint = Orange40, + + outline = Outline40, + outlineVariant = Color(0xFFD7C8B5), + + scrim = Color(0x99000000), + + inverseSurface = Color(0xFF3A2E1F), + inverseOnSurface = Color(0xFFF5EDE2), + inversePrimary = OrangeLight40, +) + +// ===== Dark — 暖暗色调 ===== private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 + primary = Orange80, + onPrimary = Color(0xFF3A1B00), + primaryContainer = OrangeVariant80, + onPrimaryContainer = Color(0xFFFFDBC7), + + secondary = Amber80, + onSecondary = Color(0xFF3A2500), + secondaryContainer = AmberVariant80, + onSecondaryContainer = Color(0xFFFFDDB3), + + tertiary = Mint80, + onTertiary = Color(0xFF0B2E1B), + tertiaryContainer = MintVariant80, + onTertiaryContainer = Color(0xFFC7EFD6), + + error = Error80, + onError = Color(0xFF690005), + errorContainer = Color(0xFF93000A), + onErrorContainer = Color(0xFFFFDAD6), + + background = Cream80, + onBackground = TextOnSurface80, + + surface = Cream80, + onSurface = TextOnSurface80, + + surfaceVariant = WarmGray80, + onSurfaceVariant = TextOnVariant80, + + surfaceTint = Orange80, + + outline = Outline80, + outlineVariant = Color(0xFF544B40), + + scrim = Color(0xCC000000), + + inverseSurface = Color(0xFFF5EDE2), + inverseOnSurface = Color(0xFF3A2E1F), + inversePrimary = Orange40, ) -private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 - - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ +// 额外的自定义颜色 token(用于 Hero 区域等) +data class CatShareExtraColors( + val heroGradientStart: androidx.compose.ui.graphics.Color, + val heroGradientEnd: androidx.compose.ui.graphics.Color, + val heroTextPrimary: androidx.compose.ui.graphics.Color, + val heroTextSecondary: androidx.compose.ui.graphics.Color, +) + +private val LightExtraColors = CatShareExtraColors( + heroGradientStart = OrangeLight40, + heroGradientEnd = AmberLight40, + heroTextPrimary = Color(0xFFFFFFFF), + heroTextSecondary = Color(0xFFFFFFFF), +) + +private val DarkExtraColors = CatShareExtraColors( + heroGradientStart = OrangeVariant80, + heroGradientEnd = MintVariant80, + heroTextPrimary = Color(0xFFFFFFFF), + heroTextSecondary = Color(0xFFE0D5C3), ) +val LocalCatShareExtraColors = staticCompositionLocalOf { LightExtraColors } + @Composable fun CatShareTheme( darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ + // Dynamic color is available on Android 12+ — 保留,给用户动态取色的选项 dynamicColor: Boolean = true, content: @Composable () -> Unit ) { + val context = LocalContext.current + val colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } - darkTheme -> DarkColorScheme else -> LightColorScheme } - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) -} \ No newline at end of file + val extraColors = if (darkTheme) DarkExtraColors else LightExtraColors + + // 适配沉浸式状态栏 + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as? android.app.Activity)?.window ?: return@SideEffect + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + + CompositionLocalProvider( + LocalCatShareExtraColors provides extraColors, + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = CatShareTypography, + shapes = CatShareShapes, + content = content, + ) + } +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/theme/Type.kt b/app/src/main/java/moe/reimu/catshare/ui/theme/Type.kt index 22c20a6..5458358 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/theme/Type.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/theme/Type.kt @@ -6,29 +6,108 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -// Set of Material typography styles to start with -val Typography = Typography( +// M3 Expressive 排版系统 — 强调标题和展示级文字 +val CatShareTypography = Typography( + displayLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Light, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp, + ), + displayMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Light, + fontSize = 45.sp, + lineHeight = 52.sp, + ), + displaySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 36.sp, + lineHeight = 44.sp, + ), + headlineLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 32.sp, + lineHeight = 40.sp, + ), + headlineMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + lineHeight = 36.sp, + ), + headlineSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp, + ), + titleMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + titleSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), bodyLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) - /* Other default text styles to override - titleLarge = TextStyle( + letterSpacing = 0.5.sp, + ), + bodyMedium = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + bodySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), + labelLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, ), labelSmall = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 16.sp, - letterSpacing = 0.5.sp - ) - */ -) \ No newline at end of file + letterSpacing = 0.5.sp, + ), +) + +// 向后兼容:保留默认变量名(供 Theme.kt 使用) +val Typography = CatShareTypography diff --git a/app/src/main/java/moe/reimu/catshare/ui/viewmodel/MainViewModel.kt b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/MainViewModel.kt new file mode 100644 index 0000000..088792c --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/MainViewModel.kt @@ -0,0 +1,100 @@ +package moe.reimu.catshare.ui.viewmodel + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import moe.reimu.catshare.services.GattServerService +import moe.reimu.catshare.utils.ServiceState +import moe.reimu.catshare.utils.registerInternalBroadcastReceiver + +// ===== 主界面 UI 状态 ===== +data class MainUiState( + val isReceiverRunning: Boolean = false, + val shizukuGranted: Boolean = false, + val shizukuAvailable: Boolean = false, + val localMacAddressGranted: Boolean = false, +) + +class MainViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(MainUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + // ===== 内部状态接收器(Broadcast → Flow 桥接) ===== + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + ServiceState.ACTION_UPDATE_RECEIVER_STATE -> { + _uiState.update { + it.copy(isReceiverRunning = intent.getBooleanExtra("isRunning", false)) + } + } + } + } + } + + private var receiverRegistered = false + private var registeredContext: Context? = null + + // ===== 生命周期入口 ===== + fun attach(context: Context) { + if (!receiverRegistered) { + context.registerInternalBroadcastReceiver( + receiver, + IntentFilter(ServiceState.ACTION_UPDATE_RECEIVER_STATE), + ) + receiverRegistered = true + registeredContext = context + } + context.sendBroadcast(ServiceState.getQueryIntent()) + } + + private fun unregister() { + if (receiverRegistered) { + try { + registeredContext?.unregisterReceiver(receiver) + } catch (_: Throwable) { + // 忽略重复 unregister + } + receiverRegistered = false + registeredContext = null + } + } + + override fun onCleared() { + super.onCleared() + unregister() + } + + // ===== Shizuku 状态 ===== + fun updateShizukuState(available: Boolean, granted: Boolean) { + _uiState.update { + it.copy( + shizukuAvailable = available, + shizukuGranted = granted, + ) + } + } + + fun setLocalMacAddressGranted(granted: Boolean) { + _uiState.update { it.copy(localMacAddressGranted = granted) } + } + + // ===== 业务操作:切换可见开关 ===== + fun toggleReceiver(context: Context, targetOn: Boolean) { + if (targetOn) { + GattServerService.start(context) + } else { + GattServerService.stop(context) + } + } +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/viewmodel/SettingsViewModel.kt b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/SettingsViewModel.kt new file mode 100644 index 0000000..6fc0774 --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/SettingsViewModel.kt @@ -0,0 +1,64 @@ +package moe.reimu.catshare.ui.viewmodel + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import moe.reimu.catshare.AppSettings + +// ===== 设置 UI 状态 ===== +data class SettingsUiState( + val deviceName: String = "", + val verbose: Boolean = false, + val autoAccept: Boolean = false, +) + +class SettingsViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(SettingsUiState()) + val uiState: StateFlow = _uiState.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = SettingsUiState(), + ) + + // ===== 从磁盘读取初始设置 ===== + fun load(context: Context) { + val settings = AppSettings(context) + _uiState.update { + SettingsUiState( + deviceName = settings.deviceName, + verbose = settings.verbose, + autoAccept = settings.autoAccept, + ) + } + } + + fun updateDeviceName(name: String) { + _uiState.update { it.copy(deviceName = name) } + } + + fun toggleVerbose() { + _uiState.update { it.copy(verbose = !it.verbose) } + } + + fun toggleAutoAccept() { + _uiState.update { it.copy(autoAccept = !it.autoAccept) } + } + + // ===== 保存设置到磁盘 ===== + fun save(context: Context) { + val state = _uiState.value + val settings = AppSettings(context) + if (state.deviceName.isNotBlank()) { + settings.deviceName = state.deviceName + } + settings.verbose = state.verbose + settings.autoAccept = state.autoAccept + } +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/viewmodel/ShareViewModel.kt b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/ShareViewModel.kt new file mode 100644 index 0000000..384dbee --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/ShareViewModel.kt @@ -0,0 +1,158 @@ +package moe.reimu.catshare.ui.viewmodel + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothManager +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.os.ParcelUuid +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import moe.reimu.catshare.models.DiscoveredDevice +import moe.reimu.catshare.utils.BleUtils +import moe.reimu.catshare.utils.DeviceUtils + +// ===== Share 界面 UI 状态 ===== +data class ShareUiState( + val devices: List = emptyList(), + val isScanning: Boolean = false, + val errorMessage: String? = null, +) + +class ShareViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(ShareUiState()) + val uiState: StateFlow = _uiState.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = ShareUiState(), + ) + + private var scanner: android.bluetooth.le.BluetoothLeScanner? = null + private var scanCallback: ScanCallback? = null + + // ===== 启动 BLE 扫描 ===== + @SuppressLint("MissingPermission") + fun startScan(context: Context) { + if (_uiState.value.isScanning) return + val btManager = context.getSystemService(BluetoothManager::class.java) + val adapter = btManager.adapter ?: return + + val callback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult?) { + val record = result?.scanRecord ?: return + // 解析广播数据 + var deviceName: String? = null + var brandId: Byte? = null + + for ((uuid, data) in record.serviceData.entries) { + when (data.size) { + 6 -> { + // 品牌 + 5GHz 标志位 + val buf = java.nio.ByteBuffer.allocate(16) + buf.putLong(uuid.mostSignificantBits) + buf.putLong(uuid.leastSignificantBits) + brandId = buf.array()[3] + } + 27 -> { + // 设备名称 + senderId + val nameBuf = mutableListOf() + for (i in 10..25) { + if (data[i].toInt() != 0) { + nameBuf.add(data[i]) + } else { + break + } + } + val senderIdRaw = data[8].toInt().shl(8).or(data[9].toInt()) + val senderId = String.format("%04x", senderIdRaw) + var name = nameBuf.toByteArray().decodeToString() + if (name.lastOrNull() == '\t') { + name = name.removeSuffix("\t") + "..." + } + deviceName = name + val brand = brandId?.let { DeviceUtils.deviceNameById(it) } + val newDevice = DiscoveredDevice( + device = result.device, + id = senderId, + name = deviceName, + brand = brand, + supports5Ghz = false, + ) + addOrReplace(newDevice) + } + } + } + } + + override fun onScanFailed(errorCode: Int) { + _uiState.update { + it.copy( + errorMessage = "Scan failed (code=$errorCode)", + isScanning = false, + ) + } + } + } + + try { + val filters = listOf( + ScanFilter.Builder().setServiceUuid(ParcelUuid(BleUtils.ADV_SERVICE_UUID)).build() + ) + val settings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + adapter.bluetoothLeScanner.startScan(filters, settings, callback) + scanner = adapter.bluetoothLeScanner + scanCallback = callback + _uiState.update { it.copy(isScanning = true, errorMessage = null) } + } catch (e: SecurityException) { + _uiState.update { + it.copy(errorMessage = "Bluetooth permission denied: ${e.message}") + } + } + } + + // ===== 停止扫描 ===== + @SuppressLint("MissingPermission") + fun stopScan() { + try { + val cb = scanCallback ?: return + scanner?.stopScan(cb) + } catch (_: Exception) { + // 忽略异常:权限/状态问题不影响 UI + } + scanner = null + scanCallback = null + _uiState.update { it.copy(isScanning = false) } + } + + private fun addOrReplace(device: DiscoveredDevice) { + _uiState.update { state -> + val replaced = state.devices.map { + if (it.id == device.id) device else it + } + val newList = if (replaced.size == state.devices.size && + replaced.none { it.id == device.id } + ) { + state.devices + device + } else { + replaced + } + state.copy(devices = newList) + } + } + + // ===== ViewModel 清理:确保不会泄漏扫描 ===== + override fun onCleared() { + super.onCleared() + stopScan() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5841d35..3a323df 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,8 @@ coreKtx = "1.17.0" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.4" composeBom = "2026.02.00" +navigationCompose = "2.8.9" +lifecycleViewmodelCompose = "2.10.0" ktor = "3.0.3" appcompat = "1.7.1" material = "1.13.0" @@ -21,6 +23,8 @@ androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } @@ -46,4 +50,3 @@ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "ko kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } android-library = { id = "com.android.library", version.ref = "agp" } - From deeb849630ab293ec585a116843dc2153b6af6d2 Mon Sep 17 00:00:00 2001 From: Shiho-Patch Date: Sun, 7 Jun 2026 06:50:28 +0000 Subject: [PATCH 2/7] =?UTF-8?q?fix(gradle):=20=E4=BF=AE=E5=A4=8D=20TOML=20?= =?UTF-8?q?catalog=20=E4=B8=AD=20tooling-preview=20=E7=9A=84=E8=BF=9E?= =?UTF-8?q?=E5=AD=97=E7=AC=A6=E5=BC=95=E7=94=A8=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gradle TOML catalog 将连字符解析为点式嵌套访问,需将 libs.androidx.ui.tooling-preview 改为 libs.androidx.ui.tooling.preview --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0ae921d..f00b522 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -94,7 +94,7 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) implementation(libs.androidx.ui.graphics) - implementation(libs.androidx.ui.tooling-preview) + implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.navigation.compose) From 9b492028a959cf65ba7c145a9b39e6f0009d2775 Mon Sep 17 00:00:00 2001 From: Shiho-Patch Date: Sun, 7 Jun 2026 06:59:20 +0000 Subject: [PATCH 3/7] =?UTF-8?q?fix(build):=20=E4=BF=AE=E5=A4=8D=E7=BC=96?= =?UTF-8?q?=E8=AF=91=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Theme.kt: 添加缺失的 androidx.compose.ui.graphics.Color import - MainActivity.kt: 修正 ActivityResultContract.SynchronousResult 嵌套类引用路径 - ShareActivity.kt: 用 listOfNotNull 处理 extractFileInfo 的可空返回值 - ShareViewModel.kt: ParcelUuid 需要通过 .uuid 获取底层 UUID 对象 --- app/src/main/java/moe/reimu/catshare/MainActivity.kt | 2 +- app/src/main/java/moe/reimu/catshare/ShareActivity.kt | 2 +- app/src/main/java/moe/reimu/catshare/ui/theme/Theme.kt | 1 + .../java/moe/reimu/catshare/ui/viewmodel/ShareViewModel.kt | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/moe/reimu/catshare/MainActivity.kt b/app/src/main/java/moe/reimu/catshare/MainActivity.kt index 9919527..0b31c83 100644 --- a/app/src/main/java/moe/reimu/catshare/MainActivity.kt +++ b/app/src/main/java/moe/reimu/catshare/MainActivity.kt @@ -109,7 +109,7 @@ class ChooseFilesContract : ActivityResultContract>() { override fun getSynchronousResult( context: android.content.Context, input: Void?, - ): androidx.activity.result.contract.ActivityResult.SynchronousResult>? = null + ): ActivityResultContract.SynchronousResult>? = null override fun parseResult(resultCode: Int, intent: Intent?): List { if (intent == null) return emptyList() diff --git a/app/src/main/java/moe/reimu/catshare/ShareActivity.kt b/app/src/main/java/moe/reimu/catshare/ShareActivity.kt index 9e595cb..a68109d 100644 --- a/app/src/main/java/moe/reimu/catshare/ShareActivity.kt +++ b/app/src/main/java/moe/reimu/catshare/ShareActivity.kt @@ -73,7 +73,7 @@ class ShareActivity : ComponentActivity() { @Suppress("DEPRECATION") val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) if (uri != null) { - listOf(extractFileInfo(uri)) + listOfNotNull(extractFileInfo(uri)) } else { val text = intent.getStringExtra(Intent.EXTRA_TEXT) if (text != null) { diff --git a/app/src/main/java/moe/reimu/catshare/ui/theme/Theme.kt b/app/src/main/java/moe/reimu/catshare/ui/theme/Theme.kt index ad6c8e1..28df1ac 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/theme/Theme.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/theme/Theme.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat diff --git a/app/src/main/java/moe/reimu/catshare/ui/viewmodel/ShareViewModel.kt b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/ShareViewModel.kt index 384dbee..b1f2489 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/viewmodel/ShareViewModel.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/ShareViewModel.kt @@ -57,8 +57,8 @@ class ShareViewModel : ViewModel() { 6 -> { // 品牌 + 5GHz 标志位 val buf = java.nio.ByteBuffer.allocate(16) - buf.putLong(uuid.mostSignificantBits) - buf.putLong(uuid.leastSignificantBits) + buf.putLong(uuid.uuid.mostSignificantBits) + buf.putLong(uuid.uuid.leastSignificantBits) brandId = buf.array()[3] } 27 -> { From 58d9dc864e08d8757f766543b5df68defaf5cbd4 Mon Sep 17 00:00:00 2001 From: Shiho-Patch Date: Sun, 7 Jun 2026 07:04:49 +0000 Subject: [PATCH 4/7] =?UTF-8?q?fix(ndk):=20=E7=A6=81=E7=94=A8=20debug=20?= =?UTF-8?q?=E7=89=88=E6=9C=AC=20JNI=20=E7=AC=A6=E5=8F=B7=E5=89=A5=E7=A6=BB?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E9=9D=9E=20x86=5F64=20=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E4=B8=8B=20llvm-strip=20=E5=90=AF=E5=8A=A8=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/build.gradle.kts: packaging.jniLibs.keepDebugSymbols += '**/*.so' - MainScreen.kt: 修复 Icons.Filled.BluetoothSearching 弃用警告 --- app/build.gradle.kts | 4 ++++ app/src/main/java/moe/reimu/catshare/ui/screens/MainScreen.kt | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f00b522..1762bf3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -78,6 +78,10 @@ android { excludes += "META-INF/INDEX.LIST" excludes += "META-INF/*.properties" } + jniLibs { + // 禁用 debug 版本的 JNI 符号剥离(规避 NDK llvm-strip 在非 x86_64 环境下的启动失败问题 + keepDebugSymbols += "**/*.so" + } } } diff --git a/app/src/main/java/moe/reimu/catshare/ui/screens/MainScreen.kt b/app/src/main/java/moe/reimu/catshare/ui/screens/MainScreen.kt index 007fcbe..7404ca9 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/screens/MainScreen.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/screens/MainScreen.kt @@ -16,7 +16,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.BluetoothSearching +import androidx.compose.material.icons.automirrored.filled.BluetoothSearching import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Settings @@ -163,7 +163,7 @@ fun MainScreen( verticalAlignment = Alignment.CenterVertically, ) { CatIcon( - imageVector = Icons.Filled.BluetoothSearching, + imageVector = Icons.AutoMirrored.Filled.BluetoothSearching, contentDescription = null, size = CatIconSize.Medium, ) From 1adcecd18536abd1bb45d606e19b40e7b2d0a297 Mon Sep 17 00:00:00 2001 From: Midori Kochiya Date: Sun, 7 Jun 2026 15:57:28 +0800 Subject: [PATCH 5/7] chore: bump deps --- gradle/libs.versions.toml | 16 ++++++++-------- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3a323df..f937701 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,16 +1,16 @@ [versions] -agp = "9.0.1" -kotlin = "2.3.10" -coreKtx = "1.17.0" +agp = "9.2.1" +kotlin = "2.4.0" +coreKtx = "1.18.0" lifecycleRuntimeKtx = "2.10.0" -activityCompose = "1.12.4" -composeBom = "2026.02.00" -navigationCompose = "2.8.9" +activityCompose = "1.13.0" +composeBom = "2026.05.01" +navigationCompose = "2.9.8" lifecycleViewmodelCompose = "2.10.0" ktor = "3.0.3" appcompat = "1.7.1" -material = "1.13.0" -activity = "1.12.4" +material = "1.14.0" +activity = "1.13.0" shizuku = "13.1.5" [libraries] diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0afc110..c1a3a6f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Sun Dec 29 17:01:12 CST 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From f00fd63410cef4d693caf36bd7627ca9334b5b49 Mon Sep 17 00:00:00 2001 From: Midori Kochiya Date: Sun, 7 Jun 2026 16:08:58 +0800 Subject: [PATCH 6/7] chore: clean up comments --- app/src/main/java/moe/reimu/catshare/MainActivity.kt | 4 ---- app/src/main/java/moe/reimu/catshare/ShareActivity.kt | 4 ---- .../java/moe/reimu/catshare/ui/components/CatButton.kt | 3 --- .../main/java/moe/reimu/catshare/ui/components/CatCard.kt | 3 --- .../main/java/moe/reimu/catshare/ui/components/CatIcon.kt | 3 --- .../moe/reimu/catshare/ui/components/CatPullRefresh.kt | 1 - .../java/moe/reimu/catshare/ui/components/CatTopAppBar.kt | 2 -- .../java/moe/reimu/catshare/ui/components/LoadingStates.kt | 7 ------- .../java/moe/reimu/catshare/ui/navigation/CatNavHost.kt | 1 - .../main/java/moe/reimu/catshare/ui/screens/MainScreen.kt | 4 ---- .../java/moe/reimu/catshare/ui/screens/SettingsScreen.kt | 5 ----- .../main/java/moe/reimu/catshare/ui/screens/ShareScreen.kt | 2 -- app/src/main/java/moe/reimu/catshare/ui/theme/Color.kt | 4 ---- app/src/main/java/moe/reimu/catshare/ui/theme/Shape.kt | 2 -- app/src/main/java/moe/reimu/catshare/ui/theme/Theme.kt | 4 ---- app/src/main/java/moe/reimu/catshare/ui/theme/Type.kt | 2 -- .../java/moe/reimu/catshare/ui/viewmodel/MainViewModel.kt | 5 ----- .../moe/reimu/catshare/ui/viewmodel/SettingsViewModel.kt | 3 --- .../java/moe/reimu/catshare/ui/viewmodel/ShareViewModel.kt | 4 ---- 19 files changed, 63 deletions(-) diff --git a/app/src/main/java/moe/reimu/catshare/MainActivity.kt b/app/src/main/java/moe/reimu/catshare/MainActivity.kt index 0b31c83..35517d0 100644 --- a/app/src/main/java/moe/reimu/catshare/MainActivity.kt +++ b/app/src/main/java/moe/reimu/catshare/MainActivity.kt @@ -20,7 +20,6 @@ import moe.reimu.catshare.ui.theme.CatShareTheme class MainActivity : ComponentActivity() { - // ===== 文件选择器 launcher(必须用 registerForActivityResult 注册) ===== private val fileLauncher = registerForActivityResult(ChooseFilesContract()) { uris -> if (uris.isNotEmpty()) { val intent = Intent(this, ShareActivity::class.java) @@ -29,7 +28,6 @@ class MainActivity : ComponentActivity() { } } - // ===== 权限请求 launcher ===== private val permissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { results -> @@ -46,7 +44,6 @@ class MainActivity : ComponentActivity() { setContent { CatShareTheme { Surface(modifier = Modifier.fillMaxSize()) { - // 委托给 NavHost CatNavHost(onPickFiles = { fileLauncher.launch(null) }) } } @@ -96,7 +93,6 @@ class MainActivity : ComponentActivity() { } } -// ===== 文件选择器 Contract(从原 Activity 提取) ===== class ChooseFilesContract : ActivityResultContract>() { override fun createIntent(context: android.content.Context, input: Void?): Intent { val cf = Intent(Intent.ACTION_GET_CONTENT) diff --git a/app/src/main/java/moe/reimu/catshare/ShareActivity.kt b/app/src/main/java/moe/reimu/catshare/ShareActivity.kt index a68109d..901da8e 100644 --- a/app/src/main/java/moe/reimu/catshare/ShareActivity.kt +++ b/app/src/main/java/moe/reimu/catshare/ShareActivity.kt @@ -22,7 +22,6 @@ class ShareActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Bluetooth 检查 val btManager = getSystemService(BluetoothManager::class.java) val btAdapter = btManager.adapter if (btAdapter == null || !btAdapter.isEnabled) { @@ -30,7 +29,6 @@ class ShareActivity : ComponentActivity() { finish() return } - // WiFi 检查 val wifiManager = getSystemService(WifiManager::class.java) if (!wifiManager.isWifiEnabled) { Toast.makeText(this, R.string.wifi_disabled, Toast.LENGTH_LONG).show() @@ -38,7 +36,6 @@ class ShareActivity : ComponentActivity() { return } - // 解析传入的文件(ACTION_SEND / ACTION_SEND_MULTIPLE) val files = try { parseSharedFiles(intent) } catch (e: Throwable) { @@ -59,7 +56,6 @@ class ShareActivity : ComponentActivity() { files = files, onBack = { finish() }, onDeviceSelected = { - // 启动发送任务后,关闭本 Activity finish() }, ) diff --git a/app/src/main/java/moe/reimu/catshare/ui/components/CatButton.kt b/app/src/main/java/moe/reimu/catshare/ui/components/CatButton.kt index c66ddde..bbb1fd0 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/components/CatButton.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/components/CatButton.kt @@ -36,10 +36,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import moe.reimu.catshare.ui.theme.CatShareShapes -// ===== 按钮变体 ===== enum class CatButtonVariant { Filled, Tonal, Outlined, Text } -// ===== 通用按钮(多态) ===== @Composable fun CatButton( text: String, @@ -117,7 +115,6 @@ fun CatButton( } } -// ===== 主操作按钮 — 带成功态动画 ===== @Composable fun CatPrimaryButton( text: String, diff --git a/app/src/main/java/moe/reimu/catshare/ui/components/CatCard.kt b/app/src/main/java/moe/reimu/catshare/ui/components/CatCard.kt index 2bf654c..5d51577 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/components/CatCard.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/components/CatCard.kt @@ -32,10 +32,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import moe.reimu.catshare.ui.theme.CatShareShapes -// ===== 卡片变体 ===== enum class CatCardVariant { Elevated, Filled, Outlined, Clickable } -// ===== 通用卡片组件 — 多态设计 ===== @Composable fun CatCard( modifier: Modifier = Modifier, @@ -130,7 +128,6 @@ fun CatCard( } } -// ===== Hero 卡片:渐变背景 + 大面积图标 ===== @Composable fun CatHeroCard( title: String, diff --git a/app/src/main/java/moe/reimu/catshare/ui/components/CatIcon.kt b/app/src/main/java/moe/reimu/catshare/ui/components/CatIcon.kt index 8877709..72db531 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/components/CatIcon.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/components/CatIcon.kt @@ -19,14 +19,12 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import moe.reimu.catshare.ui.theme.CatShareShapes -// ===== 图标尺寸规范 ===== enum class CatIconSize(val size: Dp, val padding: Dp) { Small(40.dp, 8.dp), Medium(48.dp, 10.dp), Large(56.dp, 14.dp), } -// ===== 圆形图标容器 — 带品牌色背景 ===== @Composable fun CatIcon( imageVector: ImageVector, @@ -55,7 +53,6 @@ fun CatIcon( } } -// ===== 圆角方形图标容器 ===== @Composable fun CatIconRounded( imageVector: ImageVector, diff --git a/app/src/main/java/moe/reimu/catshare/ui/components/CatPullRefresh.kt b/app/src/main/java/moe/reimu/catshare/ui/components/CatPullRefresh.kt index 9faaffb..0ff579e 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/components/CatPullRefresh.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/components/CatPullRefresh.kt @@ -18,7 +18,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -// ===== 下拉刷新容器(Material3 原生) ===== @OptIn(ExperimentalMaterial3Api::class) @Composable fun CatPullRefresh( diff --git a/app/src/main/java/moe/reimu/catshare/ui/components/CatTopAppBar.kt b/app/src/main/java/moe/reimu/catshare/ui/components/CatTopAppBar.kt index 626731d..eb7efeb 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/components/CatTopAppBar.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/components/CatTopAppBar.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp -// ===== 自定义顶栏 — 带动画 + M3 Expressive 风格 ===== @OptIn(ExperimentalMaterial3Api::class) @Composable fun CatTopAppBar( @@ -72,7 +71,6 @@ fun CatTopAppBar( ) } -// ===== 带动画的子页顶栏(有返回按钮) ===== @OptIn(ExperimentalMaterial3Api::class) @Composable fun CatChildAppBar( diff --git a/app/src/main/java/moe/reimu/catshare/ui/components/LoadingStates.kt b/app/src/main/java/moe/reimu/catshare/ui/components/LoadingStates.kt index b3fda98..beaeddf 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/components/LoadingStates.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/components/LoadingStates.kt @@ -40,7 +40,6 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import moe.reimu.catshare.ui.theme.CatShareShapes -// ========== 1. Loading(加载中) ========== @Composable fun LoadingState( label: String? = null, @@ -68,7 +67,6 @@ fun LoadingState( } } -// ========== 2. Empty(空状态) ========== @Composable fun EmptyState( icon: ImageVector, @@ -117,7 +115,6 @@ fun EmptyState( } } -// ========== 3. Error(错误态) ========== @Composable fun ErrorState( title: String, @@ -168,7 +165,6 @@ fun ErrorState( } } -// ========== 4. Scanning(扫描中 — 呼吸脉冲动画) ========== @Composable fun ScanningState( icon: ImageVector, @@ -200,13 +196,11 @@ fun ScanningState( modifier = Modifier.size(120.dp), contentAlignment = Alignment.Center, ) { - // 外部脉冲圆 Surface( shape = CircleShape, color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = alpha.value * 0.55f), modifier = Modifier.size((120 * scale.value).dp), ) {} - // 核心图标容器 Surface( shape = CircleShape, color = MaterialTheme.colorScheme.primary, @@ -229,7 +223,6 @@ fun ScanningState( } } -// ========== 5. Success(成功态 — 对勾动画) ========== @Composable fun SuccessState( title: String, diff --git a/app/src/main/java/moe/reimu/catshare/ui/navigation/CatNavHost.kt b/app/src/main/java/moe/reimu/catshare/ui/navigation/CatNavHost.kt index 34b184b..9f31c32 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/navigation/CatNavHost.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/navigation/CatNavHost.kt @@ -16,7 +16,6 @@ import moe.reimu.catshare.ui.screens.MainScreen import moe.reimu.catshare.ui.screens.SettingsScreen import moe.reimu.catshare.ui.screens.ShareScreen -// ===== 路由常量 ===== object Routes { const val MAIN = "main" const val SHARE = "share" diff --git a/app/src/main/java/moe/reimu/catshare/ui/screens/MainScreen.kt b/app/src/main/java/moe/reimu/catshare/ui/screens/MainScreen.kt index 7404ca9..3fe4752 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/screens/MainScreen.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/screens/MainScreen.kt @@ -54,7 +54,6 @@ import moe.reimu.catshare.ui.theme.CatShareShapes import moe.reimu.catshare.ui.viewmodel.MainViewModel import rikka.shizuku.Shizuku -// ===== 主界面 — M3 Expressive 风格 ===== @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen( @@ -140,7 +139,6 @@ fun MainScreen( contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { - // ========== Hero:发送入口 ========== item { CatHeroCard( title = stringResource(R.string.send), @@ -156,7 +154,6 @@ fun MainScreen( ) } - // ========== 可发现开关卡片 ========== item { CatCard(variant = CatCardVariant.Filled) { Row( @@ -190,7 +187,6 @@ fun MainScreen( } } - // ========== Shizuku 状态(仅在无本地 MAC 权限时显示) ========== if (!uiState.localMacAddressGranted) { item { val (stateIcon, stateTitle, stateDesc) = when { diff --git a/app/src/main/java/moe/reimu/catshare/ui/screens/SettingsScreen.kt b/app/src/main/java/moe/reimu/catshare/ui/screens/SettingsScreen.kt index 790ab5d..50ab4a4 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/screens/SettingsScreen.kt @@ -37,7 +37,6 @@ import moe.reimu.catshare.ui.components.CatTopAppBar import moe.reimu.catshare.ui.viewmodel.SettingsViewModel import java.io.File -// ===== 设置界面 ===== @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( @@ -77,7 +76,6 @@ fun SettingsScreen( contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { - // 1. 设备名称 item { CatCard(variant = CatCardVariant.Filled) { Column(modifier = Modifier @@ -94,7 +92,6 @@ fun SettingsScreen( } } - // 2. 详细日志 item { CatCard(variant = CatCardVariant.Filled) { Row( @@ -118,7 +115,6 @@ fun SettingsScreen( } } - // 3. 自动接收 item { CatCard(variant = CatCardVariant.Filled) { Row( @@ -142,7 +138,6 @@ fun SettingsScreen( } } - // 4. 导出日志 item { CatCard( variant = CatCardVariant.Outlined, diff --git a/app/src/main/java/moe/reimu/catshare/ui/screens/ShareScreen.kt b/app/src/main/java/moe/reimu/catshare/ui/screens/ShareScreen.kt index 2d4443e..4c6966e 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/screens/ShareScreen.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/screens/ShareScreen.kt @@ -43,7 +43,6 @@ import moe.reimu.catshare.ui.components.EmptyState import moe.reimu.catshare.ui.components.ScanningState import moe.reimu.catshare.ui.viewmodel.ShareViewModel -// ===== Share 设备选择界面 ===== @OptIn(ExperimentalMaterial3Api::class) @Composable fun ShareScreen( @@ -149,7 +148,6 @@ fun ShareScreen( } } -// ===== 单个设备行 ===== @Composable private fun DeviceRow( device: DiscoveredDevice, diff --git a/app/src/main/java/moe/reimu/catshare/ui/theme/Color.kt b/app/src/main/java/moe/reimu/catshare/ui/theme/Color.kt index 022d4fc..5a55624 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/theme/Color.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/theme/Color.kt @@ -2,9 +2,6 @@ package moe.reimu.catshare.ui.theme import androidx.compose.ui.graphics.Color -// ===== 暖橘猫品牌色板 ===== -// Light theme — 暖色基调,阳光而不刺眼 - val Orange40 = Color(0xFFE8692A) // primary 暖橘 val OrangeLight40 = Color(0xFFFF8F5F) // 浅橘色(用于渐变/点缀) val OrangeVariant40 = Color(0xFFCC5A20) // primaryContainer 深橘 @@ -24,7 +21,6 @@ val TextOnVariant40 = Color(0xFF5A4A3A) // onSurfaceVariant val Outline40 = Color(0xFFA08968) // outline 描边色 val Error40 = Color(0xFFE74C3C) // error 珊瑚红 -// ===== Dark theme — 暖暗色调 ===== val Orange80 = Color(0xFFFFAB7B) // primary val OrangeVariant80 = Color(0xFFCC5A20) // primaryContainer val Amber80 = Color(0xFFFFCC80) // secondary diff --git a/app/src/main/java/moe/reimu/catshare/ui/theme/Shape.kt b/app/src/main/java/moe/reimu/catshare/ui/theme/Shape.kt index c59d4d4..c6db948 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/theme/Shape.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/theme/Shape.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Shapes import androidx.compose.ui.unit.dp -// M3 Expressive 形状系统 — 更具表现力的圆角 val CatShareShapes = Shapes( extraSmall = RoundedCornerShape(6.dp), small = RoundedCornerShape(12.dp), @@ -13,6 +12,5 @@ val CatShareShapes = Shapes( extraLarge = RoundedCornerShape(36.dp), ) -// 额外的常用形状 val PillShape = RoundedCornerShape(percent = 50) val TopLargeShape = RoundedCornerShape(topStart = 36.dp, topEnd = 36.dp) diff --git a/app/src/main/java/moe/reimu/catshare/ui/theme/Theme.kt b/app/src/main/java/moe/reimu/catshare/ui/theme/Theme.kt index 28df1ac..b89baa2 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/theme/Theme.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/theme/Theme.kt @@ -16,7 +16,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat -// ===== Light — 暖橘猫品牌色板 ===== private val LightColorScheme = lightColorScheme( primary = Orange40, onPrimary = Color(0xFFFFFFFF), @@ -59,7 +58,6 @@ private val LightColorScheme = lightColorScheme( inversePrimary = OrangeLight40, ) -// ===== Dark — 暖暗色调 ===== private val DarkColorScheme = darkColorScheme( primary = Orange80, onPrimary = Color(0xFF3A1B00), @@ -102,7 +100,6 @@ private val DarkColorScheme = darkColorScheme( inversePrimary = Orange40, ) -// 额外的自定义颜色 token(用于 Hero 区域等) data class CatShareExtraColors( val heroGradientStart: androidx.compose.ui.graphics.Color, val heroGradientEnd: androidx.compose.ui.graphics.Color, @@ -129,7 +126,6 @@ val LocalCatShareExtraColors = staticCompositionLocalOf { LightExtraColors } @Composable fun CatShareTheme( darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ — 保留,给用户动态取色的选项 dynamicColor: Boolean = true, content: @Composable () -> Unit ) { diff --git a/app/src/main/java/moe/reimu/catshare/ui/theme/Type.kt b/app/src/main/java/moe/reimu/catshare/ui/theme/Type.kt index 5458358..769e2a5 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/theme/Type.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/theme/Type.kt @@ -6,7 +6,6 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -// M3 Expressive 排版系统 — 强调标题和展示级文字 val CatShareTypography = Typography( displayLarge = TextStyle( fontFamily = FontFamily.Default, @@ -109,5 +108,4 @@ val CatShareTypography = Typography( ), ) -// 向后兼容:保留默认变量名(供 Theme.kt 使用) val Typography = CatShareTypography diff --git a/app/src/main/java/moe/reimu/catshare/ui/viewmodel/MainViewModel.kt b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/MainViewModel.kt index 088792c..a3dcfea 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/viewmodel/MainViewModel.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/MainViewModel.kt @@ -16,7 +16,6 @@ import moe.reimu.catshare.services.GattServerService import moe.reimu.catshare.utils.ServiceState import moe.reimu.catshare.utils.registerInternalBroadcastReceiver -// ===== 主界面 UI 状态 ===== data class MainUiState( val isReceiverRunning: Boolean = false, val shizukuGranted: Boolean = false, @@ -29,7 +28,6 @@ class MainViewModel : ViewModel() { private val _uiState = MutableStateFlow(MainUiState()) val uiState: StateFlow = _uiState.asStateFlow() - // ===== 内部状态接收器(Broadcast → Flow 桥接) ===== private val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { when (intent?.action) { @@ -45,7 +43,6 @@ class MainViewModel : ViewModel() { private var receiverRegistered = false private var registeredContext: Context? = null - // ===== 生命周期入口 ===== fun attach(context: Context) { if (!receiverRegistered) { context.registerInternalBroadcastReceiver( @@ -75,7 +72,6 @@ class MainViewModel : ViewModel() { unregister() } - // ===== Shizuku 状态 ===== fun updateShizukuState(available: Boolean, granted: Boolean) { _uiState.update { it.copy( @@ -89,7 +85,6 @@ class MainViewModel : ViewModel() { _uiState.update { it.copy(localMacAddressGranted = granted) } } - // ===== 业务操作:切换可见开关 ===== fun toggleReceiver(context: Context, targetOn: Boolean) { if (targetOn) { GattServerService.start(context) diff --git a/app/src/main/java/moe/reimu/catshare/ui/viewmodel/SettingsViewModel.kt b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/SettingsViewModel.kt index 6fc0774..19fe582 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/SettingsViewModel.kt @@ -11,7 +11,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import moe.reimu.catshare.AppSettings -// ===== 设置 UI 状态 ===== data class SettingsUiState( val deviceName: String = "", val verbose: Boolean = false, @@ -27,7 +26,6 @@ class SettingsViewModel : ViewModel() { initialValue = SettingsUiState(), ) - // ===== 从磁盘读取初始设置 ===== fun load(context: Context) { val settings = AppSettings(context) _uiState.update { @@ -51,7 +49,6 @@ class SettingsViewModel : ViewModel() { _uiState.update { it.copy(autoAccept = !it.autoAccept) } } - // ===== 保存设置到磁盘 ===== fun save(context: Context) { val state = _uiState.value val settings = AppSettings(context) diff --git a/app/src/main/java/moe/reimu/catshare/ui/viewmodel/ShareViewModel.kt b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/ShareViewModel.kt index b1f2489..57c96c5 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/viewmodel/ShareViewModel.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/ShareViewModel.kt @@ -19,7 +19,6 @@ import moe.reimu.catshare.models.DiscoveredDevice import moe.reimu.catshare.utils.BleUtils import moe.reimu.catshare.utils.DeviceUtils -// ===== Share 界面 UI 状态 ===== data class ShareUiState( val devices: List = emptyList(), val isScanning: Boolean = false, @@ -38,7 +37,6 @@ class ShareViewModel : ViewModel() { private var scanner: android.bluetooth.le.BluetoothLeScanner? = null private var scanCallback: ScanCallback? = null - // ===== 启动 BLE 扫描 ===== @SuppressLint("MissingPermission") fun startScan(context: Context) { if (_uiState.value.isScanning) return @@ -120,7 +118,6 @@ class ShareViewModel : ViewModel() { } } - // ===== 停止扫描 ===== @SuppressLint("MissingPermission") fun stopScan() { try { @@ -150,7 +147,6 @@ class ShareViewModel : ViewModel() { } } - // ===== ViewModel 清理:确保不会泄漏扫描 ===== override fun onCleared() { super.onCleared() stopScan() From 6d45a3914ba1725fb8b04e4996eecce6b54d4697 Mon Sep 17 00:00:00 2001 From: Midori Kochiya Date: Sun, 7 Jun 2026 16:48:54 +0800 Subject: [PATCH 7/7] refactor: ViewModels --- app/build.gradle.kts | 3 +- .../catshare/services/P2pSenderService.kt | 9 +- .../reimu/catshare/ui/components/CatButton.kt | 169 ------------------ .../reimu/catshare/ui/components/CatCard.kt | 4 - .../reimu/catshare/ui/components/CatIcon.kt | 32 ---- .../catshare/ui/components/CatPullRefresh.kt | 6 - .../catshare/ui/components/CatTopAppBar.kt | 13 -- .../catshare/ui/components/LoadingStates.kt | 154 +--------------- .../reimu/catshare/ui/screens/MainScreen.kt | 88 ++------- .../catshare/ui/screens/SettingsScreen.kt | 30 ++-- .../reimu/catshare/ui/screens/ShareScreen.kt | 21 +-- .../catshare/ui/viewmodel/MainViewModel.kt | 95 ++++++---- .../catshare/ui/viewmodel/ShareViewModel.kt | 158 ++++++++-------- gradle/libs.versions.toml | 4 + 14 files changed, 181 insertions(+), 605 deletions(-) delete mode 100644 app/src/main/java/moe/reimu/catshare/ui/components/CatButton.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1762bf3..d834abc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -104,8 +104,9 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") + implementation(libs.kotlinx.coroutines.android) implementation("no.nordicsemi.android.kotlin.ble:client:1.3.1") + implementation(libs.accompanist.permissions) implementation(libs.ktor.client.core) implementation(libs.ktor.client.okhttp) diff --git a/app/src/main/java/moe/reimu/catshare/services/P2pSenderService.kt b/app/src/main/java/moe/reimu/catshare/services/P2pSenderService.kt index 99f1412..b50be7e 100644 --- a/app/src/main/java/moe/reimu/catshare/services/P2pSenderService.kt +++ b/app/src/main/java/moe/reimu/catshare/services/P2pSenderService.kt @@ -46,7 +46,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.selects.select import kotlinx.coroutines.withTimeoutOrNull -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import moe.reimu.catshare.AppSettings import moe.reimu.catshare.BleSecurity @@ -95,7 +94,7 @@ class P2pSenderService : BaseP2pService() { private val currentTaskLock = Object() private var currentJob: Job? = null - private var curreentTaskId: Int? = null + private var currentTaskId: Int? = null private lateinit var notificationManager: NotificationManagerCompat @@ -545,14 +544,14 @@ class P2pSenderService : BaseP2pService() { stopForeground(STOP_FOREGROUND_REMOVE) MyApplication.getInstance().clearBusy() synchronized(currentTaskLock) { - curreentTaskId = null + currentTaskId = null currentJob = null } } } synchronized(currentTaskLock) { - curreentTaskId = task.id + currentTaskId = task.id currentJob = job } @@ -561,7 +560,7 @@ class P2pSenderService : BaseP2pService() { fun cancel(taskId: Int) { synchronized(currentTaskLock) { - if (curreentTaskId == taskId) { + if (currentTaskId == taskId) { currentJob?.cancel(CancelledByUserException(false)) } } diff --git a/app/src/main/java/moe/reimu/catshare/ui/components/CatButton.kt b/app/src/main/java/moe/reimu/catshare/ui/components/CatButton.kt deleted file mode 100644 index bbb1fd0..0000000 --- a/app/src/main/java/moe/reimu/catshare/ui/components/CatButton.kt +++ /dev/null @@ -1,169 +0,0 @@ -package moe.reimu.catshare.ui.components - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonColors -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.contentColorFor -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import moe.reimu.catshare.ui.theme.CatShareShapes - -enum class CatButtonVariant { Filled, Tonal, Outlined, Text } - -@Composable -fun CatButton( - text: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - variant: CatButtonVariant = CatButtonVariant.Filled, - enabled: Boolean = true, - leadingIcon: (@Composable () -> Unit)? = null, - trailingIcon: (@Composable () -> Unit)? = null, -) { - val content: @Composable RowScope.() -> Unit = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - if (leadingIcon != null) { - leadingIcon() - Spacer(Modifier.width(8.dp)) - } - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - ) - if (trailingIcon != null) { - Spacer(Modifier.width(8.dp)) - trailingIcon() - } - } - } - - when (variant) { - CatButtonVariant.Filled -> { - Button( - onClick = onClick, - modifier = modifier.defaultMinSize(minHeight = 48.dp), - enabled = enabled, - shape = CatShareShapes.medium, - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp), - content = content, - ) - } - - CatButtonVariant.Tonal -> { - FilledTonalButton( - onClick = onClick, - modifier = modifier.defaultMinSize(minHeight = 48.dp), - enabled = enabled, - shape = CatShareShapes.medium, - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp), - content = content, - ) - } - - CatButtonVariant.Outlined -> { - OutlinedButton( - onClick = onClick, - modifier = modifier.defaultMinSize(minHeight = 48.dp), - enabled = enabled, - shape = CatShareShapes.medium, - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp), - content = content, - ) - } - - CatButtonVariant.Text -> { - TextButton( - onClick = onClick, - modifier = modifier.defaultMinSize(minHeight = 48.dp), - enabled = enabled, - shape = CatShareShapes.medium, - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 10.dp), - content = content, - ) - } - } -} - -@Composable -fun CatPrimaryButton( - text: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - success: Boolean = false, -) { - var isSuccess by remember { mutableStateOf(success) } - val containerColor: Color by animateColorAsState( - targetValue = if (isSuccess) MaterialTheme.colorScheme.tertiary - else MaterialTheme.colorScheme.primary, - animationSpec = spring(stiffness = Spring.StiffnessMedium), - label = "primary_btn_color", - ) - - Button( - onClick = { - if (enabled) { - onClick() - if (success) { - isSuccess = true - } - } - }, - modifier = modifier.defaultMinSize(minHeight = 52.dp), - enabled = enabled, - shape = CatShareShapes.medium, - colors = ButtonDefaults.buttonColors( - containerColor = containerColor, - contentColor = contentColorFor(containerColor), - ), - contentPadding = PaddingValues(horizontal = 28.dp, vertical = 14.dp), - ) { - AnimatedVisibility( - visible = isSuccess, - enter = scaleIn(animationSpec = spring()), - exit = scaleOut(animationSpec = spring()), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "✓ ", - style = MaterialTheme.typography.labelLarge, - ) - } - } - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - ) - } -} diff --git a/app/src/main/java/moe/reimu/catshare/ui/components/CatCard.kt b/app/src/main/java/moe/reimu/catshare/ui/components/CatCard.kt index 5d51577..ab99d63 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/components/CatCard.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/components/CatCard.kt @@ -1,12 +1,9 @@ package moe.reimu.catshare.ui.components -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -15,7 +12,6 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Card -import androidx.compose.material3.CardColors import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.MaterialTheme diff --git a/app/src/main/java/moe/reimu/catshare/ui/components/CatIcon.kt b/app/src/main/java/moe/reimu/catshare/ui/components/CatIcon.kt index 72db531..4d64a6d 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/components/CatIcon.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/components/CatIcon.kt @@ -1,8 +1,6 @@ package moe.reimu.catshare.ui.components -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -11,13 +9,11 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import moe.reimu.catshare.ui.theme.CatShareShapes enum class CatIconSize(val size: Dp, val padding: Dp) { Small(40.dp, 8.dp), @@ -52,31 +48,3 @@ fun CatIcon( } } } - -@Composable -fun CatIconRounded( - imageVector: ImageVector, - contentDescription: String?, - modifier: Modifier = Modifier, - size: CatIconSize = CatIconSize.Medium, - containerColor: Color? = null, - contentColor: Color? = null, -) { - val actualContainerColor = containerColor ?: MaterialTheme.colorScheme.primaryContainer - val actualContentColor = contentColor ?: contentColorFor(actualContainerColor) - - Surface( - modifier = modifier.size(size.size), - shape = CatShareShapes.medium, - color = actualContainerColor, - contentColor = actualContentColor, - ) { - Box(modifier = Modifier.padding(size.padding)) { - Icon( - imageVector = imageVector, - contentDescription = contentDescription, - modifier = Modifier.size(size.size - size.padding * 2), - ) - } - } -} diff --git a/app/src/main/java/moe/reimu/catshare/ui/components/CatPullRefresh.kt b/app/src/main/java/moe/reimu/catshare/ui/components/CatPullRefresh.kt index 0ff579e..875ad45 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/components/CatPullRefresh.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/components/CatPullRefresh.kt @@ -1,20 +1,14 @@ package moe.reimu.catshare.ui.components -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp diff --git a/app/src/main/java/moe/reimu/catshare/ui/components/CatTopAppBar.kt b/app/src/main/java/moe/reimu/catshare/ui/components/CatTopAppBar.kt index eb7efeb..e29e5cc 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/components/CatTopAppBar.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/components/CatTopAppBar.kt @@ -1,31 +1,18 @@ package moe.reimu.catshare.ui.components -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.animation.expandHorizontally -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.unit.dp @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/java/moe/reimu/catshare/ui/components/LoadingStates.kt b/app/src/main/java/moe/reimu/catshare/ui/components/LoadingStates.kt index beaeddf..249472d 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/components/LoadingStates.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/components/LoadingStates.kt @@ -1,26 +1,15 @@ package moe.reimu.catshare.ui.components -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Done -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -29,52 +18,20 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp -import kotlinx.coroutines.delay -import moe.reimu.catshare.ui.theme.CatShareShapes - -@Composable -fun LoadingState( - label: String? = null, - modifier: Modifier = Modifier, - fullscreen: Boolean = false, -) { - val rootModifier = if (fullscreen) modifier.fillMaxSize() else modifier - Column( - modifier = rootModifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.primary, - strokeWidth = 3.dp, - ) - if (label != null) { - Spacer(Modifier.height(16.dp)) - Text( - text = label, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} @Composable fun EmptyState( icon: ImageVector, title: String, + modifier: Modifier = Modifier, description: String? = null, actionLabel: String? = null, onAction: (() -> Unit)? = null, - modifier: Modifier = Modifier, ) { Column( modifier = modifier.padding(24.dp).fillMaxWidth(), @@ -115,56 +72,6 @@ fun EmptyState( } } -@Composable -fun ErrorState( - title: String, - description: String? = null, - icon: ImageVector? = null, - retryLabel: String? = null, - onRetry: (() -> Unit)? = null, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier.padding(24.dp).fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - if (icon != null) { - Surface( - shape = CatShareShapes.large, - color = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(72.dp).padding(18.dp), - ) - } - Spacer(Modifier.height(20.dp)) - } - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - if (description != null) { - Spacer(Modifier.height(8.dp)) - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (retryLabel != null && onRetry != null) { - Spacer(Modifier.height(20.dp)) - TextButton(onClick = onRetry) { - Text(text = retryLabel) - } - } - } -} - @Composable fun ScanningState( icon: ImageVector, @@ -223,62 +130,3 @@ fun ScanningState( } } -@Composable -fun SuccessState( - title: String, - description: String? = null, - modifier: Modifier = Modifier, -) { - var drawProgress by remember { mutableFloatStateOf(0f) } - LaunchedEffect(Unit) { - delay(150) - drawProgress = 1f - } - val animated by animateFloatAsState( - targetValue = drawProgress, - animationSpec = spring(stiffness = Spring.StiffnessLow), - label = "success_anim", - ) - - Column( - modifier = modifier.padding(24.dp).fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - AnimatedVisibility( - visible = animated > 0.01f, - enter = fadeIn(animationSpec = spring()) + scaleIn( - animationSpec = spring(stiffness = Spring.StiffnessLow), - initialScale = 0.5f, - ), - exit = fadeOut(), - ) { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.tertiary, - contentColor = contentColorFor(MaterialTheme.colorScheme.tertiary), - modifier = Modifier.size(72.dp), - ) { - Icon( - imageVector = Icons.Filled.Done, - contentDescription = null, - modifier = Modifier.size(72.dp).padding(18.dp), - ) - } - } - Spacer(Modifier.height(20.dp)) - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - if (description != null) { - Spacer(Modifier.height(8.dp)) - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} diff --git a/app/src/main/java/moe/reimu/catshare/ui/screens/MainScreen.kt b/app/src/main/java/moe/reimu/catshare/ui/screens/MainScreen.kt index 3fe4752..399b407 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/screens/MainScreen.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/screens/MainScreen.kt @@ -1,15 +1,11 @@ package moe.reimu.catshare.ui.screens -import android.content.pm.PackageManager import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -28,21 +24,17 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState import moe.reimu.catshare.R import moe.reimu.catshare.ui.components.CatCard import moe.reimu.catshare.ui.components.CatCardVariant @@ -50,11 +42,10 @@ import moe.reimu.catshare.ui.components.CatHeroCard import moe.reimu.catshare.ui.components.CatIcon import moe.reimu.catshare.ui.components.CatIconSize import moe.reimu.catshare.ui.components.CatTopAppBar -import moe.reimu.catshare.ui.theme.CatShareShapes import moe.reimu.catshare.ui.viewmodel.MainViewModel import rikka.shizuku.Shizuku -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) @Composable fun MainScreen( onNavigateSettings: () -> Unit, @@ -62,59 +53,8 @@ fun MainScreen( viewModel: MainViewModel = viewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val context = LocalContext.current - - LaunchedEffect(Unit) { - // 初始化:绑定广播接收器 + 查询服务状态 - viewModel.attach(context) - // MAC 权限检查 - val macGranted = context.checkSelfPermission( - "android.permission.LOCAL_MAC_ADDRESS" - ) == PackageManager.PERMISSION_GRANTED - viewModel.setLocalMacAddressGranted(macGranted) - // Shizuku 绑定 - val available = try { - Shizuku.pingBinder() - } catch (_: Throwable) { - false - } - viewModel.updateShizukuState( - available = available, - granted = available && Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED, - ) - } - - // Shizuku 监听 - DisposableEffect(Unit) { - val permListener = Shizuku.OnRequestPermissionResultListener { _, grantResult -> - val available = try { Shizuku.pingBinder() } catch (_: Throwable) { false } - viewModel.updateShizukuState( - available = available, - granted = grantResult == PackageManager.PERMISSION_GRANTED, - ) - } - val binderRecv = Shizuku.OnBinderReceivedListener { - val available = try { Shizuku.pingBinder() } catch (_: Throwable) { false } - viewModel.updateShizukuState( - available = available, - granted = Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED, - ) - } - val binderDead = Shizuku.OnBinderDeadListener { - viewModel.updateShizukuState(available = false, granted = false) - } - Shizuku.addRequestPermissionResultListener(permListener) - Shizuku.addBinderReceivedListenerSticky(binderRecv) - Shizuku.addBinderDeadListener(binderDead) - onDispose { - Shizuku.removeRequestPermissionResultListener(permListener) - Shizuku.removeBinderReceivedListener(binderRecv) - Shizuku.removeBinderDeadListener(binderDead) - } - } - + val macPermissionState = rememberPermissionState("android.permission.LOCAL_MAC_ADDRESS") val scrollState = rememberLazyListState() - val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) Scaffold( topBar = { @@ -180,30 +120,30 @@ fun MainScreen( Switch( checked = uiState.isReceiverRunning, onCheckedChange = { target -> - viewModel.toggleReceiver(context, target) + viewModel.toggleReceiver(target) }, ) } } } - if (!uiState.localMacAddressGranted) { + if (!macPermissionState.status.isGranted) { item { val (stateIcon, stateTitle, stateDesc) = when { uiState.shizukuAvailable && uiState.shizukuGranted -> Triple( Icons.Filled.Done, - context.getString(R.string.shizuku_available), - context.getString(R.string.shizuku_desc), + stringResource(R.string.shizuku_available), + stringResource(R.string.shizuku_desc), ) uiState.shizukuAvailable && !uiState.shizukuGranted -> Triple( Icons.Filled.Close, - context.getString(R.string.shizuku_not_granted), - context.getString(R.string.shizuku_desc), + stringResource(R.string.shizuku_not_granted), + stringResource(R.string.shizuku_desc), ) else -> Triple( Icons.Filled.Close, - context.getString(R.string.shizuku_unavailable), - context.getString(R.string.shizuku_desc), + stringResource(R.string.shizuku_unavailable), + stringResource(R.string.shizuku_desc), ) } CatCard( @@ -244,8 +184,6 @@ fun MainScreen( } } } - - item { Spacer(Modifier.height(8.dp)) } } } } diff --git a/app/src/main/java/moe/reimu/catshare/ui/screens/SettingsScreen.kt b/app/src/main/java/moe/reimu/catshare/ui/screens/SettingsScreen.kt index 50ab4a4..d12477b 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/screens/SettingsScreen.kt @@ -78,17 +78,13 @@ fun SettingsScreen( ) { item { CatCard(variant = CatCardVariant.Filled) { - Column(modifier = Modifier - .fillMaxWidth() - .padding(16.dp)) { - OutlinedTextField( - value = uiState.deviceName, - onValueChange = viewModel::updateDeviceName, - label = { Text(stringResource(R.string.device_name)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - } + OutlinedTextField( + value = uiState.deviceName, + onValueChange = viewModel::updateDeviceName, + label = { Text(stringResource(R.string.device_name)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) } } @@ -96,8 +92,7 @@ fun SettingsScreen( CatCard(variant = CatCardVariant.Filled) { Row( modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + .fillMaxWidth(), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { @@ -119,8 +114,7 @@ fun SettingsScreen( CatCard(variant = CatCardVariant.Filled) { Row( modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + .fillMaxWidth(), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { @@ -165,7 +159,7 @@ fun SettingsScreen( .setType("text/plain") .setFlags( Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_ACTIVITY_NEW_TASK + Intent.FLAG_ACTIVITY_NEW_TASK ) context.startActivity(intent) } catch (_: Throwable) { @@ -174,7 +168,7 @@ fun SettingsScreen( }.start() }, ) { - Column(modifier = Modifier.padding(16.dp)) { + Column { Text( text = stringResource(R.string.capture_logs), style = MaterialTheme.typography.titleMedium, @@ -187,8 +181,6 @@ fun SettingsScreen( } } } - - item { Spacer(Modifier.padding(8.dp)) } } } } diff --git a/app/src/main/java/moe/reimu/catshare/ui/screens/ShareScreen.kt b/app/src/main/java/moe/reimu/catshare/ui/screens/ShareScreen.kt index 4c6966e..42050fc 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/screens/ShareScreen.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/screens/ShareScreen.kt @@ -1,6 +1,5 @@ package moe.reimu.catshare.ui.screens -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -8,23 +7,19 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -32,7 +27,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel import moe.reimu.catshare.R import moe.reimu.catshare.models.DiscoveredDevice import moe.reimu.catshare.models.FileInfo -import moe.reimu.catshare.services.P2pSenderService import moe.reimu.catshare.ui.components.CatCard import moe.reimu.catshare.ui.components.CatCardVariant import moe.reimu.catshare.ui.components.CatChildAppBar @@ -52,11 +46,9 @@ fun ShareScreen( viewModel: ShareViewModel = viewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val context = LocalContext.current - // 生命周期感知的扫描启停 LaunchedEffect(Unit) { - viewModel.startScan(context) + viewModel.startScan() } Scaffold( @@ -98,18 +90,13 @@ fun ShareScreen( } val onPick: (DiscoveredDevice) -> Unit = { device -> - val task = moe.reimu.catshare.models.TaskInfo( - id = kotlin.random.Random.nextInt(), - device = device, - files = files, - ) - P2pSenderService.startTaskChecked(context, task) + viewModel.startTask(device, files) onDeviceSelected() } CatPullRefresh( isRefreshing = false, - onRefresh = { viewModel.startScan(context) }, + onRefresh = { viewModel.startScan() }, contentPadding = PaddingValues(16.dp), ) { when { @@ -126,7 +113,7 @@ fun ShareScreen( title = stringResource(R.string.choose_recipient), description = stringResource(R.string.scanning_desc), actionLabel = "Refresh", - onAction = { viewModel.startScan(context) }, + onAction = { viewModel.startScan() }, ) } } diff --git a/app/src/main/java/moe/reimu/catshare/ui/viewmodel/MainViewModel.kt b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/MainViewModel.kt index a3dcfea..861152f 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/viewmodel/MainViewModel.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/MainViewModel.kt @@ -1,30 +1,28 @@ package moe.reimu.catshare.ui.viewmodel +import android.app.Application import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import android.content.pm.PackageManager +import androidx.lifecycle.AndroidViewModel import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import moe.reimu.catshare.services.GattServerService import moe.reimu.catshare.utils.ServiceState import moe.reimu.catshare.utils.registerInternalBroadcastReceiver +import rikka.shizuku.Shizuku data class MainUiState( val isReceiverRunning: Boolean = false, val shizukuGranted: Boolean = false, val shizukuAvailable: Boolean = false, - val localMacAddressGranted: Boolean = false, ) -class MainViewModel : ViewModel() { - +class MainViewModel(application: Application) : AndroidViewModel(application) { private val _uiState = MutableStateFlow(MainUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -40,36 +38,54 @@ class MainViewModel : ViewModel() { } } - private var receiverRegistered = false - private var registeredContext: Context? = null + private val shizukuPermissionListener = Shizuku.OnRequestPermissionResultListener { _, grantResult -> + val available = shizukuPing() + updateShizukuState( + available = available, + granted = grantResult == PackageManager.PERMISSION_GRANTED, + ) + } - fun attach(context: Context) { - if (!receiverRegistered) { - context.registerInternalBroadcastReceiver( - receiver, - IntentFilter(ServiceState.ACTION_UPDATE_RECEIVER_STATE), - ) - receiverRegistered = true - registeredContext = context - } - context.sendBroadcast(ServiceState.getQueryIntent()) + private val shizukuBinderListener = Shizuku.OnBinderReceivedListener { + val available = shizukuPing() + updateShizukuState( + available = available, + granted = Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED, + ) } - private fun unregister() { - if (receiverRegistered) { - try { - registeredContext?.unregisterReceiver(receiver) - } catch (_: Throwable) { - // 忽略重复 unregister - } - receiverRegistered = false - registeredContext = null - } + private val shizukuDeadListener = Shizuku.OnBinderDeadListener { + updateShizukuState(available = false, granted = false) + } + + init { + val app = getApplication() + app.registerInternalBroadcastReceiver( + receiver, + IntentFilter(ServiceState.ACTION_UPDATE_RECEIVER_STATE), + ) + app.sendBroadcast(ServiceState.getQueryIntent()) + + val available = shizukuPing() + updateShizukuState( + available = available, + granted = available && Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED, + ) + + Shizuku.addRequestPermissionResultListener(shizukuPermissionListener) + Shizuku.addBinderReceivedListenerSticky(shizukuBinderListener) + Shizuku.addBinderDeadListener(shizukuDeadListener) } override fun onCleared() { super.onCleared() - unregister() + try { + getApplication().unregisterReceiver(receiver) + } catch (_: Throwable) { + } + Shizuku.removeRequestPermissionResultListener(shizukuPermissionListener) + Shizuku.removeBinderReceivedListener(shizukuBinderListener) + Shizuku.removeBinderDeadListener(shizukuDeadListener) } fun updateShizukuState(available: Boolean, granted: Boolean) { @@ -81,15 +97,20 @@ class MainViewModel : ViewModel() { } } - fun setLocalMacAddressGranted(granted: Boolean) { - _uiState.update { it.copy(localMacAddressGranted = granted) } - } - - fun toggleReceiver(context: Context, targetOn: Boolean) { + fun toggleReceiver(targetOn: Boolean) { + val app = getApplication() if (targetOn) { - GattServerService.start(context) + GattServerService.start(app) } else { - GattServerService.stop(context) + GattServerService.stop(app) + } + } + + private fun shizukuPing(): Boolean { + return try { + Shizuku.pingBinder() + } catch (_: Throwable) { + false } } } diff --git a/app/src/main/java/moe/reimu/catshare/ui/viewmodel/ShareViewModel.kt b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/ShareViewModel.kt index 57c96c5..e7f406b 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/viewmodel/ShareViewModel.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/ShareViewModel.kt @@ -1,23 +1,28 @@ package moe.reimu.catshare.ui.viewmodel import android.annotation.SuppressLint +import android.app.Application import android.bluetooth.BluetoothManager +import android.bluetooth.le.BluetoothLeScanner import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanSettings -import android.content.Context import android.os.ParcelUuid -import androidx.lifecycle.ViewModel +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import moe.reimu.catshare.MyApplication import moe.reimu.catshare.models.DiscoveredDevice +import moe.reimu.catshare.models.FileInfo +import moe.reimu.catshare.services.P2pSenderService import moe.reimu.catshare.utils.BleUtils import moe.reimu.catshare.utils.DeviceUtils +import java.nio.ByteBuffer data class ShareUiState( val devices: List = emptyList(), @@ -25,8 +30,7 @@ data class ShareUiState( val errorMessage: String? = null, ) -class ShareViewModel : ViewModel() { - +class ShareViewModel(application: Application) : AndroidViewModel(application) { private val _uiState = MutableStateFlow(ShareUiState()) val uiState: StateFlow = _uiState.stateIn( scope = viewModelScope, @@ -34,82 +38,83 @@ class ShareViewModel : ViewModel() { initialValue = ShareUiState(), ) - private var scanner: android.bluetooth.le.BluetoothLeScanner? = null - private var scanCallback: ScanCallback? = null + private var scanner: BluetoothLeScanner? = null - @SuppressLint("MissingPermission") - fun startScan(context: Context) { - if (_uiState.value.isScanning) return - val btManager = context.getSystemService(BluetoothManager::class.java) - val adapter = btManager.adapter ?: return + private val scanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult?) { + val record = result?.scanRecord ?: return + var supports5Ghz = false + var deviceName: String? + var brandId: Byte? = null - val callback = object : ScanCallback() { - override fun onScanResult(callbackType: Int, result: ScanResult?) { - val record = result?.scanRecord ?: return - // 解析广播数据 - var deviceName: String? = null - var brandId: Byte? = null + for ((uuid, data) in record.serviceData.entries) { + when (data.size) { + 6 -> { + // 品牌 + 5GHz 标志位 + val buf = ByteBuffer.allocate(16) + buf.putLong(uuid.uuid.mostSignificantBits) + buf.putLong(uuid.uuid.leastSignificantBits) + val arr = buf.array() + supports5Ghz = arr[2].toInt() == 1 + brandId = arr[3] + } - for ((uuid, data) in record.serviceData.entries) { - when (data.size) { - 6 -> { - // 品牌 + 5GHz 标志位 - val buf = java.nio.ByteBuffer.allocate(16) - buf.putLong(uuid.uuid.mostSignificantBits) - buf.putLong(uuid.uuid.leastSignificantBits) - brandId = buf.array()[3] - } - 27 -> { - // 设备名称 + senderId - val nameBuf = mutableListOf() - for (i in 10..25) { - if (data[i].toInt() != 0) { - nameBuf.add(data[i]) - } else { - break - } - } - val senderIdRaw = data[8].toInt().shl(8).or(data[9].toInt()) - val senderId = String.format("%04x", senderIdRaw) - var name = nameBuf.toByteArray().decodeToString() - if (name.lastOrNull() == '\t') { - name = name.removeSuffix("\t") + "..." + 27 -> { + // 设备名称 + senderId + val nameBuf = mutableListOf() + for (i in 10..25) { + if (data[i].toInt() != 0) { + nameBuf.add(data[i]) + } else { + break } - deviceName = name - val brand = brandId?.let { DeviceUtils.deviceNameById(it) } - val newDevice = DiscoveredDevice( - device = result.device, - id = senderId, - name = deviceName, - brand = brand, - supports5Ghz = false, - ) - addOrReplace(newDevice) } + val senderIdRaw = data[8].toInt().shl(8).or(data[9].toInt()) + val senderId = String.format("%04x", senderIdRaw) + var name = nameBuf.toByteArray().decodeToString() + if (name.lastOrNull() == '\t') { + name = name.removeSuffix("\t") + "..." + } + deviceName = name + val brand = brandId?.let { DeviceUtils.deviceNameById(it) } + val newDevice = DiscoveredDevice( + device = result.device, + id = senderId, + name = deviceName, + brand = brand, + supports5Ghz = supports5Ghz, + ) + addOrReplace(newDevice) } } } + } - override fun onScanFailed(errorCode: Int) { - _uiState.update { - it.copy( - errorMessage = "Scan failed (code=$errorCode)", - isScanning = false, - ) - } + override fun onScanFailed(errorCode: Int) { + _uiState.update { + it.copy( + errorMessage = "Scan failed (code=$errorCode)", + isScanning = false, + ) } } + } + + @SuppressLint("MissingPermission") + fun startScan() { + if (_uiState.value.isScanning) return + val context = getApplication() + val btManager = context.getSystemService(BluetoothManager::class.java) + val adapter = btManager.adapter ?: return try { val filters = listOf( ScanFilter.Builder().setServiceUuid(ParcelUuid(BleUtils.ADV_SERVICE_UUID)).build() ) - val settings = ScanSettings.Builder() - .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) - .build() - adapter.bluetoothLeScanner.startScan(filters, settings, callback) + val settings = + ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build() + adapter.bluetoothLeScanner.startScan(filters, settings, scanCallback) scanner = adapter.bluetoothLeScanner - scanCallback = callback _uiState.update { it.copy(isScanning = true, errorMessage = null) } } catch (e: SecurityException) { _uiState.update { @@ -121,13 +126,10 @@ class ShareViewModel : ViewModel() { @SuppressLint("MissingPermission") fun stopScan() { try { - val cb = scanCallback ?: return - scanner?.stopScan(cb) + scanner?.stopScan(scanCallback) } catch (_: Exception) { - // 忽略异常:权限/状态问题不影响 UI } scanner = null - scanCallback = null _uiState.update { it.copy(isScanning = false) } } @@ -136,17 +138,25 @@ class ShareViewModel : ViewModel() { val replaced = state.devices.map { if (it.id == device.id) device else it } - val newList = if (replaced.size == state.devices.size && - replaced.none { it.id == device.id } - ) { - state.devices + device - } else { - replaced - } + val newList = + if (replaced.size == state.devices.size && replaced.none { it.id == device.id }) { + state.devices + device + } else { + replaced + } state.copy(devices = newList) } } + fun startTask(device: DiscoveredDevice, files: List) { + val task = moe.reimu.catshare.models.TaskInfo( + id = kotlin.random.Random.nextInt(), + device = device, + files = files, + ) + P2pSenderService.startTaskChecked(getApplication(), task) + } + override fun onCleared() { super.onCleared() stopScan() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f937701..8d7dad9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,9 @@ [versions] +accompanistPermissions = "0.37.3" agp = "9.2.1" kotlin = "2.4.0" coreKtx = "1.18.0" +kotlinxCoroutinesAndroid = "1.10.2" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.13.0" composeBom = "2026.05.01" @@ -14,6 +16,7 @@ activity = "1.13.0" shizuku = "13.1.5" [libraries] +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } @@ -26,6 +29,7 @@ androidx-compose-material-icons-extended = { group = "androidx.compose.material" androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }