Flutter

블로그 프로젝트 - 게시글 상세 보기 구현 하기

whs5758 2025. 9. 5. 11:28

사전 기본 개념

  1. 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('확인'),
            ),
          ],
        );
      },
    );
  }
}