본문 바로가기
개발일지/GoodWishes

[굿위시 제작기] 7. 굿즈 추가 페이지 제작과정 - 도면, 초기세팅, 사진추가 기능

by GiraffePark 2024. 9. 24.

 


굿즈를 추가할 수 있는 페이지가 필요하다

바텀 내비게이션바의 '추가' 버튼을 누르면 나오는 페이지이자, 굿즈 정보를 추가할 수 있는 기능이 포함된 페이지가 필요합니다.

 

지금부터 굿즈 추가 페이지를 만드는 과정에 대해 알아보겠습니다.

굿즈 추가 페이지는 다른 페이지와 다르게 많은 기능 구현 과정이 포함됩니다. 따라서, 여러 단계로 글을 나눠서 작성을 하겠습니다.

 

 

 

 

 


페이지 디자인

굿즈나 위시리스트에 들어갈 굿즈의 정보를 자세하게 입력하고 저장하는 기능을 담당합니다.

 

 

 


제작 결과물

아이폰 15프로를 기준으로 출력된 모습입니다.

 

 


페이지 소스코드

import 'package:flutter/material.dart';
import 'package:goodwishes/constants/ui_numbers.dart';
import 'package:goodwishes/widgets/goods/add_goods_list.dart';
import 'package:goodwishes/widgets/wish/add_wish_list.dart';
import 'package:goodwishes/widgets/change_goods_wish_button.dart';
import 'package:goodwishes/widgets/top_with_profile.dart';

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

  @override
  State<AddGoodsPage> createState() => _AddGoodsPageState();
}

class _AddGoodsPageState extends State<AddGoodsPage> {
  bool isGoods = true;
  @override
  Widget build(BuildContext context) {
    void isGoodsChangeHandler() {
      setState(() {
        isGoods = !isGoods;
      });
    }

    return SingleChildScrollView(
      scrollDirection: Axis.vertical,
      child: Column(
        children: [
          Padding(
            padding: const EdgeInsets.symmetric(
                horizontal: UIDefault.pageHorizontalPadding),
            child: TopWithProfile(title: isGoods ? 'Add Goods' : 'Add Wish'),
          ),
          const SizedBox(
            height: UIDefault.sizedBoxHeight,
          ),
          ChangeGoodsWishButton(
            isGoods: isGoods,
            onClick: isGoodsChangeHandler,
          ),
          const SizedBox(
            height: UIDefault.sizedBoxHeight,
          ),
          isGoods ? const AddGoodsList() : const AddWishList(),
        ],
      ),
    );
  }
}

 

 

 

 

 


굿즈/위시 모드 설정 버튼 구현

굿즈 추가 페이지와 위시 추가 페이지를 각각 따로 만드는 게 아니라, 한 페이지에 두 개를 모두 포함시키는 방식으로 구현하길 원했습니다.

그래서 상단의 [GoodsList | WishList] 버튼을 누르면, 굿즈모드와 위시모드가 켜지는 방식으로 구현을 하고자 했습니다.

 

 

 

          ChangeGoodsWishButton(
            isGoods: isGoods,
            onClick: isGoodsChangeHandler,
          ),

소스코드의 ChangeGoodsWishButton() 위젯이 바로 이 역할을 담당합니다.

 

 

  bool isGoods = true;
  
  @override
  Widget build(BuildContext context) {
    void isGoodsChangeHandler() {
      setState(() {
        isGoods = !isGoods;
      });
    }

bool isGoods는 현재 페이지가 굿즈모드인지 위시모드인지를 나타냅니다. true이면 굿즈모드입니다.

isGoodsChangeHandler()는 버튼을 눌렀을 때 isGoods의 상태를 바꾸는 코드입니다.

이것들을 ChangeGoodsWishButton() 위젯으로 전달합니다.

 

 

ChangeGoodsWishButton 소스코드

import 'package:flutter/material.dart';
import 'package:goodwishes/constants/ui_numbers.dart';

class ChangeGoodsWishButton extends StatelessWidget {
  final bool isGoods;
  final Function onClick;

  const ChangeGoodsWishButton({
    super.key,
    required this.isGoods,
    required this.onClick,
  });

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        onClick();
      },
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          GoodsButton(
            isGoods: isGoods,
          ),
          WishButton(
            isGoods: isGoods,
          ),
        ],
      ),
    );
  }
}

