사전 기본 개념
- Family Provider vs 일반 Provider
// 일반 Provider: 앱 전체에서 하나의 인스턴스만 사용
final userProvider = NotifierProvider<UserNotifier, User?>(() {
return UserNotifier();
});
// 사용: ref.watch(userProvider) - 항상 같은 사용자 정보
// Family Provider: 매개변수별로 다른 인스턴스 생성
final postDetailProvider = AutoDisposeNotifierProvider.family<
PostDetailNotifier, PostDetailModel?, int>(() {
return PostDetailNotifier();
});
// 사용: ref.watch(postDetailProvider(1)) - 게시글 1번
// ref.watch(postDetailProvider(2)) - 게시글 2번 (별도 인스턴스)
2. AutoDispose가 필요한 이유
// 문제 상황: AutoDispose 없는 경우
final postDetailProvider = NotifierProvider.family<...>(...);
// 게시글 1, 2, 3을 봤다면 3개 인스턴스가 메모리에 계속 남아있음
// 해결책: AutoDispose 사용
final postDetailProvider = AutoDisposeNotifierProvider.family<...>(...);
// 해당 화면을 벗어나면 자동으로 메모리에서 제거됨
3. ConsumerStatefulWidget 선택 이유
// ConsumerWidget: 상태 변화 감시만 필요한 경우
class SimpleWidget extends ConsumerWidget {
Widget build(context, ref) => ref.watch(provider);
}
// ConsumerStatefulWidget: 초기 데이터 로딩이 필요한 경우
class DetailWidget extends ConsumerStatefulWidget {
void initState() {
// 화면 진입시 한 번만 데이터 로딩
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(provider.notifier).loadData();
});
}
}
권한 관리 유틸 기능 추가
PermissionUtil
// 권한 관리 전용 유틸 클래스
import '../../data/models/post.dart';
import '../../data/models/user.dart';
class PermissionUtil {
// 권한 로직 설게
// 1. 로그인 여부 체크
// 2. 게시글 작성자와 현재 사용자 id 비교
// 3. 관리자 권한 체크(옵션)
static bool canEditPost(User? currentUser, Post post) {
// 1단계
if (currentUser == null) {
return false;
}
// 2단계
bool isOwner = currentUser.id == post.user.id;
// 3단계 (관리자 사용시)
//bool isAdmin = currentUser.role == 'ADMID';
return isOwner;
}
// 게시글 삭제 권한 확인
static bool canDeletePost(User? currentUser, Post post) {
// 삭제 권한에서 시간을 추가하고 싶다면 ???
// 즉, 24간 이후에는 삭제를 못한다. !!!
// DateTime createdAt = post.createdAt;
// Duration difference = DateTime.now().difference(createdAt);
// difference.inHours < 24;
return canEditPost(currentUser, post);
}
// String action = "수정, 삭제, 댓글 작성 등"
static String getNoPermissionMessage(String action) {
return "이 게시글을 ${action}할 권한이 없습니다";
}
}
상태 관리 클래스 설계
post_detail_notifier.dart
// 통신 관련해서 ----> UI 갱신 로직
// 통신 요청 ---> 로딩 -- 성공, 실패,
import 'package:flutter_blog/data/models/post.dart';
import 'package:flutter_blog/data/models/repository/post_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logger/logger.dart';
class PostDetailModel {
final Post post;
final bool isLoading;
final String? error;
PostDetailModel({
required this.post,
this.isLoading = false,
this.error,
});
// 서버에서 응답 데이터 구조
// id , title, content, user { id : 1, username ... }
/// json 받아서 파싱 하는 코드 작성 - 네임드 생성자 fromMap(Map<String,dynamic>)
PostDetailModel.fromMap(Map<String, dynamic> data)
: post = Post.fromMap(data),
isLoading = false,
error = null;
/// 불변 패턴 사용
PostDetailModel copyWith({
Post? post,
bool? isLoading,
String? error,
}) {
return PostDetailModel(
post: post ?? this.post,
isLoading: isLoading ?? this.isLoading,
error: error ?? this.error);
}
@override
String toString() {
return 'PostDetailModel{post: $post, isLoading: $isLoading, error: $error}';
}
} // end of postDetailModel
// 창고 메뉴얼 설계
class PostDetailNotifier
extends AutoDisposeFamilyNotifier<PostDetailModel?, int> {
@override
PostDetailModel? build(int postId) {
Logger().d("상세보기 화면 id 값 : ${postId}");
// 개발 디버깅용 코드
ref.onDispose(
() {
Logger().d("postDetailNotifier 파괴됨 !!!!! - postId : ${postId}");
},
);
// 초기 값을 null 선언하는 이유는 아직 통신을 통해서 데이터를 안 불러 온 상태
return null;
}
/// 게시글 불러 오기 기능 추가
Future<void> loadPostDetail(int postId) async {
//1단계 - 게시글 상세보기 요청
state = state?.copyWith(isLoading: true, error: null);
// 2단계
Map<String, dynamic> response = await PostRepository().getOne(postId);
// 3단계
if (response["success"]) {
state = PostDetailModel.fromMap(response["response"]);
} else {
state = state?.copyWith(error: response["errorMessage"]);
}
}
/// 게시글 삭제 하는 기능 추가
Future<void> deletePost(int postId) async {
// 예외 처리 생략 ....
// 1 단계
state = state?.copyWith(isLoading: true, error: null);
// 2 단계
Map<String, dynamic> response = await PostRepository().deleteOne(postId);
// 3단계
if (response["success"]) {
state?.copyWith(isLoading: false);
} else {
state?.copyWith(isLoading: false, error: response["errorMessage"]);
}
}
/// 게시글 수정은 화면 이동해서 처리
}
// 2.x
final postDetailProvider = AutoDisposeNotifierProvider.family<
PostDetailNotifier, PostDetailModel?, int>(() => PostDetailNotifier());
기존 위젯 코드 수정
cached_network_image: ^3.4.1 # 이미지 캐싱 기능 처리
PostDetailProfile
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_blog/_core/constants/size.dart';
import 'package:flutter_blog/_core/utils/my_http.dart';
import '../../../../../data/models/post.dart';
class PostDetailProfile extends StatelessWidget {
final Post post;
const PostDetailProfile(this.post, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(10.0),
child: ListTile(
title: Text(post.user.username ?? ""),
// child: Image.network('${baseUrl}${post.user.imgUrl}'),
//http://192.168.0.132:8080/images/1.png
leading: ClipOval(
child: CachedNetworkImage(
width: 50,
height: 50,
fit: BoxFit.cover,
imageUrl: "${baseUrl}${post.user.imgUrl}",
placeholder: (context, url) => CircularProgressIndicator(),
errorWidget: (context, url, error) => Icon(Icons.error),
),
),
subtitle: Wrap(
children: [
const SizedBox(width: mediumGap),
const Text("·"),
const SizedBox(width: mediumGap),
const Text("Written on "),
Text("${post.createdAt}"),
],
)),
);
}
}
PostDetailBody 코드 수정
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_blog/_core/utils/permission_util.dart';
import 'package:flutter_blog/providers/global/post/post_detail_notifier.dart';
import 'package:flutter_blog/providers/global/post/post_list_notifier.dart';
import 'package:flutter_blog/providers/global/session_notifier.dart';
import 'package:flutter_blog/ui/pages/post/update_page/post_update_page.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../../data/models/post.dart';
class PostDetailButtons extends ConsumerWidget {
final Post post;
const PostDetailButtons(this.post, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final SessionModel sessionModel = ref.read(sessionProvider);
final currentUser = sessionModel.user;
bool canEdit = PermissionUtil.canEditPost(currentUser, post);
bool canDelete = PermissionUtil.canDeletePost(currentUser, post);
if (canEdit == false && canDelete == false) {
return SizedBox.shrink();
}
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
IconButton(
onPressed: () async {
_handleDeleteTap(context, ref);
},
icon: const Icon(CupertinoIcons.delete),
),
IconButton(
onPressed: () {
Navigator.push(
context, MaterialPageRoute(builder: (_) => PostUpdatePage()));
},
icon: const Icon(CupertinoIcons.pen),
),
],
);
} // end of build
/// 삭제 버튼 클릭 처리
void _handleDeleteTap(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text('게시글 삭제'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('정말로 이 게시글을 삭제하시겠습니까?'),
const SizedBox(height: 8),
Text(
'제목 : ${post.title}',
style: TextStyle(
fontWeight: FontWeight.bold,
),
)
],
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text('취소'),
),
TextButton(
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
onPressed: () async {
// 다이얼 로그 내리고
Navigator.of(context).pop();
// 삭제 서비스 로직 요청
Map<String, dynamic> result = await ref
.read(postDetailProvider(post.id).notifier)
.deletePost(post.id);
// 응답 결과 처리
if (result["success"]) {
// mounted = true
// 위젯이 dispose 되었을 때 (mounted = false)
// 객체는 여전히 존재 하지만 위젯 트리에 분리되 상태를 말한다
await ref.read(postListProvider.notifier).fetchPosts();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('게시글을 삭제 했습니다.'),
backgroundColor: Colors.redAccent,
),
);
Navigator.of(context).pop();
}
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('게시글을 삭제 실패'),
backgroundColor: Colors.redAccent,
),
);
}
},
child: Text('확인'),
),
],
);
},
);
}
}'Flutter' 카테고리의 다른 글
| 레코드 문법과 tyepdef 에 대해 알아 보자. (2) | 2025.09.05 |
|---|---|
| 블로그 프로젝트 - 게시글 수정하기 (1) | 2025.09.05 |
| 블로그 프로젝트 - 게시글 쓰기 구현 하기 ( Riverpod 상태관리 ) (0) | 2025.09.04 |
| 블로그 프로젝트 - 게시글 목록 구현 하기 (0) | 2025.09.04 |
| 블로그 프로젝트 - 로그아웃 기능 연결 및 UI 수정 (0) | 2025.09.04 |