본문 바로가기
Flutter + Dart/다이어리 프로젝트

다이어리 위젯 - Diary Screen

by GREEN나무 2025. 5. 19.
728x90

 

📘 Diary Screen 기능별 학습 정리


✅ 1. 전체 개요

diary_screen.dart는 일기 조회 및 작성 기능을 가진 Flutter 화면 구성 파일이다. 이 화면에서는 사용자의 일기 목록을 보여주고, 추가, 상세보기 등의 동작이 포함된다. (추후 삭제, 수정 추가예정)

주요 구성 요소:

  • StatefulWidget 기반 UI
  • FutureBuilder를 활용한 비동기 데이터 처리
  • Dismissible을 이용한 스와이프 삭제
  • 네비게이션을 통한 상세 페이지 이동

🧱 2. 위젯 구조 및 상태관리

핵심 위젯: DiaryScreen

class DiaryScreen extends StatefulWidget {
  @override
  _DiaryScreenState createState() => _DiaryScreenState();
}
  • StatefulWidget을 사용한 이유: 비동기 데이터를 불러오고 상태 변경에 따라 화면을 갱신하기 위함.
  • 내부 상태에서 다이어리 목록 데이터를 유지하고, 삭제 등 상태 변화에 대응.

🔄 3. 비동기 데이터 로딩 (FutureBuilder)

FutureBuilder<List<Diary>>(
  future: _diaryListFuture,
  builder: (context, snapshot) { ... }
)
  • FutureBuilder는 비동기 작업의 결과를 기반으로 UI를 렌더링.
  • 상태별 처리:
    • ConnectionState.waiting: 로딩 중 UI 표시
    • hasError: 에러 처리
    • hasData: 실제 데이터 렌더링

💡 Tip: Future를 미리 상태에 저장(_diaryListFuture)함으로써 setState를 호출해도 불필요한 재요청을 방지할 수 있음.


🗑️ 4. 스와이프 삭제 (Dismissible)

Dismissible(
  key: Key(diary.id.toString()),
  direction: DismissDirection.endToStart,
  onDismissed: (direction) {
    setState(() {
      // 리스트에서 제거
    });
    // DB 또는 서버에서 삭제 처리
  },
  background: Container(
    color: Colors.red,
    child: Icon(Icons.delete, color: Colors.white),
  ),
  child: ListTile(...),
)
  • Dismissible을 사용하여 오른쪽 → 왼쪽 스와이프 시 삭제.
  • 삭제 UI 피드백 제공 (background).
  • 삭제 후 setState로 UI 갱신.

📆 5. 일기 리스트 UI (ListView.builder)

ListView.builder(
  itemCount: diaryList.length,
  itemBuilder: (context, index) {
    final diary = diaryList[index];
    return Dismissible(...); // 위에서 설명한 삭제 기능 포함
  },
)
  • 리스트를 효율적으로 렌더링하는 ListView.builder.
  • 각 아이템에 대해 Dismissible로 감싸 일기 항목 UI 구성.

➕ 6. 일기 추가 버튼 (FloatingActionButton)

floatingActionButton: FloatingActionButton(
  onPressed: () {
    Navigator.push(...); // 작성 화면으로 이동
  },
  child: Icon(Icons.add),
)
  • FloatingActionButton을 이용해 일기 추가 페이지로 이동.
  • 작성 후 돌아올 때 setState() 또는 Navigator.pop(context, result)를 활용하면 화면 갱신 가능.

📄 7. 상세 페이지 이동

onTap: () {
  Navigator.push(
    context,
    MaterialPageRoute(
      builder: (context) => DiaryDetailScreen(diary: diary),
    ),
  );
}
  • 일기 항목을 탭하면 상세보기 화면으로 이동.
  • DiaryDetailScreen은 해당 다이어리 데이터를 받아 상세 UI를 표시.

✍️일기 화면 전체 코드

더보기
import 'package:flutter/material.dart';
import 'package:table_calendar/table_calendar.dart';
import 'package:intl/intl.dart' show DateFormat;
import '../widgets/profile_menu_widget.dart';

