feat(category): add autocomplete for subcategories

This commit is contained in:
JiWoong Sul
2025-12-01 18:16:28 +09:00
parent 0e75a23ade
commit 69902bbc30
4 changed files with 139 additions and 14 deletions

View File

@@ -125,6 +125,9 @@ class _ManualRestaurantInputScreenState
final categories = ref
.watch(categoriesProvider)
.maybeWhen(data: (list) => list, orElse: () => const <String>[]);
final subCategories = ref
.watch(subCategoriesProvider)
.maybeWhen(data: (list) => list, orElse: () => const <String>[]);
return Scaffold(
appBar: AppBar(
@@ -155,6 +158,7 @@ class _ManualRestaurantInputScreenState
longitudeController: _longitudeController,
onFieldChanged: _onFieldChanged,
categories: categories,
subCategories: subCategories,
),
const SizedBox(height: 24),
Row(

View File

@@ -16,6 +16,7 @@ class AddRestaurantForm extends StatefulWidget {
final TextEditingController longitudeController;
final Function(String) onFieldChanged;
final List<String> categories;
final List<String> subCategories;
const AddRestaurantForm({
super.key,
@@ -31,6 +32,7 @@ class AddRestaurantForm extends StatefulWidget {
required this.longitudeController,
required this.onFieldChanged,
this.categories = const <String>[],
this.subCategories = const <String>[],
});
@override
@@ -39,26 +41,52 @@ class AddRestaurantForm extends StatefulWidget {
class _AddRestaurantFormState extends State<AddRestaurantForm> {
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 = {...widget.categories};
_availableSubCategories = {...widget.subCategories};
final currentCategory = widget.categoryController.text.trim();
if (currentCategory.isNotEmpty) {
_availableCategories.add(currentCategory);
}
final currentSubCategory = widget.subCategoryController.text.trim();
if (currentSubCategory.isNotEmpty) {
_availableSubCategories.add(currentSubCategory);
}
}
@override
void didUpdateWidget(covariant AddRestaurantForm oldWidget) {
super.didUpdateWidget(oldWidget);
if (!setEquals(oldWidget.categories.toSet(), widget.categories.toSet())) {
_availableCategories = {..._availableCategories, ...widget.categories};
setState(() {
_availableCategories = {...widget.categories, ..._availableCategories};
});
}
if (!setEquals(
oldWidget.subCategories.toSet(),
widget.subCategories.toSet(),
)) {
setState(() {
_availableSubCategories = {
...widget.subCategories,
..._availableSubCategories,
};
});
}
}
@override
void dispose() {
_categoryFocusNode.dispose();
_subCategoryFocusNode.dispose();
super.dispose();
}
@@ -95,19 +123,7 @@ class _AddRestaurantFormState extends State<AddRestaurantForm> {
children: [
Expanded(child: _buildCategoryField(context)),
const SizedBox(width: 8),
Expanded(
child: TextFormField(
controller: widget.subCategoryController,
decoration: InputDecoration(
labelText: '세부 카테고리',
hintText: '예: 갈비',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: widget.onFieldChanged,
),
),
Expanded(child: _buildSubCategoryField(context)),
],
),
const SizedBox(height: 16),
@@ -339,4 +355,90 @@ class _AddRestaurantFormState extends State<AddRestaurantForm> {
},
);
}
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 hasExactMatch = _availableSubCategories.any(
(c) => c.toLowerCase() == lowerQuery,
);
if (!hasExactMatch && query.isNotEmpty) {
matches.insert(0, query);
}
return matches;
},
displayStringForOption: (option) => option,
onSelected: (option) {
final normalized = option.trim();
widget.subCategoryController.text = normalized;
if (normalized.isNotEmpty) {
setState(() {
_availableSubCategories.add(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 isNewEntry = !_availableSubCategories.contains(option);
return ListTile(
dense: true,
title: Text(
isNewEntry ? '새 세부 카테고리 추가: $option' : option,
style: TextStyle(
fontWeight: isNewEntry ? FontWeight.w600 : null,
),
),
onTap: () => onSelected(option),
);
},
),
),
),
);
},
);
}
}

View File

@@ -166,6 +166,9 @@ class _EditRestaurantDialogState extends ConsumerState<EditRestaurantDialog> {
final categories = ref
.watch(categoriesProvider)
.maybeWhen(data: (list) => list, orElse: () => const <String>[]);
final subCategories = ref
.watch(subCategoriesProvider)
.maybeWhen(data: (list) => list, orElse: () => const <String>[]);
return Dialog(
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
@@ -197,6 +200,7 @@ class _EditRestaurantDialogState extends ConsumerState<EditRestaurantDialog> {
longitudeController: _longitudeController,
onFieldChanged: _onFieldChanged,
categories: categories,
subCategories: subCategories,
),
const SizedBox(height: 24),
Row(