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..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 @@ -41,6 +41,13 @@ 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_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" 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..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 @@ -12,7 +12,14 @@ 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 textIconName: String?, + val emphasisText: String?, + val progress: NotificationProgress? ) { companion object { fun getData(context: Context): NotificationContent { @@ -44,6 +51,21 @@ 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 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) + return NotificationContent( title = title, text = text, @@ -51,7 +73,14 @@ data class NotificationContent( buttons = buttons, initialRoute = initialRoute, sound = sound, - vibratePattern = vibratePattern + vibratePattern = vibratePattern, + largeIconPath = largeIconPath, + subText = subText, + useCustomLayout = useCustomLayout, + trackImagePath = trackImagePath, + textIconName = textIconName, + emphasisText = emphasisText, + progress = progress ) } @@ -80,6 +109,21 @@ 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 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() } + with(prefs.edit()) { putString(PrefsKey.NOTIFICATION_CONTENT_TITLE, title) putString(PrefsKey.NOTIFICATION_CONTENT_TEXT, text) @@ -88,6 +132,13 @@ data class NotificationContent( putString(PrefsKey.NOTIFICATION_INITIAL_ROUTE, initialRoute) 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) commit() } } @@ -117,14 +168,50 @@ 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 + + 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() } + with(prefs.edit()) { title?.let { putString(PrefsKey.NOTIFICATION_CONTENT_TITLE, it) } text?.let { putString(PrefsKey.NOTIFICATION_CONTENT_TEXT, it) } 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) + 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() } } 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..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 @@ -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,15 @@ 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 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 +31,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 +44,15 @@ class ForegroundService : Service() { companion object { private val TAG = ForegroundService::class.java.simpleName + /// 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" private const val ACTION_NOTIFICATION_BUTTON_PRESSED = "onNotificationButtonPressed" @@ -100,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?) { @@ -195,6 +219,7 @@ class ForegroundService : Service() { override fun onDestroy() { super.onDestroy() soundExecutor.shutdown() + bitmapCache.evictAll() val isTimeout = this.isTimeout destroyForegroundTask(isTimeout) stopForegroundService(false) @@ -386,6 +411,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) { + loadPhoto(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 +458,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) { + loadPhoto(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 +725,167 @@ 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) + + 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 { + views.setTextViewText(R.id.fft_notification_text, notificationContent.text) + 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 = loadPhoto(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 = loadBitmap( + notificationContent.trackImagePath, TRACK_MAX_WIDTH, TRACK_MAX_HEIGHT + ) + 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 + } + + /// 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 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 loadBitmap(path: String?, maxWidth: Int, maxHeight: Int): Bitmap? { + if (path.isNullOrBlank()) { + return null + } + + return try { + val file = File(path) + if (!file.exists()) { + null + } else { + 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, "loadBitmap", e) + null + } + } + + /// 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 * 2) >= maxWidth || + bounds.outHeight / (sampleSize * 2) >= maxHeight + ) { + sampleSize *= 2 + } + + 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 @@ -717,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 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..a87d6ef8 --- /dev/null +++ b/android/src/main/res/layout/fft_notification_expanded.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/lib/flutter_foreground_task.dart b/lib/flutter_foreground_task.dart index 0d29dd38..baf8d695 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,13 @@ class FlutterForegroundTask { NotificationIcon? notificationIcon, List? notificationButtons, String? notificationInitialRoute, + String? notificationLargeIconPath, + String? notificationSubText, + NotificationProgress? notificationProgress, + bool? notificationUseCustomLayout, + String? notificationTrackImagePath, + String? notificationTextIconName, + String? notificationEmphasisText, Function? callback, }) async { try { @@ -128,6 +137,13 @@ class FlutterForegroundTask { notificationIcon: notificationIcon, notificationButtons: notificationButtons, notificationInitialRoute: notificationInitialRoute, + notificationLargeIconPath: notificationLargeIconPath, + notificationSubText: notificationSubText, + notificationProgress: notificationProgress, + notificationUseCustomLayout: notificationUseCustomLayout, + notificationTrackImagePath: notificationTrackImagePath, + notificationTextIconName: notificationTextIconName, + notificationEmphasisText: notificationEmphasisText, callback: callback, ); @@ -164,6 +180,13 @@ class FlutterForegroundTask { NotificationIcon? notificationIcon, List? notificationButtons, String? notificationInitialRoute, + String? notificationLargeIconPath, + String? notificationSubText, + NotificationProgress? notificationProgress, + bool? notificationUseCustomLayout, + String? notificationTrackImagePath, + String? notificationTextIconName, + String? notificationEmphasisText, Function? callback, String? notificationSound, NotificationVibratePattern? notificationVibratePattern, @@ -180,6 +203,13 @@ class FlutterForegroundTask { notificationIcon: notificationIcon, notificationButtons: notificationButtons, notificationInitialRoute: notificationInitialRoute, + notificationLargeIconPath: notificationLargeIconPath, + notificationSubText: notificationSubText, + 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 a06e8168..f26b74a9 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,13 @@ class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform { NotificationIcon? notificationIcon, List? notificationButtons, String? notificationInitialRoute, + String? notificationLargeIconPath, + String? notificationSubText, + NotificationProgress? notificationProgress, + bool? notificationUseCustomLayout, + String? notificationTrackImagePath, + String? notificationTextIconName, + String? notificationEmphasisText, Function? callback, }) async { final Map optionsJson = ServiceStartOptions( @@ -58,6 +66,13 @@ class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform { notificationIcon: notificationIcon, notificationButtons: notificationButtons, notificationInitialRoute: notificationInitialRoute, + notificationLargeIconPath: notificationLargeIconPath, + notificationSubText: notificationSubText, + notificationProgress: notificationProgress, + notificationUseCustomLayout: notificationUseCustomLayout, + notificationTrackImagePath: notificationTrackImagePath, + notificationTextIconName: notificationTextIconName, + notificationEmphasisText: notificationEmphasisText, callback: callback, ).toJson(platform); @@ -77,6 +92,13 @@ class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform { NotificationIcon? notificationIcon, List? notificationButtons, String? notificationInitialRoute, + String? notificationLargeIconPath, + String? notificationSubText, + NotificationProgress? notificationProgress, + bool? notificationUseCustomLayout, + String? notificationTrackImagePath, + String? notificationTextIconName, + String? notificationEmphasisText, Function? callback, String? notificationSound, NotificationVibratePattern? notificationVibratePattern, @@ -88,6 +110,13 @@ class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform { notificationIcon: notificationIcon, notificationButtons: notificationButtons, notificationInitialRoute: notificationInitialRoute, + notificationLargeIconPath: notificationLargeIconPath, + notificationSubText: notificationSubText, + 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 e293799e..304ffff9 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,13 @@ abstract class FlutterForegroundTaskPlatform extends PlatformInterface { NotificationIcon? notificationIcon, List? notificationButtons, String? notificationInitialRoute, + String? notificationLargeIconPath, + String? notificationSubText, + NotificationProgress? notificationProgress, + bool? notificationUseCustomLayout, + String? notificationTrackImagePath, + String? notificationTextIconName, + String? notificationEmphasisText, Function? callback, }) { throw UnimplementedError('startService() has not been implemented.'); @@ -61,6 +69,13 @@ abstract class FlutterForegroundTaskPlatform extends PlatformInterface { NotificationIcon? notificationIcon, List? notificationButtons, String? notificationInitialRoute, + String? notificationLargeIconPath, + String? notificationSubText, + NotificationProgress? notificationProgress, + bool? notificationUseCustomLayout, + String? notificationTrackImagePath, + String? notificationTextIconName, + String? notificationEmphasisText, 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..f8dbeccc 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,13 @@ class ServiceStartOptions { this.notificationIcon, this.notificationButtons, this.notificationInitialRoute, + this.notificationLargeIconPath, + this.notificationSubText, + this.notificationProgress, + this.notificationUseCustomLayout, + this.notificationTrackImagePath, + this.notificationTextIconName, + this.notificationEmphasisText, this.callback, }); @@ -34,6 +42,35 @@ 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; + + /// 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) { @@ -46,6 +83,13 @@ 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, + 'notificationTextIconName': notificationTextIconName, + 'notificationEmphasisText': notificationEmphasisText, }; if (platform.isAndroid) { @@ -71,6 +115,13 @@ class ServiceUpdateOptions { this.notificationIcon, this.notificationButtons, this.notificationInitialRoute, + this.notificationLargeIconPath, + this.notificationSubText, + this.notificationProgress, + this.notificationUseCustomLayout, + this.notificationTrackImagePath, + this.notificationTextIconName, + this.notificationEmphasisText, this.callback, this.notificationSound, this.notificationVibratePattern, @@ -82,6 +133,39 @@ 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; + + /// 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; @@ -93,6 +177,13 @@ 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, + 'notificationTextIconName': notificationTextIconName, + 'notificationEmphasisText': notificationEmphasisText, 'notificationSound': notificationSound, 'notificationVibratePattern': notificationVibratePattern?.name, };