이번 포스팅에서는 게시글 목록 화면과 상세보기 화면을 만들면서 자동 갱신 기능을 구현해보겠습니다.
📌 게시글 목록 (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. 정리
✔ 게시글 목록
✔ 무한 스크롤 & 새로고침
✔ 게시글 상세보기
✔ 게시글 삭제 & 자동 목록 갱신
✔ 게시글 작성 후 자동 목록 갱신
이제 게시글이 추가/삭제될 때 목록이 자동으로 업데이트됩니다.