하려던 CRUD 해보자 뭐 언제나 처럼 심플 게시판이다

import 'package:flutter/material.dart';
// 앱의 시작점 - 모든 Flutter 앱은 main() 함수부터 시작됩니다
void main() {
// MyApp 위젯을 실행하여 앱을 시작합니다
runApp(MyApp());
}
// MyApp 클래스 - 앱의 최상위 위젯 (StatelessWidget 상속)
// StatelessWidget: 상태가 변하지 않는 위젯 (한 번 그려지면 변하지 않음)
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
// MaterialApp: 구글의 Material Design을 사용하는 앱의 기본 구조
return MaterialApp(
title: '심플 게시판', // 앱의 제목 (태스크 매니저에서 보이는 이름)
theme: ThemeData(
// 앱의 전체적인 테마 설정
primarySwatch: Colors.blue, // 기본 색상을 파란색으로 설정
visualDensity: VisualDensity.adaptivePlatformDensity, // 플랫폼에 맞는 밀도 설정
),
home: BoardScreen(), // 앱이 시작될 때 보여줄 첫 번째 화면
);
}
}
// Post 클래스 - 게시글 데이터를 담는 모델 클래스
// 데이터를 구조화하여 관리하기 위한 클래스입니다
class Post {
final int id; // 게시글 고유 번호
final String title; // 게시글 제목
final String content; // 게시글 내용
final String author; // 작성자 이름
final DateTime createdAt; // 작성 날짜와 시간
// 생성자: Post 객체를 만들 때 필요한 모든 데이터를 받습니다
// required: 반드시 입력해야 하는 필수 매개변수
Post({
required this.id,
required this.title,
required this.content,
required this.author,
required this.createdAt,
});
}
// BoardScreen 클래스 - 게시판 메인 화면 (StatefulWidget 상속)
// StatefulWidget: 상태가 변할 수 있는 위젯 (데이터가 변하면 화면이 다시 그려짐)
class BoardScreen extends StatefulWidget {
@override
_BoardScreenState createState() => _BoardScreenState();
}
// BoardScreen의 상태를 관리하는 클래스
class _BoardScreenState extends State<BoardScreen> {
// 게시글 목록을 저장하는 리스트
// 실제 앱에서는 서버에서 데이터를 받아오지만, 여기서는 테스트용 데이터를 사용
List<Post> posts = [
Post(
id: 1,
title: '첫 번째 게시글',
content: '안녕하세요! 첫 번째 게시글입니다.',
author: '관리자',
createdAt: DateTime.now().subtract(Duration(days: 1)), // 1일 전
),
Post(
id: 2,
title: '두 번째 게시글',
content: '두 번째 게시글 내용입니다.',
author: '사용자1',
createdAt: DateTime.now().subtract(Duration(hours: 2)), // 2시간 전
),
];
@override
Widget build(BuildContext context) {
// Scaffold: 앱의 기본 구조를 제공하는 위젯 (AppBar, Body, FloatingActionButton 등)
return Scaffold(
// AppBar: 화면 상단의 제목 표시줄
appBar: AppBar(
title: Text('심플 게시판'),
backgroundColor: Colors.blue,
),
// body: 화면의 주요 내용이 들어가는 부분
body: ListView.builder(
// ListView.builder: 리스트 형태로 데이터를 표시하는 위젯
// 많은 데이터가 있어도 메모리를 효율적으로 사용 (화면에 보이는 부분만 렌더링)
itemCount: posts.length, // 표시할 아이템의 개수
itemBuilder: (context, index) {
// 각 아이템을 어떻게 그릴지 정의하는 함수
// index: 현재 그리고 있는 아이템의 순서 (0부터 시작)
final post = posts[index]; // 현재 인덱스에 해당하는 게시글 데이터
// Card: 카드 형태의 UI 컴포넌트 (그림자와 모서리가 둥근 사각형)
return Card(
margin: EdgeInsets.all(8.0), // 카드 주변의 여백
child: ListTile(
// ListTile: 리스트 아이템을 쉽게 만들 수 있는 위젯
title: Text(
post.title,
style: TextStyle(
fontWeight: FontWeight.bold, // 굵은 글씨
fontSize: 16,
),
),
subtitle: Column(
// Column: 세로 방향으로 위젯들을 배열
crossAxisAlignment: CrossAxisAlignment.start, // 왼쪽 정렬
children: [
SizedBox(height: 4), // 4픽셀 높이의 빈 공간
Text(
post.content,
maxLines: 2, // 최대 2줄까지만 표시
overflow: TextOverflow.ellipsis, // 넘치는 텍스트는 ...으로 표시
),
SizedBox(height: 4),
Row(
// Row: 가로 방향으로 위젯들을 배열
mainAxisAlignment: MainAxisAlignment.spaceBetween, // 양쪽 끝에 배치
children: [
Text(
'작성자: ${post.author}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600], // 회색 글씨
),
),
Text(
_formatDate(post.createdAt), // 날짜 포맷팅 함수 호출
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
],
),
onTap: () {
// 게시글을 탭했을 때 실행되는 함수
// Navigator.push: 새로운 화면으로 이동
Navigator.push(
context,
MaterialPageRoute(
// MaterialPageRoute: 페이지 전환 애니메이션을 제공하는 라우트
builder: (context) => PostDetailScreen(post: post),
),
);
},
),
);
},
),
// FloatingActionButton: 화면 오른쪽 하단에 떠있는 원형 버튼
floatingActionButton: FloatingActionButton(
onPressed: () {
// 버튼을 눌렀을 때 실행되는 함수
// 게시글 작성 화면으로 이동
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => WritePostScreen(
// onPostCreated: 게시글이 작성되었을 때 실행될 콜백 함수
onPostCreated: (title, content, author) {
// setState: 상태가 변경되었음을 Flutter에게 알려주는 함수
// 이 함수를 호출하면 화면이 다시 그려집니다
setState(() {
// 새 게시글을 리스트의 맨 앞에 추가
posts.insert(
0, // 인덱스 0 (맨 앞)에 삽입
Post(
id: posts.length + 1, // 새로운 ID 생성
title: title,
content: content,
author: author,
createdAt: DateTime.now(), // 현재 시간
),
);
});
},
),
),
);
},
child: Icon(Icons.add), // + 아이콘
backgroundColor: Colors.blue,
),
);
}
// 날짜를 "년-월-일 시:분" 형태로 포맷팅하는 함수
// private 함수 (클래스 내부에서만 사용, 함수명 앞에 _가 붙음)
String _formatDate(DateTime date) {
// padLeft: 문자열을 지정된 길이로 맞추고 왼쪽을 특정 문자로 채움
// 예: 5 -> "05" (2자리로 맞추고 왼쪽을 0으로 채움)
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')} ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
}
}
// PostDetailScreen 클래스 - 게시글 상세 보기 화면
// 게시글 하나의 전체 내용을 보여주는 화면입니다
class PostDetailScreen extends StatelessWidget {
final Post post; // 표시할 게시글 데이터
// 생성자: 게시글 데이터를 받아서 저장
PostDetailScreen({required this.post});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('게시글 상세'),
backgroundColor: Colors.blue,
// 뒤로가기 버튼은 자동으로 추가됩니다
),
body: Padding(
// Padding: 내부 여백을 주는 위젯
padding: EdgeInsets.all(16.0), // 모든 면에 16픽셀 여백
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, // 왼쪽 정렬
children: [
// 게시글 제목
Text(
post.title,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 8),
// 작성자와 작성일시를 가로로 배열
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'작성자: ${post.author}',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
Text(
_formatDate(post.createdAt),
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
Divider(height: 32), // 구분선 (위아래 여백 32픽셀)
// Expanded: 남은 공간을 모두 차지하도록 하는 위젯
Expanded(
child: SingleChildScrollView(
// SingleChildScrollView: 내용이 화면보다 클 때 스크롤 가능하게 만드는 위젯
child: Text(
post.content,
style: TextStyle(fontSize: 16),
),
),
),
],
),
),
);
}
// 날짜 포맷팅 함수 (BoardScreen과 동일)
String _formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')} ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
}
}
// WritePostScreen 클래스 - 게시글 작성 화면
// 사용자가 새로운 게시글을 작성할 수 있는 화면입니다
class WritePostScreen extends StatefulWidget {
// 게시글이 작성되었을 때 실행할 콜백 함수
// Function(String, String, String): 3개의 String 매개변수를 받는 함수 타입
final Function(String, String, String) onPostCreated;
WritePostScreen({required this.onPostCreated});
@override
_WritePostScreenState createState() => _WritePostScreenState();
}
class _WritePostScreenState extends State<WritePostScreen> {
// TextEditingController: 텍스트 입력 필드의 값을 제어하는 컨트롤러
// 텍스트 입력, 수정, 삭제 등을 프로그래밍적으로 제어할 수 있습니다
final _titleController = TextEditingController(); // 제목 입력 필드 컨트롤러
final _contentController = TextEditingController(); // 내용 입력 필드 컨트롤러
final _authorController = TextEditingController(); // 작성자 입력 필드 컨트롤러
// GlobalKey: 위젯을 식별하기 위한 고유한 키
// Form의 유효성 검사 상태를 관리하기 위해 사용
final _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('게시글 작성'),
backgroundColor: Colors.blue,
actions: [
// AppBar 오른쪽에 버튼을 추가
IconButton(
icon: Icon(Icons.save), // 저장 아이콘
onPressed: _savePost, // 저장 함수 실행
),
],
),
body: Form(
// Form: 여러 입력 필드를 그룹화하고 유효성 검사를 수행하는 위젯
key: _formKey, // Form을 식별하기 위한 키
child: Padding(
padding: EdgeInsets.all(16.0),
child: Column(
children: [
// 제목 입력 필드
TextFormField(
controller: _titleController,
decoration: InputDecoration(
labelText: '제목', // 필드 위에 표시되는 라벨
border: OutlineInputBorder(), // 테두리 스타일
),
validator: (value) {
// 유효성 검사 함수
// null 또는 빈 문자열이면 에러 메시지 반환, 유효하면 null 반환
if (value == null || value.isEmpty) {
return '제목을 입력해주세요';
}
return null; // 유효한 경우
},
),
SizedBox(height: 16), // 16픽셀 간격
// 작성자 입력 필드
TextFormField(
controller: _authorController,
decoration: InputDecoration(
labelText: '작성자',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '작성자를 입력해주세요';
}
return null;
},
),
SizedBox(height: 16),
// 내용 입력 필드 (여러 줄 입력 가능)
Expanded(
child: TextFormField(
controller: _contentController,
decoration: InputDecoration(
labelText: '내용',
border: OutlineInputBorder(),
alignLabelWithHint: true, // 라벨을 입력 필드 상단에 정렬
),
maxLines: null, // 무제한 줄 입력 허용
expands: true, // 사용 가능한 공간을 모두 차지
textAlignVertical: TextAlignVertical.top, // 텍스트를 상단에 정렬
validator: (value) {
if (value == null || value.isEmpty) {
return '내용을 입력해주세요';
}
return null;
},
),
),
SizedBox(height: 16),
// 게시글 등록 버튼
ElevatedButton(
onPressed: _savePost,
child: Text('게시글 등록'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
minimumSize: Size(double.infinity, 48), // 버튼을 화면 전체 너비로 설정, 높이 48
),
),
],
),
),
),
);
}
// 게시글 저장 함수
void _savePost() {
// 폼의 유효성 검사 수행
if (_formKey.currentState!.validate()) {
// 모든 필드가 유효하면 콜백 함수 호출
widget.onPostCreated(
_titleController.text, // 제목
_contentController.text, // 내용
_authorController.text, // 작성자
);
// 현재 화면을 닫고 이전 화면으로 돌아가기
Navigator.pop(context);
// 사용자에게 성공 메시지 표시
// SnackBar: 화면 하단에 잠시 나타나는 메시지
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('게시글이 등록되었습니다')),
);
}
}
@override
void dispose() {
// 위젯이 제거될 때 실행되는 함수
// 메모리 누수를 방지하기 위해 컨트롤러들을 해제
_titleController.dispose();
_contentController.dispose();
_authorController.dispose();
super.dispose();
}
}
Android 에뮬레이터에서 실행
bash
flutter run
웹에서 실행
bash
flutter run -d chrome
빌드
bash
# Android APK 빌드
flutter build apk
# 웹 빌드
flutter build web

cf. 혹시 몰라 남기는 소스상 간단 개념
StatelessWidget: 한 번 그려지면 변하지 않는 위젯 (예: 텍스트, 아이콘)
StatefulWidget: 상태가 변할 수 있는 위젯 (예: 버튼 클릭, 텍스트 입력)
Column: 세로 방향 배열
Row: 가로 방향 배열
Container: 박스 모델 (여백, 테두리, 배경색 등)
Padding: 내부 여백
Expanded: 남은 공간을 모두 차지
setState(): 상태가 변경되었을 때 화면을 다시 그리도록 알려주는 함수
Controller: 텍스트 입력 등을 제어하는 객체
Navigator.push(): 새 화면으로 이동
Navigator.pop(): 이전 화면으로 돌아가기