[TIL] Day 37 Semantics / ConstrainedBox / 마켓 앱 만들기 - 상품 상세페이지 구현, 상품 등록, 수정 페이지, 채팅 리스트 / 유저 플로우(User Flow) 특강 / 숙련 Recap 세션 특강

현서·2026년 1월 15일

[TIL] Flutter 9기

목록 보기
49/65
post-thumbnail

📍 튜터님과 Widget 공부

✏️ Semantics 위젯

Semantics란?

Flutter UI에 의미(semantic information) 를 부여해서
스크린 리더(VoiceOver, TalkBack)가 UI를 올바르게 해석하도록 돕는 위젯

화면에 “보이는 것”이 아니라 스크린 리더가 “이해하는 것”을 정의하는 위젯

MaterialApp showSemanticsDebugger: true 속성을 켜서 확인 가능

왜 Semantics가 필요한가?

시각적 UI의 한계

GestureDetector(
  onTap: () {},
  child: Icon(Icons.add),
)

사용자 눈: ➕ 버튼
스크린 리더: ??

Semantics 적용

Semantics(
  label: '추가 버튼',
  button: true,
  child: Icon(Icons.add),
)

스크린 리더: 추가 버튼, 버튼

Semantics의 역할

기능설명
의미 전달버튼, 이미지, 체크박스 등 역할 정의
상태 전달활성/비활성, 체크 여부
값 전달슬라이더 값, 진행률
행동 힌트“두 번 탭하세요” 같은 안내

기본 구조

Semantics(
  label: '설명',
  hint: '행동 안내',
  button: true,
  enabled: true,
  child: 위젯,
)

주요 속성 정리

label (필수)

label: '로그인 버튼'

스크린 리더가 읽는 핵심 텍스트
UI에 Text가 없어도 반드시 제공

hint

hint: '두 번 탭하면 로그인합니다'

사용자에게 행동 방법 안내
선택 사항이지만 UX 향상에 좋음

button / image / checkbox 등

button: true
image: true
checkbox: true

위젯의 역할(role) 을 명시

enabled / disabled

enabled: false

비활성 상태 전달

checked

checked: true

체크박스, 토글에서 필수
선택됨 / 선택 안 됨 읽힘

value

value: '70%'

슬라이더, 프로그레스바용

focusable

focusable: true

키보드/스크린 리더 포커스 가능 여부

의미 제어 위젯들

ExcludeSemantics

자식 위젯의 의미 제거

ExcludeSemantics(
  child: Text('읽히지 않음'),
)

MergeSemantics

여러 위젯을 하나의 의미로 묶기

MergeSemantics(
  child: Row(
    children: [
      Icon(Icons.shopping_cart),
      Text('장바구니'),
    ],
  ),
)

✏️ ConstrainedBox

ConstrainedBox란?

자식 위젯의 최소/최대 크기를 제한하는 위젯
Flutter 레이아웃에서
“이 위젯은 이 범위 안에서만 커지거나 작아질 수 있어”
라고 제약(constraint) 을 거는 역할을 함

기본 구조

ConstrainedBox(
  constraints: BoxConstraints(
    minWidth: 100,
    maxWidth: 200,
    minHeight: 50,
    maxHeight: 150,
  ),
  child: Widget,
)

자식은 이 범위를 벗어날 수 없음

BoxConstraints 개념

속성의미
minWidth최소 가로 크기
maxWidth최대 가로 크기
minHeight최소 세로 크기
maxHeight최대 세로 크기

ConstrainedBox 동작 원리

Flutter 레이아웃은 항상 부모 → 자식 방향으로 제약을 전달

부모 constraints
   ↓
ConstrainedBox (제약 추가/강화)
   ↓
자식 Widget

ConstrainedBox는 부모 제약을 무시하지 않는다
→ 기존 제약 위에 추가로 제한을 건다

SizedBox / LimitedBox와 차이

ConstrainedBox vs SizedBox

항목ConstrainedBoxSizedBox
크기범위 지정고정
유연성높음낮음
용도제한명확한 크기

SizedBox = 강제
ConstrainedBox = 제한

