From 0e75a23adedffca275ebfd46235f2f9f86259245 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Mon, 1 Dec 2025 18:02:09 +0900 Subject: [PATCH] feat(category): add autocomplete for category inputs --- .../random_selection_screen.dart | 138 +++++++++++--- .../manual_restaurant_input_screen.dart | 5 + .../widgets/add_restaurant_form.dart | 174 ++++++++++++++---- .../widgets/edit_restaurant_dialog.dart | 4 + .../providers/restaurant_provider.dart | 12 +- .../widgets/category_selector.dart | 10 +- .../providers/restaurant_provider_test.dart | 6 +- 7 files changed, 277 insertions(+), 72 deletions(-) diff --git a/lib/presentation/pages/random_selection/random_selection_screen.dart b/lib/presentation/pages/random_selection/random_selection_screen.dart index f04d537..717db75 100644 --- a/lib/presentation/pages/random_selection/random_selection_screen.dart +++ b/lib/presentation/pages/random_selection/random_selection_screen.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import '../../../core/constants/app_colors.dart'; import '../../../core/constants/app_typography.dart'; +import '../../../core/utils/category_mapper.dart'; import '../../../domain/entities/restaurant.dart'; import '../../../domain/entities/weather_info.dart'; import '../../providers/ad_provider.dart'; @@ -188,23 +189,32 @@ class _RandomSelectionScreenState extends ConsumerState { _ensureDefaultCategorySelection(categories); final selectedCategories = _effectiveSelectedCategories(categories); + final isAllSelected = _isAllCategoriesSelected( + categories, + ); + final categoryChips = categories + .map( + (category) => _buildCategoryChip( + category, + isDark, + selectedCategories.contains(category), + categories, + ), + ) + .toList(); return Wrap( spacing: 8, runSpacing: 8, children: categories.isEmpty ? [const Text('카테고리 없음')] - : categories - .map( - (category) => _buildCategoryChip( - category, - isDark, - selectedCategories.contains( - category, - ), - categories, - ), - ) - .toList(), + : [ + _buildAllCategoryChip( + isDark, + isAllSelected, + categories, + ), + ...categoryChips, + ], ); }, loading: () => const CircularProgressIndicator(), @@ -404,30 +414,77 @@ class _RandomSelectionScreenState extends ConsumerState { ); } + Widget _buildAllCategoryChip( + bool isDark, + bool isSelected, + List availableCategories, + ) { + const icon = Icons.restaurant_menu; + return FilterChip( + label: const Text('전체'), + selected: isSelected, + onSelected: (_) => _handleToggleAllCategories(availableCategories), + avatar: _buildChipIcon( + isSelected: isSelected, + icon: icon, + color: AppColors.lightPrimary, + ), + backgroundColor: isDark + ? AppColors.darkSurface + : AppColors.lightBackground, + selectedColor: AppColors.lightPrimary.withValues(alpha: 0.2), + checkmarkColor: AppColors.lightPrimary, + showCheckmark: false, + labelStyle: TextStyle( + color: isSelected + ? AppColors.lightPrimary + : (isDark ? AppColors.darkTextPrimary : AppColors.lightTextPrimary), + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + ), + side: BorderSide( + color: isSelected + ? AppColors.lightPrimary + : (isDark ? AppColors.darkDivider : AppColors.lightDivider), + ), + ); + } + Widget _buildCategoryChip( String category, bool isDark, bool isSelected, List availableCategories, ) { + final icon = CategoryMapper.getIcon(category); + final color = CategoryMapper.getColor(category); + final activeColor = AppColors.lightPrimary; + final accentColor = isSelected ? activeColor : color; + final displayName = CategoryMapper.getDisplayName(category); return FilterChip( - label: Text(category), + label: Text(displayName), selected: isSelected, onSelected: (selected) => _handleCategoryToggle(category, selected, availableCategories), + avatar: _buildChipIcon( + isSelected: isSelected, + icon: icon, + color: accentColor, + ), backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightBackground, - selectedColor: AppColors.lightPrimary.withValues(alpha: 0.2), - checkmarkColor: AppColors.lightPrimary, + selectedColor: activeColor.withValues(alpha: 0.2), + checkmarkColor: accentColor, + showCheckmark: false, labelStyle: TextStyle( color: isSelected - ? AppColors.lightPrimary + ? accentColor : (isDark ? AppColors.darkTextPrimary : AppColors.lightTextPrimary), + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, ), side: BorderSide( color: isSelected - ? AppColors.lightPrimary + ? accentColor : (isDark ? AppColors.darkDivider : AppColors.lightDivider), ), ); @@ -444,9 +501,6 @@ class _RandomSelectionScreenState extends ConsumerState { if (isSelected) { nextSelection.add(category); } else { - if (nextSelection.length <= 1 && nextSelection.contains(category)) { - return; - } nextSelection.remove(category); } @@ -458,6 +512,43 @@ class _RandomSelectionScreenState extends ConsumerState { }); } + void _handleToggleAllCategories(List availableCategories) { + if (availableCategories.isEmpty) return; + + final shouldSelectAll = !_isAllCategoriesSelected(availableCategories); + + setState(() { + _hasUserAdjustedCategories = true; + _selectedCategories + ..clear() + ..addAll(shouldSelectAll ? availableCategories : []); + }); + } + + bool _isAllCategoriesSelected(List availableCategories) { + if (availableCategories.isEmpty) return false; + if (!_hasUserAdjustedCategories) return true; + + final availableSet = availableCategories.toSet(); + final selectedSet = _selectedCategories.toSet(); + + return selectedSet.isNotEmpty && + availableSet.difference(selectedSet).isEmpty; + } + + Widget _buildChipIcon({ + required bool isSelected, + required IconData icon, + required Color color, + }) { + final displayedIcon = isSelected ? Icons.check : icon; + return SizedBox( + width: 20, + height: 20, + child: Icon(displayedIcon, size: 18, color: color), + ); + } + int _getRestaurantCountInRange( List restaurants, Position location, @@ -702,18 +793,15 @@ class _RandomSelectionScreenState extends ConsumerState { } List _effectiveSelectedCategories(List availableCategories) { - if (_selectedCategories.isEmpty && !_hasUserAdjustedCategories) { - return availableCategories; - } - final availableSet = availableCategories.toSet(); final filtered = _selectedCategories .where((category) => availableSet.contains(category)) .toList(); - if (filtered.isEmpty) { + if (!_hasUserAdjustedCategories) { return availableCategories; } + return filtered; } } 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 3f2013d..44ab984 100644 --- a/lib/presentation/pages/restaurant_list/manual_restaurant_input_screen.dart +++ b/lib/presentation/pages/restaurant_list/manual_restaurant_input_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/constants/app_colors.dart'; import '../../../core/constants/app_typography.dart'; +import '../../providers/restaurant_provider.dart'; import '../../view_models/add_restaurant_view_model.dart'; import 'widgets/add_restaurant_form.dart'; @@ -121,6 +122,9 @@ class _ManualRestaurantInputScreenState Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; final state = ref.watch(addRestaurantViewModelProvider); + final categories = ref + .watch(categoriesProvider) + .maybeWhen(data: (list) => list, orElse: () => const []); return Scaffold( appBar: AppBar( @@ -150,6 +154,7 @@ class _ManualRestaurantInputScreenState latitudeController: _latitudeController, longitudeController: _longitudeController, onFieldChanged: _onFieldChanged, + categories: categories, ), 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 5559113..188838a 100644 --- a/lib/presentation/pages/restaurant_list/widgets/add_restaurant_form.dart +++ b/lib/presentation/pages/restaurant_list/widgets/add_restaurant_form.dart @@ -1,8 +1,9 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../../services/restaurant_form_validator.dart'; /// 식당 추가 폼 위젯 -class AddRestaurantForm extends StatelessWidget { +class AddRestaurantForm extends StatefulWidget { final GlobalKey formKey; final TextEditingController nameController; final TextEditingController categoryController; @@ -14,6 +15,7 @@ class AddRestaurantForm extends StatelessWidget { final TextEditingController latitudeController; final TextEditingController longitudeController; final Function(String) onFieldChanged; + final List categories; const AddRestaurantForm({ super.key, @@ -28,18 +30,48 @@ class AddRestaurantForm extends StatelessWidget { required this.latitudeController, required this.longitudeController, required this.onFieldChanged, + this.categories = const [], }); + @override + State createState() => _AddRestaurantFormState(); +} + +class _AddRestaurantFormState extends State { + late final FocusNode _categoryFocusNode; + late Set _availableCategories; + + @override + void initState() { + super.initState(); + _categoryFocusNode = FocusNode(); + _availableCategories = {...widget.categories}; + } + + @override + void didUpdateWidget(covariant AddRestaurantForm oldWidget) { + super.didUpdateWidget(oldWidget); + if (!setEquals(oldWidget.categories.toSet(), widget.categories.toSet())) { + _availableCategories = {..._availableCategories, ...widget.categories}; + } + } + + @override + void dispose() { + _categoryFocusNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Form( - key: formKey, + key: widget.formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 가게 이름 TextFormField( - controller: nameController, + controller: widget.nameController, decoration: InputDecoration( labelText: '가게 이름 *', hintText: '예: 맛있는 한식당', @@ -48,7 +80,7 @@ class AddRestaurantForm extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), ), - onChanged: onFieldChanged, + onChanged: widget.onFieldChanged, validator: (value) { if (value == null || value.isEmpty) { return '가게 이름을 입력해주세요'; @@ -61,26 +93,11 @@ class AddRestaurantForm extends StatelessWidget { // 카테고리 Row( children: [ - Expanded( - child: TextFormField( - controller: categoryController, - decoration: InputDecoration( - labelText: '카테고리 *', - hintText: '예: 한식', - prefixIcon: const Icon(Icons.category), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - onChanged: onFieldChanged, - validator: (value) => - RestaurantFormValidator.validateCategory(value), - ), - ), + Expanded(child: _buildCategoryField(context)), const SizedBox(width: 8), Expanded( child: TextFormField( - controller: subCategoryController, + controller: widget.subCategoryController, decoration: InputDecoration( labelText: '세부 카테고리', hintText: '예: 갈비', @@ -88,7 +105,7 @@ class AddRestaurantForm extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), ), - onChanged: onFieldChanged, + onChanged: widget.onFieldChanged, ), ), ], @@ -97,7 +114,7 @@ class AddRestaurantForm extends StatelessWidget { // 설명 TextFormField( - controller: descriptionController, + controller: widget.descriptionController, maxLines: 2, decoration: InputDecoration( labelText: '설명', @@ -107,13 +124,13 @@ class AddRestaurantForm extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), ), - onChanged: onFieldChanged, + onChanged: widget.onFieldChanged, ), const SizedBox(height: 16), // 전화번호 TextFormField( - controller: phoneController, + controller: widget.phoneController, keyboardType: TextInputType.phone, decoration: InputDecoration( labelText: '전화번호', @@ -123,7 +140,7 @@ class AddRestaurantForm extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), ), - onChanged: onFieldChanged, + onChanged: widget.onFieldChanged, validator: (value) => RestaurantFormValidator.validatePhoneNumber(value), ), @@ -131,7 +148,7 @@ class AddRestaurantForm extends StatelessWidget { // 도로명 주소 TextFormField( - controller: roadAddressController, + controller: widget.roadAddressController, decoration: InputDecoration( labelText: '도로명 주소 *', hintText: '예: 서울시 중구 세종대로 110', @@ -140,7 +157,7 @@ class AddRestaurantForm extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), ), - onChanged: onFieldChanged, + onChanged: widget.onFieldChanged, validator: (value) => RestaurantFormValidator.validateAddress(value), ), @@ -148,7 +165,7 @@ class AddRestaurantForm extends StatelessWidget { // 지번 주소 TextFormField( - controller: jibunAddressController, + controller: widget.jibunAddressController, decoration: InputDecoration( labelText: '지번 주소', hintText: '예: 서울시 중구 태평로1가 31', @@ -157,7 +174,7 @@ class AddRestaurantForm extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), ), - onChanged: onFieldChanged, + onChanged: widget.onFieldChanged, ), const SizedBox(height: 16), @@ -166,7 +183,7 @@ class AddRestaurantForm extends StatelessWidget { children: [ Expanded( child: TextFormField( - controller: latitudeController, + controller: widget.latitudeController, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), @@ -178,7 +195,7 @@ class AddRestaurantForm extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), ), - onChanged: onFieldChanged, + onChanged: widget.onFieldChanged, validator: (value) { if (value != null && value.isNotEmpty) { final latitude = double.tryParse(value); @@ -193,7 +210,7 @@ class AddRestaurantForm extends StatelessWidget { const SizedBox(width: 8), Expanded( child: TextFormField( - controller: longitudeController, + controller: widget.longitudeController, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), @@ -205,7 +222,7 @@ class AddRestaurantForm extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), ), - onChanged: onFieldChanged, + onChanged: widget.onFieldChanged, validator: (value) { if (value != null && value.isNotEmpty) { final longitude = double.tryParse(value); @@ -233,4 +250,93 @@ class AddRestaurantForm extends StatelessWidget { ), ); } + + Widget _buildCategoryField(BuildContext context) { + return RawAutocomplete( + 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 hasExactMatch = _availableCategories.any( + (c) => c.toLowerCase() == lowerQuery, + ); + if (!hasExactMatch) { + matches.insert(0, query); + } + + return matches; + }, + displayStringForOption: (option) => option, + onSelected: (option) { + final normalized = option.trim(); + widget.categoryController.text = normalized; + if (normalized.isNotEmpty) { + setState(() { + _availableCategories.add(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: (value) => + RestaurantFormValidator.validateCategory(value), + ); + }, + 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 = !_availableCategories.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 45a309e..e680702 100644 --- a/lib/presentation/pages/restaurant_list/widgets/edit_restaurant_dialog.dart +++ b/lib/presentation/pages/restaurant_list/widgets/edit_restaurant_dialog.dart @@ -163,6 +163,9 @@ class _EditRestaurantDialogState extends ConsumerState { @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; + final categories = ref + .watch(categoriesProvider) + .maybeWhen(data: (list) => list, orElse: () => const []); return Dialog( backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface, @@ -193,6 +196,7 @@ class _EditRestaurantDialogState extends ConsumerState { latitudeController: _latitudeController, longitudeController: _longitudeController, onFieldChanged: _onFieldChanged, + categories: categories, ), const SizedBox(height: 24), Row( diff --git a/lib/presentation/providers/restaurant_provider.dart b/lib/presentation/providers/restaurant_provider.dart index e265e8a..039c1d8 100644 --- a/lib/presentation/providers/restaurant_provider.dart +++ b/lib/presentation/providers/restaurant_provider.dart @@ -241,14 +241,14 @@ final searchQueryProvider = StateProvider((ref) => ''); final selectedCategoryProvider = StateProvider((ref) => null); /// 필터링된 맛집 목록 Provider (검색 + 카테고리) -final filteredRestaurantsProvider = StreamProvider>(( +final filteredRestaurantsProvider = Provider>>(( ref, -) async* { +) { final searchQuery = ref.watch(searchQueryProvider); final selectedCategory = ref.watch(selectedCategoryProvider); - final restaurantsStream = ref.watch(restaurantListProvider.stream); + final restaurantsAsync = ref.watch(restaurantListProvider); - await for (final restaurants in restaurantsStream) { + return restaurantsAsync.whenData((restaurants) { var filtered = restaurants; // 검색 필터 적용 @@ -280,6 +280,6 @@ final filteredRestaurantsProvider = StreamProvider>(( }).toList(); } - yield filtered; - } + return filtered; + }); }); diff --git a/lib/presentation/widgets/category_selector.dart b/lib/presentation/widgets/category_selector.dart index aff15e5..165d919 100644 --- a/lib/presentation/widgets/category_selector.dart +++ b/lib/presentation/widgets/category_selector.dart @@ -109,6 +109,8 @@ class CategorySelector extends ConsumerWidget { required VoidCallback onTap, }) { final isDark = Theme.of(context).brightness == Brightness.dark; + final activeColor = isDark ? AppColors.darkPrimary : AppColors.lightPrimary; + final accentColor = isSelected ? activeColor : color; return Material( color: Colors.transparent, @@ -120,13 +122,13 @@ class CategorySelector extends ConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: isSelected - ? color.withOpacity(0.2) + ? accentColor.withOpacity(0.2) : isDark ? AppColors.darkSurface : AppColors.lightBackground, borderRadius: BorderRadius.circular(20), border: Border.all( - color: isSelected ? color : Colors.transparent, + color: isSelected ? accentColor : Colors.transparent, width: 2, ), ), @@ -137,7 +139,7 @@ class CategorySelector extends ConsumerWidget { icon, size: 20, color: isSelected - ? color + ? accentColor : isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary, @@ -147,7 +149,7 @@ class CategorySelector extends ConsumerWidget { label, style: TextStyle( color: isSelected - ? color + ? accentColor : isDark ? AppColors.darkText : AppColors.lightText, diff --git a/test/unit/presentation/providers/restaurant_provider_test.dart b/test/unit/presentation/providers/restaurant_provider_test.dart index 732017b..7cb2913 100644 --- a/test/unit/presentation/providers/restaurant_provider_test.dart +++ b/test/unit/presentation/providers/restaurant_provider_test.dart @@ -209,9 +209,9 @@ void main() { // Act - 카테고리 필터 설정 container.read(selectedCategoryProvider.notifier).state = '한식'; container.read(searchQueryProvider.notifier).state = '김치'; - final filtered = await container.read( - filteredRestaurantsProvider.future, - ); + await container.read(restaurantListProvider.future); + final filteredAsync = container.read(filteredRestaurantsProvider); + final filtered = filteredAsync.requireValue; // Assert expect(filtered.length, 1);