Flutter

Flutter Day 17: 블로그 만들기 - AutoDisposeNotifier 활용 게시글 목록, 상세보기 화면 구현

@leem 2025. 2. 13. 00:56

이번 포스팅에서는 게시글 목록 화면과 상세보기 화면을 만들면서 자동 갱신 기능을 구현해보겠습니다.


📌 게시글 목록 (PostListPage)
- 데이터 로드, 무한 스크롤 & 새로고침 기능, 새로운 게시글 추가 시 자동 갱신
📌 게시글 상세보기 (PostDetailPage)
- 게시글 데이터 표시, 삭제 & 수정 기능, 삭제 시 자동 갱신

기술 설명
Riverpod 상태 관리 NotifierProvider, AutoDisposeNotifier 사용
pull_to_refresh 패키지 무한 스크롤 & 새로고침 기능 구현
Navigator.push() 상세 페이지로 이동 (PostDetailPage)
ref.listen() 활용 게시글 추가/삭제 시 자동으로 목록을 갱신

 


1. 데이터 모델 정의

게시글과 관련된 데이터를 다루기 위해 User, Post, PostList 모델을 정의합니다.

1.1. 유저 모델 (User)

class User {
  int? id;
  String? username;
  String? imgUrl;

  User.fromMap(Map<String, dynamic> map)
      : id = map["id"],
        username = map["username"],
        imgUrl = map["imgUrl"];
}
  • User 모델은 JSON 데이터를 받아 객체로 변환 (fromMap).

1.2. 게시글 모델 (Post)

import 'package:intl/intl.dart';
import 'user.dart';

class Post {
  int? id;
  String? title;
  String? content;
  DateTime? createdAt;
  DateTime? updatedAt;
  int? bookmarkCount;
  bool? isBookmark;
  User? user;

  Post.fromMap(Map<String, dynamic> map)
      : id = map["id"],
        title = map["title"],
        content = map["content"],
        createdAt = DateFormat("yyyy-MM-dd").parse(map["createdAt"]),
        updatedAt = DateFormat("yyyy-MM-dd").parse(map["updatedAt"]),
        bookmarkCount = map["bookmarkCount"],
        isBookmark = map["isBookmark"],
        user = User.fromMap(map["user"]);
}
  • Post 모델은 fromMap을 사용하여 JSON 데이터를 객체로 변환.
  • DateFormat을 활용하여 날짜 데이터를 변환.

1.3. 게시글 목록 모델 (PostList)

import 'post.dart';

class PostList {
  bool isFirst;
  bool isLast;
  int pageNumber;
  int size;
  int totalPage;
  List<Post> posts;

  PostList({
    required this.isFirst,
    required this.isLast,
    required this.pageNumber,
    required this.size,
    required this.totalPage,
    required this.posts,
  });

  factory PostList.fromMap(Map<String, dynamic> map) {
    return PostList(
      isFirst: map['isFirst'] ?? false,
      isLast: map['isLast'] ?? false,
      pageNumber: map['pageNumber'] ?? 0,
      size: map['size'] ?? 10,
      totalPage: map['totalPage'] ?? 1,
      posts: (map['posts'] as List<dynamic>? ?? [])
          .map((e) => Post.fromMap(e))
          .toList(),
    );
  }
}
  • PostList 모델은 fromMap을 활용하여 JSON 데이터를 리스트로 변환.

2. 게시글 목록 화면 (PostListPage)

2.1. PostListPage 기본 구조

import 'package:flutter/material.dart';
import 'package:class_f_story/ui/pages/post/list_page/widgets/post_list_body.dart';
import 'package:class_f_story/ui/widgets/custom_drawer.dart';

class PostListPage extends StatelessWidget {
  final scaffoldKey = GlobalKey<ScaffoldState>();

  PostListPage({super.key});

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Scaffold(
        key: scaffoldKey,
        drawer: CustomDrawer(scaffoldKey),
        appBar: AppBar(title: Text('f-story')),
        body: PostListBody(),
      ),
    );
  }
}
  • Scaffold를 사용하여 기본 화면을 구성.
  • CustomDrawer 추가 (사이드바 메뉴).
  • PostListBody()에서 게시글 목록 표시.

2.2. 게시글 목록 뷰 (PostListBody)

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:class_f_story/data/_vm/post_list_view_model.dart';
import 'package:class_f_story/data/model/post_list.dart';
import 'package:class_f_story/ui/pages/post/detail_page/post_detail_page.dart';
import 'package:class_f_story/ui/pages/post/list_page/widgets/post_list_item.dart';

class PostListBody extends ConsumerStatefulWidget {
  const PostListBody({super.key});

  @override
  ConsumerState<PostListBody> createState() => _PostListBodyState();
}

class _PostListBodyState extends ConsumerState<PostListBody> {
  final RefreshController _refreshController = RefreshController();

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

