flutter...-2

이우철·2025년 7월 12일

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

  1. 프로젝트 생성
    flutter create flt_board
    cd flt_board

  1. main.dart
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();
  }
}
  1. 실행
Android 에뮬레이터에서 실행
bash
flutter run

웹에서 실행
bash
flutter run -d chrome

빌드
bash
# Android APK 빌드
flutter build apk

# 웹 빌드
flutter build web
  1. result

cf. 혹시 몰라 남기는 소스상 간단 개념

  1. Widget의 종류

StatelessWidget: 한 번 그려지면 변하지 않는 위젯 (예: 텍스트, 아이콘)
StatefulWidget: 상태가 변할 수 있는 위젯 (예: 버튼 클릭, 텍스트 입력)

  1. 주요 Layout 위젯

Column: 세로 방향 배열
Row: 가로 방향 배열
Container: 박스 모델 (여백, 테두리, 배경색 등)
Padding: 내부 여백
Expanded: 남은 공간을 모두 차지

  1. 상태 관리

setState(): 상태가 변경되었을 때 화면을 다시 그리도록 알려주는 함수
Controller: 텍스트 입력 등을 제어하는 객체

  1. 네비게이션

Navigator.push(): 새 화면으로 이동
Navigator.pop(): 이전 화면으로 돌아가기

  1. 기타
    flutter doctor: 개발 환경 상태 확인
profile
개발 정리 공간 - 업무일때도 있고, 공부일때도 있고...

0개의 댓글