ConstrainedBox vs LimitedBox

항목ConstrainedBoxLimitedBox
부모 제약 있음동작함무시됨
부모 제약 없음동작함이때만 제한
사용 상황일반적ListView 등

📝 마켓 앱 만들기

✏️ 상품 상세페이지 구현

ProductDetailViewModel

📁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 Model copyWith 함수 추가

Product copyWith({ bool? myLike })

Riverpod 상태는 immutable
좋아요 토글 시 전체 Product 재생성 필요
UI 즉시 반영 가능

ProductDetailPage

ProductDetailPage(this.productId);

모든 하위 위젯에 productId 전달

ProductDetailActions 수정

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

ProductDetailPicture

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 → 빈 위젯

ProductDetailBody

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

유저 프로필
제목 / 카테고리 / 시간
본문 내용 표시

ProductDetailBottomSheet

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 상품 리스트 재조회

✏️ 상품 등록 / 수정 페이지

ProductCategoryRepository

📁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

카테고리 Repository 테스트 코드

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

로그인 선행 필요
서버 연동 정상 여부 확인

ProductRepository (등록 / 수정 포함)

  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 전달 필수

ProductWriteViewModel

📁lib/ui/pages/product_write/widgets/product_write_viewModel.dart

ProductWriteState

상태 클래스 만들기

class ProductWriteState {
  final List<FileModel> imageFiles;        // 업로드된 이미지
  final List<ProductCategory> categories; // 카테고리 목록
  final ProductCategory? selectedCategory; // 선택된 카테고리
}

화면에서 필요한 입력 상태 관리
ViewModel 외부에서는 상태만 관찰

ProductWriteViewModel

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 → 상품 수정

  • 하나의 ViewModel로 생성/수정 공용 처리

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

Provide

final productWriteViewModelProvider =
  NotifierProvider.autoDispose
    .family<ProductWriteViewModel, ProductWriteState, Product?>(
      (product) => ProductWriteViewModel(product),
    );

family → Product 전달 가능
autoDispose → 화면 나가면 상태 제거
생성 / 수정 화면 공용 사용

ProductCategoryBox

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)

ProductWritePictureArea

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

ProductWritePage

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
}

ProductDetailActions

삭제
수정 페이지 이동

Navigator.push(
  context,
  MaterialPageRoute(
    builder: (_) => ProductWritePage(state),
  ),
);

✔️ 수정 → Product 전달

HomeFloatingActionButton

상품 등록 진입

ProductWritePage(null);

✔️ 등록 → null 전달

✏️ 채팅 리스트 & 채팅 상세 (HTTP 기반)

채팅 목록 / 채팅 상세 화면 구현
HTTP 기반 채팅 데이터 구조 이해
이후 소켓 통신(실시간 채팅) 을 위한 전역 상태 설계

채팅 기능 전체 구조

ChatRepository (HTTP)
        ↓
ChatGlobalViewModel (전역 상태)
        ↓
ChatTab (채팅 목록)
ChatDetailPage (채팅 상세)

✔ 채팅은 여러 화면에서 공유
✔ 그래서 Global ViewModel 사용

채팅 도메인 모델 구조

ChatRoom
 ├─ ChatProduct (상품 정보)
 ├─ sender (채팅 시작한 유저)
 └─ messages (ChatMessage 리스트)

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

역할
채팅 메시지 단위 데이터

핵심 필드

  • messageType
    누가 보낸 메시지인지 판별
  • createdAt
    시간 표시 및 정렬 기준

UI에서 내 메시지 / 상대 메시지 구분에 사용

ChatProduct

📁 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 전체가 아닌
✔ 채팅에 필요한 최소 정보만 포함

ChatRoom

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

역할
채팅방 단위 데이터

핵심

  • roomId → 채팅방 식별자
  • messages → 채팅 메시지 목록
  • product → 어떤 상품의 채팅인지

채팅 목록 / 상세 모두 이 모델 사용


📘 유저 플로우(User Flow) 특강

@장나슬 튜터

유저의 길을 만드는 디자이너란?