  @override
  Widget build(BuildContext context) {
    PostList? model = ref.watch(postListProvider);
    PostListViewModel vm = ref.read(postListProvider.notifier);

    if (model == null) {
      return Center(child: CircularProgressIndicator());
    }

    return SmartRefresher(
      controller: _refreshController,
      enablePullUp: true,
      onRefresh: () async {
        await vm.init();
        _refreshController.refreshCompleted();
      },
      onLoading: () async {
        await vm.nextList();
        _refreshController.loadComplete();
      },
      child: ListView.separated(
        itemCount: model.posts.length,
        separatorBuilder: (context, index) => const Divider(),
        itemBuilder: (context, index) => InkWell(
          onTap: () {
            Navigator.push(
              context,
              MaterialPageRoute(
                builder: (context) => PostDetailPage(postId: model.posts[index].id!),
              ),
            );
          },
          child: PostListItem(model.posts[index]),
        ),
      ),
    );
  }
}
  • SmartRefresher를 사용하여 무한 스크롤 & 새로고침 기능 구현.
  • Navigator.push()를 사용하여 PostDetailPage로 이동.

3. 게시글 상세보기 (PostDetailPage)

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:class_f_story/data/_vm/post_detail_view_model.dart';

class PostDetailPage extends ConsumerWidget {
  final int postId;

  PostDetailPage({required this.postId, super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    PostDetail? postDetail = ref.watch(postDetailProvider(postId));

    if (postDetail == null) {
      return Center(child: CircularProgressIndicator());
    }

    final post = postDetail.post;

    return Scaffold(
      appBar: AppBar(title: Text(post.title ?? '게시글 상세')),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(post.content ?? "내용 없음"),
            ElevatedButton(
              onPressed: () => ref.read(postDetailProvider(postId).notifier).deleteById(postId),
              child: Text('삭제하기'),
            ),
          ],
        ),
      ),
    );
  }
}
  • ref.watch(postDetailProvider)를 사용하여 게시글 데이터 표시.
  • 삭제 버튼 클릭 시 deleteById() 실행.

4. 게시글 목록 자동 갱신 기능 추가

게시글이 추가, 수정, 삭제될 때 목록이 자동으로 갱신되도록 ref.listen()을 활용하여 이벤트를 감지합니다.


4.1. 게시글 이벤트 정의 (PostEventNotifier)

PostAction 이넘(enum)과 이벤트를 관리하는 Notifier를 정의합니다.

import 'package:flutter_riverpod/flutter_riverpod.dart';

enum PostAction {
  none,     // 아무런 이벤트 없음
  created,  // 게시글 생성됨
  updated,  // 게시글 수정됨
  deleted,  // 게시글 삭제됨
}

// 게시글 이벤트를 관리하는 Notifier
class PostEventNotifier extends Notifier<PostAction> {
  @override
  PostAction build() {
    return PostAction.none;  // 초기 상태
  }

  void postCreate() => state = PostAction.created;
  void postUpdated() => state = PostAction.updated;
  void postDeleted() => state = PostAction.deleted;

  // 이벤트 처리 후 상태 초기화 (중복 이벤트 방지)
  void reset() => state = PostAction.none;
}

// 이벤트 프로바이더 생성
final postEventProvider = NotifierProvider<PostEventNotifier, PostAction>(
  () => PostEventNotifier(),
);
  • PostAction은 게시글 상태 변화를 나타냄 (created, updated, deleted).
  • PostEventNotifier는 state를 변경하여 이벤트를 발생시킴.
  • reset()을 사용하여 이벤트 발생 후 상태를 none으로 초기화 (중복 이벤트 방지).

4.2. 게시글 생성 시 이벤트 발생

게시글을 작성하면 postEventProvider의 상태를 created로 변경하여 목록이 자동으로 갱신되도록 합니다.

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:class_f_story/data/repository/post_repository.dart';
import 'package:flutter/material.dart';
import 'package:class_f_story/main.dart';

class PostWriteViewModel
    extends Notifier<(String? title, String? content, bool isWriteCompleted)> {
  final mContext = navigatorkey.currentContext!;
  final PostRepository postRepository = const PostRepository();

  @override
  (String? title, String? content, bool isWriteCompleted) build() {
    return (null, null, false);
  }

  Future<void> createPost({required String title, required String content}) async {
    try {
      final body = {"title": title, "content": content};
      Map<String, dynamic> resBody = await postRepository.save(body);

      if (!resBody['success']) {
        throw Exception('게시글 등록 실패: ${resBody['errorMessage']}');
      }

      // 게시글 작성 완료 메시지
      ScaffoldMessenger.of(mContext)
          .showSnackBar(SnackBar(content: Text('게시글 등록 완료')));

      // 게시글 목록 자동 갱신을 위해 이벤트 발생
      ref.read(postEventProvider.notifier).postCreate();

      state = (null, null, true);
    } catch (e) {
      ScaffoldMessenger.of(mContext)
          .showSnackBar(SnackBar(content: Text('게시글 등록 실패: $e')));
    }
  }
}

