있는 그대로 따라하는 성격도 아닌지라 2주차 마지막에 올라온 앱 분석 내용에서의 어플을 조금만 심화단계로 넘어가보려 한다.
소스 및 실행화면
import 'package:flutter/material.dart';
import 'package:flutter_application_1/home.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
scaffoldBackgroundColor: Colors.black,
textTheme: TextTheme(
bodyMedium: TextStyle(color: Colors.white),
),
useMaterial3: true,
),
home: Home(),
);
}
}
import 'package:flutter/material.dart';
class Home extends StatelessWidget {
const Home({super.key});
Widget _wakeUpAlarm() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'알람',
style: TextStyle(fontSize: 40),
),
SizedBox(height: 15),
Text(
'🛌 수면 | 기상',
style: TextStyle(fontSize: 20),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'알람없음',
style: TextStyle(color: Color(0xff8d8d93), fontSize: 50),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 15, vertical: 5),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Color(0xff262629),
),
child: Text(
'변경',
style: TextStyle(color: Color(0xffff9f0a), fontSize: 16),
),
)
],
)
],
),
);
}
Widget _etcAlarm() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 15),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: Color(0xff262629)))),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('오전',
style: TextStyle(fontSize: 25, color: Color(0xff8d8d93))),
SizedBox(width: 10),
Text('4:00',
style: TextStyle(
fontSize: 60,
color: Color(0xff8d8d93),
height: 1,
letterSpacing: -3))
],
),
Switch(
onChanged: (value) {
print(value);
},
value: false,
),
],
),
Text('알람', style: TextStyle(fontSize: 18, color: Color(0xff8d8d93))),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
leading: GestureDetector(
onTap: () {},
child: Center(
child: Text(
'편집',
style: TextStyle(
color: Color(0xffff9f0a),
fontSize: 20,
),
),
),
),
actions: [
GestureDetector(
onTap: () {},
child: Padding(
padding: const EdgeInsets.only(right: 15),
child: Image.asset('assets/images/icon_add.png'),
),
)
],
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_wakeUpAlarm(),
SizedBox(height: 50),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
'기타',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
),
...List.generate(5, (index) => _etcAlarm()),
],
),
),
);
}
}

간단한 알람 어플이기에 알람 추가, 삭제, 활성화...정도만 해보려한다.
참고 화면

이대로 진행을 해보자.
ios 모달 스러운 뷰를 위해 라이브러리를 사용
https://pub.dev/packages/modal_bottom_sheet
header, timepicker, button으로 구성된 곳을 각 위젯으로 분리
Column(
children: [
AddAlarmHeader(),
AddAlarmDatePicker(),
AddAlarmColumButton()
],
)
모달로 띄우기 위한 라우팅 관련이 조금 복잡했을뿐 큰 문제는 없었떤것 같다.
그외 세개로 분리된 위젯들도 간단히 작성완료.
import 'package:flutter/material.dart';
class AddAlarmHeader extends StatelessWidget {
const AddAlarmHeader({
super.key,
});
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: Text(
'취소',
style: TextStyle(
color: Color(0xffff9f0a),
fontSize: 20,
),
),
),
Text(
'알람 추가',
style: TextStyle(
color: Colors.white,
fontSize: 20,
),
),
GestureDetector(
onTap: () => Navigator.pop(context), // 알람 데이터로 넘겨주기.
child: Text(
'저장',
style: TextStyle(
color: Color(0xffff9f0a),
fontSize: 20,
),
),
),
],
),
);
}
}
import 'package:flutter/cupertino.dart';
class AddAlarmDatePicker extends StatelessWidget {
const AddAlarmDatePicker({
super.key,
});
Widget build(BuildContext context) {
return SizedBox(
height: MediaQuery.of(context).size.height * 0.2,
child: CupertinoDatePicker(
onDateTimeChanged: (value) => print(value),
mode: CupertinoDatePickerMode.time,
),
);
}
}
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
class AddAlarmColumButton extends StatelessWidget {
const AddAlarmColumButton({
super.key,
});
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.all(15),
decoration: BoxDecoration(
color: Color(0xFF2C2C2E),
borderRadius: BorderRadius.all(Radius.circular(15))),
child: Column(
children: [
ListTile(
title: Text(
'반복',
style: TextStyle(color: Colors.white),
),
trailing: Text(
'안함 >',
style: TextStyle(color: Color(0xff8d8d93)),
),
),
ListTile(
title: Text(
'레이블',
style: TextStyle(color: Colors.white),
),
trailing: Text(
'알람',
style: TextStyle(color: Color(0xff8d8d93)),
),
),
ListTile(
title: Text(
'알람',
style: TextStyle(color: Colors.white),
),
trailing: Text(
'틸트 >',
style: TextStyle(color: Color(0xff8d8d93)),
),
),
ListTile(
title: Text(
'다시 알림',
style: TextStyle(color: Colors.white),
),
trailing: CupertinoSwitch(value: true, onChanged: (value) {}))
],
));
}
}

