dashstack_poster

time_ring_picker

A highly customizable circular clock-style time range picker for Flutter. Drag two handles around a 24-hour dial to pick a start and end time — built for things like sleep schedules, quiet hours, or any "from X to Y" time range.

  • Full 24-hour dial in a single rotation, numbers 112 (or 022) shown around the circle.
  • Overnight ranges just work: 09:00 PM06:00 AM reports a 9h duration, no manual date math.
  • 12h and 24h label formats.
  • Deep customization: colors, gradients, handle icons, ring shadows/glow, text styles, tick marks, number placement.
  • Accessible out of the box — each handle works with screen readers and switch control.
  • No dependencies beyond the Flutter SDK. Works on mobile, web, and desktop.

Requires Dart >= 3.13.0 (bundled with any recent Flutter stable release). Pure Dart/Flutter — no native setup, no platform channels, nothing to configure per platform.

Default style Fully customized style, 24h format Day & Night style
Default style — a 9h overnight range, 9:00 PM to 6:00 AM Fully customized style, 24h format — coral glow arc with bedtime/sunny handle icons Day & Night style — AM/PM toggle zooming a 12h clock face, moon/sun handle icons

All three pickers above are straight from example/lib/main.dart — run it yourself to try every option below live.

Install

dependencies:
  time_ring_picker: <latest_version>
flutter pub get

Quick start

import 'package:flutter/material.dart';
import 'package:time_ring_picker/time_ring_picker.dart';

TimeRingPicker(
  initialStartTime: const TimeOfDay(hour: 21, minute: 0),
  initialEndTime: const TimeOfDay(hour: 6, minute: 0),
  onTimeChanged: (startTime, endTime, duration) {
    print(startTime);   // 21:00
    print(endTime);     // 06:00
    print(duration);    // 9:00:00.000000
  },
)

That's it — no controller, no setup required.

New here? Read "Understanding the dial" below before you start customizing colors and formats — it explains the two things that aren't obvious at a glance: how overnight duration is calculated, and why the 112 numbers can mean two different times.

Understanding the dial

The dial maps a full 24-hour day onto one 360° rotation (15° per hour), so every angle maps to exactly one time of day — there's no ambiguity between AM and PM at the math level. Duration is always computed forward from start to end, wrapping past midnight when needed:

// start: 21:00, end: 06:00
// diffMinutes = (06:00 - 21:00) = -900 -> +1440 -> 540 minutes -> 9h

