일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |
- system hacking
- MDP
- Kaggle
- Flutter
- ARM
- pytorch
- 백준
- Dreamhack
- llm을 활용 단어장 앱 개발일지
- Got
- PCA
- FastAPI
- DART
- bloc
- Image Processing
- Stream
- ML
- Algorithm
- study book
- 파이토치 트랜스포머를 활용한 자연어 처리와 컴퓨터비전 심층학습
- Computer Architecture
- C++
- BOF
- BFS
- MATLAB
- fastapi를 사용한 파이썬 웹 개발
- Widget
- BAEKJOON
- 영상처리
- rao
- Today
- Total
Bull
[Flutter::Widget] Flutter에서 git Contributions Graph(잔디밭)을 만들어 보자. 본문
[Flutter::Widget] Flutter에서 git Contributions Graph(잔디밭)을 만들어 보자.
Bull_ 2024. 7. 17. 22:54결과물
https://www.youtube.com/shorts/GuZz_DxAfIs
(이전엔 안그랬는데 지금 시간대에 뭔가 오류가 났는 지 제대로 업로드 안되서 하이퍼 링크로만 보류합니다.)
Flutter를 활용하여 Git 잔디밭을 만들어 보겠습니다.
이걸 어디에 활용할까요? 저는 최근에 앱을 둘러보던 중 많은 앱들에 이러한 기록 서비스가 들어가는 것을 확인했습니다.
저 또한 그렇듯이 깃허브나 백준처럼 일일 기록지처럼 무언가 했다는 느낌이 드는 표가 있으면 기분이 좋아집니다.
부끄럽지만 제 백준과 깃허브의 스트릭(잔디밭)입니다. 사실 백준은 풀면 자동으로 깃허브에 커밋 푸시가 되기 때문에 하나는 거의 의미가 없긴합니다. 그냥 기분이 좋아서 이중 등록해놨습니다 ㅎㅎ..
Code
import 'package:flutter/material.dart';
import 'dart:math';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Contributions Graph',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: ContributionGraph(),
);
}
}
class ContributionGraph extends StatefulWidget {
@override
_ContributionGraphState createState() => _ContributionGraphState();
}
class _ContributionGraphState extends State<ContributionGraph> {
final int daysInYear = 365;
final List<int> activityData =
List.generate(365, (index) => Random().nextInt(5));
final List<String> months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec'
];
final List<String> weekdays = [
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat',
'Sun'
];
int selectedDayIndex = -1;
@override
Widget build(BuildContext context) {
final weeksInYear = (daysInYear / 7).ceil(); // 52주
final firstDayOfYear =
DateTime(2025, 1, 1).weekday; // 2025년 1월 1일의 요일 (수요일=3)
final int emptyDays = (firstDayOfYear + 6) %
7; // 1월 1일 이전의 빈 날짜 (3+6) % 7 = 2, 빈 날짜는 표시하지 않기 위해 계산함
return Scaffold(
appBar: AppBar(
title: Text('Contributions Graph'),
),
body: Column(
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Container(
width: 80,
height: 20,
),
Container(
height: 20,
child: Row(
children: List.generate(weeksInYear, (weekIndex) {
final firstDayOfWeek = DateTime(2025, 1, 1)
.add(Duration(days: weekIndex * 7));
final month = firstDayOfWeek.month;
final isFirstWeekOfMonth = firstDayOfWeek.day <= 7;
return Container(
margin: EdgeInsets.symmetric(horizontal: 2),
width: 20,
child: isFirstWeekOfMonth
? Center(
child: Text(
months[month - 1],
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
)
: Container(),
);
}),
),
),
],
),
Row(
children: [
Row(
children: [
Container(
alignment: Alignment.centerLeft,
child: Text(
'2025',
style: TextStyle(
fontSize: 20, fontWeight: FontWeight.bold),
),
),
SizedBox(width: 10),
Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: weekdays
.map((day) => Container(
height: 23,
child: Text(day,
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.bold)),
))
.toList(),
),
],
),
Row(
children: List.generate(weeksInYear, (weekIndex) {
return Column(
children: List.generate(7, (dayIndex) {
// 여기서 emptyDays를 통해 1월 1일 이전의 빈 날짜를 제외하고 표시함
final dayIndexInYear =
weekIndex * 7 + dayIndex - emptyDays;
// 1번째 주에서 emptyDays는 2, 그러면 맨처음시작은 -2, -1, 0, 1, 2, 3, 4 이므로
// 1번째 주는 수요일부터 5개만 표시.
if (dayIndexInYear < 0 ||
dayIndexInYear >= daysInYear) {
return Container(
margin: EdgeInsets.all(2),
width: 20,
height: 20,
);
}
final activityLevel = activityData[dayIndexInYear];
return GestureDetector(
onTap: () {
setState(() {
selectedDayIndex = dayIndexInYear;
});
},
child: Container(
margin: EdgeInsets.all(2),
width: 20,
height: 20,
decoration: BoxDecoration(
color: activityLevel == 0
? Colors.grey[200]
: Colors.amber[activityLevel * 100],
border: selectedDayIndex == dayIndexInYear
? Border.all(
color: Colors.black, width: 2)
: null,
),
),
);
}),
);
}),
),
],
),
],
),
),
Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'Selected Date: ${selectedDayIndex == -1 ? "None" : getFormattedDate(selectedDayIndex)}'),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.amber,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: () {
setState(() {
if (activityData[selectedDayIndex] > 0) {
activityData[selectedDayIndex]--;
}
});
},
child: Text(
'Decrease',
style: TextStyle(
color: Colors.white,
fontSize: 16,
),
),
),
SizedBox(width: 16),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.amber,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: () {
setState(() {
if (activityData[selectedDayIndex] < 4) {
activityData[selectedDayIndex]++;
}
});
},
child: Text(
'Increase',
style: TextStyle(
color: Colors.white,
fontSize: 16,
),
),
),
],
),
],
),
],
),
);
}
String getFormattedDate(int dayIndex) {
final startDate = DateTime(2025, 1, 1);
final currentDate = startDate.add(Duration(days: dayIndex));
return '${currentDate.year}-${currentDate.month.toString().padLeft(2, '0')}-${currentDate.day.toString().padLeft(2, '0')}';
}
}
이 코드는 대부분 GPT가 작성했습니다. ㅎㅎ 저는 설계만 했을 뿐이죠. 그래도 코드를 이해할 줄 알아야겠죠?
저도 설계를 하고 GPT에게 프롬프트를 보내어 요청을 했지만 자꾸 오류를 내뱉어서 고치고 고치고 여러 번 바꿔서 얻어낸 결과물입니다.
2025년으로 설정한 이유는 잔디밭을 월요일부터 나오게 해놨는데 2024년 1월 1일이 월요일이기 때문에 월요일부터 시작하지 않는 해도 고려하기 위해서 2025년으로 정했습니다.
CODE 설명
그렇다면 코드를 하나하나 설명해보겠습니다.
1. 변수 설명
(...)
final int daysInYear = 365;
final List<int> activityData = List.generate(365, (index) => Random().nextInt(5));
final List<String> months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec'
];
final List<String> weekdays = [
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat',
'Sun'
];
int selectedDayIndex = -1;
@override
Widget build(BuildContext context) {
final weeksInYear = (daysInYear / 7).ceil(); // 52주
final firstDayOfYear =
DateTime(2025, 1, 1).weekday; // 2025년 1월 1일의 요일 (수요일=3)
final int emptyDays = (firstDayOfYear + 6) % 7; // 1월 1일 이전의 빈 날짜 (3+6) % 7 = 2, 빈 날짜는 표시하지 않기 위해 계산함
(...)
daysInYear
: 이 부분은 한 해의 일 수를 나타냅니다. 윤년도 고려해야 되는데 명확하고 기능중심적인 설명을 위해 넣지 않았습니다. 참고로 2024년은 윤년이기 때문에 일 수를 366일로 하거나 Datetime
을 통해 년도를 가져와 윤년을 계산하는 함수를 만들 수 있습니다.
months
: Widget의 윗 부분(가로)에 달을 나타내기 위해 사용하였습니다.
weekdays
: Widget의 왼쪽 부분(세로)에 요일을 나타내기 위해 사용했습니다.
selectedDayIndex
: 잔디밭에서 날짜에 대한 커밋 수를 나타냈습니다. 어떤 로직이 없기 때문에 버튼을 눌러서 커밋 수를 올려야 합니다. 나중에 필요한 로직으로 출석체크, 글을 작성한 수를 통해 올릴 수 있는 로직을 만들어 연결하면 됩니다.
weeksInYear
: 1년은 총 52주임을 나타냈습니다.
firstDayOfYear
: 해당 년도의 첫날은 무슨 요일인지 나타냈습니다. 2025년 1월 1일은 수요일입니다. 숫자로 3입니다.
emptyDays
: 1월 1일을 기점으로 이전에 얼마나 비어있는 지를 저장하기 위함입니다. 수요일부터 시작한다면 뒤에 월, 화 총 이틀이 남게되는데 이부분은 표시해야하지 않아야합니다.
뒤에 다른 변수도 새로 초기화하니 중간에 더 설명하겠습니다.
2. Column (SingleChildScrollView,Column)
Scaffold(
appBar: AppBar(
title: Text('Contributions Graph'),
),
body: Column(
children: [
SingleChildScrollView(...),
Column(...),
],
),
);
빨간선 기준으로
1. SingleChildScrollView
는 날짜와 잔디밭 부분입니다.
2. 임의로 설정해놓은 날짜에 대해 값을 증가/감소 시켜서 잔디밭의 커밋을 0에서 5까지 늘릴 수 있습니다.
3.SingleChildScrollView
//pseudo code
SingleChildScrollView(
Column(
Row(
Container(...), // 빈공간
Row(...), // 월
),
Row(
Row(
Container(...), // 년도
SizedBox(...), // 빈공간
Column(...), // 요일
),
// 잔디밭
Row(
Column(
Container(...),
),
),
),
),
),
),
그림으로 설명하는 것이 명확하게 전달될 것이라 생각이들어 직접 그려보았습니다.
추가로 맨 위의 달을 표시하는 부분에 color를 추가하였습니다. 이렇게 보이면 달이 어떤 로직으로 구현되었는 지 코드를 설명할 때 쉽게 이해될 거라 생각하여 표현했습니다.
원래는 2x2 GridView를 통해 설명하려다가 예기치 못한 오류를 고치지 못해서 Row
와 Column
만을 사용하였습니다.
코드 대략적인 부분은 Widget을 그리는 부분이라 이해 되지만 논리적으로 구현해야하는 부분을 설명하겠습니다.
3-1. SingleChildScrollView(Column → Row(2) → Row → Column) : 요일
(...)
Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: weekdays
.map((day) => Container(
height: 23,
child: Text(day,
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.bold)),
))
.toList(),
),
(...)
요일 부분입니다.
weekdays
는 처음에 설명했던 월요일부터 일요일까지 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'가 순서대로 들어가있는 List
입니다. List.map
을 사용하여 람다식을 이용하면 해당 원소가 순서대로 파라미터를 통해 입력 받을 수 있습니다.
아래는 List.map을 설명한 예시입니다.
void main() {
List<int> numbers = [1, 2, 3, 4, 5];
List<int> squaredNumbers = numbers.map((number) {
return number * number;
}).toList();
print(squaredNumbers); // [1, 4, 9, 16, 25]
}
List
에서 map
을 사용하면 원하는 형태로 가공하여 다시 List
로 반환되어 집니다.
다시 원문 코드를 보겠습니다.
weekdays
.map((day) => Container(
height: 23,
child: Text(day,
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.bold)),
))
.toList(),
위의 코드가 들어가서,
Container(
height: 23,
child: Text(
day,
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
이런 컨테이너안에 Text("요일")이 들어간 Widget이 7개 배치가 되겠죠?
3-2. SingleChildScrollView(Column → Row(1) → Row**) : 월**
Row(
children: List.generate(weeksInYear, (weekIndex) {
final firstDayOfWeek = DateTime(2025, 1, 1)
.add(Duration(days: weekIndex * 7));
final month = firstDayOfWeek.month;
final isFirstWeekOfMonth = firstDayOfWeek.day <= 7;
return Container(
margin: EdgeInsets.symmetric(horizontal: 2),
width: 20,
child: isFirstWeekOfMonth
? Center(
child: Text(
months[month - 1],
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
)
: Container(
color: Colors.red[200],
),
);
}),
),
보기만 해도 토가 나오네요. 월을 표시하는 부분입니다. 들어가기에 앞서 이전의 변수를 한 번 상기시켜보죠.
months
에는 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' 가 순서대로 들어가 있습니다.
weeksInYear
는 52주를 나타냅니다. 따라서 아래 잔디밭 부분의 Row
도 마찬가지로 52개의 줄을 사용할 것이고 현재 위치인 달을 나타내는 부분도 52개의 줄을 나타낼 수 있습니다. day의 수는 변하지 않으니 Row
가 표현되는 부분의 정보는 잔디밭 부분과 동일하겠죠?
이제 이것을 각각 월이 시작되는 첫째주가 나오는 Row index를 찾아 그 부분에만 달을 표시합니다.
List.generate
를 통해 List
를 만들어 Row
의 childrun
으로 반환할 것입니다. 인자로 weeksInYear
가 들어갔으니 0부터 총 52번 진행됩니다.
final firstDayOfWeek = DateTime(2025, 1, 1).add(Duration(days: weekIndex * 7));
1월 1일부터 7 * (n번째 주) 만큼 더하여 firsDayOfWeek
에 저장합니다.
만약 n이 2라면 1월 1일 + 14일 = 1월 15일이 됩니다.
final month = firstDayOfWeek.month;
해당 DateTime
정보에서 month
를 가져옵니다.
final isFirstWeekOfMonth = firstDayOfWeek.day <= 7;
만약 위에서 n번째 주만큼 더한 부분이 7보다 작거나 같으면 첫 째주로 간주합니다.
Container(
margin: EdgeInsets.symmetric(horizontal: 2),
width: 20,
child: isFirstWeekOfMonth
? Center(
child: Text(
months[month - 1],
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
)
: Container(),
);
이 그림을 통해 같이 이해해보죠.
1월 1일 + 0*7 = 1월 1일 → 1번째 주로 인식했습니다.
1월 1일 + 5*7 = 2월 5일 → 1번째 주로 인식했습니다.
1월 1일 + 9*7 = 3월 5일 → 1번째 주로 인식했습니다.
...
void main() {
print('${DateTime(2025, 1, 1).add(Duration(days: 9 * 7))}');
}
날짜가 헷갈리면 위 코드를 DartPad에서 간단하게 해결해보세요.
이제 첫번 째로 인식한 부분은 삼항 연산자를 통해 Container
에 Text가 들어간 Widget을 반환하게 됩니다.
month
변수에 이전에 어떤 월인지 저장해놨으니 인덱스-1 만큼 불러오면 되겠죠.
3-3. SingleChildScrollView(Column → Row(2) → Row(52) → Column(7) : 잔디밭
Row(
children: List.generate(weeksInYear, (weekIndex) {
return Column(
children: List.generate(7, (dayIndex) {
// 여기서 emptyDays를 통해 1월 1일 이전의 빈 날짜를 제외하고 표시함
final dayIndexInYear =
weekIndex * 7 + dayIndex - emptyDays;
// 1번째 주에서 emptyDays는 2, 그러면 맨처음시작은 -2, -1, 0, 1, 2, 3, 4 이므로
// 1번째 주는 수요일부터 5개만 표시.
if (dayIndexInYear < 0 ||
dayIndexInYear >= daysInYear) {
return Container(
margin: EdgeInsets.all(2),
width: 20,
height: 20,
);
}
final activityLevel = activityData[dayIndexInYear];
return GestureDetector(
onTap: () {
setState(() {
selectedDayIndex = dayIndexInYear;
});
},
child: Container(
margin: EdgeInsets.all(2),
width: 20,
height: 20,
decoration: BoxDecoration(
color: activityLevel == 0
? Colors.grey[200]
: Colors.amber[activityLevel * 100],
border: selectedDayIndex == dayIndexInYear
? Border.all(
color: Colors.black, width: 2)
: null,
),
),
);
}),
);
}),
),
대망의 잔디밭 구현이네요. 이부분은 조금 코드가 깁니다. 하나하나 차근차근 살펴보겠습니다.
달 부분과 동일하게 Row
의 List.generate
를 통해서 일단 52개의 줄을 만들어줍니다.
다시 Column
의 List.generate
를 통해서 주 개수인 7개를 만들어줍니다.
final dayIndexInYear = weekIndex * 7 + dayIndex - emptyDays;
어느 요일부터 시작할 지 정하기 위한 변수입니다. 이전에 emptyDays
를 계산했습니다. 2025년은 수요일(3)부터 시작을 하기 때문에 emptyDays
에 2가 저장되어있습니다. dayIndex는 파라미터로 들어온 7을 순서대로 나타내어 0~6를 나타냅니다.
이제 dayIndexInYear
를 순서대로 쭉 적어보면
-2, -1, 0, 1, 2, 3, 4,
5, 6, 7, 8, 9, 10, 11 ...,
360, 361, 362 가 됩니다.
이 변수를 통해,
if (dayIndexInYear < 0 ||
dayIndexInYear >= daysInYear) {
return Container(
margin: EdgeInsets.all(2),
width: 20,
height: 20,
);
}
이 분기문으로 해당되지 않는 잔디밭은 칸을 나타내지 않게됩니다.
이제 잔디밭의 농도를 표시하는 변수입니다.
final activityLevel = activityData[dayIndexInYear];
activityData
에 0~4범위의 랜덤한 수를 채워 놓아 커밋 수를 표현했습니다.
이제 이부분을 누르면
selectedDayIndex
에 해당 날짜를 저장할 것입니다. 왜냐하면 임의로 숫자를 증가/감소 시키는 로직을 구현하기 위해서 입니다. 아래와 같이 저장됩니다.
onTap: () {
setState(() {
selectedDayIndex = dayIndexInYear;
});
},
마지막으로 아래의 Container
Widget으로 색이 채워져있는 칸을 반환합니다.
Container(
margin: EdgeInsets.all(2),
width: 20,
height: 20,
decoration: BoxDecoration(
color: activityLevel == 0
? Colors.grey[200]
: Colors.amber[activityLevel * 100],
border: selectedDayIndex == dayIndexInYear
? Border.all(
color: Colors.black, width: 2)
: null,
),
),
Color의 농도는 activityData
변수에서 0~4값을 activityLevel
변수에 저장하였기 때문에 농도를 * 100을 하여 표현하였습니다. 그리고 클릭하게 되면 어떤 칸을 선택했는 지 볼 수 있게 border로 나타내었습니다.
4. Int To DateFormat
String getFormattedDate(int dayIndex) {
final startDate = DateTime(2025, 1, 1);
final currentDate = startDate.add(Duration(days: dayIndex));
return '${currentDate.year}-${currentDate.month.toString().padLeft(2, '0')}-${currentDate.day.toString().padLeft(2, '0')}';
}
선택한 데이터는 위 함수를 통해 변환할 수 있습니다.
DateTime
class를 통해서 보다 손쉽게 원하는 포맷으로 반환될 수 있도록 합니다.
5. Column → Column : Count 증가/감소 버튼
Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'Selected Date: ${selectedDayIndex == -1 ? "None" : getFormattedDate(selectedDayIndex)}'),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.amber,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: () {
setState(() {
if (activityData[selectedDayIndex] > 0) {
activityData[selectedDayIndex]--;
}
});
},
child: Text(
'Decrease',
style: TextStyle(
color: Colors.white,
fontSize: 16,
),
),
),
SizedBox(width: 16),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.amber,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: () {
setState(() {
if (activityData[selectedDayIndex] < 4) {
activityData[selectedDayIndex]++;
}
});
},
child: Text(
'Increase',
style: TextStyle(
color: Colors.white,
fontSize: 16,
),
),
),
],
),
],
),
selectedDayIndex
변수는 초기값이 -1이었습니다. 아무것도 선택하지 않은 상태라면 삼항 연산자를 통해 None을 표시하거나,
날짜를 표시할 수 있습니다.
// 증가 로직
setState(() {
if (activityData[selectedDayIndex] < 4) {
activityData[selectedDayIndex]++;
}
});
// 감소 로직
setState(() {
if (activityData[selectedDayIndex] > 0) {
activityData[selectedDayIndex]--;
}
});
},
나머지는 버튼에 대한 디자인과 증가/감소 로직입니다. 이 부분은 상태관리를 통해 값의 변화를 UI에 표시할 수 있습니다.
stateful로 Page를 표현했기 때문에 할 수 있었다는 점 유의하시면 됩니다.
'Software Framework > Flutter' 카테고리의 다른 글
[Flutter::Animation] Ticker 이해 (0) | 2024.07.23 |
---|---|
[Flutter::Animation] Tween 예제를 쓰면서 이해해보자 (3) | 2024.07.23 |
[Flutter::Widget] Card를 이용하여 트위터, 링크드인, 레딧같은 Feed 포스트 만들어보기 (0) | 2024.07.17 |
[Flutter] Bloc Widget정리 (0) | 2024.07.15 |
[Flutter] Font 적용하기 (로컬 저장 방식) (0) | 2024.07.10 |