v0.3.0: local free check-in with actionable countdown notification (Phase C)
All checks were successful
build-apk / build (push) Successful in 40m22s
All checks were successful
build-apk / build (push) Successful in 40m22s
For a zone labeled free_2h/3h/4h, "Check in (free · Xh)" starts a local, API-free countdown to the free limit — no ParkSmarter call. The bbp-notify module gains a showCheckin() that posts the ticking chronometer with "End" / "Pay" buttons, a BbpActionReceiver (declared in the module's new AndroidManifest.xml) that records the tap in SharedPreferences and, for Pay, relaunches the app; consumePendingAction() is drained on foreground by useCheckinSync() — End clears the check-in, Pay hands off to StartSession for the stored zone. A pre-expiry alert reuses the existing reminder lead-minutes. Starting a paid session supersedes an active check-in. Also: MeterDetail shows the free/pay-immediate label and (admins) labeling chips. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
eeaf09ea0e
commit
463facbe5a
10 changed files with 368 additions and 59 deletions
|
|
@ -0,0 +1,9 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<!-- Fully-qualified name: relative ".Name" would resolve against the app
|
||||
package, not this module's namespace, once manifests are merged. -->
|
||||
<receiver
|
||||
android:name="expo.modules.bbpnotify.BbpActionReceiver"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package expo.modules.bbpnotify
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
|
||||
/**
|
||||
* Handles the check-in notification's "End" / "Pay" buttons, even when the app
|
||||
* process is dead. It records the choice in SharedPreferences (read by JS via
|
||||
* `consumePendingAction` on next foreground) and, for "Pay", relaunches the app
|
||||
* so the check-in can hand off to the paid-session flow.
|
||||
*/
|
||||
class BbpActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val prefs = context.getSharedPreferences(BbpNotifyModule.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
when (intent.action) {
|
||||
BbpNotifyModule.ACTION_END -> {
|
||||
prefs.edit().putString(BbpNotifyModule.PREF_PENDING, "end").apply()
|
||||
NotificationManagerCompat.from(context).cancel(BbpNotifyModule.NOTIF_ID)
|
||||
}
|
||||
BbpNotifyModule.ACTION_PAY -> {
|
||||
prefs.edit().putString(BbpNotifyModule.PREF_PENDING, "pay").apply()
|
||||
context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { launch ->
|
||||
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(launch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import android.app.NotificationChannel
|
|||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
|
|
@ -11,63 +12,32 @@ import expo.modules.kotlin.modules.Module
|
|||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
/**
|
||||
* A tiny, self-contained module that posts an ongoing notification whose "time"
|
||||
* is a native Android chronometer counting DOWN to the session's end. The system
|
||||
* ticks it every second with no app CPU/battery — works while the app is closed.
|
||||
* Posts an ongoing notification whose "time" is a native Android chronometer
|
||||
* counting DOWN to an end time — the system ticks it every second with no app
|
||||
* CPU/battery, so it works while the app is closed.
|
||||
*
|
||||
* Uses only platform APIs (NotificationCompat) — no third-party dependencies.
|
||||
* Two flavours: a plain paid-session countdown (`showCountdown`) and a free
|
||||
* check-in countdown (`showCheckin`) that adds "End" / "Pay" action buttons.
|
||||
* The buttons fire a BroadcastReceiver ([BbpActionReceiver]) that records the
|
||||
* choice in SharedPreferences; JS reads it via `consumePendingAction` on the
|
||||
* next foreground. Uses only platform APIs — no third-party dependencies.
|
||||
*/
|
||||
class BbpNotifyModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("BbpNotify")
|
||||
|
||||
// Returns a short diagnostic string so the JS layer can log exactly what
|
||||
// happened (posted / notifications-disabled / exception). "Nothing appears"
|
||||
// bugs are otherwise invisible because notify() fails silently.
|
||||
// Returns a short diagnostic string so JS can log exactly what happened
|
||||
// (posted / notifications-disabled / exception) — notify() fails silently.
|
||||
AsyncFunction("showCountdown") { title: String, body: String, endTimeMillis: Double ->
|
||||
val ctx = appContext.reactContext
|
||||
?: return@AsyncFunction "no-context"
|
||||
ensureChannel(ctx)
|
||||
val ctx = appContext.reactContext ?: return@AsyncFunction "no-context"
|
||||
postCountdown(ctx, title, body, endTimeMillis, null, null)
|
||||
}
|
||||
|
||||
val mgr = NotificationManagerCompat.from(ctx)
|
||||
val enabled = mgr.areNotificationsEnabled()
|
||||
|
||||
val builder = NotificationCompat.Builder(ctx, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setSmallIcon(R.drawable.bbp_stat_parking)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setShowWhen(true)
|
||||
.setWhen(endTimeMillis.toLong())
|
||||
.setUsesChronometer(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
builder.setChronometerCountDown(true)
|
||||
}
|
||||
|
||||
ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.let { launch ->
|
||||
builder.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
ctx,
|
||||
0,
|
||||
launch,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return@AsyncFunction try {
|
||||
mgr.notify(NOTIF_ID, builder.build())
|
||||
"posted enabled=$enabled sdk=${Build.VERSION.SDK_INT}"
|
||||
} catch (e: SecurityException) {
|
||||
// POST_NOTIFICATIONS not granted.
|
||||
"security-exception enabled=$enabled msg=${e.message}"
|
||||
} catch (e: Exception) {
|
||||
"exception enabled=$enabled ${e.javaClass.simpleName}=${e.message}"
|
||||
}
|
||||
// Same ticking countdown, plus "End" and "Pay" action buttons.
|
||||
AsyncFunction("showCheckin") {
|
||||
title: String, body: String, endTimeMillis: Double, endLabel: String, payLabel: String ->
|
||||
val ctx = appContext.reactContext ?: return@AsyncFunction "no-context"
|
||||
postCountdown(ctx, title, body, endTimeMillis, endLabel, payLabel)
|
||||
}
|
||||
|
||||
AsyncFunction("clear") {
|
||||
|
|
@ -76,17 +46,81 @@ class BbpNotifyModule : Module() {
|
|||
NotificationManagerCompat.from(ctx).cancel(NOTIF_ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Read + clear the action a notification button set while the app was away.
|
||||
// Returns "end", "pay", or "".
|
||||
AsyncFunction("consumePendingAction") {
|
||||
val ctx = appContext.reactContext ?: return@AsyncFunction ""
|
||||
val prefs = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val action = prefs.getString(PREF_PENDING, "") ?: ""
|
||||
if (action.isNotEmpty()) prefs.edit().remove(PREF_PENDING).apply()
|
||||
action
|
||||
}
|
||||
}
|
||||
|
||||
private fun postCountdown(
|
||||
ctx: Context,
|
||||
title: String,
|
||||
body: String,
|
||||
endTimeMillis: Double,
|
||||
endLabel: String?,
|
||||
payLabel: String?,
|
||||
): String {
|
||||
ensureChannel(ctx)
|
||||
val mgr = NotificationManagerCompat.from(ctx)
|
||||
val enabled = mgr.areNotificationsEnabled()
|
||||
|
||||
val builder = NotificationCompat.Builder(ctx, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setSmallIcon(R.drawable.bbp_stat_parking)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setShowWhen(true)
|
||||
.setWhen(endTimeMillis.toLong())
|
||||
.setUsesChronometer(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
builder.setChronometerCountDown(true)
|
||||
}
|
||||
|
||||
ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.let { launch ->
|
||||
builder.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
ctx, 0, launch,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (endLabel != null) builder.addAction(0, endLabel, actionIntent(ctx, ACTION_END))
|
||||
if (payLabel != null) builder.addAction(0, payLabel, actionIntent(ctx, ACTION_PAY))
|
||||
|
||||
return try {
|
||||
mgr.notify(NOTIF_ID, builder.build())
|
||||
"posted enabled=$enabled sdk=${Build.VERSION.SDK_INT}"
|
||||
} catch (e: SecurityException) {
|
||||
"security-exception enabled=$enabled msg=${e.message}"
|
||||
} catch (e: Exception) {
|
||||
"exception enabled=$enabled ${e.javaClass.simpleName}=${e.message}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun actionIntent(ctx: Context, action: String): PendingIntent {
|
||||
val intent = Intent(ctx, BbpActionReceiver::class.java).setAction(action)
|
||||
return PendingIntent.getBroadcast(
|
||||
ctx, action.hashCode(), intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ensureChannel(ctx: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val mgr = ctx.getSystemService(NotificationManager::class.java)
|
||||
if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Active parking",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
)
|
||||
val channel = NotificationChannel(CHANNEL_ID, "Active parking", NotificationManager.IMPORTANCE_LOW)
|
||||
channel.setShowBadge(false)
|
||||
mgr.createNotificationChannel(channel)
|
||||
}
|
||||
|
|
@ -95,6 +129,10 @@ class BbpNotifyModule : Module() {
|
|||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "session-status"
|
||||
private const val NOTIF_ID = 42421
|
||||
const val NOTIF_ID = 42421
|
||||
const val PREFS_NAME = "bbp_notify"
|
||||
const val PREF_PENDING = "pending_action"
|
||||
const val ACTION_END = "expo.modules.bbpnotify.END"
|
||||
const val ACTION_PAY = "expo.modules.bbpnotify.PAY"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,22 @@ interface BbpNotifyNative {
|
|||
* to endTimeMillis. Returns a short diagnostic string (e.g. "posted enabled=true").
|
||||
*/
|
||||
showCountdown(title: string, body: string, endTimeMillis: number): Promise<string>;
|
||||
/** Same ticking countdown, plus "End" / "Pay" action buttons. */
|
||||
showCheckin(
|
||||
title: string,
|
||||
body: string,
|
||||
endTimeMillis: number,
|
||||
endLabel: string,
|
||||
payLabel: string,
|
||||
): Promise<string>;
|
||||
/** Remove the countdown notification. */
|
||||
clear(): Promise<void>;
|
||||
/** Read + clear the action a notification button set: 'end' | 'pay' | ''. */
|
||||
consumePendingAction(): Promise<string>;
|
||||
}
|
||||
|
||||
export type PendingAction = 'end' | 'pay' | null;
|
||||
|
||||
// Android-only, and only present in a build that includes the native module
|
||||
// (returns null in Expo Go / other platforms — callers degrade gracefully).
|
||||
const native =
|
||||
|
|
@ -30,6 +42,23 @@ export async function showCountdown(
|
|||
return native.showCountdown(title, body, endTimeMillis);
|
||||
}
|
||||
|
||||
export async function showCheckin(
|
||||
title: string,
|
||||
body: string,
|
||||
endTimeMillis: number,
|
||||
endLabel: string,
|
||||
payLabel: string,
|
||||
): Promise<string> {
|
||||
if (!native) return 'no-native-module';
|
||||
return native.showCheckin(title, body, endTimeMillis, endLabel, payLabel);
|
||||
}
|
||||
|
||||
export async function clearCountdown(): Promise<void> {
|
||||
await native?.clear();
|
||||
}
|
||||
|
||||
/** Read + clear a notification-button action taken while the app was away. */
|
||||
export async function consumePendingAction(): Promise<PendingAction> {
|
||||
const a = await native?.consumePendingAction();
|
||||
return a === 'end' || a === 'pay' ? a : null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue