Mobile Development7 min read
Build a Health Vitals Tracker in Flutter
Build a Flutter health vitals tracker: sub-10-second one-hand logging, per-member family profiles, offline-first storage, trend charts and daily reminders.
- #flutter
- #health
- #hive
- #fl-chart
- #notifications
- #mobile-development
Most health apps fail for the same boring reason: logging a reading takes too long. A blood pressure entry that needs five screens, three keyboards, and a login form will be abandoned by day three — and a tracker nobody opens is a tracker that never helps.
When I built Track Health & Vitals Monitor, the design target was brutal and specific: log blood pressure, heart rate, and key vitals in seconds, one-handed, straight from the home screen. Families needed separate profiles for each member so every reading stays organized, trends had to be obvious at a glance, and daily reminders had to make sure readings never get missed. This guide distills the Flutter patterns that made that work: the entry flow, the data model, offline-first storage, trend charts, and reminders that survive Android.
Design the entry flow for speed
The home screen of a vitals app should be one thing: a fast path to a saved reading. Two number fields (systolic and diastolic), an optional heart rate field, and a save button — that is the whole flow. Everything else (notes, editing history, settings) lives behind it.
Three details make or break the speed:
- Number keyboards only. Set
keyboardTypetoTextInputType.numberand addFilteringTextInputFormatter.digitsOnlyso users never fight a full keyboard for a number. - Move focus automatically. Set
textInputAction: TextInputAction.nexton the first field anddoneon the last, so the keyboard itself walks the user through the flow. - Pre-fill everything. The timestamp defaults to now, and the values default to the last reading. Most days the user changes nothing except tapping save.
class VitalsInputRow extends StatelessWidget {
final TextEditingController systolic;
final TextEditingController diastolic;
const VitalsInputRow({required this.systolic, required this.diastolic});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: TextField(
controller: systolic,
autofocus: true,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(labelText: 'Systolic'),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: diastolic,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(labelText: 'Diastolic'),
),
),
],
);
}
}
Two large fields side by side fit a thumb, and with autofocus the keyboard is already up when the screen opens. That alone cuts the flow to two taps and a save.
Model readings per profile
A family tracker is really a multi-user app wearing a single-user disguise. The cleanest way to keep it sane: every reading carries a profileId, and the app holds one active profile in state. Every query filters by the active profile, so each member gets an independent history without any data ever leaking across.
class VitalsReading {
final String id;
final String profileId;
final DateTime takenAt;
final int systolic;
final int diastolic;
final int? heartRate;
const VitalsReading({
required this.id,
required this.profileId,
required this.takenAt,
required this.systolic,
required this.diastolic,
this.heartRate,
});
Map<String, dynamic> toJson() => {
'id': id,
'profileId': profileId,
'takenAt': takenAt.toIso8601String(),
'systolic': systolic,
'diastolic': diastolic,
'heartRate': heartRate,
};
factory VitalsReading.fromJson(Map<String, dynamic> json) => VitalsReading(
id: json['id'] as String,
profileId: json['profileId'] as String,
takenAt: DateTime.parse(json['takenAt'] as String),
systolic: json['systolic'] as int,
diastolic: json['diastolic'] as int,
heartRate: json['heartRate'] as int?,
);
}
Keep the active profile in a simple state object (Provider works fine here — one ChangeNotifier with activeProfileId and a setter). When the user switches profiles, the reading list, charts, and reminders all re-filter from the same source of truth. If you are separating network code from widgets the way a clean data layer demands, the repository pattern keeps API calls out of widgets and makes this filtering trivial to test.
Store locally first, sync later
A health reading must never depend on the network. Hive is the right default here: writes are synchronous-feeling, reads are instant, and there is no schema migration ceremony for a simple model. Firebase stays optional — a sync layer you add later, not a gate in front of saving.
Future<void> saveReading(VitalsReading reading) async {
final box = await Hive.openBox('readings');
await box.put(reading.id, reading.toJson());
}
Future<List<VitalsReading>> readingsFor(String profileId) async {
final box = await Hive.openBox('readings');
final all = box.values
.map((e) => VitalsReading.fromJson(Map<String, dynamic>.from(e as Map)))
.where((r) => r.profileId == profileId)
.toList();
all.sort((a, b) => b.takenAt.compareTo(a.takenAt));
return all;
}
Open the box once at startup rather than per call in production — repeated openBox calls are cheap but sloppy. Storing plain JSON maps instead of generated Hive type adapters keeps the model readable and lets a future sync layer serialize the same payload straight to Firestore. Health data staying on the device by default is also the right privacy posture: nothing leaves the phone unless the user opts into sync.
Make trends obvious at a glance
A list of numbers does not change behavior; a picture does. The trend view should answer one question in under a second: "am I getting better or worse?" That means a 7-day rolling average line, not a scatter of raw points, with background bands for the standard blood-pressure zones (normal, elevated, high) so a reading's meaning is visible without interpreting numbers.
The fl_chart package handles this well. Feed it one FlSpot per day with the rolling average, and shade the area under the line:
LineChart(
LineChartData(
minY: 60,
maxY: 180,
lineBarsData: [
LineChartBarData(
spots: weeklyAverages, // List<FlSpot>: x = day index, y = avg systolic
isCurved: true,
barWidth: 3,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [Colors.red.withOpacity(0.25), Colors.transparent],
),
),
),
],
titlesData: const FlTitlesData(
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: true)),
bottomTitles: AxisTitles(sideTitles: SideTitles(showTitles: true)),
),
gridData: const FlGridData(show: true),
),
)
Compute the rolling averages in Dart before touching the widget — a small pure function over the sorted readings list that is easy to unit test. Keep the chart dumb: it renders spots, nothing more. A second line for diastolic uses the same pattern with a different color, and the zone bands become simple horizontal reference lines via ExtraLinesData.
Reminders that survive Android
Daily reminders are the feature that keeps the streak alive, and they are also the easiest to get wrong on Android. Doze mode delays inexact alarms, so a reminder scheduled with a naive timer arrives whenever the OS feels like it. Use flutter_local_notifications with exact scheduling and match the time-of-day component so it repeats daily:
await flutterLocalNotificationsPlugin.zonedSchedule(
0,
'Time for your reading',
'Log today’s vitals in under 10 seconds.',
_nextInstanceOfToday(9, 0),
const NotificationDetails(
android: AndroidNotificationDetails(
'vitals_daily',
'Daily reminders',
importance: Importance.high,
),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
matchDateTimeComponents: DateTimeComponents.time,
);
Two things this needs to actually work on modern Android: the SCHEDULE_EXACT_ALARM permission declared and requested at runtime, and timezone initialization (tz.initializeTimeZones()) before computing -nextInstanceOfToday. The full exact-alarm setup — permission flows, the canScheduleExactAlarms check, and the Android 14+ behavior changes — is covered in depth in Flutter Exact Alarm Scheduling on Android, which walks through the same machinery this reminder relies on.
Validate like a clinician is watching
Health apps earn trust by refusing bad data politely. Validate every entry before it touches storage:
- Physiological ranges. Systolic 70–300, diastolic 40–200, heart rate 30–220. Reject anything outside with a specific message ("Systolic must be between 70 and 300"), not a generic "invalid input".
- No future timestamps. A reading dated tomorrow corrupts every trend chart. Clamp to now.
- Confirm destructive actions. Deleting a reading needs an explicit confirm dialog — health history is not the place for swipe-to-delete without a safety net.
- Systolic above diastolic. If the user swaps the fields, say so immediately instead of storing nonsense.
None of this is glamorous, but it is what separates an app people trust with their health from a demo.
Ship the boring parts well
A vitals tracker is not a showcase for exotic architecture. It is a showcase for discipline: a sub-10-second entry flow, one reading model with a profileId, local-first storage, charts that answer "am I improving?" at a glance, and reminders that fire on time. Get those right and the app does its job quietly, every day — which is exactly what a health app should do.
If you are building a health or data-entry heavy Flutter app and want the logging flow, offline storage, and reminder scheduling done right the first time, get in touch and let's talk about your project.
Frequently asked questions
- How fast should logging a blood pressure reading take?
- Under ten seconds, one-handed, from the home screen. Anything slower gets abandoned: pre-fill the timestamp with now, default to the last values, and keep the flow to two fields and one save button.
- Where should I store health data in a Flutter app?
- Locally first with Hive, then sync to Firebase optionally. A reading must never depend on the network, and health data staying on the device is also the right privacy default.
- How do I keep family members readings separate in one app?
- Tag every reading with a profileId and keep the active profile in app state. All queries filter by the active profile, so each member gets an independent history and trend view.
- Why do my Flutter local notification reminders arrive late?
- Modern Android Doze delays inexact alarms. Use zonedSchedule with AndroidScheduleMode.exactAllowWhileIdle and request the exact-alarm permission so daily reminders fire on time.


