만든 이유
QR 스캐너를 만들면서 카메라 권한, ML Kit 의존성 같은 복잡한 네이티브 연동을 경험했다. 이번엔 그 반대편을 파보고 싶었다. 앱 자체 UI 완성도와 로컬 알림 조합. 타이머는 별거 아닌 것 같지만, 원형 진행 링을 직접 그리고, 백그라운드 알림을 붙이면 생각보다 챙길 게 많다.
기술 선택
| 항목 | 선택 | 이유 |
|---|---|---|
| 알림 | flutter_local_notifications 18.0 |
가장 범용적, iOS/Android 동시 지원 |
| 사운드 | SystemSound.play(SystemSoundType.alert) |
에셋 파일 없이 시스템 사운드 사용 |
| 진동 | HapticFeedback.heavyImpact() |
1줄로 끝, 플랫폼 네이티브 피드백 |
| 원형 UI | CustomPainter |
패키지 없이 직접 그려서 디자인 자유도 확보 |
| 펄스 효과 | AnimationController |
SingleTickerProviderStateMixin + repeat |
audioplayers 같은 패키지를 처음에 고려했지만 오디오 에셋 파일을 번들에 포함해야 한다. 시스템 사운드로 충분하다면 SystemSound가 훨씬 간단하다.
앱 구조
lib/
├── main.dart # 앱 진입, 알림 초기화
├── timer_screen.dart # 타이머 UI 전체
└── services/
└── notification_service.dart # 로컬 알림 래퍼
단일 화면이라 탭 구조도 없다. 상태 관리도 StatefulWidget 하나로 충분하다.
핵심 구현
알림 초기화 — main.dart
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await NotificationService.init();
runApp(const TimerApp());
}
flutter_local_notifications는 runApp 전에 초기화해야 한다. WidgetsFlutterBinding.ensureInitialized() 없이 await를 쓰면 플랫폼 채널이 준비되지 않아 크래시 난다.
알림 서비스 — notification_service.dart
class NotificationService {
static final _plugin = FlutterLocalNotificationsPlugin();
static Future<void> init() async {
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
);
await _plugin.initialize(
const InitializationSettings(android: androidSettings, iOS: iosSettings),
);
}
static Future<bool> requestPermission() async {
final ios = _plugin.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>();
if (ios != null) {
return await ios.requestPermissions(alert: true, badge: true, sound: true) ?? false;
}
return true;
}
static Future<void> showTimerDone(String label) async {
await _plugin.show(
0, '타이머 종료 ⏰', '$label 완료!',
const NotificationDetails(
android: AndroidNotificationDetails('timer_channel', '타이머 알림',
importance: Importance.max, priority: Priority.high),
iOS: DarwinNotificationDetails(
presentAlert: true, presentBadge: true, presentSound: true),
),
);
}
}
iOS는 init 시점에 권한을 요청하지 않는다. 앱 시작 직후 권한 팝업이 뜨는 건 UX상 좋지 않다. 헤더 배지를 탭했을 때 requestPermission()을 호출해서 사용자가 원할 때만 요청하도록 했다.
타이머 상태 관리
enum TimerState { idle, running, paused, done }
const _presets = [
TimerPreset('5분', 5 * 60),
TimerPreset('10분', 10 * 60),
TimerPreset('25분', 25 * 60), // 뽀모도로
TimerPreset('30분', 30 * 60),
TimerPreset('1시간', 60 * 60),
];
void _start() {
setState(() => _state = TimerState.running);
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (_remainingSeconds <= 0) {
_onTimerDone();
return;
}
setState(() => _remainingSeconds--);
});
}
void _onTimerDone() {
_timer?.cancel();
setState(() { _remainingSeconds = 0; _state = TimerState.done; });
HapticFeedback.heavyImpact();
SystemSound.play(SystemSoundType.alert);
NotificationService.showTimerDone(_presets[_selectedPresetIndex].label);
}
Timer.periodic은 취소하지 않으면 위젯 dispose 후에도 계속 돈다. dispose()에서 반드시 _timer?.cancel()해야 한다.
원형 링 — CustomPainter
class _RingPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = (size.width - strokeWidth) / 2;
// 글로우 효과
if (glow && glowOpacity > 0) {
final glowPaint = Paint()
..color = color.withAlpha((glowOpacity * 255).toInt())
..strokeWidth = strokeWidth + 8
..style = PaintingStyle.stroke
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8);
canvas.drawArc(/* ... */);
}
// 메인 링
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-math.pi / 2, // 12시 방향에서 시작
-2 * math.pi * progress, // 시계 반대 방향으로 줄어듦
false,
paint,
);
}
}
drawArc의 시작각 -math.pi / 2는 12시 방향. sweep각을 음수(-2π×progress)로 하면 시계 반대 방향으로 진행된다. 링이 줄어드는 방향이라 "남은 시간"을 직관적으로 표현한다.
글로우는 동일한 경로를 MaskFilter.blur가 적용된 더 굵은 선으로 한 번 더 그려서 만든다. 별도 라이브러리 없이 흐릿한 빛 효과를 낼 수 있다.
시간 표시 — tabularFigures
Text(
_timeString, // "25:00" → "00:00"
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
)
FontFeature.tabularFigures()가 없으면 09:59에서 10:00로 바뀔 때 숫자 너비 차이로 레이아웃이 살짝 흔들린다. 등폭 숫자로 고정하면 MM:SS 표시가 안정적으로 유지된다.
링 색상 — 남은 시간 기반
Color get _ringColor {
if (_state == TimerState.done) return const Color(0xFF10b981); // 완료: 초록
if (_progress > 0.5) return const Color(0xFF6366f1); // 절반 이상: 인디고
if (_progress > 0.2) return const Color(0xFFf59e0b); // 20~50%: 앰버
return const Color(0xFFef4444); // 20% 이하: 빨강
}
비교 테이블: 알림 방식
| 방식 | 장점 | 단점 |
|---|---|---|
flutter_local_notifications |
iOS/Android 통합, 채널 설정 가능 | 초기 설정 코드 많음 |
awesome_notifications |
리치 알림, 액션 버튼 | 더 복잡 |
SystemSound.play |
에셋 없이 즉시 사용 | 앱 포그라운드 상태에서만 작동 |
AudioPlayer |
커스텀 사운드 | 에셋 파일 필요, 빌드 설정 추가 |
타이머 종료 알림은 flutter_local_notifications로, 즉각적인 소리/진동은 SystemSound + HapticFeedback으로 이중 처리했다. 앱이 포그라운드면 두 가지 다 작동, 백그라운드면 로컬 알림만 전달된다.
삽질 기록
audioplayers 제거
처음엔 audioplayers를 추가해서 커스텀 소리를 내려고 했다. 그런데 audioplayers는 assets/sounds/timer_done.mp3 같은 에셋 파일을 pubspec.yaml에 등록하고 실제 파일도 폴더에 넣어야 한다. 튜토리얼 예제에서 오디오 파일 관리까지 들어가면 핵심에서 벗어난다. SystemSound.play(SystemSoundType.alert)로 교체하니 에셋 없이도 작동하고 코드도 1줄로 줄었다.
iOS 알림 권한 타이밍
DarwinInitializationSettings에서 requestAlertPermission: true로 설정하면 앱 최초 실행 시 즉시 권한 팝업이 뜬다. 이게 맞는 경우도 있지만, 타이머 앱처럼 "필요할 때 요청"하는 게 더 자연스러운 경우엔 false로 두고 UI에서 별도 버튼으로 요청하는 방식이 낫다. 헤더에 "🔔 알림 허용" 배지를 배치하고 탭하면 requestPermission()을 호출하도록 했다.
Timer.periodic dispose 누락
개발 중 hot reload를 반복하다 보면 타이머가 취소되지 않은 채 새 인스턴스가 쌓이는 경우가 있다. dispose() 오버라이드에서 _timer?.cancel()과 _pulseController.dispose()를 반드시 함께 처리해야 한다. AnimationController 누수도 마찬가지 — _pulseController가 dispose 없이 남으면 메모리 경고가 발생한다.
마무리
타이머 앱의 핵심은 UI가 아니라 상태 관리다. idle → running → paused → done 4가지 상태가 버튼 동작, 링 색상, 텍스트 표시를 전부 결정한다. CustomPainter로 직접 그리는 경험은 처음엔 낯설지만, drawArc 하나로 원하는 진행 링을 그릴 수 있다는 걸 알면 패키지 의존도를 줄일 수 있다.
기술 스택: Flutter 3.32 · Dart 3.8 · flutter_local_notifications 18.0 · CustomPainter · iOS · Android
소스 코드: GitHub
'튜토리얼 > 앱' 카테고리의 다른 글
| [Flutter] Hive로 메모앱 만들기 — NoSQL 로컬 저장소 + TypeAdapter (0) | 2026.03.03 |
|---|---|
| [Flutter] QR 코드 생성/스캔 앱 만들기 — qr_flutter + mobile_scanner (0) | 2026.03.03 |
| [Flutter] 가계부 앱 만들기 — sqflite로 로컬 저장 구현 (0) | 2026.03.03 |