UX 디자이너 = 유저의 ‘이동 경로’를 설계하는 사람
유저는 서비스를 사용할 때
👉 목적을 가지고 → 행동을 선택 → 결과를 얻음

디자이너는 이 흐름이
막히지 않는지
헷갈리지 않는지
최소한의 생각으로 목적에 도달하는지
를 설계해야 함

➡️ 유저 플로우(User Flow)
= 유저가 목표를 달성하기까지 거치는 모든 단계의 지도

UX와 UI의 정의

UX (User Experience)

사용자가 제품/서비스를 사용하며 느끼는 전체 경험
단순히 화면 예쁜 게 아님
사용 전–중–후 모든 경험 포함

  • 핵심 키워드:
    사용자 이해
    문제 정의
    사용성
    맥락(Context)

  • UX에서 중요한 것
    사용자를 관찰하는 것
    어떤 선택을 왜 했는지 이해하는 것
    그 이해를 바탕으로 구조를 설계하는 것

UI (User Interface)

사용자의 행동을 유도하는 시각적·물리적 인터페이스
버튼, 색상, 레이아웃, 타이포
사용자가 “여길 눌러야겠네” 하고 자연스럽게 행동하도록 만드는 것

UI의 종류

구분설명예시
PUI (Physical User Interface)물리적 인터페이스버튼, 다이얼, 키오스크
GUI (Graphic User Interface)그래픽 기반 인터페이스앱, 웹 화면

추천 도서 📖

『(사용자를) 생각하게 하지 마!』

  • 이 책의 핵심 메시지:
    사용자는 생각하기 싫어한다
    헷갈리는 순간 → 이탈

  • 좋은 UX란:
    설명이 필요 없는 것
    한눈에 이해되는 것

  • 핵심 포인트
    제품·시스템의 목적을 명확히 파악
    문제 해결 중심
    “이거 어떻게 쓰는 거지?”라는 생각이 들지 않게 만들기

나중에 읽어볼 것

UX 서비스 디자인 프로세스

더블 다이아몬드(Double Diamond) 방법론

발산 ↔ 수렴을 반복하는 구조

문제 영역                 해결 영역
          ◇                 ◇
Discover → Define → Develop → Deliver

전체 흐름 요약

단계의미핵심 질문
Discover문제 발견무슨 문제가 있지?
Define문제 정의진짜 문제는 뭐지?
Develop아이디어 확산어떻게 해결할 수 있지?
Deliver해결책 도출가장 좋은 해결책은?

단계별 상세 정리

Discover (발견)

문제와 맥락을 최대한 넓게 이해

Desk Research
기존 서비스, 트렌드 조사
시장 배경, 사례 분석

경쟁사 분석
잘된 점 / 불편한 점
차별 포인트

유저 리서치
설문조사 (정량)
유저 인터뷰 (정성)

➡️ 이 단계에서는
해결책 생각 X
“왜 이런 문제가 생겼지?”에 집중

Define (정의)

문제를 명확하게 정의

Persona
가상의 대표 사용자
목표, 니즈, 페인포인트 정리

➡️ 이 단계에서는
이 서비스의 핵심 유저는 누구인가
유저가 진짜로 불편해하는 지점은 무엇인가

Develop (아이디어 확산)

다양한 해결 방법을 시도
아이디어 스케치
와이어프레임
유저 플로우 설계
여러 안을 동시에 만들어보기

➡️ “이 방법 말고도 없을까?” 계속 질문

Deliver (전달 / 구현)

실제로 검증 가능한 형태로 만들기

  • Prototype
    클릭 가능한 시안
    저충실도 → 고충실도

  • 테스트
    유저 반응 확인
    문제점 발견

반복

UX 디자인은 한 번에 끝나는 작업이 아님
만들고 → 테스트하고 → 고치고 → 다시 만든다
실패는 당연한 과정
빠르게 만들고 빠르게 검증하는 게 중요

마무리

UX 디자이너는 사용자를 관찰하고, 문제를 정의하고,
사용자가 가장 덜 생각하고 목표에 도달하게 만드는 길을 설계하는 사람이다.


