Flutter · Windows 재현 매뉴얼
달력 앱 하루계획의 버그 수정·레이아웃 개편·UI 개선을 Windows에서 그대로 따라 할 수 있게 정리한 매뉴얼입니다. 먼저 환경 준비 → 각 단계 문제·원인·수정 코드 → 검증·빌드 순으로 진행하세요.
Flutter SDK 는 C:\fluttersdk 에 설치돼 있다고 가정합니다. 터미널(PowerShell 또는 명령 프롬프트)에서 아래를 실행해 준비합니다.
# 이번 세션에만 PATH 등록 (영구 등록은 시스템 환경변수에서) $env:Path += ";C:\fluttersdk\bin" # 프로젝트 폴더로 이동 cd C:\path\to\day_plan_appl_app flutter doctor # 설치 상태 점검(안드로이드 toolchain 확인) flutter pub get # 의존성 설치 flutter devices # 연결된 안드로이드 기기/에뮬레이터 확인
flutter analyze 로 오류가 없는지 확인하고, flutter run 으로 실행해 눈으로 확인합니다.문제 — 편집 시트·목록에서 이모지(☀ 📅 🔁 🔔 🗒 ✅)가 “?” 두부(tofu)로 표시됨. 원인 — 이모지를 글자로 사용. Material 아이콘 폰트는 정상 렌더링되므로 아이콘으로 교체.
// 변경 전 title: const Text('☀ 나의 하루에 추가'), // 변경 후 — 스위치 왼쪽에 해 아이콘 secondary: const Icon(Icons.wb_sunny_outlined, color: Color(0xFFF5A623)), title: const Text('나의 하루에 추가'),
목록 부제는 Text.rich + 아이콘으로, 달력 섹션 헤더 _sectionHeader(String,…) 는 IconData 인자를 받도록 변경(📅→event, 🗒→description, ✅→check_circle, 🔁→repeat).
처음엔 시뮬레이터 환경(Intel 맥 + iOS 26)의 Impeller(Metal) 렌더러를 의심해 아래 플래그로 Skia로 되돌려 봄. 그러나 증상 그대로였고, 실기기에서도 재현되어 렌더러가 아닌 코드 버그로 확정(→ 03).
<key>FLTEnableImpeller</key>
<false/> <!-- 조사용: 이번 원인 아님. iOS 전용, 안드로이드 무관 -->
증상 — 필터 전환 시 화면이 흰색으로 굳음(실기기 동일). 원인 — 기기 로그의 NaN → CoreGraphics 에러. 하단 시트 key가 행 높이에 따라 바뀌어 살아있는 컨트롤러를 쥔 채 시트가 재생성되며, 그 전환 프레임에서 크기 계산이 NaN이 됨.
// 예전(문제): 행 높이가 바뀌면 key가 바뀌어 시트가 통째로 재생성 → NaN // key: ValueKey('sheet-${calRowHeight.round()}') key: const ValueKey('calendar-sheet'), // 고정 → 재생성 안 함
var sheetInit = bodyH > 0 ? (bodyH - calH) / bodyH : 0.5; if (!sheetInit.isFinite) sheetInit = 0.5; // NaN·무한대 차단 sheetInit = sheetInit.clamp(0.06, 0.85).toDouble();
문제 — 여백 추정치(nonRowH)가 실제와 안 맞아 6주가 잘리거나 아래 여백이 남음. 해결 — 달력을 Positioned(bottom: 시트높이)로 고정 영역에 두고, 그 실제 높이를 주 수로 나눠 행 높이를 계산(추정치 제거). 5주/6주 모두 정확히 채움.
Positioned(top:0, left:0, right:0, bottom: collapsedSheetH, child: Card(child: Column(children:[ header, Expanded(child: LayoutBuilder(builder: (ctx, cc){ const dowH = 22.0; final fitRow = weeks > 0 ? (cc.maxHeight - dowH - 2) / weeks : calRowHeight; var row = calRowHeight > fitRow ? calRowHeight : fitRow; final cal = MediaQuery(/* … */ child: TableCalendar(rowHeight: row, …)); return row > fitRow ? SingleChildScrollView(child: cal) : cal; // 넘치면 스크롤 })), ])), )
하단 시트는 minChildSize = collapsedFrac로 날짜·건수 헤더보다 더 내려가지 않게 고정.
날짜칸에서 제목 텍스트·“외 N”을 없애고, 카테고리별 개수를 대표색 원 + 숫자로 표시. 일정=초록, 기록=보라, 할일=빨강, 완료=검정, 구글=파랑. 날짜 숫자는 가운데, 원들은 왼쪽 정렬.
Widget _countCircle(Color c, int n) => Container( width: 18, height: 18, alignment: Alignment.center, decoration: BoxDecoration(color: c, shape: BoxShape.circle), child: Text('$n', style: const TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)), );
SizedBox(width: double.infinity, child: Padding( padding: const EdgeInsets.only(left: 4), child: Wrap(alignment: WrapAlignment.start, spacing: 3, runSpacing: 3, children: [ if (showEv) _countCircle(const Color(0xFF0B7A3B), evCount), // 일정 if (showRec) _countCircle(const Color(0xFF7A4FBB), recCount), // 기록 if (showTodo) _countCircle(const Color(0xFFE11D1D), todoCount), // 할일 if (showDone) _countCircle(Colors.black, doneCnt), // 완료 if (showGcal) _countCircle(const Color(0xFF1A73E8), gcalCount), // 구글 ]), ))
빈 날을 눌러도 시트가 올라오던 것을, 공휴일/일정/기록/할일/완료/구글 중 하나라도 있을 때만 올라오게 함.
final si = isoOf(sel); final hasContent = Holidays.nameOf(si) != null || (_eventsByDate[si]?.isNotEmpty ?? false) || (_recordsByDate[si]?.isNotEmpty ?? false) || (_todosByDate[si]?.isNotEmpty ?? false) || ((_doneCounts[si] ?? 0) > 0) || (_gcal[si]?.isNotEmpty ?? false); if (hasContent) _raiseSheet();
시트 우상단 X를 누르면 접힌 위치(collapsedFrac, 헤더만)로 부드럽게 내려감.
Positioned(top: 2, right: 2, child: IconButton( icon: const Icon(Icons.close), iconSize: 20, tooltip: '닫기', onPressed: () { if (_sheetCtl.isAttached) { _sheetCtl.animateTo(collapsedFrac, duration: const Duration(milliseconds: 250), curve: Curves.easeOut); } }, ))
주의점 — 처음엔 Stack으로 X를 상단에 겹쳤더니 광고 위에 얹혀 광고를 닫는 것처럼 보였음. 그래서 Column으로 바꿔 광고 위쪽 별도 줄에 두고 네모 박스로 감쌈. 편집기 3종에 공통 적용.
child: Column(mainAxisSize: MainAxisSize.min, children: [ // 닫기(X): 광고 위 별도 줄 + 네모 박스(오른쪽) Align(alignment: Alignment.centerRight, child: InkWell( onTap: () => Navigator.pop(ctx), child: Container( width: 30, height: 30, alignment: Alignment.center, decoration: BoxDecoration( border: Border.all(color: const Color(0xFFCFD3D8)), borderRadius: BorderRadius.circular(6)), child: const Icon(Icons.close, size: 18)))), Flexible(child: _TodoEditor(/* … */)), // 그 아래에 광고+폼 ]),
+ 로 여는 편집(모달 바텀) 시트에 광고를 붙임. 편집기가 3종인데 그동안 할일 편집기만 광고가 있어, 일정/기록·완료 편집기에도 추가해 통일. (처음엔 달력 상세 시트에 넣었다가, 요구가 모달 편집 시트였음이 확인돼 상세 시트 쪽은 제거)
children: [ // 시트 상단 배너 광고(자리 미리 확보해 로드 시 폼이 안 밀림) const BannerAdWidget(reserveSpace: true), const SizedBox(height: 4), // … 제목 입력 등 ]
문제 — 마지막 주(예: 31일) 선택 시 선택 박스 아래가 바텀 시트에 가림. 해결 — 달력 영역을 시트보다 12px 더 위에서 끝나게 여유를 줌. 행 높이는 여전히 영역 ÷ 주 수라 폰 크기에 맞게 자동 조절됨.
Positioned(top: 0, left: 0, right: 0, // 접힌 시트 위 + 여유 → 마지막 주 선택 박스가 안 가림 bottom: collapsedSheetH + 12, child: Card(/* 달력 */)),
수정을 마쳤으면 정적 분석으로 오류를 확인하고, 실행/빌드로 검증합니다.
flutter analyze # 정적 분석 (오류 0 확인) flutter run # 안드로이드 기기/에뮬레이터에서 실행 flutter build apk --debug # 디버그 APK # 산출물: build\app\outputs\flutter-apk\app-debug.apk flutter build apk --release # 릴리스 APK
r 핫 리로드, R 핫 리스타트, q 종료.Info.plist 등 네이티브 설정 변경은 핫 리로드로 반영 안 됨 → 다시 빌드 필요.