현재 상태에선 이부분이 가장 큰 기능이라 보인다.

스와이프로 지우는 위젯이 이미 있다.
https://api.flutter.dev/flutter/widgets/Dismissible-class.html
적용을 해봤더니 꽤나 프레임드랍이 심하다. 바로 gpt질문.
onUpdate: (details) {
if (details.progress > 0.7) {
print('0.7');
setState(() {
isOverPoint7 = true;
});
} else {
setState(() {
print('0.7 ');
isOverPoint7 = false;
});
}
},
background: Container(
width: 10,
color: Color(0xff000000),
child: Row(
mainAxisAlignment:
isOverPoint7 ? MainAxisAlignment.center : MainAxisAlignment.end,
children: [
Icon(Icons.delete, color: Color(0xffff9f0a)),
SizedBox(width: isOverPoint7 ? 10 : 0),
],
),
),
불필요한 setstate 그리고 전체를 그림을 그리는거에 대한 RepaintBoundary를 설정하라는 얘기였다.
우선 지금 상태를 확인해보면

심각한것같다...
gpt 대로 진행을 해보자
onUpdate: (details) {
bool newValue = details.progress > 0.7;
if (isOverPoint7 != newValue) {
setState(() {
isOverPoint7 = newValue;
});
}
},
확실히 생각없이 짯다는 느낌이 드는... 무한 setstate 호출코드를 짜버렷다..
background: RepaintBoundary(
child: Container(
width: 10,
color: Color(0xff000000),
child: Row(
mainAxisAlignment:
isOverPoint7 ? MainAxisAlignment.center : MainAxisAlignment.end,
children: [
Icon(Icons.delete, color: Color(0xffff9f0a)),
SizedBox(width: isOverPoint7 ? 0 : 20),
],
),
),
),
RepaintBoundary 위젯은 메모리를 사용하는대신 원하는곳만 다시 그리게 해주는 위젯이다.
debugRepaintRainbowEnabled = true;
이 코드를 통해 이렇게 다시 그리는곳에 테두리를 볼수 있는데
이전 코드는 저렇게 생겻고

바꾼 코드는 이렇게 원하는곳을 따로 처리해주는 것 같다...


일단 지표가 그렇다라고 보여주니 성능면에서 효과가 있는가보다.

디버그시에 찍은거라.. 조금은 끊겨보인다
다음은 편집을 눌럿을때 나오는 애니메이션이다.

