Skip to main content

Mobile Development10 min read

Build a Period Tracker App in Flutter

Build a private, offline-first period tracker in Flutter: cycle logging, adaptive predictions for irregular cycles, ovulation estimates and on-device storage.

  • #flutter
  • #dart
  • #health-apps
  • #privacy
  • #period-tracker
Build a Period Tracker App in Flutter
Advertisement

A period tracker looks simple on the surface: a calendar, a few logged dates, a prediction. Underneath it is one of the most demanding apps a Flutter developer can build. The dates have to be right — a wrong prediction erodes trust faster than any crash. The data is some of the most sensitive health information a person carries, so privacy cannot be an afterthought. And cycles are irregular by nature, so the math has to adapt instead of assuming a textbook 28-day cycle. I built HerCycle, a privacy-first period tracker with a menstrual calendar, an irregular-cycle prediction algorithm, ovulation and fertile-window estimates, symptom and mood logging, and health reports — all stored on-device, account-free and fully offline, with a PIN and biometric app lock. This guide walks through building that kind of app in Flutter, with working Dart code.

Model cycle data as dates, not strings

Everything in a period tracker derives from dates: when periods started, how long cycles lasted, when the next one is due. Keep one entry per logged day, with the date normalized to midnight and stored as an ISO 8601 string. It survives app restarts, database migrations and time zone changes without ambiguity, and DateTime.parse reads it back exactly. Model each day as an immutable class with fromJson and toJson, so the storage layer stays boring and the logic layer works with real DateTime values:

enum FlowLevel { light, medium, heavy, spotting }
enum Mood { great, okay, low, irritable }

class DayEntry {
  const DayEntry({
    required this.date,
    this.flow,
    this.symptoms = const [],
    this.mood,
  });

  final DateTime date; // always normalized to midnight
  final FlowLevel? flow;
  final List<String> symptoms;
  final Mood? mood;

  factory DayEntry.fromJson(Map<String, dynamic> json) {
    return DayEntry(
      date: DateTime.parse(json['date'] as String),
      flow: json['flow'] == null
          ? null
          : FlowLevel.values.byName(json['flow'] as String),
      symptoms: List<String>.from(json['symptoms'] as List? ?? []),
      mood: json['mood'] == null
          ? null
          : Mood.values.byName(json['mood'] as String),
    );
  }

  Map<String, dynamic> toJson() => {
        'date': date.toIso8601String(),
        'flow': flow?.name,
        'symptoms': symptoms,
        'mood': mood?.name,
      };
}

Normalizing to midnight is the detail that saves you from the classic bug in this kind of app: two DateTime values for the same calendar day that are not equal because one carries a time component. Strip the time the moment a date enters the app, and run every comparison, set lookup and difference calculation through normalized dates:

DateTime normalizeDay(DateTime date) =>
    DateTime(date.year, date.month, date.day);

If you only remember one rule from this article, remember this one.

Compute cycle length from real history

A cycle length is the number of days between two consecutive period start dates. Deriving it from logged data — rather than asking the user to type in a number — is what makes the app feel smart and keeps the predictions honest. Given a list of period starts, the lengths are the gaps between neighbours:

List<int> cycleLengths(List<DateTime> periodStarts) {
  final sorted = List<DateTime>.of(periodStarts)..sort();
  final lengths = <int>[];
  for (var i = 1; i < sorted.length; i++) {
    lengths.add(sorted[i].difference(sorted[i - 1]).inDays);
  }
  return lengths;
}

Two edge cases matter. A brand-new user has no history, so there is nothing to average — the app must say so plainly and ask for at least two logged periods before predicting anything, instead of inventing a number. And a user can log a start date by mistake, so validate input: reject future dates and reject a start date inside an already logged period. Bad data in means wrong predictions out, and the user will blame the algorithm, not the typo.

Predict the next period, and handle irregular cycles

Advertisement

The naive prediction adds a fixed 28 days to the last period start. That is wrong for most people, most of the time. Real cycles vary, and a tracker that assumes regularity will be confidently wrong again and again. HerCycle predicts periods with an irregular-cycle algorithm that adapts to cycle variations, and the simplest robust version of that idea is the median of recent cycle lengths: it ignores outliers, so one unusually short or long cycle does not drag the prediction with it.

