2024. 2. 12. 14:50ㆍ모바일어플개발/Flutter 실전어플 개발
안녕하세요~ totally 개발자입니다.
오늘은 결제시작 부분에서 결제 버튼을 눌렀을 때, 주문하고자 하는 제품들을 파이어스토어에 담아보도록 하겠습니다.
Step 1: 파이어스토어에 접속하셔서 orders 컬렉션을 생성해주시기 바랍니다. 다음을 눌러서 임의로 문서(document) 하나 추가해주시면 됩니다.

Step 2: lib > item_checkout_page.dart에서 checkoutContainer 부분에 혹시 height 지정을 안하신 경우에 height: 130 정도로 설정해주시기 바랍니다.

Step 3: lib > models > order.dart 파일을 열어서 아래처럼 수정해줍니다. class Order로 하면 다른 dart 파일과 겹치는 내용들이 있는 것 같아 ProductOrder로 변경하여 구분하였습니다.
class ProductOrder {
String? orderNo;
int? productNo;
String? orderDate;
String? buyerName;
String? buyerEmail;
String? buyerPhone;
String? receiverName;
String? receiverPhone;
String? receiverZip;
String? receiverAddress1;
String? receiverAddress2;
String? userPwd;
String? paymentMethod;
int? quantity;
double? unitPrice;
double? totalPrice;
String? paymentStatus;
String? deliveryStatus;
ProductOrder({
this.orderNo,
this.productNo,
this.orderDate,
this.buyerName,
this.buyerEmail,
this.buyerPhone,
this.receiverName,
this.receiverPhone,
this.receiverZip,
this.receiverAddress1,
this.receiverAddress2,
this.userPwd,
this.paymentMethod,
this.quantity,
this.unitPrice,
this.totalPrice,
this.paymentStatus,
this.deliveryStatus,
});
ProductOrder.fromJson(Map<String, dynamic> json) {
orderNo = json['orderNo'];
productNo = json['productNo'];
orderDate = json['orderDate'];
buyerName = json['buyerName'];
buyerEmail = json['buyerEmail'];
buyerPhone = json['buyerPhone'];
receiverName = json['receiverName'];
receiverPhone = json['receiverPhone'];
receiverZip = json['receiverZip'];
receiverAddress1 = json['receiverAddress1'];
receiverAddress2 = json['receiverAddress2'];
userPwd = json['userPwd'];
paymentMethod = json['paymentMethod'];
quantity = json['quantity'];
unitPrice = double.parse(json['unitPrice']);
totalPrice = double.parse(json['totalPrice']);
paymentStatus = json['paymentStatus'];
deliveryStatus = json['deliveryStatus'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = {};
data['orderNo'] = orderNo;
data['productNo'] = productNo;
data['orderDate'] = orderDate;
data['buyerName'] = buyerName;
data['buyerEmail'] = buyerEmail;
data['buyerPhone'] = buyerPhone;
data['receiverName'] = receiverName;
data['receiverPhone'] = receiverPhone;
data['receiverZip'] = receiverZip;
data['receiverAddress1'] = receiverAddress1;
data['receiverAddress2'] = receiverAddress2;
data['userPwd'] = userPwd;
data['paymentMethod'] = paymentMethod;
data['quantity'] = quantity;
data['unitPrice'] = unitPrice;
data['totalPrice'] = totalPrice;
data['paymentStatus'] = paymentStatus;
data['deliveryStatus'] = deliveryStatus;
return data;
}
}
Step 4: lib > enums > delivery_status.dart 파일을 아래처럼 업데이트해줍니다.
enum DeliveryStatus {
//! 상태 열거
waiting('waiting', '배송대기'),
delivering('delivering', '배송중'),
delivered('delivered', '배송완료');
//! 생성자
const DeliveryStatus(this.status, this.statusName);
final String status;
final String statusName;
//! 상태 이름 변환
factory DeliveryStatus.getStatusName(String status) {
return DeliveryStatus.values.firstWhere((value) => value.status == status,
orElse: () => DeliveryStatus.waiting);
}
}
여기에서 enum을 사용해서 각각의 상태를 체계적으로 관리하기 위함입니다. 보통 직접적으로 데이터를 다룰 때 위와 같이 정해진 값을 사용하는 경우에는 위처럼 enum 등을 사용하여 체계적으로 관리하는 것이 좋습니다.
Step 5: lib > enums > payment_status.dart 파일도 아래처럼 수정해줍니다.
enum PaymentStatus {
//! 상태 열거
waiting('waiting', '입금대기'),
completed('completed', '결제완료'),
cancelled('cancelled', '주문취소');
//! 생성자
const PaymentStatus(this.status, this.statusName);
final String status;
final String statusName;
//! 상태 이름 변환
factory PaymentStatus.getStatusName(String status) {
return PaymentStatus.values.firstWhere((value) => value.status == status,
orElse: () => PaymentStatus.waiting);
}
}
Step 6: 먼저 pubspec.yaml에서 crypto 패키지를 설치해주시고 item_checkout_page.dart 파일로 가셔서 intl, crypto 패키지 임포트 해주신 뒤에 아래 빨간색 박스 내용을 추가합니다. 245-247번째 줄은 비밀번호를 sha256 알고리즘으로 암호화하기 위함이고 248-249번째 줄은 주문번호를 현재 일시에 맞춰서 생성하기 위함입니다. (물론 이 경우에도 중복은 나올 수 있습니다)



Step 7: 빨간 박스 아래에 아래 내용을 추가해줍니다. 이 강좌에서는 한 데이터 document에 여러 제품을 포함할 수 없는 구조이며 무조건 문서 1개당 제품 하나만 담을 수 있는 구조입니다. 그래서 여러 제품을 담는 경우에는 orderNo로 묶어서 표현할 수 있습니다. 실제 업무에서는 아래처럼 하기 보다는 products 변수가 별도로 있어서 배열 형태로 들어가는 것이 권장됩니다. 다만 제 강좌에서는 최대한 간단하게 하기 위해 배제하였습니다. try catch 구문을 넣어 예외 처리 해주시고, database.collection("orders").add 메소드를 통해서 데이터를 추가하면 됩니다.
//! 이 부분에 파이어스토어에 접근해서 데이터 insert 작업 진행함.
snapshot.data?.docs.forEach(
(document) {
ProductOrder productOrder = ProductOrder(
orderNo: orderNo,
productNo: document.data().productNo,
orderDate: DateFormat("y-M-d h:m:s")
.format(DateTime.now()),
buyerName: buyerNameController.text,
buyerEmail: buyerEmailController.text,
buyerPhone: buyerPhoneController.text,
receiverName: receiverNameController.text,
receiverPhone: receiverPhoneController.text,
receiverZip: receiverZipController.text,
receiverAddress1:
receiverAddress1Controller.text,
receiverAddress2:
receiverAddress2Controller.text,
userPwd: hashPwd.toString(),
paymentMethod: selectedPaymentMethod,
quantity: cartMap[
document.data().productNo.toString()],
unitPrice: document.data().price,
totalPrice: cartMap[document
.data()
.productNo
.toString()] *
document.data().price,
paymentStatus:
PaymentStatus.waiting.statusName,
deliveryStatus:
DeliveryStatus.waiting.statusName,
);
print(jsonEncode(productOrder));
try {
database
.collection("orders")
.add(productOrder.toJson());
} catch (e) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
content: Padding(
padding: const EdgeInsets.all(15.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Center(
child: Text("오류가 발생 했습니다.")),
],
),
),
actions: [
Center(
child: FilledButton(
onPressed: () =>
Navigator.pop(context),
child: Text("확인")),
),
],
);
},
);
//! 아래 부분이 더 이상 호출되지 않도록 return합니다.
return;
}
},
);
예외가 있는 경우 아래처럼 Dialog를 띄워서 오류가 발생했는지 띄워주시면 됩니다. (실제로는 어떤 오류인지까지 표현하는 것이 권장됩니다)

