
작업 일정
- #4 [view] 피드 탭과 게시물 화면 뼈대 잡기
- #5 [view] 기록 탭과 기록 상세 화면 뼈대 잡기
- #6 [view] 나 탭과 프로필, 친구 관련 화면 뼈대 잡기
/feed)피드 화면에는 나와 친구의 공유된 기록이 최신순으로 출력된다. 페이지네이션과 관련된 기능은 Provider를 붙인 후에 수정하기로 하고 일단 뼈대만 잡아 놓았다.
기본적으로 FeedPost widget의 List다. 사용자 및 세션 기록에 따라 데이터가 출력된다. 더보기 메뉴를 클릭할 경우 내 기록이라면 편집하기 버튼과 삭제하기 버튼이 뜨며 타인의 기록이라면 신고하기 버튼이 뜬다. 웹으로 확장하게 된다면 URL 복사 같은 버튼도 추가될 수도 있겠지.
/feed/post/:sketch_id)세션 기록과 스케치는 1 대 0..1 관계이므로 스케치 식별자는 세션 식별자를 그대로 사용해도 괜찮다고 판단했다. 동일한 UUID 값을 공유한다. 내 게시물이냐 타인의 게시물이냐에 따라 응원 버튼의 작동이 다른 점은 브런치스토리의 라이킷을 벤치마킹했다. 타인의 게시물에 대해서는 응원을 하거나 안 하거나의 토글 버튼으로 작용하며 누가 응원했는지는 알 수 없고, 자신의 게시물에 대해서는 누가 응원했는지 목록을 확인할 수 있는 구조다.
/feed/notifications)피드 알림은 일단 누구에 의해 어떤 알림이 트리거되었는지와 읽음 여부만 담았는데, 모두 읽음으로 표시하는 버튼이나 알림 기록 지우기 버튼 같은 걸 넣어도 괜찮을 것 같다. 일단은 기획에 있던 부분까지만 구현하고 이 부분에 대해서는 추후 다시 판단해 보도록 하겠다.
/history)기록 화면은 캘린더와 리스트로 구성된다. 캘린더의 달을 이동하면 그 달에 수행한 운동 기록 목록이 출력되는 형태다. 캘린더 부분은 table_calendar widget을 사용하여 구현하기로 했다. table_calendar가 제공하는 날짜 선택기는 연/월/일을 선택하는데 우리는 연/월까지만 보기 때문에 불필요한 일 선택이 포함되어 있다. 따라서 month_picker_dialog라는 별도의 package를 title header에 연결하겠다.
pubspec.yaml# 앞 부분 생략 dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.8 go_router: ^18.0.1 intl: ^0.20.2 animated_toggle_switch: ^0.8.7 uuid: ^4.6.0 flutter_map: ^8.3.2 latlong2: ^0.10.1 table_calendar: ^3.2.1 month_picker_dialog: ^6.7.2 # 뒷 부분 생략
캘린더 뷰가 생각보다 난해했다. 목록 뷰는 해당 연/월의 모든 기록을 가져와 (필요에 따라 페이지네이션을 포함할 수도 있고) 순차적으로 뿌려주면 되는데, 캘린더 뷰는 해당 날짜에 기록이 있는지, 있다면 서로 다른 유형의 기록이 함께 있지는 않는지 확인해야 했다.
이를 처리하기 위해 월간 기록을 담고 있는 mock model에 조깅 기록이 있는 날과 라이딩 기록이 있는 날의 날짜를 담은 Set을 추가했다. Set으로 구현하여 중복에 대한 처리를 따로 할 필요 없이 중복된 날짜를 배제했다. 그리고 어떤 날짜에 대해 그 날짜가 dayRidding 에 들어 있는지 dayJogging 에 들어 있는지 둘 다 들어있거나 혹은 둘 다 없는지에 따라 적절한 색상으로 표기하도록 하였다.
이러한 캘린더 뷰 작업이 생각보다 오래 걸렸다.
lib/widgets/session_calendar.dartimport 'package:flutter/material.dart'; import 'package:table_calendar/table_calendar.dart'; import 'package:month_picker_dialog/month_picker_dialog.dart'; import '../theme.dart'; import '../utils/diagonal_painter.dart'; import '../models/mock_monthly_history.dart'; class SessionCalendar extends StatelessWidget { final DateTime _focusedDay; final MockMonthlyHistory _monthlyHistory; final Function(DateTime) _onMonthChanged; const SessionCalendar({ super.key, required this._focusedDay, required this._monthlyHistory, required this._onMonthChanged, }); /// 원하는 연/월의 기록을 확인하기 위한 helper function Future<void> _pickYearMonth(BuildContext context) async { ColorScheme colorScheme = Theme.of(context).colorScheme; final selected = await showMonthPicker( context: context, initialDate: _focusedDay, firstDate: DateTime(2026), lastDate: DateTime(DateTime.now().year + 1), monthPickerDialogSettings: MonthPickerDialogSettings( dialogSettings: PickerDialogSettings(locale: Locale('ko')), dateButtonsSettings: PickerDateButtonsSettings( selectedMonthBackgroundColor: colorScheme.primary, selectedMonthTextColor: colorScheme.onPrimary, currentMonthTextColor: colorScheme.secondary, buttonBorder: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), ), ), actionBarSettings: PickerActionBarSettings(), ), ); if (selected != null) { _onMonthChanged(selected); } } /// 기록에 따라 날짜에 표시하기 위한 helper function Widget _buildCellBackground(BuildContext context, int day) { ActivityColors activityColors = context.activityColors; if (_monthlyHistory.dayJogging.contains(day) && _monthlyHistory.dayRiding.contains(day)) { return ClipRRect( borderRadius: BorderRadius.circular(20), child: CustomPaint( painter: DiagonalPainter( topLeftColor: activityColors.joggingFill, bottomRightColor: activityColors.ridingFill, ), ), ); } else if (_monthlyHistory.dayJogging.contains(day)) { return Container( decoration: BoxDecoration( color: activityColors.joggingFill, shape: BoxShape.circle, ), ); } else if (_monthlyHistory.dayRiding.contains(day)) { return Container( decoration: BoxDecoration( color: activityColors.ridingFill, shape: BoxShape.circle, ), ); } return Container(); } /// 날짜를 꾸며주는 helper function Widget? decorateDayCell( BuildContext context, DateTime day, DateTime currentFocus, ) { ColorScheme colorScheme = Theme.of(context).colorScheme; if (day.month != currentFocus.month) { return Center( child: Text('${day.day}', style: const TextStyle(color: Colors.grey)), ); } final isToday = isSameDay(day, DateTime.now()); return Center( child: Container( width: 38, height: 38, decoration: isToday ? BoxDecoration( shape: BoxShape.circle, border: Border.all(color: colorScheme.outlineVariant, width: 2), ) : null, alignment: Alignment.center, child: Stack( alignment: Alignment.center, children: [ SizedBox( width: 32, height: 32, child: _buildCellBackground(context, day.day), ), Text( '${day.day}', style: TextStyle( fontWeight: isToday || _monthlyHistory.dayJogging.contains(day.day) || _monthlyHistory.dayRiding.contains(day.day) ? FontWeight.bold : FontWeight.normal, ), ), ], ), ), ); } Widget build(BuildContext context) { TextTheme textTheme = Theme.of(context).textTheme; return SizedBox( height: 400, child: TableCalendar( locale: 'ko_KR', firstDay: DateTime.utc(2026), lastDay: DateTime.utc(DateTime.now().year + 1), focusedDay: _focusedDay, selectedDayPredicate: (day) => false, onDaySelected: null, onPageChanged: (date) => _onMonthChanged(date), headerStyle: const HeaderStyle( formatButtonVisible: false, titleCentered: true, ), calendarBuilders: CalendarBuilders( headerTitleBuilder: (context, date) => InkWell( onTap: () => _pickYearMonth(context), child: Text( '${date.year}년 ${date.month}월', style: textTheme.bodyLarge, textAlign: TextAlign.center, ), ), prioritizedBuilder: decorateDayCell, ), ), ); } }
/history/details/:session_id)기록 상세 화면은 대체로 세션 진행 화면 및 세션 결과 화면의 코드 재사용이었다. 기록을 피드에 공유했는지 여부에 따라, 그리고 공유하지 않았다면 가장 최신 기록인지 여부에 따라 하단 버튼이 달라지는 것과 비공개 메모 정도가 추가되었다. 공유된 기록은 피드 게시물로 연결되는 버튼이 있고, 공유되지 않은 기록은 가장 최신 기록에 한해 피드에 기록하는 버튼이 있다. 공유되지 않았으면서도 최신도 아닌 기록은 버튼이 보이지 않는다.
비공개 메모는 피드 게시물보다 넉넉한 분량으로 작성할 수 있고, 따로 게시 버튼을 누르지 않아도 자동으로 저장되는 형태다.
/me)프로필과 공유된 기록이 뜨는 화면이다. 공유된 기록을 클릭하면 뜨는 상세 화면은 피드에서와 같은 화면을 공유하되 route를 분리하였다. 그런데 한 가지 이슈가 발견되었다.
발견된 이슈
피드에서 내 게시물 편집을 하다가 탭을 이동하여 내 프로필 화면에서도 게수믈 편집을 할 수 있다? 충돌이 발생할 수 있을 것 같은데. 처음 업로드하는 건 아예 별도 route로 되어 있어서 하단 네비게이션이 없기 때문에 문제 없는데 편집 화면끼리가 문제다. 편집 화면도 별도 route로 빼는 게 나으려나.
편집 화면만 go가 아닌 push로 올리고 치우면 그럭저럭 괜찮을 것 같긴 한데. 일단 전체적인 UI를 마무리하고 layout 검토 작업을 하며 좀 더 생각해 보자.
프로필 사진 변경에는 image_picker package를 사용한다. 여기선 프로필 변경 모달에서 이미지를 불러 오는 것까지만 하고 실제 반영하는 건 이후에 하도록 하겠다. 이 부분은 실습 때 해봤던 거니까 어렵지 않을 것이다.
pubspec.yaml# 앞 부분 생략 dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.8 go_router: ^18.0.1 intl: ^0.20.2 animated_toggle_switch: ^0.8.7 uuid: ^4.6.0 flutter_map: ^8.3.2 latlong2: ^0.10.1 table_calendar: ^3.2.1 month_picker_dialog: ^6.7.2 image_picker: ^1.2.3 # 뒷 부분 생략
/profile/:user_id)사용자 프로필 화면은 대체로 내 프로필과 비슷하다. 그래서 같은 화면을 공유할까 싶기도 했지만 별도로 구성하기로 했다. 대상이 친구인지, 친구 요청을 보낸 상태인지, 친구 요청을 받은 상태인지, 아무 관계 없는 상태인지에 따라 화면 구성이 조금씩 다른데 여기에 나 자신인 경우까지 한 파일에 넣으면 코드가 너무 복잡해지기 때문이다.
어차피 공유된 기록 그리드는 별도의 widget으로 작성해 놓았으니 중복되는 부분은 많지 않다.
친구 목록에 있는 사용자일 경우 한 줄 소개가 뜨고 친구 요청을 보낸 상태라면 요청 취소 버튼이, 친구 요청을 받은 상태라면 요청 수락 버튼이 뜬다. 아무 관계 없는 사람의 경우 친구 요청 버튼이 뜬다. 친구 요청 상태가 어떻든 친구가 아닌 사용자라면 함께 아는 친구 목록이 뜬다. 친구가 아닐 경우에도 함께 아는 친구가 없다면 함께 아는 친구 목록이 뜨지 않는다.
함께 아는 친구 목록을 클릭하면 함께 아는 친구를 모두 확인할 수 있는 modal dialog를 띄우도록 하고 보니 go_router 상으로 같은 route를 가리키고 있어 이동되지 않는 이슈를 발견했다. 함께 아는 친구 프로필 클릭 시 누군지 확인하고 다시 돌아올 것이라 생각되어 이 부분은 context.go() 대신 context.push() 를 사용하는 것으로 문제를 해결했다.
/me/friends) 및 사용자 검색 화면 (/me/friends/search)이 두 녀석은 같이 구현했다. 사용자를 나타내는 ListTile이 나열되어 있는 형태가 겹치기 때문이다. 받은 요청 뷰, 내 친구 뷰, 알 수도 있는 사람 뷰, 검색 결과 뷰를 작성한 후, 친구 목록 화면에 받은 요청 뷰와 내 친구 뷰를 보여주고, 사용자 검색 화면에 검색 여부에 따라 알 수도 있는 사람 뷰와 검색 결과 뷰를 보여준다.
사용자 프로필 화면과 마찬가지로 ListTile의 레이아웃에 조금씩 변주가 있다. 다음과 같은 옵션을 두어 적절한 레이아웃으로 출력되게 구현하였다.
lib/widgets/user_card.dart// 앞 부분 생략 class UserCard extends StatelessWidget { final MockUser _user; final bool _isFriend; final bool _isRequested; final bool _isSent; final bool _onSearch; final bool _onRecommend; const UserCard({ super.key, required this._user, this._isFriend = false, this._isRequested = false, this._isSent = false, this._onSearch = false, this._onRecommend = false, }); // 뒷 부분 생략