DateTime? predictNextPeriod(List<DateTime> periodStarts) {
  final lengths = cycleLengths(periodStarts);
  if (lengths.isEmpty) return null;

  final recent = lengths.length > 6
      ? lengths.sublist(lengths.length - 6)
      : lengths;
  recent.sort();
  final median = recent.length.isOdd
      ? recent[recent.length ~/ 2]
      : (recent[recent.length ~/ 2 - 1] + recent[recent.length ~/ 2]) ~/ 2;

  final lastStart = periodStarts.reduce(
    (a, b) => a.isAfter(b) ? a : b,
  );
  return normalizeDay(lastStart).add(Duration(days: median));
}

Using only the last six cycles keeps the prediction responsive to recent change. Present the result as a predicted window, not a promise: "expected around 22 December" holds up far better than a countdown that claims certainty it does not have. Predictions are estimates, and the app should say so, with a clear medical disclaimer that estimates are not medical advice.

Estimate ovulation and the fertile window

Once the next period is predicted, ovulation and the fertile window follow from it. The common estimation rule is that ovulation happens roughly 14 days before the next period starts, and the fertile window covers the five days before ovulation plus ovulation day itself. It is an estimate built on an estimate, so it inherits all of the uncertainty of the period prediction — another reason to show a range rather than a single day.

({DateTime ovulation, DateTime fertileStart, DateTime fertileEnd})
    fertileWindow(DateTime predictedNextPeriod) {
  final ovulation = predictedNextPeriod.subtract(const Duration(days: 14));
  return (
    ovulation: ovulation,
    fertileStart: ovulation.subtract(const Duration(days: 5)),
    fertileEnd: ovulation,
  );
}

HerCycle refines these estimates with optional inputs: basal body temperature, cervical mucus observations, and a pregnancy due date. Each is a separate, optional signal the user can add when they have it and ignore when they do not — never require data the user may not have, and never let a missing input break the core flow.

Build the calendar UI

The calendar is the home screen of a period tracker, and it has one job: make the state of the cycle readable at a glance. Colour-code each day by its type — logged period days, the predicted next period, the fertile window, ovulation day, and today — and keep the legend to four or five colours at most. A calendar with twelve shades of pink communicates nothing. Derive the colour from a pure function that maps a date to a day type, so the UI stays dumb and the logic stays testable:

enum DayType { period, predictedPeriod, fertile, ovulation, none }

DayType dayType(
  DateTime day, {
  required Set<DateTime> loggedPeriodDays,
  required DateTime? predictedStart,
  required DateTime? fertileStart,
  required DateTime? fertileEnd,
  required DateTime? ovulation,
}) {
  final d = normalizeDay(day);
  if (loggedPeriodDays.contains(d)) return DayType.period;
  if (predictedStart != null && d == normalizeDay(predictedStart)) {
    return DayType.predictedPeriod;
  }
  if (ovulation != null && d == normalizeDay(ovulation)) {
    return DayType.ovulation;
  }
  if (fertileStart != null && fertileEnd != null) {
    final start = normalizeDay(fertileStart);
    final end = normalizeDay(fertileEnd);
    if (!d.isBefore(start) && !d.isAfter(end)) return DayType.fertile;
  }
  return DayType.none;
}

Two details make this work. The set of logged period days must contain normalized dates, or contains will miss days that are actually logged. And the order of the checks is the priority order: a logged period day beats a prediction, and ovulation beats the fertile window it sits inside. Add a next-period countdown chip above the calendar — "next period in 6 days" is the single most checked number in the app.

Log symptoms, mood and flow, then turn them into a report

Logging is where a tracker earns its keep between periods. HerCycle logs symptoms, PMS and mood alongside flow, and turns that history into cycle health reports users can share with their doctor. The logging UI should be fast: one tap per symptom, a small set of mood options, a flow selector — because nobody will fill in a form every day, but many people will tap three chips.

Advertisement

Store the entries keyed by normalized date so each day holds at most one entry, and build the report from the same data the calendar already uses: recent cycle lengths and their variation, average flow days, the most frequently logged symptoms, and the mood trend across the cycle. Generate the report as a plain summary the user can share from the app — the numbers that help a doctor are the recent history, not a lifetime of raw data. The same on-device patterns apply to a health vitals tracker too.

Remind without a server

A tracker that never reminds is a tracker the user forgets to open. The reminder for a predicted period belongs on the device, scheduled locally — there is no reason to involve a server, and every reason not to. Use flutter_local_notifications with timezone-aware scheduling, and schedule the reminder a day before the predicted start:

Future<void> schedulePeriodReminder(DateTime predictedStart) async {
  final reminderDay = predictedStart.subtract(const Duration(days: 1));
  await flutterLocalNotificationsPlugin.zonedSchedule(
    100,
    'Period expected tomorrow',
    'Your next period is predicted to start tomorrow.',
    tz.TZDateTime.from(reminderDay, tz.local),
    const NotificationDetails(
      android: AndroidNotificationDetails('cycle', 'Cycle reminders'),
    ),
    androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
    uiLocalNotificationDateInterpretation:
        UILocalNotificationDateInterpretation.absoluteTime,
  );
}

