[Flutter] flutter_blog

만들면서 배우는 플러터 앱 프로그래밍
최재원's avatar
Jul 30, 2025
[Flutter] flutter_blog
Contents
1. 서버 실행구조2. join 페이지1. Form 부분 분리2. FormModel 생성 join_fm3. fm 을 Form 에 연결4. 뷰 모델 & repository3. login 페이지1. Form 부분 분리2. Form model 생성 login_fm3. fm 을 Form 에 연결4. 뷰 모델 & repository4. model (User)1. session_gvm 에서 공통으로 사용되는 user 부분을 따로 분리5. logout 기능1. login 반대로 하기6. 진행 post list page1. 통신을 1순위로 테스트2. model (Post) 공통 모델이 필요할 것 같다3. 뷰 모델을 만들자4. 통신 후 데이터를 갱신하자5. 페이지 화면에 데이터를 바인딩 하자7. 진행 post detail page1. 리스트 페이지에서 클릭하면 상세페이지로 넘어간다2. 상세페이지는 id 가 필요하다 통신할 때 id 를 받을 수 있는 뷰 모델을 만들어 사용하자3. 통신 코드 작성4. 뷰 모델에 데이터 주입5. 뷰에 모델 데이터 렌더링8. 게시글 삭제1. 통신 코드 작성2. 뷰 모델에 삭제 메서드 추가3. 뷰에 메서드 연결9. 게시글 등록1. 뷰 확인2. FM 생성3. fm ↔ 뷰 연결4. 통신 코드 작성5. 통신 및 상태 변경6. 뷰 에서 write 메서드 호출10. 게시글 수정1. 게시글 상세페이지에서 데이터 넘겨주기2. 변경된 값 저장3. 통신 코드 작성4. 뷰 모델에서 통신 코드 사용5. 게시글 목록 상태 갱신6. 뷰에서 메서드 호출11. 자동 로그인1. 스플레시 페이지 이동2. 통신 코드 작성3. 세션에서 자동 로그인 메서드 작성12. 새로고침 & 추가요청1. 리스트 페이지에서 새로고침과 추가 요청을 할 수 있는 위젯 추가2. 뷰 모델에서 새로고침 및 추가요청 로직 작성13. 뷰와 뷰모델은 생명주기를 같이 하자1. 로그아웃 하면 리스트 뷰 모델을 날려야 한다

프로젝트 코드

💡
notion image
각각의 화면마다 view model 이 있다
form 과 같이 데이터를 받아서 화면에 뿌리지 않는다면 view model 은 단순히 데이터를 모으는 목적으로 사용한다
화면에 데이터를 뿌려야 한다면 view model 은 데이터를 요청하고 그 데이터를 뿌리고 관리하는 목적으로 사용한다
각 화면과 관계없이 글로벌하게 관리해야 하는 데이터는 global view model 을 만들어 관리한다
여기에는 user 데이터 같이 대부분의 화면에서 공통적으로 사용해야 할 데이터를 관리한다
이 global view model 은 구독이 없다. 데이터가 변경된다고 해서 특정 화면이 다시 그려지는 로직이 없다

1. 서버 실행

마우스 우클릭 해서 열면 바로 열 수 있다
notion image
notion image
  • jar 파일 실행
notion image
  • 스프링 서버 동작

구조

  • pages 와 공통 widgets 를 따로 둔다
  • pages 내부는 도메인 별로 묶는다
  • 각 도메인 내부는 page 기준으로 나눈다
notion image
notion image
notion image

2. join 페이지

notion image
  • 무조건 body는 따로 빼서 작성한다
  • Scaffold가 있는 위젯을 다시 빌드하는 건 낭비다

1. Form 부분 분리

notion image
notion image
notion image
notion image

1. Form 과 Column 을 사용해서 묶는다 JoinForm

notion image

2. FormModel 생성 join_fm

  • form의 입력값을 모아두고 유효성 검사를 하기 위한 목적
  • 오직 form 데이터를 관리하기 위한 뷰모델
notion image

1. join_fm

/// 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 ?? this.usernameError, emailError ?? this.emailError, passwordError ?? this.passwordError, ); } @override String toString() { return 'JoinModel{username: $username, email: $email, password: $password, usernameError: $usernameError, emailError: $emailError, passwordError: $passwordError}'; } }
  • 일관성을 위해서 딱 정해진 방식으로 만들어야 한다
  • 에러 메시지를 ? 를 사용해 null 이 포함되게 만들었다 → “” 공백 문자로 무조건 받는 값으로 변경
  • 에러 메시지와 데이터를 무조건 받는 것으로 통일

3. fm 을 Form 에 연결

notion image

1. join_form

class JoinForm extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { JoinFM fm = ref.read(joinProvider.notifier); JoinModel model = ref.watch(joinProvider); print("창고 state : ${model}"); return Form( child: Column( children: [ CustomAuthTextFormField( title: "Username", errorText: model.usernameError, onChanged: (value) { fm.username(value); }, ), const SizedBox(height: mediumGap), CustomAuthTextFormField( title: "Email", errorText: model.emailError, onChanged: (value) { fm.email(value); }, ), const SizedBox(height: mediumGap), CustomAuthTextFormField( title: "Password", errorText: model.passwordError, obscureText: true, onChanged: (value) { fm.password(value); }, ), const SizedBox(height: largeGap), CustomElevatedButton( text: "회원가입", click: () { if (fm.validate()) { Navigator.pushNamed(context, "/login"); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text("유효성 검사 실패입니다")), ); } }, ), CustomTextButton( text: "로그인 페이지로 이동", click: () { Navigator.pushNamed(context, "/login"); }, ), ], ), ); } }
  • ref 를 사용해 창고, 데이터에 접근한다
  • 나중에 파란 부분은 뷰 모델에서 처리한다

