[Flutter] 14. Blog app v1 : Prototype

문정준's avatar
Jun 05, 2025
[Flutter] 14. Blog app v1 : Prototype
 

1. New Project

  • 화면이 제작된 상태에서 작업
  • git clone
 

2. Riverpod 작업

  • 상태를 관리하기 위해서는 해당 상태만을 따로 분리시켜야 함
    • Scaffold 전체를 Rebuild 하지 않아야 함 : 자원 낭비
  • 서버에서 데이터를 받을 경우, 또는 사용자가 데이터를 입력하여 전송할 경우 둘 다 해당
    • 데이터를 받아오는 경우 : View Model과 연결
    • 데이터를 전송하는 경우 : Form Model과 연결
      • View Model과 Form Model은 같은 기능 (역할에 따른 컨벤션)
  • 실제 서버와 연결하여, 해당 서버의 API 문서 및 데이터 형태를 참고해서 데이터를 주고 받음
 

3. REST 서버 가동 (build)

  1. Project 폴더 중 blogserver 우클릭 → Open in Explorer 클릭
notion image
 
  1. Git Bash 열기
notion image
 
  1. 서버 실행 명령어 입력
      • 자바 버전 확인할 것
java -jar "파일명"
notion image
 
  1. API 문서 확인 : 데이터 통신 시 주고받는 값 확인
notion image
 

4. MVVM Pattern

  • 서버에서 주로 사용되는 Model - View - Controller 모델과 다르게, Flutter는 View가 Controller의 역할을 수행
    • 모바일은 앱을 다운받으면 그림을 전부 가지고 있기 때문에 브라우저와 다르게 View의 역할 가능
  • View - View Model - Model 순으로 연결되며, View Model이 서버의 Service 계층 역할을 수행
    • 비즈니스 동작 등 수행
    • MVC와의 차이점
      • MVC는 서로 데이터 및 HTML 파일을 주고받음
      • MVVM은 VM에서 View로 값을 전달해주지 않고, 내부의 state를 갱신
        • View는 VM을 계속 관찰하거나 읽어서 상태에 따라 그림을 직접 다시 그림 → CSR
notion image
 
  • 여러 개의 View에서 공통적으로 사용하는 View Model을 Global View Model (GVM)이라고 하며, 보통 사용자 인증 등의 역할을 수행
    • Session의 역할, Singleton 패턴 구현을 위해 사용
notion image

5. 코드 작성

1. Form 묶어서 분리

  • 사용자에게 입력 받을 값을 받는 위젯만 따로 분리
    • 사용자 입력 값 컴포넌트는 Form으로 묶어서 분리하는 것이 좋음
import 'package:flutter/material.dart'; import 'package:flutter_blog/ui/pages/auth/join_page/widgets/join_form.dart'; import 'package:flutter_blog/ui/widgets/custom_logo.dart'; class JoinBody extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(16.0), child: ListView( children: [ const CustomLogo("Blog"), JoinForm(), ], ), ); } }
 
import 'package:flutter/material.dart'; import 'package:flutter_blog/ui/pages/auth/login_page/widgets/login_form.dart'; import 'package:flutter_blog/ui/widgets/custom_logo.dart'; class LoginBody extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(16.0), child: ListView( children: [ const CustomLogo("Blog"), LoginForm(), ], ), ); } }
 

2. Form Model 만들기

  • 데이터를 입력받을 경우 입력 값에 대한 상태 관리를 위한 Form Model 제작
    • View Model 구성과 다르지 않음 ( 역할에 따른 컨벤션 )
    • View에서 받아온 데이터의 비즈니스 처리
// fm : Form Model, 전송할 데이터를 담는 Riverpod import 'package:flutter_blog/_core/utils/validator_util.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; /// 1. 창고 관리자 final joinProvider = NotifierProvider<JoinFM, JoinModel>(() { return JoinFM(); }); /// 2. 창고 class JoinFM extends Notifier<JoinModel> { @override JoinModel build() { return JoinModel("", "", ""); } void username(String username) { final error = validateUsername(username); print("error : ${error}"); state = state.copyWith( username: username, usernameError: error, ); } void email(String email) { final error = validateEmail(email); state = state.copyWith( email: email, emailError: error, ); } void password(String password) { final error = validatePassword(password); state = state.copyWith( password: password, passwordError: error, ); } bool validate() { final usernameError = validateUsername(state.username); final emailError = validateEmail(state.email); final passwordError = validatePassword(state.password); return usernameError.isEmpty && emailError.isEmpty && passwordError.isEmpty; } } /// 3. 창고 데이터 타입 class JoinModel { final String username; final String email; final String password; final String usernameError; final String emailError; final String passwordError; JoinModel( this.username, this.email, this.password, { this.usernameError = "", this.emailError = "", this.passwordError = "", }); JoinModel copyWith({ String? username, String? email, String? password, String? usernameError, String? emailError, String? passwordError, }) { return JoinModel( username ?? this.username, email ?? this.email, password ?? this.password, usernameError: usernameError ?? this.usernameError, emailError: emailError ?? this.emailError, passwordError: passwordError ?? this.passwordError, ); } @override String toString() { return 'JoinModel{username: $username, email: $email, password: $password, usernameError: $usernameError, emailError: $emailError, passwordError: $passwordError}'; } }
 
// fm : Form Model, 전송할 데이터를 담는 Riverpod import 'package:flutter_blog/_core/utils/validator_util.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; /// 1. 창고 관리자 final loginProvider = NotifierProvider<LoginFM, LoginModel>(() { return LoginFM(); }); /// 2. 창고 class LoginFM extends Notifier<LoginModel> { @override LoginModel build() { return LoginModel("", ""); } void username(String username) { final error = validateUsername(username); state = state.copyWith( username: username, usernameError: error, ); } void password(String password) { final error = validatePassword(password); state = state.copyWith( password: password, passwordError: error, ); } bool validate() { final usernameError = validateUsername(state.username); final passwordError = validatePassword(state.password); return usernameError.isEmpty && passwordError.isEmpty; } } /// 3. 창고 데이터 타입 class LoginModel { final String username; final String password; final String usernameError; final String passwordError; LoginModel( this.username, this.password, { this.usernameError = "", this.passwordError = "", }); LoginModel copyWith({ String? username, String? password, String? usernameError, String? passwordError, }) { return LoginModel( username ?? this.username, password ?? this.password, usernameError: usernameError ?? this.usernameError, passwordError: passwordError ?? this.passwordError, ); } @override String toString() { return 'LoginModel{username: $username, password: $password, usernameError: $usernameError, passwordError: $passwordError}'; } }

3. 사용자 입력 값 넘기기

  • 사용자의 입력 값을 통신을 위해 전달
    • View (화면) → VM or FM → Repository 순으로 전달
    • 전송받은 데이터는 Repository → VM or FM → View로 전달
  • 로그인 / 회원가입 등의 처리를 담당하기 위해 세션의 역할을 수행하는 GVM 제작 : session_gvm.dart
    • 로그인 시 메모리와 함께 디바이스에도 accessToken 저장
      • 앱 종료 후 다시 실행 시 자동 로그인 수행 가능
  • 로그아웃은 로그인 시 토큰 저장 순서의 반대로 토큰 삭제 진행
    • 토큰만 없어도 로그인이 불가능하므로 유저 객체 초기화는 불필요