Reschedule reminders whenever the prediction changes — a newly logged period moves everything, so cancel the old notification and schedule a fresh one in the same step. The full setup, including the timezone package and the Android exact-alarm permission, is covered in the local notifications guide.

Privacy is the architecture, not a setting

This is the section that decides whether people trust the app. Cycle data is intimate health data, and the only privacy promise that holds up is a structural one: everything is stored on-device, there is no account, no registration, and no server sync. HerCycle works fully offline for exactly this reason, and that architecture is not a marketing line — it is the product. The full pattern is covered in the guide to privacy-first offline apps. On-device storage only holds if the phone itself cannot be picked up and read by anyone else, which is why the app ships a PIN and biometric app lock. Gate the app behind local_auth before any cycle data renders:

final didAuthenticate = await LocalAuthentication().authenticate(
  localizedReason: 'Unlock HerCycle to view your cycle data',
  options: const AuthenticationOptions(biometricOnly: true),
);

If authentication fails or is unavailable, fall back to the PIN screen — never to an unlocked view. And keep the analytics honest: crash reporting that uploads device data by default quietly breaks the promise the app just made, so leave it out or make it opt-in.

Test the algorithm with fixed dates

Date math is exactly the kind of code that looks right and is wrong, so test it with fixed dates. A small test file with known histories pins down the median and the empty-history case:

test('predicts the next period from the median cycle length', () {
  final starts = [
    DateTime(2026, 9, 1),
    DateTime(2026, 9, 29),
    DateTime(2026, 10, 28),
    DateTime(2026, 11, 24),
  ];
  expect(predictNextPeriod(starts), DateTime(2026, 12, 22));
});

test('returns null with fewer than two logged periods', () {
  expect(predictNextPeriod([DateTime(2026, 11, 1)]), isNull);
});

Check the first test by hand: the gaps are 28, 29 and 27 days, so the median is 28, and 24 November plus 28 days is 22 December. Add a test with one wildly irregular cycle — a 45-day gap among 28-day cycles — and confirm the median ignores it while a plain average would not. That test is the whole argument for the median in one assertion.

Putting it together

A period tracker is a small set of well-separated pieces: a date model normalized to midnight, cycle lengths derived from logged history, a median-based prediction that adapts to irregular cycles, an ovulation and fertile-window estimate built on that prediction, a calendar that renders a pure day-type function, fast per-day logging that feeds a shareable health report, local reminders, and on-device storage behind a PIN or biometric lock. Each piece is easy to test on its own, and none of them needs a backend. Get in touch and let's talk about your project.

Frequently asked questions

How do I predict the next period in a Flutter period tracker app?
Store each logged period start date, compute the lengths between consecutive starts, and take the median of the recent lengths. Add that median to the most recent start date to get the predicted next period, and show it as an estimate rather than a promise.
How do I handle irregular cycles in a period tracker?
Never assume a fixed 28-day cycle. Use the median of the last few cycle lengths so one unusually short or long cycle does not skew the prediction, and present the result as a predicted window rather than a single certain day.
How do I store period tracking data privately in Flutter?
Store everything on-device in a local database with no account and no server sync, and guard the app with a PIN or biometric lock using local_auth. Period data is sensitive health data, so privacy has to be the architecture, not an optional setting.
How do I calculate the fertile window in Flutter?
Estimate ovulation as about 14 days before the predicted next period, then treat the fertile window as the five days before ovulation plus ovulation day. Refine the estimate with optional inputs such as basal body temperature and cervical mucus observations, and label every estimate as not medical advice.

Written by Muhammad Uzair

Flutter Developer & Mobile Engineering Team Lead.

Work with me
Advertisement
All articles
  • Mobile Development

    9 min read

    Build a Quiz App in Flutter: Full Guide

    Build a quiz app in Flutter from scratch: question banks, fair shuffling, scoring, timers and saved progress, with working Dart code you can reuse.

  • Mobile Development

    8 min read

    Color-Blind Friendly Games in Flutter

    Build a color-blind friendly game in Flutter: pair every color with a symbol, add aiming aids players can read, and ship settings players control.

  • Mobile Development

    7 min read

    Build a Pomodoro Focus Timer in Flutter

    Build a drift-free Pomodoro timer in Flutter with local notifications for session transitions, persistent session history, and calm deep-work analytics.