2. custom_auth_text_form_field

class CustomAuthTextFormField extends StatelessWidget { final String title; final String errorText; final Function(String)? onChanged; final bool obscureText; const CustomAuthTextFormField({ required this.title, this.errorText = "", this.onChanged, this.obscureText = false, }); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title), const SizedBox(height: smallGap), TextFormField( obscureText: obscureText, onChanged: onChanged, decoration: InputDecoration( hintText: "Enter $title", errorText: errorText.isEmpty ? null : errorText, enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(20), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(20), ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(20), ), focusedErrorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(20), ), ), ), ], ); } }
  • 폼 필드로 따로 만들어 둬서 공유해서 사용한다
  • 에러 메시지가 “” 로 들어올 경우 null 로 받아서 에러 메시지 공간을 표현하지 않는다

4. 뷰 모델 & repository

notion image
 
notion image
  • gvm 을 service 라고 생각하고 사용하자

1. session_gvm 생성

/// global view model /// 1. 창고 관리자 final sessionProvider = NotifierProvider<SessionGVM, SessionModel>(() { return SessionGVM(); }); /// 2. 창고 (상태가 변경되어도 화면에 알려주지 않음) watch x class SessionGVM extends Notifier<SessionModel> { @override SessionModel build() { return SessionModel(); } /// 트랜잭션 Future<void> join(String username, String email, String password) async { } Future<void> login(String username, String password) async { } Future<void> logout() async {} } /// 3. 창고 데이터 타입 class SessionModel { int? id; String? username; String? imgUrl; String? accessToken; bool? isLogin; SessionModel({this.id, this.username, this.imgUrl, this.accessToken, this.isLogin = false}); }
  • GVM → Global View Model 전역적으로 사용할 데이터를 관리하는 모델
  • 여기선 user 데이터를 관리한다

2. repository 작성

/// 책임 -> 통신 & 파싱(body data) class UserRepository { Future<Map<String, dynamic>> join(String username, String email, String password) async { } Future<Map<String, dynamic>> login(String username, String password) async { } }
  • 서버에서 데이터를 받아 올 join & login 메서드를 만든다
/// 책임 -> 통신 & 파싱(body data) class UserRepository { Future<Map<String, dynamic>> join(String username, String email, String password) async { // 1. Map 변환 final requestBody = { "username": username, "email": email, "password": password, }; // 2. 통신 Response response = await dio.post("/join", data: requestBody); Map<String, dynamic> responseBody = response.data; Logger().d(responseBody); return responseBody; } ... }
  • 받아온 데이터를 다트의 Map 으로 변환 한다
  • dio 에 Map을 넣으면 자동으로 json 으로 통신해준다
  • response 에는 헤더와 바디가 있다
  • 바디에서 데이터를 꺼내서 리턴한다
  • repository 를 const 로 만들거나 싱글톤으로 만들자!!

3. session_gvm 에서 join 로직 작성

class SessionGVM extends Notifier<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"); } ... }
  • view 에서 이 뷰 모델의 join() 을 호출하면 다음과 같은 로직이 동작한다
      1. join_fm 에 있는 데이터를 검증한다
      1. repository에 있는 join 메서드를 호출한다
      1. body 에 있는 성공값으로 비교
        1. 성공 → 페이지 이동
          1. 페이지 이동에 필요한 context를 가져오는 방법
          2. /// 2. 창고 (상태가 변경되어도, 화면 갱신 안함 - watch 하지마) class SessionGVM extends Notifier<SessionModel> { final mContext = navigatorKey.currentContext!; @override SessionModel build() { return SessionModel(); } ... }
            • mContext → 모든 컨텍스트의 맨 마지막 컨텍스트를 가져올 수 있다(현재 화면)
            main.dart 파일에 다음 빨간줄 추가
            // TODO: 1. Stack의 가장 위 context를 알고 있다. [지금 몰라도 됨] 맨 위에 있는 화면의 context를 찾을 수 있다. -> 뒤로가기 알림창 띄울때 사용 GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); void main() { runApp(const ProviderScope(child: MyApp())); } ...
        2. 실패 → 에러 메시지 띄우기
  • 모든 로직은 여기서 작성한다 view 도 아니고 repo 도 아니다

4. join_form 에서 모델의 join() 메서드 호출하기

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: [ ... 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"); }, ), ], ), ); } }
  • join_fm 의 데이터를 가져와서 join() 메서드를 호출한다
notion image
  • 무조건 body는 따로 빼서 작성한다
  • Scaffold가 있는 위젯을 다시 빌드하는 건 낭비다

1. Form 부분 분리

notion image
notion image
notion image
notion image

1. Form 과 Column 을 사용해서 묶는다 LoginForm

notion image

2. Form model 생성 login_fm

notion image

1. login_fm

/// 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 ?? this.usernameError, passwordError ?? this.passwordError, ); } @override String toString() { return 'LoginModel{username: $username, password: $password, usernameError: $usernameError, passwordError: $passwordError}'; } }
  • 검증 같은 로직은 여기서 작성한다

3. fm 을 Form 에 연결

notion image

1. login_form

class LoginForm extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { LoginFM fm = ref.read(loginProvider.notifier); LoginModel model = ref.watch(loginProvider); return Form( child: Column( children: [ CustomAuthTextFormField( title: "Username", errorText: model.usernameError, onChanged: (value) { fm.username(value); }, ), const SizedBox(height: mediumGap), CustomAuthTextFormField( title: "Password", errorText: model.passwordError, obscureText: true, onChanged: (value) { fm.password(value); }, ), ... ], )); } }

4. 뷰 모델 & repository

notion image
 
notion image
  • gvm 을 service 라고 생각하고 사용하자