import 'package:flutter/material.dart' show ScaffoldMessenger, Text, SnackBar, Navigator; import 'package:flutter_blog/_core/utils/my_http.dart'; import 'package:flutter_blog/data/model/user.dart'; import 'package:flutter_blog/data/repository/user_repository.dart'; import 'package:flutter_blog/main.dart'; import 'package:flutter_blog/ui/pages/auth/join_page/join_fm.dart'; import 'package:flutter_blog/ui/pages/auth/login_page/login_fm.dart'; import 'package:flutter_blog/ui/pages/post/list_page/post_list_page.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; /// 1. 창고 관리자 final sessionProvider = NotifierProvider<SessionGVM, SessionModel>(() { // 의존하는 VM return SessionGVM(); }); /// 2. 창고 (상태가 변경되어도, 화면 갱신 안함 - watch 하지마) class SessionGVM extends Notifier<SessionModel> { final mContext = navigatorKey.currentContext!; @override SessionModel build() { return SessionModel(); } Future<void> join(String username, String email, String password) async { Logger().d("username : ${username}, email : ${email}, password : ${password}"); bool isValid = ref.read(joinProvider.notifier).validate(); if (!isValid) { ScaffoldMessenger.of(mContext).showSnackBar( SnackBar(content: Text("유효성 검사 실패입니다")), ); return; } Map<String, dynamic> body = await UserRepository().join(username, email, password); if (!body["success"]) { ScaffoldMessenger.of(mContext).showSnackBar( SnackBar(content: Text("${body["errorMessage"]}")), ); return; } Navigator.pushNamed(mContext, "/login"); } Future<void> login(String username, String password) async { // 1. 유효성 검사 Logger().d("username : ${username}, password : ${password}"); bool isValid = ref.read(loginProvider.notifier).validate(); if (!isValid) { ScaffoldMessenger.of(mContext).showSnackBar( SnackBar(content: Text("유효성 검사 실패입니다")), ); return; } // 2. 통신 Map<String, dynamic> body = await UserRepository().login(username, password); if (!body["success"]) { ScaffoldMessenger.of(mContext).showSnackBar( SnackBar(content: Text("${body["errorMessage"]}")), ); return; } // 3. 파싱 User user = User.fromMap(body["response"]); // 4. 토큰을 디바이스 저장 ( 앱 다시 실행 시 자동 로그인 ) await secureStorage.write(key: "accessToken", value: user.accessToken); // 5. 세션모델 갱신 state = SessionModel(user: user, isLogin: true); // 6. dio의 header에 토큰 세팅 (Bearer 포함) dio.options.headers["Authorization"] = user.accessToken; // 7. 게시글 목록 페이지 이동 Navigator.pushNamed(mContext, "/post/list"); } Future<void> logout() async { // 1. 토큰 디바이스 제거 await secureStorage.delete(key: "accessToken"); // 2. 세션모델 초기화 state = SessionModel(); // 3. dio 세팅 제거 dio.options.headers["Authorization"] = ""; // 4. login 페이지 이동 scaffoldKey.currentState!.openEndDrawer(); Navigator.pushNamed(mContext, "/login"); } } /// 3. 창고 데이터 타입 (불변 아님) class SessionModel { User? user; bool? isLogin; SessionModel({this.user, this.isLogin = false}); @override String toString() { return 'SessionModel{user: $user, isLogin: $isLogin}'; } }
 
  • 실제 통신을 진행하기 위한 Repository도 작성
    • 회원가입, 로그인 로직을 처리하기 위해 서버와 통신을 진행할 user_repository.dart
