Development/Dart&Flutter

[Flutter] 운동용 Interval Timer 앱 개발

사이바 미도리 2024. 9. 17. 16:11

개요

  • 내가 필요한데다, 난이도도 적당해보여 만들어보기로 했다.

 


파일네이밍규칙

  • w_{name}.dart
    • Widget
  • s_{name}.dart
    • Screen -> 전체 디바이스를 덮는 화면
  • f_{name}.dart
    • Fragment -> Screen 내부에 일부로 존재하는 큰 화면
  • d_{name}.dart
    • Dialog -> Dialog 또는 Bottom Sheet
  • vo_{name}.dart
    • Value Object -> UI에서 사용하는 객체
  • dto_{name}.dart
    • Data Transfer Object -> 통신/데이터 저장에 사용되는 객체
  • 그외
    • 소문자+숫자+_ 조합

장점: 파일이름 짧아지고, 파일명 정렬시 한눈에 보이며, 해당 파일의 UI내 역할을 파일명으로 이해가능.

 


개발단계

 

레이아웃 와꾸잡기

  • Main 화면은, Column 4개 및 NavigatorBottomBar로 구현한다.
    • Column은 위처럼 UI를 구성하고,
    • BottomBar는 Home과 Setting으로 이원화한다.
    • Play 버튼 좌측의 빈 공간은, 뛰어다니는 캐릭터 스프라이트를 배치해서 포인트를 주자.

 


본격 개발시작.

  • 처음에는 Widget간 FSM을 정의하고 그대로 구현하려고 했으나,

실패한 Architecture.

  • FSM으로 통제하려고 하니, 너무나도 복잡하고 예외처리가 어려웠다.
  • SW는 SW답게, Timer의 Tick마다 어떤 행동을 수행할지를 코드로 작성하기로 했고, 이 편이 훨씬 수월히 진행되었다.

스프라이트 획득: itch.io 활용

 You are free to use it in any project as long as it abides by the Touhou Project fan content guidelines!
-
Kosuzu Motoori Sprite Pack
  •  

난제: 다른 file의 Widget 내부의 메소드를 호출할 수 있는가?

  • 결론: not recommended인듯 하다. 출처

음 찾아보니까 다른 솔루션들이 있는데?

결론: 소스코드 하나에 다 때려박자.

  • 근거: 어차피 기능 단순해서, 소스코드 하나에 몰아서 짜는게 차라리 품이 덜 들 것 같다.
    • => 매우 좋은 판단이었음.

난제: Timer runtime중에, _maxExcerciseSeconds 가 줄어들면, height계산시 음수가 나온다.

  • 말그대로, runtime중에 _maxExcerciseSeconds가 줄어들면 그게 real-time으로 반영이되어버려 붉은 배경 height 계산할때 음수가 나와 프로그램이 터지는 버그가 있었다.
    • nextMaxExcerciseSeconds 및 currMaxExcerciseSeconds 를 따로 두고,
      refresh버튼을 눌렀을때만 curr = next 초기화가 되게끔 설계하였다.

결과물

  • 가장 마음에 드는 기능은, 붉은배경이 차오르다가, 휴식할때 다시 내려가는 부분이다.

 

코드 전체

import 'dart:async';