1. repository 작성

notion image
/// 책임 -> 통신 & 파싱(body data) class UserRepository { ... 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; // 3. 헤더에서 토큰을 꺼내야 함. 헤더에 토큰이 들어있음 // 이제 body 안에 토큰을 넣어주기 때문에 헤더에 꺼낼 필요 없어짐 // String accessToken = ""; // try { // accessToken = response.headers["Authorization"]![0]; // responseBody["response"]["accessToken"] = accessToken; // } catch (e) {} Logger().d(responseBody); return responseBody; } }
  • 받아온 데이터를 다트의 Map 으로 변환 한다
  • dio 에 Map을 넣으면 자동으로 json 으로 통신해준다
  • response 에는 헤더와 바디가 있다
  • 바디에서 데이터를 꺼내서 리턴한다
  • repository 를 const 로 만들거나 싱글톤으로 만들자!!
  • 만약 토큰이 header 에 있다면
    • 헤더에서 토큰을 꺼내서 responseBody 맵에 추가해야 한다
  • 이제 바디에 토큰을 받아서 온다 그냥 쓰자

3. session_gvm 에서 login 로직 작성

class SessionGVM extends Notifier<SessionModel> { ... 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); // 5. dio의 header에 토큰 세팅 dio.options.headers["Authorization"] = user.accessToken; // 6. 게시글 목록 페이지 이동 Navigator.pushNamed(mContext, "/post/list"); } ... }
  • view 에서 이 뷰 모델의 join() 을 호출하면 다음과 같은 로직이 동작한다
      1. login_fm 에 있는 데이터를 검증한다
      1. repository에 있는 login 메서드를 호출한다
      1. body 에 있는 성공값으로 비교
        1. 성공 → 페이지 이동
          1. 페이지 이동에 필요한 context를 가져오는 방법
          2. /// 2. 창고 (상태가 변경되어도, 화면 갱신 안함 - watch 하지마) class SessionGVM extends Notifier<SessionModel> { final mContext = navigatorKey.currentContext!; @override SessionModel build() { return SessionModel(); } ... }
            • mContext → 모든 컨텍스트의 맨 마지막 컨텍스트를 가져올 수 있다(현재 화면)
            main.dart 파일에 다음 빨간줄 추가
            // TODO: 1. Stack의 가장 위 context를 알고 있다. [지금 몰라도 됨] 맨 위에 있는 화면의 context를 찾을 수 있다. -> 뒤로가기 알림창 띄울때 사용 GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); void main() { runApp(const ProviderScope(child: MyApp())); } ...
        2. 실패 → 에러 메시지 띄우기
  • 모든 로직은 여기서 작성한다 view 도 아니고 repo 도 아니다

4. login_form 에서 모델의 login() 메서드 호출하기

class LoginForm extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { LoginFM fm = ref.read(loginProvider.notifier); LoginModel model = ref.watch(loginProvider); return Form( child: Column( children: [ ... const SizedBox(height: largeGap), CustomElevatedButton( text: "로그인", click: () { ref.read(sessionProvider.notifier).login(model.username, model.password); }, ), CustomTextButton( text: "회원가입 페이지로 이동", click: () { Navigator.pushNamed(context, "/join"); }, ), ], )); } }
  • login_fm 창고의 데이터를 가져와서 login() 메서드를 호출한다

4. model (User)

  • 세션에서 관리할 유저 데이터를 모델로 따로 분리한다
notion image

1. session_gvm 에서 공통으로 사용되는 user 부분을 따로 분리

1. user

class User { int id; String username; String imgUrl; String? accessToken; User({ required this.id, required this.username, required this.imgUrl, this.accessToken, }); User.fromMap(Map<String, dynamic> data) : id = data['id'], username = data['username'], imgUrl = data['imgUrl'], accessToken = data['accessToken']; @override String toString() { return 'User(id: $id, username: $username, imgUrl: $imgUrl, accessToken: $accessToken)'; } }

2. session_gvm

/// 3. 창고 데이터 타입 (불변 아님) class SessionModel { User? user; bool? isLogin; SessionModel({this.user, this.isLogin = false}); @override String toString() { return 'SessionModel{user: $user, isLogin: $isLogin}'; } }

5. logout 기능

1. session_gvm

/// 2. 창고 (상태가 변경되어도, 화면 갱신 안함 - watch 하지마) class SessionGVM extends Notifier<SessionModel> { final mContext = navigatorKey.currentContext!; @override SessionModel build() { return SessionModel(); } ... Future<void> logout() async { // 1. 토큰 디바이스 제거 await secureStorage.delete(key: "accessToken"); // 2. 세션모델 초기화 state = SessionModel(); // 3. dio 세팅 제거 dio.options.headers.remove("Authorization"); // 4. login 페이지 이동 scaffoldKey.currentState!.openEndDrawer(); Navigator.pushNamed(mContext, "/login"); } }
  • scaffoldKey → PostListPage 에서 생성한 키를 가져와서 사용함
    • 나중에 해당 키가 여러 개가 생기면 식별 가능하게 이름을 바꿔야 함
  1. 세션모델 초기화
      • 지금 기본생성자로 덮어씌우는 방법을 썻지만 나중엔 식별하기 쉽게 static 메서드를 사용해 처리한다
      • SessionModel.clear() 같은 메서드를 만들어 기본생성자를 return 하게 만든다
      • 그리고 기본생성자를 외부에서 사용하지 못하게 막는다
PostListPage
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( title: Text("Blog ${model.isLogin} ${model.user!.username}"), ), body: RefreshIndicator( key: refreshKey, onRefresh: () async {}, child: PostListBody(), ), ); } }

2. 로그아웃 버튼에 적용