// Provider 정의
final postWriteViewModelProvider = NotifierProvider<
    PostWriteViewModel,
    (String? title, String? content, bool isWriteCompleted)>(
  () => PostWriteViewModel(),
);
  • 게시글 작성 후 postEventProvider.notifier.postCreate()를 호출하여 이벤트 발생.
  • 게시글 목록을 구독하는 PostListViewModel이 해당 이벤트를 감지하고 자동으로 갱신.

4.3. 게시글 삭제 시 이벤트 발생

게시글이 삭제되면 postEventProvider의 상태를 deleted로 변경합니다.

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/material.dart';
import 'package:class_f_story/data/repository/post_repository.dart';
import 'package:class_f_story/main.dart';
import 'package:class_f_story/data/gvm/post_event_notifier.dart';

class PostDetailViewModel extends AutoDisposeFamilyNotifier<PostDetail?, int> {
  final mContext = navigatorkey.currentContext!;
  final PostRepository postRepository = const PostRepository();

  @override
  PostDetail? build(id) {
    ref.onDispose(() {
      debugPrint('PostDetailViewModel 파괴됨');
    });

    init(id);
    return null;
  }

  Future<void> init(int id) async {
    try {
      Map<String, dynamic> responseBody = await postRepository.findById(id: id);

      if (!responseBody['success']) {
        throw Exception('게시글 상세보기 실패');
      }

      state = PostDetail.fromMap(responseBody['response']);
    } catch (e) {
      ScaffoldMessenger.of(mContext)
          .showSnackBar(SnackBar(content: Text('게시글 상세보기 오류: $e')));
    }
  }

  // 게시글 삭제 기능
  Future<void> deleteById(int id) async {
    try {
      Map<String, dynamic> responseBody = await postRepository.delete(id: id);

      if (!responseBody['success']) {
        throw Exception('게시글 삭제 실패: ${responseBody['errorMessage']}');
      }

      // 삭제 이벤트 발생
      ref.read(postEventProvider.notifier).postDeleted();

      // 현재 페이지 종료
      Navigator.pop(mContext);
    } catch (e) {
      ScaffoldMessenger.of(mContext)
          .showSnackBar(SnackBar(content: Text('게시글 삭제 실패: $e')));
    }
  }
}

// Provider 정의
final postDetailProvider = NotifierProvider.autoDispose.family<PostDetailViewModel, PostDetail?, int>(
  () => PostDetailViewModel(),
);
  • deleteById()에서 postEventProvider.notifier.postDeleted() 호출.
  • 목록 페이지에서 ref.listen()을 사용하여 상태 변화를 감지.

4.4. 게시글 목록에서 ref.listen()으로 이벤트 감지

게시글 목록 페이지에서 게시글이 추가/삭제되었을 때 자동으로 목록을 새로고침하도록 설정합니다.

class PostListViewModel extends AutoDisposeNotifier<PostList?> {
  final refreshController = RefreshController();
  final PostRepository postRepository = const PostRepository();

  @override
  PostList? build() {
    ref.onDispose(() {
      refreshController.dispose();
    });

    // 초기 데이터 로딩
    init();

    // 이벤트 리스너 등록
    ref.listen<PostAction>(
      postEventProvider,
      (previous, next) {
        if (next != PostAction.none) {
          init(); // 목록 새로고침
          ref.read(postEventProvider.notifier).reset(); // 상태 초기화
        }
      },
    );

    return null;
  }

  Future<void> init() async {
    try {
      Map<String, dynamic> resBody = await postRepository.findAll();

      if (!resBody['success']) {
        throw Exception('게시글 목록 로딩 실패');
      }

      state = PostList.fromMap(resBody['response']);
      refreshController.refreshCompleted();
    } catch (e) {
      debugPrint('게시글 목록 로딩 오류: $e');
    }
  }
}

// Provider 정의
final postListProvider = NotifierProvider.autoDispose<PostListViewModel, PostList?>(
  () => PostListViewModel(),
);
  • ref.listen()을 사용하여 postEventProvider 상태 변화를 감지.
  • PostAction.created 또는 PostAction.deleted 이벤트 발생 시 init()을 호출하여 목록을 자동으로 새로고침.

5. 정리

게시글 목록

무한 스크롤 & 새로고침

게시글 상세보기

게시글 삭제 & 자동 목록 갱신

게시글 작성 후 자동 목록 갱신

 

이제 게시글이 추가/삭제될 때 목록이 자동으로 업데이트됩니다.