import 'package:dio/dio.dart'; import 'package:flutter_blog/_core/utils/my_http.dart'; import 'package:logger/logger.dart'; class UserRepository { Future<Map<String, dynamic>> join(String username, String email, String password) async { final requestBody = {"username": username, "email": email, "password": password}; Response response = await dio.post("/join", data: requestBody); Map<String, dynamic> responseBody = response.data; Logger().d(responseBody); return responseBody; } Future<Map<String, dynamic>> login(String username, String password) async { // 1. Map 변환 final requestBody = { "username": username, "password": password, }; // 2. 통신 Response response = await dio.post("/login", data: requestBody); Map<String, dynamic> responseBody = response.data; return responseBody; } }
 
  • 실제 폼에서 사용자가 입력한 값 넘기기
    • 의존성 주입을 통해 View → VM → Repository 순으로 넘기기
import 'package:flutter/material.dart'; import 'package:flutter_blog/_core/constants/size.dart'; import 'package:flutter_blog/data/gvm/session_gvm.dart'; import 'package:flutter_blog/ui/pages/auth/join_page/join_fm.dart'; import 'package:flutter_blog/ui/widgets/custom_auth_text_form_field.dart'; import 'package:flutter_blog/ui/widgets/custom_elavated_button.dart'; import 'package:flutter_blog/ui/widgets/custom_text_button.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class JoinForm extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { JoinFM fm = ref.read(joinProvider.notifier); JoinModel model = ref.watch(joinProvider); return Form( child: Column( children: [ CustomAuthTextFormField( title: "Username", onChanged: (value) { fm.username(value); print("창고 state : ${model}"); }, errorText: model.usernameError, ), const SizedBox(height: mediumGap), CustomAuthTextFormField( title: "Email", onChanged: (value) { fm.email(value); print("창고 state : ${model}"); }, errorText: model.emailError, ), const SizedBox(height: mediumGap), CustomAuthTextFormField( title: "Password", onChanged: (value) { fm.password(value); print("창고 state : ${model}"); }, obscureText: true, errorText: model.passwordError, ), const SizedBox(height: largeGap), CustomElevatedButton( text: "회원가입", click: () { ref.read(sessionProvider.notifier).join(model.username, model.email, model.password); }, ), CustomTextButton( text: "로그인 페이지로 이동", click: () { Navigator.pushNamed(context, "/login"); }, ), ], ), ); } }
 
import 'package:flutter/material.dart'; import 'package:flutter_blog/_core/constants/size.dart'; import 'package:flutter_blog/data/gvm/session_gvm.dart'; import 'package:flutter_blog/ui/pages/auth/login_page/login_fm.dart'; import 'package:flutter_blog/ui/widgets/custom_auth_text_form_field.dart'; import 'package:flutter_blog/ui/widgets/custom_elavated_button.dart'; import 'package:flutter_blog/ui/widgets/custom_text_button.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; class LoginForm extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { LoginFM fm = ref.read(loginProvider.notifier); LoginModel model = ref.watch(loginProvider); Logger().d(model); return Form( child: Column( children: [ CustomAuthTextFormField( title: "Username", onChanged: (value) { fm.username(value); }, errorText: model.usernameError, ), const SizedBox(height: mediumGap), CustomAuthTextFormField( title: "Password", onChanged: (value) { fm.password(value); }, obscureText: true, errorText: model.passwordError, ), const SizedBox(height: largeGap), CustomElevatedButton( text: "로그인", click: () { ref.read(sessionProvider.notifier).login(model.username, model.password); }, ), CustomTextButton( text: "회원가입 페이지로 이동", click: () { Navigator.pushNamed(context, "/join"); }, ), ], )); } }

4. Transaction

  • 실제 통신을 수행하며 입력, 출력 값에 대한 처리 및 데이터 통신을 진행
    • 중간 에러 처리는 VM에서, 데이터 파싱은 Repository에서 진행
    • 화면 전환 등의 로직은 View에서 처리해도 되고, VM에서 처리해도 됨
      • VM에서 처리할 경우 context를 전역적으로 사용할 수 있게 도와주는 GlobalKey 사용
      import 'package:flutter/material.dart'; import 'package:flutter_blog/_core/constants/theme.dart'; import 'package:flutter_blog/ui/pages/auth/join_page/join_page.dart'; import 'package:flutter_blog/ui/pages/auth/login_page/login_page.dart'; import 'package:flutter_blog/ui/pages/post/list_page/post_list_page.dart'; import 'package:flutter_blog/ui/pages/post/write_page/post_write_page.dart'; import 'package:flutter_blog/ui/pages/splash/splash_page.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; // TODO: 1. Stack의 가장 위 context를 알고 있다. GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); void main() { runApp(const ProviderScope(child: MyApp())); } class MyApp extends StatelessWidget { const MyApp({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return MaterialApp( navigatorKey: navigatorKey, // context가 없는 곳에서 context를 사용할 수 있는 방법 >> 경고창 or 뒤로 가기 등 debugShowCheckedModeBanner: false, home: SplashPage(), // Riverpod으로 구현 : 데이터 통신 받고 넘어가는 용도 routes: { "/login": (context) => const LoginPage(), "/join": (context) => const JoinPage(), "/post/list": (context) => PostListPage(), "/post/write": (context) => const PostWritePage(), }, theme: theme(), ); } }
       
    • 로그인 확인을 위해 사용하는 accessToken의 경우 메모리와 디바이스에 전부 저장
      • 디바이스 저장 시 따로 로그아웃을 하지 않을 경우 자동 로그인을 수행할 수 있음
      • 디바이스 저장 시 사용하는 secureStorage
import 'package:dio/dio.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; final baseUrl = "http://192.168.0.16:8080"; // cmd -> ipconfig -> ip주소 적기 //로그인 되면, dio에 jwt 추가하기 //dio.options.headers['Authorization'] = 'Bearer $_accessToken'; final dio = Dio( BaseOptions( baseUrl: baseUrl, // 내 IP 입력 contentType: "application/json; charset=utf-8", validateStatus: (status) => true, // 200 이 아니어도 예외 발생안하게 설정 -> 메시지 확인용 ), ); const secureStorage = FlutterSecureStorage();
 

Post

List

  • 리스트의 전달 값을 확인하여 객체 생성 필요
  • Postman 또는 API 문서를 확인하여 응답 구조 확인
notion image
 
  • Post 객체
    • posts 리스트 안의 구조를 그대로 작성
    • 글을 표시할 때 불필요한 정보를 빼더라도 전송되는 데이터를 그대로 받아오는 것이 좋음
    • 유저 정보는 User 객체의 fromMap 함수를 통해 Map을 객체로 변환하여 저장
import 'package:flutter_blog/data/model/user.dart'; class Post { final int id; final String title; final String content; final DateTime createdAt; final DateTime updatedAt; final User user; Post({ required this.id, required this.title, required this.content, required this.createdAt, required this.updatedAt, required this.user, }); Post.fromMap(Map<String, dynamic> data) : id = data['id'], title = data['title'], content = data['content'], createdAt = DateTime.parse(data['createdAt']), updatedAt = DateTime.parse(data['updatedAt']), user = User.fromMap(data['user']); }
 
  • PostRepository : 게시글 리스트를 통신을 통해 받아옴
    • page라는 QueryString이 포함되어 있으므로 queryParameters 추가
      • 기본 값은 0으로 설정 : 문서 참고
      • Map 형태로 지정
    • body를 반환하여 VM에서 파싱
import 'package:dio/dio.dart'; import 'package:flutter_blog/_core/utils/my_http.dart'; import 'package:logger/logger.dart'; class PostRepository { Future<Map<String, dynamic>> getList({int page = 0}) async { Response response = await dio.get("/api/post", queryParameters: {"page": page}); final responseBody = response.data; Logger().d(responseBody); return responseBody; } }
 
  • 통신 함수 제작 후 항상 통신이 원활히 이루어지는지 테스트가 필요
  • PostRepositoryTest : 로그인을 위한 accessToken 추가 후 getList가 잘 호출되는 지 테스트
    • 통신 이후 로그를 통해 데이터를 올바르게 받아오는 지 같이 확인
import 'package:flutter_blog/_core/utils/my_http.dart'; import 'package:flutter_blog/data/repository/post_repository.dart'; void main() async { dio.options.headers["Authorization"] = "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpbWdVcmwiOiIvaW1hZ2VzLzEucG5nIiwic3ViIjoibWV0YWNvZGluZyIsImlkIjoxLCJleHAiOjE3NDk2MDU1MTEsInVzZXJuYW1lIjoic3NhciJ9.ZK50ecQD8LEuS1jrucCw3sf-ETATjopDu3L7VPNfr42heoRC5T8g7vWpWX60ijItBPgy_1zNMj6U5dsVkZSt8Q"; PostRepository post = PostRepository(); await post.getList(); }
 
  • PostListVM : 게시글 리스트를 관리하는 창고 / 창고 관리자 생성
    • 게시글은 서버에서 통신을 통해 받아와야 함 : 초기값이 존재할 수 없음
      • build 시 null을 우선적으로 반환해야 함 : PostListModel도 ? 타입으로 정의
    • 상태 초기화 함수 init 작성 : 서버와 통신한 후 받아온 데이터를 파싱하여 상태 갱신
      • 비동기 함수이므로 await / async 필수 포함
      • 이후에 상태를 갱신할 시 copyWith 사용
    • PostListModel에서는 데이터를 전송받은 후 파싱하는 fromMap 및 copyWith 작성
      • 다른 객체를 반환해야 할 때에는 factory 사용 가능
        • 새로운 객체를 반환하지 않고 재사용 가능 : return 사용
notion image
import 'package:flutter_blog/data/model/post.dart'; import 'package:flutter_blog/data/repository/post_repository.dart'; import 'package:flutter_blog/main.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; /// 1. 창고 관리자 final postListProvider = NotifierProvider<PostListVM, PostListModel?>(() { return PostListVM(); }); /// 2. 창고 (상태가 변경되어도, 화면 갱신 안함 - watch 하지마) class PostListVM extends Notifier<PostListModel?> { final mContext = navigatorKey.currentContext!; @override PostListModel? build() { init(); return null; } Future<void> init({int page = 0}) async { Map<String, dynamic> body = await PostRepository().getList(page: page); state = PostListModel.fromMap(body["response"]); } } /// 3. 창고 데이터 타입 (불변 아님) class PostListModel { bool isFirst; bool isLast; int pageNumber; int size; int totalPage; List<Post> posts; PostListModel(this.isFirst, this.isLast, this.pageNumber, this.size, this.totalPage, this.posts); 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}'; } }
 
  • PostListPage : 유저 로그인 정보를 받아오면 게시글 리스트를 반환하는 페이지
    • 유저 정보로 페이지를 갱신할 필요가 없기 때문에 watch 불필요