class CustomNavigation extends ConsumerWidget { final scaffoldKey; const CustomNavigation(this.scaffoldKey, {Key? key}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { SessionGVM gvm = ref.read(sessionProvider.notifier); return Container( width: getDrawerWidth(context), height: double.infinity, color: Colors.white, child: SafeArea( child: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextButton( onPressed: () { scaffoldKey.currentState!.openEndDrawer(); Navigator.pushNamed(context, "/post/write"); }, child: const Text( "글쓰기", style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black54, ), ), ), const Divider(), TextButton( onPressed: () { gvm.logout(); }, child: const Text( "로그아웃", style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black54, ), ), ), const Divider(), ], ), ), ), ); } }

6. 진행 post list page

1. 통신을 1순위로 테스트

notion image
notion image

postRepository

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; } }

postRepotitoryTest

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 repo = PostRepository(); await repo.getList(); }
notion image

2. model (Post) 공통 모델이 필요할 것 같다

post

import 'package:flutter_blog/data/model/user.dart'; class Post { int id; String title; String content; DateTime createdAt; DateTime updatedAt; 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']); }

3. 뷰 모델을 만들자

  • 뷰 모델은 page 옆에 만든다
notion image

post_list_vm

import 'package:flutter_blog/data/model/post.dart'; import 'package:flutter_blog/main.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; /// 1. 창고 관리자 final postListProvider = NotifierProvider<PostListVM, PostListModel?>(() { return PostListVM(); }); /// 2. 창고 class PostListVM extends Notifier<PostListModel?> { final mContext = navigatorKey.currentContext!; @override PostListModel? build() { init(); return null; } Future<void> init({int page = 0}) async {} } /// 3. 창고 데이터 타입 class PostListModel { final bool isFirst; final bool isLast; final int pageNumber; final int size; final int totalPage; final List<Post> posts; PostListModel({ required this.isFirst, required this.isLast, required this.pageNumber, required this.size, required this.totalPage, required this.posts, }); @override String toString() { return 'PostListModel{isFirst: $isFirst, isLast: $isLast, pageNumber: $pageNumber, size: $size, totalPage: $totalPage, posts: $posts}'; } }
약속
  1. 창고 이름은 → 페이지 이름 + VM
  1. 데이터 타입 이름은 → 페이지 이름 + Model
  1. 초기화는 항상 null

4. 통신 후 데이터를 갱신하자

post_list_vm

/// 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; // 일단 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 { final bool isFirst; final bool isLast; final int pageNumber; final int size; final int totalPage; final List<Post> posts; PostListModel({ required this.isFirst, required this.isLast, required this.pageNumber, required this.size, required this.totalPage, required 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: isFirst ?? this.isFirst, isLast: isLast ?? this.isLast, pageNumber: pageNumber ?? this.pageNumber, size: size ?? this.size, totalPage: totalPage ?? this.totalPage, posts: posts ?? this.posts, ); } @override String toString() { return 'PostListModel{isFirst: $isFirst, isLast: $isLast, pageNumber: $pageNumber, size: $size, totalPage: $totalPage, posts: $posts}'; } }
  • init() 으로 통신을 하고 fromMap() 을 사용해서 데이터를 갱신한다

5. 페이지 화면에 데이터를 바인딩 하자

PostListBody

notion image
notion image
class PostListBody extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { PostListModel? model = ref.watch(postListProvider); 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(); }, ); } } }
  • 리스트 바디에서 모델 데이터를 가져와서 출력함
    • null = 로딩 화면 출력
    • null ≠ 리스트 출력

PostListItem

notion image
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: SizedBox( width: 50, height: 50, child: ClipOval( child: CachedNetworkImage( imageUrl: "${baseUrl}${post.user.imgUrl}", placeholder: (context, url) => CircularProgressIndicator(), errorWidget: (context, url, error) => Icon(Icons.error), fit: BoxFit.cover, ), ), ), ); } }
  • CachedNetworkImage → 캐싱과 로딩을 지원하는 이미지 위젯, 외부라이브러리다
    • dependencies: cached_network_image: ^3.4.1

7. 진행 post detail page

notion image

1. 리스트 페이지에서 클릭하면 상세페이지로 넘어간다

1. PostListBody

notion image
class PostListBody extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { PostListModel? model = ref.watch(postListProvider); 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(model.posts[index].id))); }, child: PostListItem(model.posts[index]), ); }, separatorBuilder: (context, index) { return const Divider(); }, ); } } }
  • id 를 상세페이지로 넘긴다

2. PostDetailPage

notion image
notion image
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), ); } }
  • body 에 id 를 전달 한다

3. PostDetailBody

notion image
class PostDetailBody extends StatelessWidget { int postId; PostDetailBody(this.postId); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(16.0), child: ListView( children: [ PostDetailTitle("제목"), const SizedBox(height: largeGap), PostDetailProfile(), PostDetailButtons(), const Divider(), const SizedBox(height: largeGap), PostDetailContent("내용"), ], ), ); } }
  • 여기서 받아온 id 로 데이터를 요청해야 한다

2. 상세페이지는 id 가 필요하다 통신할 때 id 를 받을 수 있는 뷰 모델을 만들어 사용하자

1. post_detail_vm (FamilyNotifier)

notion image
import 'package:flutter_blog/main.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; /// 1. 창고 관리자 /// NotifierProvider.family 의 세번째 값으로 매개변수 타입을 작성 final postDetailProvider = NotifierProvider.family<PostDetailVM, PostDetailModel?, int>(() { return PostDetailVM(); }); /// 2. 창고 /// FamilyNotifier 를 사용해서 매개변수를 받을 수 있다. 제네릭에 2번째 값으로 매개변수 타입을 작성 /// TODO 3 : init 완성하기 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 {} } /// 3. 창고 데이터 타입 /// TODO 2 : replies 빼고 상태 관리 class PostDetailModel {}
  • FamilyNotifier → 직접 사용하는 클래스는 아니고, NotifierProvider.family가 만들어내는 내부 구현체에서 사용됨 (개념적으로 이해하면 충분)
  • NotifierProvider.family → NotifierProvider에 파라미터를 전달할 수 있게 해주는 기능

