diff --git a/lib/components/product/product_card.dart b/lib/components/product/product_card.dart index 1f05c4b..252d4e3 100644 --- a/lib/components/product/product_card.dart +++ b/lib/components/product/product_card.dart @@ -116,7 +116,7 @@ class ProductCard extends StatelessWidget { style: Theme.of(context) .textTheme .bodyMedium! - .copyWith(fontSize: 16, height: 1.3), + .copyWith(fontSize: 15, height: 1.2), ), const Spacer(), if (priceAfterDiscount != null) diff --git a/lib/models/product.dart b/lib/models/product.dart index 0c6881a..54a0c24 100644 --- a/lib/models/product.dart +++ b/lib/models/product.dart @@ -8,6 +8,7 @@ class Product with _$Product { const factory Product({ required String id, required String name, + required String slug, required double price, String? description, @JsonKey(name: 'image_url') String? imageUrl, @@ -19,23 +20,6 @@ class Product with _$Product { _$ProductFromJson(json); } -@freezed -class ProductWithCategory with _$ProductWithCategory { - const factory ProductWithCategory({ - required String id, - required String name, - required double price, - String? description, - @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'category_id') required String categoryId, - @JsonKey(name: 'is_available') required bool isAvailable, - @Default(null) Category? category, - }) = _ProductWithCategory; - - factory ProductWithCategory.fromJson(Map json) => - _$ProductWithCategoryFromJson(json); -} - @freezed class ProductDraft with _$ProductDraft { const factory ProductDraft({ @@ -55,16 +39,6 @@ class ProductDraft with _$ProductDraft { factory ProductDraft.fromJson(Map json) => _$ProductDraftFromJson(json); - factory ProductDraft.fromProduct(Product product) => ProductDraft( - id: product.id, - name: product.name, - price: product.price, - description: product.description, - imageUrl: product.imageUrl, - categoryId: product.categoryId, - isAvailable: product.isAvailable, - ); - Map toPayload() { final data = { 'name': name.trim(), diff --git a/lib/models/product.freezed.dart b/lib/models/product.freezed.dart index c7ddc1f..741b2e1 100644 --- a/lib/models/product.freezed.dart +++ b/lib/models/product.freezed.dart @@ -22,6 +22,7 @@ Product _$ProductFromJson(Map json) { mixin _$Product { String get id => throw _privateConstructorUsedError; String get name => throw _privateConstructorUsedError; + String get slug => throw _privateConstructorUsedError; double get price => throw _privateConstructorUsedError; String? get description => throw _privateConstructorUsedError; @JsonKey(name: 'image_url') @@ -48,6 +49,7 @@ abstract class $ProductCopyWith<$Res> { $Res call( {String id, String name, + String slug, double price, String? description, @JsonKey(name: 'image_url') String? imageUrl, @@ -72,6 +74,7 @@ class _$ProductCopyWithImpl<$Res, $Val extends Product> $Res call({ Object? id = null, Object? name = null, + Object? slug = null, Object? price = null, Object? description = freezed, Object? imageUrl = freezed, @@ -87,6 +90,10 @@ class _$ProductCopyWithImpl<$Res, $Val extends Product> ? _value.name : name // ignore: cast_nullable_to_non_nullable as String, + slug: null == slug + ? _value.slug + : slug // ignore: cast_nullable_to_non_nullable + as String, price: null == price ? _value.price : price // ignore: cast_nullable_to_non_nullable @@ -121,6 +128,7 @@ abstract class _$$ProductImplCopyWith<$Res> implements $ProductCopyWith<$Res> { $Res call( {String id, String name, + String slug, double price, String? description, @JsonKey(name: 'image_url') String? imageUrl, @@ -143,6 +151,7 @@ class __$$ProductImplCopyWithImpl<$Res> $Res call({ Object? id = null, Object? name = null, + Object? slug = null, Object? price = null, Object? description = freezed, Object? imageUrl = freezed, @@ -158,6 +167,10 @@ class __$$ProductImplCopyWithImpl<$Res> ? _value.name : name // ignore: cast_nullable_to_non_nullable as String, + slug: null == slug + ? _value.slug + : slug // ignore: cast_nullable_to_non_nullable + as String, price: null == price ? _value.price : price // ignore: cast_nullable_to_non_nullable @@ -188,6 +201,7 @@ class _$ProductImpl implements _Product { const _$ProductImpl( {required this.id, required this.name, + required this.slug, required this.price, this.description, @JsonKey(name: 'image_url') this.imageUrl, @@ -202,6 +216,8 @@ class _$ProductImpl implements _Product { @override final String name; @override + final String slug; + @override final double price; @override final String? description; @@ -217,7 +233,7 @@ class _$ProductImpl implements _Product { @override String toString() { - return 'Product(id: $id, name: $name, price: $price, description: $description, imageUrl: $imageUrl, categoryId: $categoryId, isAvailable: $isAvailable)'; + return 'Product(id: $id, name: $name, slug: $slug, price: $price, description: $description, imageUrl: $imageUrl, categoryId: $categoryId, isAvailable: $isAvailable)'; } @override @@ -227,6 +243,7 @@ class _$ProductImpl implements _Product { other is _$ProductImpl && (identical(other.id, id) || other.id == id) && (identical(other.name, name) || other.name == name) && + (identical(other.slug, slug) || other.slug == slug) && (identical(other.price, price) || other.price == price) && (identical(other.description, description) || other.description == description) && @@ -240,8 +257,8 @@ class _$ProductImpl implements _Product { @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash(runtimeType, id, name, price, description, - imageUrl, categoryId, isAvailable); + int get hashCode => Object.hash(runtimeType, id, name, slug, price, + description, imageUrl, categoryId, isAvailable); /// Create a copy of Product /// with the given fields replaced by the non-null parameter values. @@ -263,6 +280,7 @@ abstract class _Product implements Product { const factory _Product( {required final String id, required final String name, + required final String slug, required final double price, final String? description, @JsonKey(name: 'image_url') final String? imageUrl, @@ -277,6 +295,8 @@ abstract class _Product implements Product { @override String get name; @override + String get slug; + @override double get price; @override String? get description; @@ -298,335 +318,6 @@ abstract class _Product implements Product { throw _privateConstructorUsedError; } -ProductWithCategory _$ProductWithCategoryFromJson(Map json) { - return _ProductWithCategory.fromJson(json); -} - -/// @nodoc -mixin _$ProductWithCategory { - String get id => throw _privateConstructorUsedError; - String get name => throw _privateConstructorUsedError; - double get price => throw _privateConstructorUsedError; - String? get description => throw _privateConstructorUsedError; - @JsonKey(name: 'image_url') - String? get imageUrl => throw _privateConstructorUsedError; - @JsonKey(name: 'category_id') - String get categoryId => throw _privateConstructorUsedError; - @JsonKey(name: 'is_available') - bool get isAvailable => throw _privateConstructorUsedError; - Category? get category => throw _privateConstructorUsedError; - - /// Serializes this ProductWithCategory to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of ProductWithCategory - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $ProductWithCategoryCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $ProductWithCategoryCopyWith<$Res> { - factory $ProductWithCategoryCopyWith( - ProductWithCategory value, $Res Function(ProductWithCategory) then) = - _$ProductWithCategoryCopyWithImpl<$Res, ProductWithCategory>; - @useResult - $Res call( - {String id, - String name, - double price, - String? description, - @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'category_id') String categoryId, - @JsonKey(name: 'is_available') bool isAvailable, - Category? category}); - - $CategoryCopyWith<$Res>? get category; -} - -/// @nodoc -class _$ProductWithCategoryCopyWithImpl<$Res, $Val extends ProductWithCategory> - implements $ProductWithCategoryCopyWith<$Res> { - _$ProductWithCategoryCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of ProductWithCategory - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? name = null, - Object? price = null, - Object? description = freezed, - Object? imageUrl = freezed, - Object? categoryId = null, - Object? isAvailable = null, - Object? category = freezed, - }) { - return _then(_value.copyWith( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - price: null == price - ? _value.price - : price // ignore: cast_nullable_to_non_nullable - as double, - description: freezed == description - ? _value.description - : description // ignore: cast_nullable_to_non_nullable - as String?, - imageUrl: freezed == imageUrl - ? _value.imageUrl - : imageUrl // ignore: cast_nullable_to_non_nullable - as String?, - categoryId: null == categoryId - ? _value.categoryId - : categoryId // ignore: cast_nullable_to_non_nullable - as String, - isAvailable: null == isAvailable - ? _value.isAvailable - : isAvailable // ignore: cast_nullable_to_non_nullable - as bool, - category: freezed == category - ? _value.category - : category // ignore: cast_nullable_to_non_nullable - as Category?, - ) as $Val); - } - - /// Create a copy of ProductWithCategory - /// with the given fields replaced by the non-null parameter values. - @override - @pragma('vm:prefer-inline') - $CategoryCopyWith<$Res>? get category { - if (_value.category == null) { - return null; - } - - return $CategoryCopyWith<$Res>(_value.category!, (value) { - return _then(_value.copyWith(category: value) as $Val); - }); - } -} - -/// @nodoc -abstract class _$$ProductWithCategoryImplCopyWith<$Res> - implements $ProductWithCategoryCopyWith<$Res> { - factory _$$ProductWithCategoryImplCopyWith(_$ProductWithCategoryImpl value, - $Res Function(_$ProductWithCategoryImpl) then) = - __$$ProductWithCategoryImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {String id, - String name, - double price, - String? description, - @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'category_id') String categoryId, - @JsonKey(name: 'is_available') bool isAvailable, - Category? category}); - - @override - $CategoryCopyWith<$Res>? get category; -} - -/// @nodoc -class __$$ProductWithCategoryImplCopyWithImpl<$Res> - extends _$ProductWithCategoryCopyWithImpl<$Res, _$ProductWithCategoryImpl> - implements _$$ProductWithCategoryImplCopyWith<$Res> { - __$$ProductWithCategoryImplCopyWithImpl(_$ProductWithCategoryImpl _value, - $Res Function(_$ProductWithCategoryImpl) _then) - : super(_value, _then); - - /// Create a copy of ProductWithCategory - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? id = null, - Object? name = null, - Object? price = null, - Object? description = freezed, - Object? imageUrl = freezed, - Object? categoryId = null, - Object? isAvailable = null, - Object? category = freezed, - }) { - return _then(_$ProductWithCategoryImpl( - id: null == id - ? _value.id - : id // ignore: cast_nullable_to_non_nullable - as String, - name: null == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String, - price: null == price - ? _value.price - : price // ignore: cast_nullable_to_non_nullable - as double, - description: freezed == description - ? _value.description - : description // ignore: cast_nullable_to_non_nullable - as String?, - imageUrl: freezed == imageUrl - ? _value.imageUrl - : imageUrl // ignore: cast_nullable_to_non_nullable - as String?, - categoryId: null == categoryId - ? _value.categoryId - : categoryId // ignore: cast_nullable_to_non_nullable - as String, - isAvailable: null == isAvailable - ? _value.isAvailable - : isAvailable // ignore: cast_nullable_to_non_nullable - as bool, - category: freezed == category - ? _value.category - : category // ignore: cast_nullable_to_non_nullable - as Category?, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$ProductWithCategoryImpl implements _ProductWithCategory { - const _$ProductWithCategoryImpl( - {required this.id, - required this.name, - required this.price, - this.description, - @JsonKey(name: 'image_url') this.imageUrl, - @JsonKey(name: 'category_id') required this.categoryId, - @JsonKey(name: 'is_available') required this.isAvailable, - this.category = null}); - - factory _$ProductWithCategoryImpl.fromJson(Map json) => - _$$ProductWithCategoryImplFromJson(json); - - @override - final String id; - @override - final String name; - @override - final double price; - @override - final String? description; - @override - @JsonKey(name: 'image_url') - final String? imageUrl; - @override - @JsonKey(name: 'category_id') - final String categoryId; - @override - @JsonKey(name: 'is_available') - final bool isAvailable; - @override - @JsonKey() - final Category? category; - - @override - String toString() { - return 'ProductWithCategory(id: $id, name: $name, price: $price, description: $description, imageUrl: $imageUrl, categoryId: $categoryId, isAvailable: $isAvailable, category: $category)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$ProductWithCategoryImpl && - (identical(other.id, id) || other.id == id) && - (identical(other.name, name) || other.name == name) && - (identical(other.price, price) || other.price == price) && - (identical(other.description, description) || - other.description == description) && - (identical(other.imageUrl, imageUrl) || - other.imageUrl == imageUrl) && - (identical(other.categoryId, categoryId) || - other.categoryId == categoryId) && - (identical(other.isAvailable, isAvailable) || - other.isAvailable == isAvailable) && - (identical(other.category, category) || - other.category == category)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash(runtimeType, id, name, price, description, - imageUrl, categoryId, isAvailable, category); - - /// Create a copy of ProductWithCategory - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$ProductWithCategoryImplCopyWith<_$ProductWithCategoryImpl> get copyWith => - __$$ProductWithCategoryImplCopyWithImpl<_$ProductWithCategoryImpl>( - this, _$identity); - - @override - Map toJson() { - return _$$ProductWithCategoryImplToJson( - this, - ); - } -} - -abstract class _ProductWithCategory implements ProductWithCategory { - const factory _ProductWithCategory( - {required final String id, - required final String name, - required final double price, - final String? description, - @JsonKey(name: 'image_url') final String? imageUrl, - @JsonKey(name: 'category_id') required final String categoryId, - @JsonKey(name: 'is_available') required final bool isAvailable, - final Category? category}) = _$ProductWithCategoryImpl; - - factory _ProductWithCategory.fromJson(Map json) = - _$ProductWithCategoryImpl.fromJson; - - @override - String get id; - @override - String get name; - @override - double get price; - @override - String? get description; - @override - @JsonKey(name: 'image_url') - String? get imageUrl; - @override - @JsonKey(name: 'category_id') - String get categoryId; - @override - @JsonKey(name: 'is_available') - bool get isAvailable; - @override - Category? get category; - - /// Create a copy of ProductWithCategory - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$ProductWithCategoryImplCopyWith<_$ProductWithCategoryImpl> get copyWith => - throw _privateConstructorUsedError; -} - ProductDraft _$ProductDraftFromJson(Map json) { return _ProductDraft.fromJson(json); } diff --git a/lib/models/product.g.dart b/lib/models/product.g.dart index f219004..4bf2cf4 100644 --- a/lib/models/product.g.dart +++ b/lib/models/product.g.dart @@ -10,6 +10,7 @@ _$ProductImpl _$$ProductImplFromJson(Map json) => _$ProductImpl( id: json['id'] as String, name: json['name'] as String, + slug: json['slug'] as String, price: (json['price'] as num).toDouble(), description: json['description'] as String?, imageUrl: json['image_url'] as String?, @@ -21,6 +22,7 @@ Map _$$ProductImplToJson(_$ProductImpl instance) => { 'id': instance.id, 'name': instance.name, + 'slug': instance.slug, 'price': instance.price, 'description': instance.description, 'image_url': instance.imageUrl, @@ -28,34 +30,6 @@ Map _$$ProductImplToJson(_$ProductImpl instance) => 'is_available': instance.isAvailable, }; -_$ProductWithCategoryImpl _$$ProductWithCategoryImplFromJson( - Map json) => - _$ProductWithCategoryImpl( - id: json['id'] as String, - name: json['name'] as String, - price: (json['price'] as num).toDouble(), - description: json['description'] as String?, - imageUrl: json['image_url'] as String?, - categoryId: json['category_id'] as String, - isAvailable: json['is_available'] as bool, - category: json['category'] == null - ? null - : Category.fromJson(json['category'] as Map), - ); - -Map _$$ProductWithCategoryImplToJson( - _$ProductWithCategoryImpl instance) => - { - 'id': instance.id, - 'name': instance.name, - 'price': instance.price, - 'description': instance.description, - 'image_url': instance.imageUrl, - 'category_id': instance.categoryId, - 'is_available': instance.isAvailable, - 'category': instance.category, - }; - _$ProductDraftImpl _$$ProductDraftImplFromJson(Map json) => _$ProductDraftImpl( id: json['id'] as String?, diff --git a/lib/repository/product_repository.dart b/lib/repository/product_repository.dart index d4b245d..8105406 100644 --- a/lib/repository/product_repository.dart +++ b/lib/repository/product_repository.dart @@ -173,6 +173,32 @@ class ProductRepository extends BaseRepository { } } + Future> searchBySlug(String slug) async { + final response = await client + .from(productsSchema.table) + .select('*, ${categoriesSchema.table}!inner(*)') + .ilike('slug', '%$slug%') + .eq('is_available', true); + + return _parseProducts(response); + } + + Future getBySlug(String slug) async { + try { + final response = await client + .from(productsSchema.table) + .select() + .eq('slug', slug) + .eq('is_available', true) + .maybeSingle(); + + return response != null ? Product.fromJson(response) : null; + } catch (error, stackTrace) { + _logger.e('Failed to fetch product by slug $slug', error, stackTrace); + throw Exception('Failed to fetch product by slug: $error'); + } + } + List _parseProducts(dynamic response) { final data = List>.from(response as List); return data.map(Product.fromJson).toList(); diff --git a/lib/route/route_constants.dart b/lib/route/route_constants.dart index 082d28f..668878f 100644 --- a/lib/route/route_constants.dart +++ b/lib/route/route_constants.dart @@ -20,6 +20,7 @@ const String brandScreenRoute = "brand"; const String discoverScreenRoute = "discover"; const String onSaleScreenRoute = "on_sale"; const String searchScreenRoute = "search"; +const String searchResultsScreenRoute = "search_results"; const String bookmarkScreenRoute = "bookmark"; const String entryPointScreenRoute = "entry_point"; const String profileScreenRoute = "profile"; diff --git a/lib/route/router.dart b/lib/route/router.dart index ffa0045..dbe8442 100644 --- a/lib/route/router.dart +++ b/lib/route/router.dart @@ -104,6 +104,14 @@ Route generateRoute(RouteSettings settings) { settings: settings, child: const SearchScreen(), ); + case searchResultsScreenRoute: + final args = settings.arguments as Map; + return _buildRoute( + settings: settings, + child: SearchResultsScreen( + query: args['query'] as String, + searchText: args['searchText'] as String), + ); case entryPointScreenRoute: return _buildRoute( settings: settings, diff --git a/lib/route/screen_export.dart b/lib/route/screen_export.dart index fa76ee5..2e925f9 100644 --- a/lib/route/screen_export.dart +++ b/lib/route/screen_export.dart @@ -10,6 +10,7 @@ export '/screens/on_sale/views/on_sale_screen.dart'; export '/screens/reviews/view/product_reviews_screen.dart'; export '/screens/search/views/search_screen.dart'; +export '/screens/search/views/search_results_screen.dart'; export '/screens/checkout/views/cart_screen.dart'; export '/screens/notification/view/enable_notification_screen.dart'; diff --git a/lib/screens/checkout/views/cart_screen.dart b/lib/screens/checkout/views/cart_screen.dart index b9fb18a..7391720 100644 --- a/lib/screens/checkout/views/cart_screen.dart +++ b/lib/screens/checkout/views/cart_screen.dart @@ -57,10 +57,10 @@ class _CartScreenState extends State { Icon( Icons.lock_outline, color: cellphoneZRed, - size: 28, + size: 24, ), - const SizedBox(width: 8), - const Text('Yêu cầu đăng nhập'), + const SizedBox(width: 6), + const Text('Yêu cầu đăng nhập', style: TextStyle(fontSize: 20)), ], ), content: const Text( diff --git a/lib/screens/home/views/home_screen.dart b/lib/screens/home/views/home_screen.dart index a32daa7..b39198b 100644 --- a/lib/screens/home/views/home_screen.dart +++ b/lib/screens/home/views/home_screen.dart @@ -71,13 +71,11 @@ class _HomeScreenState extends State { ), ), const Spacer(), - IconButton( - icon: const Icon(Icons.qr_code_scanner), - onPressed: () {}, - ), IconButton( icon: const Icon(Icons.search), - onPressed: () {}, + onPressed: () { + Navigator.pushNamed(context, searchScreenRoute); + }, ), IconButton( icon: const Icon(Icons.shopping_bag_outlined), diff --git a/lib/screens/search/views/search_results_screen.dart b/lib/screens/search/views/search_results_screen.dart new file mode 100644 index 0000000..e224876 --- /dev/null +++ b/lib/screens/search/views/search_results_screen.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:shop/components/product/product_card.dart'; +import 'package:shop/constants.dart'; +import 'package:shop/models/product.dart'; +import 'package:shop/route/route_constants.dart'; +import 'package:shop/services/product_service.dart'; + +class SearchResultsScreen extends StatefulWidget { + const SearchResultsScreen({ + super.key, + required this.query, + required this.searchText, + }); + + final String query; + final String searchText; + + @override + State createState() => _SearchResultsScreenState(); +} + +class _SearchResultsScreenState extends State { + final ProductService _productService = ProductService(); + List _searchResults = []; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _performSearch(); + } + + Future _performSearch() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final results = await _productService.searchProductsBySlug(widget.query); + + if (mounted) { + setState(() { + _searchResults = results; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = 'Lỗi khi tìm kiếm: $e'; + _isLoading = false; + }); + } + } + } + + Widget _buildSearchResults() { + if (_isLoading) { + return const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(cellphoneZRed), + ), + ); + } + + if (_error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + color: Colors.grey.shade400, + size: 64, + ), + const SizedBox(height: 16), + Text( + _error!, + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 16, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _performSearch, + style: ElevatedButton.styleFrom( + backgroundColor: cellphoneZRed, + foregroundColor: Colors.white, + ), + child: const Text('Thử lại'), + ), + ], + ), + ); + } + + if (_searchResults.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + color: Colors.grey.shade400, + size: 64, + ), + const SizedBox(height: 16), + Text( + 'Không tìm thấy sản phẩm nào', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Thử tìm kiếm với từ khóa khác', + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 14, + ), + ), + ], + ), + ); + } + + return GridView.builder( + padding: const EdgeInsets.all(defaultPadding), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 0.75, + crossAxisSpacing: defaultPadding, + mainAxisSpacing: defaultPadding, + ), + itemCount: _searchResults.length, + itemBuilder: (context, index) { + final product = _searchResults[index]; + return ProductCard.fromProduct( + product: product, + onPressed: () { + Navigator.pushNamed( + context, + productDetailScreenRoute, + arguments: product.id, + ); + }, + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: cellphoneZRed, + foregroundColor: Colors.white, + elevation: 0, + title: Text('Kết quả cho "${widget.searchText}"'), + actions: [ + IconButton( + onPressed: () { + Navigator.pushNamed(context, searchScreenRoute); + }, + icon: const Icon(Icons.search), + ), + ], + ), + body: Column( + children: [ + // Search info header + Container( + width: double.infinity, + padding: const EdgeInsets.all(defaultPadding), + color: Colors.grey.shade50, + child: Text( + _isLoading + ? 'Đang tìm kiếm...' + : 'Tìm thấy ${_searchResults.length} sản phẩm', + style: TextStyle( + color: Colors.grey.shade700, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + + // Results + Expanded( + child: _buildSearchResults(), + ), + ], + ), + ); + } +} diff --git a/lib/screens/search/views/search_screen.dart b/lib/screens/search/views/search_screen.dart index 6817269..f1172fa 100644 --- a/lib/screens/search/views/search_screen.dart +++ b/lib/screens/search/views/search_screen.dart @@ -1,5 +1,10 @@ +import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:shop/components/buy_full_ui_kit.dart'; +import 'package:shop/constants.dart'; +import 'package:shop/models/product.dart'; +import 'package:shop/services/product_service.dart'; +import 'package:shop/route/route_constants.dart'; +import 'package:shop/components/network_image_with_loader.dart'; class SearchScreen extends StatefulWidget { const SearchScreen({super.key}); @@ -9,19 +14,513 @@ class SearchScreen extends StatefulWidget { } class _SearchScreenState extends State { + final TextEditingController _searchController = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + final ProductService _productService = ProductService(); + + bool _isDropdownVisible = false; + List _searchResults = []; + bool _isSearching = false; + Timer? _debounceTimer; + + @override + void initState() { + super.initState(); + _focusNode.addListener(_onFocusChange); + // Auto focus when screen opens + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } + + @override + void dispose() { + _debounceTimer?.cancel(); + _searchController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + String _generateSlug(String text) { + return text + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9\s-]'), '') + .replaceAll(RegExp(r'\s+'), '-') + .replaceAll(RegExp(r'-+'), '-') + .trim(); + } + + void _onFocusChange() { + if (_focusNode.hasFocus) { + _showDropdown(); + } + } + + void _showDropdown() { + setState(() { + _isDropdownVisible = true; + if (_searchController.text.isEmpty) { + _searchResults = []; + } + }); + } + + void _hideDropdown() { + setState(() { + _isDropdownVisible = false; + }); + } + + Future _onSearchChanged(String query) async { + // Cancel previous timer + _debounceTimer?.cancel(); + + if (query.isEmpty) { + setState(() { + _searchResults = []; + _isDropdownVisible = _focusNode.hasFocus; + _isSearching = false; + }); + return; + } + + // Show loading immediately + setState(() { + _isSearching = true; + _isDropdownVisible = true; + }); + + // Debounce the search request + _debounceTimer = Timer(const Duration(milliseconds: 1500), () async { + try { + // Generate slug from the query for searching + final searchSlug = _generateSlug(query); + + // Search products by slug using the service + final results = await _productService.searchProductsBySlug(searchSlug); + + if (mounted) { + setState(() { + _searchResults = results.take(8).toList(); + _isSearching = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _searchResults = []; + _isSearching = false; + }); + } + } + }); + } + + void _onSearchResultSelected(Product product) { + _searchController.text = product.name; + _hideDropdown(); + _focusNode.unfocus(); + + Navigator.pushNamed( + context, + productDetailScreenRoute, + arguments: product.id, + ); + } + + void _onSearchSubmit(String query) async { + if (query.trim().isEmpty) return; + + // Cancel any pending debounce timer + _debounceTimer?.cancel(); + + _focusNode.unfocus(); + _hideDropdown(); + + // Generate slug from the query for search results + final searchSlug = _generateSlug(query.trim()); + + // Navigate to search results screen with the slug + Navigator.pushNamed( + context, + searchResultsScreenRoute, + arguments: {'query': searchSlug, 'searchText': query.trim()}, + ); + } + + Widget _buildSearchBar() { + return Container( + padding: const EdgeInsets.all(defaultPadding), + color: Colors.white, + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black87), + onPressed: () => Navigator.pop(context), + ), + Expanded( + child: Container( + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: TextField( + controller: _searchController, + focusNode: _focusNode, + onChanged: _onSearchChanged, + onSubmitted: _onSearchSubmit, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: 'Tìm kiếm sản phẩm...', + prefixIcon: + const Icon(Icons.search, color: Colors.grey, size: 20), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, + color: Colors.grey, size: 20), + onPressed: () { + _searchController.clear(); + _onSearchChanged(''); + }, + ) + : null, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + ), + ), + ), + const SizedBox(width: 8), + TextButton( + onPressed: () => _onSearchSubmit(_searchController.text), + child: Text( + 'Tìm', + style: TextStyle( + color: cellphoneZRed, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } + + Widget _buildDropdownResults() { + if (!_isDropdownVisible) { + return const SizedBox.shrink(); + } + + if (_isSearching) { + return Container( + color: Colors.white, + padding: const EdgeInsets.all(20), + child: const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(cellphoneZRed), + ), + ), + ); + } + + if (_searchResults.isEmpty) { + return Container( + color: Colors.white, + padding: const EdgeInsets.all(20), + child: Center( + child: Text( + 'Không tìm thấy sản phẩm nào', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 14, + ), + ), + ), + ); + } + + return Container( + color: Colors.white, + constraints: const BoxConstraints(maxHeight: 400), + child: ListView.separated( + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: _searchResults.length, + separatorBuilder: (context, index) => Divider( + height: 1, + color: Colors.grey.shade200, + ), + itemBuilder: (context, index) { + final product = _searchResults[index]; + return ListTile( + dense: true, + leading: SizedBox( + width: 40, + height: 40, + child: product.imageUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(6), + child: NetworkImageWithLoader( + product.imageUrl!, + fit: BoxFit.cover, + ), + ) + : Container( + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + Icons.image_not_supported_outlined, + color: Colors.grey.shade400, + size: 20, + ), + ), + ), + title: Text( + product.name, + style: const TextStyle(fontSize: 14), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + product.slug, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () => _onSearchResultSelected(product), + trailing: const Icon( + Icons.north_west, + size: 16, + color: Colors.grey, + ), + ); + }, + ), + ); + } + + Widget _buildPopularSearches() { + final popularSearches = [ + 'iPhone 15', + 'Samsung Galaxy', + 'MacBook', + 'AirPods', + 'Gaming Laptop', + 'Wireless Charger', + 'Phone Case', + 'Bluetooth Speaker', + ]; + + return Container( + padding: const EdgeInsets.all(defaultPadding), + color: Colors.white, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.trending_up, + color: cellphoneZRed, + size: 20, + ), + const SizedBox(width: 8), + const Text( + 'Tìm kiếm phổ biến', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: popularSearches.map((search) { + return GestureDetector( + onTap: () { + _searchController.text = search; + _onSearchSubmit(search); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Colors.grey.shade300, + width: 0.5, + ), + ), + child: Text( + search, + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade700, + ), + ), + ), + ); + }).toList(), + ), + ], + ), + ); + } + + Widget _buildRecentSearches() { + final recentSearches = [ + 'iPhone 15 Pro Max', + 'Samsung S24 Ultra', + 'MacBook Pro M3', + 'AirPods Pro 2', + ]; + + return Container( + padding: const EdgeInsets.all(defaultPadding), + color: Colors.white, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.history, + color: Colors.grey.shade600, + size: 20, + ), + const SizedBox(width: 8), + const Text( + 'Tìm kiếm gần đây', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const Spacer(), + TextButton( + onPressed: () { + // TODO: Clear recent searches + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Đã xóa lịch sử tìm kiếm'), + duration: Duration(seconds: 1), + ), + ); + }, + child: Text( + 'Xóa tất cả', + style: TextStyle( + fontSize: 14, + color: cellphoneZRed, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + ...recentSearches.map((search) { + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon( + Icons.history, + color: Colors.grey.shade500, + size: 18, + ), + title: Text( + search, + style: const TextStyle(fontSize: 14), + ), + trailing: IconButton( + icon: Icon( + Icons.close, + color: Colors.grey.shade500, + size: 18, + ), + onPressed: () { + // TODO: Remove this search from recent + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Đã xóa "$search" khỏi lịch sử'), + duration: const Duration(seconds: 1), + ), + ); + }, + ), + onTap: () { + _searchController.text = search; + _onSearchSubmit(search); + }, + ); + }), + ], + ), + ); + } + @override Widget build(BuildContext context) { - return const BuyFullKit( - images: [ - "assets/screens/SEARCH_1.png", - "assets/screens/Search_2.png", - "assets/screens/Search_3.png", - "assets/screens/Search_4.png", - "assets/screens/Search_5.png", - "assets/screens/Search_6.png", - "assets/screens/Search_7.png", - "assets/screens/Search_8.png", - ], + return Scaffold( + backgroundColor: Colors.grey.shade50, + body: SafeArea( + child: GestureDetector( + onTap: () { + if (_isDropdownVisible) { + _hideDropdown(); + } + }, + child: Column( + children: [ + _buildSearchBar(), + Expanded( + child: Stack( + children: [ + // Main content (popular searches, recent searches) + if (!_isDropdownVisible) ...[ + SingleChildScrollView( + child: Column( + children: [ + const SizedBox(height: 8), + _buildPopularSearches(), + const SizedBox(height: 8), + _buildRecentSearches(), + ], + ), + ), + ], + + // Dropdown results overlay + if (_isDropdownVisible) ...[ + Positioned( + top: 0, + left: 0, + right: 0, + child: _buildDropdownResults(), + ), + ], + ], + ), + ), + ], + ), + ), + ), ); } } diff --git a/lib/services/product_service.dart b/lib/services/product_service.dart index 1fc8e06..dbff119 100644 --- a/lib/services/product_service.dart +++ b/lib/services/product_service.dart @@ -15,8 +15,7 @@ class ProductService { ProductRepository? productRepository, StorageService? storageService, AppLogger? logger, - }) : _productRepository = - productRepository ?? getIt(), + }) : _productRepository = productRepository ?? getIt(), _storageService = storageService ?? getIt(), _logger = logger ?? AppLogger.instance; @@ -108,8 +107,7 @@ class ProductService { isAvailable: isAvailable, ); - final result = - await _productRepository.updateProduct(current.id, draft); + final result = await _productRepository.updateProduct(current.id, draft); if (uploadResult != null && previousStoragePath != null) { await _safeRemoveImage(previousStoragePath); @@ -227,12 +225,17 @@ class ProductService { final categoryProducts = await _productRepository.getProductsByCategory(product.categoryId); - final related = - categoryProducts.where((p) => p.id != productId).toList(); + final related = categoryProducts.where((p) => p.id != productId).toList(); - return related.length > limit - ? related.sublist(0, limit) - : related; + return related.length > limit ? related.sublist(0, limit) : related; + } + + Future> searchProductsBySlug(String slug) async { + return await _productRepository.searchBySlug(slug); + } + + Future getProductBySlug(String slug) async { + return await _productRepository.getBySlug(slug); } // endregion @@ -282,8 +285,8 @@ class ProductService { extension: ext == 'png' ? 'png' : 'jpg', ); } catch (error, stackTrace) { - _logger.w('Image compression failed, using original bytes', - error, stackTrace); + _logger.w( + 'Image compression failed, using original bytes', error, stackTrace); return _PreparedImage(bytes: image.bytes, extension: image.extension); } }