Flutter 다이어리 앱 만들기
Flutter로 파일 기반 일기장 앱을 처음부터 만드는 과정을 단계별로 개발해보아요.
목차
1.
3.
5.
7.
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
복사
폴더 구조 생성
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개 제한 여부 | |
파일명 형식 | 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'): 한 자리 숫자를 두 자리로 패딩 (9 → 09)
기존 일기 수정 — 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
복사
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 (사이드 메뉴)
Scaffold의 drawer 속성에 등록하면 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 | confirmDismiss가 true 반환 후 | 없음 | 실제 삭제 로직 실행 |
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 |















