DEV Community

Cover image for Build a Health Vitals Tracker in Flutter
Muhammad Uzair
Muhammad Uzair

Posted on Originally published at uzairxdev.online

Build a Health Vitals Tracker in Flutter

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 keyboardType\ to TextInputType.number\ and add FilteringTextInputFormatter.digitsOnly\ so users never fight a full keyboard for a number.
  • Move focus automatically. Set textInputAction: TextInputAction.next\ on the first field and done\ on 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.

\`dart
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.

\`dart
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 toJson() => {
'id': id,
'profileId': profileId,
'takenAt': takenAt.toIso8601String(),
'systolic': systolic,
'diastolic': diastolic,
'heartRate': heartRate,
};

factory VitalsReading.fromJson(Map 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.

\`dart
Future saveReading(VitalsReading reading) async {
final box = await Hive.openBox('readings');
await box.put(reading.id, reading.toJson());
}

Future> readingsFor(String profileId) async {
final box = await Hive.openBox('readings');
final all = box.values
.map((e) => VitalsReading.fromJson(Map.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:

\dart
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:

\dart
await flutterLocalNotificationsPlugin.zonedSchedule(
0,
'Time for your reading',
'Log today\u2019s 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.


Originally published at uzairxdev.online

Top comments (0)