import 'package:flutter/material.dart'; import 'package:flutter_blog/data/gvm/session_gvm.dart'; import 'package:flutter_blog/ui/pages/post/list_page/wiegets/post_list_body.dart'; import 'package:flutter_blog/ui/widgets/custom_navigator.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; final scaffoldKey = GlobalKey<ScaffoldState>(); class PostListPage extends ConsumerWidget { final refreshKey = GlobalKey<RefreshIndicatorState>(); PostListPage(); @override Widget build(BuildContext context, WidgetRef ref) { SessionModel model = ref.read(sessionProvider); return Scaffold( key: scaffoldKey, drawer: CustomNavigation(scaffoldKey), appBar: AppBar( // 로그인을 수행해야 저 화면이 넘어오기 때문에 user는 절대 null이 될 수 없음 : ! 붙이기 title: Text("Blog ${model.isLogin} ${model.user!.username}"), ), body: RefreshIndicator( key: refreshKey, onRefresh: () async {}, child: PostListBody(), ), ); } }
 
  • PostListBody : 게시글 목록 표시
    • 처음 화면을 표시할 때는 null이 반환되므로 빈 화면 대신 Progress Indicator 표시
    • 이후 통신 완료 시 리스트 뷰 반환
      • 모델 상태 갱신 후 게시글 표시
import 'package:flutter/material.dart'; import 'package:flutter_blog/ui/pages/post/detail_page/post_detail_page.dart'; import 'package:flutter_blog/ui/pages/post/list_page/post_list_vm.dart'; import 'package:flutter_blog/ui/pages/post/list_page/wiegets/post_list_item.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class PostListBody extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { PostListModel? model = ref.watch(postListProvider); PostListVM vm = ref.read(postListProvider.notifier); if (model == null) { return Center(child: CircularProgressIndicator()); } else { return ListView.separated( itemCount: model.posts.length, itemBuilder: (context, index) { return InkWell( onTap: () { Navigator.push(context, MaterialPageRoute(builder: (_) => PostDetailPage())); }, child: PostListItem(model.posts[index]), ); }, separatorBuilder: (context, index) { return const Divider(); }, ); } } }
 
  • PostListItem : PostListBody에서 전달받은 Post 정보를 전달
    • CachedNetworkImage : 외부 이미지를 캐싱할 수 있는 라이브러리
      • 사진을 불러오기 전, 에러 발생 시 대체할 수 있는 옵션 보유
      • 이미지 주소의 경우 baseUrl + imgUrl로 작성해야 받아올 수 있음
        • imgUrl의 경우 호스트 주소를 반환하지 않음
    • 이미지 비율 지정 및 Round 처리
      • AspectRatio : 1
      • ClipOval
import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_blog/_core/utils/my_http.dart'; import 'package:flutter_blog/data/model/post.dart'; class PostListItem extends StatelessWidget { Post post; PostListItem(this.post); @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: ClipOval( // 네모난 이미지를 동그랗게 만들기 위한 값 설정 child: AspectRatio( aspectRatio: 1 / 1, child: CachedNetworkImage( imageUrl: "${baseUrl}${post.user.imgUrl}", placeholder: (context, url) => CircularProgressIndicator(), errorWidget: (context, url, error) => Icon(Icons.error), fit: BoxFit.cover, ), ), // 네모난 이미지 ), ); } }
 

Result

  • accessToken 미 전달 시 (로그인 실패) 또는 게시글 통신 미완 시
    • model == null이므로 화면에 ProgressIndicator 표시
notion image
 
  • 데이터가 전송되고 난 화면
notion image

Detail

  • 리스트의 전달 값을 확인하여 객체 생성 필요
  • Postman 또는 API 문서를 확인하여 응답 구조 확인
notion image
 
  • PostRepository : 하나의 게시글의 세부 정보를 받아오는 getOne 함수 작성
    • postId 필요 → build 시 받아와야 함
import 'package:dio/dio.dart'; import 'package:flutter_blog/_core/utils/my_http.dart'; import 'package:logger/logger.dart'; class PostRepository { Future<Map<String, dynamic>> getList({int page = 0}) async { Response response = await dio.get("/api/post", queryParameters: {"page": page}); final responseBody = response.data; Logger().d(responseBody); return responseBody; } Future<Map<String, dynamic>> getOne(int postId) async { Response response = await dio.get("/api/post/${postId}"); final responseBody = response.data; Logger().d(responseBody); return responseBody; } }
 
  • PostListBody : 리스트의 아이템 클릭 시 해당 게시글의 상세 정보로 이동하는 이벤트 발생
    • Navigator.push : 주소가 아닌 해당 페이지로 직접 이동
      • DetailPage에 게시글 id 전달
import 'package:flutter/material.dart'; import 'package:flutter_blog/ui/pages/post/detail_page/post_detail_page.dart'; import 'package:flutter_blog/ui/pages/post/list_page/post_list_vm.dart'; import 'package:flutter_blog/ui/pages/post/list_page/widgets/post_list_item.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class PostListBody extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { PostListModel? model = ref.watch(postListProvider); PostListVM vm = ref.read(postListProvider.notifier); if (model == null) { return Center(child: CircularProgressIndicator()); } else { return ListView.separated( itemCount: model.posts.length, itemBuilder: (context, index) { return InkWell( onTap: () { // push -> 주소가 아닌 페이지 자체를 이동 Navigator.push(context, MaterialPageRoute(builder: (context) => PostDetailPage(model.posts[index].id))); }, child: PostListItem(model.posts[index]), ); }, separatorBuilder: (context, index) { return const Divider(); }, ); } } }
 
  • PostDetailPage : 게시글의 세부 사항을 확인할 수 있는 페이지
    • PostDetailBody를 Rebuild해야 하므로 postId를 Body에 다시 전달
import 'package:flutter/material.dart'; import 'package:flutter_blog/ui/pages/post/detail_page/widgets/post_detail_body.dart'; class PostDetailPage extends StatelessWidget { int postId; PostDetailPage(this.postId); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: PostDetailBody(postId), ); } }
 
  • PostDetailVM : 게시글 상세 내용 데이터 저장/갱신 및 파싱
    • postId를 통해 통신받은 데이터를 표시해야 하는데, 기존 Provider에서는 매개변수 전달이 불가함
    • FamilyNotifier : 창고 데이터 타입과 함께 전달 받을 매개변수 타입을 지정
    • NotifierProvider.family : 창고 관리자와 전달 받을 매개 변수 타입을 지정
      • 전달 받을 매개변수를 build에 작성 가능
    • 전달받은 postId를 통해 서버와 통신하여 상태 갱신
