A friend of mine, who works as an organizer, told me that some of her neurodivergent clients are very fond of physical timers for cleaning and count how long it would take in "kvarts". Notably what was helping the most is not the timer itself nor the magic of 15 minutes, but seeing the flow of time.
When I came back home I asked Claude to research visual timer apps and common pain points. Turns out there were a few and I could probably build something that would work.
TL;DR: The app is available on App Store and the code is available on GitHub.
I've been working on Hounty for the past 9 months now so instead of giving that new app idea a try I convinced myself to keep pushing with the existing one.
After I've spent a few hours on Hounty and saw that my sync feature was messing up dates and losing images I threw in a towel: I needed a break. And maybe to learn a little bit more of Flutter? And what's best for learning if not building something small and a bit different?
A Plan
With my plan to build fast, I knew I needed to be strategic about scope. I could dedicate a few evenings and no more to take the app from design to app store submission. And it has to be a fully functional app with a viable business model, no less.
I gave myself permission to try out whatever I wanted. I created a new flutter project and wrote the plan in README.md:
# Kvart
Kvart (from Swedish "kvart", meaning "quarter") is a visual timer app written in Flutter.
It does one thing and does it well: it helps you keep track of time in set intervals.
## How to use
1. Open the app.
2. Select your desired time interval from a preset or add a new one
3. Tap the timer to start/pause/resume.
4. Go on with your work / cleaning / cooking / studying / gaming / exercising / meditating / relaxing...
5. Optionally, tap the "+ 15 min" or another preset to add one more "kvart" (15 minutes) to the current timer
6. When the timer ends, you'll receive a notification - by default, a gentle sound and vibration.
7. Repeat as needed!
## Core concepts
- Start timer in under 3 taps
- Clear visual indication of passing time
- Ability to add one more "kvart" at any moment
- No forced breaks or interruptions
- Reliable background operations & local notifications
- Sensible defaults & customizable intervals and notifications
- Offline only, no account or login required
- Privacy first, no data collection or tracking
This plan wasn't perfect and I changed a few bits in the process, but that's where I started. Notably, there was nothing here about monetization, which, given the fact that I was set on keeping this app open source, was an interesting challenge to adress.
Nonetheless, it was good enough to tell Claude Code to go and build the timer screen: I wanted an "arc" and a background "notch" in specific colors. Claude created a timer.dart
I played a bit with the code and changed a few numbers here and there, asked to add some "realistic notches", scrapped it and settled onto something like:
import 'dart:math';
import 'package:flutter/material.dart';
class Timer extends StatelessWidget {
const Timer({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
backgroundColor: Color(0xFF020C1D),
body: Center(child: TimerView()),
);
}
}
class TimerView extends StatelessWidget {
const TimerView({super.key});
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: TimerArcPainter(0.75),
child: const Center(
child: Text(
'15:00',
style: TextStyle(
color: Colors.white,
fontSize: 80,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
),
);
}
}
class TimerArcPainter extends CustomPainter {
final double progress;
TimerArcPainter(this.progress);
@override
void paint(Canvas canvas, Size size) {
const startAngle = -pi * 1.25;
const sweepAngle = pi * 1.5;
const strokeWidth = 32.0;
final center = Offset(size.width / 2, size.height / 2);
final radius = min(size.width, size.height) * 0.4;
final rect = Rect.fromCircle(center: center, radius: radius);
final progressSweepAngle = sweepAngle * progress;
// Draw the background track
final backgroundPaint = Paint()
..color = const Color(0xFF0A1A30)
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth + 8
..strokeCap = StrokeCap.round;
canvas.drawArc(rect, startAngle, sweepAngle, false, backgroundPaint);
// Draw the main arc
final paint = Paint()
..color = const Color(0xFFC5F974)
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
canvas.drawArc(rect, startAngle, progressSweepAngle, false, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
Counting Time, Reliably
One of the things that came out of Claude research was a very common pain point that timers were stopping or lagging behind when app was backgrounded punishing users for answering the phone, opening another app or, worst of all, placing the phone aside to do the task they were going to do in the first place.
Calculating progress is simple: divide time left by total time, but when the app is in the background it's essentially paused to save resources, so just counting how many seconds have passed is not enough.
I asked Claude to build the ability to track the time by storing timestamps instead. I made a few edits, asked to switch to finite state machine and ended up with this timer_controller.dart:
enum TimerState { idle, running, paused }
class TimerController {
TimerState _state = TimerState.idle;
List<TimeInterval> _intervals = [];
void startTimer() {
if (_state == TimerState.running) return;
_state = TimerState.running;
_intervals.add(TimeInterval(DateTime.now()));
}
void pauseTimer() {
if (_state == TimerState.paused) return;
_state = TimerState.paused;
_intervals.last.close(DateTime.now());
}
void resetTimer() {
_state = TimerState.idle;
_intervals.clear();
}
TimerState get state => _state;
// Stream of seconds elapsed
Stream<int> get elapsedSeconds async* {
while (true) {
final totalDuration = _intervals.fold<Duration>(
Duration.zero,
(sum, interval) => sum + interval.duration,
);
yield totalDuration.inSeconds;
await Future.delayed(const Duration(milliseconds: 500));
}
}
}
class TimeInterval {
final DateTime _start;
DateTime? _end;
TimeInterval(this._start, [this._end]);
Duration get duration {
final endTime = _end ?? DateTime.now();
return endTime.difference(_start);
}
void close(DateTime endTime) {
if (_end != null) return;
_end = endTime;
}
}
It was ticking!
Seven Segment Display
As the timer was ticking, the display was "wobbling" around and switching to a monospace font didn't seem to help without installing one. So I got an idea to build the "digit clock display", Claude helped me out and taught me that it was called "Seven Segment Display":

However I needed to adjust the code quite heavily - some parts were weird and the display looked clunky. Eventually I ended up with this:
import 'package:flutter/material.dart';
/// A widget that displays a single digit (0-9) using a seven-segment display style.
///
/// The segments are arranged like this:
/// _a_
/// f| |b
/// _g_
/// e| |c
/// _d_
class SevenSegmentDigit extends StatelessWidget {
final int? digit;
final Color onColor;
final Color offColor;
final double width;
final double height;
final double segmentThickness;
const SevenSegmentDigit({
super.key,
required this.digit,
required this.onColor,
required this.offColor,
required this.width,
required this.height,
required this.segmentThickness,
}) : assert(
(digit == null || (digit >= 0 && digit <= 9)),
'Digit must be between 0 and 9',
);
/// Returns which segments should be lit for each digit (0-9)
/// Segments: [a, b, c, d, e, f, g]
static List<bool> _getSegmentsForDigit(int? digit) {
switch (digit) {
case 0:
return [true, true, true, true, true, true, false];
case 1:
return [false, true, true, false, false, false, false];
case 2:
return [true, true, false, true, true, false, true];
case 3:
return [true, true, true, true, false, false, true];
case 4:
return [false, true, true, false, false, true, true];
case 5:
return [true, false, true, true, false, true, true];
case 6:
return [true, false, true, true, true, true, true];
case 7:
return [true, true, true, false, false, false, false];
case 8:
return [true, true, true, true, true, true, true];
case 9:
return [true, true, true, true, false, true, true];
default:
return [false, false, false, false, false, false, false];
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.all(segmentThickness / 2),
child: CustomPaint(
size: Size(width, height),
painter: _SevenSegmentPainter(
segments: _getSegmentsForDigit(digit),
onColor: onColor,
offColor: offColor,
segmentThickness: segmentThickness,
),
),
);
}
}
class _SevenSegmentPainter extends CustomPainter {
final List<bool> segments;
final Color onColor;
final Color offColor;
final double segmentThickness;
_SevenSegmentPainter({
required this.segments,
required this.onColor,
required this.offColor,
required this.segmentThickness,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..style = PaintingStyle.fill
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
final t = segmentThickness;
// Calculate segment positions
final topY = t / 2;
final middleY = size.height / 2;
final bottomY = size.height - t / 2;
final leftX = t / 2;
final rightX = size.width - t / 2;
final centerX = size.width / 2;
final height = size.height / 2 - t / 2;
final width = size.width - t;
// Draw each segment
// Segment A (top horizontal)
_drawHorizontalSegment(
canvas,
paint,
Offset(centerX, topY),
width,
segments[0] ? onColor : offColor,
);
// Segment B (top right vertical)
_drawVerticalSegment(
canvas,
paint,
Offset(rightX, middleY - height / 2),
height,
segments[1] ? onColor : offColor,
);
// Segment C (bottom right vertical)
_drawVerticalSegment(
canvas,
paint,
Offset(rightX, middleY + height / 2),
height,
segments[2] ? onColor : offColor,
);
// Segment D (bottom horizontal)
_drawHorizontalSegment(
canvas,
paint,
Offset(centerX, bottomY),
width,
segments[3] ? onColor : offColor,
);
// Segment E (bottom left vertical)
_drawVerticalSegment(
canvas,
paint,
Offset(leftX, middleY + height / 2),
height,
segments[4] ? onColor : offColor,
);
// Segment F (top left vertical)
_drawVerticalSegment(
canvas,
paint,
Offset(leftX, middleY - height / 2),
height,
segments[5] ? onColor : offColor,
);
// Segment G (middle horizontal)
_drawHorizontalSegment(
canvas,
paint,
Offset(centerX, middleY),
width,
segments[6] ? onColor : offColor,
);
}
void _drawHorizontalSegment(
Canvas canvas,
Paint paint,
Offset center,
double length,
Color color,
) {
paint.color = color;
final cx = center.dx;
final cy = center.dy;
final w = length;
final h = segmentThickness;
final path = Path()
..moveTo(cx - w / 2, cy)
..lineTo(cx - w / 2 + h / 2, cy - h / 2)
..lineTo(cx + w / 2 - h / 2, cy - h / 2)
..lineTo(cx + w / 2, cy)
..lineTo(cx + w / 2 - h / 2, cy + h / 2)
..lineTo(cx - w / 2 + h / 2, cy + h / 2)
..close();
canvas.drawPath(path, paint);
}
void _drawVerticalSegment(
Canvas canvas,
Paint paint,
Offset center,
double length,
Color color,
) {
paint.color = color;
final cx = center.dx;
final cy = center.dy;
final w = segmentThickness;
final h = length;
final path = Path()
..moveTo(cx, cy - h / 2)
..lineTo(cx + w / 2, cy - h / 2 + w / 2)
..lineTo(cx + w / 2, cy + h / 2 - w / 2)
..lineTo(cx, cy + h / 2)
..lineTo(cx - w / 2, cy + h / 2 - w / 2)
..lineTo(cx - w / 2, cy - h / 2 + w / 2)
..close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant _SevenSegmentPainter oldDelegate) {
return segments != oldDelegate.segments ||
onColor != oldDelegate.onColor ||
offColor != oldDelegate.offColor ||
segmentThickness != oldDelegate.segmentThickness;
}
}
class DigitSeparatorPainter extends CustomPainter {
final Color color;
final double thickness;
DigitSeparatorPainter({required this.color, required this.thickness});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.fill;
final centerX = size.width / 2;
final centerY1 = size.height * 0.25;
final centerY2 = size.height * 0.75;
// Draw top dot
canvas.drawCircle(Offset(centerX, centerY1), thickness / 2, paint);
// Draw bottom dot
canvas.drawCircle(Offset(centerX, centerY2), thickness / 2, paint);
}
@override
bool shouldRepaint(covariant DigitSeparatorPainter oldDelegate) {
return color != oldDelegate.color || thickness != oldDelegate.thickness;
}
}
class SevenSegmentDisplay extends StatelessWidget {
final int minutes;
final int seconds;
final Color onColor;
final Color offColor;
final double digitWidth;
final double digitHeight;
final double segmentThickness;
final bool disabled;
const SevenSegmentDisplay({
super.key,
required this.minutes,
required this.seconds,
this.onColor = const Color(0xFFFFFFFF),
this.offColor = const Color(0x0DFFFFFF),
this.digitWidth = 48,
this.digitHeight = 80,
this.segmentThickness = 8,
this.disabled = false,
});
@override
Widget build(BuildContext context) {
final minTens = (minutes ~/ 10) % 10;
final minUnits = minutes % 10;
final secTens = (seconds ~/ 10) % 10;
final secUnits = seconds % 10;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
SevenSegmentDigit(
digit: disabled ? null : minTens,
width: digitWidth,
height: digitHeight,
segmentThickness: segmentThickness,
onColor: onColor,
offColor: offColor,
),
SizedBox(width: segmentThickness / 2),
SevenSegmentDigit(
digit: disabled ? null : minUnits,
width: digitWidth,
height: digitHeight,
segmentThickness: segmentThickness,
onColor: onColor,
offColor: offColor,
),
SizedBox(width: segmentThickness / 2),
CustomPaint(
size: Size(segmentThickness * 2, digitHeight),
painter: DigitSeparatorPainter(
color: disabled ? offColor : onColor,
thickness: segmentThickness,
),
),
SizedBox(width: segmentThickness / 2),
SevenSegmentDigit(
digit: disabled ? null : secTens,
width: digitWidth,
height: digitHeight,
segmentThickness: segmentThickness,
onColor: onColor,
offColor: offColor,
),
SizedBox(width: segmentThickness / 2),
SevenSegmentDigit(
digit: disabled ? null : secUnits,
width: digitWidth,
height: digitHeight,
segmentThickness: segmentThickness,
onColor: onColor,
offColor: offColor,
),
],
);
}
}
Finishing Touches
Once the main functionality was working, it took a few prompts and a couple of hours to get the sound to play (I spent more time choosing sounds on Uppbeat), add settings and theming.
At around 2AM I added an in app purchase functionality for the Fallout-inspired amber theme (viable business model, yay!), filled in app submission details and released on TestFlight.
I noticed a few bug with timer going over time after coming from the background, squashed some more and submitted the app for review. Review took more than the development itself this time, but the app passed it on the first try!
I really enjoyed building this fast, and now I'm trying to figure out how to turn the rest of my ideas into micro-apps. Maybe that's what they meant with all the lean development and agile after all 😂
The app is live on App Store and fully open source on GitHub. If you build something with it or create your own micro-app, I'd love to hear about it!

Top comments (0)