Created at : 2025-01-16 18:02
Auther: Soo.Y


πŸ“λ¬Έμ œ

  • 앱은 μ•„λž˜μ™€ 같은 κΈ°λŠ₯을 κ°–κ³ μžˆμ–΄μ•Ό ν•©λ‹ˆλ‹€.

    • μœ μ €κ°€ νƒ€μ΄λ¨Έμ˜ μ‹œκ°„(15, 20, 25, 30, 35)을 선택할 수 μžˆμ–΄μ•Ό ν•©λ‹ˆλ‹€.
    • μœ μ €κ°€ 타이머λ₯Ό μž¬μ„€μ • (리셋)ν•  수 μžˆμ–΄μ•Ό ν•©λ‹ˆλ‹€.
    • μœ μ €κ°€ ν•œ 사이클을 μ™„λ£Œν•œ 횟수λ₯Ό μΉ΄μš΄νŠΈν•΄μ•Ό ν•©λ‹ˆλ‹€.
    • μœ μ €κ°€ 4개의 사이클(1λΌμš΄λ“œ)λ₯Ό μ™„λ£Œν•œ 횟수λ₯Ό μΉ΄μš΄νŠΈν•΄μ•Ό ν•©λ‹ˆλ‹€.
    • 각 λΌμš΄λ“œκ°€ λλ‚˜λ©΄ μ‚¬μš©μžκ°€ 5λΆ„κ°„ νœ΄μ‹μ„ μ·¨ν•  수 μžˆμ–΄μ•Ό ν•©λ‹ˆλ‹€.
  • 이번 κ³Όμ œμ—μ„œ μ€‘μš”ν•œ 것은 λ½€λͺ¨λ„λ‘œ 타이머λ₯Ό 직접 κ΅¬ν˜„ν•˜λŠ” λΆ€λΆ„μž…λ‹ˆλ‹€. λ•Œλ¬Έμ— μ‚¬μ΄ν΄μ˜ νšŸμˆ˜λŠ” μž„μ˜λ‘œ μ„€μ •ν•˜μ…”λ„ λ©λ‹ˆλ‹€.

μ½”λ“œ

import 'package:flutter/material.dart';
import 'dart:async';
 
void main() {
  runApp(const MyApp());
}
 
class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Pomotimer',
      theme: ThemeData(primarySwatch: Colors.red),
      home: const PomodoroTimer(),
    );
  }
}
 
class PomodoroTimer extends StatefulWidget {
  const PomodoroTimer({Key? key}) : super(key: key);
 
  @override
  _PomodoroTimerState createState() => _PomodoroTimerState();
}
 
class _PomodoroTimerState extends State<PomodoroTimer> {
  Timer? _timer;
  int _remainingSeconds = 25 * 60;
 
  final List<int> _timeOptions = [15, 20, 25, 30, 35];
  int _selectedTime = 25;
 
  bool _isRunning = false;
  bool _isBreak = false;
 
  int _cycleCount = 0; 
  int _roundCount = 0; 
 
  
  final int _breakTime = 5;
 
  @override
  void dispose() {
    _timer?.cancel();
    super.dispose();
  }
 
