Search
Duplicate

다이어리 앱

Flutter 다이어리 앱 만들기

Flutter로 파일 기반 일기장 앱을 처음부터 만드는 과정을 단계별로 개발해보아요.

목차

1. 프로젝트 개요

앱 소개

이름: 다이어리 앱 (Diary App)
목적: Flutter의 파일 I/O, 상태 관리, 페이지 라우팅을 익히기 위한 실습 프로젝트
특징: DB 없이 텍스트 파일만으로 일기를 저장·관리

주요 기능

기능
설명
일기 작성
날짜·제목·본문 입력 후 저장
일기 목록
최신순 정렬, 스와이프 삭제
일기 상세
보기 / 수정 / 날짜 변경
달력 뷰
월별 달력 + 일기 있는 날 마커 표시
검색
날짜·제목·본문 통합 검색 (디바운스 적용)
다중 선택 삭제
여러 일기 한 번에 삭제

프로젝트 구조

diary_app/ ├── lib/ │ ├── main.dart # 앱 진입점 · 테마 설정 │ ├── services/ │ │ └── file_service.dart # 파일 I/O 서비스 (비즈니스 로직) │ └── pages/ │ ├── home_page.dart # 일기 목록 화면 │ ├── write_page.dart # 일기 작성 화면 │ ├── detail_page.dart # 일기 상세보기 · 수정 화면 │ └── calendar_page.dart # 달력 뷰 화면 ├── guide/ # 강의 노트 문서 폴더 └── pubspec.yaml
Plain Text
복사

2. 프로젝트 초기 설정

Flutter 프로젝트 생성

flutter create diary_app cd diary_app
Bash
복사

패키지 추가

flutter pub add path_provider flutter pub add calendar_date_picker2 flutter pub add table_calendar
Bash
복사

pubspec.yaml 결과

dependencies: flutter: sdk: flutter path_provider: ^2.0.0 calendar_date_picker2: ^2.0.1 table_calendar: ^3.2.0 flutter_multi_select_items: ^0.4.3
YAML
복사
: 버전을 직접 지정하지 않고 flutter pub add 패키지명을 사용하면 pub.dev에서 최신 버전을 자동으로 선택합니다.

폴더 구조 생성

lib/ ├── services/ ← FileService 배치 └── pages/ ← 각 화면 배치
Plain Text
복사

main.dart — 앱 진입점

void main() => runApp(const DiaryApp()); class DiaryApp extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp( theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.amber), useMaterial3: true, ), home: const HomePage(), ); } }
Dart
복사
useMaterial3: true + amber 시드 컬러로 전체 테마를 통일합니다.

3. 사용 라이브러리

3-1. path_provider ^2.0.0

역할: 플랫폼(Android/iOS/Windows 등)별로 다른 파일 저장 경로를 자동으로 반환
플랫폼
경로 예시
Android
/data/user/0/com.example.diary_app/app_flutter/
iOS
/var/mobile/.../Documents/
Windows
C:\\Users\\user\\Documents\\
import 'package:path_provider/path_provider.dart'; final dir = await getApplicationDocumentsDirectory(); // → 이 경로 아래에 .txt 파일 저장
Dart
복사

3-2. calendar_date_picker2 ^2.0.1

역할: 다이얼로그 형태의 날짜 선택 달력 위젯
적용 위치
화면
기능
WritePage
일기를 쓸 날짜 선택 (기본값: 오늘)
DetailPage (edit 모드)
기존 일기의 날짜 변경
Future<void> _pickDate() async { final result = await showCalendarDatePicker2Dialog( context: context, config: CalendarDatePicker2WithActionButtonsConfig( calendarType: CalendarDatePicker2Type.single, selectedDayHighlightColor: Colors.amber, okButton: const Text('확인', style: TextStyle(color: Colors.amber)), cancelButton: const Text('취소'), ), dialogSize: const Size(325, 400), borderRadius: BorderRadius.circular(15), value: [_selectedDate], ); if (result != null && result.isNotEmpty && result.first != null) { setState(() => _selectedDate = result.first!); } }
Dart
복사

3-3. table_calendar ^3.2.0