import 'package:flutter_blog/data/model/user.dart'; import 'package:flutter_blog/data/repository/post_repository.dart'; import 'package:flutter_blog/main.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; /// 1. 창고 관리자 /// 창고가 매개변수를 받을 경우 Provider도 family 설정, 끝에 매개 변수 타입 지정 final postDetailProvider = NotifierProvider.family<PostDetailVM, PostDetailModel?, int>(() { return PostDetailVM(); }); /// 2. 창고 (상태가 변경되어도, 화면 갱신 안함 - watch 하지마) /// FamilyNotifier : 매개변수를 받을 수 있는 Notifier, 끝에 매개변수 타입 지정 class PostDetailVM extends FamilyNotifier<PostDetailModel?, int> { final mContext = navigatorKey.currentContext!; @override PostDetailModel? build(int postId) { init(postId); return null; } Future<void> init(int postId) async { Map<String, dynamic> body = await PostRepository().getOne(postId); state = PostDetailModel.fromMap(body["response"]); } } /// 3. 창고 데이터 타입 (불변 아님) class PostDetailModel { int id; String title; String content; String createdAt; String updatedAt; int bookmarkCount; bool isBookmark; User user; PostDetailModel( this.id, this.title, this.content, this.createdAt, this.updatedAt, this.bookmarkCount, this.isBookmark, this.user, ); PostDetailModel.fromMap(Map<String, dynamic> data) : this.id = data["id"], this.title = data["title"], this.content = data["content"], this.createdAt = data["createdAt"].toString(), this.updatedAt = data["updatedAt"].toString(), this.bookmarkCount = data["bookmarkCount"], this.isBookmark = data["isBookmark"], this.user = User.fromMap(data["user"]); }
 
  • PostDetailBody : 게시글 상세보기 내용
    • PostDetailPage에서 전달 받은 postId를 Provider에 전달하여 데이터 통신 후 바인딩
    • PostDetailProfile, PostDetailButtons에 추가로 필요한 model 정보 전달
      • PostDetailProfile : user 정보 및 작성 시간이 필요하므로 model 전달
      • PostDetailButtons : user 정보와 sessionGVM의 user 정보를 비교해야 하므로 model.user 전달