이렇게 지우기 표시와 화살표가 새로 생성이된다.
애니메이션에 대한 내용을 적어본다.
처음엔 이동 애니메이션과 fadein out으로 하려햇지만 애니메이션적으로 부자연스럽고 더군다나
FractionalTranslation 위젯이 미리 생겨서 배치에도 문제가 생겼다.
이동 애니메이션이 0->1 로 가는 애니메이션이다 보니 이전 위치에서 FractionalTranslation위젯이 미리 할당이 되어 공간을 차지하고 있었다.
다른 어울리는 애니메이션중 사이즈 조절이 생각이되어 그대로 진행했다.
class _PresetAlarmTileState extends State<PresetAlarmTile>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _sizeUpAnimation;
late Animation<double> _sizeDownAnimation;
...
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_sizeUpAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
_sizeDownAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
}
...
void dispose() {
_animationController.dispose();
super.dispose();
}
대충 작성을 하고 적용을 하려고 보니 편집 글씨가 있는 header와 지금 위치가 다른것을 깨닫고 입력을 받게 만들어야할것같다. 추가로 입력을받앗을시 실행될 코드까지
const PresetAlarmTile({
super.key,
required this.index,
required this.isEditMode, // 상위에서 전달받음
});
final int index;
final bool isEditMode;
...
void didUpdateWidget(covariant PresetAlarmTile oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isEditMode != widget.isEditMode) {
if (widget.isEditMode) {
_animationController.forward();
} else {
_animationController.reverse();
}
}
}
forward와 reverse를 사용해 호출해주자.
이후엔 편집을 눌럿을시 변경이되는 위젯에 전에 만들어준 애니메이션을 적용
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
// 편집 모드에서 왼쪽 아이콘 애니메이션 (사라질 때도 부드럽게)
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: SizeTransition(
axis: Axis.horizontal, // 가로 방향으로 크기 변화
sizeFactor: _sizeUpAnimation,
child: child),
);
},
child: widget.isEditMode
? Padding(
padding: const EdgeInsets.only(right: 10),
child: const Icon(Icons.block,
color: Colors.red, key: ValueKey('edit_icon')))
: const SizedBox.shrink(),
),
// 시간 표시
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: const [
Text('오전',
style: TextStyle(
fontSize: 25, color: Color(0xff8d8d93))),
SizedBox(width: 10),
Text(
'4:00',
style: TextStyle(
fontSize: 60,
color: Color(0xff8d8d93),
height: 1,
letterSpacing: -3),
),
],
),
Text('알람',
style: TextStyle(fontSize: 18, color: Color(0xff8d8d93))),
],
),
Spacer(),
// 편집 모드가 아닐 때 스위치 표시 (부드럽게 사라짐)
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: SizeTransition(
axis: Axis.horizontal, // 가로 방향으로 크기 변화
sizeFactor: _sizeDownAnimation,
child: child),
);
},
child: widget.isEditMode
? const SizedBox.shrink()
: CupertinoSwitch(
value: true,
onChanged: (value) {},
key: ValueKey('switch')),
),
// 편집 모드에서 오른쪽 아이콘 애니메이션 (사라질 때도 자연스럽게)
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: SizeTransition(
axis: Axis.horizontal, // 가로 방향으로 크기 변화
sizeFactor: _sizeUpAnimation,
child: child),
);
},
child: widget.isEditMode
? const Text(
'>',
style: TextStyle(
fontSize: 60,
color: Color(0xff8d8d93),
height: 1,
letterSpacing: -3),
key: ValueKey('edit_arrow'),
)
: const SizedBox.shrink(),
),
],
),

괜찮게 적용이 된것 같다.
이제 +를 눌러서 알람이 추가가 되는 것까지 적용하면 될것같다.
알람 관련 클래스를 하나 만들면 좋을것 같다.
추가시 넣은 정보를 확인해보면
시간 반복 레이블 사운드 다시알림
다섯가지 정보 + 활성화 정보 까지 총 6개 정도면 될것같다.
class Alarm {
DateTime time;
Weekday weekday;
bool repeat;
String label;
String sound;
bool isActivated;
Alarm({
required this.time,
required this.weekday,
required this.repeat,
required this.label,
required this.sound,
required this.isActivated,
});
}
enum Weekday { Mon, Tue, Wed, Thu, Fri, Sat, Sun }
간단히 작성했다.
List<Alarm> alarm = [];
...
AddAlarmHeader(
onSave: (alarm) {
setState(() {
this.alarm.add(alarm);
});
},
),
...
Expanded(
child: ListView.builder(
itemCount: alarm.length,
itemBuilder: (context, index) {
return PresetAlarmTile(
onRemove: (i) => setState(() {
alarm.removeAt(i);
}),
isEditMode: isEditMode,
index: index,
);
},
),
),
이렇게 생성시 그리고 그 생성시에 리스트뷰 생성, 그리고 지울때 까지 코드가 완성 되엇다
이제 실제 구현쪽을 작성해보자
onDismissed: (direction) {
widget.onRemove(direction.index);
},
...
onTap: () {
onSave(Alarm(
time: DateTime.now(),
repeat: true,
label: '알람',
sound: '빵빠레',
isActivated: true,
weekday: Weekday.Mon,
));
Navigator.pop(context);
},
깨알로 스위치 관련 코드도 수정해줬다.
onChanged: (_) {
setState(() {
value = !value;
});
},
이제 추가화면에서 데이터를 옮겨 제대로 해보자