3. 통신 코드 작성

notion image

1. PostRepository

import 'package:dio/dio.dart'; import 'package:flutter_blog/_core/utils/my_http.dart'; import 'package:logger/logger.dart'; class PostRepository { ... /// TODO 1 : getOne 만들기 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; } }

2. PostRepositoryTest

void main() async { dio.options.headers["Authorization"] = "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpbWdVcmwiOiIvaW1hZ2VzLzEucG5nIiwic3ViIjoibWV0YWNvZGluZyIsImlkIjoxLCJleHAiOjE3NDk2MDU1MTEsInVzZXJuYW1lIjoic3NhciJ9.ZK50ecQD8LEuS1jrucCw3sf-ETATjopDu3L7VPNfr42heoRC5T8g7vWpWX60ijItBPgy_1zNMj6U5dsVkZSt8Q"; PostRepository repo = PostRepository(); await repo.getOne(1); }

4. 뷰 모델에 데이터 주입

1. post_detail_vm

/// 1. 창고 관리자 /// NotifierProvider.family 의 세번째 값으로 매개변수 타입을 작성 final postDetailProvider = NotifierProvider.family<PostDetailVM, PostDetailModel?, int>(() { return PostDetailVM(); }); /// 2. 창고 /// FamilyNotifier 를 사용해서 매개변수를 받을 수 있다. 제네릭에 2번째 값으로 매개변수 타입을 작성 /// TODO 3 : init 완성하기 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); if (!body["success"]) { ScaffoldMessenger.of(mContext).showSnackBar( SnackBar(content: Text("게시글 상세보기 실패 : ${body["errorMessage"]}")), ); return; } state = PostDetailModel.fromMap(body['response']); } } /// 3. 창고 데이터 타입 /// TODO 2 : replies 빼고 상태 관리 class PostDetailModel { final Post post; PostDetailModel({required this.post}); PostDetailModel.fromMap(Map<String, dynamic> data) : post = Post.fromMap(data); PostDetailModel copyWith({Post? post}) { return PostDetailModel(post: post ?? this.post); } @override String toString() { return 'PostDetailModel{post: $post}'; } }

2. post 수정

import 'package:flutter_blog/data/model/user.dart'; class Post { int id; String title; String content; DateTime createdAt; DateTime updatedAt; User user; int bookmarkCount; bool? isBookmark; Post({ required this.id, required this.title, required this.content, required this.createdAt, required this.updatedAt, required this.user, required this.bookmarkCount, this.isBookmark, }); 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']), bookmarkCount = data["bookmarkCount"], isBookmark = data["isBookmark"]; @override String toString() { return 'Post{id: $id, title: $title, content: $content, createdAt: $createdAt, updatedAt: $updatedAt, user: $user, bookmarkCount: $bookmarkCount, isBookmark: $isBookmark}'; } }
  • isBookmark 추가

5. 뷰에 모델 데이터 렌더링

1. PostDetailBody

class PostDetailBody extends ConsumerWidget { int postId; PostDetailBody(this.postId); @override Widget build(BuildContext context, WidgetRef ref) { // family 를 사용하면 매개변수를 넘길 수 있다 PostDetailModel? model = ref.watch(postDetailProvider(postId)); /// TODO 4 : model 데이터 렌더링하기 if (model == null) { return Center(child: CircularProgressIndicator()); } else { return Padding( padding: const EdgeInsets.all(16.0), child: ListView( children: [ PostDetailTitle("${model.post.title}"), const SizedBox(height: largeGap), PostDetailProfile(model.post), // 유저 넘기고 email 생략 PostDetailButtons(model.post), const Divider(), const SizedBox(height: largeGap), PostDetailContent("${model.post.content}"), ], ), ); } } }

2. PostDetailProfile

notion image
class PostDetailProfile extends StatelessWidget { Post post; PostDetailProfile(this.post); @override Widget build(BuildContext context) { return ListTile( title: Text("${post.user.username}"), leading: SizedBox( height: 50, width: 50, child: ClipOval( child: CachedNetworkImage( imageUrl: "${baseUrl}${post.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("${post.createdAt}"), ], )); } }

3. PostDetailButtons

notion image
class PostDetailButtons extends ConsumerWidget { Post post; PostDetailButtons(this.post); @override Widget build(BuildContext context, WidgetRef ref) { SessionModel model = ref.read(sessionProvider); return Visibility( visible: model.user!.id == post.user.id, child: 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), ), ], ), ); } }
  • 상태값에 접근해서 처리했다
  • 상태값이 아닌 행위를 사용해서 true | false 를 return 하는 메서드를 사용하는게 더 식별하기 좋다
    • 예) isOwner(post.user.id) → true

8. 게시글 삭제

1. 통신 코드 작성

1. PostRepository

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>> deleteOne(int postId) async { Response response = await dio.delete('/api/post/${postId}'); final responseBody = response.data; Logger().d(responseBody); return responseBody; } }

2. PostRepositoryTest

void main() async { dio.options.headers["Authorization"] = "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpbWdVcmwiOiIvaW1hZ2VzLzEucG5nIiwic3ViIjoibWV0YWNvZGluZyIsImlkIjoxLCJleHAiOjE3NDk2MDU1MTEsInVzZXJuYW1lIjoic3NhciJ9.ZK50ecQD8LEuS1jrucCw3sf-ETATjopDu3L7VPNfr42heoRC5T8g7vWpWX60ijItBPgy_1zNMj6U5dsVkZSt8Q"; PostRepository repo = PostRepository(); await repo.deleteOne(1); }

2. 뷰 모델에 삭제 메서드 추가

1. post_detail_vm

final postDetailProvider = AutoDisposeNotifierProvider.family<PostDetailVM, PostDetailModel?, int>(() { return PostDetailVM(); }); // TODO 3 : init 완성하기 (state 갱신) 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; } //init(postId); 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({required this.post}); PostDetailModel.fromMap(Map<String, dynamic> data) : post = Post.fromMap(data); PostDetailModel copyWith({Post? post}) { return PostDetailModel(post: post ?? this.post); } @override String toString() { return 'PostDetailModel(post: $post)'; } }
  • 삭제 요청 후 화면이 날라갈 때 vm 도 같이 날려야 한다
  • AutoDisposeFamilyNotifierAutoDisposeNotifierProvider 를 사용하면 화면이 날라갈 때 vm 도 같이 삭제 해준다
    • AutoDispose 를 붙이면 된다
  • 데이터 삭제 후 리스트 화면 처리 방법
      1. 리스트 vm 을 init() 한다 → 다시 리스트 요청한다
      1. 리스트 model 데이터를 갱신한다 → where 을 사용해 id 를 던져주고 삭제 한다
  • ref.read(postListProvider.notifier).notifyDeleteOne(postId) → list_vm 에 이거 삭제 하라고 알려주는 메서드

2. post_list_vm

/// 1. 창고 관리자 final postListProvider = NotifierProvider<PostListVM, PostListModel?>(() { return PostListVM(); }); /// 2. 창고 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); if (!body["success"]) { ScaffoldMessenger.of(mContext).showSnackBar( SnackBar(content: Text("게시글 목록보기 실패 : ${body["errorMessage"]}")), ); return; } state = PostListModel.fromMap(body["response"]); } void notifyDeleteOne(int postId) { PostListModel model = state!; model.posts = model.posts.where((p) => p.id != postId).toList(); state = state!.copyWith(posts: model.posts); } } /// 3. 창고 데이터 타입 class PostListModel { bool isFirst; bool isLast; int pageNumber; int size; int totalPage; List<Post> posts; PostListModel({ required this.isFirst, required this.isLast, required this.pageNumber, required this.size, required this.totalPage, required 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: isFirst ?? this.isFirst, isLast: isLast ?? this.isLast, pageNumber: pageNumber ?? this.pageNumber, size: size ?? this.size, totalPage: totalPage ?? this.totalPage, posts: posts ?? this.posts, ); } @override String toString() { return 'PostListModel{isFirst: $isFirst, isLast: $isLast, pageNumber: $pageNumber, size: $size, totalPage: $totalPage, posts: $posts}'; } }
  • notifyDeleteOne → 알림 메서드는 notify를 붙여서 사용하자
  • detail page 에서 삭제를 하면 list 페이지의 데이터도 수정해야할 때 사용한다

3. 뷰에 메서드 연결

1. PostDetailButtons

notion image
class PostDetailButtons extends ConsumerWidget { Post post; PostDetailButtons(this.post); @override Widget build(BuildContext context, WidgetRef ref) { SessionModel sessionModel = ref.read(sessionProvider); PostDetailVM vm = ref.read(postDetailProvider(post.id).notifier); return Visibility( visible: sessionModel.user!.id == post.user.id, child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ IconButton( onPressed: () async { vm.deleteOne(post.id); }, icon: const Icon(CupertinoIcons.delete), ), IconButton( onPressed: () { Navigator.push(context, MaterialPageRoute(builder: (_) => PostUpdatePage())); }, icon: const Icon(CupertinoIcons.pen), ), ], ), ); } }
  • 삭제 아이콘 클릭 하면 삭제 로직 실행

9. 게시글 등록

폼 모델에 데이터를 검증하고 모은 다음
가장 가까운 뷰 모델에 등록 삭제 수정 기능을 넣어주면 된다
다른 이유로 게시글을 등록하면 누가 상태가 바뀌어야 하나 생각하면 리스트의 상태가 변경되어야 하기 때문에 리스트 뷰모델에 기능을 만들어 줘야 한다

1. 뷰 확인

notion image
notion image

2. FM 생성

  • 뷰가 form 을 사용하고 있기 때문에 form 데이터를 관리할 FM 생성

1. post_write_fm

final postWriteProvider = NotifierProvider<PostWriteFM, PostWriteModel>(() { return PostWriteFM(); }); class PostWriteFM extends Notifier<PostWriteModel> { @override PostWriteModel build() { return PostWriteModel("", ""); } void title(String title) { state = state.copyWith(title: title); } void content(String content) { state = state.copyWith(content: content); } } class PostWriteModel { final String title; final String content; PostWriteModel(this.title, this.content); PostWriteModel copyWith({ String? title, String? content, }) { return PostWriteModel( title ?? this.title, content ?? this.content, ); } @override String toString() { return 'PostWriteModel{title: $title, content: $content}'; } }

3. fm ↔ 뷰 연결

1. PostWriteForm

class PostWriteForm extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { PostWriteFM fm = ref.read(postWriteProvider.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); }, ), ], ), ); } }

