feat(app): add vworld geocoding and native ads placeholders

This commit is contained in:
JiWoong Sul
2025-12-03 14:30:20 +09:00
parent d101f7d0dc
commit 3ff9e5f837
23 changed files with 1108 additions and 540 deletions

View File

@@ -17,6 +17,7 @@ class AddRestaurantForm extends StatefulWidget {
final Function(String) onFieldChanged;
final List<String> categories;
final List<String> subCategories;
final String geocodingStatus;
const AddRestaurantForm({
super.key,
@@ -33,6 +34,7 @@ class AddRestaurantForm extends StatefulWidget {
required this.onFieldChanged,
this.categories = const <String>[],
this.subCategories = const <String>[],
this.geocodingStatus = '',
});
@override
@@ -255,12 +257,28 @@ class _AddRestaurantFormState extends State<AddRestaurantForm> {
],
),
const SizedBox(height: 8),
Text(
'주소가 정확하지 않을 경우 위도/경도를 현재 위치로 입력합니다.',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.grey),
textAlign: TextAlign.center,
Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'주소가 정확하지 않을 경우 위도/경도를 현재 위치로 입력합니다.',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.grey),
textAlign: TextAlign.center,
),
if (widget.geocodingStatus.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
widget.geocodingStatus,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.blueGrey,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
],
],
),
],
),

View File

