플러터 앱 개발

[코드 뜯어보기] 쇼핑몰 앱 만들기 B

yuna 2026. 1. 16. 21:07

안녕하세요!

오늘은 쇼핑몰 앱 코드 뜯어보기 두번째 시간입니다 ㅎㅎ

 

홈 화면을 구성하는 코드를 가져와봤는데요, 

하나씩 뜯어보면서 차근차근 공부해 보아요~~

 

 

홈 화면은 카드 형식으로 제품리스트를 구성하여

카드 코드와 리스트 코드를 분리해서 작업했는데요!

 

오늘은 리스트 코드 먼저 정리했습니다 :)


[product_list.dart 파일 전체 코드]

import 'package:flutter/material.dart';
import '../models/product.dart';
import '../widgets/product_card.dart';
import 'product_detail_page.dart';
import 'product_create_page.dart';
import 'product_shopping_cart.dart';

class ProductListPage extends StatefulWidget {
  const ProductListPage({super.key});

  @override
  State<ProductListPage> createState() => _ProductListPageState();
}

class _ProductListPageState extends State<ProductListPage> {
  // 임시 상품 데이터
  List<Product> products = [
    const Product(
      name: 'Creeper Twin Baby Epoxy Case',
      price: 35000,
      imageUrl: 'https://img.29cm.co.kr/item/202501/11efce3938a527d2ad1b2dc32f03e40b.jpg?width=700&format=webp',
      description: '에폭시 범퍼 케이스',
    ),
    const Product(
      name: 'MINT & WHITE Case',
      price: 29000,
      imageUrl: 'https://img.29cm.co.kr/item/202501/11efce5619910b2cad1bcfec99e7dd75.jpg?width=700&format=webp',
      description: '폴리카보네이트 소재의 케이스',
    ),
    const Product(
      name: '(B) Love, Romantic, Success Case',
      price: 35000,
      imageUrl: 'https://img.29cm.co.kr/item/202501/11efce3b278d52658521bb0f49478188.jpg?width=700&format=webp',
      description: '러브 에폭시 범퍼 케이스',
    ),
    const Product(
      name: 'Baby dog case',
      price: 35000,
      imageUrl: 'https://img.29cm.co.kr/item/202501/11efcd7680e37552ad1b413fee643a03.jpg?width=700&format=webp',
      description: '강아지 에폭시 케이스',
    ),
    const Product(
      name: 'White horse case',
      price: 35000,
      imageUrl: 'https://img.29cm.co.kr/item/202501/11efcd754ee8c586ad1bfd60beba8629.jpg?width=700&format=webp',
      description: '백마 에폭시 케이스',
    ),
  ];

  // 장바구니 리스트 (상품과 수량을 함께 관리)
  final List<Product> cart = [];
  final Map<String, int> cartQuantities = {}; // 상품명: 수량

  void _addToCart(Product product, int quantity) {
    setState(() {
      // 이미 장바구니에 있는 상품인지 확인
      if (cartQuantities.containsKey(product.name)) {
        // 이미 있으면 수량만 증가
        cartQuantities[product.name] = cartQuantities[product.name]! + quantity;
      } else {
        // 없으면 새로 추가
        cart.add(product);
        cartQuantities[product.name] = quantity;
      }
    });
  }

  void _navigateToDetail(Product product, int index) async {
    final shouldDelete = await Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) =>
            ProductDetailPage(product: product, onAddToCart: _addToCart),
      ),
    );

    // 상품 상세 페이지에서 삭제를 선택한 경우
    if (shouldDelete == true) {
      setState(() {
        products.removeAt(index);
      });
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('${product.name}이(가) 삭제되었습니다.'),
            duration: const Duration(seconds: 2),
          ),
        );
      }
    }
  }

  void _navigateToCreate() async {
    final result = await Navigator.push(
      context,
      MaterialPageRoute(builder: (context) => const ProductCreatePage()),
    );

    // 상품 등록 페이지에서 돌아올 때 새 상품이 있으면 추가
    if (result != null && result is Product) {
      setState(() {
        products.add(result);
      });
    }
  }

  void _navigateToCart() async {
    await Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) =>
            ProductShoppingCart(cart: cart, cartQuantities: cartQuantities),
      ),
    );
    // 장바구니에서 돌아왔을 때 화면 새로고침
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: AppBar(
        title: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            const SizedBox(width: 8),
            const Text(
              'Case Shop',
              style: TextStyle(
                fontWeight: FontWeight.w900,
                fontSize: 35,
                fontFamily: 'Schoolbell'),
            ),
          ],
        ),
        centerTitle: true,
        elevation: 0,
        backgroundColor: Colors.white,
        foregroundColor: Colors.black87,
        actions: [
          // 장바구니 아이콘
          Stack(
            children: [
              IconButton(
                icon: const Icon(Icons.shopping_cart_outlined),
                onPressed: _navigateToCart,
                tooltip: '장바구니',
              ),
              if (cart.isNotEmpty)
                Positioned(
                  right: 8,
                  top: 8,
                  child: Container(
                    padding: const EdgeInsets.all(4),
                    decoration: const BoxDecoration(
                      shape: BoxShape.circle,
                      color: Colors.red,
                    ),
                    constraints: const BoxConstraints(
                      minWidth: 16,
                      minHeight: 16,
                    ),
                    child: Text(
                      '${cartQuantities.values.fold(0, (sum, qty) => sum + qty)}',
                      style: const TextStyle(
                        fontSize: 10,
                        color: Colors.white,
                        fontWeight: FontWeight.bold,
                      ),
                      textAlign: TextAlign.center,
                    ),
                  ),
                ),
            ],
          ),
          const SizedBox(width: 8),
        ],
      ),
      body: SafeArea(
        child: products.isEmpty
            ? const Center(
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Icon(Icons.inbox, size: 80, color: Colors.grey),
                    SizedBox(height: 16),
                    Text(
                      '상품이 없습니다.',
                      style: TextStyle(
                        fontSize: 18,
                        color: Colors.grey,
                        fontWeight: FontWeight.w500,
                      ),
                    ),
                    SizedBox(height: 8),
                    Text(
                      '하단의 + 버튼을 눌러 상품을 등록해보세요.',
                      style: TextStyle(fontSize: 14, color: Colors.grey),
                    ),
                  ],
                ),
              )
            : ListView.builder(
                padding: const EdgeInsets.all(16),
                itemCount: products.length,
                itemBuilder: (context, index) {
                  final product = products[index];
                  return Padding(
                    padding: const EdgeInsets.only(bottom: 12),
                    child: ProductCard(
                      product: product,
                      onTap: () => _navigateToDetail(product, index),
                    ),
                  );
                },
              ),
      ),
      floatingActionButton: FloatingActionButton(
        shape: const CircleBorder(),
        onPressed: _navigateToCreate,
        tooltip: '상품 등록',
        child: const Icon(Icons.add),
      ),
    );
  }
}

 