class GoodsButton extends StatelessWidget {
  final bool isGoods;
  const GoodsButton({
    super.key,
    required this.isGoods,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 30,
      width: 100,
      decoration: BoxDecoration(
        color: isGoods ? UIDefault.activeColor : UIDefault.inactiveColor,
        borderRadius: const BorderRadius.only(
          topLeft: Radius.circular(5),
          bottomLeft: Radius.circular(5),
          bottomRight: Radius.circular(0),
          topRight: Radius.circular(0),
        ),
      ),
      child: const Center(
        child: Text(
          'GoodsList',
          style: TextStyle(
            fontWeight: FontWeight.bold,
          ),
        ),
      ),
    );
  }
}

class WishButton extends StatelessWidget {
  final bool isGoods;
  const WishButton({
    super.key,
    required this.isGoods,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 30,
      width: 100,
      decoration: BoxDecoration(
        color: isGoods ? UIDefault.inactiveColor : UIDefault.activeColor,
        borderRadius: const BorderRadius.only(
          topLeft: Radius.circular(0),
          bottomLeft: Radius.circular(0),
          bottomRight: Radius.circular(5),
          topRight: Radius.circular(5),
        ),
      ),
      child: const Center(
        child: Text(
          'WishList',
          style: TextStyle(
            fontWeight: FontWeight.bold,
            color: Color(0xAA000000),
          ),
        ),
      ),
    );
  }
}

GoodsButton, WishButton 두 위젯을 Row로 엮어서, ChangeGoodsWishButton 위젯을 만드는 구조입니다.

활성화, 비활성화 됐을 때를 쉽게 구분하기 위해, 배경 색상이 눌릴 때마다 서로 바뀌도록 설정했습니다.

 

 

 


AddGoodsList

 isGoods ? const AddGoodsList() : const AddWishList(),

이제 버튼 하단의 AddGoodsList() 위젯으로 들어가보겠습니다.

 

 

 

AddGoodsList 위젯은, 사진에 나온 굿즈 정보를 입력받는 Form 전체입니다.

 

 

AddGoodsList 소스코드

import 'dart:typed_data';

import 'package:flutter/material.dart';
import 'package:goodwishes/Functions/show_info_dialog.dart';
import 'package:goodwishes/Models/category_model.dart';
import 'package:goodwishes/Models/goods_model.dart';
import 'package:goodwishes/pages/add_category_page.dart';
import 'package:goodwishes/widgets/goods/add_goods_list_el.dart';
import 'package:goodwishes/widgets/goods/add_photo.dart';
import 'package:goodwishes/widgets/date_picker.dart';
import 'package:goodwishes/widgets/memo_text_input.dart';
import 'package:goodwishes/widgets/section_title.dart';
import 'package:goodwishes/widgets/submit_button.dart';
import 'package:goodwishes/widgets/tag.dart';
import 'package:goodwishes/widgets/text_input.dart';
import 'package:provider/provider.dart';

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

  @override
  State<AddGoodsList> createState() => _AddGoodsListState();
}

class _AddGoodsListState extends State<AddGoodsList> {
  final formKey = GlobalKey<FormState>();
  Uint8List thumbnail = Uint8List.fromList([]);
  String goodsName = ' ';
  String date =
      "${DateTime.now().year.toString()}-${DateTime.now().month.toString().padLeft(2, '0')}-${DateTime.now().day.toString().padLeft(2, '0')}";
  String category = '카테고리 없음';
  String location = ' ';
  String wayToBuy = ' ';
  String memo = ' ';
  String amount = '0';
  String price = '0';
  List<String> tagList = [];

