Flutter

블로그 프로젝트 - 게시글 목록 구현 하기

whs5758 2025. 9. 4. 19:26
PostListBody
import 'package:flutter/material.dart';
import 'package:flutter_blog/providers/global/post/post_list_notifier.dart';
import 'package:flutter_blog/ui/pages/post/detail_page/post_detail_page.dart';
import 'package:flutter_blog/ui/pages/post/list_page/wiegets/post_list_item.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Stateless + riverpod =  ConsumerWidget
// StatefullWidget + riverpod = ConsumerStatefulWidget

// 로컬 UI 상태 변경이 필요한 경우,
// 여러 컨트롤러 객체 필요 한 경우,
// 애니메이션을 필요한 경우
class PostListBody extends ConsumerStatefulWidget {
  const PostListBody({super.key});

  @override
  _PostListBodyState createState() => _PostListBodyState();
}

class _PostListBodyState extends ConsumerState<PostListBody> {
  // 스크롤 위치 감시와 메모리 해제 필요 함
  final ScrollController _scrollController = ScrollController();
  // 추가 로딩 상태 관리
  bool _isLoadingMore = false;

  @override
  void initState() {
    super.initState();
    _scrollController.addListener(_onScroll);
  }

  void _onScroll() {
    if (_scrollController.position.pixels >=
        _scrollController.position.maxScrollExtent - 10) {
      // 우리가 서버측에 추가 게시글 목록 요청
      if (_isLoadingMore == false) {
        _loadMorePosts();
      }
    }
  }

  Future<void> _loadMorePosts() async {
    // 마지막 페이지라면 추가 요청이 없고 아니라면 추고 요청
    PostListModel? model = ref.read(postListProvider);
    if (model == null || model.isLast) {
      return;
    }
    try {
      _isLoadingMore = true;
      await ref.read(postListProvider.notifier).loadMorePosts();
    } finally {
      _isLoadingMore = false;
    }
  }

  @override
  void dispose() {
    // 메모리 해제기 필요한 경우 많이 활용
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    PostListModel? postListModel = ref.watch(postListProvider);
    if (postListModel == null) {
      return const Center(child: CircularProgressIndicator());
    } else {
      return ListView.separated(
        controller: _scrollController,
        itemCount: postListModel.posts.length,
        itemBuilder: (context, index) {
          return InkWell(
            onTap: () {
              Navigator.push(
                  context, MaterialPageRoute(builder: (_) => PostDetailPage()));
            },
            child: PostListItem(postListModel.posts[index]),
          );
        },
        separatorBuilder: (context, index) {
          return const Divider();
        },
      );
    }
  }
}

 

PostListItem
import 'package:flutter/material.dart';
import 'package:flutter_blog/_core/utils/my_http.dart';
import 'package:flutter_blog/data/models/post.dart';

class PostListItem extends StatelessWidget {
  final Post post;
  const PostListItem(this.post, {Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text(post.title, style: TextStyle(fontWeight: FontWeight.bold)),
      subtitle: Text(
        post.content,
        style: TextStyle(color: Colors.black45),
        overflow: TextOverflow.ellipsis,
        maxLines: 1,
      ),
      trailing: ClipRRect(
        borderRadius: BorderRadius.circular(50), // 네모난 이미지를 동그랗게 만들기 위한 값 설정
        child: Image.network('${baseUrl} ${post.user.imgUrl}'), // 네모난 이미지
      ),
    );
  }
}

 

post_list_notifier.dart
// 1. 게시글 목록에 대한 데이터를 설계 하자.
import 'package:flutter_blog/_core/utils/exception_handler.dart';
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';

class PostListModel {
  bool isFirst;
  bool isLast;
  int pageNumber; // 현재 페이지 번호 - 0번 부터 시작
  int size; // 페이지당 게시글 개수
  int totalPage; // 전체 페이지 수
  List<Post> posts;

  PostListModel(
    this.isFirst,
    this.isLast,
    this.pageNumber,
    this.size,
    this.totalPage,
    this.posts,
  ); // 실제 게시글 데이터

  // 서버 응답 데이터를 PostListModel 객체로 변환 하는 생성자.
  // 네임드 생성자 호출시 멤버 변수에 값을 할당 하려면 초기화 키워드 사용해야 한다.
  PostListModel.fromMap(Map<String, dynamic> data)
      : isFirst = data['isFirst'],
        isLast = data['isLast'],
        pageNumber = data['pageNumber'],
        size = data['size'],
        totalPage = data['totalPage'],
        posts = (data['posts'] as List).map((e) => Post.fromMap(e)).toList();

  PostListModel copyWith({
    bool? isFirst,
    bool? isLast,
    int? pageNumber,
    int? size,
    int? totalPage,
    List<Post>? posts,
  }) {
    return PostListModel(
      isFirst ?? this.isFirst,
      isLast ?? this.isLast,
      pageNumber ?? this.pageNumber,
      size ?? this.size,
      totalPage ?? this.totalPage,
      posts ?? this.posts,
    );
  }

  @override
  String toString() {
    return 'PostListModel{isFirst: $isFirst, isLast: $isLast, pageNumber: $pageNumber, size: $size, totalPage: $totalPage, posts: $posts}';
  }
} // end of PostListModel class

class PostListNotifier extends Notifier<PostListModel?> {
  @override
  PostListModel? build() {
    // 창고 데이터 초기 모델은 항상 ---> 통신 요청 이후에 결정이 된다
    // TODO 초기화 메서드 추가하기(통신요청)
    fetchPosts();
    return null;
  }

  // 1. fetchPosts - 게시글 목록 가져 오는 로직
  Future<Map<String, dynamic>> fetchPosts({int page = 0}) async {
    //TODO -  예외 처리 추후 추가
    Map<String, dynamic> body = await PostRepository().getList(page: page);
    if (body["success"]) {
      // 서버에서 정상적으로 데이터를 내려 준다면 json 데이터를 PostListModel 로 파싱 처리
      PostListModel newModel = PostListModel.fromMap(body["response"]);
      // 상태 변경
      state = newModel;
      return {"success": true};
    } else {
      // 서버에서 내려준 에러 메세지를 사용자에게 보여 주자.
      ExceptionHandler.handleException(
          body["errorMessage"], StackTrace.current);
      return {"success": false};
    }
  }

  // 2. refreshPostList - 목록 새로 고침 로직

  // 3. loadMorePosts - 페이지 처리, 추가 데이터 요청
  Future<Map<String, dynamic>> loadMorePosts() async {
    print("현재 페이지 번호 : ${state!.pageNumber}");
    print("다음 페이지 번호는? : ${state!.pageNumber + 1}");
    int nextPage = state!.pageNumber + 1;
    Map<String, dynamic> body = await PostRepository().getList(page: nextPage);
    if (body["success"]) {
      PostListModel newPostListModel = PostListModel.fromMap(body["response"]);
      List<Post> combinationPost = [...state!.posts, ...newPostListModel.posts];
      state = newPostListModel.copyWith(posts: combinationPost);
      return {"success": true};
    } else {
      return {"success": false};
    }
  }
}

final postListProvider = NotifierProvider<PostListNotifier, PostListModel?>(
    () => PostListNotifier());