Mobile Development8 min read
Flutter Wake-Up Challenge Alarm
Build a snooze-proof wake-up alarm in Flutter with interactive math and shake dismissal challenges, reliable scheduling, and lessons from the Rise Baby app.
- #flutter
- #dart
- #android
- #background-services
- #local-notifications

The snooze button is the most-used widget in every alarm app ever shipped, and it is also the reason most people oversleep. I learned this building Rise Baby, a Flutter alarm app with smart alarms, snooze control, interactive wake-up challenges, and morning insights. The core insight was simple: scheduling the alarm is the easy part. Making sure a half-asleep human actually gets out of bed is the real engineering problem, and it took a reliable background alarm scheduling system, dismissal gates that force the user to be genuinely awake, and a morning dashboard with sleep insights and daily weather to make it motivating. This article distills what worked: how to schedule alarms that fire reliably on modern Android, how to build wake-up challenges — math problems and physical actions — that must be completed before the alarm stops, and the permission and battery pitfalls that will silently kill your alarms in production.
Scheduling is only half the problem
A wake-up alarm has two independent jobs. First, it must fire at the right time even when the app is dead, the phone is locked, and Android's Doze mode is doing everything it can to save battery. Second, it must keep the user awake once they hear it. Most tutorials stop at job one. On Android 12 and later, job one means dealing with the SCHEDULE_EXACT_ALARM permission and checking canScheduleExactAlarms before you promise the user an exact wake time. I covered that deep dive in Flutter Exact Alarm Scheduling on Android, so this article focuses on job two: the dismissal gate. The pattern is a state machine with five states: idle, ringing, challenged, awake, and dismissed. The alarm stays in ringing until the challenge screen takes over, it stays in challenged until the challenge is solved, and only then does it reach dismissed. There is no button, gesture, or notification swipe that jumps straight from ringing to dismissed. If your UI ever lets the user dismiss the alarm without solving the challenge, half-asleep users will find it within a week — guaranteed.
The dismissal gate pattern
Think of the dismissal gate as a tiny, deliberately annoying exam. The ringing screen hands off to a full-screen challenge, the alarm sound and vibration keep going in the background, and the whole thing only stops when the user passes the gate. Two gates shipped in Rise Baby: a math challenge and a shake challenge. Both are easy to implement in pure Flutter and Dart, which is exactly why they are great starting points. The architecture is deliberately boring. An Alarm model holds the scheduled time, the selected challenge type, and the ringtone. A ChallengeScreen widget takes the challenge type and an onSolved callback. The alarm service keeps ringing until onSolved fires. Nothing about this needs a backend, which is why the app works with Flutter and Dart plus background services and local notifications on the device.
Challenge 1: math problems to dismiss
The math challenge generates a random arithmetic problem and blocks dismissal until the user types the correct answer. The trick is to never store the expected answer where it could be read back — compute it from the problem operands at check time:
import 'dart:math';
class MathChallenge {
final Random _random = Random();
int a = 0;
int b = 0;
void newProblem() {
a = 20 + _random.nextInt(60);
b = 10 + _random.nextInt(50);
}
String get prompt => '$a + $b = ?';
bool check(String input) {
return int.tryParse(input.trim()) == a + b;
}
}
The widget is a plain form with a TextField, a numeric keyboard, and a submit handler:
class MathChallengeScreen extends StatefulWidget {
final VoidCallback onSolved;
const MathChallengeScreen({super.key, required this.onSolved});
@override
State<MathChallengeScreen> createState() => _MathChallengeScreenState();
}
class _MathChallengeScreenState extends State<MathChallengeScreen> {
final _challenge = MathChallenge();
final _controller = TextEditingController();
String? _error;
@override
void initState() {
super.initState();
_challenge.newProblem();
}
void _submit() {
if (_challenge.check(_controller.text)) {
widget.onSolved();
} else {
setState(() {
_error = 'Wrong answer. Try again.';
_controller.clear();
_challenge.newProblem();
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_challenge.prompt,
style: Theme.of(context).textTheme.displaySmall),
const SizedBox(height: 16),
SizedBox(
width: 200,
child: TextField(
controller: _controller,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
onSubmitted: (_) => _submit(),
decoration: InputDecoration(
hintText: 'Answer',
errorText: _error,
),
),
),
const SizedBox(height: 16),
FilledButton(onPressed: _submit, child: const Text('Stop alarm')),
],
),
),
);
}
}
Notice that a wrong answer generates a brand-new problem. This is intentional: a user who is awake enough to guess repeatedly is awake enough to solve one more problem. Do not let them retry the same problem, because a wrong guess followed by a slightly different wrong guess will eventually stumble onto the answer without any real wakefulness. Tune the difficulty. Two-digit addition wakes up an average adult without being cruel. Multiplication or three-digit numbers make the challenge feel hostile at 6 AM, and hostile users uninstall. Difficulty that ramps up with consecutive snoozes is the sweet spot: easy the first time, harder the second, brutal the third.
Challenge 2: shake-to-dismiss
The physical challenge uses the accelerometer to require a fixed number of shakes before the alarm stops. The sensors_plus package exposes accelerometerEventStream, and the detection logic counts peaks of the acceleration magnitude above a threshold:
import 'dart:async';
import 'package:sensors_plus/sensors_plus.dart';
class ShakeChallenge {
StreamSubscription<AccelerometerEvent>? _subscription;
int shakeCount = 0;
DateTime _lastShake = DateTime.now();
static const int requiredShakes = 20;
static const double shakeThreshold = 400.0;
static const int minGapMs = 300;
void start(VoidCallback onSolved, VoidCallback onShake) {
_subscription = accelerometerEventStream().listen((event) {
final magnitude =
event.x * event.x + event.y * event.y + event.z * event.z;
final now = DateTime.now();
if (magnitude > shakeThreshold &&
now.difference(_lastShake).inMilliseconds > minGapMs) {
_lastShake = now;
shakeCount++;
onShake();
if (shakeCount >= requiredShakes) {
stop();
onSolved();
}
}
});
}
void stop() {
_subscription?.cancel();
_subscription = null;
}
}
Three constants do all the quality work here. The threshold rejects ordinary movement, the minimum gap prevents one violent shake from registering as five, and the target count of twenty requires sustained effort — roughly ten seconds of deliberate shaking, which is enough physical activity to end sleep inertia. Show a progress ring filling up with each shake so the user sees they are making progress; a silent counter feels like the app is ignoring them.
Always cancel the sensor subscription the moment the challenge ends. An accelerometer stream left running drains battery and keeps the app in the foreground-task list long after the alarm is over. Stop it in dispose as a backstop too.
Surviving a reboot
Alarms scheduled only in memory disappear when the phone restarts, and a user who wakes up late because of a 2 AM security update will never trust your app again. The fix has two parts: persist every alarm to storage (shared_preferences or a local database) at the moment it is created, and reschedule all persisted alarms when the device boots, through a BOOT_COMPLETED receiver. Every mainstream alarm plugin documents this pattern — the important part is making it part of your alarm model from day one, not a bug report from month three. This is also why the alarm data model should be boring and serializable: an id, a time, a repeat rule, a challenge type, and an enabled flag. If your alarm cannot survive a JSON round-trip, it cannot survive a reboot.
The morning dashboard
Rise Baby pairs the alarm with a morning dashboard showing sleep insights and the daily weather. This sounds like scope creep, but it serves the product goal: the dashboard turns the alarm from a chore into a habit. Sleep insights are cheap to compute — compare the scheduled time against the actual dismissal time and the challenge completion time to derive bedtime consistency and snooze frequency. The weather is a single REST call and a FutureBuilder. Keep the insights local and honest: you know when the alarm rang and when the user dismissed it, so report exactly that instead of inventing sleep-stage data you cannot measure. A playful, motivating UI matters here more than in most apps. Your user interacts with it while barely conscious, so the dismissal screen needs huge touch targets, high contrast, and zero ambiguity about what to do next.
Permission and battery pitfalls
This is the section that saves you a one-star review storm. Three things must be true or your perfect alarm code never runs: Exact alarm permission. On Android 12 and later, scheduling exact alarms requires SCHEDULE_EXACT_ALARM, and the user can revoke it in system settings at any time. Check canScheduleExactAlarms before scheduling and re-check on every app launch, because a revoked permission silently converts your exact alarm into an approximate one that fires whenever Android feels like it. Full-screen intent. The ringing screen must appear over the lock screen. That requires the USE_FULL_SCREEN_INTENT permission and a notification channel configured with maximum importance. I cover the notification plumbing in Flutter Local Notifications Guide — the short version is that a default-importance channel will buzz politely in the notification shade while the user sleeps through it. Battery optimization. Every major Android manufacturer ships aggressive background killers on top of stock Doze mode. Your app must guide the user to disable battery optimization for it, and you must explain why in plain language: "Android will silence your alarm to save battery" converts far better than "allow background activity." Test on at least one Samsung and one Xiaomi device before you ship — they are the ones your users own.
Putting it together
A snooze-proof alarm app is four pieces: a reliable scheduler that handles exact permissions and reboots, a dismissal gate built as an explicit state machine, one or two challenge types tuned to be annoying but fair, and honest morning insights that reward the user for getting up. Build them in that order, because a beautiful challenge screen on top of an alarm that never fires is just a demo. Start with the math challenge — it is thirty lines of Dart, needs no sensors, and teaches you the whole dismissal-gate architecture. Add shake detection next for the physical variant. Then harden the boring parts: boot rescheduling, permission re-checks, and battery-optimization onboarding. That is the order that shipped in Rise Baby, and it is the order I would build it again. Get in touch and let us talk about your project.
Frequently asked questions
- Why does my Flutter alarm not fire when the screen is locked?
- Doze mode and battery optimization defer or kill background work. Use exact alarms with the SCHEDULE_EXACT_ALARM permission, a full-screen intent to show over the lock screen, and guide the user to disable battery optimization for your app.
- How do I stop users from just swiping away the alarm?
- Replace the dismiss button with a dismissal gate: an interactive challenge such as a math problem or a shake counter that must be completed before the alarm stops ringing.
- Do alarms survive a phone reboot in Flutter?
- No. Alarms scheduled at runtime are lost on reboot, so persist every alarm to storage and reschedule them all when the device boots, typically through a BOOT_COMPLETED receiver.
- How do I detect shakes reliably in Flutter?
- Listen to the accelerometer stream, compute the squared magnitude of the acceleration vector, and count peaks above a threshold with a minimum gap between peaks and a required total count.


