1. New Project
- 화면이 제작된 상태에서 작업
- git clone
2. Riverpod 작업
- 상태를 관리하기 위해서는 해당 상태만을 따로 분리시켜야 함
- Scaffold 전체를 Rebuild 하지 않아야 함 : 자원 낭비
- 서버에서 데이터를 받을 경우, 또는 사용자가 데이터를 입력하여 전송할 경우 둘 다 해당
- 데이터를 받아오는 경우 : View Model과 연결
- 데이터를 전송하는 경우 : Form Model과 연결
- View Model과 Form Model은 같은 기능 (역할에 따른 컨벤션)
- 실제 서버와 연결하여, 해당 서버의 API 문서 및 데이터 형태를 참고해서 데이터를 주고 받음
3. REST 서버 가동 (build)
- Project 폴더 중 blogserver 우클릭 → Open in Explorer 클릭

- Git Bash 열기

- 서버 실행 명령어 입력
- 자바 버전 확인할 것
java -jar "파일명"

- API 문서 확인 : 데이터 통신 시 주고받는 값 확인

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

- 여러 개의 View에서 공통적으로 사용하는 View Model을 Global View Model (GVM)이라고 하며, 보통 사용자 인증 등의 역할을 수행
- Session의 역할, Singleton 패턴 구현을 위해 사용

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(),
);
}
}
- 디바이스 저장 시 따로 로그아웃을 하지 않을 경우 자동 로그인을 수행할 수 있음
- 디바이스 저장 시 사용하는 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 문서를 확인하여 응답 구조 확인

- 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 사용

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 표시

- 데이터가 전송되고 난 화면

Detail
- 리스트의 전달 값을 확인하여 객체 생성 필요
- Postman 또는 API 문서를 확인하여 응답 구조 확인

- 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에서 처리 필요
- 리포지토리를 호출하여 삭제 메서드 작성
- 삭제 후 게시글 리스트 갱신 필요
- 다시 통신 (init)
- 깊은 복사 (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 구조와 동일

- 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
- 글을 작성하면 새롭게 리스트가 갱신됨

Update
- API 문서를 참고하여 Request, Response 구조를 파악
- Response의 body[”response”]는 post 구조와 동일

- 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
- 글 수정 화면

- 글 수정 후 List 및 Detail에 반영된 화면


Auto-Login
- API 문서를 참고하여 Request, Response 구조를 파악
- Post 요청이나 body가 없음, 로그인 기능이므로 토큰 전달 필요

- 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
- 로그인을 이전에 한 적이 없으면 로그인 화면으로 이동

- 로그인을 한 이후 앱을 재실행 (같은 포트 재접속) 시 로그인 생략 후 게시글 리스트로 이동

Refresh & Paging
- Web에서 테스트 불가 : 새로고침 및 로딩을 위한 드래그 액션은 모바일에만 존재
- Web에서는 직접 새로고침할 수 있도록 action 추가 구현이 필요
- Web에서 F12 → 맨 왼쪽 디바이스 버튼 클릭 시 모바일로 전환

- 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
- 최상단 리스트를 아래로 끌어내리면 새로 고침 동작

- 리스트의 맨 아래쪽으로 드래그 후 아래쪽으로 드래그 시 새 페이지 호출

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