숙련 Recap 세션 특강

@이주원 튜터님

왜 MVVM(Riverpod)을 사용하는가?

🔴 기존 방식 (문제점)

  • 모든 로직이 위젯에 집중됨
    API 호출
    상태 관리
    UI 렌더링
    → 전부 한 위젯 안에 섞여 있음

  • 결과
    코드 가독성 ↓
    테스트 어려움
    재사용 불가능
    유지보수 지옥

개선된 방식 : 관심사 분리 (Separation of Concerns)

역할책임
Model데이터 구조, Repository, API
ViewUI 렌더링, 사용자 이벤트
ViewModel비즈니스 로직, 상태 관리

각자 잘하는 일만 하게 분리

Props Drilling 문제

Before (콜백 지옥)
상태 변경을 위해 콜백을 계속 전달
깊어질수록 유지보수 불가능

After (Riverpod)
ref로 어디서든 상태 접근
중간 위젯 신경 ❌

JSON 직렬화 / 역직렬화

📤 직렬화 (Dart → API)

Dart Object
→ Map<String, dynamic>
→ String (HTTP Request)

📥 역직렬화 (API → Dart)

String
→ Map<String, dynamic>
→ Dart Object

Before (수동 처리)
오타 발생
런타임 에러
타입 불확실

After (자동 생성)
타입 안전
중첩 객체 처리 가능
유지보수 쉬움

REST API 처리 흐름

🏪 편의점 비유

View(손님)
→ Repository(편의점)
→ API / Mock(물건)

"콜라 주세요"
→ GET /product/cola

손님(View)은
물건이 어디서 오는지 모름
API인지 Mock인지 신경 ❌

Repository 패턴

핵심 개념
데이터 출처를 숨김
ViewModel은 Repository만 알면 됨

장점
useMock 토글로 Mock ↔ API 전환
테스트 용이
View 코드 간결

REST API 비교

Before
URL 하드코딩
재사용 불가
View가 API 구조를 알아야 함

After
View → Repository만 의존
데이터 출처 완전 분리

MVVM 아키텍처 흐름

View
(UI, 사용자 이벤트)
   ↓ 이벤트
ViewModel
(비즈니스 로직, 상태)
   ↓ 데이터 요청
Model / Repository
(API, 데이터 구조)
   ↑ 데이터 응답

각 레이어가 책임을 명확히 가짐

Riverpod 핵심 개념

❌ setState의 한계

Props Drilling
전역 상태 관리 어려움

✅ Riverpod 해결책

어디서든 상태 접근
필요한 곳만 리빌드

ref 사용법

ref.watch

상태 구독
상태 변경 시 리빌드 발생

ref.read

상태 읽기 전용
이벤트 핸들러에서 사용

ref.listen

상태 변화 감지
사이드 이펙트 처리 (다이얼로그, 스낵바 등)

ref.invalidate

상태 초기화
강제 새로고침

syncValue

비동기 상태 3가지

loading
error
data

UI 처리 예시

userAsync.when(
  loading: () => CircularProgressIndicator(),
  error: (e, _) => Text('에러: $e'),
  data: (users) => UserList(users),
);

AsyncValue

Before
로딩 상태 직접 관리
에러 처리 직접 구현
UI 분기 코드 복잡

After
로딩 / 에러 / 데이터 자동 관리
when으로 선언적 UI 처리
가독성 향상

Consumer vs ConsumerWidget

ConsumerWidget

전체 build() 메서드 리빌드

Consumer

builder 내부만 리빌드
child는 리빌드되지 않음 (성능 최적화)

핵심 요약

MVVM
View / ViewModel / Model 분리
관심사 분리로 유지보수성 향상

Repository 패턴
데이터 출처 은닉
Mock / API 전환 용이

Riverpod
효율적인 상태 관리
의존성 주입 제공

AsyncValue
비동기 상태를 우아하게 처리

공부 소감

과제 제출하고 조금 여유있을 줄 알았는데 응 아니야~ 특강 2개~ 강의 고봉밥~~ 내일부터 조별과제 시작인데 큰일잉야~

0개의 댓글