From 806855aa85f9c7839b04af971767763b4c1dc7b9 Mon Sep 17 00:00:00 2001 From: Hendrik Kees Koppe Date: Thu, 1 Oct 2026 11:36:34 -0300 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20adi=C3=A7=C3=A3o=20de=20largeIcon,?= =?UTF-8?q?=20subText,=20useCustomLayout=20e=20outros=20par=C3=A2mrtros?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../flutter_foreground_task/PreferencesKey.kt | 5 + .../models/NotificationContent.kt | 61 ++++++++- .../models/NotificationProgress.kt | 28 ++++ .../service/ForegroundService.kt | 128 ++++++++++++++++++ .../res/layout/fft_notification_expanded.xml | 79 +++++++++++ lib/flutter_foreground_task.dart | 22 +++ ...lutter_foreground_task_method_channel.dart | 21 +++ ...er_foreground_task_platform_interface.dart | 11 ++ lib/models/notification_progress.dart | 39 ++++++ lib/models/service_options.dart | 67 +++++++++ 10 files changed, 459 insertions(+), 2 deletions(-) create mode 100644 android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationProgress.kt create mode 100644 android/src/main/res/layout/fft_notification_expanded.xml create mode 100644 lib/models/notification_progress.dart diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/PreferencesKey.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/PreferencesKey.kt index 7e1b8e54..afe42f40 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/PreferencesKey.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/PreferencesKey.kt @@ -41,6 +41,11 @@ object PreferencesKey { const val NOTIFICATION_CONTENT_TEXT = "notificationContentText" const val NOTIFICATION_CONTENT_ICON = "icon" const val NOTIFICATION_CONTENT_BUTTONS = "buttons" + const val NOTIFICATION_LARGE_ICON_PATH = "notificationLargeIconPath" + const val NOTIFICATION_SUB_TEXT = "notificationSubText" + const val NOTIFICATION_USE_CUSTOM_LAYOUT = "notificationUseCustomLayout" + const val NOTIFICATION_TRACK_IMAGE_PATH = "notificationTrackImagePath" + const val NOTIFICATION_PROGRESS = "notificationProgress" const val NOTIFICATION_INITIAL_ROUTE = "initialRoute" const val NOTIFICATION_SOUND = "notificationSound" const val NOTIFICATION_VIBRATE_PATTERN = "notificationVibratePattern" diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationContent.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationContent.kt index c385e960..66c32b05 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationContent.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationContent.kt @@ -12,7 +12,12 @@ data class NotificationContent( val buttons: List, val initialRoute: String?, val sound: String?, - val vibratePattern: String? + val vibratePattern: String?, + val largeIconPath: String?, + val subText: String?, + val useCustomLayout: Boolean, + val trackImagePath: String?, + val progress: NotificationProgress? ) { companion object { fun getData(context: Context): NotificationContent { @@ -44,6 +49,17 @@ data class NotificationContent( val vibratePattern = prefs.getString(PrefsKey.NOTIFICATION_VIBRATE_PATTERN, null) + val largeIconPath = prefs.getString(PrefsKey.NOTIFICATION_LARGE_ICON_PATH, null) + + val subText = prefs.getString(PrefsKey.NOTIFICATION_SUB_TEXT, null) + + val useCustomLayout = prefs.getBoolean(PrefsKey.NOTIFICATION_USE_CUSTOM_LAYOUT, false) + + val trackImagePath = prefs.getString(PrefsKey.NOTIFICATION_TRACK_IMAGE_PATH, null) + + val progressJsonString = prefs.getString(PrefsKey.NOTIFICATION_PROGRESS, null) + val progress = progressJsonString?.let(NotificationProgress::fromJsonString) + return NotificationContent( title = title, text = text, @@ -51,7 +67,12 @@ data class NotificationContent( buttons = buttons, initialRoute = initialRoute, sound = sound, - vibratePattern = vibratePattern + vibratePattern = vibratePattern, + largeIconPath = largeIconPath, + subText = subText, + useCustomLayout = useCustomLayout, + trackImagePath = trackImagePath, + progress = progress ) } @@ -80,6 +101,17 @@ data class NotificationContent( val vibratePattern = map?.get(PrefsKey.NOTIFICATION_VIBRATE_PATTERN) as? String + val largeIconPath = map?.get(PrefsKey.NOTIFICATION_LARGE_ICON_PATH) as? String + + val subText = map?.get(PrefsKey.NOTIFICATION_SUB_TEXT) as? String + + val useCustomLayout = map?.get(PrefsKey.NOTIFICATION_USE_CUSTOM_LAYOUT) as? Boolean ?: false + + val trackImagePath = map?.get(PrefsKey.NOTIFICATION_TRACK_IMAGE_PATH) as? String + + val progressJson = map?.get(PrefsKey.NOTIFICATION_PROGRESS) as? Map<*, *> + val progressJsonString = progressJson?.let { JSONObject(it).toString() } + with(prefs.edit()) { putString(PrefsKey.NOTIFICATION_CONTENT_TITLE, title) putString(PrefsKey.NOTIFICATION_CONTENT_TEXT, text) @@ -88,6 +120,13 @@ data class NotificationContent( putString(PrefsKey.NOTIFICATION_INITIAL_ROUTE, initialRoute) putString(PrefsKey.NOTIFICATION_SOUND, sound) putString(PrefsKey.NOTIFICATION_VIBRATE_PATTERN, vibratePattern) + // Estes três definem a notificação exatamente como descrita: + // ausente significa remover, e não manter o valor anterior. + putString(PrefsKey.NOTIFICATION_LARGE_ICON_PATH, largeIconPath) + putString(PrefsKey.NOTIFICATION_SUB_TEXT, subText) + putBoolean(PrefsKey.NOTIFICATION_USE_CUSTOM_LAYOUT, useCustomLayout) + putString(PrefsKey.NOTIFICATION_TRACK_IMAGE_PATH, trackImagePath) + putString(PrefsKey.NOTIFICATION_PROGRESS, progressJsonString) commit() } } @@ -117,6 +156,17 @@ data class NotificationContent( val vibratePattern = map?.get(PrefsKey.NOTIFICATION_VIBRATE_PATTERN) as? String + val largeIconPath = map?.get(PrefsKey.NOTIFICATION_LARGE_ICON_PATH) as? String + + val subText = map?.get(PrefsKey.NOTIFICATION_SUB_TEXT) as? String + + val useCustomLayout = map?.get(PrefsKey.NOTIFICATION_USE_CUSTOM_LAYOUT) as? Boolean ?: false + + val trackImagePath = map?.get(PrefsKey.NOTIFICATION_TRACK_IMAGE_PATH) as? String + + val progressJson = map?.get(PrefsKey.NOTIFICATION_PROGRESS) as? Map<*, *> + val progressJsonString = progressJson?.let { JSONObject(it).toString() } + with(prefs.edit()) { title?.let { putString(PrefsKey.NOTIFICATION_CONTENT_TITLE, it) } text?.let { putString(PrefsKey.NOTIFICATION_CONTENT_TEXT, it) } @@ -125,6 +175,13 @@ data class NotificationContent( initialRoute?.let { putString(PrefsKey.NOTIFICATION_INITIAL_ROUTE, it) } putString(PrefsKey.NOTIFICATION_SOUND, sound) putString(PrefsKey.NOTIFICATION_VIBRATE_PATTERN, vibratePattern) + // Estes três definem a notificação exatamente como descrita: + // ausente significa remover, e não manter o valor anterior. + putString(PrefsKey.NOTIFICATION_LARGE_ICON_PATH, largeIconPath) + putString(PrefsKey.NOTIFICATION_SUB_TEXT, subText) + putBoolean(PrefsKey.NOTIFICATION_USE_CUSTOM_LAYOUT, useCustomLayout) + putString(PrefsKey.NOTIFICATION_TRACK_IMAGE_PATH, trackImagePath) + putString(PrefsKey.NOTIFICATION_PROGRESS, progressJsonString) commit() } } diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationProgress.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationProgress.kt new file mode 100644 index 00000000..76ad03ad --- /dev/null +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationProgress.kt @@ -0,0 +1,28 @@ +package com.pravera.flutter_foreground_task.models + +import org.json.JSONObject + +/// Barra de progresso exibida na notificação do serviço. +data class NotificationProgress( + val max: Int, + val current: Int, + val indeterminate: Boolean +) { + companion object { + fun fromJsonString(jsonString: String): NotificationProgress? { + return try { + fromJSONObject(JSONObject(jsonString)) + } catch (e: Exception) { + null + } + } + + fun fromJSONObject(jsonObj: JSONObject): NotificationProgress { + return NotificationProgress( + max = jsonObj.optInt("max", 0), + current = jsonObj.optInt("current", 0), + indeterminate = jsonObj.optBoolean("indeterminate", false) + ) + } + } +} diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundService.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundService.kt index 3e3f010c..2964cd94 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundService.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundService.kt @@ -4,6 +4,8 @@ import android.annotation.SuppressLint import android.app.* import android.content.* import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.graphics.Color import android.media.RingtoneManager import android.net.Uri @@ -13,10 +15,13 @@ import android.text.Spannable import android.text.SpannableString import android.text.style.ForegroundColorSpan import android.util.Log +import android.view.View +import android.widget.RemoteViews import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import com.pravera.flutter_foreground_task.FlutterForegroundTaskLifecycleListener +import com.pravera.flutter_foreground_task.R import com.pravera.flutter_foreground_task.RequestCode import com.pravera.flutter_foreground_task.models.* import com.pravera.flutter_foreground_task.utils.ForegroundServiceUtils @@ -24,6 +29,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlin.intArrayOf +import java.io.File import java.util.concurrent.Executors /** @@ -36,6 +42,10 @@ class ForegroundService : Service() { companion object { private val TAG = ForegroundService::class.java.simpleName + /// Teto de pixels por bitmap embarcado no RemoteViews. + /// 1024 x 256 a 4 bytes por pixel dá cerca de 1 MB. + private const val MAX_BITMAP_PIXELS = 1024 * 256 + private const val ACTION_NOTIFICATION_PRESSED = "onNotificationPressed" private const val ACTION_NOTIFICATION_DISMISSED = "onNotificationDismissed" private const val ACTION_NOTIFICATION_BUTTON_PRESSED = "onNotificationButtonPressed" @@ -386,6 +396,26 @@ class ForegroundService : Service() { builder.style = Notification.BigTextStyle() builder.setVisibility(notificationOptions.visibility) builder.setOnlyAlertOnce(notificationOptions.onlyAlertOnce) + notificationContent.subText?.let { builder.setSubText(it) } + // Com o corpo customizado a foto é desenhada por ele. Definir o + // ícone grande aqui também duplicaria a imagem e faria o sistema + // reservar a largura dele, espremendo o título. + if (!notificationContent.useCustomLayout) { + loadLargeIcon(notificationContent.largeIconPath)?.let { + builder.setLargeIcon(it) + } + } + notificationContent.progress?.let { + builder.setProgress(it.max, it.current, it.indeterminate) + } + if (notificationContent.useCustomLayout) { + // Só a expandida é customizada. A recolhida tem altura fixa e + // menor do que o corpo customizado, o que cortava a segunda + // linha no meio; deixá-la com o template padrão resolve e é o + // que o protótipo desenha de qualquer forma. + builder.style = Notification.DecoratedCustomViewStyle() + builder.setCustomBigContentView(buildCustomView(notificationContent)) + } if (iconBackgroundColor != null) { builder.setColor(iconBackgroundColor) } @@ -413,6 +443,22 @@ class ForegroundService : Service() { builder.setStyle(NotificationCompat.BigTextStyle().bigText(notificationContent.text)) builder.setVisibility(notificationOptions.visibility) builder.setOnlyAlertOnce(notificationOptions.onlyAlertOnce) + notificationContent.subText?.let { builder.setSubText(it) } + // Com o corpo customizado a foto é desenhada por ele. Definir o + // ícone grande aqui também duplicaria a imagem e faria o sistema + // reservar a largura dele, espremendo o título. + if (!notificationContent.useCustomLayout) { + loadLargeIcon(notificationContent.largeIconPath)?.let { + builder.setLargeIcon(it) + } + } + notificationContent.progress?.let { + builder.setProgress(it.max, it.current, it.indeterminate) + } + if (notificationContent.useCustomLayout) { + builder.setStyle(NotificationCompat.DecoratedCustomViewStyle()) + builder.setCustomBigContentView(buildCustomView(notificationContent)) + } if (iconBackgroundColor != null) { builder.color = iconBackgroundColor } @@ -664,6 +710,88 @@ class ForegroundService : Service() { } } + /// Monta o corpo customizado da notificação expandida. + /// + /// Só o corpo: o cabeçalho continua sendo do sistema, via + /// DecoratedCustomViewStyle. A trilha chega pronta como imagem porque o + /// RemoteViews não permite posicionar um ícone sobre uma barra. + private fun buildCustomView( + notificationContent: NotificationContent + ): RemoteViews { + val views = RemoteViews(packageName, R.layout.fft_notification_expanded) + + views.setTextViewText(R.id.fft_notification_title, notificationContent.title) + + if (notificationContent.text.isBlank()) { + views.setViewVisibility(R.id.fft_notification_text, View.GONE) + } else { + views.setTextViewText(R.id.fft_notification_text, notificationContent.text) + views.setViewVisibility(R.id.fft_notification_text, View.VISIBLE) + } + + val photo = loadLargeIcon(notificationContent.largeIconPath) + if (photo == null) { + views.setViewVisibility(R.id.fft_notification_photo, View.GONE) + } else { + views.setImageViewBitmap(R.id.fft_notification_photo, photo) + views.setViewVisibility(R.id.fft_notification_photo, View.VISIBLE) + } + + val track = loadLargeIcon(notificationContent.trackImagePath) + if (track == null) { + views.setViewVisibility(R.id.fft_notification_track, View.GONE) + } else { + views.setImageViewBitmap(R.id.fft_notification_track, track) + views.setViewVisibility(R.id.fft_notification_track, View.VISIBLE) + } + + return views + } + + /// Carrega o ícone grande a partir de um arquivo no disco. + /// + /// O arquivo é gravado pelo app, não pelo plugin. Uma falha de leitura não + /// pode derrubar a notificação: sem imagem, a notificação é exibida sem o + /// ícone grande. + private fun loadLargeIcon(path: String?): Bitmap? { + if (path.isNullOrBlank()) { + return null + } + + return try { + val file = File(path) + if (!file.exists()) { + null + } else { + BitmapFactory.decodeFile(path, sampledOptions(path)) + } + } catch (e: Exception) { + Log.e(TAG, "loadLargeIcon", e) + null + } + } + + /// Calcula a subamostragem necessária para o bitmap caber no limite. + /// + /// Um RemoteViews atravessa o Binder até a interface do sistema, e a + /// transação comporta cerca de 1 MB. Uma imagem grande o bastante faz a + /// notificação ser aceita e nunca desenhada, sem erro no log — então o + /// limite é imposto aqui, e não confiado a quem chama. + private fun sampledOptions(path: String): BitmapFactory.Options { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(path, bounds) + + var sampleSize = 1 + while ( + (bounds.outWidth / sampleSize) * (bounds.outHeight / sampleSize) > + MAX_BITMAP_PIXELS + ) { + sampleSize *= 2 + } + + return BitmapFactory.Options().apply { inSampleSize = sampleSize } + } + private fun buildNotificationActions( buttons: List, needsRebuild: Boolean = false diff --git a/android/src/main/res/layout/fft_notification_expanded.xml b/android/src/main/res/layout/fft_notification_expanded.xml new file mode 100644 index 00000000..c6d4f6a4 --- /dev/null +++ b/android/src/main/res/layout/fft_notification_expanded.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + diff --git a/lib/flutter_foreground_task.dart b/lib/flutter_foreground_task.dart index 0d29dd38..e8df7e74 100644 --- a/lib/flutter_foreground_task.dart +++ b/lib/flutter_foreground_task.dart @@ -16,6 +16,7 @@ import 'models/notification_button.dart'; import 'models/notification_icon.dart'; import 'models/notification_options.dart'; import 'models/notification_permission.dart'; +import 'models/notification_progress.dart'; import 'models/notification_vibrate_pattern.dart'; import 'models/service_request_result.dart'; import 'task_handler.dart'; @@ -34,6 +35,7 @@ export 'models/notification_icon.dart'; export 'models/notification_options.dart'; export 'models/notification_permission.dart'; export 'models/notification_priority.dart'; +export 'models/notification_progress.dart'; export 'models/notification_vibrate_pattern.dart'; export 'models/notification_visibility.dart'; export 'models/service_request_result.dart'; @@ -106,6 +108,11 @@ class FlutterForegroundTask { NotificationIcon? notificationIcon, List? notificationButtons, String? notificationInitialRoute, + String? notificationLargeIconPath, + String? notificationSubText, + NotificationProgress? notificationProgress, + bool? notificationUseCustomLayout, + String? notificationTrackImagePath, Function? callback, }) async { try { @@ -128,6 +135,11 @@ class FlutterForegroundTask { notificationIcon: notificationIcon, notificationButtons: notificationButtons, notificationInitialRoute: notificationInitialRoute, + notificationLargeIconPath: notificationLargeIconPath, + notificationSubText: notificationSubText, + notificationProgress: notificationProgress, + notificationUseCustomLayout: notificationUseCustomLayout, + notificationTrackImagePath: notificationTrackImagePath, callback: callback, ); @@ -164,6 +176,11 @@ class FlutterForegroundTask { NotificationIcon? notificationIcon, List? notificationButtons, String? notificationInitialRoute, + String? notificationLargeIconPath, + String? notificationSubText, + NotificationProgress? notificationProgress, + bool? notificationUseCustomLayout, + String? notificationTrackImagePath, Function? callback, String? notificationSound, NotificationVibratePattern? notificationVibratePattern, @@ -180,6 +197,11 @@ class FlutterForegroundTask { notificationIcon: notificationIcon, notificationButtons: notificationButtons, notificationInitialRoute: notificationInitialRoute, + notificationLargeIconPath: notificationLargeIconPath, + notificationSubText: notificationSubText, + notificationProgress: notificationProgress, + notificationUseCustomLayout: notificationUseCustomLayout, + notificationTrackImagePath: notificationTrackImagePath, callback: callback, notificationSound: notificationSound, notificationVibratePattern: notificationVibratePattern, diff --git a/lib/flutter_foreground_task_method_channel.dart b/lib/flutter_foreground_task_method_channel.dart index a06e8168..494830e8 100644 --- a/lib/flutter_foreground_task_method_channel.dart +++ b/lib/flutter_foreground_task_method_channel.dart @@ -14,6 +14,7 @@ import 'models/notification_button.dart'; import 'models/notification_icon.dart'; import 'models/notification_options.dart'; import 'models/notification_permission.dart'; +import 'models/notification_progress.dart'; import 'models/notification_vibrate_pattern.dart'; import 'models/service_options.dart'; import 'task_handler.dart'; @@ -45,6 +46,11 @@ class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform { NotificationIcon? notificationIcon, List? notificationButtons, String? notificationInitialRoute, + String? notificationLargeIconPath, + String? notificationSubText, + NotificationProgress? notificationProgress, + bool? notificationUseCustomLayout, + String? notificationTrackImagePath, Function? callback, }) async { final Map optionsJson = ServiceStartOptions( @@ -58,6 +64,11 @@ class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform { notificationIcon: notificationIcon, notificationButtons: notificationButtons, notificationInitialRoute: notificationInitialRoute, + notificationLargeIconPath: notificationLargeIconPath, + notificationSubText: notificationSubText, + notificationProgress: notificationProgress, + notificationUseCustomLayout: notificationUseCustomLayout, + notificationTrackImagePath: notificationTrackImagePath, callback: callback, ).toJson(platform); @@ -77,6 +88,11 @@ class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform { NotificationIcon? notificationIcon, List? notificationButtons, String? notificationInitialRoute, + String? notificationLargeIconPath, + String? notificationSubText, + NotificationProgress? notificationProgress, + bool? notificationUseCustomLayout, + String? notificationTrackImagePath, Function? callback, String? notificationSound, NotificationVibratePattern? notificationVibratePattern, @@ -88,6 +104,11 @@ class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform { notificationIcon: notificationIcon, notificationButtons: notificationButtons, notificationInitialRoute: notificationInitialRoute, + notificationLargeIconPath: notificationLargeIconPath, + notificationSubText: notificationSubText, + notificationProgress: notificationProgress, + notificationUseCustomLayout: notificationUseCustomLayout, + notificationTrackImagePath: notificationTrackImagePath, notificationSound: notificationSound, notificationVibratePattern: notificationVibratePattern, callback: callback, diff --git a/lib/flutter_foreground_task_platform_interface.dart b/lib/flutter_foreground_task_platform_interface.dart index e293799e..7ccb41d1 100644 --- a/lib/flutter_foreground_task_platform_interface.dart +++ b/lib/flutter_foreground_task_platform_interface.dart @@ -7,6 +7,7 @@ import 'models/notification_button.dart'; import 'models/notification_icon.dart'; import 'models/notification_options.dart'; import 'models/notification_permission.dart'; +import 'models/notification_progress.dart'; import 'models/notification_vibrate_pattern.dart'; import 'task_handler.dart'; @@ -45,6 +46,11 @@ abstract class FlutterForegroundTaskPlatform extends PlatformInterface { NotificationIcon? notificationIcon, List? notificationButtons, String? notificationInitialRoute, + String? notificationLargeIconPath, + String? notificationSubText, + NotificationProgress? notificationProgress, + bool? notificationUseCustomLayout, + String? notificationTrackImagePath, Function? callback, }) { throw UnimplementedError('startService() has not been implemented.'); @@ -61,6 +67,11 @@ abstract class FlutterForegroundTaskPlatform extends PlatformInterface { NotificationIcon? notificationIcon, List? notificationButtons, String? notificationInitialRoute, + String? notificationLargeIconPath, + String? notificationSubText, + NotificationProgress? notificationProgress, + bool? notificationUseCustomLayout, + String? notificationTrackImagePath, Function? callback, String? notificationSound, NotificationVibratePattern? notificationVibratePattern, diff --git a/lib/models/notification_progress.dart b/lib/models/notification_progress.dart new file mode 100644 index 00000000..3832b977 --- /dev/null +++ b/lib/models/notification_progress.dart @@ -0,0 +1,39 @@ +/// Barra de progresso exibida na notificação do serviço em primeiro plano. +/// +/// Só tem efeito no Android. No iOS o valor é ignorado. +class NotificationProgress { + /// Constrói uma barra de progresso determinada, de [current] sobre [max]. + const NotificationProgress({required this.max, required this.current}) + : indeterminate = false; + + /// Constrói uma barra de progresso indeterminada, para quando não há como + /// medir o avanço. + const NotificationProgress.indeterminate() + : max = 0, + current = 0, + indeterminate = true; + + /// Valor máximo da barra. + final int max; + + /// Valor atual da barra. + final int current; + + /// Indica se a barra deve ser exibida sem um valor definido. + final bool indeterminate; + + /// Retorna os campos de [NotificationProgress] em formato JSON. + Map toJson() { + return {'max': max, 'current': current, 'indeterminate': indeterminate}; + } + + @override + bool operator ==(Object other) => + other is NotificationProgress && + other.max == max && + other.current == current && + other.indeterminate == indeterminate; + + @override + int get hashCode => Object.hash(max, current, indeterminate); +} diff --git a/lib/models/service_options.dart b/lib/models/service_options.dart index 84be11e2..9d157c52 100644 --- a/lib/models/service_options.dart +++ b/lib/models/service_options.dart @@ -7,6 +7,7 @@ import 'foreground_task_options.dart'; import 'notification_button.dart'; import 'notification_icon.dart'; import 'notification_options.dart'; +import 'notification_progress.dart'; import 'notification_vibrate_pattern.dart'; class ServiceStartOptions { @@ -21,6 +22,11 @@ class ServiceStartOptions { this.notificationIcon, this.notificationButtons, this.notificationInitialRoute, + this.notificationLargeIconPath, + this.notificationSubText, + this.notificationProgress, + this.notificationUseCustomLayout, + this.notificationTrackImagePath, this.callback, }); @@ -34,6 +40,27 @@ class ServiceStartOptions { final NotificationIcon? notificationIcon; final List? notificationButtons; final String? notificationInitialRoute; + + /// Caminho de um arquivo de imagem exibido como ícone grande. Só Android. + final String? notificationLargeIconPath; + + /// Texto exibido ao lado do nome do app no cabeçalho. Só Android. + final String? notificationSubText; + + /// Barra de progresso da notificação. Só Android. + final NotificationProgress? notificationProgress; + + /// Usa um corpo customizado na notificação, com título maior, foto e trilha. + /// + /// O cabeçalho continua sendo do sistema. Só Android. + final bool? notificationUseCustomLayout; + + /// Caminho da imagem da trilha da rota, exibida no corpo customizado. + /// + /// A imagem chega pronta porque o Android não permite posicionar um ícone + /// sobre uma barra de progresso. Só Android. + final String? notificationTrackImagePath; + final Function? callback; Map toJson(Platform platform) { @@ -46,6 +73,11 @@ class ServiceStartOptions { 'icon': notificationIcon?.toJson(), 'buttons': notificationButtons?.map((e) => e.toJson()).toList(), 'initialRoute': notificationInitialRoute, + 'notificationLargeIconPath': notificationLargeIconPath, + 'notificationSubText': notificationSubText, + 'notificationProgress': notificationProgress?.toJson(), + 'notificationUseCustomLayout': notificationUseCustomLayout, + 'notificationTrackImagePath': notificationTrackImagePath, }; if (platform.isAndroid) { @@ -71,6 +103,11 @@ class ServiceUpdateOptions { this.notificationIcon, this.notificationButtons, this.notificationInitialRoute, + this.notificationLargeIconPath, + this.notificationSubText, + this.notificationProgress, + this.notificationUseCustomLayout, + this.notificationTrackImagePath, this.callback, this.notificationSound, this.notificationVibratePattern, @@ -82,6 +119,31 @@ class ServiceUpdateOptions { final NotificationIcon? notificationIcon; final List? notificationButtons; final String? notificationInitialRoute; + + /// Caminho de um arquivo de imagem exibido como ícone grande. Só Android. + /// + /// Nulo remove o ícone: a atualização define a notificação exatamente como + /// descrita, sem herdar o que estava antes. + final String? notificationLargeIconPath; + + /// Texto exibido ao lado do nome do app no cabeçalho. Só Android. Nulo + /// remove. + final String? notificationSubText; + + /// Barra de progresso da notificação. Só Android. Nulo remove. + final NotificationProgress? notificationProgress; + + /// Usa um corpo customizado na notificação, com título maior, foto e trilha. + /// + /// O cabeçalho continua sendo do sistema. Só Android. + final bool? notificationUseCustomLayout; + + /// Caminho da imagem da trilha da rota, exibida no corpo customizado. + /// + /// A imagem chega pronta porque o Android não permite posicionar um ícone + /// sobre uma barra de progresso. Só Android. + final String? notificationTrackImagePath; + final Function? callback; final String? notificationSound; final NotificationVibratePattern? notificationVibratePattern; @@ -93,6 +155,11 @@ class ServiceUpdateOptions { 'icon': notificationIcon?.toJson(), 'buttons': notificationButtons?.map((e) => e.toJson()).toList(), 'initialRoute': notificationInitialRoute, + 'notificationLargeIconPath': notificationLargeIconPath, + 'notificationSubText': notificationSubText, + 'notificationProgress': notificationProgress?.toJson(), + 'notificationUseCustomLayout': notificationUseCustomLayout, + 'notificationTrackImagePath': notificationTrackImagePath, 'notificationSound': notificationSound, 'notificationVibratePattern': notificationVibratePattern?.name, }; From b7079378283f6b394731a20cdb064205c55fecf7 Mon Sep 17 00:00:00 2001 From: Hendrik Kees Koppe Date: Wed, 7 Oct 2026 11:22:41 -0300 Subject: [PATCH 2/7] feat: adicionar layout ongoing notificatiojn android --- .../flutter_foreground_task/PreferencesKey.kt | 2 + .../models/NotificationContent.kt | 20 +++ .../service/ForegroundService.kt | 33 +++++ .../res/layout/fft_notification_expanded.xml | 135 +++++++++--------- lib/flutter_foreground_task.dart | 8 ++ ...lutter_foreground_task_method_channel.dart | 8 ++ ...er_foreground_task_platform_interface.dart | 4 + lib/models/service_options.dart | 24 ++++ 8 files changed, 170 insertions(+), 64 deletions(-) diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/PreferencesKey.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/PreferencesKey.kt index afe42f40..e00672ea 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/PreferencesKey.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/PreferencesKey.kt @@ -45,6 +45,8 @@ object PreferencesKey { const val NOTIFICATION_SUB_TEXT = "notificationSubText" const val NOTIFICATION_USE_CUSTOM_LAYOUT = "notificationUseCustomLayout" const val NOTIFICATION_TRACK_IMAGE_PATH = "notificationTrackImagePath" + const val NOTIFICATION_TEXT_ICON_NAME = "notificationTextIconName" + const val NOTIFICATION_EMPHASIS_TEXT = "notificationEmphasisText" const val NOTIFICATION_PROGRESS = "notificationProgress" const val NOTIFICATION_INITIAL_ROUTE = "initialRoute" const val NOTIFICATION_SOUND = "notificationSound" diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationContent.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationContent.kt index 66c32b05..4572e47d 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationContent.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationContent.kt @@ -17,6 +17,8 @@ data class NotificationContent( val subText: String?, val useCustomLayout: Boolean, val trackImagePath: String?, + val textIconName: String?, + val emphasisText: String?, val progress: NotificationProgress? ) { companion object { @@ -57,6 +59,10 @@ data class NotificationContent( val trackImagePath = prefs.getString(PrefsKey.NOTIFICATION_TRACK_IMAGE_PATH, null) + val textIconName = prefs.getString(PrefsKey.NOTIFICATION_TEXT_ICON_NAME, null) + + val emphasisText = prefs.getString(PrefsKey.NOTIFICATION_EMPHASIS_TEXT, null) + val progressJsonString = prefs.getString(PrefsKey.NOTIFICATION_PROGRESS, null) val progress = progressJsonString?.let(NotificationProgress::fromJsonString) @@ -72,6 +78,8 @@ data class NotificationContent( subText = subText, useCustomLayout = useCustomLayout, trackImagePath = trackImagePath, + textIconName = textIconName, + emphasisText = emphasisText, progress = progress ) } @@ -109,6 +117,10 @@ data class NotificationContent( val trackImagePath = map?.get(PrefsKey.NOTIFICATION_TRACK_IMAGE_PATH) as? String + val textIconName = map?.get(PrefsKey.NOTIFICATION_TEXT_ICON_NAME) as? String + + val emphasisText = map?.get(PrefsKey.NOTIFICATION_EMPHASIS_TEXT) as? String + val progressJson = map?.get(PrefsKey.NOTIFICATION_PROGRESS) as? Map<*, *> val progressJsonString = progressJson?.let { JSONObject(it).toString() } @@ -126,6 +138,8 @@ data class NotificationContent( putString(PrefsKey.NOTIFICATION_SUB_TEXT, subText) putBoolean(PrefsKey.NOTIFICATION_USE_CUSTOM_LAYOUT, useCustomLayout) putString(PrefsKey.NOTIFICATION_TRACK_IMAGE_PATH, trackImagePath) + putString(PrefsKey.NOTIFICATION_TEXT_ICON_NAME, textIconName) + putString(PrefsKey.NOTIFICATION_EMPHASIS_TEXT, emphasisText) putString(PrefsKey.NOTIFICATION_PROGRESS, progressJsonString) commit() } @@ -164,6 +178,10 @@ data class NotificationContent( val trackImagePath = map?.get(PrefsKey.NOTIFICATION_TRACK_IMAGE_PATH) as? String + val textIconName = map?.get(PrefsKey.NOTIFICATION_TEXT_ICON_NAME) as? String + + val emphasisText = map?.get(PrefsKey.NOTIFICATION_EMPHASIS_TEXT) as? String + val progressJson = map?.get(PrefsKey.NOTIFICATION_PROGRESS) as? Map<*, *> val progressJsonString = progressJson?.let { JSONObject(it).toString() } @@ -181,6 +199,8 @@ data class NotificationContent( putString(PrefsKey.NOTIFICATION_SUB_TEXT, subText) putBoolean(PrefsKey.NOTIFICATION_USE_CUSTOM_LAYOUT, useCustomLayout) putString(PrefsKey.NOTIFICATION_TRACK_IMAGE_PATH, trackImagePath) + putString(PrefsKey.NOTIFICATION_TEXT_ICON_NAME, textIconName) + putString(PrefsKey.NOTIFICATION_EMPHASIS_TEXT, emphasisText) putString(PrefsKey.NOTIFICATION_PROGRESS, progressJsonString) commit() } diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundService.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundService.kt index 2964cd94..31436371 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundService.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundService.kt @@ -15,6 +15,7 @@ import android.text.Spannable import android.text.SpannableString import android.text.style.ForegroundColorSpan import android.util.Log +import android.util.TypedValue import android.view.View import android.widget.RemoteViews import androidx.annotation.RequiresApi @@ -722,6 +723,20 @@ class ForegroundService : Service() { views.setTextViewText(R.id.fft_notification_title, notificationContent.title) + val emphasis = notificationContent.emphasisText + if (emphasis.isNullOrBlank()) { + views.setViewVisibility(R.id.fft_notification_emphasis, View.GONE) + views.setTextViewTextSize( + R.id.fft_notification_title, TypedValue.COMPLEX_UNIT_SP, 22f + ) + } else { + views.setTextViewText(R.id.fft_notification_emphasis, emphasis) + views.setViewVisibility(R.id.fft_notification_emphasis, View.VISIBLE) + views.setTextViewTextSize( + R.id.fft_notification_title, TypedValue.COMPLEX_UNIT_SP, 16f + ) + } + if (notificationContent.text.isBlank()) { views.setViewVisibility(R.id.fft_notification_text, View.GONE) } else { @@ -729,6 +744,14 @@ class ForegroundService : Service() { views.setViewVisibility(R.id.fft_notification_text, View.VISIBLE) } + val textIconResId = resolveDrawableResId(notificationContent.textIconName) + if (textIconResId == 0) { + views.setViewVisibility(R.id.fft_notification_text_icon, View.GONE) + } else { + views.setImageViewResource(R.id.fft_notification_text_icon, textIconResId) + views.setViewVisibility(R.id.fft_notification_text_icon, View.VISIBLE) + } + val photo = loadLargeIcon(notificationContent.largeIconPath) if (photo == null) { views.setViewVisibility(R.id.fft_notification_photo, View.GONE) @@ -748,6 +771,16 @@ class ForegroundService : Service() { return views } + /// Resolve um drawable do app pelo nome. Retorna 0 quando não existe, que é + /// o valor que o Android usa para "nenhum resource". + private fun resolveDrawableResId(name: String?): Int { + if (name.isNullOrBlank()) { + return 0 + } + + return resources.getIdentifier(name, "drawable", packageName) + } + /// Carrega o ícone grande a partir de um arquivo no disco. /// /// O arquivo é gravado pelo app, não pelo plugin. Uma falha de leitura não diff --git a/android/src/main/res/layout/fft_notification_expanded.xml b/android/src/main/res/layout/fft_notification_expanded.xml index c6d4f6a4..17b535c4 100644 --- a/android/src/main/res/layout/fft_notification_expanded.xml +++ b/android/src/main/res/layout/fft_notification_expanded.xml @@ -1,79 +1,86 @@ - + + + android:orientation="horizontal" + android:gravity="center_vertical"> + + - - + android:textAppearance="@style/TextAppearance.Compat.Notification.Title" + android:textSize="22sp" + android:textStyle="bold" + android:maxLines="2" + android:ellipsize="end" /> - + - - + + android:id="@+id/fft_notification_text_icon" + android:layout_width="15dp" + android:layout_height="15dp" + android:layout_marginEnd="6dp" + android:layout_marginTop="3dp" + android:scaleType="fitCenter" + android:contentDescription="@null" /> + + + - - + android:id="@+id/fft_notification_photo" + android:layout_width="52dp" + android:layout_height="52dp" + android:layout_marginStart="12dp" + android:scaleType="fitCenter" + android:adjustViewBounds="true" + android:contentDescription="@null" /> + + + + \ No newline at end of file diff --git a/lib/flutter_foreground_task.dart b/lib/flutter_foreground_task.dart index e8df7e74..d3fef559 100644 --- a/lib/flutter_foreground_task.dart +++ b/lib/flutter_foreground_task.dart @@ -113,6 +113,8 @@ class FlutterForegroundTask { NotificationProgress? notificationProgress, bool? notificationUseCustomLayout, String? notificationTrackImagePath, + String? notificationTextIconName, + String? notificationEmphasisText, Function? callback, }) async { try { @@ -140,6 +142,8 @@ class FlutterForegroundTask { notificationProgress: notificationProgress, notificationUseCustomLayout: notificationUseCustomLayout, notificationTrackImagePath: notificationTrackImagePath, + notificationTextIconName: notificationTextIconName, + notificationEmphasisText: notificationEmphasisText, callback: callback, ); @@ -181,6 +185,8 @@ class FlutterForegroundTask { NotificationProgress? notificationProgress, bool? notificationUseCustomLayout, String? notificationTrackImagePath, + String? notificationTextIconName, + String? notificationEmphasisText, Function? callback, String? notificationSound, NotificationVibratePattern? notificationVibratePattern, @@ -202,6 +208,8 @@ class FlutterForegroundTask { notificationProgress: notificationProgress, notificationUseCustomLayout: notificationUseCustomLayout, notificationTrackImagePath: notificationTrackImagePath, + notificationTextIconName: notificationTextIconName, + notificationEmphasisText: notificationEmphasisText, callback: callback, notificationSound: notificationSound, notificationVibratePattern: notificationVibratePattern, diff --git a/lib/flutter_foreground_task_method_channel.dart b/lib/flutter_foreground_task_method_channel.dart index 494830e8..f26b74a9 100644 --- a/lib/flutter_foreground_task_method_channel.dart +++ b/lib/flutter_foreground_task_method_channel.dart @@ -51,6 +51,8 @@ class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform { NotificationProgress? notificationProgress, bool? notificationUseCustomLayout, String? notificationTrackImagePath, + String? notificationTextIconName, + String? notificationEmphasisText, Function? callback, }) async { final Map optionsJson = ServiceStartOptions( @@ -69,6 +71,8 @@ class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform { notificationProgress: notificationProgress, notificationUseCustomLayout: notificationUseCustomLayout, notificationTrackImagePath: notificationTrackImagePath, + notificationTextIconName: notificationTextIconName, + notificationEmphasisText: notificationEmphasisText, callback: callback, ).toJson(platform); @@ -93,6 +97,8 @@ class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform { NotificationProgress? notificationProgress, bool? notificationUseCustomLayout, String? notificationTrackImagePath, + String? notificationTextIconName, + String? notificationEmphasisText, Function? callback, String? notificationSound, NotificationVibratePattern? notificationVibratePattern, @@ -109,6 +115,8 @@ class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform { notificationProgress: notificationProgress, notificationUseCustomLayout: notificationUseCustomLayout, notificationTrackImagePath: notificationTrackImagePath, + notificationTextIconName: notificationTextIconName, + notificationEmphasisText: notificationEmphasisText, notificationSound: notificationSound, notificationVibratePattern: notificationVibratePattern, callback: callback, diff --git a/lib/flutter_foreground_task_platform_interface.dart b/lib/flutter_foreground_task_platform_interface.dart index 7ccb41d1..304ffff9 100644 --- a/lib/flutter_foreground_task_platform_interface.dart +++ b/lib/flutter_foreground_task_platform_interface.dart @@ -51,6 +51,8 @@ abstract class FlutterForegroundTaskPlatform extends PlatformInterface { NotificationProgress? notificationProgress, bool? notificationUseCustomLayout, String? notificationTrackImagePath, + String? notificationTextIconName, + String? notificationEmphasisText, Function? callback, }) { throw UnimplementedError('startService() has not been implemented.'); @@ -72,6 +74,8 @@ abstract class FlutterForegroundTaskPlatform extends PlatformInterface { NotificationProgress? notificationProgress, bool? notificationUseCustomLayout, String? notificationTrackImagePath, + String? notificationTextIconName, + String? notificationEmphasisText, Function? callback, String? notificationSound, NotificationVibratePattern? notificationVibratePattern, diff --git a/lib/models/service_options.dart b/lib/models/service_options.dart index 9d157c52..f8dbeccc 100644 --- a/lib/models/service_options.dart +++ b/lib/models/service_options.dart @@ -27,6 +27,8 @@ class ServiceStartOptions { this.notificationProgress, this.notificationUseCustomLayout, this.notificationTrackImagePath, + this.notificationTextIconName, + this.notificationEmphasisText, this.callback, }); @@ -61,6 +63,14 @@ class ServiceStartOptions { /// sobre uma barra de progresso. Só Android. final String? notificationTrackImagePath; + /// Nome de um drawable do app exibido à esquerda do corpo. + /// + /// Vetor, para acompanhar o tema da notificação. Só Android. + final String? notificationTextIconName; + + /// Linha exibida com mais destaque que o título. Só Android. + final String? notificationEmphasisText; + final Function? callback; Map toJson(Platform platform) { @@ -78,6 +88,8 @@ class ServiceStartOptions { 'notificationProgress': notificationProgress?.toJson(), 'notificationUseCustomLayout': notificationUseCustomLayout, 'notificationTrackImagePath': notificationTrackImagePath, + 'notificationTextIconName': notificationTextIconName, + 'notificationEmphasisText': notificationEmphasisText, }; if (platform.isAndroid) { @@ -108,6 +120,8 @@ class ServiceUpdateOptions { this.notificationProgress, this.notificationUseCustomLayout, this.notificationTrackImagePath, + this.notificationTextIconName, + this.notificationEmphasisText, this.callback, this.notificationSound, this.notificationVibratePattern, @@ -144,6 +158,14 @@ class ServiceUpdateOptions { /// sobre uma barra de progresso. Só Android. final String? notificationTrackImagePath; + /// Nome de um drawable do app exibido à esquerda do corpo. + /// + /// Vetor, para acompanhar o tema da notificação. Só Android. + final String? notificationTextIconName; + + /// Linha exibida com mais destaque que o título. Só Android. + final String? notificationEmphasisText; + final Function? callback; final String? notificationSound; final NotificationVibratePattern? notificationVibratePattern; @@ -160,6 +182,8 @@ class ServiceUpdateOptions { 'notificationProgress': notificationProgress?.toJson(), 'notificationUseCustomLayout': notificationUseCustomLayout, 'notificationTrackImagePath': notificationTrackImagePath, + 'notificationTextIconName': notificationTextIconName, + 'notificationEmphasisText': notificationEmphasisText, 'notificationSound': notificationSound, 'notificationVibratePattern': notificationVibratePattern?.name, }; From 58b75c487f2dcfeba71f503f567c89a101ee3c0b Mon Sep 17 00:00:00 2001 From: Hendrik Kees Koppe Date: Wed, 7 Oct 2026 11:37:27 -0300 Subject: [PATCH 3/7] =?UTF-8?q?doc:=20removendo=20coment=C3=A1rio=20desnec?= =?UTF-8?q?ess=C3=A1rio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../flutter_foreground_task/models/NotificationContent.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationContent.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationContent.kt index 4572e47d..121e112e 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationContent.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationContent.kt @@ -132,8 +132,6 @@ data class NotificationContent( putString(PrefsKey.NOTIFICATION_INITIAL_ROUTE, initialRoute) putString(PrefsKey.NOTIFICATION_SOUND, sound) putString(PrefsKey.NOTIFICATION_VIBRATE_PATTERN, vibratePattern) - // Estes três definem a notificação exatamente como descrita: - // ausente significa remover, e não manter o valor anterior. putString(PrefsKey.NOTIFICATION_LARGE_ICON_PATH, largeIconPath) putString(PrefsKey.NOTIFICATION_SUB_TEXT, subText) putBoolean(PrefsKey.NOTIFICATION_USE_CUSTOM_LAYOUT, useCustomLayout) @@ -193,8 +191,6 @@ data class NotificationContent( initialRoute?.let { putString(PrefsKey.NOTIFICATION_INITIAL_ROUTE, it) } putString(PrefsKey.NOTIFICATION_SOUND, sound) putString(PrefsKey.NOTIFICATION_VIBRATE_PATTERN, vibratePattern) - // Estes três definem a notificação exatamente como descrita: - // ausente significa remover, e não manter o valor anterior. putString(PrefsKey.NOTIFICATION_LARGE_ICON_PATH, largeIconPath) putString(PrefsKey.NOTIFICATION_SUB_TEXT, subText) putBoolean(PrefsKey.NOTIFICATION_USE_CUSTOM_LAYOUT, useCustomLayout) From afa3c7e22d4296aacacfdae92fcf0ee34ce73468 Mon Sep 17 00:00:00 2001 From: Hendrik Kees Koppe Date: Wed, 7 Oct 2026 11:39:43 -0300 Subject: [PATCH 4/7] =?UTF-8?q?ref:=20espa=C3=A7amento?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/flutter_foreground_task.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/flutter_foreground_task.dart b/lib/flutter_foreground_task.dart index d3fef559..baf8d695 100644 --- a/lib/flutter_foreground_task.dart +++ b/lib/flutter_foreground_task.dart @@ -143,7 +143,7 @@ class FlutterForegroundTask { notificationUseCustomLayout: notificationUseCustomLayout, notificationTrackImagePath: notificationTrackImagePath, notificationTextIconName: notificationTextIconName, - notificationEmphasisText: notificationEmphasisText, + notificationEmphasisText: notificationEmphasisText, callback: callback, ); @@ -209,7 +209,7 @@ class FlutterForegroundTask { notificationUseCustomLayout: notificationUseCustomLayout, notificationTrackImagePath: notificationTrackImagePath, notificationTextIconName: notificationTextIconName, - notificationEmphasisText: notificationEmphasisText, + notificationEmphasisText: notificationEmphasisText, callback: callback, notificationSound: notificationSound, notificationVibratePattern: notificationVibratePattern, From 97626f9db95db2480e059d0d50c4d5a80b8f4105 Mon Sep 17 00:00:00 2001 From: Hendrik Kees Koppe Date: Wed, 7 Oct 2026 11:41:36 -0300 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20ajustes=20na=20obten=C3=A7=C3=A3o=20?= =?UTF-8?q?da=20imagem=20da=20notifiica=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ForegroundService.kt | 110 ++++++++++++++---- 1 file changed, 89 insertions(+), 21 deletions(-) diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundService.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundService.kt index 31436371..42dbd820 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundService.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundService.kt @@ -15,6 +15,7 @@ import android.text.Spannable import android.text.SpannableString import android.text.style.ForegroundColorSpan import android.util.Log +import android.util.LruCache import android.util.TypedValue import android.view.View import android.widget.RemoteViews @@ -43,9 +44,14 @@ class ForegroundService : Service() { companion object { private val TAG = ForegroundService::class.java.simpleName - /// Teto de pixels por bitmap embarcado no RemoteViews. - /// 1024 x 256 a 4 bytes por pixel dá cerca de 1 MB. - private const val MAX_BITMAP_PIXELS = 1024 * 256 + /// Limites, em pixels, de cada bitmap embarcado na notificação. + private const val PHOTO_MAX_SIZE = 256 + private const val TRACK_MAX_WIDTH = 1080 + private const val TRACK_MAX_HEIGHT = 128 + + /// Folga para a foto e a trilha no limite, com espaço para a troca de + /// versão de um arquivo. + private const val BITMAP_CACHE_BYTES = 2 * 1024 * 1024 private const val ACTION_NOTIFICATION_PRESSED = "onNotificationPressed" private const val ACTION_NOTIFICATION_DISMISSED = "onNotificationDismissed" @@ -111,6 +117,13 @@ class ForegroundService : Service() { // Executor para tocar sons em thread separada private val soundExecutor = Executors.newSingleThreadExecutor() + // Bitmaps já decodificados, por arquivo e limite. + private val bitmapCache = object : LruCache(BITMAP_CACHE_BYTES) { + override fun sizeOf(key: String, value: CachedBitmap): Int { + return value.bitmap.byteCount + } + } + // A broadcast receiver that handles intents that occur in the foreground service. private var broadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { @@ -206,6 +219,7 @@ class ForegroundService : Service() { override fun onDestroy() { super.onDestroy() soundExecutor.shutdown() + bitmapCache.evictAll() val isTimeout = this.isTimeout destroyForegroundTask(isTimeout) stopForegroundService(false) @@ -402,7 +416,7 @@ class ForegroundService : Service() { // ícone grande aqui também duplicaria a imagem e faria o sistema // reservar a largura dele, espremendo o título. if (!notificationContent.useCustomLayout) { - loadLargeIcon(notificationContent.largeIconPath)?.let { + loadPhoto(notificationContent.largeIconPath)?.let { builder.setLargeIcon(it) } } @@ -449,7 +463,7 @@ class ForegroundService : Service() { // ícone grande aqui também duplicaria a imagem e faria o sistema // reservar a largura dele, espremendo o título. if (!notificationContent.useCustomLayout) { - loadLargeIcon(notificationContent.largeIconPath)?.let { + loadPhoto(notificationContent.largeIconPath)?.let { builder.setLargeIcon(it) } } @@ -752,7 +766,7 @@ class ForegroundService : Service() { views.setViewVisibility(R.id.fft_notification_text_icon, View.VISIBLE) } - val photo = loadLargeIcon(notificationContent.largeIconPath) + val photo = loadPhoto(notificationContent.largeIconPath) if (photo == null) { views.setViewVisibility(R.id.fft_notification_photo, View.GONE) } else { @@ -760,7 +774,9 @@ class ForegroundService : Service() { views.setViewVisibility(R.id.fft_notification_photo, View.VISIBLE) } - val track = loadLargeIcon(notificationContent.trackImagePath) + val track = loadBitmap( + notificationContent.trackImagePath, TRACK_MAX_WIDTH, TRACK_MAX_HEIGHT + ) if (track == null) { views.setViewVisibility(R.id.fft_notification_track, View.GONE) } else { @@ -781,12 +797,17 @@ class ForegroundService : Service() { return resources.getIdentifier(name, "drawable", packageName) } - /// Carrega o ícone grande a partir de um arquivo no disco. + /// Carrega a foto, usada tanto como ícone grande quanto no corpo customizado. + private fun loadPhoto(path: String?): Bitmap? { + return loadBitmap(path, PHOTO_MAX_SIZE, PHOTO_MAX_SIZE) + } + + /// Carrega uma imagem do disco reduzida para caber em maxWidth x maxHeight. /// /// O arquivo é gravado pelo app, não pelo plugin. Uma falha de leitura não /// pode derrubar a notificação: sem imagem, a notificação é exibida sem o /// ícone grande. - private fun loadLargeIcon(path: String?): Bitmap? { + private fun loadBitmap(path: String?, maxWidth: Int, maxHeight: Int): Bitmap? { if (path.isNullOrBlank()) { return null } @@ -796,28 +817,49 @@ class ForegroundService : Service() { if (!file.exists()) { null } else { - BitmapFactory.decodeFile(path, sampledOptions(path)) + val key = "$path|$maxWidth|$maxHeight" + val lastModified = file.lastModified() + val length = file.length() + val cached = bitmapCache.get(key) + if (cached != null && + cached.lastModified == lastModified && + cached.length == length + ) { + return cached.bitmap + } + + val options = sampledOptions(path, maxWidth, maxHeight) + val bitmap = BitmapFactory.decodeFile(path, options) + ?.let { scaleToFit(it, maxWidth, maxHeight) } + if (bitmap == null) { + bitmapCache.remove(key) + } else { + bitmapCache.put(key, CachedBitmap(lastModified, length, bitmap)) + } + bitmap } } catch (e: Exception) { - Log.e(TAG, "loadLargeIcon", e) + Log.e(TAG, "loadBitmap", e) null } } - /// Calcula a subamostragem necessária para o bitmap caber no limite. - /// - /// Um RemoteViews atravessa o Binder até a interface do sistema, e a - /// transação comporta cerca de 1 MB. Uma imagem grande o bastante faz a - /// notificação ser aceita e nunca desenhada, sem erro no log — então o - /// limite é imposto aqui, e não confiado a quem chama. - private fun sampledOptions(path: String): BitmapFactory.Options { + /// Calcula a maior subamostragem que ainda deixa o bitmap maior ou igual ao + /// tamanho final, para o decode ficar barato sem perder nitidez. O ajuste + /// fino até o limite fica com scaleToFit, já que inSampleSize só anda em + /// potências de 2. + private fun sampledOptions( + path: String, + maxWidth: Int, + maxHeight: Int + ): BitmapFactory.Options { val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeFile(path, bounds) var sampleSize = 1 while ( - (bounds.outWidth / sampleSize) * (bounds.outHeight / sampleSize) > - MAX_BITMAP_PIXELS + bounds.outWidth / (sampleSize * 2) >= maxWidth || + bounds.outHeight / (sampleSize * 2) >= maxHeight ) { sampleSize *= 2 } @@ -825,6 +867,25 @@ class ForegroundService : Service() { return BitmapFactory.Options().apply { inSampleSize = sampleSize } } + /// Reduz o bitmap, mantendo a proporção, até caber em maxWidth x maxHeight. + private fun scaleToFit(bitmap: Bitmap, maxWidth: Int, maxHeight: Int): Bitmap { + val scale = minOf( + maxWidth.toFloat() / bitmap.width, + maxHeight.toFloat() / bitmap.height + ) + if (scale >= 1f) { + return bitmap + } + + val width = (bitmap.width * scale).toInt().coerceAtLeast(1) + val height = (bitmap.height * scale).toInt().coerceAtLeast(1) + val scaled = Bitmap.createScaledBitmap(bitmap, width, height, true) + if (scaled !== bitmap) { + bitmap.recycle() + } + return scaled + } + private fun buildNotificationActions( buttons: List, needsRebuild: Boolean = false @@ -878,4 +939,11 @@ class ForegroundService : Service() { return actions } -} \ No newline at end of file +} + +/// Bitmap em cache junto com a versão do arquivo de onde veio. +private class CachedBitmap( + val lastModified: Long, + val length: Long, + val bitmap: Bitmap +) \ No newline at end of file From f47158ff578bedee2c68e72149757890cd2c3688 Mon Sep 17 00:00:00 2001 From: Hendrik Kees Koppe Date: Fri, 9 Oct 2026 11:51:22 -0300 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20ajuste=20de=20design=20no=20textSize?= =?UTF-8?q?=20da=20notifica=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/src/main/res/layout/fft_notification_expanded.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/src/main/res/layout/fft_notification_expanded.xml b/android/src/main/res/layout/fft_notification_expanded.xml index 17b535c4..a87d6ef8 100644 --- a/android/src/main/res/layout/fft_notification_expanded.xml +++ b/android/src/main/res/layout/fft_notification_expanded.xml @@ -35,7 +35,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:textAppearance="@style/TextAppearance.Compat.Notification.Title" - android:textSize="30sp" + android:textSize="24sp" android:textStyle="bold" android:maxLines="1" android:ellipsize="end" /> From d65a34675a48c3a21bc39c36690f70af4ed62c5b Mon Sep 17 00:00:00 2001 From: Hendrik Kees Koppe Date: Tue, 13 Oct 2026 11:38:31 -0300 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20melhoria=20para=20evitar=20troca=20n?= =?UTF-8?q?o=20estilo=20da=20notifica=C3=A7=C3=A3o=20indevida?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../models/NotificationContent.kt | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationContent.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationContent.kt index 121e112e..9eb96eef 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationContent.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/models/NotificationContent.kt @@ -172,7 +172,7 @@ data class NotificationContent( val subText = map?.get(PrefsKey.NOTIFICATION_SUB_TEXT) as? String - val useCustomLayout = map?.get(PrefsKey.NOTIFICATION_USE_CUSTOM_LAYOUT) as? Boolean ?: false + val useCustomLayout = map?.get(PrefsKey.NOTIFICATION_USE_CUSTOM_LAYOUT) as? Boolean val trackImagePath = map?.get(PrefsKey.NOTIFICATION_TRACK_IMAGE_PATH) as? String @@ -189,15 +189,29 @@ data class NotificationContent( iconJsonString?.let { putString(PrefsKey.NOTIFICATION_CONTENT_ICON, it) } buttonsJsonString?.let { putString(PrefsKey.NOTIFICATION_CONTENT_BUTTONS, it) } initialRoute?.let { putString(PrefsKey.NOTIFICATION_INITIAL_ROUTE, it) } - putString(PrefsKey.NOTIFICATION_SOUND, sound) - putString(PrefsKey.NOTIFICATION_VIBRATE_PATTERN, vibratePattern) - putString(PrefsKey.NOTIFICATION_LARGE_ICON_PATH, largeIconPath) - putString(PrefsKey.NOTIFICATION_SUB_TEXT, subText) - putBoolean(PrefsKey.NOTIFICATION_USE_CUSTOM_LAYOUT, useCustomLayout) - putString(PrefsKey.NOTIFICATION_TRACK_IMAGE_PATH, trackImagePath) - putString(PrefsKey.NOTIFICATION_TEXT_ICON_NAME, textIconName) - putString(PrefsKey.NOTIFICATION_EMPHASIS_TEXT, emphasisText) - putString(PrefsKey.NOTIFICATION_PROGRESS, progressJsonString) + sound?.let { putString(PrefsKey.NOTIFICATION_SOUND, it) } + vibratePattern?.let { + putString(PrefsKey.NOTIFICATION_VIBRATE_PATTERN, it) + } + largeIconPath?.let { + putString(PrefsKey.NOTIFICATION_LARGE_ICON_PATH, it) + } + subText?.let { putString(PrefsKey.NOTIFICATION_SUB_TEXT, it) } + useCustomLayout?.let { + putBoolean(PrefsKey.NOTIFICATION_USE_CUSTOM_LAYOUT, it) + } + trackImagePath?.let { + putString(PrefsKey.NOTIFICATION_TRACK_IMAGE_PATH, it) + } + textIconName?.let { + putString(PrefsKey.NOTIFICATION_TEXT_ICON_NAME, it) + } + emphasisText?.let { + putString(PrefsKey.NOTIFICATION_EMPHASIS_TEXT, it) + } + progressJsonString?.let { + putString(PrefsKey.NOTIFICATION_PROGRESS, it) + } commit() } }