지금 추가가 되는 위젯 구조는 이렇게 생겼다.
DateTime과 그외 변수, 그리고 저장버튼의 함수까지를 모두 add_alarm_screen으로 올리고 한번에 Home으로 값을 보내 화면에 추가 시킬것이다.
변수들 입력까지하는 창을 만들기엔 너무 길어질것 같아 하드코딩으로 값을 대입하겟다.
onDateTimeChanged: (value) => onDateTimeChanged(value),
시간값이 변경될때 onDateTimeChanged로 콜백
GestureDetector(
onTap: () {
onSave();
<},
저장 버튼 클릭시 onSave로 콜백
Column(
children: [
AddAlarmHeader(
onSave: () {
widget.onSave(Alarm(
time: selectedDateTime,
repeat: isRepeat,
label: '알람',
sound: '빵빠레',
isActivated: true,
weekday: getWeekday(selectedDateTime),
));
Navigator.pop(context);
},
),
AddAlarmDatePicker(onDateTimeChanged: (value) {
selectedDateTime = value;
}),
AddAlarmColumButton(
onRepeatChanged: (value) {
setState(() {
isRepeat = value;
});
},
),
],
),
add_alarm_screen 에서도 모든 값을 취합하여 onSave로 콜백
AddAlarmScreen(
onSave: (alarm) {
setState(() {
this.alarm.add(alarm);
});
},
)
home으로까지 값을 전달해 주면 home에서 알람리스트의 값을 갖고 있게된다.
이제 타일을 만드는 곳으로 값을 전달.
PresetAlarmTile(
alarm: alarm[index],
onRemove: (i) => setState(() {
alarm.removeAt(i);
}),
isEditMode: isEditMode,
index: index,
);
이제 시간을 보여주는 텍스트위젯에 넘겨주고
PresetAlarmContext(alarm: widget.alarm),
포멧 형식 만들어서 꾸며주면 끝.
DateFormat dateFormat = DateFormat('a h:mm');
...
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
dateFormat.format(alarm.time).split(' ')[0] == 'AM'
? '오전'
: '오후',
style: TextStyle(fontSize: 25, color: Color(0xff8d8d93))),
SizedBox(width: 10),
Text(
dateFormat.format(alarm.time).split(' ')[1],
style: TextStyle(
fontSize: 60,
color: Color(0xff8d8d93),
height: 1,
letterSpacing: -3),
),
],
),
Alarm 객체에 접근하기 편하게 freezed 라이브러리 사용했다.
https://pub.dev/packages/freezed

dismissible위젯을 지워도 아직 남아있다는 뜻인거 같은데... 분명 key로 연결했던거 같은데.. 다시보니 index로만 넣어둬서 중복이 됏던거같다..
시간은 고유한 값이기때문에 ( 밀리세컨드까지는 같을 경우가... )
key: ValueKey<int>(widget.alarm.time.millisecondsSinceEpoch),
로 수정해준다.
다음 자잘한 오류로는 편집을 누르고 추가를 누르면 뷰가 섞인다

if (isEditMode) {
setState(() {
isEditMode = false;
});
}
스위치 값을 setstate를 해주지 않아 바뀌지않던 에러까지 수정을 하면...
setState(() {
isActivated = !isActivated;
widget.onSwitchChanged(isActivated);
});
에러 수정완료.
https://pub.dev/packages/shared_preferences
계획한 마지막 기능. 로컬에 저장하기이다.
Future<void> initPreferences() async {
final prefs = await SharedPreferences.getInstance();
final data = prefs.getString("alarm");
if (data != null) {
final decode = jsonDecode(data);
if (decode is List<dynamic>) {
setState(() {
alarm = decode.map((e) => Alarm.fromJson(e)).toList();
});
}
}
}
Future<void> saveAlarms() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
"alarm", jsonEncode(alarm.map((e) => e.toJson()).toList()));
}
초기 호출부분과 저장 부분을 작성하면 완성이다.