4. 통신 코드 작성

1. PostRepository

class PostRepository { ... Future<Map<String, dynamic>> write(String title, String content) async { Response response = await dio.post( '/api/post', data: { "title": title, "content": content, }, ); final responseBody = response.data; Logger().d(responseBody); return responseBody; } }

5. 통신 및 상태 변경

1. post_list_vm

/// 1. 창고 관리자 final postListProvider = NotifierProvider<PostListVM, PostListModel?>(() { return PostListVM(); }); /// 2. 창고 class PostListVM extends Notifier<PostListModel?> { final mContext = navigatorKey.currentContext!; @override PostListModel? build() { init(); return null; } ... Future<void> write(String title, String content) async { // 1. 레포지토리 함수 호출 Map<String, dynamic> body = await PostRepository().write(title, content); // 2. 성공 여부 확인 if (!body["success"]) { ScaffoldMessenger.of(mContext).showSnackBar( SnackBar(content: Text("게시글 쓰기 실패 : ${body["errorMessage"]}")), ); return; } // 3. 상태 갱신 // 3-1. 파싱 Post post = Post.fromMap(body["response"]); // 3-2. 상태 변경 List<Post> nextPosts = [post, ...state!.posts]; // 3-3. 상태 갱신 state = state!.copyWith(posts: nextPosts); // 4. 글쓰기 화면 pop Navigator.pop(mContext); } } /// 3. 창고 데이터 타입 class PostListModel { ... }
  • 글쓰기를 하면 list 의 상태가 변경된다
  • 글쓰기의 가장 가까운 vm 은 list_vm 이다
  • 위 두 가지 조건에 의해 write 메서드는 list_vm 에서 수행한다

