요약
| # | 파일 | 내용 |
|---|---|---|
| 1 | lib/screens/list_tab.dart |
할 일 탭 상단 필터 칩: 가로 스크롤 → 줄바꿈(Wrap), 이름·순서 변경, 체크 표시 제거,
칩 크기 축소 + 모든 칩 폭을 가장 긴 칩 폭으로 통일, 정렬 콤보 → 칩 줄 맨 뒤 칩 모양 버튼 |
| 2 | lib/screens/calendar_tab.dart |
달력 상단 보기 버튼(전체·할일·일정·기록·반복·공휴일): 화면 폭 600 미만이면 헤더를 2줄로 나눠 버튼을 원래 크기로 |
| 3 | lib/screens/calendar_tab.dart |
(해당 시) 공휴일 이름 holi! → holi — analyze 경고 제거, 동작 변화 없음 |
DB·모델·로직 변경은 없다. 화면(UI) 코드만 바뀐다.
1. 할 일 탭 필터 칩 — lib/screens/list_tab.dart
문제
필터 칩 6개(오늘 할 일 · 별표 · 예정 · 지연 · 완료됨 · 전체 할 일)가 가로 스크롤 한 줄이라 폰에서는 앞 2개만 보이고 나머지는 옆으로 밀어야 보였다(밀 수 있다는 표시도 없음).
결과 모습
[ 전체 ] [ 오늘 ] [ 별표 ] [ 예정 ] [ 지연 ]
[완료됨] [내림차순▾]
- 순서·이름:
전체(맨 앞, 원래 '전체 할 일') ·오늘(원래 '오늘 할 일') ·별표·예정·지연·완료됨·내림차순▾ - 별표 칩: 별 아이콘·개수 제거 →
별표만. 별표가 0개면 칩을 숨기는 동작은 그대로. - 선택 칩의 ✓ 표시 제거 — 선택은 배경색으로만 구분.
- 칩 크기 축소: 글자 13,
VisualDensity.compact, 여백 최소, 탭 영역 shrinkWrap. - 칩 폭 통일: 정렬 버튼까지 포함해 모든 칩의 라벨 폭 = 가장 긴 라벨(
내림차순▾) 폭. 글자는 가운데. - 폭이 모자라면 다음 줄로 넘어간다(가로 스크롤 없음).
- 정렬: 오른쪽
DropdownButton→ 칩 줄 맨 뒤의 칩 모양 버튼. 누르면 메뉴(내림차순/오름차순), 고른 값이 버튼 글자로 표시.
1-1. 필터 이름·순서 (_views)
변경 전
static const _views = {
'today': '오늘 할 일',
'incomplete': '예정',
'overdue': '지연',
'done': '완료됨',
'all': '전체 할 일',
};
변경 후 — 'all'을 맨 앞으로, 이름 줄임 (Map 리터럴 순서 = 칩 순서)
static const _views = {
'all': '전체',
'today': '오늘',
'incomplete': '예정',
'overdue': '지연',
'done': '완료됨',
};
1-2. 별표 칩 라벨 (_visibleEntries)
변경 전
/// 칩 목록. 별표가 있을 때만 '오늘 할 일' 뒤에 '별표' 칩을 끼움.
...
out.add(MapEntry('important', '별표 $_starCount'));
변경 후
/// 칩 목록. 별표가 있을 때만 '오늘' 뒤에 '별표' 칩을 끼움.
List<MapEntry<String, String>> _visibleEntries() {
final out = <MapEntry<String, String>>[];
_views.forEach((k, v) {
out.add(MapEntry(k, v));
if (k == 'today' && _starCount > 0) {
out.add(const MapEntry('important', '별표'));
}
});
return out;
}
_starCount는 칩을 보일지 말지 판단하는 데 계속 쓰인다(지우지 말 것).
1-3. 정렬 콤보 → 칩 크기 상수 · 폭 측정 · 정렬 버튼
변경 전 — 이 함수를 통째로 지운다
// 날짜 오름/내림 콤보(상단 오른쪽).
Widget _orderCombo() => Padding(
padding: const EdgeInsets.only(right: 10, left: 4),
child: DropdownButton<bool>(
value: _desc,
isDense: true,
underline: const SizedBox(),
items: const [
DropdownMenuItem(value: true, child: Text('내림차순')),
DropdownMenuItem(value: false, child: Text('오름차순')),
],
onChanged: (v) => setState(() => _desc = v ?? true),
),
);
변경 후 — 같은 자리(_visibleEntries() 바로 아래)에 넣는다
// 필터 칩 공통 크기: 글자 폭보다 조금만 크게, 높이도 낮게.
static const _chipPadding = EdgeInsets.symmetric(horizontal: 2);
static const _chipLabelPadding = EdgeInsets.symmetric(horizontal: 6);
static const _chipFont = TextStyle(fontSize: 13);
static const _orderIconSize = 18.0;
/// 모든 칩(정렬 버튼 포함)의 라벨 폭 = 가장 긴 라벨 폭. 글자 배율(textScaler) 반영.
double _uniformLabelWidth(BuildContext context) {
final scaler = MediaQuery.textScalerOf(context);
double measure(String s) => (TextPainter(
text: TextSpan(text: s, style: _chipFont),
textDirection: TextDirection.ltr,
textScaler: scaler,
)..layout())
.width;
var w = 0.0;
for (final e in _visibleEntries()) {
final m = measure(e.value);
if (m > w) w = m;
}
for (final s in const ['내림차순', '오름차순']) {
final m = measure(s) + _orderIconSize;
if (m > w) w = m;
}
return w.ceilToDouble();
}
// 날짜 오름/내림 선택 — 칩 줄 맨 뒤에 칩과 같은 모양의 버튼으로.
Widget _orderButton(double labelWidth) => PopupMenuButton<bool>(
initialValue: _desc,
onSelected: (v) => setState(() => _desc = v),
itemBuilder: (_) => const [
PopupMenuItem(value: true, child: Text('내림차순')),
PopupMenuItem(value: false, child: Text('오름차순')),
],
child: IgnorePointer(
child: ActionChip(
padding: _chipPadding,
labelPadding: _chipLabelPadding,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
label: SizedBox(
width: labelWidth,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_desc ? '내림차순' : '오름차순', style: _chipFont),
const Icon(Icons.arrow_drop_down, size: _orderIconSize),
]),
),
onPressed: () {},
),
),
);
- 폭 측정:
TextPainter로 실제 글자 폭을 잰다.main.dart에서 앱 전체 글자 배율을 1.06으로 두므로MediaQuery.textScalerOf(context)를 꼭 넘긴다(안 넘기면 칩 폭이 글자보다 좁게 계산돼 글자가 잘린다). iOS 시스템 '텍스트 크기' 설정이 바뀌어도 같이 반영된다. - 정렬 버튼: 모양은
ActionChip, 누름 처리는 바깥PopupMenuButton이 한다. 칩이 탭을 먹지 않도록IgnorePointer로 감싼다(onPressed: () {}는 비활성 회색 모양을 피하려는 것). _desc(true = 내림차순, 기본값) 필드와 정렬 로직(_cmpTodo등)은 그대로.
1-4. build()의 칩 줄
변경 전
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
const SizedBox(height: 8),
Row(children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: _visibleEntries().map((e) {
final sel = e.key == _view;
final isImportant = e.key == 'important';
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
avatar: isImportant
? const Icon(Icons.star,
size: 18, color: Color(0xFFF5A623))
: null,
label: Text(e.value),
selected: sel,
onSelected: (_) {
setState(() => _view = e.key);
_load();
},
),
);
}).toList(),
),
),
),
_orderCombo(),
]),
const Divider(height: 12),
...
변경 후
Widget build(BuildContext context) {
final labelWidth = _uniformLabelWidth(context);
return Scaffold(
body: Column(
children: [
const SizedBox(height: 8),
// 칩이 화면 폭을 넘으면 다음 줄로 내려 전부 보이게 한다(가로 스크롤 X).
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Wrap(
spacing: 6,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
..._visibleEntries().map((e) {
return ChoiceChip(
showCheckmark: false,
padding: _chipPadding,
labelPadding: _chipLabelPadding,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
label: SizedBox(
width: labelWidth,
child: Text(e.value,
style: _chipFont, textAlign: TextAlign.center),
),
selected: e.key == _view,
onSelected: (_) {
setState(() => _view = e.key);
_load();
},
);
}),
_orderButton(labelWidth),
],
),
),
const Divider(height: 12),
... // 아래(Expanded 목록, FAB)는 변경 없음
2. 달력 보기 버튼 헤더 — lib/screens/calendar_tab.dart
문제
월 이동(‹ 2026년 9월 ›)과 보기 버튼 6개가 한 줄이고, 폭이 모자라면 FittedBox가 버튼 전체를 축소한다.
좁은 화면(폴드 접힘)에서는 버튼이 절반 크기로 작아져 누르기 어려웠다.
결과 모습
| 화면 폭 | 모습 |
|---|---|
| 600 이상 (폴드 펼침, 태블릿, 가로 모드) |
기존 그대로 1줄 |
| 600 미만 (폴드 접힘, 일반 폰 세로) |
2줄 — 버튼을 폭 전체에 같은 폭으로, 원래 크기(글자 13 · 높이 32)
|
2-1. _modeBtn 교체 + 헬퍼 2개 추가
변경 전
Widget _modeBtn(String label, bool active, Color activeBg, VoidCallback onTap) {
return Padding(
padding: const EdgeInsets.only(left: 5),
child: OutlinedButton(
onPressed: onTap,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
minimumSize: const Size(0, 32),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6)), // 네모+약간 라운드
backgroundColor: active ? activeBg : null,
foregroundColor: active ? Colors.white : null,
textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
child: Text(label),
),
);
}
변경 후 — 같은 자리에 아래 전체로 교체
/// 좁은 화면(폴드 접힘 등, 폭 600dp 미만) → 달력 헤더를 2줄로.
bool get _narrowHeader => MediaQuery.of(context).size.width < 600;
/// 넓으면 1줄(월 이동 + 오른쪽 끝 버튼, 모자라면 축소),
/// 좁으면 2줄(월 이동 가운데 / 버튼 6개를 같은 폭으로 원래 크기).
Widget _calendarHeader({required List<Widget> nav, required List<Widget> modes}) {
if (_narrowHeader) {
return Column(mainAxisSize: MainAxisSize.min, children: [
Row(mainAxisAlignment: MainAxisAlignment.center, children: nav),
const SizedBox(height: 4),
Row(children: [for (final m in modes) Expanded(child: m)]),
]);
}
return Row(children: [
...nav,
// 남는 폭에 맞춰 버튼을 오른쪽 정렬 + 좁으면 축소(overflow 방지).
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(mainAxisSize: MainAxisSize.min, children: modes),
),
),
]);
}
Widget _modeBtn(String label, bool active, Color activeBg, VoidCallback onTap) {
final narrow = _narrowHeader; // 2줄 헤더: 칸 폭을 꽉 채우고 좌우 간격을 균등하게
return Padding(
padding: narrow
? const EdgeInsets.symmetric(horizontal: 2)
: const EdgeInsets.only(left: 5),
child: OutlinedButton(
onPressed: onTap,
style: OutlinedButton.styleFrom(
padding: EdgeInsets.symmetric(horizontal: narrow ? 0 : 10, vertical: 2),
minimumSize: const Size(0, 32),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6)), // 네모+약간 라운드
backgroundColor: active ? activeBg : null,
foregroundColor: active ? Colors.white : null,
textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
// 아주 좁은 폰에서만 글자가 칸을 넘지 않게 축소(보통은 원래 크기).
child: narrow
? FittedBox(fit: BoxFit.scaleDown, child: Text(label))
: Text(label),
),
);
}
_narrowHeader는 State의context를 쓰므로build중에만 호출된다(헤더와 버튼 모두 build 안에서 만들어짐).- 2줄 모드의 버튼은
Expanded로 같은 폭을 받고, 좌우 여백 2씩이라 간격이 균등하다. 안쪽 가로 패딩을 0으로 해서 좁은 폭에서도공휴일(가장 긴 글자)이 원래 크기로 들어간다.
2-2. 헤더 부분 — build() 안 Card → Column의 첫 자식
바꾸는 것은 감싸는 구조뿐이다. 월 이동 위젯 4개와 _modeBtn(...) 6개 호출의 내용(onTap 로직 포함)은 그대로 둔다.
변경 전 (뼈대)
// 커스텀 헤더 1줄: ‹ 2026년 8월 › …오른쪽 끝… [전체][할일][일정][기록][반복][공휴일]
Padding(
padding: const EdgeInsets.fromLTRB(6, 4, 6, 4),
child: Row(
children: [
IconButton(... Icons.chevron_left ... _changeMonth(-1)),
InkWell(key: _yearKey, ... '${_focused.year}년' ...),
InkWell(key: _monthKey, ... '${_focused.month}월' ...),
IconButton(... Icons.chevron_right ... _changeMonth(1)),
// 남는 폭에 맞춰 버튼 4개를 오른쪽 정렬 + 좁으면 축소(overflow 방지).
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_modeBtn('전체', ...),
_modeBtn('할일', ...),
_modeBtn('일정', ...),
_modeBtn('기록', ...),
_modeBtn('반복', ...),
_modeBtn('공휴일', ...),
],
),
),
),
],
),
),
변경 후 (뼈대)
// 커스텀 헤더: 넓은 화면(펼침)은 1줄 ‹ 2026년 8월 › …오른쪽 끝… [전체]…[공휴일],
// 좁은 화면(폴드 접힘 등)은 2줄 — 버튼을 줄이지 않고 아래 줄에 같은 폭으로.
Padding(
padding: const EdgeInsets.fromLTRB(6, 4, 6, 4),
child: _calendarHeader(
nav: [
IconButton(... Icons.chevron_left ... _changeMonth(-1)),
InkWell(key: _yearKey, ... '${_focused.year}년' ...),
InkWell(key: _monthKey, ... '${_focused.month}월' ...),
IconButton(... Icons.chevron_right ... _changeMonth(1)),
],
modes: [
_modeBtn('전체', ...),
_modeBtn('할일', ...),
_modeBtn('일정', ...),
_modeBtn('기록', ...),
_modeBtn('반복', ...),
_modeBtn('공휴일', ...),
],
),
),
실제 편집 순서(3곳):
child: Row(children: [→child: _calendarHeader(nav: [- 오른쪽 화살표
IconButton(...),바로 뒤의
를Expanded( child: FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerRight, child: Row( mainAxisSize: MainAxisSize.min, children: [], modes: [로 교체 - 마지막
_modeBtn('공휴일', ...)뒤의 닫는 괄호
를], ), ), ), ], ), ),
로 교체], ), ),
달력 표 행 높이는 헤더 아래 Expanded + LayoutBuilder의 fitRow로 남는 높이에서 계산하므로,
2줄 헤더가 되면 행 높이가 자동으로 그만큼(약 36) 줄어든다. 다른 곳은 고칠 필요 없다.
3. (해당 시) 공휴일 이름 holi! 경고 — lib/screens/calendar_tab.dart
아이폰 앱의 _cell()에서 공휴일 이름 표시가 아래처럼 holi!이면 flutter analyze에
unnecessary_non_null_assertion 경고가 난다. 이미 holi이면 건너뛴다.
final holi = Holidays.nameOf(iso); // String?
final showHoli = holi != null && !outside && ...;
...
if (showHoli) ...[
const SizedBox(width: 2),
Expanded(
child: Text(
holi!, // ← 변경 전
holi, // ← 변경 후 (showHoli 안에서 이미 non-null로 승격됨)
동작 변화 없음.
검증
flutter analyze— 안드로이드 두 앱: No issues found. 아이폰 앱도 같은 결과여야 한다.- 화면 확인
- 할 일 탭: 칩 7개(별표 0개면 6개)가 밀지 않아도 전부 보임 · 모든 칩 폭 동일 · ✓ 없음 ·
내림차순▾누르면 메뉴 →오름차순선택 시 목록 순서 반전 + 버튼 글자 변경. - 별표를 모두 해제하면
별표칩이 사라지고, 별표 보기였으면오늘로 돌아감(기존 동작). - 달력 탭(아이폰 세로): 헤더 2줄, 버튼 6개가 폭 전체에 같은 폭 · 원래 크기.
- 달력 탭(아이폰 가로 / 아이패드): 기존 1줄 헤더.
- 보기 버튼 동작(전체=필터 해제+오늘 이동, 할일=오늘 이동, 나머지 토글) 기존과 동일.
- 할 일 탭: 칩 7개(별표 0개면 6개)가 밀지 않아도 전부 보임 · 모든 칩 폭 동일 · ✓ 없음 ·
- iOS 설정 > 디스플레이 > 텍스트 크기를 키워도 할 일 칩 글자가 잘리지 않는지(폭 측정이 배율 반영).