  @override
  Widget build(BuildContext context) {
    final categoryList = context.read<CategoryListProvider>();
    final goodsList = Provider.of<GoodsListProvider>(context);

    Future<void> categoryButtonHandler(BuildContext context) async {
      final pickedCategory = await Navigator.push(
        context,
        MaterialPageRoute(
          builder: (context) =>
              ChangeNotifierProvider<CategoryListProvider>.value(
            value: categoryList,
            builder: (context, child) {
              return const AddCategoryPage();
            },
          ),
        ),
      );
      if (!mounted) {
        return;
      }
      setState(() {
        category = pickedCategory;
      });
    }

    return Form(
      key: formKey,
      child: Column(
        children: [
          AddPhoto(
            onUpload: (val) {
              thumbnail = val;
            },
          ),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 24.0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                const SizedBox(
                  height: 30,
                ),
                AddGoodsListEl(
                  leftText: '굿즈 이름',
                  rightWidget: TextInput(
                    onSaved: (val) {
                      goodsName = val!;
                    },
                    hintText: '굿즈 이름',
                    //
                  ),
                ),
                AddGoodsListEl(
                  leftText: '구매 일자',
                  rightWidget: DatePicker(
                    callback: (DateTime newDate) {
                      date =
                          "${newDate.year.toString()}-${newDate.month.toString().padLeft(2, '0')}-${newDate.day.toString().padLeft(2, '0')}";
                    },
                  ),
                ),
                AddGoodsListEl(
                  leftText: '굿즈 분류',
                  rightWidget: Tag(
                    tagName: category,
                    onNavigate: categoryButtonHandler,
                  ),
                ),
                AddGoodsListEl(
                  leftText: '소지 수량',
                  rightWidget: TextInput(
                    onSaved: (val) {
                      amount = val!;
                    },
                    hintText: '소지 수량',
                    keyboardType: TextInputType.number,
                    initVal: '0',
                  ),
                ),
                AddGoodsListEl(
                  leftText: '구매 가격',
                  rightWidget: TextInput(
                    onSaved: (val) {
                      price = val!;
                    },
                    hintText: '구매 가격',
                    keyboardType: TextInputType.number,
                    initVal: '0',
                  ),
                ),
                AddGoodsListEl(
                  leftText: '구매 방법',
                  rightWidget: TextInput(
                    onSaved: (val) {
                      wayToBuy = val!;
                    },
                    hintText: '구매 방법',
                  ),
                ),
                AddGoodsListEl(
                  leftText: '보관 장소',
                  rightWidget: TextInput(
                    onSaved: (val) {
                      location = val!;
                    },
                    hintText: '보관 장소',
                  ),
                ),
                const SizedBox(
                  height: 10,
                ),
                const SectionTitle(titleText: '메모'),
                const SizedBox(
                  height: 5,
                ),
                MemoTextInput(
                  onSaved: (val) {
                    memo = val!;
                  },
                ),
                const SizedBox(
                  height: 20,
                ),
                TextButton(
                  style: const ButtonStyle(
                      padding: WidgetStatePropertyAll(EdgeInsets.zero)),
                  onPressed: () {
                    if (formKey.currentState!.validate()) {
                      formKey.currentState!.save();

                      String createdAt = DateTime.now().toString();
                      Goods newGoods = Goods(
                        id: createdAt,
                        thumbnail: thumbnail,
                        goodsName: goodsName,
                        date: date,
                        category: category,
                        location: location,
                        wayToBuy: wayToBuy,
                        memo: memo,
                        amount: int.parse(amount),
                        price: int.parse(price),
                        tagList: tagList,
                      );
                      goodsList.addGoods(newGoods);
                      categoryList.upCountCategory(category);

                      showInfoDialog(
                        context,
                        '알림',
                        '등록되었습니다.',
                      );
                    }
                  },
                  child: const SubmitButton(),
                ),
                const SizedBox(
                  height: 20,
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

 

 

이 모든 Input 위젯들을 천천히 살펴볼 예정인데, 이번 글에서는 '사진 추가' 부분만 살펴보겠습니다.

 

 

 

 

 


사진 추가하기 : AddPhoto

          AddPhoto(
            onUpload: (val) {
              thumbnail = val;
            },
          ),

'사진 추가하기' 영역을 담당하고 있는 위젯은 AddPhoto()입니다.

onUpload property를 통해서 thumbnail 변수를 수정하는 함수를 전달 받습니다.

 

 

  Uint8List thumbnail = Uint8List.fromList([]);

thumbnail은 Uint8List 타입의 변수로, 사진을 저장하는 데에 적합한 타입입니다.

사용자로부터 사진을 입력받으면, thumbnail 변수에 저장하는 것을 목표로 합니다.

 

 

 

AddPhoto 소스코드

import 'dart:typed_data';

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';

class AddPhoto extends StatefulWidget {
  const AddPhoto({
    super.key,
    required this.onUpload,
    this.alreadyImg,
  });

  final Function onUpload;
  final Uint8List? alreadyImg;

  @override
  State<AddPhoto> createState() => _AddPhotoState();
}

class _AddPhotoState extends State<AddPhoto> {
  XFile? image;
  final ImagePicker picker = ImagePicker();

  // 이미지를 가져오는 함수
  Future getImage(ImageSource imageSource) async {
    // pickedFile에 ImagePicker로 가져온 이미지가 담긴다.
    final XFile? pickedFile = await picker.pickImage(source: imageSource);

    if (pickedFile != null) {
      List<int> imageBytes = await pickedFile.readAsBytes();
      setState(() {
        image = XFile(pickedFile.path);
        widget.onUpload(Uint8List.fromList(imageBytes));
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    // print(widget.alreadyImg);
    return Container(
      width: MediaQuery.of(context).size.width,
      height: MediaQuery.of(context).size.width,
      decoration: image == null
          ? widget.alreadyImg == null
              ? const BoxDecoration(
                  color: Color(0xFFD9D9D9),
                )
              : BoxDecoration(
                  image: DecorationImage(
                    image: MemoryImage(widget.alreadyImg!),
                    fit: BoxFit.contain,
                  ),
                )
          : BoxDecoration(
              image: DecorationImage(
                image: FileImage(
                  File(image!.path),
                ),
                fit: BoxFit.contain,
              ),
            ),
      child: TextButton(
          onPressed: () {
            getImage(ImageSource.gallery);
          },
          child: image == null && widget.alreadyImg == null
              ? const Center(
                  child: Text(
                    '사진 추가하기',
                    style: TextStyle(
                      fontSize: 20,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                )
              : const Text('')),
    );
  }
}

 

 


Image_Picker 라이브러리

우선, 이미지를 불러오기 위해서 Image Picker라는 라이브러리를 사용했습니다.

공식 주소 : https://pub.dev/packages/image_picker

 

image_picker | Flutter package

Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera.

pub.dev

 

 

 


AddPhoto 위젯의 초기 변수와 함수 선언

  XFile? image;
  final ImagePicker picker = ImagePicker();

  Future getImage(ImageSource imageSource) async {
    final XFile? pickedFile = await picker.pickImage(source: imageSource);

    if (pickedFile != null) {
      List<int> imageBytes = await pickedFile.readAsBytes();
      setState(() {
        image = XFile(pickedFile.path);
        widget.onUpload(Uint8List.fromList(imageBytes));
      });
    }
  }

 

1. XFile image

XFile 타입은 이미지를 임시로 불러왔을 때, 임시 이미지가 저장된 경로를 비롯한 각종 정보를 담아놓는 역할을 합니다.

 

 

2. ImagePicker picker = ImagePicker()

아까 설치한 Image_Picker 라이브러리를 사용하기 위해, ImagePicker 인스턴스를 picker에 저장합니다.

 

 

 

3. Future getImage

  Future getImage(ImageSource imageSource) async {
    final XFile? pickedFile = await picker.pickImage(source: imageSource);

    if (pickedFile != null) {
      List<int> imageBytes = await pickedFile.readAsBytes();
      setState(() {
        image = XFile(pickedFile.path);
        widget.onUpload(Uint8List.fromList(imageBytes));
      });
    }
  }

picker.pickImage()를 통해 사진을 불러온 후, pickedFile 변수에 사진을 저장합니다.

 

만약, 사진이 정상적으로 불러와진다면 if문이 실행됩니다.

- 사진이 담긴 pickedFile 변수를 readAsBytes() 함수를 이용해서 List<int>로 변환합니다. 그리고 Uint8List로 한 번 더 변환한 후에, onUpload(외부에서 보낸 핸들러 함수)의 인수로 넘깁니다.

- image 변수에 <pickedFile의 경로를 XFile에 담은 값>을 넣어줍니다. 그러면 임시로 불러온 이미지의 메모리 주소를 따라서 미리보기 사진을 띄울 수 있게 됩니다.

 

 

 


미리보기 이미지

	BoxDecoration(
              image: DecorationImage(
                image: FileImage(
                  File(image!.path),
                ),
                fit: BoxFit.contain,
              ),
            ),

바로 위의 코드를 이용해서 사진 미리보기를 만들 수 있는데,

XFile의 path 값을 File()에 넣어준 후, 다시 FileImage() 안에 담아서, 이걸 그대로 DecoratoinImage의 image 속성에 전달해주면 됩니다.

 

 

 


완성

이제 '사진 추가하기'버튼을 누르면 사진 선택창이 뜨고, 사진을 선택하면 정상적으로 사진이 불러와집니다.

게다가 미리보기 이미지까지 잘 작동되는 것을 확인할 수 있습니다.

(사진을 바꾸고 싶다면, 미리보기 이미지를 클릭하면 됩니다.)

 

 

반응형