// 더미데이터
// 전역에 선언된 일기 리스트 (임시 저장소)
List<Map<String, dynamic>> diary_item = [
  {
    'id': 0,
    'title': '일기 제목 1',
    'content': '오늘 하루는 정말 즐거웠어요!',
    'date': DateTime(2025, 5, 1),
    'emoji': '😊',
    'weather': '☀️',
    'isLocked': false,
    'pw': '',
  },
  {
    'id': 1,
    'title': '일기 제목 2',
    'content': '비가 내려서 우울했어요.',
    'date': DateTime(2025, 5, 2),
    'emoji': '☔️',
    'weather': '🌧️',
    'isLocked': false,
    'pw': '',
  },
  {
    'id': 2,
    'title': '일기 제목 3',
    'content': '오늘은 시험이 있었어요.',
    'date': DateTime(2025, 5, 3),
    'emoji': '📚',
    'weather': '☁️',
    'isLocked': true,
    'pw': '1234',
  },
  {
    'id': 3,
    'title': '일기 제목 4',
    'content': '친구와 맛집에 갔어요!',
    'date': DateTime(2025, 5, 4),
    'emoji': '🍔',
    'weather': '☀️',
    'isLocked': false,
    'pw': '',
  },
  {
    'id': 4,
    'title': '일기 제목 5',
    'content': '꽃이 피는 계절이 왔어요.',
    'date': DateTime(2025, 5, 5),
    'emoji': '🌸',
    'weather': '🌸',
    'isLocked': false,
    'pw': '',
  },
  {
    'id': 5,
    'title': '일기 제목 6',
    'content': '오늘은 긴 하루였어요.',
    'date': DateTime(2025, 5, 6),
    'emoji': '😅',
    'weather': '🌥️',
    'isLocked': false,
    'pw': '',
  },
  {
    'id': 6,
    'title': '일기 제목 7',
    'content': '운동하고 와서 기분 좋아요.',
    'date': DateTime(2025, 5, 7),
    'emoji': '💪',
    'weather': '☀️',
    'isLocked': false,
    'pw': '',
  },
  {
    'id': 7,
    'title': '일기 제목 8',
    'content': '저녁에 별이 정말 아름다웠어요.',
    'date': DateTime(2025, 5, 8),
    'emoji': '🌟',
    'weather': '☀️',
    'isLocked': false,
    'pw': '',
  },
  {
    'id': 8,
    'title': '일기 제목 9',
    'content': '집에서 편히 쉬었어요.',
    'date': DateTime(2025, 5, 9),
    'emoji': '🛋️',
    'weather': '구름 많음',
    'isLocked': false,
    'pw': '',
  },
  {
    'id': 9,
    'title': '일기 제목 10',
    'content':
        '오늘은 특별한 일이 없었어요.\n\n\n\n\n\n\n\nnnnnnn\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nnn',
    'date': DateTime(2025, 5, 10),
    'emoji': '😴',
    'weather': '🌧️',
    'isLocked': false,
    'pw': '',
  },
];
/**    // 리스트에 추가
    diary_item.add({
      'id': id,
      'title': title,
      'content': content,
      'date': selectedDate,
      'emoji': mainEmoji,
      'weather': weatherEmoji,
      'isLocked': isLocked,
      'pw': pwController.text,
    });
     */

/// 1. 일기 목록 화면
class DiaryListScreen extends StatefulWidget {
  @override
  _DiaryListScreen createState() => _DiaryListScreen();
}

class _DiaryListScreen extends State<DiaryListScreen> {
  // 검색창
  final TextEditingController searchController = TextEditingController();

  static const int pageSize = 20;
  int currentPage = 1;

  List<Map<String, dynamic>> get currentItems {
    final start = (currentPage - 1) * pageSize;
    final end = (start + pageSize) > diary_item.length
        ? diary_item.length
        : start + pageSize;
    return diary_item.sublist(start, end);
  }

  int get totalPages => (diary_item.length / pageSize).ceil();

  List<int> getPageNumbers() {
    int startPage = currentPage - 2;
    int endPage = currentPage + 2;

    if (startPage < 1) {
      endPage += (1 - startPage);
      startPage = 1;
    }

    if (endPage > totalPages) {
      startPage -= (endPage - totalPages);
      endPage = totalPages;
    }

    if (startPage < 1) startPage = 1;

    List<int> pages = [];
    for (int i = startPage; i <= endPage; i++) {
      pages.add(i);
    }
    return pages;
  }

