
Flutter UI에 의미(semantic information) 를 부여해서
스크린 리더(VoiceOver, TalkBack)가 UI를 올바르게 해석하도록 돕는 위젯
화면에 “보이는 것”이 아니라 스크린 리더가 “이해하는 것”을 정의하는 위젯
MaterialApp showSemanticsDebugger: true 속성을 켜서 확인 가능
GestureDetector(
onTap: () {},
child: Icon(Icons.add),
)
사용자 눈: ➕ 버튼
스크린 리더: ??
Semantics(
label: '추가 버튼',
button: true,
child: Icon(Icons.add),
)
스크린 리더: 추가 버튼, 버튼
| 기능 | 설명 |
|---|---|
| 의미 전달 | 버튼, 이미지, 체크박스 등 역할 정의 |
| 상태 전달 | 활성/비활성, 체크 여부 |
| 값 전달 | 슬라이더 값, 진행률 |
| 행동 힌트 | “두 번 탭하세요” 같은 안내 |
Semantics(
label: '설명',
hint: '행동 안내',
button: true,
enabled: true,
child: 위젯,
)
label: '로그인 버튼'
스크린 리더가 읽는 핵심 텍스트
UI에 Text가 없어도 반드시 제공
hint: '두 번 탭하면 로그인합니다'
사용자에게 행동 방법 안내
선택 사항이지만 UX 향상에 좋음
button: true
image: true
checkbox: true
위젯의 역할(role) 을 명시
enabled: false
비활성 상태 전달
checked: true
체크박스, 토글에서 필수
선택됨 / 선택 안 됨 읽힘
value: '70%'
슬라이더, 프로그레스바용
focusable: true
키보드/스크린 리더 포커스 가능 여부
자식 위젯의 의미 제거
ExcludeSemantics(
child: Text('읽히지 않음'),
)
여러 위젯을 하나의 의미로 묶기
MergeSemantics(
child: Row(
children: [
Icon(Icons.shopping_cart),
Text('장바구니'),
],
),
)
자식 위젯의 최소/최대 크기를 제한하는 위젯
Flutter 레이아웃에서
“이 위젯은 이 범위 안에서만 커지거나 작아질 수 있어”
라고 제약(constraint) 을 거는 역할을 함
ConstrainedBox(
constraints: BoxConstraints(
minWidth: 100,
maxWidth: 200,
minHeight: 50,
maxHeight: 150,
),
child: Widget,
)
자식은 이 범위를 벗어날 수 없음
| 속성 | 의미 |
|---|---|
| minWidth | 최소 가로 크기 |
| maxWidth | 최대 가로 크기 |
| minHeight | 최소 세로 크기 |
| maxHeight | 최대 세로 크기 |
Flutter 레이아웃은 항상 부모 → 자식 방향으로 제약을 전달
부모 constraints
↓
ConstrainedBox (제약 추가/강화)
↓
자식 Widget
ConstrainedBox는 부모 제약을 무시하지 않는다
→ 기존 제약 위에 추가로 제한을 건다
| 항목 | ConstrainedBox | SizedBox |
|---|---|---|
| 크기 | 범위 지정 | 고정 |
| 유연성 | 높음 | 낮음 |
| 용도 | 제한 | 명확한 크기 |
SizedBox = 강제
ConstrainedBox = 제한
| 항목 | ConstrainedBox | LimitedBox |
|---|---|---|
| 부모 제약 있음 | 동작함 | 무시됨 |
| 부모 제약 없음 | 동작함 | 이때만 제한 |
| 사용 상황 | 일반적 | ListView 등 |
📁lib/ui/pages/product_detail/product_detail_view_model.dart
import 'package:flutter_market_app/data/model/product.dart';
import 'package:flutter_market_app/data/repository/product_repository.dart';
import 'package:flutter_riverpod/legacy.dart';
class ProductDetailViewModel extends StateNotifier<Product?> {
final int productId;
ProductDetailViewModel({required this.productId}) : super(null) {
fetchDetail();
}
// 상품 상세 불러오기
final productRepository = ProductRepository();
Future<void> fetchDetail() async {
state = await productRepository.fetchDetail(productId);
}
// 좋아요
Future<bool> like() async {
final newLike = await productRepository.like(productId);
if (newLike != null && state != null) {
state = state!.copyWith(myLike: newLike);
return true;
}
return false;
}
// 삭제
Future<bool> delete() async {
return await productRepository.delete(productId);
}
Future<void> createChat() async {
// TODO : 나중에 구현
}
}
final productDetailViewModelProvider =
StateNotifierProvider.family<ProductDetailViewModel, Product?, int>((
ref,
productId,
) {
return ProductDetailViewModel(productId: productId);
});
Product copyWith({ bool? myLike })
Riverpod 상태는 immutable
좋아요 토글 시 전체 Product 재생성 필요
UI 즉시 반영 가능
ProductDetailPage(this.productId);
모든 하위 위젯에 productId 전달
import 'package:flutter/material.dart';
import 'package:flutter_market_app/ui/pages/home/_tab/home_tab/home_tap_view_model.dart';
import 'package:flutter_market_app/ui/pages/product_detail/product_detail_view_model.dart';
import 'package:flutter_market_app/ui/pages/product_write/product_write_page.dart';
import 'package:flutter_market_app/ui/user_global_view_model.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class ProductDetailActions extends StatelessWidget {
ProductDetailActions(this.productId);
// Family 뷰모델 사용하기 위해서 위젯에서 넘겨줘야함
final int productId;
Widget build(BuildContext context) {
return Consumer(
builder: (context, ref, child) {
final vm = ref.read(productDetailViewModel(productId).notifier);
final state = ref.watch(productDetailViewModel(productId));
// 이미 로그인 되어있기때문에 read 사용
final user = ref.read(userGlobalViewModel);
if (state?.user.id != user?.id) {
return SizedBox();
}
return Row(
children: [
GestureDetector(
onTap: () async {
final result = await vm.delete();
if (result) {
// 업데이트 하고 homeTab도 갱신
// 다른 뷰를 업데이트 하는 방법 여러가지
// 1. 업데이트 안하고 pull to refresh 구현
// 2. ViewModel 안의 객체 업데이트
// 3. 서버에서 새로고침
// 등등
final result = await vm.delete();
if (result) {
ref.read(homeTabViewModel.notifier).fetchProducts();
Navigator.pop(context);
}
ref.read(homeTabViewModel.notifier).fetchProducts();
Navigator.pop(context);
}
},
child: Container(
width: 50,
height: 50,
color: Colors.transparent,
child: Icon(Icons.delete),
),
),
GestureDetector(
onTap: () {
Navigator.push(context, MaterialPageRoute(
builder: (context) {
return ProductWritePage();
},
));
},
child: Container(
width: 50,
height: 50,
color: Colors.transparent,
child: Icon(Icons.edit),
),
)
],
);
},
);
}
}
작성자만 노출
if (state?.user.id != user?.id) return SizedBox();
삭제 시 -> 서버 삭제 > 홈탭 갱신 > 페이지 pop
import 'package:flutter/material.dart';
import 'package:flutter_market_app/ui/pages/product/widgets/product_detail_actions.dart';
import 'package:flutter_market_app/ui/pages/product/widgets/product_detail_body.dart';
import 'package:flutter_market_app/ui/pages/product/widgets/product_detail_bottom_sheet.dart';
import 'package:flutter_market_app/ui/pages/product/widgets/product_detail_picture.dart';
class ProductDetailPage extends StatelessWidget {
final int productId;
const ProductDetailPage({super.key, required this.productId});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(actions: [ProductDetailActions(productId: productId)]),
extendBody: true,
bottomSheet: ProductDetailBottomSheet(
// MediaQuery 현재 context 즉 현재 위젯 기준으로 정보를 제공
// => device bottom padding(safe area) 영역 구하려면 반드시 Scaffold에서 호출
bottomPadding: MediaQuery.of(context).padding.bottom,
),
body: ListView(
children: [ProductDetailPicture(productId), ProductDetailBody()],
),
);
}
}
PageView로 이미지 슬라이드
state == null → 빈 위젯
import 'package:flutter/material.dart';
import 'package:flutter_market_app/core/date_time_utils.dart';
import 'package:flutter_market_app/data/model/product.dart';
import 'package:flutter_market_app/ui/pages/product/product_detail_view_model.dart';
import 'package:flutter_market_app/ui/widgets/user_profile_image.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class ProductDetailBody extends StatelessWidget {
const ProductDetailBody(this.productId, {super.key});
final int productId;
Widget build(BuildContext context) {
return Consumer(
builder: (context, ref, child) {
final state = ref.watch(productDetailViewModelProvider(productId));
if (state == null) {
return SizedBox();
}
return Padding(
padding: const EdgeInsets.only(
left: 20,
right: 20,
top: 10,
bottom: 800,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
profileArea(state),
Divider(height: 30),
Text(
state.title,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
SizedBox(height: 10),
Text(
'${state.category.category} - ${DateTimeUtils.formatString(state.updatedAt)}',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
SizedBox(height: 10),
Text(state.content, style: TextStyle(fontSize: 16)),
],
),
);
},
);
}
Row profileArea(Product product) {
return Row(
children: [
UserProfileImage(dimension: 50, imgSrc: product.user.profileImage.url),
SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.user.nickname,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
Text(
product.address.displayName,
style: TextStyle(fontSize: 13, color: Colors.grey),
),
],
),
],
);
}
}
유저 프로필
제목 / 카테고리 / 시간
본문 내용 표시
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_market_app/ui/pages/chat_detail/chat_detail_page.dart';
import 'package:flutter_market_app/ui/pages/home/_tab/home_tab/home_tab_view_model.dart';
import 'package:flutter_market_app/ui/pages/product/product_detail_view_model.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
class ProductDetailBottomSheet extends StatelessWidget {
const ProductDetailBottomSheet({
required this.bottomPadding,
required this.productId,
});
final double bottomPadding;
final int productId;
Widget build(BuildContext context) {
return Consumer(
builder: (context, ref, child) {
final vm = ref.read(productDetailViewModelProvider(productId).notifier);
final state = ref.watch(productDetailViewModelProvider(productId));
if (state == null) {
return const SizedBox();
}
return Container(
height: 50 + bottomPadding,
color: Colors.white,
child: Column(
children: [
Divider(height: 0),
Expanded(
child: Row(
children: [
GestureDetector(
onTap: () async {
final result = await vm.like();
// 좋아요하면 홈탭 재로드
// 다른 뷰를 업데이트 하는 방법 여러가지
// 1. 업데이트 안하고 pull to refresh 구현
// 2. ViewModel 안의 객체 업데이트
// 3. 서버에서 새로고침
// 등등
if (result) {
ref.read(homeTabViewModel.notifier).fetchProducts();
}
},
child: Container(
width: 50,
height: 50,
color: Colors.transparent,
child: Icon(
state.myLike
? CupertinoIcons.heart_fill
: CupertinoIcons.heart,
),
),
),
VerticalDivider(
width: 20,
indent: 10,
endIndent: 10,
color: Colors.grey,
),
Expanded(
child: Text(
NumberFormat('###,###원').format(state.price),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
SizedBox(
width: 100,
height: 40,
child: ElevatedButton(
onPressed: () async {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return ChatDetailPage();
},
),
);
},
child: Text(
'채팅하기',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
),
),
SizedBox(width: 20),
],
),
),
SizedBox(height: bottomPadding),
],
),
);
},
);
}
}
기능
좋아요 성공 시 -> HomeTab 상품 리스트 재조회
📁lib/data/repository/product_category_repository.dart
서버에서 상품 카테고리 목록 조회
import 'package:flutter_market_app/data/model/product_category.dart';
import 'package:flutter_market_app/data/repository/base_remote_repository.dart';
class ProductCategoryRepository extends BaseRemoteRepository {
// 서버에서 에러 응답 시 => null
Future<List<ProductCategory>?> getCategoryList() async {
final response = await client.get("/api/product/category");
if (response.statusCode == 200) {
return List.of(
response.data['content'],
).map((e) => ProductCategory.fromJson(e)).toList();
}
return null;
}
}
성공 시 List<ProductCategory>
실패 시 null
단순 GET API
📁test/product_category_repository_test.dart
import 'package:flutter_market_app/data/repository/product_category_repository.dart';
import 'package:flutter_market_app/data/repository/user_repository.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
final userRepository = UserRepository();
final productCategoryRepository = ProductCategoryRepository();
test(
'ProductCategoryRepository : getCategoryList test',
() async {
await userRepository.login(username: 'tester', password: '1111');
final results = await productCategoryRepository.getCategoryList();
expect(results == null, false);
for (var category in results!) {
print(category.toJson());
}
},
);
}
로그인 선행 필요
서버 연동 정상 여부 확인
Future<Product?> create({
required String title,
required String content,
required List<int> imageFileIdList,
required int categoryId,
required int price,
}) async {
final response = await client.post(
"/api/product",
data: {
'title': title,
'content': content,
'imageFileIdList': imageFileIdList,
'categoryId': categoryId,
'price': price,
},
);
if (response.statusCode == 201) {
return Product.fromJson(response.data['content']);
}
return null;
}
Future<bool> update({
required int id,
required String title,
required String content,
required List<int> imageFileIdList,
required int categoryId,
required int price,
}) async {
final response = await client.put(
"/api/product",
data: {
'id': id,
'title': title,
'content': content,
'imageFileIdList': imageFileIdList,
'categoryId': categoryId,
'price': price,
},
);
return response.statusCode == 200;
}
등록 → POST /api/product
수정 → PUT /api/product
imageFileIdList 전달 필수
📁lib/ui/pages/product_write/widgets/product_write_viewModel.dart
상태 클래스 만들기
class ProductWriteState {
final List<FileModel> imageFiles; // 업로드된 이미지
final List<ProductCategory> categories; // 카테고리 목록
final ProductCategory? selectedCategory; // 선택된 카테고리
}
화면에서 필요한 입력 상태 관리
ViewModel 외부에서는 상태만 관찰
lass ProductWriteViewModel extends Notifier<ProductWriteState> {
ProductWriteViewModel(this.product);
/// family로 전달된 Product (null이면 생성, 있으면 수정)
final Product? product;
late final ProductRepository _productRepository;
late final ProductCategoryRepository _categoryRepository;
late final FileRepository _fileRepository;
ProductWriteState build() {
_productRepository = ProductRepository();
_categoryRepository = ProductCategoryRepository();
_fileRepository = FileRepository();
// 최초 1회 카테고리 조회
fetchCategories();
return ProductWriteState(
imageFiles: product?.imageFiles ?? [],
categories: const [],
selectedCategory: product?.category,
);
}
/// 카테고리 목록 조회
Future<void> fetchCategories() async {
final categories = await _categoryRepository.getCategoryList();
if (categories != null) {
state = state.copyWith(categories: categories);
}
}
/// 이미지 업로드
Future<void> uploadImage({
required String filename,
required String mimeType,
required List<int> bytes,
}) async {
final result = await _fileRepository.upload(
filename: filename,
mimeType: mimeType,
bytes: bytes,
);
if (result != null) {
state = state.copyWith(imageFiles: [...state.imageFiles, result]);
}
}
/// 상품 생성 / 수정
Future<bool?> upload({
required String title,
required String content,
required int price,
}) async {
// 유효성 체크
if (state.imageFiles.isEmpty) return null;
if (state.selectedCategory == null) return null;
final imageIds = state.imageFiles.map((e) => e.id).toList();
// ✅ 생성
if (product == null) {
final result = await _productRepository.create(
title: title,
content: content,
imageFileIdList: imageIds,
categoryId: state.selectedCategory!.id,
price: price,
);
if (result != null) {
ref.invalidate(homeTabViewModel);
return true;
}
}
// ✅ 수정
else {
final result = await _productRepository.update(
id: product!.id,
title: title,
content: content,
imageFileIdList: imageIds,
categoryId: state.selectedCategory!.id,
price: price,
);
if (result) return true;
}
return false;
}
/// 카테고리 선택
void onCategorySelected(String category) {
final target = state.categories.firstWhere(
(e) => e.category == category,
orElse: () => state.selectedCategory!,
);
state = state.copyWith(selectedCategory: target);
}
}
생성자 & Product 처리 방식
class ProductWriteViewModel extends Notifier<ProductWriteState> {
ProductWriteViewModel(this.product);
final Product? product;
}
product == null → 상품 생성
product != null → 상품 수정
build() 메서드
ProductWriteState build() {
fetchCategories();
return ProductWriteState(
imageFiles: product?.imageFiles ?? [],
categories: const [],
selectedCategory: product?.category,
);
}
카테고리 목록 조회
Future<void> fetchCategories() async {
final categories = await _categoryRepository.getCategoryList();
if (categories != null) {
state = state.copyWith(categories: categories);
}
}
API 호출 후 상태 갱신
UI는 자동 리빌드
카테고리 선택
void onCategorySelected(String category) {
final target = state.categories.firstWhere(
(e) => e.category == category,
);
state = state.copyWith(selectedCategory: target);
}
문자열 → ProductCategory 매칭
선택 결과를 상태로 저장
이미지 업로드
Future<void> uploadImage({
required String filename,
required String mimeType,
required List<int> bytes,
}) async {
final result = await _fileRepository.upload(...);
if (result != null) {
state = state.copyWith(
imageFiles: [...state.imageFiles, result],
);
}
}
서버 업로드 성공 시
기존 리스트 + 새 이미지 추가
공통 유효성 검사
if (state.imageFiles.isEmpty) return null;
if (state.selectedCategory == null) return null;
상품 생성
if (product == null) {
await _productRepository.create(...);
}
새 상품 등록
완료 후 홈 탭 갱신
ref.invalidate(homeTabViewModel);
상품 수정
else {
await _productRepository.update(
id: product!.id,
...
);
}
기존 상품 ID 기반 수정
Product.copyWith() 사용 x
final productWriteViewModelProvider =
NotifierProvider.autoDispose
.family<ProductWriteViewModel, ProductWriteState, Product?>(
(product) => ProductWriteViewModel(product),
);
family → Product 전달 가능
autoDispose → 화면 나가면 상태 제거
생성 / 수정 화면 공용 사용
class ProductCategoryBox extends StatelessWidget {
const ProductCategoryBox(this.product, {super.key});
final Product? product;
Widget build(BuildContext context) {
return Consumer(
builder: (context, ref, child) {
final state = ref.watch(productWriteViewModelProvider(product));
final vm = ref.read(productWriteViewModelProvider(product).notifier);
return Align(
alignment: Alignment.centerLeft,
child: PopupMenuButton<String>(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16.0)),
),
position: PopupMenuPosition.under,
color: Colors.white,
itemBuilder: (context) {
final categoryItems = state.categories.map((e) {
return categoryItem(
e.category,
state.selectedCategory?.id == e.id,
);
});
return [
categoryItem('카테고리 선택', false),
// 스프레드 연산자. 리스트안에 다른 리스트를 포함시킬 때
...categoryItems,
];
},
onSelected: vm.onCategorySelected,
child: Container(
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8),
),
padding: EdgeInsets.all(10),
child: Text(
state.selectedCategory?.category ?? '카테고리 선택',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
),
),
);
},
);
}
PopupMenuItem<String> categoryItem(String text, bool selected) {
return PopupMenuItem<String>(
value: text,
child: Text(
text,
style: TextStyle(
fontWeight: selected ? FontWeight.bold : null,
color: selected ? Colors.black : Colors.grey,
),
),
);
}
}
카테고리 선택 UI
PopupMenuButton 사용
상태 연동
final state = ref.watch(productWriteViewModel(product));
final vm = ref.read(productWriteViewModel(product).notifier);
선택 시
vm.onCategorySelected(category)
import 'package:flutter/material.dart';
import 'package:flutter_market_app/core/image_picker_helper.dart';
import 'package:flutter_market_app/data/model/product.dart';
import 'package:flutter_market_app/ui/pages/product_write/widgets/product_write_viewModel.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class ProductWritePictureArea extends StatelessWidget {
const ProductWritePictureArea(this.product, {super.key});
final Product? product;
Widget build(BuildContext context) {
return Consumer(
builder: (context, ref, child) {
final state = ref.watch(productWriteViewModelProvider(product));
final vm = ref.read(productWriteViewModelProvider(product).notifier);
return SizedBox(
width: double.infinity,
height: 60,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
...state.imageFiles.map((e) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
child: AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.network(e.url, fit: BoxFit.cover),
),
),
);
}),
SizedBox(width: 10),
GestureDetector(
onTap: () async {
final file = await ImagePickerHelper.pickImage();
if (file != null) {
await vm.uploadImage(
filename: file.filename,
mimeType: file.mimeType,
bytes: file.bytes,
);
}
},
child: AspectRatio(
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
color: Colors.transparent,
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(4),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.camera_alt, color: Colors.grey),
Text(
'${state.imageFiles.length}/10',
style: TextStyle(fontSize: 11),
),
],
),
),
),
),
],
),
);
},
);
}
}
업로드된 이미지 미리보기
이미지 추가 버튼
가로 스크롤
최대 10장 UI 표시
ImagePickerHelper 사용
class ProductWritePage extends StatefulWidget {
const ProductWritePage(this.product, {super.key});
final Product? product;
State<ProductWritePage> createState() => _ProductWritePageState();
}
class _ProductWritePageState extends State<ProductWritePage> {
final formKey = GlobalKey<FormState>();
late final titleController = TextEditingController(
text: widget.product?.title,
);
late final priceController = TextEditingController(
text: widget.product?.price.toString(),
);
late final contentController = TextEditingController(
text: widget.product?.content,
);
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
FocusScope.of(context).unfocus();
},
child: Scaffold(
appBar: AppBar(title: Text('내 물건 팔기')),
body: Form(
key: formKey,
child: ListView(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 20),
children: [
ProductWritePictureArea(widget.product),
const SizedBox(height: 20),
ProductCategoryBox(widget.product),
SizedBox(height: 10),
TextFormField(
decoration: InputDecoration(hintText: "상품명을 입력해주세요"),
validator: ValidatorUtil.validatorTitle,
controller: titleController,
),
const SizedBox(height: 20),
TextFormField(
decoration: InputDecoration(hintText: "가격을 입력해주세요"),
validator: ValidatorUtil.validatorTitle,
controller: priceController,
// 키보드 숫자 UI 나오게!
keyboardType: TextInputType.number,
// 모바일에서 블루투스 키보드로 글자 입력해도 안됨! 오직 숫자만!
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
const SizedBox(height: 20),
TextFormField(
decoration: InputDecoration(hintText: "상품 내용을 입력해주세요"),
validator: ValidatorUtil.validatorTitle,
controller: contentController,
),
const SizedBox(height: 20),
Consumer(
builder: (context, ref, child) {
return ElevatedButton(
onPressed: () async {
if (formKey.currentState?.validate() ?? false) {
final vm = ref.read(
productWriteViewModelProvider(
widget.product,
).notifier,
);
final result = await vm.upload(
title: titleController.text,
content: contentController.text,
price: int.parse(priceController.text),
);
if (result != null) {
ref.read(homeTabViewModel.notifier).fetchProducts();
if (widget.product != null) {
ref
.read(
productDetailViewModelProvider(
widget.product!.id,
).notifier,
)
.fetchDetail();
}
Navigator.pop(context);
}
}
},
child: Text('작성 완료'),
);
},
),
],
),
),
),
);
}
}
하나의 페이지로 생성/수정 처리
Product? product 전달
ProductWritePage(this.product);
컨트롤러 초기값
late final titleController = TextEditingController(
text: widget.product?.title,
);
✔️ late 붙여주기
✔️ 수정 시 자동 채워짐
작성 완료 버튼 로직
final result = await vm.upload(...);
if (result != null) {
홈 목록 갱신
상세 페이지 갱신 (수정일 경우)
Navigator.pop
}
삭제
수정 페이지 이동
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ProductWritePage(state),
),
);
✔️ 수정 → Product 전달
상품 등록 진입
ProductWritePage(null);
✔️ 등록 → null 전달
채팅 목록 / 채팅 상세 화면 구현
HTTP 기반 채팅 데이터 구조 이해
이후 소켓 통신(실시간 채팅) 을 위한 전역 상태 설계
ChatRepository (HTTP)
↓
ChatGlobalViewModel (전역 상태)
↓
ChatTab (채팅 목록)
ChatDetailPage (채팅 상세)
✔ 채팅은 여러 화면에서 공유
✔ 그래서 Global ViewModel 사용
ChatRoom
├─ ChatProduct (상품 정보)
├─ sender (채팅 시작한 유저)
└─ messages (ChatMessage 리스트)
📁lib/data/model/chat_message.dart
class ChatMessage {
final int id;
final String messageType;
final String content;
final DateTime createdAt;
ChatMessage({
required this.id,
required this.messageType,
required this.content,
required this.createdAt,
});
ChatMessage.fromJson(Map<String, dynamic> json)
: this(
id: json["id"],
messageType: json["messageType"],
content: json["content"],
createdAt: DateTime.parse(json["createdAt"]),
);
Map<String, dynamic> toJson() => {
"id": id,
"messageType": messageType,
"content": content,
"createdAt": createdAt.toIso8601String(),
};
}
역할
채팅 메시지 단위 데이터
핵심 필드
UI에서 내 메시지 / 상대 메시지 구분에 사용
📁 lib/data/model/chat_product.dart
class ChatProduct {
final int id;
final String title;
final User user;
final Address address;
final int price;
ChatProduct({
required this.id,
required this.title,
required this.user,
required this.address,
required this.price,
});
ChatProduct.fromJson(Map<String, dynamic> json)
: this(
id: json["id"],
title: json["title"],
user: User.fromJson(json["user"]),
address: Address.fromJson(json["address"]),
price: json["price"],
);
Map<String, dynamic> toJson() => {
"id": id,
"title": title,
"user": user.toJson(),
"address": address.toJson(),
"price": price,
};
}
역할
채팅방 상단에 표시할 상품 정보
✔ Product 전체가 아닌
✔ 채팅에 필요한 최소 정보만 포함
📁 lib/data/model/chat_room.dart
class ChatRoom {
final int roomId;
final ChatProduct product;
final User sender;
final List<ChatMessage> messages;
final DateTime createdAt;
ChatRoom({
required this.roomId,
required this.product,
required this.sender,
required this.messages,
required this.createdAt,
});
ChatRoom.fromJson(Map<String, dynamic> json)
: this(
roomId: json["roomId"],
product: ChatProduct.fromJson(json["product"]),
sender: User.fromJson(json["sender"]),
messages: List<ChatMessage>.from(
json["messages"].map((x) => ChatMessage.fromJson(x))),
createdAt: DateTime.parse(json["createdAt"]),
);
Map<String, dynamic> toJson() => {
"roomId": roomId,
"product": product.toJson(),
"sender": sender.toJson(),
"messages": List<dynamic>.from(messages.map((x) => x.toJson())),
"createdAt": createdAt.toIso8601String(),
};
}
역할
채팅방 단위 데이터
핵심
채팅 목록 / 상세 모두 이 모델 사용
@장나슬 튜터
UX 디자이너 = 유저의 ‘이동 경로’를 설계하는 사람
유저는 서비스를 사용할 때
👉 목적을 가지고 → 행동을 선택 → 결과를 얻음
디자이너는 이 흐름이
막히지 않는지
헷갈리지 않는지
최소한의 생각으로 목적에 도달하는지
를 설계해야 함
➡️ 유저 플로우(User Flow)
= 유저가 목표를 달성하기까지 거치는 모든 단계의 지도
사용자가 제품/서비스를 사용하며 느끼는 전체 경험
단순히 화면 예쁜 게 아님
사용 전–중–후 모든 경험 포함
핵심 키워드:
사용자 이해
문제 정의
사용성
맥락(Context)
UX에서 중요한 것
사용자를 관찰하는 것
어떤 선택을 왜 했는지 이해하는 것
그 이해를 바탕으로 구조를 설계하는 것
사용자의 행동을 유도하는 시각적·물리적 인터페이스
버튼, 색상, 레이아웃, 타이포
사용자가 “여길 눌러야겠네” 하고 자연스럽게 행동하도록 만드는 것
| 구분 | 설명 | 예시 |
|---|---|---|
| PUI (Physical User Interface) | 물리적 인터페이스 | 버튼, 다이얼, 키오스크 |
| GUI (Graphic User Interface) | 그래픽 기반 인터페이스 | 앱, 웹 화면 |
『(사용자를) 생각하게 하지 마!』
이 책의 핵심 메시지:
사용자는 생각하기 싫어한다
헷갈리는 순간 → 이탈
좋은 UX란:
설명이 필요 없는 것
한눈에 이해되는 것
핵심 포인트
제품·시스템의 목적을 명확히 파악
문제 해결 중심
“이거 어떻게 쓰는 거지?”라는 생각이 들지 않게 만들기
나중에 읽어볼 것
발산 ↔ 수렴을 반복하는 구조
문제 영역 해결 영역
◇ ◇
Discover → Define → Develop → Deliver
| 단계 | 의미 | 핵심 질문 |
|---|---|---|
| Discover | 문제 발견 | 무슨 문제가 있지? |
| Define | 문제 정의 | 진짜 문제는 뭐지? |
| Develop | 아이디어 확산 | 어떻게 해결할 수 있지? |
| Deliver | 해결책 도출 | 가장 좋은 해결책은? |
문제와 맥락을 최대한 넓게 이해
Desk Research
기존 서비스, 트렌드 조사
시장 배경, 사례 분석
경쟁사 분석
잘된 점 / 불편한 점
차별 포인트
유저 리서치
설문조사 (정량)
유저 인터뷰 (정성)
➡️ 이 단계에서는
해결책 생각 X
“왜 이런 문제가 생겼지?”에 집중
문제를 명확하게 정의
Persona
가상의 대표 사용자
목표, 니즈, 페인포인트 정리
➡️ 이 단계에서는
이 서비스의 핵심 유저는 누구인가
유저가 진짜로 불편해하는 지점은 무엇인가
다양한 해결 방법을 시도
아이디어 스케치
와이어프레임
유저 플로우 설계
여러 안을 동시에 만들어보기
➡️ “이 방법 말고도 없을까?” 계속 질문
실제로 검증 가능한 형태로 만들기
Prototype
클릭 가능한 시안
저충실도 → 고충실도
테스트
유저 반응 확인
문제점 발견
UX 디자인은 한 번에 끝나는 작업이 아님
만들고 → 테스트하고 → 고치고 → 다시 만든다
실패는 당연한 과정
빠르게 만들고 빠르게 검증하는 게 중요
UX 디자이너는 사용자를 관찰하고, 문제를 정의하고,
사용자가 가장 덜 생각하고 목표에 도달하게 만드는 길을 설계하는 사람이다.
@이주원 튜터님
모든 로직이 위젯에 집중됨
API 호출
상태 관리
UI 렌더링
→ 전부 한 위젯 안에 섞여 있음
결과
코드 가독성 ↓
테스트 어려움
재사용 불가능
유지보수 지옥
| 역할 | 책임 |
|---|---|
| Model | 데이터 구조, Repository, API |
| View | UI 렌더링, 사용자 이벤트 |
| ViewModel | 비즈니스 로직, 상태 관리 |
각자 잘하는 일만 하게 분리
Before (콜백 지옥)
상태 변경을 위해 콜백을 계속 전달
깊어질수록 유지보수 불가능
After (Riverpod)
ref로 어디서든 상태 접근
중간 위젯 신경 ❌
Dart Object
→ Map<String, dynamic>
→ String (HTTP Request)
String
→ Map<String, dynamic>
→ Dart Object
Before (수동 처리)
오타 발생
런타임 에러
타입 불확실
After (자동 생성)
타입 안전
중첩 객체 처리 가능
유지보수 쉬움
🏪 편의점 비유
View(손님)
→ Repository(편의점)
→ API / Mock(물건)
"콜라 주세요"
→ GET /product/cola
손님(View)은
물건이 어디서 오는지 모름
API인지 Mock인지 신경 ❌
핵심 개념
데이터 출처를 숨김
ViewModel은 Repository만 알면 됨
장점
useMock 토글로 Mock ↔ API 전환
테스트 용이
View 코드 간결
Before
URL 하드코딩
재사용 불가
View가 API 구조를 알아야 함
After
View → Repository만 의존
데이터 출처 완전 분리
View
(UI, 사용자 이벤트)
↓ 이벤트
ViewModel
(비즈니스 로직, 상태)
↓ 데이터 요청
Model / Repository
(API, 데이터 구조)
↑ 데이터 응답
각 레이어가 책임을 명확히 가짐
Props Drilling
전역 상태 관리 어려움
어디서든 상태 접근
필요한 곳만 리빌드
상태 구독
상태 변경 시 리빌드 발생
상태 읽기 전용
이벤트 핸들러에서 사용
상태 변화 감지
사이드 이펙트 처리 (다이얼로그, 스낵바 등)
상태 초기화
강제 새로고침
loading
error
data
userAsync.when(
loading: () => CircularProgressIndicator(),
error: (e, _) => Text('에러: $e'),
data: (users) => UserList(users),
);
Before
로딩 상태 직접 관리
에러 처리 직접 구현
UI 분기 코드 복잡
After
로딩 / 에러 / 데이터 자동 관리
when으로 선언적 UI 처리
가독성 향상
전체 build() 메서드 리빌드
builder 내부만 리빌드
child는 리빌드되지 않음 (성능 최적화)
MVVM
View / ViewModel / Model 분리
관심사 분리로 유지보수성 향상
Repository 패턴
데이터 출처 은닉
Mock / API 전환 용이
Riverpod
효율적인 상태 관리
의존성 주입 제공
AsyncValue
비동기 상태를 우아하게 처리
과제 제출하고 조금 여유있을 줄 알았는데 응 아니야~ 특강 2개~ 강의 고봉밥~~ 내일부터 조별과제 시작인데 큰일잉야~