  // 타이머 μ‹œμž‘
  void _startTimer() {
    _isRunning = true;
    _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
      setState(() {
        if (_remainingSeconds > 0) {
          _remainingSeconds--;
        } else {
          // 타이머 λλ‚œ 경우
          _timerEndHandler();
        }
      });
    });
  }
 
  // 타이머 μΌμ‹œμ •μ§€
  void _pauseTimer() {
    _isRunning = false;
    _timer?.cancel();
    setState(() {});
  }
 
  // 타이머 리셋
  void _resetTimer() {
    _timer?.cancel();
    _isRunning = false;
    _isBreak = false;
    // μ΄ˆκΈ°κ°’μ€ _selectedTime에 따라 λΆ„ μ„€μ •
    _remainingSeconds = _selectedTime * 60;
    setState(() {});
  }
 
  // 타이머 μ’…λ£Œ μ‹œ 처리
  void _timerEndHandler() {
    _timer?.cancel();
    _isRunning = false;
 
    if (_isBreak) {
      // νœ΄μ‹ 타이머가 λλ‚œ 경우
      _isBreak = false;
      _resetTimer();
    } else {
      // μž‘μ—… 타이머가 λλ‚œ 경우
      _cycleCount++;
      // 사이클이 4κ°œκ°€ 되면 λΌμš΄λ“œ 1 증가
      if (_cycleCount % 4 == 0) {
        _roundCount++;
      }
      // νœ΄μ‹ μ‹œμž‘
      _startBreak();
    }
    setState(() {});
  }
 
  // νœ΄μ‹ 타이머 μ‹œμž‘
  void _startBreak() {
    _isBreak = true;
    _remainingSeconds = _breakTime * 60;
    _startTimer();
  }
 
  // λΆ„:초 ν˜•νƒœλ‘œ λ³€ν™˜
  String _formatTime(int totalSeconds) {
    final int minutes = totalSeconds ~/ 60;
    final int seconds = totalSeconds % 60;
    final String minutesStr = minutes.toString().padLeft(2, '0');
    final String secondsStr = seconds.toString().padLeft(2, '0');
    return '$minutesStr:$secondsStr';
  }
 
  // μ‚¬μš©μž 선택 μ‹œκ°„ λ³€κ²½
  void _changeTimeOption(int newTime) {
    // 타이머 λ™μž‘ 쀑이면 λ³€κ²½ λΆˆκ°€ν•˜λ„λ‘ ν•  μˆ˜λ„ μžˆμœΌλ‚˜,
    // μ˜ˆμ œμ—μ„œλŠ” κ°„λ‹¨νžˆ 리셋 ν›„ λ³€κ²½ 처리
    _pauseTimer();
    _selectedTime = newTime;
    _resetTimer();
  }
 
  @override
  Widget build(BuildContext context) {
    // 전체 ν…Œλ§ˆ 컬러
    const Color mainColor = Color(0xFFFF5A5F);
 
    return Scaffold(
      backgroundColor: mainColor,
      body: SafeArea(
        child: SingleChildScrollView(
          child: Center(
            child: Padding(
              padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8.0),
              child: Column(
                // 화면이 μž‘μ„ λ•Œ λ„˜μΉ˜μ§€ μ•Šλ„λ‘ 슀크둀 κ°€λŠ₯ν•˜κ²Œ
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  // μ•± 타이틀
                  const Padding(
                    padding: EdgeInsets.only(bottom: 20),
                    child: Text(
                      'POMOTIMER',
                      style: TextStyle(
                        color: Colors.white,
                        fontSize: 22,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                  ),
 
                  // 남은 μ‹œκ°„ ν‘œμ‹œ (λΆ„:초)
                  Text(
                    _formatTime(_remainingSeconds),
                    style: const TextStyle(
                      color: Colors.white,
                      fontSize: 60,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
 
                  // 타이머 길이 선택
                  Padding(
                    padding: const EdgeInsets.symmetric(vertical: 20),
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: _timeOptions.map((time) {
                        return GestureDetector(
                          onTap: () => _changeTimeOption(time),
                          child: Container(
                            margin: const EdgeInsets.symmetric(horizontal: 5),
                            padding: const EdgeInsets.symmetric(
                                horizontal: 15, vertical: 8),
                            decoration: BoxDecoration(
                              color: _selectedTime == time
                                  ? Colors.white.withAlpha((0.8 * 255).toInt())
                                  : Colors.white.withAlpha((0.3 * 255).toInt()),
                              borderRadius: BorderRadius.circular(5),
                            ),
                            child: Text(
                              '$time',
                              style: TextStyle(
                                color: Colors.red.shade700,
                                fontSize: 16,
                                fontWeight: FontWeight.bold,
                              ),
                            ),
                          ),
                        );
                      }).toList(),
                    ),
                  ),
 
                  // μ‹œμž‘/μΌμ‹œμ •μ§€ λ²„νŠΌ
                  IconButton(
                    iconSize: 70,
                    color: Colors.white,
                    icon: Icon(_isRunning
                        ? Icons.pause_circle_filled
                        : Icons.play_circle_fill),
                    onPressed: () {
                      if (_isRunning) {
                        _pauseTimer();
                      } else {
                        _startTimer();
                      }
                    },
                  ),
 
                  const SizedBox(height: 20),
                  ElevatedButton(
                    style: ElevatedButton.styleFrom(
                      backgroundColor: Colors.white.withAlpha((0.9 * 255).toInt()),
                    ),
                    onPressed: _resetTimer,
                    child: Text(
                      'RESET',
                      style: TextStyle(
                        color: Colors.red.shade700,
                        fontSize: 18,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                  ),
 
                  const SizedBox(height: 40),
 
                  // ν•˜λ‹¨μ— 사이클 / λΌμš΄λ“œ μ§„ν–‰ ν˜„ν™© ν‘œμ‹œ
                  Row(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Column(
                        children: [
                          Text(
                            '${_cycleCount % 4}/4',
                            style: const TextStyle(
                              color: Colors.white,
                              fontSize: 22,
                            ),
                          ),
                          const Text(
                            'ROUND',
                            style: TextStyle(
                              color: Colors.white70,
                              fontSize: 14,
                            ),
                          ),
                        ],
                      ),
                      const SizedBox(width: 40),
                      Column(
                        children: [
                          Text(
                            '$_roundCount/12',
                            style: const TextStyle(
                              color: Colors.white,
                              fontSize: 22,
                            ),
                          ),
                          const Text(
                            'GOAL',
                            style: TextStyle(
                              color: Colors.white70,
                              fontSize: 14,
                            ),
                          ),
                        ],
                      ),
                    ],
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}
 

TA의 μ •λ‹΅ ν•΄μ„€

  • 각 MinButton은 SingleChildScrollView μœ„μ—μ„œ μˆ˜ν‰μœΌλ‘œ 슀크둀 λ©λ‹ˆλ‹€.
  • λ°˜λ³΅λ˜λŠ” MinButton 을 List.generate λ₯Ό ν™œμš©ν•˜μ—¬ 5λΆ„ κ°„κ²©μœΌλ‘œ μƒμ„±λ˜κ²Œ ν•˜μ˜€μŠ΅λ‹ˆλ‹€.
  • TimeTile의 경우 Stack, Positioned, Opacity 의 μ‘°ν•©μœΌλ‘œ μΉ΄λ“œκ°€ μŒ“μΈ 효과λ₯Ό λ§Œλ“€μ—ˆμŠ΅λ‹ˆλ‹€.
  • μž¬μƒ λ²„νŠΌμ„ λˆ„λ₯΄λŠ” μˆœκ°„ countDown ν•¨μˆ˜κ°€ μ‹€ν–‰λ˜κ³  0.001 초 에 ν•œ λ²ˆμ”© μ•„λž˜ λ‘œμ§μ„ λ°˜λ³΅ν•©λ‹ˆλ‹€.
    • 1초 κ°μ†Œ
    • 0μ΄ˆκ°€ 된 경우 μ§€μ •λœ μ‹œκ°„μœΌλ‘œ reset
    • break λͺ¨λ“œμ™€ round λͺ¨λ“œ λ³€κ²½
    • round λͺ¨λ“œμ˜€λ˜ 경우 round 와 goal 을 κ°±μ‹ 
    • μž¬μƒμ—¬λΆ€ false μ„€μ •
    • μž¬μƒμ€‘μ΄κ³  μ‹œκ°„μ΄ λ‚¨μ•˜λŠ”μ§€ 여뢀에 따라 루프 μ’…λ£Œ
  • StatefullWidget 을 λ§Œλ“€κ³  각 state 의 변화에 따라 루프λ₯Ό λ°˜λ³΅ν•˜κ³  μœ„μ ―μ„ λ‹€μ‹œ λ Œλ”λ§ ν•˜λŠ” μ—°μŠ΅μ„ ν•  수 μžˆλŠ” κ³Όμ œμ˜€μŠ΅λ‹ˆλ‹€.

main.dart

import 'package:flutter/material.dart';
import 'package:pomodoro/views/pomodoro.dart';
 
void main() {
  runApp(const App());
}
 
class App extends StatelessWidget {
  const App({super.key});
 
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Pomodoro',
      theme: ThemeData(
        primaryColor: Colors.green[200],
      ),
      home: const Pomodoro(),
    );
  }
}

views/pomodoro.dart

import 'package:flutter/material.dart';
import 'package:pomodoro/widgets/min_button.dart';
 
import '../widgets/time_tile.dart';
 
class Pomodoro extends StatefulWidget {
  const Pomodoro({Key? key}) : super(key: key);
 
  @override
  State<Pomodoro> createState() => _PomodoroState();
}
 
class _PomodoroState extends State<Pomodoro> {
  static final List<int> mins = List.generate(7, (i) => 5 + i * 5).toList();
  static const _initMin = 15;
  int targetMin = _initMin;
  int sec = _initMin * 60;
  bool playing = false;
  bool isBreak = false;
  int round = 1;
  int goal = 0;
 
  void countDown() {
    Future.doWhile(() async {
      await Future.delayed(const Duration(milliseconds: 1));
      setState(() {
        sec = sec - 1;
      });
 
      if (sec == 0) {
        sec = isBreak ? targetMin * 60 : 5 * 60;
        isBreak = !isBreak;
        setRoundGoal();
        playing = false;
        setState(() {});
 
        return false;
      }
 
      return playing && sec > 0;
    });
  }
 
  void setRoundGoal() {
    if (isBreak) {
      return;
    }
 
    if (round >= 4) {
      round = 1;
      goal++;
    } else {
      round++;
    }
  }
 
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: isBreak ? Colors.purple : Theme.of(context).primaryColor,
      appBar: AppBar(
        elevation: 0,
        backgroundColor:
            isBreak ? Colors.purple : Theme.of(context).primaryColor,
        title: Row(
          mainAxisAlignment: MainAxisAlignment.start,
          children: const [
            Text(
              'Minchodoro',
              textAlign: TextAlign.start,
              style: TextStyle(
                fontWeight: FontWeight.bold,
              ),
            ),
          ],
        ),
      ),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.spaceAround,
        children: [
          isBreak
              ? Text(
                  'Break till bored',
                  style: TextStyle(
                    fontSize: 24,
                    fontWeight: FontWeight.bold,
                    color: Theme.of(context).primaryColor,
                  ),
                )
              : const Text(
                  'Work till die',
                  style: TextStyle(
                    fontSize: 24,
                    fontWeight: FontWeight.bold,
                    color: Colors.purple,
                  ),
                ),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              TimeTile(
                time: (sec / 60).floor(),
              ),
              const Text(
                ':',
                style: TextStyle(
                  color: Colors.white,
                  fontSize: 32,
                ),
              ),
              TimeTile(time: sec % 60)
            ],
          ),
          SingleChildScrollView(
            scrollDirection: Axis.horizontal,
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                for (var min in mins)
                  MinButton(
                    min: min,
                    selected: min == targetMin,
                    onPressed: () => setState(() {
                      targetMin = min;
                      sec = min * 60;
                    }),
                  )
              ],
            ),
          ),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              InkWell(
                onTap: () => setState(() {
                  playing = !playing;
                  if (playing) {
                    countDown();
                  }
                }),
                child: Container(
                  width: 64,
                  height: 64,
                  decoration: BoxDecoration(
                    shape: BoxShape.circle,
                    color: Colors.grey.shade700,
                  ),
                  child: Icon(
                    playing ? Icons.pause : Icons.play_arrow,
                    color: Colors.white,
                    size: 48,
                  ),
                ),
              ),
              const SizedBox(width: 20),
              InkWell(
                onTap: () => setState(() {
                  playing = false;
                  sec = targetMin * 60;
                }),
                child: Container(
                  width: 64,
                  height: 64,
                  decoration: BoxDecoration(
                    shape: BoxShape.circle,
                    color: Colors.grey.shade700,
                  ),
                  child: const Icon(
                    Icons.replay,
                    color: Colors.white,
                    size: 48,
                  ),
                ),
              ),
            ],
          ),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text('Round: $round/4'),
              const SizedBox(width: 20),
              Text('Goal: $goal/12'),
            ],
          ),
        ],
      ),
    );
  }
}

