지난번에 "거래 등록" 화면에 작성한 글/이미지를 Firebase 에 업로드하는것을 구현한 이후
폼빌더 및 검증 패키지 를 이용한 데이터 유효성 검증 방법을 구현해 보겠습니다.
개발환경 : 윈도우11, 안드로이드 스튜디오, flutter 3.0.1
구현된 화면 이미지는 아래와 같습니다. 이미지와 카테고리 선택에 대한 검증은 if 문으로 처리하였습니다.
필요한 패키지는 아래와 같습니다.
flutter_form_builder: ^7.6.0
form_builder_validators: ^8.3.0
./src/screens/input/input_screen.dart - 전체 코드는 "더보기" 클릭하세요
import 'dart:typed_data';
import 'package:extended_image/extended_image.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_multi_formatter/flutter_multi_formatter.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import 'package:get/get.dart';
import '../../models/item_model.dart';
import '../../repo/image_storage.dart';
import '../../repo/item_service.dart';
import '../../utils/logger.dart';
import '../../states/user_controller.dart';
import '../../states/category_controller.dart';
import '../../states/select_image_controller.dart';
import '../../constants/common_size.dart';
import '../../widgets/warning_dialog.dart';
import 'multi_image_select.dart';
class InputScreen extends StatefulWidget {
const InputScreen({Key? key}) : super(key: key);
@override
_InputScreenState createState() => _InputScreenState();
}
class _InputScreenState extends State<InputScreen> {
final dividerCustom = Divider(
height: 1, thickness: 1, color: Colors.grey[350], indent: padding_16, endIndent: padding_16);
final underLineBorder =
const UnderlineInputBorder(borderSide: BorderSide(color: Colors.transparent));
bool _suggestPriceSelected = false;
bool isCreatingItem = false;
final TextEditingController _priceController = TextEditingController();
final TextEditingController _titleController = TextEditingController();
final TextEditingController _detailController = TextEditingController();
final GlobalKey<FormBuilderState> _fbKey = GlobalKey<FormBuilderState>();
AutovalidateMode autoValidate = AutovalidateMode.disabled;
@override
void dispose() {
_priceController.dispose();
_titleController.dispose();
_detailController.dispose();
super.dispose();
}
void attemptCreateItem() async {
if (FirebaseAuth.instance.currentUser == null) return;
// 완료 버튼 클릭
isCreatingItem = true;
// setState 해줘야 인디케이터가 동작한다,
setState(() {
autoValidate = AutovalidateMode.always;
});
final form = _fbKey.currentState;
if (form == null || !form.validate()) {
isCreatingItem = false;
return;
}
form.save();
final inputValues = form.value;
debugPrint(inputValues.toString());
final String userKey = FirebaseAuth.instance.currentUser!.uid;
final String userPhone = FirebaseAuth.instance.currentUser!.phoneNumber!;
final String itemKey = ItemModel2.generateItemKey(userKey);
List<Uint8List> images = SelectImageController.to.images;
// final num? price = num.tryParse(_priceController.text.replaceAll('.', '').replaceAll(' 원', ''));
final num? price = num.tryParse(_priceController.text.replaceAll(RegExp(r'\D'), ''));
// UserNotifier userNotifier = context.read<UserNotifier>();
if (images.isEmpty) {
dataWarning(context, '확인', '이미지를 선택해주세요');
return;
}
if (CategoryController.to.currentCategoryInEng == 'none') {
dataWarning(context, '확인', '카테고리를 선택해주세요');
return;
}
// uploading raw data and return the Urls,
List<String> downloadUrls = await ImageStorage.uploadImage(images, itemKey);
logger.d('upload finished(${downloadUrls.length}) : $downloadUrls');
ItemModel2 itemModel = ItemModel2(
itemKey: itemKey,
userKey: userKey,
userPhone: userPhone,
imageDownloadUrls: downloadUrls,
title: _titleController.text,
category: CategoryController.to.currentCategoryInEng,
price: price ?? 0,
negotiable: _suggestPriceSelected,
detail: _detailController.text,
address: UserController.to.userModel.value!.address,
//userNotifier.userModel!.address,
geoFirePoint: UserController.to.userModel.value!.geoFirePoint,
//userNotifier.userModel!.geoFirePoint,
createdDate: DateTime.now().toUtc(),
);
// await ItemService().createNewItem(itemModel, itemKey, userNotifier.user!.uid);
await ItemService().createNewItem(itemModel, itemKey, UserController.to.user.value!.uid);
Get.back();
}
Future<bool> dataWarning(BuildContext context, String title, String msg) async {
isCreatingItem = false;
return await showDialog<bool>(
context: context,
builder: (context) => WarningYesNo(
title: title,
msg: msg,
yesMsg: '확인',
),
) ??
false;
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
Size _size = MediaQuery.of(context).size;
return IgnorePointer(
ignoring: isCreatingItem,
child: Scaffold(
appBar: AppBar(
centerTitle: true,
// leading 을 통해서 back 버튼을 "뒤로" 버튼으로 대체할 수 있음.
leadingWidth: 55.0,
leading: TextButton(
onPressed: () {
debugPrint('뒤로가기 버튼 클릭');
Get.back();
},
style: TextButton.styleFrom(
primary: Colors.black,
// backgroundColor 는 기본으로 흰색으로 설정되어 있음,
backgroundColor: Theme.of(context).appBarTheme.backgroundColor,
),
child: Text(
'뒤로',
style: Theme.of(context).textTheme.bodyText1,
),
),
// 로딩중일때,
bottom: PreferredSize(
preferredSize: Size(_size.width, 3),
child: isCreatingItem ? const LinearProgressIndicator(minHeight: 3) : Container(),
),
actions: <Widget>[
TextButton(
onPressed: attemptCreateItem,
style: TextButton.styleFrom(
primary: Colors.black,
backgroundColor: Theme.of(context).appBarTheme.backgroundColor,
// 완료와 뒤로 버튼 사이즈를 조정함,
minimumSize: const Size(55, 40),
),
child: Text(
'완료',
style: Theme.of(context).textTheme.bodyText1,
),
),
],
title: Text(
'중고거래 등록',
style: Theme.of(context).textTheme.headline6,
),
),
// 컬럼으로 안하고 ListView 로 하는 이유는 스크롤 기능이 필요해서,
body: FormBuilder(
key: _fbKey,
child: ListView(
children: <Widget>[
// 멀티 이미지 영역
const MultiImageSelect(),
dividerCustom,
// 제목영역 ********************************************************
Padding(
padding: const EdgeInsets.symmetric(horizontal: padding_16),
child: TextFormField(
validator: FormBuilderValidators.compose([
FormBuilderValidators.required(errorText: '글제목은 필수입니다'),
]),
controller: _titleController,
decoration: InputDecoration(
hintText: '글제목',
// padding 으로 하지 않고 처리하는 방법,
// contentPadding: const EdgeInsets.symmetric(horizontal: padding_16),
border: underLineBorder,
enabledBorder: underLineBorder,
focusedBorder: underLineBorder,
// error 관련 border 설정
errorBorder: underLineBorder,
focusedErrorBorder: underLineBorder,
// errorStyle: const TextStyle(color: Colors.grey)
),
),
),
dividerCustom,
// 카테고리 영역 ********************************************************
ListTile(
onTap: () {
debugPrint('/LOCATION_INPUT/LOCATION_CATEGORY_INPUT');
Get.toNamed('/category_input');
},
dense: true,
title: Obx(() {
return Text(CategoryController.to.currentCategoryInKor);
}),
trailing: const Icon(Icons.navigate_next),
),
dividerCustom,
Row(
children: <Widget>[
// Expanded 를 처리하여 전체 공간을 차지하게 처리
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: padding_16),
// 가격입력 ********************************************************
child: TextFormField(
validator: FormBuilderValidators.compose([
FormBuilderValidators.required(errorText: '가격은 필수입니다'),
]),
// 숫자만 입력가능하게 설정,
keyboardType: TextInputType.number,
controller: _priceController,
onChanged: (value) {
if ('0 원' == value) {
_priceController.clear();
}
setState(() {});
},
// 입력한 숫자 형식을 지정해주는 옵션,
inputFormatters: [
MoneyInputFormatter(mantissaLength: 0, trailingSymbol: ' 원')
],
decoration: InputDecoration(
hintText: '가격',
prefixIcon: ImageIcon(
const ExtendedAssetImageProvider('assets/imgs/won.png'),
// 숫자가 입력되면 원화표시 사이 색상이 변경된다,
color: (_priceController.text.isEmpty)
? Colors.grey[350]
: Colors.black87,
),
// prefixIcon 의 사이즈를 결정,
prefixIconConstraints: const BoxConstraints(maxWidth: 20),
contentPadding: const EdgeInsets.symmetric(vertical: padding_08),
border: underLineBorder,
enabledBorder: underLineBorder,
focusedBorder: underLineBorder,
// error 관련 border 설정
errorBorder: underLineBorder,
),
),
),
),
Padding(
padding: const EdgeInsets.only(right: padding_16),
child: TextButton.icon(
onPressed: () {
// 가격제안 클릭시 토글 방식으로 화면 처리,
setState(() {
_suggestPriceSelected = !_suggestPriceSelected;
});
},
icon: Icon(
_suggestPriceSelected ? Icons.check_circle : Icons.check_circle_outline,
color: _suggestPriceSelected
? Theme.of(context).primaryColor
: Colors.black54,
),
label: Text(
'가격제안 받기',
style: TextStyle(
color: _suggestPriceSelected
? Theme.of(context).primaryColor
: Colors.black54,
),
),
style: TextButton.styleFrom(
backgroundColor: Colors.transparent,
primary: Colors.black45,
),
),
)
],
),
dividerCustom,
// 올릴 게시글 내용을 작성
Padding(
padding: const EdgeInsets.symmetric(horizontal: padding_16),
child: TextFormField(
validator: FormBuilderValidators.compose([
FormBuilderValidators.required(errorText: '내용을 작성해주세요'),
]),
controller: _detailController,
// 엔터 줄바꿈 가능하게, 줄수 제한 없애기,
maxLines: null,
// multiline 설정하면 완료키가 엔터키로 변경됨,
keyboardType: TextInputType.multiline,
decoration: InputDecoration(
hintText: '올릴 게시글 내용을 작성해주세요',
contentPadding: const EdgeInsets.symmetric(
// horizontal: padding_16,
vertical: padding_16,
),
border: underLineBorder,
enabledBorder: underLineBorder,
focusedBorder: underLineBorder,
// error 관련 border 설정
errorBorder: underLineBorder,
),
),
),
],
),
),
),
);
},
);
}
}
주요 수정 사항은 아래와 같습니다.
'Flutter > 12 Clone 'Used Goods app'' 카테고리의 다른 글
[Flutter] Clone - 당근마켓38(Item detail & CustomScrollView) (0) | 2022.08.21 |
---|---|
[Flutter] Clone - 당근마켓37(Item read & Get.arguments) (2) | 2022.08.19 |
[Flutter] Clone - 당근마켓35(ItemModel upload) (0) | 2022.08.18 |
[Flutter] Clone - 당근마켓34(InputScreen - image uploading) (0) | 2022.08.18 |
[Flutter] Clone - 당근마켓33(InputScreen - image picker/Getx) (0) | 2022.08.17 |