Compose-first Android tips with rules, persistence, and reusable UI.
NudgeKit is an Android library for contextual tips, feature discovery hints, and onboarding nudges. It is inspired by Apple TipKit, but built around Kotlin, Jetpack Compose, and Android app constraints.
Status:
- Stable release (latest: 1.0.0) — public API stable under semantic versioning
- Stable source release available on GitHub; Maven Central publishing is pending
- 200 tests passing in a correctly configured environment
- sample app included
- GitHub Actions CI configured
- local Maven publishing dry-run configured (
publishToMavenLocal) - not published to Maven Central yet
- Maven Central upload / signing credentials not configured yet
Real captures from the bundled sample app on an Android device.
| Light | Dark | |
|---|---|---|
| Overview (inline tip + anchored tip) | ![]() |
![]() |
| Anchored TipBox | ![]() |
![]() |
Android apps often end up with:
- one-off tooltip UI
- scattered SharedPreferences flags
- ad hoc event counters
- custom "show once" logic
- dismissal state that is hard to reason about
NudgeKit provides one small library surface for those concerns:
nudgekit-core: Android-free rule engine and modelsnudgekit-datastore: DataStore-backed persistence and evaluation helpersnudgekit-compose: pure Compose UI components (InlineTip,TipBox) — no DataStore dependencynudgekit-compose-datastore: state-aware managed components (ManagedInlineTip,ManagedTipBox)sample: a demo app exercising the full library
private val checkoutTip = Tip(
id = "save_address",
title = "Save your address",
message = "Save your delivery address for faster checkout next time.",
actionLabel = "Add address",
rules = listOf(
TipRule.NotDismissed,
TipRule.AfterScreenVisits("checkout", 2),
TipRule.MaxDisplayCount(1),
),
)
class MainActivity : ComponentActivity() {
private val tipManager by lazy { DataStoreTipManager.create(applicationContext) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
ManagedInlineTip(
tip = checkoutTip,
manager = tipManager,
modifier = Modifier.fillMaxWidth(),
onActionClick = { /* open address flow */ },
)
}
}
}
}NudgeKit 1.0.0 is a stable source release on GitHub. It is not on Maven Central yet (publishing is pending), so consume it locally for now.
Planned Maven Central coordinates (once published):
implementation("io.github.abdullajon1881:nudgekit-core:<version>")
implementation("io.github.abdullajon1881:nudgekit-datastore:<version>")
implementation("io.github.abdullajon1881:nudgekit-compose:<version>") // pure UI
implementation("io.github.abdullajon1881:nudgekit-compose-datastore:<version>") // managed componentsFor now, use the modules locally in a multi-module Gradle build:
// settings.gradle.kts
include(":nudgekit-core")
include(":nudgekit-datastore")
include(":nudgekit-compose")
include(":nudgekit-compose-datastore")
project(":nudgekit-core").projectDir = file("../NudgeKit/nudgekit-core")
project(":nudgekit-datastore").projectDir = file("../NudgeKit/nudgekit-datastore")
project(":nudgekit-compose").projectDir = file("../NudgeKit/nudgekit-compose")
project(":nudgekit-compose-datastore").projectDir = file("../NudgeKit/nudgekit-compose-datastore")// app/build.gradle.kts
dependencies {
// Pure UI only (InlineTip, TipBox) — does not pull in DataStore:
implementation(project(":nudgekit-compose"))
// Managed components (ManagedInlineTip, ManagedTipBox) — transitively
// pulls in nudgekit-compose, nudgekit-datastore, and nudgekit-core:
implementation(project(":nudgekit-compose-datastore"))
}Requirements:
- Android minSdk 24
- Kotlin 2.1.0
- Java 17
- Compose BOM
2024.12.01
Build with JDK 17. This repo has previously failed when launched with Java 25 instead of Java 17.
class MyApp : Application() {
val tipManager by lazy { DataStoreTipManager.create(this) }
}val favoritesTip = Tip(
id = "favorites_tip",
title = "Save favorites",
message = "Tap the heart icon to save items for later.",
actionLabel = "Try it",
rules = listOf(
TipRule.NotDismissed,
TipRule.AfterEvent("item_viewed", 3),
TipRule.MaxDisplayCount(1),
),
)@Composable
fun ProductScreen(manager: DataStoreTipManager) {
ManagedInlineTip(
tip = favoritesTip,
manager = manager,
modifier = Modifier.fillMaxWidth(),
onActionClick = { /* navigate or trigger UI */ },
)
}LaunchedEffect(Unit) {
manager.trackScreen("product")
}
scope.launch {
manager.trackEvent("item_viewed")
}Tip: immutable value object withid,title,message, optionalactionLabel, optionalpriority, optionalgroupId, and a list ofTipRuleTipRule: built-in rules includeNotDismissed,Once,MaxDisplayCount,AfterEvent,AfterScreenVisits,MinIntervalHours,ExpiresAt,ExpiresAfter,AnyOf/AllOf(OR/AND), andCustom- Mutual exclusion: give tips a shared
groupIdand usemanager.selectEligible(group)(orTipEvaluator.select) to show only the highest-priorityeligible tip TipState: per-tip dismissed state, display count, and last shown timestampTipCounters: app-wide event counts and screen visit countsTipManager: write-side lifecycle interfaceReactiveTipManager:TipManager+ reactive reads (observeTipState,observeCounters) +shouldShow; the contract the managed UI depends onDataStoreTipManager: Android DataStore implementation ofReactiveTipManagerplus evaluation helpersMemoryTipManager: in-memoryReactiveTipManagerinnudgekit-corefor tests, previews, and sample/debug flows (no Android, nothing persisted) — can drive the managed components directly- Managed UI:
ManagedInlineTipandManagedTipBox(accept anyReactiveTipManager) TipAnalytics: SDK-agnostic hook for observing tip lifecycle events (onTipShown,onTipDismissed,onTipActionClicked)
Give competing tips a shared groupId and a priority, then let the selector pick the single highest-priority eligible one. Higher priority wins; ties break by id.
val homeTips = listOf(
Tip(id = "filters", title = "Filters", message = "...", priority = 10, groupId = "home"),
Tip(id = "favorites", title = "Favorites", message = "...", priority = 5, groupId = "home"),
)
// Returns the one tip to show right now, or null if none are eligible.
val winner: Tip? = manager.selectEligible(homeTips)
winner?.let { InlineTip(tip = it, onDismiss = { /* manager.dismiss(it.id) */ } ) }For a full per-candidate breakdown (each tip's TipHideReason), use TipEvaluator.select(...). See docs/core-concepts.md.
NudgeKit bundles no analytics SDK and makes no network calls. Instead it
exposes a tiny SDK-agnostic interface in nudgekit-core:
interface TipAnalytics {
fun onTipShown(tip: Tip) {}
fun onTipDismissed(tip: Tip) {}
fun onTipActionClicked(tip: Tip) {}
}
object NoOpTipAnalytics : TipAnalyticsImplement it to forward events to whatever you already use — Firebase, Mixpanel, Amplitude, a logger — then pass it to a managed component:
class MyTipAnalytics(private val tracker: Tracker) : TipAnalytics {
override fun onTipShown(tip: Tip) = tracker.log("tip_shown", tip.id)
override fun onTipDismissed(tip: Tip) = tracker.log("tip_dismissed", tip.id)
override fun onTipActionClicked(tip: Tip) = tracker.log("tip_action", tip.id)
}
ManagedInlineTip(
tip = favoritesTip,
manager = manager,
analytics = MyTipAnalytics(tracker),
onActionClick = { /* … */ },
)Events fire once per real user-facing action: onTipShown in lock-step with
markShown (not on every recomposition), onTipDismissed on dismiss, and
onTipActionClicked on the action button. The default is NoOpTipAnalytics,
so analytics is strictly opt-in. Pure UI components (InlineTip, TipBox)
stay callback-based — bridge their callbacks to analytics yourself if needed.
InlineTip(
tip = Tip(
id = "filters",
title = "Use filters",
message = "Filter results to find items faster.",
actionLabel = "Open filters",
),
modifier = Modifier.fillMaxWidth(),
onDismiss = { /* hide locally */ },
onActionClick = { /* open filters */ },
)TipBox(
tip = Tip(
id = "notifications",
title = "Enable notifications",
message = "Get updates for important activity.",
actionLabel = "Enable",
),
visible = true,
position = TipPosition.Bottom,
onDismiss = { /* hide it */ },
onActionClick = { /* open settings */ },
) {
Button(onClick = {}) {
Text("Notification settings")
}
}val reviewTip = Tip(
id = "review_tip",
title = "Leave a review",
message = "You have viewed several items. Tell us what you think.",
rules = listOf(
TipRule.NotDismissed,
TipRule.AfterEvent("item_viewed", 3),
TipRule.MaxDisplayCount(1),
),
)val checkoutTip = Tip(
id = "checkout_tip",
title = "Save your address",
message = "Save your address to speed up checkout.",
rules = listOf(
TipRule.NotDismissed,
TipRule.AfterScreenVisits("checkout", 2),
),
)scope.launch {
manager.reset("checkout_tip")
manager.resetAll()
}TooltipBox is a UI primitive. NudgeKit adds the behavior layer:
- eligibility rules
- dismissal state
- display counts
- event thresholds
- screen-visit thresholds
- interval rules
- persistence across restarts
If you only need a tooltip, use a tooltip. If you need "show the right nudge at the right time and remember what happened", use NudgeKit.
- Stable API (1.0.0) — public surface is stable under semantic versioning; breaking changes would mean a 2.0 (see API stability).
- Not published to Maven Central yet — a local publishing dry-run (sources + Dokka javadoc + gated signing) is configured; use the modules locally for now.
- managed components (
ManagedInlineTip,ManagedTipBox) live innudgekit-compose-datastore;nudgekit-composeis pure UI with no DataStore dependency. - managed components observe all counters through
observeCounters()(intentional —TipRule.Customcan read any counter). TipBoxis in-flow, not a floating overlay/popover.- production apps should reuse the process-wide
DataStoreTipManagerfromcreate().
The tip components ship with sensible accessibility defaults: the dismiss button keeps the Material 48 dp touch target with a "Dismiss tip" content description, the tip title is exposed as a heading for screen-reader navigation, text scales with the user's font-size setting, and the action button uses standard TextButton semantics. See docs/compose-ui.md#accessibility.
Done in 1.0.0: tip groups / mutual exclusion (Tip.priority is now meaningful), time-bounded rules (ExpiresAt / ExpiresAfter), and OR/AND combinators (AnyOf / AllOf).
Near-term priorities (post-1.0):
- deeper accessibility audit (TalkBack focus order, dynamic-type stress, measured contrast)
- improve
TipBoxanchoring toward a true popover; animation customization - automatic group coordination in the managed components (
ManagedTipGroup) - prepare Maven Central publishing (real GPG key + Sonatype account)
A first-party React Native / Expo package, nudgekit, lives in
packages/react-native/. It ports the rule engine,
managers, and UI to pure TypeScript (behaviour pinned to this Android library via
shared rule vectors in spec/rule-vectors/) and ships a runnable
Expo example app. Install it with
npm install nudgekit (first release 0.1.0). No Expo config plugin is needed
because it is pure JS/TS with no native modules; the native Android library
remains a separate Maven/Gradle distribution.
See its README.
Contributions are welcome, especially around API review, Compose UI polish, tests, and documentation improvements.
- Read CONTRIBUTING.md for setup, build commands, and the PR workflow.
- Bug reports and feature requests use the issue templates.
- Security issues should follow SECURITY.md — please do not file them as public issues.
- All participants are expected to follow the Code of Conduct.
NudgeKit is licensed under the Apache License 2.0. See LICENSE.



