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 @@

- Version 0.4.0 + Version 0.4.2 Kotlin Android Material Design @@ -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 @@

- Version 0.4.2 + Version 0.4.2 Kotlin Android Material Design 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