If start and end land on the same time, the range is treated as a full 24h selection (a zero-width arc isn't a meaningful range) rather than 0h.

About the 112 numbers: in TimeFormat.h12, only 12 numbers fit around the dial, so each position is shared by two clock hours 12 hours apart (e.g. the 9 mark is either 09:00 or 21:00). style.showAmPmIndicator: true (the default) renders a small AM/PM tag next to each handle disambiguating which one it's on — the handle's own time label is always unambiguous regardless. Switch to TimeFormat.h24 and this goes away entirely: every position gets its own unique hour (0, 2, 4, ... 22), so there's nothing to disambiguate.

Callbacks

TimeRingPicker(
  initialStartTime: const TimeOfDay(hour: 21, minute: 0),
  initialEndTime: const TimeOfDay(hour: 6, minute: 0),
  onDragStart: (handle) {
    // handle is TimeRingHandle.start or TimeRingHandle.end
  },
  onTimeChanged: (startTime, endTime, duration) {
    // fires continuously while dragging
  },
  onDragEnd: (startTime, endTime, duration) {
    // fires once when the drag ends
  },
)

Customizing the look

TimeRingPicker(
  initialStartTime: const TimeOfDay(hour: 22, minute: 30),
  initialEndTime: const TimeOfDay(hour: 5, minute: 45),
  timeFormat: TimeFormat.h24,
  minuteInterval: 15,
  style: const TimeRingPickerStyle(
    size: 280,
    borderWidth: 18,
    backgroundColor: Color(0xFF1B1D28),
    gradientColors: [Color(0xFFFF7E5F), Color(0xFFFEB47B)],
    handleColor: Colors.white,
    handleSize: 24,
    handleBorderColor: Color(0xFFFF7E5F),
    durationTextStyle: TextStyle(
      fontSize: 28,
      fontWeight: FontWeight.bold,
      color: Colors.white,
    ),
    startEndTimeTextStyle: TextStyle(fontSize: 13, color: Colors.white70),
    showTicks: false,
  ),
)
TimeRingPickerStyle field Controls
size Dial diameter
padding Space reserved around the dial
borderWidth Stroke width of the ring/arc
backgroundColor Unselected track color
selectedArcColor Solid fill for the selected arc (used when gradientColors is null)
gradientColors Sweep-gradient colors for the selected arc
handleColor / handleBorderColor / handleBorderWidth Handle fill/border
handleSize Handle diameter
textColor Fallback text color
numberStyle Style of the 112 clock numbers
durationTextStyle Style of the center duration label (e.g. 9h)
startEndTimeTextStyle Style of the start/end time labels
showAmPmIndicator Show/hide the small AM/PM tag near each handle (h12 mode only)
amPmIndicatorBackgroundColor Background pill color behind the AM/PM tag (keeps it legible over nearby clock numbers). Defaults to backgroundColor
showTicks / tickColor Minor tick marks around the dial
numberPosition NumberPosition.outside (default, numbers past the ring) or NumberPosition.inside (numbers between the ring and the center text — smaller overall footprint)
startHandleIcon / endHandleIcon IconData? drawn inside each handle instead of a plain circle (e.g. Icons.bedtime / Icons.wb_sunny). null (default) keeps plain circles
handleIconColor / handleIconSize Color/size of the handle icons, when set. Default to handleBorderColor and 55% of handleSize
ringShadows List<BoxShadow>? flat-color drop shadow(s) behind the ring — same type as BoxDecoration.boxShadow. null (default) draws no shadow
arcGlowBlurRadius / arcGlowSpread / arcGlowOpacity Colored "neon" glow behind the selected arc, reusing its own color/gradient (see below). arcGlowBlurRadius: null (default) draws no glow

Custom handle icons

style: const TimeRingPickerStyle(
  startHandleIcon: Icons.bedtime,
  endHandleIcon: Icons.wb_sunny,
  handleIconColor: Colors.orange,
)

Ring shadow

Two options, depending on the look you want:

  • ringShadows — a flat-color drop shadow behind the whole ring, like BoxDecoration.boxShadow:

    style: const TimeRingPickerStyle(
      ringShadows: [
        BoxShadow(color: Color(0x66FF7E5F), blurRadius: 20, spreadRadius: 2),
      ],
    )
    
  • arcGlowBlurRadius — a colored "neon tube" glow behind just the selected arc, automatically matching its gradientColors/selectedArcColor (no separate color to configure):

    style: const TimeRingPickerStyle(
      gradientColors: [Color(0xFFFFF3B0), Color(0xFFFF9E2C)],
      arcGlowBlurRadius: 18,
      arcGlowSpread: 10,
      arcGlowOpacity: 0.55,
    )
    

Both can extend past the ring — make sure padding leaves enough room, or a tightly-constrained parent may clip it.

Programmatic control

Supply your own TimeRingController to read or set the selection outside of drag gestures:

final controller = TimeRingController(
  initialStartTime: const TimeOfDay(hour: 21, minute: 0),
  initialEndTime: const TimeOfDay(hour: 6, minute: 0),
);

TimeRingPicker(
  initialStartTime: controller.selection.startTime,
  initialEndTime: controller.selection.endTime,
  controller: controller,
  onTimeChanged: (start, end, duration) {},
)

// Elsewhere:
controller.setStartTime(const TimeOfDay(hour: 22, minute: 0));

Platform support

Pure Dart/Flutter, no platform channels — works anywhere Flutter runs: Android, iOS, web, macOS, Windows, Linux. Drag gestures use GestureDetector's pan callbacks, which already unify touch, mouse, and trackpad input — nothing platform-specific to set up.

Performance

The dial is painted in two layers, each in its own RepaintBoundary:

  • Background (ring, ticks, numbers) — only repaints when style/timeFormat change.
  • Foreground (selected arc, handles, center text) — repaints every drag frame.

Dragging a handle never re-lays-out the clock numbers, so it stays smooth even with a heavily customized style.

Accessibility

Each handle exposes an invisible Semantics node with a label (Start time/End time), its current formatted value, and increase/decrease actions — screen readers and switch control can adjust the selection by minuteInterval without needing to perform a drag gesture.

Troubleshooting

Duration shows 24h when I expect 0h. Start and end landing on the same time is treated as a full day selected, not an empty range — see "Understanding the dial" above. If you need a true zero-width state, check startTime == endTime yourself before reading duration.

A handle feels hard to grab / drags the wrong handle. Touch targets are generous by default (handleSize * 2, min 28px), and picking is based on real on-screen distance, not just angle — so this is usually the two handles sitting close together (small minSelectionDuration gap). Increase handleSize or minSelectionDuration if it's still awkward.

The AM/PM tag or a handle icon looks clipped at the edge. Increase style.padding — numbers, the AM/PM tag, and handles are all allowed to paint slightly past the ring's nominal radius, and padding is what reserves room for that.

ringShadows/arcGlowBlurRadius isn't visible. Same cause as above (not enough padding), or the color's alpha is too low — ringShadows colors need their own opacity baked in (e.g. Color(0x66FF7E5F)), while arcGlowOpacity handles that for arcGlowBlurRadius automatically.

onTimeChanged isn't firing. It only fires from user drags (and TimeRingController calls). Setting initialStartTime/initialEndTime on a rebuild does nothing once the widget has mounted — use a TimeRingController and call setStartTime/setEndTime/setSelection if you need to change the value programmatically.

Contributing

Issues and PRs welcome. Run flutter analyze and flutter test before submitting.

Bugs & Credits

Report bugs and ask questions on GitHub Issues. Maintained by Dashstack Infotech, Surat.

Libraries

time_ring_picker
A highly customizable circular clock-style time range picker.