6. 뷰 에서 write 메서드 호출

1. PostWriteForm

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); }, ), ], ), ); } }
  • fm 의 값을 읽어 온 다음 write 메서드 호출

10. 게시글 수정

1. 게시글 상세페이지에서 데이터 넘겨주기

1. PostDetailButtons

class PostDetailButtons extends ConsumerWidget { Post post; PostDetailButtons(this.post); @override Widget build(BuildContext context, WidgetRef ref) { SessionModel sessionModel = ref.read(sessionProvider); PostDetailVM vm = ref.read(postDetailProvider(post.id).notifier); return Visibility( visible: sessionModel.user!.id == post.user.id, child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ IconButton( onPressed: () async { vm.deleteOne(post.id); }, icon: const Icon(CupertinoIcons.delete), ), IconButton( onPressed: () { Navigator.push(context, MaterialPageRoute(builder: (_) => PostUpdatePage(post))); }, icon: const Icon(CupertinoIcons.pen), ), ], ), ); } }
  • post 데이터 넘기기

2. PostUpdatePage

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), ); } }

3. PostUpdateBody

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), ); } }

4. PostUpdateForm

class PostUpdateForm extends StatelessWidget { Post post; PostUpdateForm(this.post); @override Widget build(BuildContext context) { return Form( child: ListView( children: [ CustomTextFormField( hint: "Title", initialValue: post.title, onChanged: (value) {}, ), const SizedBox(height: smallGap), CustomTextArea( hint: "Content", initialValue: post.content, onChanged: (value) {}, ), const SizedBox(height: largeGap), CustomElevatedButton( text: "글 수정하기", click: () {}, ), ], ), ); } }

2. 변경된 값 저장

1. PostUpdateForm

class PostUpdateForm extends StatelessWidget { Post post; PostUpdateForm(this.post); @override Widget build(BuildContext context) { return Form( child: ListView( children: [ CustomTextFormField( hint: "Title", initialValue: post.title, onChanged: (value) { post.title = value; }, ), const SizedBox(height: smallGap), CustomTextArea( hint: "Content", initialValue: post.content, onChanged: (value) { post.content = value; }, ), const SizedBox(height: largeGap), CustomElevatedButton( text: "글 수정하기", click: () {}, ), ], ), ); } }

3. 통신 코드 작성

1. PostRepository

class PostRepository { ... Future<Map<String, dynamic>> updateOne(Post post) async { Response response = await dio.put( '/api/post/${post.id}', data: { "title": post.title, "content": post.content, }, ); final responseBody = response.data; Logger().d(responseBody); return responseBody; } }

4. 뷰 모델에서 통신 코드 사용

1. post_detail_vm

final postDetailProvider = AutoDisposeNotifierProvider.family<PostDetailVM, PostDetailModel?, int>(() { return PostDetailVM(); }); // TODO 3 : init 완성하기 (state 갱신) 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> updateOne(Post post) async { // 1. 통신 Map<String, dynamic> body = await PostRepository().updateOne(post); if (!body["success"]) { ScaffoldMessenger.of(mContext!).showSnackBar( SnackBar(content: Text("게시글 수정 실패 : ${body["errorMessage"]}")), ); return; } // 2. 파싱 Post nextPost = Post.fromMap(body["response"]); // 3. 상태갱신 (detail) state = state!.copyWith(post: nextPost); // 4. 상태갱신 (list -> notify) ref.read(postListProvider.notifier).notifyUpdateOne(nextPost); // 5. pop Navigator.pop(mContext); } ... } // TODO 2 : replies 빼고 상태로 관리하기 class PostDetailModel { ... }

5. 게시글 목록 상태 갱신

1. post_list_vm

/// 1. 창고 관리자 final postListProvider = NotifierProvider<PostListVM, PostListModel?>(() { return PostListVM(); }); /// 2. 창고 class PostListVM extends Notifier<PostListModel?> { final mContext = navigatorKey.currentContext!; @override PostListModel? build() { init(); return null; } ... void notifyUpdateOne(Post post) { List<Post> nextPosts = state!.posts.map((p) => p.id == post.id ? post : p).toList(); state = state!.copyWith(posts: nextPosts); } ... } /// 3. 창고 데이터 타입 class PostListModel { ... }

6. 뷰에서 메서드 호출

1. PostUpdateForm