import 'package:flutter/material.dart';

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  int _selectedIndex = 0;
  void _onItemTapped(int index) {
    setState(() {
      _selectedIndex = index;
    });
  }

  final TextEditingController _hoursExerciseController =
      TextEditingController();
  final TextEditingController _minutesExerciseController =
      TextEditingController();
  final TextEditingController _secondsExerciseController =
      TextEditingController();

  final TextEditingController _hoursRestController = TextEditingController();
  final TextEditingController _minutesRestController = TextEditingController();
  final TextEditingController _secondsRestController = TextEditingController();

  late Timer _timer;
  int _setCount = 0;
  bool isTimerActive = false;

  int _nextMaxExcerciseSeconds = 1 * 60; // one minutes
  int _currMaxExcerciseSeconds = 1 * 60; // one minutes
  late int _remainingExcerciseSeconds;
  bool isExcerciseMode = true;

  int _nextMaxRestSeconds = 2 * 60; // one minutes
  int _currMaxRestSeconds = 2 * 60; // two minutes
  late int _remainingRestSeconds;

  late Timer _imageTimer;
  int _currentImageIndex = 0;
  static const String _relativeImagePath = 'assets/images/kosuzu_run/';
  static const List<String> _imagePaths = [
    'run1.png',
    'run2.png',
    'run3.png',
    'run4.png',
    'run5.png',
    'run6.png',
    'run7.png',
    'run8.png',
  ];
  String get _currentImagePath =>
      (_relativeImagePath + _imagePaths[_currentImageIndex]).toString();

  @override
  void initState() {
    super.initState();

    _hoursExerciseController.text = '0';
    _minutesExerciseController.text = '0';
    _secondsExerciseController.text = '10';
    _hoursRestController.text = '0';
    _minutesRestController.text = '0';
    _secondsRestController.text = '5';

    _currMaxExcerciseSeconds = controllers2Int(
      _hoursExerciseController,
      _minutesExerciseController,
      _secondsExerciseController,
    );
    _currMaxRestSeconds = controllers2Int(
      _hoursRestController,
      _minutesRestController,
      _secondsRestController,
    );
    _remainingExcerciseSeconds = _currMaxExcerciseSeconds;
    _remainingRestSeconds = _currMaxRestSeconds;
  }

  @override
  void dispose() {
    _timer.cancel();
    _hoursExerciseController.dispose();
    _minutesExerciseController.dispose();
    _secondsExerciseController.dispose();
    _hoursRestController.dispose();
    _minutesRestController.dispose();
    _secondsRestController.dispose();
    super.dispose();
  }

  String format(int seconds) {
    var duration = Duration(seconds: seconds);
    return duration.toString().split(".").first.substring(2, 7);
  }

  int controllers2Int(
    TextEditingController hoursController,
    TextEditingController minutesController,
    TextEditingController secondsController,
  ) {
    int hours = int.tryParse(hoursController.text) ?? 0;
    int minutes = int.tryParse(minutesController.text) ?? 0;
    int seconds = int.tryParse(secondsController.text) ?? 0;
    return hours * 3600 + minutes * 60 + seconds;
  }

  // return Normalized result: from 0 to 1
  double getFillHeight() {
    double progress;
    if (isExcerciseMode) {
      progress = 1 -
          (_remainingExcerciseSeconds /
              (_currMaxExcerciseSeconds != 0
                  ? _currMaxExcerciseSeconds
                  : _remainingExcerciseSeconds));
    } else {
      progress = (_remainingRestSeconds /
          (_currMaxRestSeconds != 0
              ? _currMaxRestSeconds
              : _remainingRestSeconds));
    }
    debugPrint("getFillHeight: isExerciseMode $isExcerciseMode");
    debugPrint(
        "getFillHeight: _remainingExcerciseSeconds $_remainingExcerciseSeconds");
    debugPrint(
        "getFillHeight: _currMaxExcerciseSeconds $_currMaxExcerciseSeconds");
    debugPrint("getFillHeight: _remainingRestSeconds $_remainingRestSeconds");
    debugPrint("getFillHeight: _currMaxRestSeconds $_currMaxRestSeconds");
    debugPrint("getFillHeight: return $progress");
    return progress;
  }

  // return Normalized result: from 0 to 1
  Color getBackgroundColor() {
    double progress;
    if (isExcerciseMode) {
      progress = 1 -
          (_remainingExcerciseSeconds /
              (_currMaxExcerciseSeconds != 0
                  ? _currMaxExcerciseSeconds
                  : _remainingExcerciseSeconds));
    } else {
      progress = (_remainingRestSeconds /
          (_currMaxRestSeconds != 0
              ? _currMaxRestSeconds
              : _remainingRestSeconds));
    }
    debugPrint("getBackgroundColor: isExerciseMode $isExcerciseMode");
    debugPrint(
        "getBackgroundColor: _remainingExcerciseSeconds $_remainingExcerciseSeconds");
    debugPrint(
        "getBackgroundColor: _currMaxExcerciseSeconds $_currMaxExcerciseSeconds");
    debugPrint(
        "getBackgroundColor: _remainingRestSeconds $_remainingRestSeconds");
    debugPrint("getBackgroundColor: _currMaxRestSeconds $_currMaxRestSeconds");
    debugPrint("getBackgroundColor: return $progress");
    return ColorTween(begin: Colors.white, end: Colors.red).lerp(progress)!;
  }

  void onImageTick(Timer imageTimer) {
    setState(() {
      _currentImageIndex = (_currentImageIndex + 1) % _imagePaths.length;
    });
  }

  void onTick(Timer timer) {
    setState(() {
      if (isTimerActive) {
        // play mode
        if (isExcerciseMode) {
          // excercise counting
          if (_remainingExcerciseSeconds <= 0) {
            isExcerciseMode = !isExcerciseMode;
          } else {
            _remainingExcerciseSeconds--;
          }
        } else {
          // rest counting
          if (_remainingRestSeconds <= 0) {
            isExcerciseMode = !isExcerciseMode;
            _remainingExcerciseSeconds = _currMaxExcerciseSeconds;
            _remainingRestSeconds = _currMaxRestSeconds;
            _setCount++;
          } else {
            _remainingRestSeconds--;
          }
        }
      }
    });
  }

  void startTimer() {
    _timer = Timer.periodic(const Duration(seconds: 1), onTick);
    _imageTimer = Timer.periodic(const Duration(milliseconds: 40), onImageTick);
    setState(() {
      isTimerActive = true;
    });
  }

  void stopTimer() {
    setState(() {
      isTimerActive = false;
    });
    _imageTimer.cancel();
  }

  void resetTimer() {
    _timer.cancel();
    _imageTimer.cancel();
    setState(() {
      isTimerActive = false;
      _currMaxExcerciseSeconds = _nextMaxExcerciseSeconds;
      _currMaxRestSeconds = _nextMaxRestSeconds;
      _remainingExcerciseSeconds = _currMaxExcerciseSeconds;
      _remainingRestSeconds = _currMaxRestSeconds;
      _setCount = 0;
      isExcerciseMode = true;
    });
  }

  void onPlayPressed() {
    startTimer();
  }

  void onPausePressed() {
    stopTimer();
  }

  void onRefreshPressed() {
    resetTimer();
  }

  void updateMaxTime() {
    setState(() {
      _nextMaxExcerciseSeconds = controllers2Int(
        _hoursExerciseController,
        _minutesExerciseController,
        _secondsExerciseController,
      );
      _nextMaxRestSeconds = controllers2Int(
        _hoursRestController,
        _minutesRestController,
        _secondsRestController,
      );
    });
  }

  @override
  Widget build(BuildContext context) {
    List<Widget> widgetOptions = <Widget>[
      Container(
        padding: const EdgeInsets.only(
          top: 75.0,
        ), // 상단바 간격 확보 -> 시간 및 와이파이, 배터리잔량칸 잡아먹지않도록.
        child: LayoutBuilder(
          builder: (BuildContext context, BoxConstraints constraints) {
            return Stack(
              children: [
                Positioned.fill(
                  child: Align(
                    alignment: Alignment.bottomCenter,
                    child: AnimatedContainer(
                      duration: const Duration(seconds: 1),
                      height: constraints.maxHeight * getFillHeight(),
                      color: getBackgroundColor(),
                    ),
                  ),
                ),
                Column(
                  children: [
                    Center(
                      child: Text('Set: ${_setCount.toString()}',
                          style: const TextStyle(
                            fontSize: 50,
                            fontWeight: FontWeight.bold,
                          )),
                    ),
                    Expanded(
                      child: Padding(
                        padding: const EdgeInsets.all(15.0),
                        child: Container(
                          decoration: BoxDecoration(
                            border: Border.all(
                              color: Colors.black,
                              width: 5.0,
                            ),
                          ),
                          child: Row(
                            children: [
                              const Text(
                                'Exercise: ',
                                style: TextStyle(
                                  fontSize: 30,
                                ),
                              ),
                              const Spacer(),
                              Text(
                                format(_remainingExcerciseSeconds),
                                style: const TextStyle(
                                  fontSize: 50,
                                  fontWeight: FontWeight.bold,
                                ),
                              ),
                            ],
                          ),
                        ),
                      ),
                    ),
                    Expanded(
                      child: Padding(
                        padding: const EdgeInsets.all(15.0),
                        child: Container(
                          decoration: BoxDecoration(
                            border: Border.all(
                              color: Colors.black,
                              width: 5.0,
                            ),
                          ),
                          child: Row(
                            children: [
                              const Text(
                                'Rest: ',
                                style: TextStyle(
                                  fontSize: 30,
                                ),
                              ),
                              const Spacer(),
                              Text(
                                format(_remainingRestSeconds),
                                style: const TextStyle(
                                  fontSize: 50,
                                  fontWeight: FontWeight.bold,
                                ),
                              ),
                            ],
                          ),
                        ),
                      ),
                    ),
                    Flexible(
                      child: Center(
                        child: Row(
                          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                          children: [
                            Padding(
                              padding: const EdgeInsets.only(bottom: 50.0),
                              child: Image.asset(
                                _currentImagePath,
                                width: 64,
                                height: 128,
                                fit: BoxFit.cover,
                              ),
                            ),
                            // Image.asset('assets/images/kosuzu_run/run3.png'),
                            // const IconButton(
                            //   icon: Icon(Icons.question_mark),
                            //   iconSize: 50,
                            //   onPressed: null,
                            // ),
                            isTimerActive == true
                                ? IconButton(
                                    icon: const Icon(Icons.pause),
                                    iconSize: 50,
                                    onPressed: onPausePressed,
                                  )
                                : IconButton(
                                    icon: const Icon(Icons.play_arrow),
                                    iconSize: 50,
                                    onPressed: onPlayPressed,
                                  ),
                            IconButton(
                              icon: const Icon(Icons.refresh),
                              iconSize: 50,
                              onPressed: onRefreshPressed,
                            ),
                          ],
                        ),
                      ),
                    ),
                  ],
                ),
              ],
            );
          },
        ),
      ),
      Column(
        children: [
          SizedBox(
              height: MediaQuery.of(context).padding.top), // 상단바 제외한 면적만 사용할 의도
          Card(
            margin: const EdgeInsets.all(15.0),
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(10.0),
              side: const BorderSide(
                color: Colors.black,
                width: 2.0,
              ),
            ),
            child: Padding(
              padding: const EdgeInsets.all(15.0),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: <Widget>[
                  const Text(
                    'Exercise Settings',
                    style: TextStyle(
                      fontSize: 20,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  const SizedBox(height: 10),
                  ListTile(
                    title: TextField(
                      controller: _hoursExerciseController,
                      keyboardType: TextInputType.number,
                      decoration: const InputDecoration(
                        labelText: 'Enter hours',
                      ),
                      onChanged: (text) => updateMaxTime(),
                    ),
                  ),
                  const SizedBox(height: 10),
                  ListTile(
                    title: TextField(
                      controller: _minutesExerciseController,
                      keyboardType: TextInputType.number,
                      decoration: const InputDecoration(
                        labelText: 'Enter minutes',
                      ),
                      onChanged: (text) => updateMaxTime(),
                    ),
                  ),
                  const SizedBox(height: 10),
                  ListTile(
                    title: TextField(
                      controller: _secondsExerciseController,
                      keyboardType: TextInputType.number,
                      decoration: const InputDecoration(
                        labelText: 'Enter seconds',
                      ),
                      onChanged: (text) => updateMaxTime(),
                    ),
                  ),
                ],
              ),
            ),
          ),
          const SizedBox(height: 20),
          Card(
            margin: const EdgeInsets.all(15.0),
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(10.0),
              side: const BorderSide(
                color: Colors.black,
                width: 2.0,
              ),
            ),
            child: Padding(
              padding: const EdgeInsets.all(15.0),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: <Widget>[
                  const Text(
                    'Rest Settings',
                    style: TextStyle(
                      fontSize: 20,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  const SizedBox(height: 10),
                  ListTile(
                    title: TextField(
                      controller: _hoursRestController,
                      keyboardType: TextInputType.number,
                      decoration: const InputDecoration(
                        labelText: 'Enter hours',
                      ),
                      onChanged: (text) => updateMaxTime(),
                    ),
                  ),
                  const SizedBox(height: 10),
                  ListTile(
                    title: TextField(
                      controller: _minutesRestController,
                      keyboardType: TextInputType.number,
                      decoration: const InputDecoration(
                        labelText: 'Enter minutes',
                      ),
                      onChanged: (text) => updateMaxTime(),
                    ),
                  ),
                  const SizedBox(height: 10),
                  ListTile(
                    title: TextField(
                      controller: _secondsRestController,
                      keyboardType: TextInputType.number,
                      decoration: const InputDecoration(
                        labelText: 'Enter seconds',
                      ),
                      onChanged: (text) => updateMaxTime(),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ],
      )
    ];
    return Scaffold(
      bottomNavigationBar: BottomNavigationBar(
        items: const <BottomNavigationBarItem>[
          BottomNavigationBarItem(
            icon: Icon(Icons.home),
            label: 'Home',
            backgroundColor: Colors.black,
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.settings),
            label: 'Setting',
          ),
        ],
        currentIndex: _selectedIndex,
        selectedItemColor: Colors.blue,
        onTap: _onItemTapped,
      ),
      body: widgetOptions.elementAt(_selectedIndex),
    );
  }
}