아이폰 앱에 넣을 코드 — 할 일 필터 칩 · 달력 헤더

2026-09-27 17:23 (KST). 안드로이드 day_plan_app · auto_schedule_app에 이미 들어간 코드다. 아이폰 앱도 같은 Flutter(Dart) 파일 경로를 쓴다고 보고, 어느 파일의 어느 자리를 어떻게 바꾸는지를 변경 전/후 코드로 적었다. 파일 전체를 덮어쓰지 말고 해당 부분만 바꾼다.

요약

#파일내용
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개만 보이고 나머지는 옆으로 밀어야 보였다(밀 수 있다는 표시도 없음).

결과 모습

[ 전체 ] [ 오늘 ] [ 별표 ] [ 예정 ] [ 지연 ]
[완료됨] [내림차순▾]

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: () {},
          ),
        ),
      );

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줄
‹ 2026년 9월 ›        [전체][할일][일정][기록][반복][공휴일]
600 미만
(폴드 접힘, 일반 폰 세로)
2줄 — 버튼을 폭 전체에 같은 폭으로, 원래 크기(글자 13 · 높이 32)
          ‹  2026년 9월  ›
[전체][할일][일정][기록][반복][공휴일]
아이폰에서의 의미: 기준은 논리 폭 600(안드로이드 dp = iOS pt). 아이폰은 세로 폭이 약 375~440pt라 세로 모드에서는 항상 2줄로 나온다. 가로 모드(약 667~956pt)와 아이패드에서는 1줄(기존 모습). 회전하면 폭이 바뀌어 자동으로 전환된다.

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),
      ),
    );
  }

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곳):

  1. child: Row(children: [ → child: _calendarHeader(nav: [
  2. 오른쪽 화살표 IconButton(...), 바로 뒤의
                          Expanded(
                            child: FittedBox(
                              fit: BoxFit.scaleDown,
                              alignment: Alignment.centerRight,
                              child: Row(
                                mainAxisSize: MainAxisSize.min,
                                children: [
    를 ], modes: [로 교체
  3. 마지막 _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로 승격됨)

동작 변화 없음.

검증