From 69902bbc3030b1482009563d6ee66dee2687f118 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Mon, 1 Dec 2025 18:16:28 +0900 Subject: [PATCH] feat(category): add autocomplete for subcategories --- .../manual_restaurant_input_screen.dart | 4 + .../widgets/add_restaurant_form.dart | 130 ++++++++++++++++-- .../widgets/edit_restaurant_dialog.dart | 4 + .../providers/restaurant_provider.dart | 15 ++ 4 files changed, 139 insertions(+), 14 deletions(-) diff --git a/lib/presentation/pages/restaurant_list/manual_restaurant_input_screen.dart b/lib/presentation/pages/restaurant_list/manual_restaurant_input_screen.dart index 44ab984..dfa801e 100644 --- a/lib/presentation/pages/restaurant_list/manual_restaurant_input_screen.dart +++ b/lib/presentation/pages/restaurant_list/manual_restaurant_input_screen.dart @@ -125,6 +125,9 @@ class _ManualRestaurantInputScreenState final categories = ref .watch(categoriesProvider) .maybeWhen(data: (list) => list, orElse: () => const []); + final subCategories = ref + .watch(subCategoriesProvider) + .maybeWhen(data: (list) => list, orElse: () => const []); return Scaffold( appBar: AppBar( @@ -155,6 +158,7 @@ class _ManualRestaurantInputScreenState longitudeController: _longitudeController, onFieldChanged: _onFieldChanged, categories: categories, + subCategories: subCategories, ), const SizedBox(height: 24), Row( diff --git a/lib/presentation/pages/restaurant_list/widgets/add_restaurant_form.dart b/lib/presentation/pages/restaurant_list/widgets/add_restaurant_form.dart index 188838a..6da741f 100644 --- a/lib/presentation/pages/restaurant_list/widgets/add_restaurant_form.dart +++ b/lib/presentation/pages/restaurant_list/widgets/add_restaurant_form.dart @@ -16,6 +16,7 @@ class AddRestaurantForm extends StatefulWidget { final TextEditingController longitudeController; final Function(String) onFieldChanged; final List categories; + final List subCategories; const AddRestaurantForm({ super.key, @@ -31,6 +32,7 @@ class AddRestaurantForm extends StatefulWidget { required this.longitudeController, required this.onFieldChanged, this.categories = const [], + this.subCategories = const [], }); @override @@ -39,26 +41,52 @@ class AddRestaurantForm extends StatefulWidget { class _AddRestaurantFormState extends State { late final FocusNode _categoryFocusNode; + late final FocusNode _subCategoryFocusNode; late Set _availableCategories; + late Set _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 { 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 { }, ); } + + Widget _buildSubCategoryField(BuildContext context) { + return RawAutocomplete( + 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), + ); + }, + ), + ), + ), + ); + }, + ); + } } diff --git a/lib/presentation/pages/restaurant_list/widgets/edit_restaurant_dialog.dart b/lib/presentation/pages/restaurant_list/widgets/edit_restaurant_dialog.dart index e680702..99fde92 100644 --- a/lib/presentation/pages/restaurant_list/widgets/edit_restaurant_dialog.dart +++ b/lib/presentation/pages/restaurant_list/widgets/edit_restaurant_dialog.dart @@ -166,6 +166,9 @@ class _EditRestaurantDialogState extends ConsumerState { final categories = ref .watch(categoriesProvider) .maybeWhen(data: (list) => list, orElse: () => const []); + final subCategories = ref + .watch(subCategoriesProvider) + .maybeWhen(data: (list) => list, orElse: () => const []); return Dialog( backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface, @@ -197,6 +200,7 @@ class _EditRestaurantDialogState extends ConsumerState { longitudeController: _longitudeController, onFieldChanged: _onFieldChanged, categories: categories, + subCategories: subCategories, ), const SizedBox(height: 24), Row( diff --git a/lib/presentation/providers/restaurant_provider.dart b/lib/presentation/providers/restaurant_provider.dart index 039c1d8..887c9ba 100644 --- a/lib/presentation/providers/restaurant_provider.dart +++ b/lib/presentation/providers/restaurant_provider.dart @@ -78,6 +78,21 @@ final categoriesProvider = StreamProvider>((ref) { }); }); +/// 세부 카테고리 목록 Provider +final subCategoriesProvider = StreamProvider>((ref) { + final restaurantsStream = ref.watch(restaurantListProvider.stream); + return restaurantsStream.map((restaurants) { + final subCategories = + restaurants + .map((r) => r.subCategory) + .where((s) => s.isNotEmpty) + .toSet() + .toList() + ..sort(); + return subCategories; + }); +}); + /// 맛집 관리 StateNotifier class RestaurantNotifier extends StateNotifier> { final RestaurantRepository _repository;