  void goToPage(int page) {
    if (page >= 1 && page <= totalPages) {
      setState(() {
        currentPage = page;
      });
    }
  }

  late ScrollController listScrollController;

  @override
  void initState() {
    super.initState();
    listScrollController = ScrollController();
  }

  @override
  void dispose() {
    listScrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        leading: Padding(
          padding: const EdgeInsets.only(left: 10.0),
          child: IconButton(
            icon: const Icon(Icons.home),
            onPressed: () => Navigator.pushNamed(context, '/home'),
          ),
        ),
        title: const Text('ઇଓ FLY 다이어리'),
        centerTitle: true,
        elevation: 0,
        actions: [
          // 여기서 프로필 이미지 클릭 메뉴 위젯 호출
          ProfileMenuWidget(
            profileImageUrl: 'https://path-to-your-profile-image.jpg',
          ),
        ],
      ),
      body: NestedScrollView(
        headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
          return [
            SliverToBoxAdapter(
              child: Column(
                children: [
                  Container(
                    // EdgeInsets.fromLTRB(left, top, right, bottom)
                    padding: const EdgeInsets.fromLTRB(5, 2, 5, 2),
                    child: TableCalendar(
                      focusedDay: DateTime.now(),
                      firstDay: DateTime(2020, 1, 1),
                      lastDay: DateTime(2030, 12, 31),
                    ),
                  ),
                  Padding(
                    padding: const EdgeInsets.fromLTRB(
                        8, 10, 8, 0), // EdgeInsets.symmetric(horizontal: 8.0),
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: [
                        DropdownButton<String>(
                          value: '최신순',
                          items: ['최신순', '오래된순', '제목순']
                              .map((e) =>
                                  DropdownMenuItem(value: e, child: Text(e)))
                              .toList(),
                          onChanged: (val) {},
                        ),
                        SizedBox(
                          width: 50,
                        ),
                        Expanded(
                          child: Padding(
                            padding:
                                const EdgeInsets.symmetric(horizontal: 8.0),
                            child: TextField(
                              controller: searchController,
                              decoration: const InputDecoration(
                                labelText: '검색 🔍',
                                border: OutlineInputBorder(),
                              ),
                            ),
                          ),
                        ),
                        ElevatedButton(
                          onPressed: () {
                            // 검색 로직
                            print(searchController.text);
                          },
                          child: Icon(Icons.search),
                        ),
                      ],
                    ),
                  ),
                ],
              ),
            ),
          ];
        },
        // 본문의 리스트와 페이징
        body: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 20),
          child: Column(
            children: [
              // 리스트 보여주는 부분 (스크롤 가능)
              Expanded(
                child: ListView.builder(
                  controller: listScrollController,
                  itemCount: currentItems.length,
                  itemBuilder: (context, index) {
                    /* return ListTile(
                      title: Text(currentItems[index]),
                    );*/
                    final diary = currentItems[index]; // 현재 보이는 목록
                    return ListTile(
                      title: Text(
                          '${diary['emoji']}  ${diary['title']}'), // 저장된 제목 사용
                      subtitle: Text(
                        (() {
                          final lines = diary['content']
                              .toString()
                              .split('\n'); // 줄바꿈 단위로 자른 배열
                          final firstLine = lines.first; // 첫째줄
                          return firstLine.length > 30
                              ? '${firstLine.substring(0, 30)}...'
                              : firstLine;
                        })(),
                      ),
                      // 내용 일부만 미리보기  첫줄 or 앞에 30자만
                      onTap: () {
                        Navigator.push(
                          context,
                          MaterialPageRoute(
                            builder: (context) =>
                                DiaryDetailScreen(diary: diary), // diary 자체 전달
                          ),
                        );
                      },
                    );
                  },
                ),
              ),
              //페이징 버튼
              Container(
                padding: EdgeInsets.symmetric(vertical: 8),
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    // 이전 페이지
                    IconButton(
                      icon: Icon(Icons.chevron_left),
                      onPressed: currentPage > 1
                          ? () => goToPage(currentPage - 1)
                          : null,
                    ),
                    // 페이지 번호 버튼들
                    ...getPageNumbers().map((page) => GestureDetector(
                          onTap: () => goToPage(page),
                          child: Container(
                            margin: EdgeInsets.symmetric(horizontal: 4),
                            padding: EdgeInsets.all(8),
                            decoration: BoxDecoration(
                              color: page == currentPage
                                  ? Colors.blue
                                  : Colors.grey[200],
                              borderRadius: BorderRadius.circular(4),
                            ),
                            child: Text(
                              '$page',
                              style: TextStyle(
                                color: page == currentPage
                                    ? Colors.white
                                    : Colors.black,
                              ),
                            ),
                          ),
                        )),
                    // 다음 페이지
                    IconButton(
                      icon: Icon(Icons.chevron_right),
                      onPressed: currentPage < totalPages
                          ? () => goToPage(currentPage + 1)
                          : null,
                    ),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
      // 플로팅 추가 버튼
      floatingActionButton: FloatingActionButton(
        onPressed: () async {
          final result = await Navigator.push(
            context,
            MaterialPageRoute(
              builder: (context) => const WriteDiaryScreen(),
            ),
          );

          if (result == true) {
            // 새 일기가 추가되었음을 감지하고 화면 갱신
            (context as Element).markNeedsBuild(); // 또는 상태관리 적용
          }
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}

/// 2. 일기 작성 화면 (입력받고 저장 시 콘솔에 출력)
class WriteDiaryScreen extends StatefulWidget {
  const WriteDiaryScreen({Key? key}) : super(key: key);

  @override
  State<WriteDiaryScreen> createState() => _WriteDiaryScreenState();
}

class _WriteDiaryScreenState extends State<WriteDiaryScreen> {
  // 제목창
  final TextEditingController titleController = TextEditingController();
  // 대표 이모지, 날씨 이모지 < 버튼으로
  String mainEmoji = '🪽';
  String weatherEmoji = '☀️'; // 일기예보 연결하고 현재날씨로 바꾸기
  // 잠금 여부 - pw + 토글
  bool isLocked = false;
  final TextEditingController pwController = TextEditingController();

  // 기록일자
  //final TextEditingController searchController = TextEditingController();
  late DateTime selectedDate = DateTime.now();

  // 배경
  final TextEditingController bgController = TextEditingController();

  // 내용
  final TextEditingController contentController = TextEditingController();

  // 저장 버튼 클릭 시 호출되는 함수
  void _saveDiary() {
    final title = titleController.text;
    final content = contentController.text;

    // 아이디 부여: 리스트가 비어있으면 0, 아니면 마지막 id + 1
    final int id = diary_item.isEmpty ? 0 : diary_item.last['id'] + 1;

    // 리스트에 추가
    diary_item.add({
      'id': id,
      'title': title,
      'content': content,
      'date': selectedDate,
      'emoji': mainEmoji,
      'weather': weatherEmoji,
      'isLocked': isLocked,
      'pw': pwController.text,
    });

    print('제목: $title');
    print('내용: $content');

    // 작성 화면 종료
    Navigator.pop(context, true);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        leading: Padding(
          padding: const EdgeInsets.only(left: 10.0),
          child: IconButton(
            icon: const Icon(Icons.arrow_back),
            // onPressed: () => Navigator.pop(context, true),
            onPressed: () => Navigator.pushNamed(context, '/diary'),
          ),
        ),
        title: const Text('ઇଓ 일기'),
        centerTitle: true,
        elevation: 0,
      ),
      body: NestedScrollView(
        headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
          return [
            SliverToBoxAdapter(
              child: Column(
                children: [
                  /** 작성 항목
             * 제목
             * 잠금 여부
             * 대표 이모지, 날씨 이모지 (기본값:🪽, 현재 날씨)
             * 기록일자 ( 클릭하면 달력 띄우기, 기본값 오늘 )
             * 배경 이미지 ( 이미지3개 이상 중에 선택)
             * 내용 ( 중간에 이미지, 링크, 줄띄우기, 폰트 변경, 글자크기, 굵게쓰기, 기울이기, 밑줄, 취소선 가능하게)
             */

                  // 제목 입력 필드
                  TextField(
                    controller: titleController, // 제목 컨트롤러 연결
                    decoration: const InputDecoration(
                      labelText: '제목',
                      border: OutlineInputBorder(),
                    ),
                  ),
                  const SizedBox(height: 5),

                  // 잠금 설정 필드
                  // TextField(
                  //   controller: pwController, // 잠금 설정 컨트롤러 연결
                  //   decoration: const InputDecoration(
                  //     labelText: '잠금 설정, 비밀번호 설정',
                  //     border: OutlineInputBorder(),
                  //   ),
                  // ),
                  // const SizedBox(height: 5),
                  Row(
                    children: [
                      Text('대표 이모지'),
                      DropdownButton<String>(
                        value: mainEmoji,
                        items: ['🪽', '😊', '😴', '🔥']
                            .map((e) =>
                                DropdownMenuItem(value: e, child: Text(e)))
                            .toList(),
                        onChanged: (val) => setState(() => mainEmoji = val!),
                      ),
                      Text('날씨'),
                      DropdownButton<String>(
                        value: weatherEmoji,
                        items: ['☀️', '🌧️', '⛅', '🌩️']
                            .map((e) =>
                                DropdownMenuItem(value: e, child: Text(e)))
                            .toList(),
                        onChanged: (val) => setState(() => weatherEmoji = val!),
                      ),
                      Text('잠금 여부'),
                      Switch(
                        value: isLocked,
                        onChanged: (val) {
                          setState(() {
                            isLocked = val;
                          });
                        },
                      ),
                      if (isLocked)
                        Expanded(
                          child: TextField(
                            controller: pwController,
                            obscureText: true,
                            decoration: InputDecoration(labelText: '비밀번호'),
                          ),
                        ),
                    ],
                  ),
                  const SizedBox(height: 5),
                  ListTile(
                    title: Text(
                        '기록일자: ${DateFormat('yyyy-MM-dd').format(selectedDate)}'),
                    trailing: Icon(Icons.calendar_today),
                    onTap: () async {
                      final picked = await showDatePicker(
                        context: context,
                        initialDate: selectedDate,
                        firstDate: DateTime(2000),
                        lastDate: DateTime(2100),
                      );
                      if (picked != null) {
                        setState(() => selectedDate = picked);
                      }
                    },
                  ),

                  const SizedBox(height: 5),

                  // 배경 선택 필드
                  TextField(
                    controller: bgController,
                    decoration: InputDecoration(
                      labelText: '배경',
                      border: OutlineInputBorder(),
                    ),
                  ),
                  const SizedBox(height: 5),
                  TextField(
                    controller: contentController,
                    maxLines: 10,
                    decoration: const InputDecoration(
                      labelText: '내용',
                      border: OutlineInputBorder(),
                    ),
                  ),
                  const SizedBox(height: 20),

                  ElevatedButton(
                    onPressed: _saveDiary,
                    child: const Text('저장'),
                  ),
                ],
              ),
            ),
          ];
        },
        body: SizedBox.shrink(),
      ),
    );
  }
}

////////////////////////////////////////////////////////////////
/// 3. 일기 상세 보기 화면 (선택한 일기의 제목과 내용 표시)
class DiaryDetailScreen extends StatelessWidget {
  final Map<String, dynamic> diary;

  const DiaryDetailScreen({Key? key, required this.diary}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final String formattedDate = DateFormat('yyyy-MM-dd').format(diary['date']);

    return Scaffold(
      appBar: AppBar(
        title: Text(
          '${diary['emoji'] ?? ''}  ${diary['title'] ?? ''}',
          style: const TextStyle(fontSize: 24),
        ),
        actions: [
          // 여기서 프로필 이미지 클릭 메뉴 위젯 호출
          ProfileMenuWidget(
            profileImageUrl: 'https://path-to-your-profile-image.jpg',
          ),
        ],
      ),
      body: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // 날짜 + 날씨
          Padding(
            padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.end,
              children: [
                Text(
                  '$formattedDate ${diary['weather'] ?? ''}',
                  style: const TextStyle(fontSize: 18, color: Colors.grey),
                ),
              ],
            ),
          ),
          // 본문 내용 (스크롤 가능하게)
          Expanded(
            child: SingleChildScrollView(
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
              child: Text(
                diary['content'] ?? '',
                style: const TextStyle(fontSize: 18),
              ),
            ),
          ),
        ],
      ),
    );
  }
}