class PostUpdateForm extends ConsumerWidget { Post post; PostUpdateForm(this.post); @override Widget build(BuildContext context, WidgetRef ref) { PostDetailVM vm = ref.read(postDetailProvider(post.id).notifier); return Form( child: ListView( children: [ CustomTextFormField( hint: "Title", initialValue: post.title, onChanged: (value) { post.title = value; }, ), const SizedBox(height: smallGap), CustomTextArea( hint: "Content", initialValue: post.content, onChanged: (value) { post.content = value; }, ), const SizedBox(height: largeGap), CustomElevatedButton( text: "글 수정하기", click: () { vm.updateOne(post); }, ), ], ), ); } }

11. 자동 로그인

1. 스플레시 페이지 이동

1. main

// TODO: 1. Stack의 가장 위 context를 알고 있다. [지금 몰라도 됨] 맨 위에 있는 화면의 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를 사용할 수 있는 방법 debugShowCheckedModeBanner: false, home: SplashPage(), // 스플레시 페이지는 준비 화면인데 통신을 하지 않으면 쓸모 없다 routes: { "/login": (context) => const LoginPage(), "/join": (context) => const JoinPage(), "/post/list": (context) => PostListPage(), "/post/write": (context) => const PostWritePage(), }, theme: theme(), ); } }

2. SplachPage

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, ), ), ); } }
  • 스플레시 페이지에서 자동 로그인 메서드 호출

2. 통신 코드 작성

1. UserRepository

import 'package:dio/dio.dart'; import 'package:flutter_blog/_core/utils/my_http.dart'; import 'package:logger/logger.dart'; /// 책임 -> 통신 & 파싱(body data) class UserRepository { ... 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; Logger().d(responseBody); return responseBody; } }

3. 세션에서 자동 로그인 메서드 작성

1. session_gvm

/// 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> autoLogin() async { // 디바이스에서 토큰 값 가져오기 String? accessToken = await secureStorage.read(key: "accessToken"); if (accessToken == null) { Navigator.pushReplacement(mContext, MaterialPageRoute(builder: (_) => LoginPage())); return; } // 1. 통신 Map<String, dynamic> body = await UserRepository().autoLogin(accessToken); if (!body["success"]) { ScaffoldMessenger.of(mContext).showSnackBar( SnackBar(content: Text("${body["errorMessage"]}")), ); Navigator.pushReplacement(mContext, MaterialPageRoute(builder: (_) => LoginPage())); return; } User user = User.fromMap(body["response"]); user.accessToken = accessToken; // 5. 세션모델 갱신 state = SessionModel(user: user, isLogin: true); // 5. dio의 header에 토큰 세팅 [Bearer <- 이거 들어가 있음] dio.options.headers["Authorization"] = user.accessToken; // 6. 게시글 목록 페이지 이동 Navigator.pushReplacement(mContext, MaterialPageRoute(builder: (_) => PostListPage())); } } /// 3. 창고 데이터 타입 (불변 아님) class SessionModel { ... }
  1. 디스크에서 토큰 읽기
    1. 토큰 없으면 바로 login 페이지 이동
  1. 통신해서 유효한지 확인
    1. 실패하면 login 페이지 이동
  1. 유저로 파싱
  1. 세션 데이터 갱신
  1. dio 토큰 세팅
  1. list 페이지 이동
  • pushReplacement현재 페이지를 pop하고, 새 페이지를 push함. 즉, 대체하는 방식

12. 새로고침 & 추가요청

notion image
notion image
Dart packagesDart packagespull_to_refresh | Flutter package
dependencies: pull_to_refresh: ^2.0.0

1. 리스트 페이지에서 새로고침과 추가 요청을 할 수 있는 위젯 추가

1. PostListBody

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, enablePullDown: true, enablePullUp: true, onRefresh: () { vm.init(); }, onLoading: () { vm.nextList(); }, child: ListView.separated( itemCount: model.posts.length, itemBuilder: (context, index) { return InkWell( onTap: () { Navigator.push(context, MaterialPageRoute(builder: (_) => PostDetailPage(model.posts[index].id))); }, child: PostListItem(model.posts[index]), ); }, separatorBuilder: (context, index) { return const Divider(); }, ), ); } } }
  • enablePullDown → bool | true 당겨서 새로고침 허용 여부
  • enablePullUp → bool | false 위로 올려서 로딩 허용 여부
  • child → Widget | null 스크롤 가능한 리스트
  • SmartRefresher → 컬렉션 위젯에 적용 가능한 새로고침 및 추가요청 가능한 위젯

2. 뷰 모델에서 새로고침 및 추가요청 로직 작성

1. post_list_vm

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:pull_to_refresh/pull_to_refresh.dart'; /// 1. 창고 관리자 final postListProvider = NotifierProvider<PostListVM, PostListModel?>(() { return PostListVM(); }); /// 2. 창고 class PostListVM extends Notifier<PostListModel?> { final mContext = navigatorKey.currentContext!; final refreshCtrl = RefreshController(); @override PostListModel? build() { init(); return null; } 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(); } ... 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 { ... }
  • vm 에서 관리하기 위해 모델 내부에 RefreshController 생성
  • init 메서드 맨 마지막에 새로고침 완료 추가
  • nextList 메서드 추가
  • 다음 페이지 요청해서 기존 데이터랑 합침

13. 뷰와 뷰모델은 생명주기를 같이 하자

1. 로그아웃 하면 리스트 뷰 모델을 날려야 한다

1. post_list_vm

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. 창고 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; } ... } /// 3. 창고 데이터 타입 class PostListModel { ... }
  • AutoDispose → 를 사용하면 모든 구독자가 사라질 때 모델이 같이 사라진다
  • ref → 현재 Provider의 생명주기를 추적하는 객체
    • onDispose → Provider가 dispose될 때 실행할 작업을 등록
 
Share article

jjack1