widgets/min_buttion.dart

import 'package:flutter/material.dart';
 
class MinButton extends StatelessWidget {
  final int min;
  final bool selected;
  final VoidCallback onPressed;
 
  const MinButton({
    Key? key,
    required this.min,
    required this.selected,
    required this.onPressed,
  }) : super(key: key);
 
  @override
  Widget build(BuildContext context) {
    return Container(
      margin: const EdgeInsets.symmetric(horizontal: 4),
      child: TextButton(
        onPressed: onPressed,
        style: TextButton.styleFrom(
          fixedSize: const Size(84, 48),
          backgroundColor: selected ? Colors.white : Colors.transparent,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(8),
            side: BorderSide(
              width: 1,
              color: Colors.grey.shade700,
            ),
          ),
        ),
        child: Text(
          min < 10 ? "0${min.toString()}" : min.toString(),
          style: TextStyle(
            color: selected ? Colors.black : Colors.white,
          ),
        ),
      ),
    );
  }
}

widgets/time_title.dart

import 'package:flutter/material.dart';
 
class TimeTile extends StatelessWidget {
  final int time;
 
  const TimeTile({
    Key? key,
    required this.time,
  }) : super(key: key);
 
  @override
  Widget build(BuildContext context) {
    return Stack(
      clipBehavior: Clip.none,
      children: [
        Positioned.fill(
          top: -20,
          child: Container(
            margin: const EdgeInsets.symmetric(horizontal: 4),
            decoration: BoxDecoration(
              color: Colors.white.withOpacity(0.2),
              borderRadius: BorderRadius.circular(8),
            ),
          ),
        ),
        Positioned.fill(
          top: -15,
          child: Container(
            margin: const EdgeInsets.symmetric(horizontal: 3),
            decoration: BoxDecoration(
              color: Colors.white.withOpacity(0.3),
              borderRadius: BorderRadius.circular(8),
            ),
          ),
        ),
        Positioned.fill(
          top: -10,
          child: Container(
            margin: const EdgeInsets.symmetric(horizontal: 2),
            decoration: BoxDecoration(
              color: Colors.white.withOpacity(0.4),
              borderRadius: BorderRadius.circular(8),
            ),
          ),
        ),
        Positioned.fill(
          top: -5,
          child: Container(
            margin: const EdgeInsets.symmetric(horizontal: 1),
            decoration: BoxDecoration(
              color: Colors.white.withOpacity(0.5),
              borderRadius: BorderRadius.circular(8),
            ),
          ),
        ),
        Container(
          decoration: BoxDecoration(
            color: Colors.white,
            borderRadius: BorderRadius.circular(8),
          ),
          padding: const EdgeInsets.all(48),
          child: Text(
            time < 10 ? "0${time.toString()}" : time.toString(),
            style: const TextStyle(fontSize: 48, color: Colors.purple),
          ),
        )
      ],
    );
  }
}

πŸ“œμΆœμ²˜(μ°Έκ³  λ¬Έν—Œ)


πŸ”—μ—°κ²° λ¬Έμ„œ