@@ -37,24 +37,24 @@ class AddRestaurantUrlTab extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
size: 20,
color: isDark
? AppColors.darkPrimary
: AppColors.lightPrimary,
),
const SizedBox(width: 8),
Text(
'네이버 지도에서 맛집 정보 가져오기',
style: AppTypography.body1(
isDark,
).copyWith(fontWeight: FontWeight.bold),
),
],
),
// Row(
// children: [
// Icon(
// Icons.info_outline,
// size: 20,
// color: isDark
// ? AppColors.darkPrimary
// : AppColors.lightPrimary,
// ),
// const SizedBox(width: 8),
// Text(
// '네이버 지도에서 맛집 정보 가져오기',
// style: AppTypography.body1(
// isDark,
// ).copyWith(fontWeight: FontWeight.bold),
// ),
// ],
// ),
const SizedBox(height: 8),
Text(
'1. 네이버 지도에서 맛집을 검색합니다\n'
@@ -71,6 +71,9 @@ class AddRestaurantUrlTab extends StatelessWidget {
// URL 입력 필드
TextField(
controller: urlController,
keyboardType: TextInputType.multiline,
minLines: 1,
maxLines: 6,
decoration: InputDecoration(
labelText: '네이버 지도 URL',
hintText: kIsWeb
@@ -79,6 +82,7 @@ class AddRestaurantUrlTab extends StatelessWidget {
prefixIcon: const Icon(Icons.link),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
errorText: errorMessage,
errorMaxLines: 8,
),
onSubmitted: (_) => onFetchPressed(),
),

View File

@@ -4,7 +4,7 @@ import '../../../../core/constants/app_colors.dart';
import '../../../../core/constants/app_typography.dart';
import '../../../services/restaurant_form_validator.dart';
class FetchedRestaurantJsonView extends StatelessWidget {
class FetchedRestaurantJsonView extends StatefulWidget {
final bool isDark;
final TextEditingController nameController;
final TextEditingController categoryController;
@@ -34,17 +34,59 @@ class FetchedRestaurantJsonView extends StatelessWidget {
required this.onFieldChanged,
});
@override
State<FetchedRestaurantJsonView> createState() =>
_FetchedRestaurantJsonViewState();
}
class _FetchedRestaurantJsonViewState extends State<FetchedRestaurantJsonView> {
late final FocusNode _categoryFocusNode;
late final FocusNode _subCategoryFocusNode;
late Set<String> _availableCategories;
late Set<String> _availableSubCategories;
@override
void initState() {
super.initState();
_categoryFocusNode = FocusNode();
_subCategoryFocusNode = FocusNode();
_availableCategories = {
'기타',
if (widget.categoryController.text.trim().isNotEmpty)
widget.categoryController.text.trim(),
};
_availableSubCategories = {
'기타',
if (widget.subCategoryController.text.trim().isNotEmpty)
widget.subCategoryController.text.trim(),
};
if (widget.categoryController.text.trim().isEmpty) {
widget.categoryController.text = '기타';
}
if (widget.subCategoryController.text.trim().isEmpty) {
widget.subCategoryController.text = '기타';
}
}
@override
void dispose() {
_categoryFocusNode.dispose();
_subCategoryFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark
color: widget.isDark
? AppColors.darkBackground
: AppColors.lightBackground.withOpacity(0.5),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark ? AppColors.darkDivider : AppColors.lightDivider,
color: widget.isDark ? AppColors.darkDivider : AppColors.lightDivider,
),
),
child: Column(
@@ -57,78 +99,55 @@ class FetchedRestaurantJsonView extends StatelessWidget {
Text(
'가져온 정보',
style: AppTypography.body1(
isDark,
widget.isDark,
).copyWith(fontWeight: FontWeight.w600),
),
],
),
const SizedBox(height: 12),
const Text(
'{',
style: TextStyle(fontFamily: 'RobotoMono', fontSize: 16),
),
const SizedBox(height: 12),
_buildJsonField(
context,
label: 'name',
controller: nameController,
label: '상호',
controller: widget.nameController,
icon: Icons.store,
validator: (value) =>
value == null || value.isEmpty ? '가게 이름을 입력해주세요' : null,
),
_buildJsonField(
context,
label: 'category',
controller: categoryController,
icon: Icons.category,
validator: RestaurantFormValidator.validateCategory,
),
_buildJsonField(
context,
label: 'subCategory',
controller: subCategoryController,
icon: Icons.label_outline,
),
_buildJsonField(
context,
label: 'description',
controller: descriptionController,
icon: Icons.description,
maxLines: 2,
),
_buildJsonField(
context,
label: 'phoneNumber',
controller: phoneController,
icon: Icons.phone,
keyboardType: TextInputType.phone,
validator: RestaurantFormValidator.validatePhoneNumber,
),
_buildJsonField(
context,
label: 'roadAddress',
controller: roadAddressController,
label: '도로명 주소',
controller: widget.roadAddressController,
icon: Icons.location_on,
validator: RestaurantFormValidator.validateAddress,
),
_buildJsonField(
context,
label: 'jibunAddress',
controller: jibunAddressController,
label: '지번 주소',
controller: widget.jibunAddressController,
icon: Icons.map,
),
_buildCoordinateFields(context),
_buildJsonField(
context,
label: 'naverUrl',
controller: naverUrlController,
icon: Icons.link,
monospace: true,
label: '전화번호',
controller: widget.phoneController,
icon: Icons.phone,
keyboardType: TextInputType.phone,
validator: RestaurantFormValidator.validatePhoneNumber,
),
const SizedBox(height: 12),
const Text(
'}',
style: TextStyle(fontFamily: 'RobotoMono', fontSize: 16),
Row(
children: [
Expanded(child: _buildCategoryField(context)),
const SizedBox(width: 8),
Expanded(child: _buildSubCategoryField(context)),
],
),
_buildJsonField(
context,
label: '설명',
controller: widget.descriptionController,
icon: Icons.description,
maxLines: 2,
),
],
),
@@ -145,7 +164,7 @@ class FetchedRestaurantJsonView extends StatelessWidget {
children: const [
Icon(Icons.my_location, size: 16),
SizedBox(width: 8),
Text('coordinates'),
Text('좌표'),
],
),
const SizedBox(height: 6),
@@ -153,16 +172,16 @@ class FetchedRestaurantJsonView extends StatelessWidget {
children: [
Expanded(
child: TextFormField(
controller: latitudeController,
controller: widget.latitudeController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: InputDecoration(
labelText: 'latitude',
labelText: '위도',
border: border,
isDense: true,
),
onChanged: onFieldChanged,
onChanged: widget.onFieldChanged,
validator: (value) {
if (value == null || value.isEmpty) {
return '위도를 입력해주세요';
@@ -178,16 +197,16 @@ class FetchedRestaurantJsonView extends StatelessWidget {
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: longitudeController,
controller: widget.longitudeController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: InputDecoration(
labelText: 'longitude',
labelText: '경도',
border: border,
isDense: true,
),
onChanged: onFieldChanged,
onChanged: widget.onFieldChanged,
validator: (value) {
if (value == null || value.isEmpty) {
return '경도를 입력해주세요';
@@ -209,6 +228,170 @@ class FetchedRestaurantJsonView extends StatelessWidget {
);
}
Widget _buildCategoryField(BuildContext context) {
return RawAutocomplete<String>(
textEditingController: widget.categoryController,
focusNode: _categoryFocusNode,
optionsBuilder: (TextEditingValue value) {
final query = value.text.trim();
if (query.isEmpty) return _availableCategories;
final lowerQuery = query.toLowerCase();
final matches = _availableCategories
.where((c) => c.toLowerCase().contains(lowerQuery))
.toList();
final hasExact = _availableCategories.any(
(c) => c.toLowerCase() == lowerQuery,
);
if (!hasExact) {
matches.insert(0, query.isEmpty ? '기타' : query);
}
return matches;
},
displayStringForOption: (option) => option,
onSelected: (option) {
final normalized = option.trim().isEmpty ? '기타' : option.trim();
setState(() {
_availableCategories.add(normalized);
});
widget.categoryController.text = normalized;
widget.onFieldChanged(normalized);
},
fieldViewBuilder:
(context, textEditingController, focusNode, onFieldSubmitted) {
return TextFormField(
controller: textEditingController,
focusNode: focusNode,
decoration: InputDecoration(
labelText: '카테고리',
hintText: '예: 한식',
// prefixIcon: const Icon(Icons.category),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
suffixIcon: const Icon(Icons.arrow_drop_down),
),
onChanged: widget.onFieldChanged,
onFieldSubmitted: (_) => onFieldSubmitted(),
validator: RestaurantFormValidator.validateCategory,
);
},
optionsViewBuilder: (context, onSelected, options) {
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(8),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 220, minWidth: 200),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (context, index) {
final option = options.elementAt(index);
final isNew = !_availableCategories.contains(option);
return ListTile(
dense: true,
title: Text(
isNew ? '새 카테고리 추가: $option' : option,
style: TextStyle(
fontWeight: isNew ? FontWeight.w600 : null,
),
),
onTap: () => onSelected(option),
);
},
),
),
),
);
},
);
}
Widget _buildSubCategoryField(BuildContext context) {
return RawAutocomplete<String>(
textEditingController: widget.subCategoryController,
focusNode: _subCategoryFocusNode,
optionsBuilder: (TextEditingValue value) {
final query = value.text.trim();
if (query.isEmpty) return _availableSubCategories;
final lowerQuery = query.toLowerCase();
final matches = _availableSubCategories
.where((c) => c.toLowerCase().contains(lowerQuery))
.toList();
final hasExact = _availableSubCategories.any(
(c) => c.toLowerCase() == lowerQuery,
);
if (!hasExact) {
matches.insert(0, query.isEmpty ? '기타' : query);
}
return matches;
},
displayStringForOption: (option) => option,
onSelected: (option) {
final normalized = option.trim().isEmpty ? '기타' : option.trim();
setState(() {
_availableSubCategories.add(normalized);
});
widget.subCategoryController.text = normalized;
widget.onFieldChanged(normalized);
},
fieldViewBuilder:
(context, textEditingController, focusNode, onFieldSubmitted) {
return TextFormField(
controller: textEditingController,
focusNode: focusNode,
decoration: InputDecoration(
labelText: '세부 카테고리',
hintText: '예: 갈비',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
suffixIcon: const Icon(Icons.arrow_drop_down),
),
onChanged: widget.onFieldChanged,
onFieldSubmitted: (_) => onFieldSubmitted(),
);
},
optionsViewBuilder: (context, onSelected, options) {
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(8),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 220, minWidth: 200),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (context, index) {
final option = options.elementAt(index);
final isNew = !_availableSubCategories.contains(option);
return ListTile(
dense: true,
title: Text(
isNew ? '새 세부 카테고리 추가: $option' : option,
style: TextStyle(
fontWeight: isNew ? FontWeight.w600 : null,
),
),
onTap: () => onSelected(option),
);
},
),
),
),
);
},
);
}
Widget _buildJsonField(
BuildContext context, {
required String label,
@@ -236,17 +419,18 @@ class FetchedRestaurantJsonView extends StatelessWidget {
controller: controller,
maxLines: maxLines,
keyboardType: keyboardType,
onChanged: onFieldChanged,
onChanged: widget.onFieldChanged,
validator: validator,
style: monospace
? const TextStyle(fontFamily: 'RobotoMono', fontSize: 13)
: null,
decoration: InputDecoration(
isDense: true,
labelText: label,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
isDense: true,
),
style: monospace
? const TextStyle(fontFamily: 'RobotoMono', fontSize: 14)
: null,
),
],
),