import 'package:flutter/material.dart'; import 'package:flutter_blog/_core/constants/size.dart'; import 'package:flutter_blog/ui/pages/post/detail_page/post_detail_vm.dart'; import 'package:flutter_blog/ui/pages/post/detail_page/widgets/post_detail_buttons.dart'; import 'package:flutter_blog/ui/pages/post/detail_page/widgets/post_detail_content.dart'; import 'package:flutter_blog/ui/pages/post/detail_page/widgets/post_detail_profile.dart'; import 'package:flutter_blog/ui/pages/post/detail_page/widgets/post_detail_title.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class PostDetailBody extends ConsumerWidget { int postId; PostDetailBody(this.postId); @override Widget build(BuildContext context, WidgetRef ref) { // NotifierProvider.family -> 매개변수는 괄호 안에 넣어서 전달 PostDetailModel? model = ref.watch(postDetailProvider(postId)); if (model == null) return Center(child: CircularProgressIndicator()); else { return Padding( padding: const EdgeInsets.all(16.0), child: ListView( children: [ PostDetailTitle(model.title), const SizedBox(height: largeGap), // user 정보 전달 (email은 생략 (response에서 미포함)) PostDetailProfile(model), // 내가 글을 쓴 사람이면 (글의 userId == sessionGVM의 userId) 표시 (if 가능) PostDetailButtons(model.user), const Divider(), const SizedBox(height: largeGap), PostDetailContent(model.content), ], ), ); } } }
 
  • PostDetailProfile : 글을 작성한 사람의 정보
    • imgUrl, createdAt 등의 정보 추가
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 'package:flutter_blog/ui/pages/post/detail_page/post_detail_vm.dart'; class PostDetailProfile extends StatelessWidget { PostDetailModel model; PostDetailProfile(this.model); @override Widget build(BuildContext context) { return ListTile( title: Text("${model.user.username}"), leading: ClipOval( // 네모난 이미지를 동그랗게 만들기 위한 값 설정 child: AspectRatio( aspectRatio: 1 / 1, child: CachedNetworkImage( imageUrl: "${baseUrl}${model.user.imgUrl}", placeholder: (context, url) => CircularProgressIndicator(), errorWidget: (context, url, error) => Icon(Icons.error), fit: BoxFit.cover, ), ), // 네모난 이미지 ), subtitle: Row( children: [ Text("ssar@nate.com"), const SizedBox(width: mediumGap), const Text("·"), const SizedBox(width: mediumGap), const Text("Written on "), Text("${model.createdAt}"), ], )); } }
 
  • PostDetailButtons : 수정, 삭제 버튼
    • 로그인 한 유저가 글을 작성한 유저와 동일해야만 수정, 삭제 권한 부여 가능
    • 전송 받은 데이터에 isOwner 등의 권한 확인 용 bool 변수가 없으므로 직접 sessionGVM에서 id를 가져와 비교해야 함
      • 이때, 로그인한 유저의 정보는 페이지를 갱신하는 용도가 아니므로 read 사용
      • sessionUser의 user는 ? 로 선언되어 있으나 값을 불러올 때에는 무조건 로그인이 되어 있으므로 ! 타입 추가
    • 같으면 버튼을 추가하고, 다르면 빈 컨테이너 추가 (버튼 비활성화)
import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_blog/data/gvm/session_gvm.dart'; import 'package:flutter_blog/data/model/user.dart'; import 'package:flutter_blog/ui/pages/post/update_page/post_update_page.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class PostDetailButtons extends ConsumerWidget { User user; PostDetailButtons(this.user); @override Widget build(BuildContext context, WidgetRef ref) { SessionModel sessionUser = ref.read(sessionProvider); bool isOwner = user.id == sessionUser.user!.id; if (isOwner) { return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ IconButton( onPressed: () async {}, icon: const Icon(CupertinoIcons.delete), ), IconButton( onPressed: () { Navigator.push(context, MaterialPageRoute(builder: (_) => PostUpdatePage())); }, icon: const Icon(CupertinoIcons.pen), ), ], ); } else return Container(); } }

Delete

  • Delete는 요청, 응답 body가 없기 때문에 파싱 불필요
  • 다만, 삭제 후 VM 및 뷰 처리를 위한 코드 추가가 필요
 
  • PostRepository : 삭제 요청 함수 추가
    • id를 받아 해당 id를 가진 게시글을 삭제하는 요청을 보내는 함수
import 'package:dio/dio.dart'; import 'package:flutter_blog/_core/utils/my_http.dart'; import 'package:logger/logger.dart'; class PostRepository { Future<Map<String, dynamic>> getList({int page = 0}) async { Response response = await dio.get("/api/post", queryParameters: {"page": page}); final responseBody = response.data; Logger().d(responseBody); return responseBody; } Future<Map<String, dynamic>> getOne(int postId) async { Response response = await dio.get("/api/post/${postId}"); final responseBody = response.data; Logger().d(responseBody); return responseBody; } Future<Map<String, dynamic>> deleteOne(int postId) async { Response response = await dio.delete("/api/post/${postId}"); final responseBody = response.data; Logger().d(responseBody); return responseBody; } }
 
  • PostDetailVM : 상세보기 화면에서 삭제를 진행하기 때문에 해당 화면을 관리하는 VM에서 처리 필요
  • 리포지토리를 호출하여 삭제 메서드 작성
    • 삭제 후 게시글 리스트 갱신 필요
        1. 다시 통신 (init)
        1. 깊은 복사 (stream) 를 활용
import 'package:flutter/material.dart'; import 'package:flutter_blog/data/model/post.dart'; import 'package:flutter_blog/data/repository/post_repository.dart'; import 'package:flutter_blog/main.dart'; import 'package:flutter_blog/ui/pages/post/list_page/post_list_vm.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; // AutoDispose 설정 final postDetailProvider = AutoDisposeNotifierProvider.family<PostDetailVM, PostDetailModel?, int>(() { return PostDetailVM(); }); // TODO 3 : init 완성하기 (state 갱신) // AutoDispose : 화면 삭제하면 VM도 같이 날라감 class PostDetailVM extends AutoDisposeFamilyNotifier<PostDetailModel?, int> { final mContext = navigatorKey.currentContext!; @override PostDetailModel? build(int postId) { // 1. 상태 초기화 init(postId); // 2. VM 파괴되는지 확인하는 이벤트 ref.onDispose(() { Logger().d("PostDetailModel 파괴됨"); }); // 3. 상태 값 세팅 return null; } // 게시글 삭제 Future<void> deleteOne(int postId) async { Map<String, dynamic> body = await PostRepository().deleteOne(postId); if (!body["success"]) { ScaffoldMessenger.of(mContext!).showSnackBar( SnackBar(content: Text("게시글 삭제하기 실패 : ${body["errorMessage"]}")), ); return; } // 데이터 동기화 방법 1. init (다시 통신) //init(postId); // 데이터 동기화 방법 2. notifyDeleteOne ref.read(postListProvider.notifier).notifyDeleteOne(postId); // 화면 날리기 Navigator.pop(mContext); } Future<void> init(int postId) async { Map<String, dynamic> body = await PostRepository().getOne(postId); if (!body["success"]) { ScaffoldMessenger.of(mContext!).showSnackBar( SnackBar(content: Text("게시글 상세보기 실패 : ${body["errorMessage"]}")), ); return; } state = PostDetailModel.fromMap(body["response"]); } } // TODO 2 : replies 빼고 상태로 관리하기 class PostDetailModel { Post post; PostDetailModel(this.post); PostDetailModel.fromMap(Map<String, dynamic> data) : post = Post.fromMap(data); PostDetailModel copyWith({ Post? post, }) { return PostDetailModel(post ?? this.post); } @override String toString() { return 'PostDetailModel(post: $post)'; } }
 
 

Write

  • API 문서를 참고하여 Request, Response 구조를 파악
    • Response의 body[”response”]는 post 구조와 동일
notion image
 
  • PostRepository : 게시글 저장 요청 함수 작성
    • request를 Map으로 만들어 전송
import 'package:dio/dio.dart'; import 'package:flutter_blog/_core/utils/my_http.dart'; import 'package:logger/logger.dart'; class PostRepository { Future<Map<String, dynamic>> write(String title, String content) async { // 1. Map 변환 final requestBody = { "title": title, "content": content, }; // 2. 통신 Response response = await dio.post("/api/post", data: requestBody); Map<String, dynamic> responseBody = response.data; return responseBody; } }
 
  • PostListVM : FM에서 직접 리포지토리에 접근해도 되지만, 일관성을 위해 VM에서 작성
    • 상위 VM은 해당 화면과 가장 가까운 VM을 선택하면 됨
  • Post의 리스트 갱신은 전개 연산자를 이용한 깊은 복사 활용
    • 새 게시글이 앞에 와야 하므로 post 저장 후 state의 post 전개
import 'package:flutter/material.dart'; import 'package:flutter_blog/data/model/post.dart'; import 'package:flutter_blog/data/repository/post_repository.dart'; import 'package:flutter_blog/main.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; /// 1. 창고 관리자 final postListProvider = NotifierProvider<PostListVM, PostListModel?>(() { return PostListVM(); }); /// 2. 창고 (상태가 변경되어도, 화면 갱신 안함 - watch 하지마) class PostListVM extends Notifier<PostListModel?> { final mContext = navigatorKey.currentContext!; @override PostListModel? build() { init(); return null; } Future<void> write(String title, String content) async { Logger().d("title : ${title}, content : ${content}"); // 1. repository 함수 호출 Map<String, dynamic> body = await PostRepository().write(title, content); // 2. 성공 여부 확인 if (!body["success"]) { ScaffoldMessenger.of(mContext!).showSnackBar( SnackBar(content: Text("게시글 저장 실패 : ${body["errorMessage"]}")), ); return; } // 3. List 상태 갱신 Post post = Post.fromMap(body["response"]); List<Post> newPosts = [post, ...state!.posts]; state = state!.copyWith(posts: newPosts); // 4. 글쓰기 화면 POP Navigator.pop(mContext); } } /// 3. 창고 데이터 타입 (불변 아님) class PostListModel { bool isFirst; bool isLast; int pageNumber; int size; int totalPage; List<Post> posts; PostListModel(this.isFirst, this.isLast, this.pageNumber, this.size, this.totalPage, this.posts); 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}'; } }
 
  • PostWriteForm : 글 쓰기 Form
    • FM과 VM, Model을 전부 연결하여 사용
    • 해당 화면에서는 굳이 FM을 이용해서 상태를 변경할 필요는 없음
      • 변수를 직접 지정해서 VM에 전달해도 괜찮음
      • 비즈니스 및 컴포넌트 구조를 보고 결정
import 'package:flutter/material.dart'; import 'package:flutter_blog/_core/constants/size.dart'; import 'package:flutter_blog/ui/pages/post/list_page/post_list_vm.dart'; import 'package:flutter_blog/ui/pages/post/write_page/post_write_fm.dart'; import 'package:flutter_blog/ui/widgets/custom_elavated_button.dart'; import 'package:flutter_blog/ui/widgets/custom_text_area.dart'; import 'package:flutter_blog/ui/widgets/custom_text_form_field.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class PostWriteForm extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { PostWriteFM fm = ref.read(postWriteProvider.notifier); PostWriteModel model = ref.watch(postWriteProvider); PostListVM vm = ref.read(postListProvider.notifier); return Form( child: ListView( shrinkWrap: true, children: [ CustomTextFormField( hint: "Title", onChanged: (value) { fm.title(value); }, ), const SizedBox(height: smallGap), CustomTextArea( hint: "Content", onChanged: (value) { fm.content(value); }, ), const SizedBox(height: largeGap), CustomElevatedButton( text: "글쓰기", click: () { vm.write(model.title, model.content); }, ), ], ), ); } }
 

Result

  • 글을 작성하면 새롭게 리스트가 갱신됨
notion image

Update

  • API 문서를 참고하여 Request, Response 구조를 파악
    • Response의 body[”response”]는 post 구조와 동일
notion image
 
  • PostRepository : 게시글 수정 요청 함수 작성
    • postId, title, content가 모두 필요
Future<Map<String, dynamic>> update(int postId, String title, String content) async { // 1. Map 변환 final requestBody = { "title": title, "content": content, }; // 2. 통신 Response response = await dio.put("/api/post/${postId}", data: requestBody); Map<String, dynamic> responseBody = response.data; return responseBody; }
 
  • PostUpdateFM : 게시글 수정 FM
    • PostWriteFM과 동일한 구조
// fm : Form Model, 전송할 데이터를 담는 Riverpod import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; /// 1. 창고 관리자 final postUpdateProvider = NotifierProvider<PostUpdateFM, PostUpdateModel>(() { return PostUpdateFM(); }); /// 2. 창고 class PostUpdateFM extends Notifier<PostUpdateModel> { @override PostUpdateModel build() { return PostUpdateModel("", ""); } void title(String title) { Logger().d("title : ${title}"); state = state.copyWith( title: title, ); } void content(String content) { Logger().d("content : ${content}"); state = state.copyWith( content: content, ); } } /// 3. 창고 데이터 타입 class PostUpdateModel { final String title; final String content; PostUpdateModel( this.title, this.content, ); PostUpdateModel copyWith({ String? title, String? content, }) { return PostUpdateModel( title ?? this.title, content ?? this.content, ); } }
 
  • PostDetailVM : 게시글 수정 함수 작성
    • 상위 VM인 PostDetailVM에서 작성 : 컨벤션
    • 수정 사항을 상태 갱신을 통해 반영 : notifyUpdate
Future<void> update(int postId, String title, String content) async { Map<String, dynamic> body = await PostRepository().update(postId, title, content); if (!body["success"]) { ScaffoldMessenger.of(mContext!).showSnackBar( SnackBar(content: Text("게시글 수정 실패 : ${body["errorMessage"]}")), ); return; } Post nextPost = Post.fromMap(body["response"]); state = state!.copyWith(post: nextPost); ref.read(postListProvider.notifier).notifyUpdate(nextPost); Navigator.pop(mContext); }
 
  • PostListVM : notifyUpdate 작성
    • Detail 뿐만이 아닌 리스트에도 변경 사항 반영이 필요
    • PostListVM에서 관리
void notifyUpdate(Post post) { List<Post> nextPosts = state!.posts.map((p) { if (p.id == post.id) { return post; } else { return p; } }).toList(); state = state!.copyWith(posts: nextPosts); }
 
  • PostDetailButtons
    • 수정 버튼에 화면 이동 및 post 함께 전달 : 기존 값 반영 필요
import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_blog/data/gvm/session_gvm.dart'; import 'package:flutter_blog/data/model/post.dart'; import 'package:flutter_blog/ui/pages/post/detail_page/post_detail_vm.dart'; import 'package:flutter_blog/ui/pages/post/update_page/post_update_page.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class PostDetailButtons extends ConsumerWidget { Post post; PostDetailButtons(this.post); @override Widget build(BuildContext context, WidgetRef ref) { SessionModel sessionUser = ref.read(sessionProvider); // isOwner 값을 sessionGVM 내부 함수로 만들어서 호출하면 가독성 증가 bool isOwner = post.user.id == sessionUser.user!.id; if (isOwner) { return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ IconButton( onPressed: () async { // DetailProvider는 FamilyNotifier -> 매개변수 전달 필요 ref.read(postDetailProvider(post.id).notifier).deleteOne(post.id); }, icon: const Icon(CupertinoIcons.delete), ), IconButton( onPressed: () { Navigator.push(context, MaterialPageRoute(builder: (_) => PostUpdatePage(post))); }, icon: const Icon(CupertinoIcons.pen), ), ], ); } else return Container(); } }
 
  • PostUpdatePage : 게시글 수정 페이지
    • Body에 post 객체 전달
import 'package:flutter/material.dart'; import 'package:flutter_blog/data/model/post.dart'; import 'package:flutter_blog/ui/pages/post/update_page/widgets/post_update_body.dart'; class PostUpdatePage extends StatelessWidget { Post post; PostUpdatePage(this.post); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: PostUpdateBody(post), ); } }
 
  • PostUpdateBody : 게시글 수정 body
    • Form에 post 객체 전달
      • 객체 dribbling이 많아질 경우, 따로 VM 제작 후 호출하는 것이 좋음
import 'package:flutter/material.dart'; import 'package:flutter_blog/data/model/post.dart'; import 'package:flutter_blog/ui/pages/post/update_page/widgets/post_update_form.dart'; class PostUpdateBody extends StatelessWidget { Post post; PostUpdateBody(this.post); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(16.0), child: PostUpdateForm(post), ); } }
 
  • PostUpdateForm : 게시글 수정 폼
    • post 객체 내부의 title, content를 기존 값으로 설정
    • PostDetailVM, PostUpdateFM 전부 연결
      • FM : 입력 값 제어
      • VM : 통신 및 데이터 처리
import 'package:flutter/material.dart'; import 'package:flutter_blog/_core/constants/size.dart'; import 'package:flutter_blog/data/model/post.dart'; import 'package:flutter_blog/ui/pages/post/detail_page/post_detail_vm.dart'; import 'package:flutter_blog/ui/pages/post/update_page/post_update_fm.dart'; import 'package:flutter_blog/ui/widgets/custom_elavated_button.dart'; import 'package:flutter_blog/ui/widgets/custom_text_area.dart'; import 'package:flutter_blog/ui/widgets/custom_text_form_field.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class PostUpdateForm extends ConsumerWidget { Post post; PostUpdateForm(this.post); @override Widget build(BuildContext context, WidgetRef ref) { PostUpdateFM fm = ref.read(postUpdateProvider.notifier); PostUpdateModel model = ref.watch(postUpdateProvider); PostDetailVM vm = ref.read(postDetailProvider(post.id).notifier); return Form( child: ListView( children: [ CustomTextFormField( hint: "Title", initialValue: post.title, onChanged: (value) { fm.title(value); }, ), const SizedBox(height: smallGap), CustomTextArea( hint: "Content", initialValue: post.content, onChanged: (value) { fm.content(value); }, ), const SizedBox(height: largeGap), CustomElevatedButton( text: "글 수정하기", click: () { vm.update(post.id, model.title, model.content); }, ), ], ), ); } }
 

Result

  • 글 수정 화면
notion image
 
  • 글 수정 후 List 및 Detail에 반영된 화면
notion image
notion image

Auto-Login

  • API 문서를 참고하여 Request, Response 구조를 파악
    • Post 요청이나 body가 없음, 로그인 기능이므로 토큰 전달 필요
notion image
 
  • SplashPage : 실제 앱 진입 전 표시하는 페이지
    • 그림이 표시되는 동안 로그인 인증 진행 : SessionGVM 호출
import 'package:flutter/material.dart'; import 'package:flutter_blog/data/gvm/session_gvm.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class SplashPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { ref.read(sessionProvider.notifier).autoLogin(); return Scaffold( body: Center( child: Image.asset( 'assets/splash.gif', width: double.infinity, height: double.infinity, fit: BoxFit.cover, ), ), ); } }
 
  • SessionGVM : 자동 로그인 함수 구현
    • 디바이스에 저장되어 있는 토큰을 읽어서 로그인 요청
    • 토큰이 없거나 통신이 실패할 경우 로그인 페이지로 이동
    • 이후 데이터 파싱 및 처리는 로그인과 동일하나, 약간의 차이점 존재
      • 디바이스 토큰을 통해 로그인을 진행한 것이므로 디바이스에 토큰 저장 불필요
      • 응답받은 user에는 accessToken 정보가 없음 : 디바이스 토큰을 삽입해야 함
Future<void> autoLogin() async { // 통신해도 null일 수 있음 String? accessToken = await secureStorage.read(key: "accessToken"); if (accessToken == null) { Navigator.pushNamed(mContext, "/login"); // splash 페이지 넘어감 return; } Map<String, dynamic> body = await UserRepository().autoLogin(accessToken); if (!body["success"]) { ScaffoldMessenger.of(mContext).showSnackBar( SnackBar(content: Text("${body["errorMessage"]}")), ); Navigator.pushNamed(mContext, "/login"); return; } // 3. 파싱 User user = User.fromMap(body["response"]); user.accessToken = accessToken; // 디바이스 토큰 주입 // 4. 세션모델 갱신 state = SessionModel(user: user, isLogin: true); // 5. dio의 header에 토큰 세팅 (Bearer 포함) dio.options.headers["Authorization"] = user.accessToken; // 6. 게시글 목록 페이지 이동 Navigator.pushNamed(mContext, "/post/list"); }
 
  • UserRepository : 자동 로그인 요청 함수 작성
    • body 대신 header에 토큰을 넣어야 함
      • 실제 로그인 전이기 때문에 dio에 토큰을 삽입할 수 없음
      • 직접 Option에서 설정 필요
Future<Map<String, dynamic>> autoLogin(String accessToken) async { Response response = await dio.post("/auto/login", options: Options( headers: {"Authorization": accessToken}, )); Map<String, dynamic> responseBody = response.data; return responseBody; }
 

Result

  • 로그인을 이전에 한 적이 없으면 로그인 화면으로 이동
notion image
 
  • 로그인을 한 이후 앱을 재실행 (같은 포트 재접속) 시 로그인 생략 후 게시글 리스트로 이동
notion image
 

Refresh & Paging

  • Web에서 테스트 불가 : 새로고침 및 로딩을 위한 드래그 액션은 모바일에만 존재
    • Web에서는 직접 새로고침할 수 있도록 action 추가 구현이 필요
  • Web에서 F12 → 맨 왼쪽 디바이스 버튼 클릭 시 모바일로 전환
notion image
 
  • PostListBody : 새로고침 및 페이징 함수 추가
    • pull_to_refresh 라이브러리 추가
    • 새로 고침 및 로딩 이벤트 부여
      • 새로 고침 : init() 호출
      • 로딩 : 게시글 리스트의 다음 페이지 호출
import 'package:flutter/material.dart'; import 'package:flutter_blog/ui/pages/post/detail_page/post_detail_page.dart'; import 'package:flutter_blog/ui/pages/post/list_page/post_list_vm.dart'; import 'package:flutter_blog/ui/pages/post/list_page/widgets/post_list_item.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pull_to_refresh/pull_to_refresh.dart'; class PostListBody extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { PostListModel? model = ref.watch(postListProvider); PostListVM vm = ref.read(postListProvider.notifier); if (model == null) { return Center(child: CircularProgressIndicator()); } else { return SmartRefresher( controller: vm.refreshCtrl, enablePullUp: true, onRefresh: () { vm.init(); }, enablePullDown: true, onLoading: () { vm.nextList(); }, child: ListView.separated( itemCount: model.posts.length, itemBuilder: (context, index) { return InkWell( onTap: () { // push -> 주소가 아닌 페이지 자체를 이동 Navigator.push(context, MaterialPageRoute(builder: (context) => PostDetailPage(model.posts[index].id))); }, child: PostListItem(model.posts[index]), ); }, separatorBuilder: (context, index) { return const Divider(); }, ), ); } } }
 
  • PostListVM : init() 리팩토링 및 다음 페이지 호출 함수 작성
    • 컨트롤러 연결을 위해 창고 내부에 refreshController 생성
      • init 호출 후 새로 고침 완료 호출
    • 로그아웃 시 메모리 내부 값 초기화를 위한 AutoDispose 선언
    • 로딩 시 이전에 받아온 post도 남아있어야 하므로 state를 copyWith를 이용해 새로 갱신해야 함
      • prevModel을 저장하고, nextModel을 통신을 통해 받아온 후 새로운 List로 작성
      • 새 List를 state에 저장 후 로딩 완료 호출
import 'package:flutter/material.dart'; import 'package:flutter_blog/data/model/post.dart'; import 'package:flutter_blog/data/repository/post_repository.dart'; import 'package:flutter_blog/main.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; import 'package:pull_to_refresh/pull_to_refresh.dart'; /// 1. 창고 관리자 final postListProvider = AutoDisposeNotifierProvider<PostListVM, PostListModel?>(() { return PostListVM(); }); /// 2. 창고 (상태가 변경되어도, 화면 갱신 안함 - watch 하지마) class PostListVM extends AutoDisposeNotifier<PostListModel?> { final mContext = navigatorKey.currentContext!; final refreshCtrl = RefreshController(); @override PostListModel? build() { init(); ref.onDispose(() { refreshCtrl.dispose(); Logger().d("PostListVM 파괴됨"); }); return null; } Future<void> write(String title, String content) async { // 1. 레포지토리에 함수 호출 Map<String, dynamic> body = await PostRepository().write(title, content); if (!body["success"]) { ScaffoldMessenger.of(mContext!).showSnackBar( SnackBar(content: Text("게시글 쓰기 실패 : ${body["errorMessage"]}")), ); return; } // 2. 파싱 Post post = Post.fromMap(body["response"]); // 3. List 상태 갱신 List<Post> nextPosts = [post, ...state!.posts]; state = state!.copyWith(posts: nextPosts); // 4. 글쓰기 화면 PoP Navigator.pop(mContext); } void notifyDeleteOne(int postId) { PostListModel model = state!; model.posts = model.posts.where((p) => p.id != postId).toList(); state = state!.copyWith(posts: model.posts); } Future<void> init({int page = 0}) async { Map<String, dynamic> body = await PostRepository().getList(page: page); if (!body["success"]) { ScaffoldMessenger.of(mContext!).showSnackBar( SnackBar(content: Text("게시글 목록보기 실패 : ${body["errorMessage"]}")), ); return; } state = PostListModel.fromMap(body["response"]); refreshCtrl.refreshCompleted(); } void notifyUpdate(Post post) { List<Post> nextPosts = state!.posts.map((p) { if (p.id == post.id) { return post; } else { return p; } }).toList(); state = state!.copyWith(posts: nextPosts); } Future<void> nextList() async { PostListModel prevModel = state!; if (prevModel.isLast) { await Future.delayed(Duration(milliseconds: 500)); refreshCtrl.loadComplete(); return; } Map<String, dynamic> body = await PostRepository().getList(page: prevModel.pageNumber + 1); if (!body["success"]) { ScaffoldMessenger.of(mContext!).showSnackBar( SnackBar(content: Text("게시글 로드 실패 : ${body["errorMessage"]}")), ); refreshCtrl.loadComplete(); return; } PostListModel nextModel = PostListModel.fromMap(body["response"]); state = nextModel.copyWith(posts: [...prevModel.posts, ...nextModel.posts]); refreshCtrl.loadComplete(); } } /// 3. 창고 데이터 타입 (불변 아님) class PostListModel { bool isFirst; bool isLast; int pageNumber; int size; int totalPage; List<Post> posts; PostListModel(this.isFirst, this.isLast, this.pageNumber, this.size, this.totalPage, this.posts); 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}'; } }
 

Result

  • 최상단 리스트를 아래로 끌어내리면 새로 고침 동작
notion image
 
  • 리스트의 맨 아래쪽으로 드래그 후 아래쪽으로 드래그 시 새 페이지 호출
notion image
 

Riverpod vs Variable

  • 변수를 직접 지정하면 편리하게 사용이 가능하다는 장점
    • 당장 사용은 간편하지만, 변수를 계속 dribble해야 한다는 단점이 존재
    • 컴포넌트가 복잡해지면 변수 파악이 어려움
  • Riverpod은 변수 지정보다는 불편한 점이 존재하지만, 상태 관리에 있어서 더욱 유리
    • VM/FM이라는 별개의 창고에서 관련된 모든 상태값을 관리 가능
    • 컴포넌트가 다중으로 설계되어도 값이 필요한 곳에서 VM을 호출하기만 하면 됨
Share article

sxias