개요
- 내가 필요한데다, 난이도도 적당해보여 만들어보기로 했다.
파일네이밍규칙
- 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을 정의하고 그대로 구현하려고 했으나,
- FSM으로 통제하려고 하니, 너무나도 복잡하고 예외처리가 어려웠다.
- SW는 SW답게, Timer의 Tick마다 어떤 행동을 수행할지를 코드로 작성하기로 했고, 이 편이 훨씬 수월히 진행되었다.
스프라이트 획득: itch.io 활용
- 동방관련 제작시 무료로 활용할 수 있는 스프라이트를 획득함. 매우 퀄리티가 좋았고, pixel row/column 크기도 동일했음.
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 초기화가 되게끔 설계하였다.
- nextMaxExcerciseSeconds 및 currMaxExcerciseSeconds 를 따로 두고,
결과물
- 가장 마음에 드는 기능은, 붉은배경이 차오르다가, 휴식할때 다시 내려가는 부분이다.
코드 전체
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),
);
}
}
'Development > Dart&Flutter' 카테고리의 다른 글
[Flame] Klondike 3 - 카드 상호작용 구현 (1) | 2024.09.12 |
---|---|
[Flame] Klondlike 2 - 카드 렌더링 구현 (1) | 2024.09.07 |
[Flame] Klondlike 1 - World 레이아웃 구현 (0) | 2024.09.07 |
[Flame] Assertion failed: overlay_manager.dart:51:7 - overlay 오타를 살펴보라. (0) | 2024.09.05 |