Step 8: 아래처럼 제품을 담고 입력한 뒤 테스트해봅니다.

제가 위 코드에 print로 productOrder 객체를 출력되도록 한 결과는 아래와 같습니다.

그리고 아래처럼 주문완료 페이지로 넘어가게 됩니다.

Step 9: 파이어스토어(Firestore)에서 아래처럼 바로 제품 3개의 주문이 들어간 것을 확인해볼 수 있습니다.

다음 시간에는 내가 주문한 데이터들을 확인해볼 수 있도록 작업을 진행하겠습니다. 감사합니다.
[전체 소스 코드]
[item_checkout_page.dart]
import 'dart:convert'; | |
import 'package:cloud_firestore/cloud_firestore.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:cached_network_image/cached_network_image.dart'; | |
import 'package:intl/intl.dart'; | |
import 'package:project/components/basic_dialog.dart'; | |
import 'package:project/constants.dart'; | |
import 'package:project/enums/delivery_status.dart'; | |
import 'package:project/enums/payment_status.dart'; | |
import 'package:project/item_order_result_page.dart'; | |
import 'package:project/models/order.dart'; | |
import 'package:project/models/product.dart'; | |
import 'package:kpostal/kpostal.dart'; | |
import 'package:crypto/crypto.dart'; | |
class ItemCheckoutPage extends StatefulWidget { | |
const ItemCheckoutPage({super.key}); | |
@override | |
State<ItemCheckoutPage> createState() => _ItemCheckoutPageState(); | |
} | |
class _ItemCheckoutPageState extends State<ItemCheckoutPage> { | |
final database = FirebaseFirestore.instance; | |
Query<Product>? productListRef; | |
double totalPrice = 0; | |
Map<String, dynamic> cartMap = {}; | |
Stream<QuerySnapshot<Product>>? productList; | |
List<int> keyList = []; | |
final formKey = GlobalKey<FormState>(); | |
//! controller 변수 추가 | |
TextEditingController buyerNameController = TextEditingController(); | |
TextEditingController buyerEmailController = TextEditingController(); | |
TextEditingController buyerPhoneController = TextEditingController(); | |
TextEditingController receiverNameController = TextEditingController(); | |
TextEditingController receiverPhoneController = TextEditingController(); | |
TextEditingController receiverZipController = TextEditingController(); | |
TextEditingController receiverAddress1Controller = TextEditingController(); | |
TextEditingController receiverAddress2Controller = TextEditingController(); | |
TextEditingController userPwdController = TextEditingController(); | |
TextEditingController userConfirmPwdController = TextEditingController(); | |
TextEditingController cardNoController = TextEditingController(); | |
TextEditingController cardAuthController = TextEditingController(); | |
TextEditingController cardExpiredDateController = TextEditingController(); | |
TextEditingController cardPwdTwoDigitsController = TextEditingController(); | |
TextEditingController depositNameController = TextEditingController(); | |
//! 결제수단 옵션 선택 변수 | |
final List<String> paymentMethodList = [ | |
'결제수단선택', | |
'카드결제', | |
'무통장입금', | |
]; | |
String selectedPaymentMethod = "결제수단선택"; | |
@override | |
void initState() { | |
super.initState(); | |
//! 저장한 장바구니 리스트 가져오기 | |
try { | |
cartMap = | |
json.decode(sharedPreferences.getString("cartMap") ?? "{}") ?? {}; | |
} catch (e) { | |
debugPrint(e.toString()); | |
cartMap = {}; | |
} | |
//! 조건문에 넘길 product no 키 값 리스트를 선언 (기존 값이 string이어서 int로 변환) | |
cartMap.forEach( | |
(key, value) { | |
keyList.add(int.parse(key)); | |
}, | |
); | |
//! 파이어스토어에서 데이터 가져오는 Ref 변수 | |
if (keyList.isNotEmpty) { | |
productListRef = FirebaseFirestore.instance | |
.collection("products") | |
.withConverter( | |
fromFirestore: (snapshot, _) => | |
Product.fromJson(snapshot.data()!), | |
toFirestore: (product, _) => product.toJson()) | |
.where("productNo", whereIn: keyList); | |
} | |
productList = productListRef?.orderBy("productNo").snapshots(); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
appBar: AppBar( | |
title: const Text("결제시작"), | |
centerTitle: true, | |
), | |
body: SingleChildScrollView( | |
child: Column( | |
children: [ | |
if (cartMap.isNotEmpty) | |
StreamBuilder( | |
stream: productList, | |
builder: (context, snapshot) { | |
if (snapshot.hasData) { | |
return ListView( | |
shrinkWrap: true, | |
children: snapshot.data!.docs.map((document) { | |
if (cartMap[document.data().productNo.toString()] != | |
null) { | |
return checkoutContainer( | |
productNo: document.data().productNo ?? 0, | |
productName: document.data().productName ?? "", | |
productImageUrl: | |
document.data().productImageUrl ?? "", | |
price: document.data().price ?? 0, | |
quantity: cartMap[ | |
document.data().productNo.toString()]); | |
} | |
return Container(); | |
}).toList(), | |
); | |
} else if (snapshot.hasError) { | |
return const Center( | |
child: Text("오류가 발생 했습니다."), | |
); | |
} else { | |
return const Center( | |
child: CircularProgressIndicator( | |
strokeWidth: 2, | |
), | |
); | |
} | |
}, | |
), | |
//! 입력폼 필드 | |
Form( | |
key: formKey, | |
child: Column( | |
children: [ | |
inputTextField( | |
currentController: buyerNameController, | |
currentHintText: "주문자명"), | |
inputTextField( | |
currentController: buyerEmailController, | |
currentHintText: "주문자 이메일"), | |
inputTextField( | |
currentController: buyerPhoneController, | |
currentHintText: "주문자 휴대전화"), | |
inputTextField( | |
currentController: receiverNameController, | |
currentHintText: "받는 사람 이름"), | |
inputTextField( | |
currentController: receiverPhoneController, | |
currentHintText: "받는 사람 휴대 전화"), | |
receiverZipTextField(), | |
inputTextField( | |
currentController: receiverAddress1Controller, | |
currentHintText: "기본 주소", | |
isReadOnly: true), | |
inputTextField( | |
currentController: receiverAddress2Controller, | |
currentHintText: "상세 주소"), | |
inputTextField( | |
currentController: userPwdController, | |
currentHintText: "비회원 주문조회 비밀번호", | |
isObscure: true), | |
inputTextField( | |
currentController: userConfirmPwdController, | |
currentHintText: "비회원 주문조회 비밀번호 확인", | |
isObscure: true), | |
paymentMethodDropdownButton(), | |
if (selectedPaymentMethod == "카드결제") | |
Column( | |
children: [ | |
inputTextField( | |
currentController: cardNoController, | |
currentHintText: "카드번호"), | |
inputTextField( | |
currentController: cardAuthController, | |
currentHintText: "카드명의자 주민번호 앞자리 또는 사업자번호", | |
currentMaxLength: 10), | |
inputTextField( | |
currentController: cardExpiredDateController, | |
currentHintText: "카드 만료일 (YYYYMM)", | |
currentMaxLength: 6), | |
inputTextField( | |
currentController: cardPwdTwoDigitsController, | |
currentHintText: "카드 비밀번호 앞2자리", | |
currentMaxLength: 2, | |
isObscure: true), | |
], | |
), | |
if (selectedPaymentMethod == "무통장입금") | |
inputTextField( | |
currentController: depositNameController, | |
currentHintText: "입금자명"), | |
], | |
), | |
), | |
], | |
), | |
), | |
bottomNavigationBar: cartMap.isEmpty | |
? const Center( | |
child: Text("결제할 제품이 없습니다."), | |
) | |
: StreamBuilder( | |
stream: productList, | |
builder: (context, snapshot) { | |
if (snapshot.hasData) { | |
totalPrice = 0; | |
snapshot.data?.docs.forEach((document) { | |
if (cartMap[document.data().productNo.toString()] != null) { | |
totalPrice += | |
cartMap[document.data().productNo.toString()] * | |
document.data().price ?? | |
0; | |
} | |
}); | |
return Padding( | |
padding: const EdgeInsets.all(20), | |
child: FilledButton( | |
onPressed: () { | |
if (formKey.currentState!.validate()) { | |
if (selectedPaymentMethod == "결제수단선택") { | |
showDialog( | |
context: context, | |
barrierDismissible: true, | |
builder: (context) { | |
return BasicDialog( | |
content: "결제수단을 선택해 주세요.", | |
buttonText: "닫기", | |
buttonFunction: () => | |
Navigator.of(context).pop(), | |
); | |
}, | |
); | |
return; | |
} | |
List<int> bytes = | |
utf8.encode(userPwdController.text); | |
Digest hashPwd = sha256.convert(bytes); | |
String orderNo = | |
"${DateFormat("yMdhms").format(DateTime.now())}-${DateTime.now().millisecond}"; | |
//! 이 부분에 파이어스토어에 접근해서 데이터 insert 작업 진행함. | |
snapshot.data?.docs.forEach( | |
(document) { | |
ProductOrder productOrder = ProductOrder( | |
orderNo: orderNo, | |
productNo: document.data().productNo, | |
orderDate: DateFormat("y-M-d h:m:s") | |
.format(DateTime.now()), | |
buyerName: buyerNameController.text, | |
buyerEmail: buyerEmailController.text, | |
buyerPhone: buyerPhoneController.text, | |
receiverName: receiverNameController.text, | |
receiverPhone: receiverPhoneController.text, | |
receiverZip: receiverZipController.text, | |
receiverAddress1: | |
receiverAddress1Controller.text, | |
receiverAddress2: | |
receiverAddress2Controller.text, | |
userPwd: hashPwd.toString(), | |
paymentMethod: selectedPaymentMethod, | |
quantity: cartMap[ | |
document.data().productNo.toString()], | |
unitPrice: document.data().price, | |
totalPrice: cartMap[document | |
.data() | |
.productNo | |
.toString()] * | |
document.data().price, | |
paymentStatus: | |
PaymentStatus.waiting.statusName, | |
deliveryStatus: | |
DeliveryStatus.waiting.statusName, | |
); | |
print(jsonEncode(productOrder)); | |
try { | |
database | |
.collection("orders") | |
.add(productOrder.toJson()); | |
} catch (e) { | |
showDialog( | |
context: context, | |
builder: (context) { | |
return AlertDialog( | |
content: Padding( | |
padding: const EdgeInsets.all(15.0), | |
child: Column( | |
mainAxisSize: MainAxisSize.min, | |
children: [ | |
Center( | |
child: Text("오류가 발생 했습니다.")), | |
], | |
), | |
), | |
actions: [ | |
Center( | |
child: FilledButton( | |
onPressed: () => | |
Navigator.pop(context), | |
child: Text("확인")), | |
), | |
], | |
); | |
}, | |
); | |
//! 아래 부분이 더 이상 호출되지 않도록 return합니다. | |
return; | |
} | |
}, | |
); | |
Navigator.of(context).push(MaterialPageRoute( | |
builder: (context) { | |
return ItemOrderResultPage( | |
paymentMethod: selectedPaymentMethod, | |
paymentAmount: totalPrice, | |
receiverName: receiverNameController.text, | |
receiverPhone: receiverPhoneController.text, | |
zip: receiverZipController.text, | |
address1: receiverAddress1Controller.text, | |
address2: receiverAddress2Controller.text, | |
); | |
}, | |
)); | |
} | |
}, | |
child: | |
Text("총 ${numberFormat.format(totalPrice)}원 결제하기"), | |
)); | |
} else if (snapshot.hasError) { | |
return const Center( | |
child: Text("오류가 발생 했습니다."), | |
); | |
} else { | |
return const Center( | |
child: CircularProgressIndicator( | |
strokeWidth: 2, | |
), | |
); | |
} | |
}, | |
), | |
); | |
} | |
Widget checkoutContainer({ | |
required int productNo, | |
required String productName, | |
required String productImageUrl, | |
required double price, | |
required int quantity, | |
}) { | |
return Container( | |
padding: const EdgeInsets.all(8), | |
child: Row( | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
CachedNetworkImage( | |
width: MediaQuery.of(context).size.width * 0.3, | |
height: 130, | |
fit: BoxFit.cover, | |
imageUrl: productImageUrl, | |
placeholder: (context, url) { | |
return const Center( | |
child: CircularProgressIndicator( | |
strokeWidth: 2, | |
), | |
); | |
}, | |
errorWidget: (context, url, error) { | |
return const Center( | |
child: Text("오류 발생"), | |
); | |
}, | |
), | |
Container( | |
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 8), | |
child: Column( | |
crossAxisAlignment: CrossAxisAlignment.start, | |
mainAxisSize: MainAxisSize.min, | |
children: [ | |
Text( | |
productName, | |
textScaleFactor: 1.2, | |
style: const TextStyle( | |
fontWeight: FontWeight.bold, | |
), | |
), | |
Text("${numberFormat.format(price)}원"), | |
Text("수량: $quantity"), | |
Text("합계: ${numberFormat.format(price * quantity)}원"), | |
], | |
), | |
), | |
], | |
), | |
); | |
} | |
Widget inputTextField({ | |
required TextEditingController currentController, | |
required String currentHintText, | |
int? currentMaxLength, | |
bool isObscure = false, | |
bool isReadOnly = false, | |
}) { | |
return Padding( | |
padding: const EdgeInsets.all(8.0), | |
child: TextFormField( | |
validator: (value) { | |
if (value!.isEmpty) { | |
return "내용을 입력해 주세요."; | |
} else { | |
if (currentController == userConfirmPwdController && | |
userPwdController.text != userConfirmPwdController.text) { | |
return "비밀번호가 일치하지 않습니다."; | |
} | |
} | |
return null; | |
}, | |
controller: currentController, | |
maxLength: currentMaxLength, | |
obscureText: isObscure, | |
readOnly: isReadOnly, | |
decoration: InputDecoration( | |
border: const OutlineInputBorder(), | |
hintText: currentHintText, | |
), | |
), | |
); | |
} | |
Widget receiverZipTextField() { | |
return Padding( | |
padding: const EdgeInsets.all(8.0), | |
child: Row( | |
children: [ | |
Expanded( | |
child: TextFormField( | |
readOnly: true, | |
controller: receiverZipController, | |
decoration: const InputDecoration( | |
border: OutlineInputBorder(), | |
hintText: "우편번호", | |
), | |
), | |
), | |
const SizedBox(width: 15), | |
FilledButton( | |
onPressed: () { | |
Navigator.of(context).push( | |
MaterialPageRoute( | |
builder: (context) { | |
return KpostalView(callback: (Kpostal result) { | |
receiverZipController.text = result.postCode; | |
receiverAddress1Controller.text = result.address; | |
}); | |
}, | |
), | |
); | |
}, | |
style: FilledButton.styleFrom( | |
shape: RoundedRectangleBorder( | |
borderRadius: BorderRadius.circular(5), | |
), | |
), | |
child: const Padding( | |
padding: EdgeInsets.symmetric(vertical: 22), | |
child: Text("우편 번호 찾기"), | |
), | |
), | |
], | |
), | |
); | |
} | |
Widget paymentMethodDropdownButton() { | |
return Container( | |
width: double.infinity, | |
margin: const EdgeInsets.all(8), | |
padding: const EdgeInsets.all(8), | |
decoration: BoxDecoration( | |
border: Border.all( | |
width: 0.5, | |
), | |
borderRadius: BorderRadius.circular(4), | |
), | |
child: DropdownButton<String>( | |
value: selectedPaymentMethod, | |
onChanged: (String? value) { | |
setState(() { | |
selectedPaymentMethod = value ?? ""; | |
}); | |
}, | |
isExpanded: true, | |
underline: Container(), | |
items: paymentMethodList.map<DropdownMenuItem<String>>((String value) { | |
return DropdownMenuItem<String>( | |
value: value, | |
child: Text(value), | |
); | |
}).toList(), | |
), | |
); | |
} | |
} |
[유튜브 강좌 영상]