역할: 월/주 뷰 달력 위젯. 이벤트 마커, 날짜 선택, 커스텀 스타일 지원
TableCalendar<DiaryEntry>( firstDay: DateTime(2020), lastDay: DateTime(2100), focusedDay: _focusedDay, selectedDayPredicate: (day) => isSameDay(_selectedDay, day), eventLoader: _eventsFor, // 날짜 → 이벤트 리스트 함수 onDaySelected: _onDaySelected, onPageChanged: (day) => setState(() => _focusedDay = day), calendarStyle: CalendarStyle( todayDecoration: BoxDecoration(color: Colors.amber.shade300, shape: BoxShape.circle), selectedDecoration: BoxDecoration(color: Colors.amber, shape: BoxShape.circle), markerDecoration: BoxDecoration(color: Colors.deepOrange, shape: BoxShape.circle), ), headerStyle: HeaderStyle( formatButtonVisible: false, titleCentered: true, ), )
Dart
복사
이벤트 마커 원리
Map<String, List<DiaryEntry>> _eventMap = {}; List<DiaryEntry> _eventsFor(DateTime day) { final key = '${day.year}-${day.month.toString().padLeft(2, '0')}' '-${day.day.toString().padLeft(2, '0')}'; return _eventMap[key] ?? []; } // 반환된 리스트 크기만큼 날짜 아래에 점(marker)이 표시됨
Dart
복사

3-4. flutter_multi_select_items ^0.4.3

설치는 되어 있으나 사용하지 않음
사용하지 않은 이유: 내부적으로 Wrap을 사용하는 위젯이 SingleChildScrollView + Expanded 안에서 무한 높이 레이아웃 오류 발생
RenderFlex children have non-zero flex but incoming height constraints are unbounded.
Plain Text
복사
대안: Set<String> + ListView.builder로 직접 구현
Set<String> _selectedPaths = {}; onTap: () => setState(() { if (_selectedPaths.contains(path)) { _selectedPaths.remove(path); } else { _selectedPaths.add(path); } });
Dart
복사

4. 데이터 설계 — FileService

원칙: UI와 데이터 로직을 분리한다 (서비스 레이어 패턴)

4-1. 파일 저장 전략

고려 사항
결정
DB 사용 여부
사용 안 함 — 텍스트 파일로 충분
하루 1개 제한 여부
제한 없음 — 시각(HHmmss)을 파일명에 포함
파일명 형식
YYYY-MM-DD_HHmmss.txt
내용 직렬화
제목 + \\n---DIARY---\\n + 본문
파일명 예시
2026-03-26_143022.txt
Plain Text
복사
날짜(YYYY-MM-DD) + 시각(HHmmss) 조합 → 파일 이름 충돌 없음
파일 이름 자체가 메타데이터를 겸함 → DB 불필요
파일 내용 포맷
오늘의 일기 제목 ---DIARY--- 오늘은 날씨가 맑았다. Flutter 공부를 열심히 했다. 여러 줄도 가능하다.
Plain Text
복사

4-2. DiaryEntry 모델 클래스

class DiaryEntry { final String path; // 파일 절대 경로 (기본 키) final String date; // "YYYY-MM-DD" (UI 표시용) final String time; // "HH:mm:ss" (UI 표시용) final String title; // 제목 (목록에서 미리 로드) }
Dart
복사
설계 이유: FileSystemEntity를 직접 쓰면 목록 렌더링 시 매번 파일을 읽어야 합니다.
DiaryEntry로 한 번에 로드해두면 추가 I/O 없이 빠른 목록 갱신이 가능합니다.

4-3. 주요 메서드 정리

직렬화 / 역직렬화

// 저장: 제목 + 구분자 + 본문 → 하나의 문자열 String _serialize(String title, String content) => '$title\\n---DIARY---\\n$content'; // 읽기: 제목 추출 String parseTitleFromRaw(String raw) { final idx = raw.indexOf('\\n---DIARY---\\n'); if (idx == -1) return ''; return raw.substring(0, idx); } // 읽기: 본문 추출 String parseContentFromRaw(String raw) { final sep = '\\n---DIARY---\\n'; final idx = raw.indexOf(sep); if (idx == -1) return raw; // 구분자 없으면 전체가 본문 (구버전 호환) return raw.substring(idx + sep.length); }
Dart
복사

새 일기 저장 — saveDiaryForDate()

Future<void> saveDiaryForDate(String date, String title, String content) async { final dirPath = await getDirPath(); final now = DateTime.now(); final t = '${now.hour.toString().padLeft(2, '0')}' '${now.minute.toString().padLeft(2, '0')}' '${now.second.toString().padLeft(2, '0')}'; final file = File('$dirPath/${date}_$t.txt'); await file.writeAsString(_serialize(title, content)); }
Dart
복사
현재 시각을 파일명에 포함 → 같은 날 여러 일기 저장 가능
padLeft(2, '0'): 한 자리 숫자를 두 자리로 패딩 (909)

기존 일기 수정 — saveDiaryToPath()

Future<void> saveDiaryToPath(String path, String title, String content) async { await File(path).writeAsString(_serialize(title, content)); } // 경로가 고정 → 파일명(날짜·시각) 변경 없이 내용만 덮어씌움
Dart
복사

날짜 변경 — changeDiaryDate()

Future<String> changeDiaryDate( String oldPath, String newDate, String title, String content) async { final dirPath = await getDirPath(); final name = oldPath.split(Platform.pathSeparator).last.replaceAll('.txt', ''); final timePart = name.contains('_') ? name.split('_').last : ''; final newPath = '$dirPath/${newDate}_$timePart.txt'; await File(newPath).writeAsString(_serialize(title, content)); await File(oldPath).delete(); return newPath; }
Dart
복사
날짜가 바뀌면 파일명도 바뀌어야 함 → 새 파일 생성 후 기존 파일 삭제
HHmmss 시각 부분은 그대로 유지 (작성 시각 보존)

전체 목록 조회 — getDiaryEntries()

Future<List<DiaryEntry>> getDiaryEntries() async { final dir = Directory(await getDirPath()); final txts = dir.listSync().where((e) => e.path.endsWith('.txt')).toList(); txts.sort((a, b) => b.path.compareTo(a.path)); // 최신순 정렬 final entries = <DiaryEntry>[]; for (final f in txts) { final raw = await File(f.path).readAsString(); entries.add(DiaryEntry( path: f.path, date: getDateFromPath(f.path), time: getTimeFromPath(f.path), title: parseTitleFromRaw(raw), )); } return entries; }
Dart
복사

경로 파싱 — getDateFromPath() / getTimeFromPath()

// "2026-03-26_143022.txt" → "2026-03-26" String getDateFromPath(String path) { final name = path.split(Platform.pathSeparator).last.replaceAll('.txt', ''); return name.contains('_') ? name.split('_').first : name; } // "2026-03-26_143022.txt" → "14:30:22" String getTimeFromPath(String path) { final name = path.split(Platform.pathSeparator).last.replaceAll('.txt', ''); final t = name.split('_').last; return '${t.substring(0, 2)}:${t.substring(2, 4)}:${t.substring(4, 6)}'; }
Dart
복사
Platform.pathSeparator: Windows(\\) / macOS·Linux(/) 자동 대응

검색 — searchEntries()

Future<List<DiaryEntry>> searchEntries(String query) async { final all = await getDiaryEntries(); final q = query.toLowerCase(); final result = <DiaryEntry>[]; for (final entry in all) { // 1차: 날짜 or 제목 (이미 로드된 정보 → 추가 I/O 없음) if (entry.date.contains(q) || entry.title.toLowerCase().contains(q)) { result.add(entry); continue; } // 2차: 본문 (필요한 경우에만 파일 추가 읽기) final raw = await readDiaryRaw(entry.path); if (parseContentFromRaw(raw).toLowerCase().contains(q)) { result.add(entry); } } return result; }
Dart
복사
검색 범위: 날짜 → 제목 → 본문 순서로 탐색, 조건 만족 시 즉시 추가

다중 삭제 — deleteMultiple()

Future<void> deleteMultiple(List<String> paths) async { for (final p in paths) { await deleteDiary(p); // File(path).delete() } }
Dart
복사

4-4. 전체 데이터 흐름

사용자 동작 │ ▼ HomePage / WritePage / DetailPage │ 호출 ▼ FileService (file_service.dart) │ ├── 쓰기 → saveDiaryForDate / saveDiaryToPath / changeDiaryDate │ └── File.writeAsString(_serialize(title, content)) │ ├── 읽기 → getDiaryEntries / readDiaryRaw │ └── File.readAsString → parseTitleFromRaw / parseContentFromRaw │ ├── 삭제 → deleteDiary / deleteMultiple │ └── File.delete() │ └── 검색 → searchEntries └── getDiaryEntries + 본문 순차 검색
Plain Text
복사

5. 화면 구현

5-1. HomePage — 일기 목록 화면

구현 순서
1. _loadDiaries() → getDiaryEntries() 호출, 상태 저장 2. _buildNormalList() → ListView.builder + Dismissible 카드 3. _buildAppBar() → 검색 모드 TextField / 일반 모드 제목 4. _buildDrawer() → 사이드 메뉴 5. _buildMultiSelectList() → Set<String>으로 다중 선택 관리 6. 검색 로직 → Timer 디바운스 500ms
Plain Text
복사
검색 디바운스 패턴
Timer? _debounce; void _onSearchChanged(String query) { _debounce?.cancel(); // 이전 타이머 취소 _debounce = Timer( const Duration(milliseconds: 500), () async { final results = await _fileService.searchEntries(query); setState(() => _filteredList = results); }, ); }
Dart
복사
이유: 키 입력마다 파일 검색을 실행하면 성능 낭비. 500ms 동안 추가 입력이 없을 때만 실행.
다중 선택 상태 관리
Set<String> _selectedPaths = {}; // 전체 선택 _selectedPaths = _filteredList.map((e) => e.path).toSet(); // 전체 해제 _selectedPaths.clear(); // 토글 _selectedPaths.contains(path) ? _selectedPaths.remove(path) : _selectedPaths.add(path);
Dart
복사

5-2. WritePage — 일기 작성 화면

구현 순서
1. _titleController → 제목 입력 필드 2. _controller → 본문 입력 필드 (expands: true로 남은 공간 채움) 3. _selectedDate → DateTime 상태 (기본: 오늘) 4. _pickDate() → calendar_date_picker2 다이얼로그 5. _save() → 유효성 검사 → saveDiaryForDate() → Navigator.pop() 6. bottomSheet → "작성하기" 버튼 (화면 하단 고정)
Plain Text
복사
UI 레이아웃 구조
Scaffold ├── AppBar (저장 아이콘) ├── bottomSheet (작성하기 ElevatedButton) └── body (Padding fromLTRB(16, 16, 16, 80)) └── Column ├── InkWell → 날짜 선택 Row ├── SizedBox(height: 14) ├── TextField (제목) ├── SizedBox(height: 12) └── Expanded → TextField (본문, expands: true)
Plain Text
복사
bottomSheet를 사용하면 body가 버튼 뒤로 가려질 수 있으므로 body 하단 패딩을 버튼 높이(약 80px)만큼 추가해야 합니다.

5-3. DetailPage — 일기 상세보기 · 수정 화면

구현 순서
1. _loadContent() → readDiaryRaw() → parseTitleFromRaw / parseContentFromRaw 2. 보기 모드 → 제목(굵게) + Divider + 본문 텍스트 3. 수정 모드 진입 → _isEditing = true 4. 편집 UI → 날짜 선택 행 + 제목 TextField + 본문 TextField (Expanded) 5. _saveEdit() → 날짜 변경 여부 분기 ├── 날짜 동일 → saveDiaryToPath() → setState(_isEditing = false) └── 날짜 변경 → changeDiaryDate() → Navigator.pop() (파일명이 바뀌므로)
Plain Text
복사
날짜 변경 시 파일 처리 전략
if (_selectedDateStr != widget.date) { await _fileService.changeDiaryDate( widget.path, _selectedDateStr, title, content); Navigator.pop(context); // 목록 화면으로 돌아가 자동 새로고침 return; }
Dart
복사
날짜가 바뀌면 파일명도 바뀌어야 하므로, 기존 DetailPage를 pop하고 목록 화면에서 새로고침합니다.

5-4. CalendarPage — 달력 뷰 화면

구현 순서
1. _loadEntries() → getDiaryEntries() → Map<String, List<DiaryEntry>> 변환 2. _eventsFor(day) → 날짜 키 → 일기 목록 반환 (eventLoader) 3. TableCalendar → 마커 표시 + 날짜 선택 이벤트 4. _onDaySelected() → 1개: 바로 DetailPage / 2개 이상: BottomSheet 선택 5. _buildSelectedDayList() → 달력 아래 선택일 일기 목록
Plain Text
복사
날짜별 Map 구성 로직
final entries = await _fileService.getDiaryEntries(); final map = <String, List<DiaryEntry>>{}; for (final e in entries) { map.putIfAbsent(e.date, () => []).add(e); // "2026-03-26" → [DiaryEntry, DiaryEntry, ...] }
Dart
복사
날짜 선택 이벤트 처리
void _onDaySelected(DateTime selectedDay, DateTime focusedDay) { setState(() { _selectedDay = selectedDay; _focusedDay = focusedDay; }); final entries = _eventsFor(selectedDay); if (entries.isEmpty) return; if (entries.length == 1) { _openDetail(entries.first); // 1개 → 바로 상세보기 } else { _showPickerSheet(entries); // 2개 이상 → BottomSheet 목록 } }
Dart
복사

6. 주요 UI 컴포넌트

6-1. Drawer (사이드 메뉴)

Scaffolddrawer 속성에 등록하면 AppBar 햄버거 아이콘()이 자동 생성됩니다.
Widget _buildDrawer() { return Drawer( child: ListView( padding: EdgeInsets.zero, // DrawerHeader가 상단에 딱 붙도록 children: [ const DrawerHeader( decoration: BoxDecoration(color: Colors.amber), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.end, children: [ Icon(Icons.menu_book, size: 48, color: Colors.white), SizedBox(height: 8), Text('내 일기장', style: TextStyle( color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold)), ], ), ), ListTile( leading: const Icon(Icons.calendar_month), title: const Text('달력으로 보기'), onTap: () { Navigator.pop(context); // Drawer 먼저 닫기 Navigator.push(context, MaterialPageRoute( builder: (_) => const CalendarPage())); }, ), ], ), ); }
Dart
복사
핵심 포인트
항목
설명
padding: EdgeInsets.zero
ListView 기본 상단 패딩 제거 → DrawerHeader가 상단에 딱 붙음
Navigator.pop(context)
Drawer를 먼저 닫고 페이지 이동해야 자연스러운 UX
DrawerHeader
고정 높이(160px)의 헤더 영역
ListTile
Drawer 메뉴의 표준 항목 (leading 아이콘 + title 텍스트)

6-2. Dismissible (스와이프 삭제)

리스트 항목을 스와이프하면 사라지게 만드는 위젯
Dismissible( key: Key(entry.path), // 파일 경로를 고유 키로 사용 direction: DismissDirection.endToStart, // 왼쪽 스와이프만 허용 background: Container( alignment: Alignment.centerRight, padding: const EdgeInsets.only(right: 20), margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 4), decoration: BoxDecoration( color: Colors.redAccent, borderRadius: BorderRadius.circular(12), ), child: const Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.delete, color: Colors.white, size: 28), Text('삭제', style: TextStyle(color: Colors.white, fontSize: 12)), ], ), ), confirmDismiss: (_) => showDialog<bool>( context: context, builder: (ctx) => AlertDialog( title: const Text('일기 삭제'), content: Text('[$date] 일기를 삭제하시겠습니까?'), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')), TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('삭제', style: TextStyle(color: Colors.red))), ], ), ), onDismissed: (_) => _deleteSingle(entry.path), child: Card(child: ListTile(...)), )
Dart
복사
confirmDismiss vs onDismissed 차이
속성
실행 시점
반환값
역할
confirmDismiss
스와이프 후, 항목이 사라지기 전
Future<bool?>
삭제 여부 최종 확인
onDismissed
confirmDismisstrue 반환 후
없음
실제 삭제 로직 실행
confirmDismiss에서 false 또는 null을 반환하면 항목이 원위치로 돌아옵니다.
key가 중요한 이유
// ❌ 잘못된 예 — 인덱스를 키로 사용 key: Key(index.toString()) // 삭제 후 다음 항목이 같은 키를 가져 오동작 가능 // ✅ 올바른 예 — 고유한 식별자를 키로 사용 key: Key(entry.path) // 파일 경로는 항상 고유
Dart
복사

6-3. ModalBottomSheet (달력 다중 일기 선택)

같은 날에 일기가 2개 이상일 때 선택 목록을 보여주는 패널
showModalBottomSheet( context: context, shape: RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (ctx) => Column( mainAxisSize: MainAxisSize.min, // 내용 높이에만 맞춤 children: [ Container(width: 40, height: 4, decoration: BoxDecoration(color: Colors.grey.shade300, borderRadius: BorderRadius.circular(2))), ListView.builder(shrinkWrap: true, ...), ], ), );
Dart
복사
속성
설명
mainAxisSize: MainAxisSize.min
Column이 컨텐츠 높이에만 맞춰 줄어듦
shrinkWrap: true
ListView가 Column 안에서 스크롤 없이 전체 높이 차지
shape
상단 모서리 둥근 처리

6-4. FloatingActionButton

화면 우하단에 고정된 원형 버튼 (새 일기 쓰기 액션)
floatingActionButton: _isMultiSelect ? null // 다중 선택 모드에서는 숨김 : FloatingActionButton( onPressed: () async { await Navigator.push( context, MaterialPageRoute(builder: (_) => const WritePage()), ); _loadDiaries(); // 돌아온 후 목록 새로고침 }, backgroundColor: Colors.amber, foregroundColor: Colors.black, child: const Icon(Icons.edit), ),
Dart
복사

6-5. BottomSheet (작성하기 버튼)

Scaffold.bottomSheet에 위젯을 올리면 화면 하단에 항상 고정된 영역이 생깁니다.
Scaffold( bottomSheet: SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), child: SizedBox( width: double.infinity, height: 52, child: ElevatedButton.icon( onPressed: _isSaving ? null : _save, icon: const Icon(Icons.edit_note), label: const Text('작성하기'), style: ElevatedButton.styleFrom( backgroundColor: Colors.amber, foregroundColor: Colors.black, ), ), ), ), ), body: Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 80), // 하단 80px 패딩 추가 ... ), )
Dart
복사
SafeArea를 감싸는 이유: iOS 홈 인디케이터 등 시스템 UI와 겹치지 않도록 자동으로 여백을 추가합니다.

7. 트러블슈팅

문제 1: 패키지 버전을 찾을 수 없음

# 잘못된 버전 지정 예 calendar_date_picker2: ^0.9.0 # ❌ 존재하지 않는 버전
Plain Text
복사
해결: flutter pub add 패키지명 사용 → pub.dev에서 최신 버전 자동 선택
flutter pub add calendar_date_picker2 # → ^2.0.1 자동 설치
Bash
복사

문제 2: MultiSelectContainer 레이아웃 충돌

오류 메시지
RenderFlex children have non-zero flex but incoming height constraints are unbounded.
Plain Text
복사
원인: MultiSelectContainer(내부에 Wrap 사용)를 SingleChildScrollView + Expanded 안에 넣으면 높이가 무한대로 계산됨
해결: 패키지 제거 후 Set<String> + ListView.builder + 체크 아이콘으로 직접 구현

문제 3: 기존 파일에 코드가 덧붙여짐

원인: 파일 편집 도구의 매칭 실패 후 파일 끝에 내용이 append되는 현상
해결: PowerShell Set-Content로 파일 전체를 덮어씌움
Set-Content -Path "경로\\파일.dart" -Value $content -Encoding UTF8
PowerShell
복사

문제 4: System Navigation Bar, BottomSheet 버튼 겹침

문제 발생
하단 버튼이 네비게이션 바 밑으로 들어감
원인 추정
SafeArea 문제인가?
BottomSheet 구조 문제인가?
padding 문제인가?
원인 발견
System Navigation Bar 높이를 고려 안 함
해결
MediaQuery.of(context).padding.bottom
Plain Text
복사
재발 방지
하단 고정 버튼 만들 때 항상 MediaQuery padding 사용
write_page.dart
detail_page.dart
... bottomSheet: SafeArea( child: Padding( // ⚡ System Navigation Bar [ ||| ㅁ < ] 위로 BottomSheet 버튼 겹치는 문제 해결 // padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), padding: EdgeInsets.fromLTRB(16, 8, 16,MediaQuery.of(context).padding.bottom + 12, ), ...
C
복사

MediaQuery란?

현재 기기의 화면 정보 가져오는 것
MediaQuery.of(context)
Dart
복사
가져올 수 있는 정보 예:
화면 크기
상태바 높이
키보드 높이
하단 네비게이션 바 높이
화면 방향 등

padding.bottom 의미

MediaQuery.of(context).padding.bottom
Dart
복사
시스템 UI 때문에 사용하면 안 되는 화면 아래쪽 영역 높이
즉,
안드로이드 네비게이션 바 높이
아이폰 홈 인디케이터 높이
제스처 네비게이션 영역
을 자동으로 알려줌.

예시로 보면

기기마다 값이 다름
기기
padding.bottom
안드로이드 버튼 있음
48
안드로이드 제스처
16
아이폰
34
PC / 웹
0
그래서 고정값 padding 주면 안 되고 MediaQuery 써야 함

실제 사용 예

하단 버튼 안 가리게

Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).padding.bottom ), child: ElevatedButton( onPressed: () {}, child: Text("저장"), ), )
Dart
복사
그러면 버튼이 네비게이션 바 위로 올라옴

padding / viewInsets 차이 (중요)

Flutter에서 이거 시험 문제급으로 중요함.
코드
의미
padding.bottom
네비게이션 바
viewInsets.bottom
키보드
viewPadding.bottom
SafeArea 포함 영역

키보드 올라왔을 때

MediaQuery.of(context).viewInsets.bottom
Dart
복사

네비게이션 바

MediaQuery.of(context).padding.bottom
Dart
복사

실무에서 제일 많이 쓰는 코드

하단 버튼 + 키보드 대응까지
padding: EdgeInsets.only( bottom: MediaQuery.of(context).padding.bottom + MediaQuery.of(context).viewInsets.bottom )
Dart
복사
이거 하나 기억하면 Flutter 하단 UI 문제 거의 다 해결됨.

한 줄 정리

MediaQuery.of(context).padding.bottom
Dart
복사
폰 화면 맨 아래 시스템 영역 높이 (네비게이션 바 높이)

8. 핵심 Flutter 개념 요약

개념
사용 위치
설명
StatefulWidget
모든 페이지
상태가 바뀌는 화면
setState()
모든 페이지
UI 재빌드 트리거
async/await
파일 I/O
비동기 처리
Navigator.push/pop
페이지 전환
스택 기반 라우팅
TextEditingController
입력 필드
텍스트 읽기/초기화
mounted 체크
비동기 후 setState
위젯이 트리에서 제거된 경우 방지
Timer
검색 디바운스
지연 실행
Set<T>
다중 선택
O(1) 중복 없는 경로 집합
Map<K, List<V>>
달력 이벤트
날짜별 일기 그룹핑
Platform.pathSeparator
경로 파싱
Windows(\\) / macOS·Linux(/) 자동 대응
SafeArea
bottomSheet
시스템 UI 겹침 방지
expands: true
TextField
남은 공간 전체 채움

9. 기능 리스트

기능
담당 파일
파일 저장/읽기/삭제
file_service.dart
하루 여러 일기
file_service.dart (HHmmss 파일명)
제목 + 본문 분리 저장
file_service.dart (---DIARY--- 구분자)
날짜 선택 (작성)
write_page.dart
날짜 선택 (수정)
detail_page.dart
날짜 변경 시 파일명 교체
file_service.dart changeDiaryDate()
Drawer 사이드 메뉴
home_page.dart
Dismissible 스와이프 삭제
home_page.dart
다중 선택 삭제
home_page.dart (Set<String>)
검색 (날짜·제목·본문)
home_page.dart + file_service.dart
검색 디바운스
home_page.dart (Timer)
달력 뷰 + 이벤트 마커
calendar_page.dart
달력 다중 일기 BottomSheet
calendar_page.dart
bottomSheet 작성하기 버튼
write_page.dart, detail_page.dart