From 2ed7bfc6b9caa561bb92ffabd8f3f874ac0bdf52 Mon Sep 17 00:00:00 2001 From: kevin930321 Date: Tue, 10 Feb 2026 07:50:34 +0000 Subject: [PATCH] feat: implement ai reply notifications and fix auto-scroll in chat --- app/src/main/AndroidManifest.xml | 1 + .../com/openclaw/dashboard/MainActivity.kt | 40 +++++++-- .../com/openclaw/dashboard/OpenClawApp.kt | 2 + .../data/repository/SettingsRepository.kt | 17 ++++ .../dashboard/presentation/MainViewModel.kt | 43 ++++++++++ .../presentation/components/SettingsDialog.kt | 33 ++++++- .../presentation/screen/chat/ChatScreen.kt | 34 ++++---- .../screen/overview/OverviewScreen.kt | 5 +- .../dashboard/util/NotificationHelper.kt | 85 +++++++++++++++++++ app/src/main/res/values-zh-rTW/strings.xml | 7 ++ app/src/main/res/values/strings.xml | 7 ++ 11 files changed, 249 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/openclaw/dashboard/util/NotificationHelper.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5680519..a0194c4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + /* Permission result handled by system */ } + override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() super.onCreate(savedInstanceState) enableEdgeToEdge() + // Request notification permission on Android 13+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + setContent { - val mainViewModel: MainViewModel = viewModel() - val themeMode by mainViewModel.themeMode.collectAsState() - val useDynamicColor by mainViewModel.useDynamicColor.collectAsState() + val vm: MainViewModel = viewModel() + mainViewModel = vm + val themeMode by vm.themeMode.collectAsState() + val useDynamicColor by vm.useDynamicColor.collectAsState() val isDarkTheme = when (themeMode) { ThemeMode.SYSTEM -> isSystemInDarkTheme() @@ -35,9 +56,18 @@ class MainActivity : AppCompatActivity() { darkTheme = isDarkTheme, dynamicColor = useDynamicColor ) { - OpenClawDashboardApp(mainViewModel = mainViewModel) + OpenClawDashboardApp(mainViewModel = vm) } } } + + override fun onStart() { + super.onStart() + mainViewModel?.setAppInForeground(true) + } + + override fun onStop() { + super.onStop() + mainViewModel?.setAppInForeground(false) + } } - diff --git a/app/src/main/java/com/openclaw/dashboard/OpenClawApp.kt b/app/src/main/java/com/openclaw/dashboard/OpenClawApp.kt index 8b369e3..8c7727e 100644 --- a/app/src/main/java/com/openclaw/dashboard/OpenClawApp.kt +++ b/app/src/main/java/com/openclaw/dashboard/OpenClawApp.kt @@ -1,6 +1,7 @@ package com.openclaw.dashboard import android.app.Application +import com.openclaw.dashboard.util.NotificationHelper class OpenClawApp : Application() { @@ -12,5 +13,6 @@ class OpenClawApp : Application() { override fun onCreate() { super.onCreate() instance = this + NotificationHelper.createNotificationChannels(this) } } diff --git a/app/src/main/java/com/openclaw/dashboard/data/repository/SettingsRepository.kt b/app/src/main/java/com/openclaw/dashboard/data/repository/SettingsRepository.kt index 1d9e841..94d97f9 100644 --- a/app/src/main/java/com/openclaw/dashboard/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/openclaw/dashboard/data/repository/SettingsRepository.kt @@ -32,6 +32,7 @@ class SettingsRepository(private val context: Context) { private val KEY_USE_DYNAMIC_COLOR = booleanPreferencesKey("use_dynamic_color") private val KEY_THEME_MODE = stringPreferencesKey("theme_mode") private val KEY_LAST_SESSION_KEY = stringPreferencesKey("last_session_key") + private val KEY_NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled") } /** @@ -120,6 +121,22 @@ class SettingsRepository(private val context: Context) { } } + /** + * Get notifications enabled preference + */ + val notificationsEnabled: Flow = context.dataStore.data.map { preferences -> + preferences[KEY_NOTIFICATIONS_ENABLED] ?: true + } + + /** + * Update notifications enabled preference + */ + suspend fun setNotificationsEnabled(enabled: Boolean) { + context.dataStore.edit { preferences -> + preferences[KEY_NOTIFICATIONS_ENABLED] = enabled + } + } + /** * Get last selected session key */ diff --git a/app/src/main/java/com/openclaw/dashboard/presentation/MainViewModel.kt b/app/src/main/java/com/openclaw/dashboard/presentation/MainViewModel.kt index ee2b614..85c71a1 100644 --- a/app/src/main/java/com/openclaw/dashboard/presentation/MainViewModel.kt +++ b/app/src/main/java/com/openclaw/dashboard/presentation/MainViewModel.kt @@ -14,6 +14,7 @@ import com.openclaw.dashboard.data.remote.GatewayClient import com.openclaw.dashboard.data.remote.GatewayEvent import com.openclaw.dashboard.data.repository.SettingsRepository import com.openclaw.dashboard.data.repository.ThemeMode +import com.openclaw.dashboard.util.NotificationHelper import com.openclaw.dashboard.R import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -53,6 +54,24 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { true ) + // Notification settings + val notificationsEnabled = settingsRepository.notificationsEnabled.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + true + ) + + // Track if the app is in foreground + private var _isAppInForeground = true + + fun setAppInForeground(inForeground: Boolean) { + _isAppInForeground = inForeground + if (inForeground) { + // Cancel notification when user returns to the app + NotificationHelper.cancelChatNotification(getApplication()) + } + } + fun setThemeMode(mode: ThemeMode) { viewModelScope.launch { settingsRepository.setThemeMode(mode) @@ -65,6 +84,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } + fun setNotificationsEnabled(enabled: Boolean) { + viewModelScope.launch { + settingsRepository.setNotificationsEnabled(enabled) + } + } + // Sessions private val _sessions = MutableStateFlow>(emptyList()) val sessions: StateFlow> = _sessions.asStateFlow() @@ -160,6 +185,24 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } } + + // Send notification for AI replies when app is in background + if (chatEvent.state == "final" && + chatEvent.message?.role?.lowercase() != "user" && + !_isAppInForeground && + notificationsEnabled.value) { + val content = chatEvent.delta ?: chatEvent.message?.content?.toString() ?: "" + if (content.isNotBlank()) { + val sessionTitle = _sessions.value + .find { it.key == chatEvent.sessionKey } + ?.let { it.derivedTitle ?: it.label ?: it.key } + NotificationHelper.showChatReplyNotification( + getApplication(), + sessionTitle, + content.replace("\\n", "\n").take(200) + ) + } + } } is GatewayEvent.Agent -> { val agentEvent = event.event diff --git a/app/src/main/java/com/openclaw/dashboard/presentation/components/SettingsDialog.kt b/app/src/main/java/com/openclaw/dashboard/presentation/components/SettingsDialog.kt index 6395f8b..e344d8f 100644 --- a/app/src/main/java/com/openclaw/dashboard/presentation/components/SettingsDialog.kt +++ b/app/src/main/java/com/openclaw/dashboard/presentation/components/SettingsDialog.kt @@ -18,7 +18,7 @@ import com.openclaw.dashboard.R import com.openclaw.dashboard.data.repository.ThemeMode /** - * Settings popup dialog for theme and language configuration + * Settings popup dialog for theme, notification, and language configuration */ @Composable fun SettingsDialog( @@ -27,7 +27,9 @@ fun SettingsDialog( currentThemeMode: ThemeMode, onThemeModeChange: (ThemeMode) -> Unit, useDynamicColor: Boolean, - onDynamicColorChange: (Boolean) -> Unit + onDynamicColorChange: (Boolean) -> Unit, + notificationsEnabled: Boolean, + onNotificationsEnabledChange: (Boolean) -> Unit ) { if (!isOpen) return @@ -110,6 +112,33 @@ fun SettingsDialog( HorizontalDivider() + // Notifications Section + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_notifications), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = stringResource(R.string.settings_notifications_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = notificationsEnabled, + onCheckedChange = onNotificationsEnabledChange + ) + } + + HorizontalDivider() + // Language Section Text( text = stringResource(R.string.language), diff --git a/app/src/main/java/com/openclaw/dashboard/presentation/screen/chat/ChatScreen.kt b/app/src/main/java/com/openclaw/dashboard/presentation/screen/chat/ChatScreen.kt index 2b433ea..fdb7b1f 100644 --- a/app/src/main/java/com/openclaw/dashboard/presentation/screen/chat/ChatScreen.kt +++ b/app/src/main/java/com/openclaw/dashboard/presentation/screen/chat/ChatScreen.kt @@ -97,10 +97,23 @@ fun ChatScreen( } } - // Auto-scroll to bottom when new message arrives - LaunchedEffect(messages.size) { - if (messages.isNotEmpty()) { - listState.animateScrollToItem(messages.size - 1) + // Filter out messages that shouldn't be shown to users + val filteredMessages = remember(messages) { + messages.filter { msg -> + val role = msg.message?.role?.lowercase() ?: "" + if (role in listOf("toolresult", "tool", "system", "toolcall")) { + return@filter false + } + val content = extractMessageContent(msg) + content.isNotBlank() + } + } + + // Auto-scroll to bottom when new message arrives or typing indicator appears + LaunchedEffect(filteredMessages.size, isAiTyping) { + if (filteredMessages.isNotEmpty()) { + val lastIndex = filteredMessages.size - 1 + if (isAiTyping) 1 else 0 + listState.animateScrollToItem(lastIndex) } } @@ -171,19 +184,6 @@ fun ChatScreen( ) } else -> { - // Filter out messages that shouldn't be shown to users - val filteredMessages = messages.filter { msg -> - val role = msg.message?.role?.lowercase() ?: "" - // Hide tool results, tool calls, and system messages - if (role in listOf("toolresult", "tool", "system", "toolcall")) { - return@filter false - } - - // Also filter out messages with empty content - val content = extractMessageContent(msg) - content.isNotBlank() - } - LazyColumn( state = listState, contentPadding = PaddingValues(16.dp), diff --git a/app/src/main/java/com/openclaw/dashboard/presentation/screen/overview/OverviewScreen.kt b/app/src/main/java/com/openclaw/dashboard/presentation/screen/overview/OverviewScreen.kt index 655fadf..6fc67fa 100644 --- a/app/src/main/java/com/openclaw/dashboard/presentation/screen/overview/OverviewScreen.kt +++ b/app/src/main/java/com/openclaw/dashboard/presentation/screen/overview/OverviewScreen.kt @@ -34,6 +34,7 @@ fun OverviewScreen( // Theme settings state val themeMode by viewModel.themeMode.collectAsState() val useDynamicColor by viewModel.useDynamicColor.collectAsState() + val notificationsEnabled by viewModel.notificationsEnabled.collectAsState() var showSettingsDialog by remember { mutableStateOf(false) } // Settings Dialog @@ -43,7 +44,9 @@ fun OverviewScreen( currentThemeMode = themeMode, onThemeModeChange = { viewModel.setThemeMode(it) }, useDynamicColor = useDynamicColor, - onDynamicColorChange = { viewModel.setUseDynamicColor(it) } + onDynamicColorChange = { viewModel.setUseDynamicColor(it) }, + notificationsEnabled = notificationsEnabled, + onNotificationsEnabledChange = { viewModel.setNotificationsEnabled(it) } ) Scaffold( diff --git a/app/src/main/java/com/openclaw/dashboard/util/NotificationHelper.kt b/app/src/main/java/com/openclaw/dashboard/util/NotificationHelper.kt new file mode 100644 index 0000000..893a2c4 --- /dev/null +++ b/app/src/main/java/com/openclaw/dashboard/util/NotificationHelper.kt @@ -0,0 +1,85 @@ +package com.openclaw.dashboard.util + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import com.openclaw.dashboard.MainActivity +import com.openclaw.dashboard.R + +/** + * Helper class for managing notifications + */ +object NotificationHelper { + + const val CHANNEL_ID_CHAT = "chat_replies" + private const val NOTIFICATION_ID_CHAT = 1001 + + /** + * Create notification channels (must be called on app startup) + */ + fun createNotificationChannels(context: Context) { + val channel = NotificationChannel( + CHANNEL_ID_CHAT, + context.getString(R.string.notification_channel_chat), + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = context.getString(R.string.notification_channel_chat_desc) + } + + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel(channel) + } + + /** + * Show a notification for AI reply + */ + fun showChatReplyNotification( + context: Context, + sessionTitle: String?, + messagePreview: String + ) { + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val title = sessionTitle ?: context.getString(R.string.notification_chat_default_title) + + // Truncate long messages + val preview = if (messagePreview.length > 200) { + messagePreview.take(200) + "…" + } else { + messagePreview + } + + val notification = NotificationCompat.Builder(context, CHANNEL_ID_CHAT) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(title) + .setContentText(preview) + .setStyle(NotificationCompat.BigTextStyle().bigText(preview)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build() + + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.notify(NOTIFICATION_ID_CHAT, notification) + } + + /** + * Cancel chat notification (e.g., when user opens the chat) + */ + fun cancelChatNotification(context: Context) { + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.cancel(NOTIFICATION_ID_CHAT) + } +} diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index d7c2ee6..4179300 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -64,6 +64,11 @@ 目前沒有可用的 Session 選擇一個 Session + + AI 回覆 + AI 聊天回覆通知 + AI 回覆 + 配置編輯器 儲存中… @@ -120,6 +125,8 @@ 深色模式 動態色彩 使用壁紙色彩 + 通知 + AI 回覆時發送通知 完成 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f82b760..102c8c8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -64,6 +64,11 @@ No sessions available Select a session + + AI Replies + Notifications for AI chat replies + AI Reply + Config Editor Saving… @@ -120,6 +125,8 @@ Dark Mode Dynamic Color Use wallpaper colors + Notifications + Notify when AI replies Done