1) ProductListPage (화면 선언)

class ProductListPage extends StatefulWidget {
  const ProductListPage({super.key});

  @override
  State<ProductListPage> createState() => _ProductListPageState();
}

 

  • 상품의 상태에 따라 변화값을 갖기 때문에 StatefulWidget 클래스를 사용합니다!
  • ProductListPage라는 클래스 안에서 상품 리스트를 관리

 

2) _ProductListPageState (화면의 ‘상태 + 기능’을 담는 본체)

class _ProductListPageState extends State<ProductListPage> {

 

  • 화면이 실제로 움직이는 컨트롤 센터라고 생각!
  • 여기 안에 화면에서 실제로 구성하는 상태들이 전부 들어있다

 

3) products (상품 목록 데이터)

List<Product> products = [ ... ];
  • 홈 화면에 기본 값으로 들어갈 상품들을 리스트 형태로 구성
  • 임시 데이터라서 하드 코딩으로 해두었으며, 나중에는 서버나 DB에서 받아오는 자리
-> 하드 코딩이란?
프로그래밍을 할 때 데이터를 코드 내부에 직접 고정해서 입력하는 방식
쉽게 말해서 나중에 바뀔 가능성이 있는 값을 변수나 외부 파일에서 불러오지 않고 소스 코드에 못 박아 버리는 것!

 

 

4) cart / cartQuantities (장바구니 상태)

final List<Product> cart = [];
final Map<String, int> cartQuantities = {}; // 상품명: 수량

 

  • cart -> 장바구니에 "어떤 상품이 들어있는지" 목록
  • cartQuantities -> 상품별 수량을 저장하는 맵 형태의 목록

 

5) _addToCart (장바구니 담기 로직)

void _addToCart(Product product, int quantity) {
  setState(() {
    if (cartQuantities.containsKey(product.name)) { // 이미 장바구니에 해당 상품이 들어있는지 확인
      cartQuantities[product.name] = cartQuantities[product.name]! + quantity; // 이미 있으면 수량만 추가
    } else { // 없으면 새로 추가
      cart.add(product);
      cartQuantities[product.name] = quantity;
    }
  });
}

 

  • 이 상품을 quantity만큼 장바구니에 담아 달라는 코드
  • containsKey(product.name): cartQuantities라는 저장소에 해당 상품 이름이 이미 등록되어 있는지 체크
  • cartQuantities[product.name]: 상품 이름을 '키(Key)'로 사용하여 현재 담긴 '수량(Value)'을 가져옴
  • + quantity: 새로 전달받은 수량만큼 더해줌
  • ! (Null Safety): 프로그래밍 언어(주로 Dart/Flutter)에서 "내가 방금 확인했으니까 이 값은 절대 비어있지(null) 않아, 안심하고 계산해!"라고 확신을 주는 표시

 

6) _navigateToDetail (상세 페이지로 이동 + 삭제 결과 처리)

void _navigateToDetail(Product product, int index) async {
  final shouldDelete = await Navigator.push(...);

  if (shouldDelete == true) {
    setState(() {
      products.removeAt(index);
    });
    if (mounted) {
      ScaffoldMessenger.of(context).showSnackBar(...);
    }
  }
}

 

  • await Navigator.push(...) : 상세 페이지로 전환하면서, 그 페이지가 닫힐 때까지 함수의 실행을 잠시 멈추고 기다림
    상세 페이지에서 Navigator.pop(context, true)와 같이 값을 돌려주면, 그 값이 shouldDelete 변수에 저장