From b87af0cd5be1c1c90a1cdff5de7ed85011039426 Mon Sep 17 00:00:00 2001
From: David Df
Date: Tue, 10 Mar 2026 16:26:56 +0100
Subject: [PATCH 1/3] Update version badge in README.md to 0.4.4
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index e51738f..4934adc 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@
-
+
@@ -126,4 +126,4 @@ Distributed under the Apache 2.0 License. See `LICENSE` for more information.
**D4vidDf**
* Website: [d4viddf.com](https://d4viddf.com)
-* GitHub: [@D4vidDf](https://github.com/D4vidDf)
\ No newline at end of file
+* GitHub: [@D4vidDf](https://github.com/D4vidDf)
From 80c63141060837b11a1a51fddfdee3d22c6885c7 Mon Sep 17 00:00:00 2001
From: David Df
Date: Tue, 10 Mar 2026 16:27:14 +0100
Subject: [PATCH 2/3] Update version badge in README.md
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 4934adc..be0f006 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@
-
+
From a2ea3d08b7a0272a4f82b9cdd178b2e79fcca887 Mon Sep 17 00:00:00 2001
From: David Df
Date: Thu, 12 Mar 2026 19:45:26 +0100
Subject: [PATCH 3/3] Feat/live updates (#129)
* Feat/color picker (#127)
* feat(theme): implement HSL color picker and enhanced lightness slider
- **New `CustomColorBottomSheet`**: Introduced a dedicated HSL (Hue, Saturation, Lightness) color picker with gradient sliders for precise theme color customization.
- **Enhanced `ColorsDetailContent`**:
- Overhauled the preset tab with an animated lightness slider that appears when a preset is selected.
- Improved the preset selection logic to maintain the "base" hue even when adjusting lightness to extreme white or black.
- Redesigned the "Custom" colors section to use the new HSL bottom sheet instead of a simple hex text field.
- Switched from simple circular bubbles to a more refined UI with selection rings and rounded-square transitions for active colors.
- **Improved Theme Creator State**:
- Implemented strict state clearing (nulling `currentEditingThemeId`) when exiting the creator to prevent state leakage between editing sessions.
- Updated save/apply dialog logic to ensure the UI resets correctly after persistence.
- **Shared Components**: Added `GradientSlider` and `ColorBubble` to `HyperOSColorPicker.kt` for reusable, stylized color selection components.
- **Localization**: Added missing string resources for color labels (Hue, Saturation, Lightness) in English and Spanish.
* #### feat(theme): implement advanced color modes and redesigned color picker
- **Color Modes**: Introduced a `ColorMode` system (`CUSTOM`, `APP_ICON`, `MATERIAL_YOU`) to replace the binary "Use App Colors" toggle. This allows themes to dynamically adapt to Material You system colors or extract colors from app icons globally and per-app.
- **Improved Color Picker**: Replaced standard text fields with a new `CustomColorBottomSheet` in the Call Style and Theme Creator screens, featuring a visual HSL-based selection and hex formatting.
- **UI & UX Refinement**:
- Redesigned `ColorsDetailContent` with a scrollable layout and new sections for dynamic color modes.
- Updated the Theme Creator preview to correctly reflect the active color mode.
- Standardized "My Theme" and "Share Theme" strings to use localized resources.
- **Data Persistence**: Updated `HyperTheme`, `GlobalConfig`, and `AppThemeOverride` models to support the new `ColorMode` enum while maintaining backward compatibility with legacy themes.
- **Theme Creator**: Refactored `ThemeViewModel` to handle more robust state initialization and migration when editing or creating new themes.
- **Tooling**: Added Crowdin configuration settings for the IDE.
* feat(widget): add favorites, recommended filtering, and update notice (#128)
- **Widget Picker**:
- Implemented a "Recommended" filter to highlight 1x4 and 2x4 layouts compatible with the Island size.
- Added a Favorite system for widget apps, allowing users to star apps for top-level sorting.
- Added a floating navigation bar to switch between Recommended and All widgets.
- Improved dimension display by converting DP to grid cell proportions (e.g., 4x2).
- **Saved Widgets**:
- Added a persistent "Update Notice" dialog to remind users to check Autostart/Battery permissions after an app update.
- Implemented a "Kill Active Widgets" action to instantly dismiss all running widget overlays.
- Redesigned the UI with a centered filter toolbar (Favorites vs. All) and a dedicated Add FAB.
- Added reactive listening for newly added widgets to ensure the list updates immediately.
- **Service & Data**:
- Enhanced `WidgetOverlayService` with specific `ACTION_KILL_WIDGET` and `ACTION_KILL_ALL_WIDGETS` handlers for immediate UI cleanup.
- Added `favoriteWidgetAppsFlow` to `AppPreferences` to persist starred widget providers.
- **Localization**:
- Added English and Spanish strings for filters, permission reminders, and widget management.
CLOSE #72
* feat(service): implement Native Android 16 Live Updates support
- Added `LiveUpdateTranslator` to transform standard notifications into native Android 16 promoted ongoing tasks using `android.requestPromotedOngoing` and `android.shortCriticalText`.
- Implemented a toggle in Global Settings to choose between custom Xiaomi Islands and native Android 16 Live Updates.
- Created a dedicated `hyper_bridge_live_update_channel` for native updates to manage notifications and visibility independently.
- Added `POST_PROMOTED_NOTIFICATIONS` permission to `AndroidManifest.xml` to support Android 16's promoted notification features.
- Enhanced `LiveUpdateTranslator` with a `generateCriticalShortText` helper to dynamically extract progress percentages or ETAs for the system status chip.
- Fixed Android 16 category filtering by forcing Live Updates into strictly valid categories (`PROGRESS` or `TRANSPORT`) to prevent OS blocking.
- Ensured original notification timestamps are preserved for accurate system-wide "time active" calculations.
- Updated `NotificationReaderService` to support hot-swapping between custom injection and native updates via shared preferences.
* feat(service): enhance Live Update processing and Android 16 compatibility
- **NotificationReaderService**: Implemented a more robust content hashing algorithm that tracks Title, Text, Progress (max, current, indeterminate), and Button Action states (e.g., Play to Pause transitions) to ensure accurate UI updates.
- **LiveUpdateTranslator**:
- Added support for large icons and picture extras in live updates.
- Improved media notification handling by suppressing progress bars that often persist in the background.
- Optimized category mapping (Progress vs. Transport) to comply with Android 16 ongoing promotion requirements.
- Updated `shortCriticalText` generation logic to prioritize media titles, percentages, or time ETAs for the Dynamic Island chip.
- Switched to native Android 16 APIs for promoted ongoing requests and short critical text injection.
* feat(settings): refactor Global Settings UI and update version
- Updated `versionName` to `0.5.0-alpha01`.
- Redesigned `GlobalSettingsScreen` to use consistent card-based layouts for all settings items.
- Replaced the "Native Live Updates" `ListItem` with a new standardized `SettingsSwitchItem` component.
- Improved UI spacing and styling across the settings screen, including unified icon background treatments.
- Simplified state management and click behavior for toggleable settings.
---
.idea/CrowdinSettingsPlugin.xml | 9 +
app/build.gradle.kts | 2 +-
app/src/main/AndroidManifest.xml | 1 +
.../hyperbridge/data/AppPreferences.kt | 13 +
.../hyperbridge/models/theme/ThemeModels.kt | 35 ++-
.../service/NotificationReaderService.kt | 91 ++++--
.../service/WidgetOverlayService.kt | 21 ++
.../translators/LiveUpdateTranslator.kt | 113 ++++++++
.../ui/components/CustomColorBottomSheet.kt | 180 ++++++++++++
.../ui/components/HyperOSColorPicker.kt | 102 +++++++
.../screens/design/SavedAppWidgetsScreen.kt | 153 ++++++++--
.../ui/screens/design/WidgetPickerScreen.kt | 179 +++++++++---
.../screens/settings/GlobalSettingsScreen.kt | 80 +++++-
.../ui/screens/theme/AppThemeEditorScreen.kt | 11 +-
.../ui/screens/theme/ThemeCreatorScreen.kt | 65 +++--
.../ui/screens/theme/ThemeViewModel.kt | 25 +-
.../theme/content/CallStyleSheetContent.kt | 52 +++-
.../theme/content/ColorsDetailContent.kt | 272 +++++++++++++-----
app/src/main/res/values-es-rES/strings.xml | 17 ++
app/src/main/res/values/strings.xml | 21 ++
20 files changed, 1241 insertions(+), 201 deletions(-)
create mode 100644 .idea/CrowdinSettingsPlugin.xml
create mode 100644 app/src/main/java/com/d4viddf/hyperbridge/service/translators/LiveUpdateTranslator.kt
create mode 100644 app/src/main/java/com/d4viddf/hyperbridge/ui/components/CustomColorBottomSheet.kt
create mode 100644 app/src/main/java/com/d4viddf/hyperbridge/ui/components/HyperOSColorPicker.kt
diff --git a/.idea/CrowdinSettingsPlugin.xml b/.idea/CrowdinSettingsPlugin.xml
new file mode 100644
index 0000000..018093b
--- /dev/null
+++ b/.idea/CrowdinSettingsPlugin.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index ef65592..03438ff 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -17,7 +17,7 @@ android {
minSdk = 35
targetSdk = 36
versionCode = 16
- versionName = "0.4.2"
+ versionName = "0.5.0-alpha01"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 802503c..2d4f0b6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
+
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/data/AppPreferences.kt b/app/src/main/java/com/d4viddf/hyperbridge/data/AppPreferences.kt
index 50f8e3b..abbdf7f 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/data/AppPreferences.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/data/AppPreferences.kt
@@ -315,4 +315,17 @@ class AppPreferences(context: Context) {
dao.delete("widget_${id}_auto_update")
dao.delete("widget_${id}_update_interval")
}
+
+ // ========================================================================
+ // FAVORITE WIDGET APPS
+ // ========================================================================
+
+ val favoriteWidgetAppsFlow: Flow> = dao.getSettingFlow("favorite_widget_apps").map { it.deserializeSet() }
+
+ suspend fun toggleFavoriteWidgetApp(packageName: String, isFavorite: Boolean) {
+ val currentStr = dao.getSetting("favorite_widget_apps")
+ val currentSet = currentStr.deserializeSet()
+ val newSet = if (isFavorite) currentSet + packageName else currentSet - packageName
+ save("favorite_widget_apps", newSet.serialize())
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/models/theme/ThemeModels.kt b/app/src/main/java/com/d4viddf/hyperbridge/models/theme/ThemeModels.kt
index a4618f7..32e5c9f 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/models/theme/ThemeModels.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/models/theme/ThemeModels.kt
@@ -33,12 +33,21 @@ data class GlobalConfig(
@SerialName("highlight_color") val highlightColor: String? = null,
@SerialName("background_color") val backgroundColor: String? = null,
@SerialName("text_color") val textColor: String? = "#FFFFFF",
+
+ @Deprecated("Use colorMode instead. Kept for backward compatibility with v0.4.x themes.")
@SerialName("use_app_colors") val useAppColors: Boolean = false,
+ // [NEW] Safe migration enum. Defaults to null for old JSONs.
+ @SerialName("color_mode") val colorMode: ColorMode? = null,
+
// SHAPE CONFIGURATION
@SerialName("icon_shape_id") val iconShapeId: String = "circle", // "circle", "square", "squircle", "cookie", "flower"
@SerialName("icon_padding_percent") val iconPaddingPercent: Int = 15
-)
+) {
+ // Safe getter: reads new enum, falls back to legacy boolean logic if null
+ val activeColorMode: ColorMode
+ get() = colorMode ?: if (useAppColors) ColorMode.APP_ICON else ColorMode.CUSTOM
+}
@Serializable
data class CallModule(
@@ -53,17 +62,27 @@ data class CallModule(
@Serializable
data class AppThemeOverride(
@SerialName("highlight_color") val highlightColor: String? = null,
- // [NEW] Added Shape and Padding overrides
+
+ @Deprecated("Use colorMode instead. Kept for backward compatibility with v0.4.x themes.")
@SerialName("use_app_colors") val useAppColors: Boolean? = null,
+
+ // [NEW] Safe migration enum for per-app overrides
+ @SerialName("color_mode") val colorMode: ColorMode? = null,
+
+ // [NEW] Added Shape and Padding overrides
@SerialName("icon_shape_id") val iconShapeId: String? = null,
@SerialName("icon_padding_percent") val iconPaddingPercent: Int? = null,
+
// [NEW] Added Call Config override
@SerialName("call_config") val callConfig: CallModule? = null,
val actions: Map? = null,
val progress: ProgressModule? = null,
val navigation: NavigationModule? = null
-)
-
+) {
+ // Safe getter: reads new enum, falls back to legacy boolean if present, else returns null (uses global)
+ val activeColorMode: ColorMode?
+ get() = colorMode ?: useAppColors?.let { if (it) ColorMode.APP_ICON else ColorMode.CUSTOM }
+}
@Serializable
data class ActionConfig(
val mode: ActionButtonMode = ActionButtonMode.ICON,
@@ -113,4 +132,10 @@ data class ThemeResource(val type: ResourceType, val value: String)
enum class ResourceType { PRESET_DRAWABLE, LOCAL_FILE, URI_CONTENT }
-enum class ActionButtonMode { ICON, TEXT, BOTH }
\ No newline at end of file
+enum class ActionButtonMode { ICON, TEXT, BOTH }
+
+enum class ColorMode {
+ CUSTOM, // Uses selectedColorHex
+ APP_ICON, // Extracts color from the notification's app icon
+ MATERIAL_YOU // Extracts color from the system wallpaper (Monet)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/service/NotificationReaderService.kt b/app/src/main/java/com/d4viddf/hyperbridge/service/NotificationReaderService.kt
index b35143b..0be670b 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/service/NotificationReaderService.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/service/NotificationReaderService.kt
@@ -5,6 +5,7 @@ import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
+import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.service.notification.NotificationListenerService
@@ -26,6 +27,7 @@ import com.d4viddf.hyperbridge.models.NotificationType
import com.d4viddf.hyperbridge.models.WidgetConfig
import com.d4viddf.hyperbridge.models.WidgetRenderMode
import com.d4viddf.hyperbridge.service.translators.CallTranslator
+import com.d4viddf.hyperbridge.service.translators.LiveUpdateTranslator
import com.d4viddf.hyperbridge.service.translators.MediaTranslator
import com.d4viddf.hyperbridge.service.translators.NavTranslator
import com.d4viddf.hyperbridge.service.translators.ProgressTranslator
@@ -54,7 +56,7 @@ class NotificationReaderService : NotificationListenerService() {
// --- CHANNELS ---
private val NOTIFICATION_CHANNEL_ID = "hyper_bridge_notification_channel"
private val WIDGET_CHANNEL_ID = "hyper_bridge_widget_channel"
-
+ private val LIVE_UPDATE_CHANNEL_ID = "hyper_bridge_live_update_channel" // [NEW]
private val serviceScope = CoroutineScope(Dispatchers.Default + Job())
// --- STATE & CONFIG ---
@@ -89,6 +91,7 @@ class NotificationReaderService : NotificationListenerService() {
private lateinit var standardTranslator: StandardTranslator
private lateinit var mediaTranslator: MediaTranslator
private lateinit var widgetTranslator: WidgetTranslator
+ private lateinit var liveUpdateTranslator: LiveUpdateTranslator // [NEW]
@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
override fun onCreate() {
@@ -100,12 +103,13 @@ class NotificationReaderService : NotificationListenerService() {
themeRepository = ThemeRepository(this)
rulesEngine = RulesEngine()
- // [UPDATED] Pass ThemeRepository to Translators
+ // Pass ThemeRepository to Translators
callTranslator = CallTranslator(this, themeRepository)
navTranslator = NavTranslator(this, themeRepository)
timerTranslator = TimerTranslator(this, themeRepository)
progressTranslator = ProgressTranslator(this, themeRepository)
standardTranslator = StandardTranslator(this, themeRepository)
+ liveUpdateTranslator = LiveUpdateTranslator(this, themeRepository) // [NEW]
// Media and Widget translators don't necessarily need the repo yet
mediaTranslator = MediaTranslator(this)
@@ -118,7 +122,7 @@ class NotificationReaderService : NotificationListenerService() {
serviceScope.launch { preferences.appPriorityListFlow.collectLatest { appPriorityList = it } }
serviceScope.launch { preferences.globalBlockedTermsFlow.collectLatest { globalBlockedTerms = it } }
- // [FIX] Listen for Theme Changes and update the Repository
+ // Listen for Theme Changes and update the Repository
serviceScope.launch {
preferences.activeThemeIdFlow.collectLatest { themeId ->
Log.d(TAG, "Service detected theme change: $themeId")
@@ -126,7 +130,7 @@ class NotificationReaderService : NotificationListenerService() {
themeRepository.activateTheme(themeId)
} else {
// Reset to defaults if theme is removed/disabled
- themeRepository.activateTheme("") // Handle empty/null in repo logic implies default
+ themeRepository.activateTheme("")
}
}
}
@@ -160,7 +164,7 @@ class NotificationReaderService : NotificationListenerService() {
}
}
} else if (intent?.action == ACTION_RELOAD_THEME) {
- // [NEW] Force reload current theme from disk
+ // Force reload current theme from disk
serviceScope.launch {
val themeId = preferences.activeThemeIdFlow.first()
if (themeId != null) {
@@ -348,13 +352,9 @@ class NotificationReaderService : NotificationListenerService() {
}
// [UPDATED] 4. Theme & Rules Interception
- // A. Get Active Theme
val activeTheme = themeRepository.activeTheme.value
-
- // B. Check Interceptor Rules
val ruleMatch = rulesEngine.match(sbn, effectiveTitle, effectiveText, activeTheme)
- // C. Determine Type: If rule matched, FORCE that type. Else, use detection.
val type = if (ruleMatch?.targetLayout != null) {
try {
NotificationType.valueOf(ruleMatch.targetLayout)
@@ -366,7 +366,6 @@ class NotificationReaderService : NotificationListenerService() {
detectNotificationType(sbn)
}
- // D. Check if app is enabled for this type (standard config check)
val config = preferences.getAppConfig(sbn.packageName).first()
if (!config.contains(type.name)) return
@@ -379,14 +378,66 @@ class NotificationReaderService : NotificationListenerService() {
val appIslandConfig = preferences.getAppIslandConfig(sbn.packageName).first()
val globalConfig = preferences.globalConfigFlow.first()
-
- // Merge configs (Island behavior config, NOT theme style)
val finalConfig = appIslandConfig.mergeWith(globalConfig)
val bridgeId = sbn.key.hashCode()
val picKey = "pic_${bridgeId}"
- // [CRITICAL UPDATE] Pass 'activeTheme' to all Translators
+ // ===================================================================
+ // [NEW LOGIC] NATIVE LIVE UPDATES FALLBACK
+ // ===================================================================
+ val useLiveUpdates = getSharedPreferences("hyperbridge_settings", Context.MODE_PRIVATE)
+ .getBoolean("use_native_live_updates", false)
+
+ if (useLiveUpdates) {
+ Log.i(TAG, " POSTING Native Live Update -> ID: $bridgeId, Type: $type")
+
+ // [FIXED] Pass the dedicated Live Update Channel ID
+ val builder = liveUpdateTranslator.translateToLiveUpdate(sbn, finalConfig, LIVE_UPDATE_CHANNEL_ID)
+
+ // Track original notification key for dismissal synchronization
+ builder.extras.putString(EXTRA_ORIGINAL_KEY, sbn.key)
+
+ val notification = builder.build()
+
+ // [FIXED] Dynamic Hash that accounts for Text, Progress, AND Button Actions (Play/Pause)
+ val actualProgress = extras.getInt(Notification.EXTRA_PROGRESS, 0)
+ val actualMax = extras.getInt(Notification.EXTRA_PROGRESS_MAX, 0)
+ val isIndeterminate = extras.getBoolean(Notification.EXTRA_PROGRESS_INDETERMINATE, false)
+
+ // Track the button titles so we know when Play becomes Pause
+ val actionState = sbn.notification.actions?.joinToString { it.title?.toString() ?: "" } ?: ""
+
+ val newContentHash = effectiveTitle.hashCode() * 31 +
+ effectiveText.hashCode() +
+ actualProgress +
+ actualMax +
+ isIndeterminate.hashCode() +
+ actionState.hashCode()
+
+ if (isUpdate && previous != null && previous.lastContentHash == newContentHash) {
+ Log.d(TAG, " ABORTING: Content hash duplicate (Live Update).")
+ return
+ }
+
+ NotificationManagerCompat.from(this).notify(bridgeId, notification)
+
+ activeTranslations[sbn.key] = bridgeId
+ reverseTranslations[bridgeId] = sbn.key
+
+ activeIslands[key] = ActiveIsland(
+ id = bridgeId, type = type, postTime = System.currentTimeMillis(),
+ packageName = sbn.packageName,
+ title = effectiveTitle,
+ text = effectiveText,
+ subText = "LiveUpdate",
+ lastContentHash = newContentHash
+ )
+ return
+ }
+ // ===================================================================
+
+ // [ORIGINAL LOGIC] Xiaomi Custom Island Injection
val data: HyperIslandData = when (type) {
NotificationType.CALL -> callTranslator.translate(sbn, picKey, finalConfig, activeTheme)
NotificationType.NAVIGATION -> {
@@ -395,14 +446,13 @@ class NotificationReaderService : NotificationListenerService() {
}
NotificationType.TIMER -> timerTranslator.translate(sbn, picKey, finalConfig, activeTheme)
NotificationType.PROGRESS -> progressTranslator.translate(sbn, effectiveTitle, picKey, finalConfig, activeTheme)
- NotificationType.MEDIA -> mediaTranslator.translate(sbn, picKey, finalConfig) // Media usually keeps its own art
+ NotificationType.MEDIA -> mediaTranslator.translate(sbn, picKey, finalConfig)
else -> standardTranslator.translate(sbn, effectiveTitle, effectiveText, picKey, finalConfig, activeTheme)
}
val newContentHash = data.jsonParam.hashCode()
- val previousIsland = activeIslands[key]
- if (isUpdate && previousIsland != null && previousIsland.lastContentHash == newContentHash) {
+ if (isUpdate && previous != null && previous.lastContentHash == newContentHash) {
Log.d(TAG, " ABORTING: Content hash duplicate.")
return
}
@@ -534,10 +584,19 @@ class NotificationReaderService : NotificationListenerService() {
setSound(null, null); enableVibration(false); setShowBadge(false)
}
manager.createNotificationChannel(notifChannel)
+
val widgetChannel = NotificationChannel(WIDGET_CHANNEL_ID, "Widgets Overlay", NotificationManager.IMPORTANCE_LOW).apply {
setSound(null, null); enableVibration(false); setShowBadge(false)
}
manager.createNotificationChannel(widgetChannel)
+
+ // [NEW] Channel specifically for Native Android Live Updates
+ val liveUpdateChannel = NotificationChannel(LIVE_UPDATE_CHANNEL_ID, getString(R.string.channel_live_updates), NotificationManager.IMPORTANCE_DEFAULT).apply {
+ setSound(null, null)
+ enableVibration(false)
+ setShowBadge(false)
+ }
+ manager.createNotificationChannel(liveUpdateChannel)
}
private fun shouldProcessWidgetUpdate(widgetId: Int, config: WidgetConfig): Boolean {
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/service/WidgetOverlayService.kt b/app/src/main/java/com/d4viddf/hyperbridge/service/WidgetOverlayService.kt
index 8420b20..c4e4d63 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/service/WidgetOverlayService.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/service/WidgetOverlayService.kt
@@ -34,6 +34,8 @@ class WidgetOverlayService : Service() {
const val WIDGET_CHANNEL_ID = "hyper_bridge_widget_channel"
const val ACTION_TEST_WIDGET = "ACTION_TEST_WIDGET"
const val ACTION_START_MONITORING = "ACTION_START_MONITORING"
+ const val ACTION_KILL_ALL_WIDGETS = "ACTION_KILL_ALL_WIDGETS"
+ const val ACTION_KILL_WIDGET = "ACTION_KILL_WIDGET"
}
private val serviceScope = CoroutineScope(Dispatchers.IO + Job())
@@ -73,12 +75,31 @@ class WidgetOverlayService : Service() {
}
}
}
+ ACTION_KILL_ALL_WIDGETS -> {
+ // Instantly kill all active widgets
+ serviceScope.launch(Dispatchers.IO) {
+ val savedIds = preferences.savedWidgetIdsFlow.first()
+ savedIds.forEach { id ->
+ notificationManager.cancel(9000 + id)
+ widgetUpdateDebouncer.remove(id)
+ }
+ }
+ }
+ ACTION_KILL_WIDGET -> {
+ // NEW: Instantly kill a specific widget
+ val widgetId = intent.getIntExtra("WIDGET_ID", -1)
+ if (widgetId != -1) {
+ notificationManager.cancel(9000 + widgetId)
+ widgetUpdateDebouncer.remove(widgetId) // Clean up memory
+ }
+ }
ACTION_START_MONITORING -> {
// Ensure service is sticky
}
}
return START_STICKY
}
+
@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
private fun startMonitoringWidgets() {
serviceScope.launch {
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/service/translators/LiveUpdateTranslator.kt b/app/src/main/java/com/d4viddf/hyperbridge/service/translators/LiveUpdateTranslator.kt
new file mode 100644
index 0000000..e83e514
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/service/translators/LiveUpdateTranslator.kt
@@ -0,0 +1,113 @@
+package com.d4viddf.hyperbridge.service.translators
+
+import android.app.Notification
+import android.content.Context
+import android.graphics.Bitmap
+import android.service.notification.StatusBarNotification
+import androidx.core.app.NotificationCompat
+import androidx.core.graphics.drawable.IconCompat
+import com.d4viddf.hyperbridge.R
+import com.d4viddf.hyperbridge.data.theme.ThemeRepository
+import com.d4viddf.hyperbridge.models.IslandConfig
+
+class LiveUpdateTranslator(
+ context: Context,
+ repo: ThemeRepository
+) : BaseTranslator(context, repo) {
+
+ fun translateToLiveUpdate(
+ sbn: StatusBarNotification,
+ config: IslandConfig,
+ channelId: String
+ ): NotificationCompat.Builder {
+ val original = sbn.notification
+ val extras = original.extras
+
+ val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() ?: ""
+ val text = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString() ?: ""
+
+ var progressMax = extras.getInt(Notification.EXTRA_PROGRESS_MAX, 0)
+ var progress = extras.getInt(Notification.EXTRA_PROGRESS, 0)
+ var indeterminate = extras.getBoolean(Notification.EXTRA_PROGRESS_INDETERMINATE, false)
+
+ // Identify if it's media so we can use the title for the Island chip
+ val isMedia = extras.containsKey(Notification.EXTRA_MEDIA_SESSION) ||
+ extras.getString(Notification.EXTRA_TEMPLATE)?.contains("MediaStyle") == true
+
+ // [FIXED] Media apps often leave random progress flags in the background.
+ // Force them off so it doesn't show an indeterminate loading bar!
+ if (isMedia) {
+ progressMax = 0
+ progress = 0
+ indeterminate = false
+ }
+ // Force category for Android 16 promotion limits
+ val validCategory = if (original.category.isNullOrEmpty() || original.category == NotificationCompat.CATEGORY_SERVICE) {
+ if (progressMax > 0 || indeterminate) NotificationCompat.CATEGORY_PROGRESS else NotificationCompat.CATEGORY_TRANSPORT
+ } else {
+ original.category
+ }
+
+ val builder = NotificationCompat.Builder(context, channelId)
+ .setSmallIcon(original.smallIcon?.let { IconCompat.createFromIcon(context, it) } ?: IconCompat.createWithResource(context, R.drawable.ic_launcher_foreground))
+ .setContentTitle(title)
+ .setContentText(text)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setCategory(validCategory)
+ .setContentIntent(original.contentIntent)
+
+ // --- ADD PICTURE / LARGE ICON ---
+ if (android.os.Build.VERSION.SDK_INT >= 23 && original.getLargeIcon() != null) {
+ builder.setLargeIcon(original.getLargeIcon())
+ } else {
+ @Suppress("DEPRECATION")
+ val picture = extras.getParcelable(Notification.EXTRA_PICTURE)
+ if (picture != null) builder.setLargeIcon(picture)
+ }
+
+ // Carry over the original timestamp (Standard behavior)
+ if (original.`when` > 0) {
+ builder.setWhen(original.`when`)
+ builder.setShowWhen(true)
+ }
+
+ // --- COPY BUTTONS ---
+ original.actions?.forEach { action ->
+ val iconCompat = if ( action.getIcon() != null) {
+ IconCompat.createFromIcon(context, action.getIcon()!!)
+ } else {
+ IconCompat.createWithResource(context, action.icon)
+ }
+ builder.addAction(NotificationCompat.Action.Builder(iconCompat, action.title, action.actionIntent).build())
+ }
+
+ // --- APPLY STYLES ---
+ if (progressMax > 0 || indeterminate) {
+ builder.setProgress(progressMax, progress, indeterminate)
+ } else {
+ // Standard notification: Allow expanding text
+ builder.setStyle(NotificationCompat.BigTextStyle().bigText(text).setBigContentTitle(title))
+ }
+
+ // --- ANDROID 16 LIVE UPDATE INJECTION ---
+ val shortAlertText = generateCriticalShortText(title, text, progress, progressMax, isMedia)
+ builder.setRequestPromotedOngoing(true)
+ builder.setShortCriticalText(shortAlertText)
+
+
+ return builder
+ }
+
+ private fun generateCriticalShortText(title: String, text: String, progress: Int, max: Int, isMedia: Boolean): String {
+ if (isMedia) return title.ifBlank { "Media" }
+
+ if (max > 0) return "${(progress * 100) / max}%"
+
+ val timeRegex = Regex("(\\d+\\s*(min|m))", RegexOption.IGNORE_CASE)
+ timeRegex.find(text)?.let { return it.groupValues[1] }
+ timeRegex.find(title)?.let { return it.groupValues[1] }
+
+ return title.ifBlank { text }.ifBlank { "Active" }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/components/CustomColorBottomSheet.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/CustomColorBottomSheet.kt
new file mode 100644
index 0000000..fbc8de6
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/CustomColorBottomSheet.kt
@@ -0,0 +1,180 @@
+package com.d4viddf.hyperbridge.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+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.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.core.graphics.ColorUtils
+import com.d4viddf.hyperbridge.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CustomColorBottomSheet(
+ initialColor: Color,
+ onDismiss: () -> Unit,
+ onColorAdded: (Color) -> Unit
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+
+ // Extract initial HSL
+ val hsl = FloatArray(3)
+ ColorUtils.colorToHSL(initialColor.toArgb(), hsl)
+
+ var hue by remember { mutableFloatStateOf(hsl[0]) } // 0..360
+ var saturation by remember { mutableFloatStateOf(hsl[1]) } // 0..1
+ var lightness by remember { mutableFloatStateOf(hsl[2]) } // 0..1
+
+ // Convert current HSL back to Compose Color for the local preview
+ val currentColor = remember(hue, saturation, lightness) {
+ Color(ColorUtils.HSLToColor(floatArrayOf(hue, saturation, lightness)))
+ }
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ ) {
+ // Header (Centered Title with Left-Aligned Close Button)
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 24.dp)
+ ) {
+ IconButton(
+ onClick = onDismiss,
+ modifier = Modifier.align(Alignment.CenterStart)
+ ) {
+ Icon(
+ Icons.Default.Close,
+ contentDescription = stringResource(R.string.close)
+ )
+ }
+
+ Text(
+ text = stringResource(R.string.colors_dialog_title),
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.align(Alignment.Center)
+ )
+ }
+
+ // 1. Hue Slider
+ Text(
+ text = stringResource(R.string.colors_label_hue),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.labelLarge
+ )
+ GradientSlider(
+ value = hue,
+ onValueChange = { hue = it }, // ONLY updates local state
+ valueRange = 0f..360f,
+ brush = Brush.horizontalGradient(
+ colors = listOf(
+ Color.Red, Color.Yellow, Color.Green,
+ Color.Cyan, Color.Blue, Color.Magenta, Color.Red
+ )
+ )
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // 2. Saturation Slider
+ Text(
+ text = stringResource(R.string.colors_label_saturation),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.labelLarge
+ )
+ GradientSlider(
+ value = saturation,
+ onValueChange = { saturation = it }, // ONLY updates local state
+ valueRange = 0f..1f,
+ brush = Brush.horizontalGradient(
+ colors = listOf(
+ Color(ColorUtils.HSLToColor(floatArrayOf(hue, 0f, lightness))),
+ Color(ColorUtils.HSLToColor(floatArrayOf(hue, 1f, lightness)))
+ )
+ )
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // 3. Lightness Slider
+ Text(
+ text = stringResource(R.string.colors_label_lightness),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.labelLarge
+ )
+ GradientSlider(
+ value = lightness,
+ onValueChange = { lightness = it }, // ONLY updates local state
+ valueRange = 0f..1f,
+ brush = Brush.horizontalGradient(
+ colors = listOf(
+ Color.Black,
+ Color(ColorUtils.HSLToColor(floatArrayOf(hue, saturation, 0.5f))),
+ Color.White
+ )
+ )
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // 4. Preview and Save Button
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Live preview of the constructed color
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .clip(CircleShape)
+ .background(currentColor)
+ .border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Button(
+ onClick = { onColorAdded(currentColor) },
+ modifier = Modifier.weight(1f).height(50.dp)
+ ) {
+ Text(stringResource(R.string.colors_action_done))
+ }
+ }
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/components/HyperOSColorPicker.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/HyperOSColorPicker.kt
new file mode 100644
index 0000000..a92f1ac
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/HyperOSColorPicker.kt
@@ -0,0 +1,102 @@
+package com.d4viddf.hyperbridge.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Slider
+import androidx.compose.material3.SliderDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GradientSlider(
+ value: Float,
+ onValueChange: (Float) -> Unit,
+ valueRange: ClosedFloatingPointRange,
+ brush: Brush,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(36.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ // The gradient track
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(24.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(brush)
+ )
+
+ // The transparent slider on top to handle touch events and draw the thumb
+ Slider(
+ value = value,
+ onValueChange = onValueChange,
+ valueRange = valueRange,
+ colors = SliderDefaults.colors(
+ thumbColor = Color.White,
+ activeTrackColor = Color.Transparent,
+ inactiveTrackColor = Color.Transparent
+ ),
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+}
+
+// Reusable circular color bubble
+@Composable
+fun ColorBubble(
+ color: Color,
+ isSelected: Boolean,
+ onClick: () -> Unit,
+ backgroundBrush: Brush? = null,
+ content: @Composable BoxScope.() -> Unit = {}
+) {
+ Box(
+ modifier = Modifier
+ .size(56.dp)
+ .clip(CircleShape)
+ .clickable(onClick = onClick),
+ contentAlignment = Alignment.Center
+ ) {
+ // Outer selection ring
+ if (isSelected) {
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .border(2.dp, Color(0xFF3B82F6), CircleShape) // HyperOS Blue ring
+ )
+ }
+
+ // Inner color circle
+ Box(
+ modifier = Modifier
+ .size(if (isSelected) 44.dp else 48.dp) // Shrinks slightly when selected
+ .clip(CircleShape)
+ .then(
+ if (backgroundBrush != null) Modifier.background(backgroundBrush)
+ else Modifier.background(color)
+ ),
+ contentAlignment = Alignment.Center,
+ content = content
+ )
+ }
+}
+
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/SavedAppWidgetsScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/SavedAppWidgetsScreen.kt
index 420b288..e3160b5 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/SavedAppWidgetsScreen.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/SavedAppWidgetsScreen.kt
@@ -1,5 +1,6 @@
package com.d4viddf.hyperbridge.ui.screens.design
+import android.content.Context
import android.content.Intent
import android.graphics.drawable.Drawable
import android.view.View
@@ -28,6 +29,7 @@ import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.outlined.Block
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Widgets
import androidx.compose.material3.*
@@ -48,15 +50,15 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.content.pm.PackageInfoCompat
import androidx.core.graphics.drawable.toBitmap
import com.d4viddf.hyperbridge.R
import com.d4viddf.hyperbridge.data.AppPreferences
import com.d4viddf.hyperbridge.data.widget.WidgetManager
import com.d4viddf.hyperbridge.models.WidgetSize
-import com.d4viddf.hyperbridge.ui.components.EmptyState // Import EmptyState
+import com.d4viddf.hyperbridge.ui.components.EmptyState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -81,21 +83,41 @@ fun SavedAppWidgetsScreen(
val scope = rememberCoroutineScope()
val preferences = remember { AppPreferences(context.applicationContext) }
+ val favorites by preferences.favoriteWidgetAppsFlow.collectAsState(initial = emptySet())
+ val savedIds by preferences.savedWidgetIdsFlow.collectAsState(initial = null) // Reactive listening for newly added widgets!
+
var allGroups by remember { mutableStateOf>(emptyList()) }
var searchQuery by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(true) }
+ var tabIndex by remember { mutableIntStateOf(1) } // 0 = Favorites, 1 = All
+ var showPermissionReminder by remember { mutableStateOf(false) }
val refreshTrigger = remember { mutableStateOf(0) }
val pullState = rememberPullToRefreshState()
val isRefreshing = isLoading && allGroups.isNotEmpty()
- LaunchedEffect(refreshTrigger.value) {
+ // App Update Permission Reminder Logic
+ LaunchedEffect(Unit) {
+ val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
+ val currentVersion = PackageInfoCompat.getLongVersionCode(packageInfo).toInt()
+ val sharedPrefs = context.getSharedPreferences("hyperbridge_internal", Context.MODE_PRIVATE)
+ val lastVersion = sharedPrefs.getInt("last_seen_version", currentVersion)
+
+ if (lastVersion in 1 until currentVersion) {
+ showPermissionReminder = true
+ }
+ sharedPrefs.edit().putInt("last_seen_version", currentVersion).apply()
+ }
+
+ // Fetch Saved Widgets (Reacts to new widgets being added dynamically)
+ LaunchedEffect(savedIds, refreshTrigger.value) {
+ if (savedIds == null) return@LaunchedEffect
+
isLoading = true
withContext(Dispatchers.IO) {
- val savedIds = preferences.savedWidgetIdsFlow.first()
val groupsMap = mutableMapOf>()
- savedIds.forEach { id ->
+ savedIds!!.forEach { id ->
val info = WidgetManager.getWidgetInfo(context, id)
val pkg = info?.provider?.packageName ?: return@forEach
groupsMap.getOrPut(pkg) { mutableListOf() }.add(id)
@@ -120,16 +142,38 @@ fun SavedAppWidgetsScreen(
isLoading = false
}
- val displayedGroups = remember(allGroups, searchQuery) {
- if (searchQuery.isEmpty()) {
- allGroups
- } else {
- allGroups.filter {
+ // Reactive Filtering (Favorites + Search)
+ val displayedGroups = remember(allGroups, searchQuery, tabIndex, favorites) {
+ var filtered = allGroups
+
+ // Filter by Favorites
+ if (tabIndex == 0) {
+ filtered = filtered.filter { favorites.contains(it.packageName) }
+ }
+
+ // Filter by Search Query
+ if (searchQuery.isNotEmpty()) {
+ filtered = filtered.filter {
it.appName.contains(searchQuery, ignoreCase = true)
}.map {
it.copy(isExpanded = true)
}
}
+
+ filtered
+ }
+
+ if (showPermissionReminder) {
+ AlertDialog(
+ onDismissRequest = { showPermissionReminder = false },
+ title = { Text(stringResource(R.string.permission_reminder_title)) },
+ text = { Text(stringResource(R.string.permission_reminder_desc)) },
+ confirmButton = {
+ Button(onClick = { showPermissionReminder = false }) {
+ Text(stringResource(R.string.got_it))
+ }
+ }
+ )
}
Scaffold(
@@ -139,7 +183,7 @@ fun SavedAppWidgetsScreen(
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.saved_widgets_title), fontWeight = FontWeight.Bold)
- // [NEW] Beta Badge
+ // Beta Badge
Spacer(Modifier.width(8.dp))
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
@@ -154,7 +198,7 @@ fun SavedAppWidgetsScreen(
)
}
}
- },
+ },
navigationIcon = {
FilledTonalIconButton(
onClick = onBack,
@@ -165,15 +209,79 @@ fun SavedAppWidgetsScreen(
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
+ actions = {
+ if (allGroups.isNotEmpty()) {
+ FilledTonalIconButton(
+ onClick = {
+ val intent = Intent(context, com.d4viddf.hyperbridge.service.WidgetOverlayService::class.java).apply {
+ action = "ACTION_KILL_ALL_WIDGETS"
+ }
+ context.startService(intent)
+ },
+ colors = IconButtonDefaults.filledTonalIconButtonColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer
+ )
+ ) {
+ Icon(Icons.Outlined.Block, contentDescription = stringResource(R.string.kill_all_widgets))
+ }
+ }
+ },
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface)
)
},
+ floatingActionButtonPosition = FabPosition.Center,
floatingActionButton = {
- ExtendedFloatingActionButton(
- onClick = onAddMore,
- icon = { Icon(Icons.Default.Add, null) },
- text = { Text(stringResource(R.string.new_widget_fab)) }
- )
+ // A full-width Box so the Toolbar can be centered and the FAB right-aligned
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ ) {
+ // Centered Filter Toolbar
+ Box(modifier = Modifier.align(Alignment.Center)) {
+ HorizontalFloatingToolbar(
+ expanded = true,
+ content = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.padding(horizontal = 4.dp)
+ ) {
+ TextButton(
+ onClick = { tabIndex = 0 },
+ colors = ButtonDefaults.textButtonColors(
+ containerColor = if (tabIndex == 0) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent,
+ contentColor = if (tabIndex == 0) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ ) {
+ Text(stringResource(R.string.favorites), fontWeight = FontWeight.SemiBold)
+ }
+
+ TextButton(
+ onClick = { tabIndex = 1 },
+ colors = ButtonDefaults.textButtonColors(
+ containerColor = if (tabIndex == 1) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent,
+ contentColor = if (tabIndex == 1) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ ) {
+ Text(stringResource(R.string.all), fontWeight = FontWeight.SemiBold)
+ }
+ }
+ }
+ )
+ }
+
+ // Add FAB aligned to the far right
+ FloatingActionButton(
+ onClick = onAddMore,
+ modifier = Modifier.align(Alignment.CenterEnd),
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary
+ ) {
+ Icon(Icons.Default.Add, contentDescription = "Add Widget")
+ }
+ }
},
containerColor = MaterialTheme.colorScheme.surface
) { padding ->
@@ -227,7 +335,6 @@ fun SavedAppWidgetsScreen(
LoadingIndicator()
}
} else if (displayedGroups.isEmpty()) {
- // [UPDATED] Use EmptyState Component
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
EmptyState(
title = stringResource(R.string.no_saved_widgets),
@@ -238,7 +345,7 @@ fun SavedAppWidgetsScreen(
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
- contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 80.dp),
+ contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 100.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(displayedGroups, key = { it.packageName }) { group ->
@@ -251,6 +358,14 @@ fun SavedAppWidgetsScreen(
onToggle = { expanded = !expanded },
onEdit = onEditWidget,
onDelete = { widgetId ->
+ // 1. Instantly kill the active Island notification
+ val killIntent = Intent(context, com.d4viddf.hyperbridge.service.WidgetOverlayService::class.java).apply {
+ action = "ACTION_KILL_WIDGET"
+ putExtra("WIDGET_ID", widgetId)
+ }
+ context.startService(killIntent)
+
+ // 2. Remove from database and refresh UI
scope.launch {
preferences.removeWidgetId(widgetId)
refreshTrigger.value++
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/WidgetPickerScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/WidgetPickerScreen.kt
index 90ac097..a28d3af 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/WidgetPickerScreen.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/WidgetPickerScreen.kt
@@ -16,7 +16,20 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.*
+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.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -28,9 +41,38 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.outlined.Star
+import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.material.icons.outlined.Widgets
-import androidx.compose.material3.*
-import androidx.compose.runtime.*
+import androidx.compose.material.icons.rounded.Star
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FabPosition
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.HorizontalFloatingToolbar
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -44,8 +86,10 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import com.d4viddf.hyperbridge.R
+import com.d4viddf.hyperbridge.data.AppPreferences
import com.d4viddf.hyperbridge.data.widget.WidgetManager
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class WidgetAppGroup(
@@ -56,7 +100,7 @@ data class WidgetAppGroup(
val isExpanded: Boolean = false
)
-@OptIn(ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun WidgetPickerScreen(
onBack: () -> Unit,
@@ -64,9 +108,14 @@ fun WidgetPickerScreen(
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
+ val scope = rememberCoroutineScope()
+ val preferences = remember { AppPreferences(context.applicationContext) }
+
+ val favorites by preferences.favoriteWidgetAppsFlow.collectAsState(initial = emptySet())
var allGroups by remember { mutableStateOf>(emptyList()) }
var searchQuery by remember { mutableStateOf("") }
+ var tabIndex by remember { mutableIntStateOf(0) } // 0 = Recommended, 1 = All
var pendingWidgetId by remember { mutableStateOf(-1) }
val bindLauncher = rememberLauncherForActivityResult(
@@ -84,37 +133,41 @@ fun WidgetPickerScreen(
withContext(Dispatchers.IO) {
val manager = AppWidgetManager.getInstance(context)
val providers = manager.installedProviders
-
val grouped = providers.groupBy { it.provider.packageName }
- val uiGroups = grouped.map { (pkg, list) ->
- val appName = try {
- val appInfo = context.packageManager.getApplicationInfo(pkg, 0)
- context.packageManager.getApplicationLabel(appInfo).toString()
- } catch (e: Exception) { pkg }
-
- val icon = try {
- context.packageManager.getApplicationIcon(pkg)
+ val uiGroups = grouped.mapNotNull { (pkg, list) ->
+ try {
+ val appName = context.packageManager.getApplicationLabel(context.packageManager.getApplicationInfo(pkg, 0)).toString()
+ val icon = context.packageManager.getApplicationIcon(pkg)
+ WidgetAppGroup(pkg, appName, icon, list)
} catch (e: Exception) { null }
-
- WidgetAppGroup(pkg, appName, icon, list)
}.sortedBy { it.appName }
allGroups = uiGroups
}
}
- val displayedGroups = remember(allGroups, searchQuery) {
- if (searchQuery.isEmpty()) {
- allGroups
- } else {
- allGroups.filter {
- it.appName.contains(searchQuery, ignoreCase = true)
- }.map {
- it.copy(isExpanded = true)
+ val displayedGroups = allGroups.mapNotNull { group ->
+ val filteredWidgets = if (tabIndex == 0) {
+ group.widgets.filter { w ->
+ // Filter specifically for 1x4 and 2x4 layouts (Compatible with Island sizes)
+ if (android.os.Build.VERSION.SDK_INT >= 31) {
+ w.targetCellWidth == 4 && (w.targetCellHeight == 1 || w.targetCellHeight == 2)
+ } else {
+ w.minWidth >= 200 && w.minHeight <= 150 // Rough estimation for older Android versions
+ }
}
- }
- }
+ } else group.widgets
+
+ if (filteredWidgets.isNotEmpty()) group.copy(widgets = filteredWidgets) else null
+ }.filter {
+ if (searchQuery.isNotEmpty()) it.appName.contains(searchQuery, ignoreCase = true) else true
+ }.map {
+ if (searchQuery.isNotEmpty()) it.copy(isExpanded = true) else it
+ }.sortedWith(
+ compareByDescending { favorites.contains(it.packageName) }
+ .thenBy { it.appName }
+ )
Scaffold(
topBar = {
@@ -122,8 +175,6 @@ fun WidgetPickerScreen(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.widget_picker_title), fontWeight = FontWeight.Bold)
-
- // [NEW] Beta Badge
Spacer(Modifier.width(8.dp))
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
@@ -138,7 +189,7 @@ fun WidgetPickerScreen(
)
}
}
- },
+ },
navigationIcon = {
FilledTonalIconButton(
onClick = onBack,
@@ -149,9 +200,32 @@ fun WidgetPickerScreen(
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
- colors = TopAppBarDefaults.topAppBarColors(
- containerColor = MaterialTheme.colorScheme.surface
- )
+ colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface)
+ )
+ },
+ floatingActionButtonPosition = FabPosition.Center,
+ floatingActionButton = {
+ HorizontalFloatingToolbar(
+ expanded = true,
+ content = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ ToolbarOption(
+ selected = tabIndex == 0,
+ icon = Icons.Outlined.Star,
+ text = stringResource(R.string.recommended),
+ onClick = { tabIndex = 0 }
+ )
+ ToolbarOption(
+ selected = tabIndex == 1,
+ icon = Icons.Outlined.Widgets,
+ text = stringResource(R.string.all),
+ onClick = { tabIndex = 1 }
+ )
+ }
+ }
)
},
containerColor = MaterialTheme.colorScheme.surface
@@ -176,10 +250,8 @@ fun WidgetPickerScreen(
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
colors = TextFieldDefaults.colors(
- focusedIndicatorColor = Color.Transparent,
- unfocusedIndicatorColor = Color.Transparent,
- disabledIndicatorColor = Color.Transparent,
- errorIndicatorColor = Color.Transparent,
+ focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent, errorIndicatorColor = Color.Transparent,
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
@@ -188,17 +260,22 @@ fun WidgetPickerScreen(
LazyColumn(
modifier = Modifier.fillMaxSize(),
- contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp),
+ contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 80.dp), // Extra padding for the floating bar
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(displayedGroups, key = { it.packageName }) { group ->
var expanded by remember(group.packageName, searchQuery) { mutableStateOf(group.isExpanded) }
+ val isFavorite = favorites.contains(group.packageName)
Box(modifier = Modifier.animateItem()) {
AppGroupItem(
group = group,
isExpanded = expanded,
+ isFavorite = isFavorite,
onToggle = { expanded = !expanded },
+ onFavoriteToggle = {
+ scope.launch { preferences.toggleFavoriteWidgetApp(group.packageName, !isFavorite) }
+ },
onSelectWidget = { provider ->
val widgetId = WidgetManager.allocateId(context)
val allowed = WidgetManager.bindWidget(context, widgetId, provider.provider)
@@ -226,7 +303,9 @@ fun WidgetPickerScreen(
fun AppGroupItem(
group: WidgetAppGroup,
isExpanded: Boolean,
+ isFavorite: Boolean,
onToggle: () -> Unit,
+ onFavoriteToggle: () -> Unit,
onSelectWidget: (AppWidgetProviderInfo) -> Unit
) {
val numWidget = pluralStringResource(R.plurals.widget_count_fmt, group.widgets.size, group.widgets.size)
@@ -270,6 +349,15 @@ fun AppGroupItem(
)
}
+ // [NEW] Star Button
+ IconButton(onClick = onFavoriteToggle) {
+ Icon(
+ imageVector = if (isFavorite) Icons.Rounded.Star else Icons.Outlined.StarBorder,
+ contentDescription = "Favorite",
+ tint = if (isFavorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
Icon(
imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
@@ -318,7 +406,21 @@ fun WidgetChildItem(
) {
val context = LocalContext.current
val label = info.loadLabel(context.packageManager)
- val dims = "${info.minWidth} × ${info.minHeight}"
+
+ // Convert dp dimensions to Android Grid Proportions (e.g., 4x1, 2x2)
+ val cols = if (android.os.Build.VERSION.SDK_INT >= 31) {
+ info.targetCellWidth
+ } else {
+ maxOf(1, Math.ceil((info.minWidth + 30) / 70.0).toInt())
+ }
+
+ val rows = if (android.os.Build.VERSION.SDK_INT >= 31) {
+ info.targetCellHeight
+ } else {
+ maxOf(1, Math.ceil((info.minHeight + 30) / 70.0).toInt())
+ }
+
+ val dims = "$cols × $rows"
var preview by remember { mutableStateOf(null) }
LaunchedEffect(info) {
@@ -369,7 +471,8 @@ fun WidgetChildItem(
text = label,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
- color = MaterialTheme.colorScheme.onSurface
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
Text(
text = dims,
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/GlobalSettingsScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/GlobalSettingsScreen.kt
index 9afafd5..dcb7994 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/GlobalSettingsScreen.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/GlobalSettingsScreen.kt
@@ -1,5 +1,6 @@
package com.d4viddf.hyperbridge.ui.screens.settings
+import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -8,6 +9,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.ArrowForwardIos
import androidx.compose.material.icons.filled.Navigation
+import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -28,7 +30,7 @@ import kotlinx.coroutines.launch
fun GlobalSettingsScreen(
onBack: () -> Unit,
onNavSettingsClick: () -> Unit // New Callback
- ) {
+) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val preferences = remember { AppPreferences(context) }
@@ -48,6 +50,7 @@ fun GlobalSettingsScreen(
}
) { padding ->
Column(modifier = Modifier.padding(padding).padding(16.dp)) {
+ // Island Settings Card
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
@@ -62,7 +65,7 @@ fun GlobalSettingsScreen(
}
Spacer(modifier = Modifier.height(16.dp))
- // NEW: Navigation Layout Card
+ // Navigation Layout Card
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)) {
SettingsItem(
icon = Icons.Default.Navigation,
@@ -71,6 +74,29 @@ fun GlobalSettingsScreen(
onClick = onNavSettingsClick
)
}
+ Spacer(modifier = Modifier.height(16.dp))
+
+ var useNativeLiveUpdates by remember { mutableStateOf(false) }
+
+ LaunchedEffect(Unit) {
+ val prefs = context.getSharedPreferences("hyperbridge_settings", Context.MODE_PRIVATE)
+ useNativeLiveUpdates = prefs.getBoolean("use_native_live_updates", false)
+ }
+
+ // Native Live Updates Card
+ Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)) {
+ SettingsSwitchItem(
+ icon = Icons.Default.Notifications,
+ title = stringResource(R.string.settings_live_updates_title),
+ subtitle = stringResource(R.string.settings_live_updates_desc),
+ checked = useNativeLiveUpdates,
+ onCheckedChange = { isChecked ->
+ useNativeLiveUpdates = isChecked
+ context.getSharedPreferences("hyperbridge_settings", Context.MODE_PRIVATE)
+ .edit().putBoolean("use_native_live_updates", isChecked).apply()
+ }
+ )
+ }
}
}
}
@@ -125,4 +151,54 @@ fun SettingsItem(
modifier = Modifier.size(16.dp)
)
}
+}
+
+@Composable
+fun SettingsSwitchItem(
+ icon: ImageVector,
+ title: String,
+ subtitle: String,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onCheckedChange(!checked) }
+ .padding(horizontal = 20.dp, vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), CircleShape),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Switch(
+ checked = checked,
+ onCheckedChange = onCheckedChange
+ )
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/AppThemeEditorScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/AppThemeEditorScreen.kt
index b08e0a0..3ba0f27 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/AppThemeEditorScreen.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/AppThemeEditorScreen.kt
@@ -328,14 +328,9 @@ fun AppEditorMenu(
fun AppColorEditor(viewModel: ThemeViewModel) {
ColorsDetailContent(
selectedColorHex = viewModel.appHighlightColor ?: viewModel.selectedColorHex,
- useAppColors = viewModel.appUseAppColors == true,
- onColorSelected = {
- viewModel.appHighlightColor = it
- viewModel.appUseAppColors = false
- },
- onUseAppColorsChanged = { isEnabled ->
- viewModel.appUseAppColors = isEnabled
- }
+ colorMode = viewModel.appColorMode ?: viewModel.colorMode,
+ onColorSelected = { viewModel.appHighlightColor = it },
+ onColorModeChanged = { viewModel.appColorMode = it }
)
}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeCreatorScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeCreatorScreen.kt
index 7a91111..3295f9d 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeCreatorScreen.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeCreatorScreen.kt
@@ -90,6 +90,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.d4viddf.hyperbridge.R
+import com.d4viddf.hyperbridge.models.theme.ColorMode
import com.d4viddf.hyperbridge.ui.screens.theme.content.ActionsDetailContent
import com.d4viddf.hyperbridge.ui.screens.theme.content.AppsDetailContent
import com.d4viddf.hyperbridge.ui.screens.theme.content.CallStyleSheetContent
@@ -114,28 +115,19 @@ fun ThemeCreatorScreen(
val viewModel: ThemeViewModel = viewModel()
val activeThemeId by viewModel.activeThemeId.collectAsState()
- // [SWITCH] If editing an app override, delegate to AppThemeEditor
if (viewModel.editingAppPackage != null) {
AppThemeEditor(viewModel)
} else {
- // --- GLOBAL THEME CREATOR MENU ---
var currentRoute by remember { mutableStateOf(CreatorRoute.MAIN_MENU) }
var showSettingsSheet by remember { mutableStateOf(false) }
var showSaveDialog by remember { mutableStateOf(false) }
- // [CRITICAL FIX] Robust state initialization
LaunchedEffect(editThemeId) {
if (editThemeId != null) {
- // Only load if we aren't already editing this specific ID.
- // This prevents overwriting unsaved changes if this effect re-runs.
if (viewModel.currentEditingThemeId != editThemeId) {
viewModel.loadThemeForEditing(editThemeId)
}
} else {
- // Only clear/init new state if we haven't started a session yet.
- // If currentEditingThemeId is NOT null, it means we are already
- // working on a new draft (and likely just came back from AppEditor),
- // so we MUST NOT clear the state.
if (viewModel.currentEditingThemeId == null) {
viewModel.clearCreatorState()
}
@@ -146,6 +138,11 @@ fun ThemeCreatorScreen(
currentRoute = CreatorRoute.MAIN_MENU
}
+ BackHandler(enabled = currentRoute == CreatorRoute.MAIN_MENU) {
+ viewModel.currentEditingThemeId = null
+ onBack()
+ }
+
Scaffold(
topBar = {
TopAppBar(
@@ -165,7 +162,12 @@ fun ThemeCreatorScreen(
navigationIcon = {
FilledTonalIconButton(
onClick = {
- if (currentRoute != CreatorRoute.MAIN_MENU) currentRoute = CreatorRoute.MAIN_MENU else onBack()
+ if (currentRoute != CreatorRoute.MAIN_MENU) {
+ currentRoute = CreatorRoute.MAIN_MENU
+ } else {
+ viewModel.currentEditingThemeId = null
+ onBack()
+ }
},
colors = IconButtonDefaults.filledTonalIconButtonColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest)
) {
@@ -176,12 +178,11 @@ fun ThemeCreatorScreen(
if (currentRoute == CreatorRoute.MAIN_MENU) {
Button(
onClick = {
- // If we are editing the currently active theme, save and re-apply immediately
if (editThemeId != null && editThemeId == activeThemeId) {
viewModel.saveTheme(editThemeId)
+ viewModel.currentEditingThemeId = null
onThemeCreated()
} else {
- // Otherwise ask user if they want to Apply or just Save
showSaveDialog = true
}
},
@@ -214,7 +215,8 @@ fun ThemeCreatorScreen(
previewContent = {
SharedThemePreview(
highlightColorHex = viewModel.selectedColorHex,
- useAppColors = viewModel.useAppColors,
+ // [FIX 1] Convert ColorMode Enum to boolean for the preview
+ useAppColors = (viewModel.colorMode == ColorMode.APP_ICON),
shapeId = viewModel.selectedShapeId,
paddingPercent = viewModel.iconPaddingPercent,
answerColorHex = viewModel.callAnswerColor,
@@ -226,9 +228,10 @@ fun ThemeCreatorScreen(
content = {
ColorsDetailContent(
selectedColorHex = viewModel.selectedColorHex,
- useAppColors = viewModel.useAppColors,
+ // [FIX 2] Pass ColorMode Enum instead of boolean
+ colorMode = viewModel.colorMode,
onColorSelected = { viewModel.selectedColorHex = it },
- onUseAppColorsChanged = { viewModel.useAppColors = it }
+ onColorModeChanged = { viewModel.colorMode = it }
)
}
)
@@ -236,7 +239,8 @@ fun ThemeCreatorScreen(
previewContent = {
SharedThemePreview(
highlightColorHex = viewModel.selectedColorHex,
- useAppColors = viewModel.useAppColors,
+ // [FIX 3] Convert ColorMode Enum to boolean
+ useAppColors = (viewModel.colorMode == ColorMode.APP_ICON),
shapeId = viewModel.selectedShapeId,
paddingPercent = viewModel.iconPaddingPercent,
answerColorHex = viewModel.callAnswerColor,
@@ -259,7 +263,8 @@ fun ThemeCreatorScreen(
previewContent = {
SharedThemePreview(
highlightColorHex = viewModel.selectedColorHex,
- useAppColors = viewModel.useAppColors,
+ // [FIX 4] Convert ColorMode Enum to boolean
+ useAppColors = (viewModel.colorMode == ColorMode.APP_ICON),
shapeId = viewModel.selectedShapeId,
paddingPercent = viewModel.iconPaddingPercent,
answerColorHex = viewModel.callAnswerColor,
@@ -309,11 +314,24 @@ fun SaveDialog(viewModel: ThemeViewModel, editThemeId: String?, activeThemeId: S
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.creator_dialog_apply_title)) },
text = { Text(stringResource(R.string.creator_dialog_apply_desc)) },
- confirmButton = { Button(onClick = { onDismiss(); viewModel.saveTheme(editThemeId, apply = true); onThemeCreated() }) { Text(stringResource(R.string.creator_dialog_action_save_apply)) } },
- dismissButton = { TextButton(onClick = { onDismiss(); viewModel.saveTheme(editThemeId, apply = false); onThemeCreated() }) { Text(stringResource(R.string.creator_dialog_action_save_only)) } }
+ confirmButton = {
+ Button(onClick = {
+ onDismiss()
+ viewModel.saveTheme(editThemeId, apply = true)
+ viewModel.currentEditingThemeId = null // Clear state!
+ onThemeCreated()
+ }) { Text(stringResource(R.string.creator_dialog_action_save_apply)) }
+ },
+ dismissButton = {
+ TextButton(onClick = {
+ onDismiss()
+ viewModel.saveTheme(editThemeId, apply = false)
+ viewModel.currentEditingThemeId = null // Clear state!
+ onThemeCreated()
+ }) { Text(stringResource(R.string.creator_dialog_action_save_only)) }
+ }
)
}
-
@Composable
fun CreatorMainList(viewModel: ThemeViewModel, onNavigate: (CreatorRoute) -> Unit, onEditSettings: () -> Unit) {
Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
@@ -321,7 +339,10 @@ fun CreatorMainList(viewModel: ThemeViewModel, onNavigate: (CreatorRoute) -> Uni
Surface(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 16.dp), shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surfaceContainer) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(vertical = 12.dp)) {
SharedThemePreview(
- viewModel.selectedColorHex, viewModel.useAppColors, viewModel.selectedShapeId, viewModel.iconPaddingPercent,
+ viewModel.selectedColorHex,
+ // [FIX 5] Convert ColorMode Enum to boolean
+ (viewModel.colorMode == ColorMode.APP_ICON),
+ viewModel.selectedShapeId, viewModel.iconPaddingPercent,
viewModel.callAnswerColor, viewModel.callDeclineColor, viewModel.callAnswerShapeId, viewModel.callDeclineShapeId
)
}
@@ -437,4 +458,4 @@ fun ThemeMetadataSheet(viewModel: ThemeViewModel, onDismiss: () -> Unit) {
Spacer(Modifier.height(24.dp))
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeViewModel.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeViewModel.kt
index 970b76e..aa79186 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeViewModel.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeViewModel.kt
@@ -22,6 +22,7 @@ import com.d4viddf.hyperbridge.data.theme.ThemeRepository
import com.d4viddf.hyperbridge.models.theme.ActionConfig
import com.d4viddf.hyperbridge.models.theme.AppThemeOverride
import com.d4viddf.hyperbridge.models.theme.CallModule
+import com.d4viddf.hyperbridge.models.theme.ColorMode
import com.d4viddf.hyperbridge.models.theme.GlobalConfig
import com.d4viddf.hyperbridge.models.theme.HyperTheme
import com.d4viddf.hyperbridge.models.theme.ResourceType
@@ -67,7 +68,7 @@ class ThemeViewModel(application: Application) : AndroidViewModel(application) {
var customShareLink by mutableStateOf("")
var selectedColorHex by mutableStateOf("#3DDA82")
- var useAppColors by mutableStateOf(false)
+ var colorMode by mutableStateOf(ColorMode.CUSTOM)
var isDarkThemePreview by mutableStateOf(true)
var selectedShapeId by mutableStateOf("circle")
@@ -87,7 +88,7 @@ class ThemeViewModel(application: Application) : AndroidViewModel(application) {
var editingAppLabel by mutableStateOf("")
var appHighlightColor by mutableStateOf(null)
- var appUseAppColors by mutableStateOf(null)
+ var appColorMode by mutableStateOf(null)
var appShapeId by mutableStateOf(null)
var appPaddingPercent by mutableStateOf(null)
@@ -104,7 +105,6 @@ class ThemeViewModel(application: Application) : AndroidViewModel(application) {
private val _tempAssets = mutableMapOf()
- // [FIX] Use application context to get string resource
val shareTheme: String = application.getString(R.string.share_theme)
init {
@@ -169,7 +169,6 @@ class ThemeViewModel(application: Application) : AndroidViewModel(application) {
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
- // [FIX] Use the pre-loaded string
val chooser = Intent.createChooser(intent, shareTheme)
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(chooser)
@@ -214,7 +213,7 @@ class ThemeViewModel(application: Application) : AndroidViewModel(application) {
editingAppLabel = label
appHighlightColor = override?.highlightColor
- appUseAppColors = override?.useAppColors
+ appColorMode = override?.activeColorMode
appShapeId = override?.iconShapeId
appPaddingPercent = override?.iconPaddingPercent
@@ -260,7 +259,8 @@ class ThemeViewModel(application: Application) : AndroidViewModel(application) {
val newOverride = AppThemeOverride(
highlightColor = appHighlightColor,
- useAppColors = appUseAppColors,
+ useAppColors = appColorMode?.let { it == ColorMode.APP_ICON },
+ colorMode = appColorMode,
iconShapeId = appShapeId,
iconPaddingPercent = appPaddingPercent,
callConfig = callModule,
@@ -298,7 +298,7 @@ class ThemeViewModel(application: Application) : AndroidViewModel(application) {
customShareLink = theme.meta.customShareLink ?: ""
selectedColorHex = theme.global.highlightColor ?: "#3DDA82"
- useAppColors = theme.global.useAppColors
+ colorMode = theme.global.activeColorMode
selectedShapeId = theme.global.iconShapeId
iconPaddingPercent = theme.global.iconPaddingPercent
callAnswerColor = theme.callConfig.answerColor ?: "#34C759"
@@ -314,8 +314,7 @@ class ThemeViewModel(application: Application) : AndroidViewModel(application) {
fun clearCreatorState() {
currentEditingThemeId = UUID.randomUUID().toString()
- // [FIX] Extract "My Theme" string
- themeName = "" // Let the UI handle the "My Theme" placeholder or logic
+ themeName = ""
themeAuthor = ""
themeDescription = ""
themeIconUri = null
@@ -323,7 +322,7 @@ class ThemeViewModel(application: Application) : AndroidViewModel(application) {
customShareLink = ""
selectedColorHex = "#3DDA82"
- useAppColors = false
+ colorMode = ColorMode.CUSTOM
selectedShapeId = "circle"
iconPaddingPercent = 15
callAnswerUri = null
@@ -334,7 +333,7 @@ class ThemeViewModel(application: Application) : AndroidViewModel(application) {
callDeclineShapeId = "circle"
appHighlightColor = null
- appUseAppColors = null
+ appColorMode = null
appCallAnswerShapeId = null
appCallDeclineShapeId = null
@@ -402,7 +401,6 @@ class ThemeViewModel(application: Application) : AndroidViewModel(application) {
val declineRes = if (callDeclineUri != null) ThemeResource(ResourceType.LOCAL_FILE, "icons/call_decline.png")
else getThemeById(themeId)?.callConfig?.declineIcon
- // [FIX] Use extracted string for default theme name
val defaultThemeName = getApplication().getString(R.string.my_theme)
val newTheme = HyperTheme(
@@ -417,7 +415,8 @@ class ThemeViewModel(application: Application) : AndroidViewModel(application) {
),
global = GlobalConfig(
highlightColor = selectedColorHex,
- useAppColors = useAppColors,
+ useAppColors = (colorMode == ColorMode.APP_ICON), // Fallback saving
+ colorMode = colorMode,
iconShapeId = selectedShapeId,
iconPaddingPercent = iconPaddingPercent,
backgroundColor = "#202124"
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/content/CallStyleSheetContent.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/content/CallStyleSheetContent.kt
index aea6cf0..d5dc430 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/content/CallStyleSheetContent.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/content/CallStyleSheetContent.kt
@@ -35,7 +35,6 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.HorizontalFloatingToolbar
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -43,18 +42,21 @@ import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
+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.draw.clip
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.d4viddf.hyperbridge.R
+import com.d4viddf.hyperbridge.ui.components.CustomColorBottomSheet
import com.d4viddf.hyperbridge.ui.screens.design.ToolbarOption
import com.d4viddf.hyperbridge.ui.screens.theme.AssetPickerButton
import com.d4viddf.hyperbridge.ui.screens.theme.ThemeViewModel
@@ -165,6 +167,8 @@ private fun CallConfigTab(
onAssetSelected: (Uri) -> Unit,
defaultIcon: androidx.compose.ui.graphics.vector.ImageVector
) {
+ var showColorPicker by remember { mutableStateOf(false) }
+
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(24.dp)
@@ -176,6 +180,7 @@ private fun CallConfigTab(
)
Row(verticalAlignment = Alignment.CenterVertically) {
+ // Pick Icon Button
Box(
modifier = Modifier
.size(56.dp)
@@ -187,22 +192,33 @@ private fun CallConfigTab(
Spacer(Modifier.width(16.dp))
- OutlinedTextField(
- value = color,
- onValueChange = onColorChange,
- label = { Text("Hex Color") },
- modifier = Modifier.weight(1f),
- trailingIcon = {
+ // Pick Color Button
+ Surface(
+ onClick = { showColorPicker = true },
+ shape = RoundedCornerShape(16.dp),
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ modifier = Modifier.weight(1f).height(56.dp)
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = color.uppercase(),
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium
+ )
+
Box(
modifier = Modifier
- .size(24.dp)
+ .size(32.dp)
.clip(CircleShape)
.background(safeParseColor(color))
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
)
- },
- singleLine = true
- )
+ }
+ }
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
@@ -247,4 +263,18 @@ private fun CallConfigTab(
}
}
}
+
+ // Call the bottom sheet if state is true
+ if (showColorPicker) {
+ CustomColorBottomSheet(
+ initialColor = safeParseColor(color),
+ onDismiss = { showColorPicker = false },
+ onColorAdded = { newColor ->
+ // Format the compose Color back to Hex String
+ val hex = String.format("#%06X", (0xFFFFFF and newColor.toArgb()))
+ onColorChange(hex)
+ showColorPicker = false
+ }
+ )
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/content/ColorsDetailContent.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/content/ColorsDetailContent.kt
index 1c66a77..e5e4f39 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/content/ColorsDetailContent.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/content/ColorsDetailContent.kt
@@ -1,8 +1,11 @@
package com.d4viddf.hyperbridge.ui.screens.theme.content
import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -22,14 +25,15 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Colorize
import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Check
-import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FabPosition
@@ -40,36 +44,42 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
+import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateListOf
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.draw.clip
+import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
+import androidx.core.graphics.ColorUtils
+import androidx.core.graphics.toColorInt
import com.d4viddf.hyperbridge.R
+import com.d4viddf.hyperbridge.models.theme.ColorMode
+import com.d4viddf.hyperbridge.ui.components.CustomColorBottomSheet
+import com.d4viddf.hyperbridge.ui.components.GradientSlider
import com.d4viddf.hyperbridge.ui.screens.design.ToolbarOption
-import androidx.core.graphics.toColorInt
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ColorsDetailContent(
selectedColorHex: String,
- useAppColors: Boolean,
+ colorMode: ColorMode,
onColorSelected: (String) -> Unit,
- onUseAppColorsChanged: (Boolean) -> Unit
+ onColorModeChanged: (ColorMode) -> Unit
) {
var tabIndex by remember { mutableIntStateOf(0) }
@@ -126,8 +136,8 @@ fun ColorsDetailContent(
modifier = Modifier.padding(vertical = 12.dp)
) { selectedTab ->
when (selectedTab) {
- 0 -> ColorsPresetsTab(selectedColorHex, useAppColors, onColorSelected, onUseAppColorsChanged)
- 1 -> ColorsCustomTab(selectedColorHex, onColorSelected)
+ 0 -> ColorsPresetsTab(selectedColorHex, colorMode, onColorSelected, onColorModeChanged)
+ 1 -> ColorsCustomTab(selectedColorHex, colorMode, onColorSelected, onColorModeChanged)
}
}
}
@@ -135,17 +145,30 @@ fun ColorsDetailContent(
}
}
+
@Composable
private fun ColorsPresetsTab(
selectedColorHex: String,
- useAppColors: Boolean,
+ colorMode: ColorMode,
onColorSelected: (String) -> Unit,
- onUseAppColorsChanged: (Boolean) -> Unit
+ onColorModeChanged: (ColorMode) -> Unit
) {
val presets = listOf("#3DDA82", "#FF3B30", "#007AFF", "#FF9500", "#9333ea", "#e11d48", "#2563eb", "#FFFFFF")
+ var activePresetBase by remember {
+ mutableStateOf(presets.find { it.equals(selectedColorHex, ignoreCase = true) })
+ }
+
+ val selectedColor = safeParseColor(selectedColorHex)
+ val hsl = FloatArray(3)
+ ColorUtils.colorToHSL(selectedColor.toArgb(), hsl)
+ val currentLightness = hsl[2]
+
Column(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState()) // <-- ADDED SCROLLING HERE
+ .padding(bottom = 16.dp), // Added bottom padding for clearance
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
@@ -154,14 +177,16 @@ private fun ColorsPresetsTab(
modifier = Modifier.padding(start = 16.dp, top = 8.dp)
)
+ // 1. Preset Bubbles Row
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(horizontal = 16.dp),
- modifier = Modifier.fillMaxWidth().height(72.dp)
+ modifier = Modifier.fillMaxWidth().height(64.dp),
+ verticalAlignment = Alignment.CenterVertically
) {
items(presets) { hex ->
val color = safeParseColor(hex)
- val isSelected = selectedColorHex.equals(hex, ignoreCase = true) && !useAppColors
+ val isSelected = activePresetBase.equals(hex, ignoreCase = true) && colorMode == ColorMode.CUSTOM
val shape = if (isSelected) RoundedCornerShape(16.dp) else CircleShape
val borderColor = if (isSelected) MaterialTheme.colorScheme.onSurface else Color.Transparent
@@ -173,8 +198,9 @@ private fun ColorsPresetsTab(
.clip(shape)
.background(color)
.clickable {
+ activePresetBase = hex
onColorSelected(hex)
- onUseAppColorsChanged(false)
+ onColorModeChanged(ColorMode.CUSTOM)
}
.border(borderWidth, borderColor, shape),
contentAlignment = Alignment.Center
@@ -186,15 +212,71 @@ private fun ColorsPresetsTab(
}
}
+ // 2. Animated Lightness Slider Row
+ AnimatedVisibility(
+ visible = activePresetBase != null && colorMode == ColorMode.CUSTOM,
+ enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
+ exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut()
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .height(36.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Outlined.Palette,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(24.dp)
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ val baseColor = safeParseColor(activePresetBase)
+ val baseHsl = FloatArray(3)
+ ColorUtils.colorToHSL(baseColor.toArgb(), baseHsl)
+
+ val dynamicSliderBrush = remember(baseHsl[0], baseHsl[1]) {
+ Brush.horizontalGradient(
+ colors = listOf(
+ Color.Black,
+ Color(ColorUtils.HSLToColor(floatArrayOf(baseHsl[0], baseHsl[1], 0.5f))),
+ Color.White
+ )
+ )
+ }
+
+ GradientSlider(
+ value = currentLightness,
+ onValueChange = { newLightness ->
+ val newHsl = floatArrayOf(baseHsl[0], baseHsl[1], newLightness)
+ val newColorArgb = ColorUtils.HSLToColor(newHsl)
+ val newColorHex = String.format("#%06X", (0xFFFFFF and newColorArgb))
+ onColorSelected(newColorHex)
+ },
+ valueRange = 0f..1f,
+ brush = dynamicSliderBrush,
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant,
- modifier = Modifier.padding(horizontal = 16.dp)
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
+ Text(
+ text = stringResource(R.string.colors_label_dynamic_modes),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(start = 16.dp, bottom = 4.dp)
+ )
+
+ // 3. App Icon Colors Option (Switch)
ListItem(
- headlineContent = {
- Text(stringResource(R.string.colors_label_use_app_colors), fontWeight = FontWeight.Medium)
- },
+ headlineContent = { Text(stringResource(R.string.colors_label_use_app_colors), fontWeight = FontWeight.Medium) },
supportingContent = {
Text(
stringResource(R.string.colors_desc_use_app_colors),
@@ -206,10 +288,41 @@ private fun ColorsPresetsTab(
},
trailingContent = {
Switch(
- checked = useAppColors,
- onCheckedChange = onUseAppColorsChanged
+ checked = colorMode == ColorMode.APP_ICON,
+ onCheckedChange = { isChecked ->
+ onColorModeChanged(if (isChecked) ColorMode.APP_ICON else ColorMode.CUSTOM)
+ }
+ )
+ },
+ modifier = Modifier.clickable {
+ onColorModeChanged(if (colorMode == ColorMode.APP_ICON) ColorMode.CUSTOM else ColorMode.APP_ICON)
+ },
+ colors = ListItemDefaults.colors(containerColor = Color.Transparent)
+ )
+
+ // 4. Material You Option (Switch)
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.colors_label_material_you), fontWeight = FontWeight.Medium) },
+ supportingContent = {
+ Text(
+ stringResource(R.string.colors_desc_material_you),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ minLines = 1,
+ maxLines = 3
+ )
+ },
+ trailingContent = {
+ Switch(
+ checked = colorMode == ColorMode.MATERIAL_YOU,
+ onCheckedChange = { isChecked ->
+ onColorModeChanged(if (isChecked) ColorMode.MATERIAL_YOU else ColorMode.CUSTOM)
+ }
)
},
+ modifier = Modifier.clickable {
+ onColorModeChanged(if (colorMode == ColorMode.MATERIAL_YOU) ColorMode.CUSTOM else ColorMode.MATERIAL_YOU)
+ },
colors = ListItemDefaults.colors(containerColor = Color.Transparent)
)
}
@@ -218,92 +331,119 @@ private fun ColorsPresetsTab(
@Composable
private fun ColorsCustomTab(
selectedColorHex: String,
- onColorSelected: (String) -> Unit
+ colorMode: ColorMode,
+ onColorSelected: (String) -> Unit,
+ onColorModeChanged: (ColorMode) -> Unit
) {
var showColorPicker by remember { mutableStateOf(false) }
- val savedColors = listOf(selectedColorHex)
+
+ val savedColors = remember {
+ mutableStateListOf().apply {
+ if (selectedColorHex.isNotEmpty()) add(selectedColorHex)
+ }
+ }
+
+ val selectedColor = safeParseColor(selectedColorHex)
+ val isCustomColorSelected = savedColors.contains(selectedColorHex) && colorMode == ColorMode.CUSTOM
+
+ val hsl = FloatArray(3)
+ ColorUtils.colorToHSL(selectedColor.toArgb(), hsl)
+ val hue = hsl[0]
+ val saturation = hsl[1]
+ val lightness = hsl[2]
Column(
modifier = Modifier
.fillMaxWidth()
+ .verticalScroll(rememberScrollState()) // <-- ADDED SCROLLING HERE
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
- Text(stringResource(R.string.colors_label_custom_title), style = MaterialTheme.typography.titleMedium)
+ // ... (Keep the rest of your ColorsCustomTab exactly the same) ...
+ Text(
+ stringResource(R.string.colors_label_custom_title),
+ style = MaterialTheme.typography.titleMedium
+ )
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
+ // 1. Add Button
FilledTonalIconButton(
onClick = { showColorPicker = true },
modifier = Modifier.size(56.dp)
) {
- Icon(Icons.Rounded.Add, contentDescription = stringResource(R.string.colors_cd_add_custom))
+ Icon(
+ Icons.Rounded.Add,
+ contentDescription = stringResource(R.string.colors_cd_add_custom)
+ )
}
- Spacer(Modifier.width(16.dp))
+ // 2. Vertical Separator
+ VerticalDivider(
+ modifier = Modifier
+ .height(32.dp)
+ .padding(horizontal = 12.dp),
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ // 3. User's Custom Colors
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f)
) {
items(savedColors) { hex ->
val color = safeParseColor(hex)
- val shape = RoundedCornerShape(16.dp)
+ val isSelected = selectedColorHex.equals(
+ hex,
+ ignoreCase = true
+ ) && colorMode == ColorMode.CUSTOM
+
+ val shape = if (isSelected) RoundedCornerShape(16.dp) else CircleShape
+ val borderColor =
+ if (isSelected) MaterialTheme.colorScheme.onSurface else Color.Transparent
+ val borderWidth = if (isSelected) 3.dp else 0.dp
Box(
modifier = Modifier
.size(56.dp)
.clip(shape)
.background(color)
- .clickable { onColorSelected(hex) }
- .border(3.dp, MaterialTheme.colorScheme.onSurface, shape),
+ .clickable {
+ onColorSelected(hex)
+ onColorModeChanged(ColorMode.CUSTOM)
+ }
+ .border(borderWidth, borderColor, shape),
contentAlignment = Alignment.Center
) {
- Icon(Icons.Rounded.Check, null, tint = if (color == Color.White) Color.Black else Color.White)
+ if (isSelected) {
+ Icon(
+ Icons.Rounded.Check,
+ null,
+ tint = if (color == Color.White) Color.Black else Color.White
+ )
+ }
}
}
}
}
- }
-
- if (showColorPicker) {
- AlertDialog(
- onDismissRequest = { showColorPicker = false },
- title = { Text(stringResource(R.string.colors_dialog_title)) },
- text = {
- Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
- Text(stringResource(R.string.colors_dialog_desc))
-
- OutlinedTextField(
- value = selectedColorHex,
- onValueChange = { onColorSelected(it) },
- label = { Text(stringResource(R.string.colors_label_hex)) },
- modifier = Modifier.fillMaxWidth(),
- singleLine = true,
- shape = RoundedCornerShape(12.dp),
- leadingIcon = {
- Icon(Icons.Outlined.Colorize, null)
- },
- trailingIcon = {
- Box(
- modifier = Modifier
- .size(24.dp)
- .clip(CircleShape)
- .background(safeParseColor(selectedColorHex))
- .border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)
- )
- }
- )
- }
- },
- confirmButton = {
- TextButton(onClick = { showColorPicker = false }) {
- Text(stringResource(R.string.colors_action_done))
+ // 4. Custom HSL Bottom Sheet
+ if (showColorPicker) {
+ CustomColorBottomSheet(
+ initialColor = safeParseColor(selectedColorHex),
+ onDismiss = { showColorPicker = false },
+ onColorAdded = { newColor ->
+ val hex = String.format("#%06X", (0xFFFFFF and newColor.toArgb()))
+ if (!savedColors.contains(hex)) {
+ savedColors.add(0, hex)
+ }
+ onColorSelected(hex)
+ onColorModeChanged(ColorMode.CUSTOM)
+ showColorPicker = false
}
- }
- )
+ )
+ }
}
}
diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml
index dce12a5..35ea6f7 100644
--- a/app/src/main/res/values-es-rES/strings.xml
+++ b/app/src/main/res/values-es-rES/strings.xml
@@ -551,4 +551,21 @@
•<b>Corrección de errores:</b> Se solucionó un error crítico que provocaba el cierre de la aplicación al abrir la configuración en Chino Tradicional.\n
•<b>Mejoras generales:</b> Corrección de errores menores y mejoras de estabilidad para que la Isla funcione sin problemas.
+
+ Tono
+ Saturación
+ Claridad
+
+ Material You (Sistema)
+ Extrae los colores de tu fondo de pantalla actual.
+
+ Mostrar solo compatibles con la Isla
+ Detener widgets activos
+ Aviso de actualización
+ ¡HyperBridge se ha actualizado! Por favor, asegúrate de que los permisos de Inicio automático y Batería en segundo plano no hayan sido revocados silenciosamente por el sistema para que tus widgets sigan funcionando sin problemas.
+ Entendido
+
+ Recomendados
+ Todos
+ Favoritos
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a596c63..120be53 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -654,4 +654,25 @@
•<b>Crash Fix:</b> Resolved a critical issue that caused the app to crash when opening the settings menu in Traditional Chinese.\n
•<b>Under the Hood:</b> General bug fixes and stability improvements to keep your Island running smoothly.
+
+ Hue
+ Saturation
+ Lightness
+ Dynamic Colors
+ Material You (System)
+ Extracts colors from your current wallpaper.
+
+ Show Island Compatible Only
+ Kill Active Widgets
+ Update Notice
+ HyperBridge has been updated! Please ensure that your Autostart and Background Battery permissions haven\'t been silently revoked by the system to keep your widgets running smoothly.
+ Got it
+
+ Recommended
+ All
+ Favorites
+
+ Native Android Live Updates
+ Transform notifications into standard Android 16 ongoing tasks instead of overriding them with custom Xiaomi Islands.
+ Native Live Updates