From 24251c3fc079e91cfe3a5e0e75d876b36bfbe849 Mon Sep 17 00:00:00 2001 From: TanKhoa1709 Date: Sat, 15 Aug 2026 23:19:10 +0700 Subject: [PATCH 1/2] refactor(seller): Refactor dialog widgets and refine product visibility controls This commit improves code organization in the listing suggestion screen by extracting dialog logic into dedicated widgets and updates the status-based visibility logic for seller products. Key changes: - **Dialog Refactoring**: - Extracted the "Add Tag" and "Add Specification" logic from `ListingSuggestionScreen` into standalone `_AddTagDialog` and `_AddSpecDialog` widgets. - Improved state management and controller disposal within these new dialog components. - Simplified the main screen's methods (`_addTag`, `_addSpec`) by offloading UI construction to the new widgets. - **Data Hygiene**: Added string trimming to tag and specification inputs to prevent leading/trailing whitespace. - **Visibility Logic**: Updated `SellerProductsScreen` to only show the visibility toggle for listings with `active` or `hidden` status, preventing users from attempting to toggle products that are in `draft` or pending review. --- .../screens/listing_suggestion_screen.dart | 293 +++++++++++------- .../screens/seller_products_screen.dart | 7 +- 2 files changed, 182 insertions(+), 118 deletions(-) diff --git a/lib/features/seller/presentation/screens/listing_suggestion_screen.dart b/lib/features/seller/presentation/screens/listing_suggestion_screen.dart index 8dabe7f..4c1b353 100644 --- a/lib/features/seller/presentation/screens/listing_suggestion_screen.dart +++ b/lib/features/seller/presentation/screens/listing_suggestion_screen.dart @@ -990,132 +990,32 @@ class _ListingSuggestionScreenState return; } - final controller = TextEditingController(); - final raw = await _promptText( - title: 'Thêm thẻ', - hint: 'handmade', - controller: controller, + final raw = await showDialog( + context: context, + builder: (dialogContext) => _AddTagDialog( + cardColor: _cardColor, + inputDecoration: _inputDecoration, + ), ); - controller.dispose(); - setState(() => _tags = listingTags([..._tags, raw ?? ''])); + if (!mounted || raw == null || raw.trim().isEmpty) return; + setState(() => _tags = listingTags([..._tags, raw.trim()])); } Future _addSpec() async { - final keyController = TextEditingController(); - final valueController = TextEditingController(); - final theme = Theme.of(context); - - final added = await showDialog( + final pair = await showDialog>( context: context, - builder: (dialogContext) => AlertDialog( - backgroundColor: _cardColor, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - title: Text( - 'Thêm thông số', - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.bold, - color: theme.colorScheme.onSurface, - ), - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: keyController, - autofocus: true, - style: TextStyle(color: theme.colorScheme.onSurface), - decoration: _inputDecoration( - hint: 'Tên thông số, ví dụ: Dung lượng', - ), - ), - const SizedBox(height: 10), - TextField( - controller: valueController, - style: TextStyle(color: theme.colorScheme.onSurface), - decoration: _inputDecoration(hint: 'Giá trị, ví dụ: 64GB'), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext, false), - child: Text( - 'Hủy', - style: TextStyle(color: theme.colorScheme.onSurfaceVariant), - ), - ), - ElevatedButton( - onPressed: () => Navigator.pop(dialogContext, true), - style: ElevatedButton.styleFrom( - backgroundColor: theme.colorScheme.primary, - foregroundColor: theme.colorScheme.onPrimary, - ), - child: const Text('Thêm'), - ), - ], + builder: (dialogContext) => _AddSpecDialog( + cardColor: _cardColor, + inputDecoration: _inputDecoration, ), ); - - final key = keyController.text.trim(); - final value = valueController.text.trim(); - keyController.dispose(); - valueController.dispose(); - if (added != true || key.isEmpty || value.isEmpty) return; + if (!mounted || pair == null) return; setState(() { - _specs.removeWhere((spec) => spec.key == key); - _specs.add(MapEntry(key, value)); + _specs.removeWhere((spec) => spec.key == pair.key); + _specs.add(pair); }); } - Future _promptText({ - required String title, - required String hint, - required TextEditingController controller, - }) { - final theme = Theme.of(context); - - return showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - backgroundColor: _cardColor, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - title: Text( - title, - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.bold, - color: theme.colorScheme.onSurface, - ), - ), - content: TextField( - controller: controller, - autofocus: true, - style: TextStyle(color: theme.colorScheme.onSurface), - decoration: _inputDecoration(hint: hint), - onSubmitted: (value) => Navigator.pop(dialogContext, value), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text( - 'Hủy', - style: TextStyle(color: theme.colorScheme.onSurfaceVariant), - ), - ), - ElevatedButton( - onPressed: () => Navigator.pop(dialogContext, controller.text), - style: ElevatedButton.styleFrom( - backgroundColor: theme.colorScheme.primary, - foregroundColor: theme.colorScheme.onPrimary, - ), - child: const Text('Thêm'), - ), - ], - ), - ); - } - void _applySuggestion(ListingSuggestion suggestion) { _nameController.text = suggestion.name; _descriptionController.text = suggestion.description; @@ -1420,3 +1320,166 @@ class _IdentityGate extends StatelessWidget { ); } } + +class _AddTagDialog extends StatefulWidget { + final Color cardColor; + final InputDecoration Function({required String hint}) inputDecoration; + + const _AddTagDialog({ + required this.cardColor, + required this.inputDecoration, + }); + + @override + State<_AddTagDialog> createState() => _AddTagDialogState(); +} + +class _AddTagDialogState extends State<_AddTagDialog> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AlertDialog( + backgroundColor: widget.cardColor, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Text( + 'Thêm thẻ', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, + ), + ), + content: TextField( + controller: _controller, + autofocus: true, + style: TextStyle(color: theme.colorScheme.onSurface), + decoration: widget.inputDecoration(hint: 'handmade'), + onSubmitted: (value) => Navigator.pop(context, value.trim()), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + 'Hủy', + style: TextStyle(color: theme.colorScheme.onSurfaceVariant), + ), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, _controller.text.trim()), + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + ), + child: const Text('Thêm'), + ), + ], + ); + } +} + +class _AddSpecDialog extends StatefulWidget { + final Color cardColor; + final InputDecoration Function({required String hint}) inputDecoration; + + const _AddSpecDialog({ + required this.cardColor, + required this.inputDecoration, + }); + + @override + State<_AddSpecDialog> createState() => _AddSpecDialogState(); +} + +class _AddSpecDialogState extends State<_AddSpecDialog> { + late final TextEditingController _keyController; + late final TextEditingController _valueController; + + @override + void initState() { + super.initState(); + _keyController = TextEditingController(); + _valueController = TextEditingController(); + } + + @override + void dispose() { + _keyController.dispose(); + _valueController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AlertDialog( + backgroundColor: widget.cardColor, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Text( + 'Thêm thông số', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _keyController, + autofocus: true, + style: TextStyle(color: theme.colorScheme.onSurface), + decoration: widget.inputDecoration( + hint: 'Tên thông số, ví dụ: Dung lượng', + ), + ), + const SizedBox(height: 10), + TextField( + controller: _valueController, + style: TextStyle(color: theme.colorScheme.onSurface), + decoration: widget.inputDecoration(hint: 'Giá trị, ví dụ: 64GB'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + 'Hủy', + style: TextStyle(color: theme.colorScheme.onSurfaceVariant), + ), + ), + ElevatedButton( + onPressed: () { + final key = _keyController.text.trim(); + final value = _valueController.text.trim(); + if (key.isNotEmpty && value.isNotEmpty) { + Navigator.pop(context, MapEntry(key, value)); + } else { + Navigator.pop(context); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + ), + child: const Text('Thêm'), + ), + ], + ); + } +} diff --git a/lib/features/seller/presentation/screens/seller_products_screen.dart b/lib/features/seller/presentation/screens/seller_products_screen.dart index 4b18411..b2a0b93 100644 --- a/lib/features/seller/presentation/screens/seller_products_screen.dart +++ b/lib/features/seller/presentation/screens/seller_products_screen.dart @@ -446,9 +446,10 @@ class _SellerProductsScreenState extends ConsumerState { Row( mainAxisSize: MainAxisSize.min, children: [ - // A draft has never been published, so there is no - // publication to take down and nothing to toggle. - if (listing.status != ListingStatus.draft) ...[ + // Only active or hidden listings can be toggled. + // Products that are pending review (Chờ duyệt) or draft cannot be toggled. + if (listing.status == ListingStatus.active || + listing.status == ListingStatus.hidden) ...[ GestureDetector( onTap: () => _showToggleConfirmDialog( context, From 5c697d754c50815184824641dc8b702fbebaa825 Mon Sep 17 00:00:00 2001 From: TanKhoa1709 Date: Sun, 16 Aug 2026 00:10:39 +0700 Subject: [PATCH 2/2] feat(seller): Implement multi-variant management and enhance listing workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces comprehensive support for product variants, allowing sellers to manage multiple versions of a product (e.g., different sizes or colors) with specific pricing and stock levels. It also refines the listing publication and category selection experience. Key changes: - **Multi-Variant Support**: - Expanded `EditVariantSheet` to allow adding and deleting variants in addition to updating price and quantity. - Updated `ListingSuggestionScreen` to support defining multiple variants with custom attributes (e.g., "Color: Red") during the initial product creation. - Modified `ListingDraft` model to handle lists of variants instead of single price/quantity fields. - **Searchable Category Selection**: - Replaced the standard category dropdown with a searchable bottom sheet (`_CategorySearchBottomSheet`) in both the edit and suggestion screens to improve navigation through large category trees. - **Listing Workflow Enhancements**: - Added "Send for Review" (Gửi duyệt) functionality to the seller product list, allowing drafts to be published directly. - Updated `SellerRepository` and `SellerProductsNotifier` to support the new publication flow and variant additions. - Improved UI status labels and refined the visual layout of product management cards. - **UI/UX Refinements**: - Standardized variant labels to display attribute pairs (e.g., "Size: L · Color: Blue"). - Added confirmation dialogs for destructive actions like deleting a variant. - Improved form validation for price, quantity, and weight inputs. --- .../seller/data/models/listing_draft.dart | 27 +- .../data/repositories/seller_repository.dart | 27 +- .../providers/seller_products_provider.dart | 9 + .../screens/listing_edit_screen.dart | 285 +++++- .../screens/listing_suggestion_screen.dart | 907 +++++++++++++++--- .../screens/seller_products_screen.dart | 137 ++- .../widgets/edit_variant_sheet.dart | 842 +++++++++++++--- 7 files changed, 1946 insertions(+), 288 deletions(-) diff --git a/lib/features/seller/data/models/listing_draft.dart b/lib/features/seller/data/models/listing_draft.dart index 51a566c..5da76f8 100644 --- a/lib/features/seller/data/models/listing_draft.dart +++ b/lib/features/seller/data/models/listing_draft.dart @@ -93,14 +93,23 @@ CreateListingRequest listingDraftRequest({ required ListingCondition condition, required String currency, required PriceMode priceMode, - required int price, - required int weightG, - required int quantity, + List? variants, + int? price, + int? weightG, + int? quantity, List attachments = const [], List tags = const [], Map specifications = const {}, }) { final slugs = listingTags(tags); + final finalVariants = variants ?? [ + CreateVariantRequest( + price: price ?? 0, + quantity: quantity ?? 1, + attributes: defaultVariantAttributes, + packageDetails: {'weight_g': weightG ?? 500}, + ), + ]; return CreateListingRequest( name: name, @@ -114,16 +123,6 @@ CreateListingRequest listingDraftRequest({ attachments: attachments.isEmpty ? null : attachments, tags: slugs.isEmpty ? null : slugs, specifications: specifications.isEmpty ? null : specifications, - variants: [ - // One variant: this is a marketplace for used goods, so a listing is - // normally the one item in the seller's hands. Price and parcel weight - // live on the variant, which is why it exists at all. - CreateVariantRequest( - price: price, - quantity: quantity, - attributes: defaultVariantAttributes, - packageDetails: {'weight_g': weightG}, - ), - ], + variants: finalVariants, ); } diff --git a/lib/features/seller/data/repositories/seller_repository.dart b/lib/features/seller/data/repositories/seller_repository.dart index 981626c..213642b 100644 --- a/lib/features/seller/data/repositories/seller_repository.dart +++ b/lib/features/seller/data/repositories/seller_repository.dart @@ -10,6 +10,7 @@ import 'package:shopnexus_flutter_app/api/generated/model/create_bank_account_re import 'package:shopnexus_flutter_app/api/generated/model/payment_session.dart'; import 'package:shopnexus_flutter_app/api/generated/model/transaction.dart'; import 'package:shopnexus_flutter_app/api/generated/model/update_bank_account_request.dart'; +import 'package:shopnexus_flutter_app/api/generated/model/create_variant_request.dart'; import 'package:shopnexus_flutter_app/api/generated/model/update_variant_request.dart'; import 'package:shopnexus_flutter_app/api/generated/model/create_withdrawal_request.dart'; import 'package:shopnexus_flutter_app/api/generated/model/listing.dart'; @@ -20,6 +21,7 @@ import 'package:shopnexus_flutter_app/api/generated/model/order_item.dart'; import 'package:shopnexus_flutter_app/api/generated/model/order_state.dart'; import 'package:shopnexus_flutter_app/api/generated/model/order_summary.dart'; import 'package:shopnexus_flutter_app/api/generated/model/listing_detail.dart'; +import 'package:shopnexus_flutter_app/api/generated/model/publish_listing_request.dart'; import 'package:shopnexus_flutter_app/api/generated/model/tax_info.dart'; import 'package:shopnexus_flutter_app/api/generated/model/update_listing_request.dart'; import 'package:shopnexus_flutter_app/api/generated/model/upsert_tax_info_request.dart'; @@ -254,11 +256,26 @@ class SellerRepository { Future hideListing(String id) => _catalogApi.listingsIdPublicationDelete(id: id); - /// Re-queues a hidden listing for moderation, so it comes back as `pending` - /// rather than straight to `active`. That is also the route out of a takedown — - /// it clears the marker and its reason and the listing is reviewed again. - Future publishListing(String id) => - _catalogApi.listingsIdPublicationPost(id: id); + Future publishListing(String id, {String? pickupContactId}) => + _catalogApi.listingsIdPublicationPost( + id: id, + publishListingRequest: pickupContactId == null + ? null + : PublishListingRequest(pickupContactId: pickupContactId), + ); + + Future addVariant( + String listingId, + CreateVariantRequest request, + ) async { + final response = await _catalogApi.listingsIdVariantsPost( + id: listingId, + createVariantRequest: request, + ); + final detail = response.data?.data; + if (detail == null) throw StateError('empty add variant response'); + return detail; + } Future deleteVariant(String id) => _catalogApi.variantsIdDelete(id: id); diff --git a/lib/features/seller/presentation/providers/seller_products_provider.dart b/lib/features/seller/presentation/providers/seller_products_provider.dart index 585a06e..a790bc5 100644 --- a/lib/features/seller/presentation/providers/seller_products_provider.dart +++ b/lib/features/seller/presentation/providers/seller_products_provider.dart @@ -79,6 +79,15 @@ class SellerProductsNotifier extends _$SellerProductsNotifier { await refresh(); } + /// Sends a draft or inactive listing to moderation (`pending`). + Future publishListing(String id, {String? pickupContactId}) async { + await ref.read(sellerRepositoryProvider).publishListing( + id, + pickupContactId: pickupContactId, + ); + await refresh(); + } + Future deleteListing(String id) async { await ref.read(sellerRepositoryProvider).deleteListing(id); await refresh(); diff --git a/lib/features/seller/presentation/screens/listing_edit_screen.dart b/lib/features/seller/presentation/screens/listing_edit_screen.dart index 3728e61..0dde010 100644 --- a/lib/features/seller/presentation/screens/listing_edit_screen.dart +++ b/lib/features/seller/presentation/screens/listing_edit_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shopnexus_flutter_app/api/generated/model/category.dart'; import 'package:shopnexus_flutter_app/api/generated/model/listing_condition.dart'; import 'package:shopnexus_flutter_app/api/generated/model/listing_detail.dart'; import 'package:shopnexus_flutter_app/api/generated/model/price_mode.dart'; @@ -227,7 +228,7 @@ class _ListingEditScreenState extends ConsumerState { const SizedBox(height: 8), // Ảnh sửa ở đâu thì nói ra, thay vì để một ô trống người bán tìm mãi. Text( - 'Ảnh và giá bán sửa ở màn danh sách tin, mục "Sửa giá & tồn kho".', + 'Ảnh, giá bán và phiên bản sửa ở màn danh sách tin, mục "Quản lý phiên bản (giá & tồn kho)".', textAlign: TextAlign.center, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, @@ -249,30 +250,286 @@ class _CategoryPicker extends ConsumerWidget { final bool enabled; final ValueChanged onChanged; + String _categoryLabel(Category cat, List all) { + if (cat.parentId == null) return cat.name; + final parent = all.where((c) => c.id == cat.parentId).firstOrNull; + if (parent == null) return cat.name; + return '${_categoryLabel(parent, all)} > ${cat.name}'; + } + @override Widget build(BuildContext context, WidgetRef ref) { final categories = ref.watch(editableCategoriesProvider); + final theme = Theme.of(context); return switch (categories) { - AsyncData(value: final tree) => DropdownButtonFormField( - initialValue: tree.any((c) => c.id == value) ? value : null, - decoration: const InputDecoration(border: OutlineInputBorder()), - items: [ - for (final category in tree) - DropdownMenuItem(value: category.id, child: Text(category.name)), - ], - onChanged: enabled - ? (next) { - if (next != null) onChanged(next); - } - : null, - ), + AsyncData(value: final tree) => InkWell( + onTap: !enabled || tree.isEmpty + ? null + : () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: + BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) => _EditCategorySearchBottomSheet( + categories: tree, + selectedCategoryId: value, + categoryLabel: (cat) => _categoryLabel(cat, tree), + onSelect: (selectedId) { + onChanged(selectedId); + Navigator.pop(sheetContext); + }, + ), + ); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.outline.withAlpha(120), + ), + ), + child: Row( + children: [ + Icon( + Icons.category_outlined, + size: 20, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + tree.where((c) => c.id == value).firstOrNull?.let( + (c) => _categoryLabel(c, tree), + ) ?? + 'Chọn danh mục...', + style: TextStyle( + fontSize: 14, + color: theme.colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Icon( + Icons.search, + size: 20, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), AsyncError() => const Text('Không tải được danh mục.'), _ => const LinearProgressIndicator(), }; } } +extension _CategoryLet on T { + R let(R Function(T) op) => op(this); +} + +class _EditCategorySearchBottomSheet extends StatefulWidget { + final List categories; + final String? selectedCategoryId; + final String Function(Category) categoryLabel; + final ValueChanged onSelect; + + const _EditCategorySearchBottomSheet({ + required this.categories, + required this.selectedCategoryId, + required this.categoryLabel, + required this.onSelect, + }); + + @override + State<_EditCategorySearchBottomSheet> createState() => + _EditCategorySearchBottomSheetState(); +} + +class _EditCategorySearchBottomSheetState + extends State<_EditCategorySearchBottomSheet> { + final _searchController = TextEditingController(); + String _query = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final query = _query.trim().toLowerCase(); + + final filtered = widget.categories.where((cat) { + if (query.isEmpty) return true; + final nameMatch = cat.name.toLowerCase().contains(query); + final labelMatch = + widget.categoryLabel(cat).toLowerCase().contains(query); + return nameMatch || labelMatch; + }).toList(); + + return DraggableScrollableSheet( + initialChildSize: 0.75, + minChildSize: 0.4, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + const SizedBox(height: 12), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: theme.colorScheme.onSurfaceVariant.withAlpha(80), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Expanded( + child: Text( + 'Chọn danh mục', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextField( + controller: _searchController, + autofocus: false, + style: TextStyle(color: theme.colorScheme.onSurface), + decoration: InputDecoration( + hintText: 'Tìm kiếm danh mục...', + hintStyle: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurfaceVariant, + ), + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: _query.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () { + _searchController.clear(); + setState(() => _query = ''); + }, + ) + : null, + filled: true, + fillColor: isDark + ? theme.colorScheme.surfaceContainerHighest + : const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + onChanged: (val) => setState(() => _query = val), + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + '${filtered.length} danh mục', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ), + const Divider(height: 1), + Expanded( + child: filtered.isEmpty + ? Center( + child: Text( + 'Không tìm thấy danh mục "$_query"', + style: TextStyle( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 14, + ), + ), + ) + : ListView.separated( + controller: scrollController, + itemCount: filtered.length, + separatorBuilder: (_, __) => + const Divider(height: 1, indent: 16, endIndent: 16), + itemBuilder: (context, index) { + final cat = filtered[index]; + final isSelected = + cat.id == widget.selectedCategoryId; + final label = widget.categoryLabel(cat); + + return ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 4, + ), + title: Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + color: isSelected + ? theme.colorScheme.primary + : theme.colorScheme.onSurface, + ), + ), + trailing: isSelected + ? Icon( + Icons.check_circle_rounded, + color: theme.colorScheme.primary, + size: 20, + ) + : null, + onTap: () => widget.onSelect(cat.id), + ); + }, + ), + ), + ], + ); + }, + ); + } +} + /// Nhập thẻ, hiện thẻ, bỏ thẻ. Slug hoá ở tầng model chứ không ở đây, vì route /// mới là thứ đặt luật và cùng luật đó dùng cho cả màn đăng tin. class _TagField extends StatelessWidget { diff --git a/lib/features/seller/presentation/screens/listing_suggestion_screen.dart b/lib/features/seller/presentation/screens/listing_suggestion_screen.dart index 4c1b353..2e8adcb 100644 --- a/lib/features/seller/presentation/screens/listing_suggestion_screen.dart +++ b/lib/features/seller/presentation/screens/listing_suggestion_screen.dart @@ -6,11 +6,13 @@ import 'package:shimmer/shimmer.dart'; import 'package:shopnexus_flutter_app/api/generated/model/category.dart'; import 'package:shopnexus_flutter_app/api/generated/model/contact.dart'; +import 'package:shopnexus_flutter_app/api/generated/model/create_variant_request.dart'; import 'package:shopnexus_flutter_app/api/generated/model/listing_condition.dart'; import 'package:shopnexus_flutter_app/api/generated/model/listing_suggestion.dart'; import 'package:shopnexus_flutter_app/api/generated/model/price_mode.dart'; import 'package:shopnexus_flutter_app/core/theme/app_colors.dart'; import 'package:shopnexus_flutter_app/core/upload/upload_media.dart'; +import 'package:shopnexus_flutter_app/core/utils/money_utils.dart'; import 'package:shopnexus_flutter_app/features/seller/data/models/listing_draft.dart'; import 'package:shopnexus_flutter_app/features/seller/presentation/providers/listing_suggestion_provider.dart'; import 'package:shopnexus_flutter_app/features/seller/presentation/providers/seller_products_provider.dart'; @@ -39,9 +41,8 @@ class _ListingSuggestionScreenState final _noteController = TextEditingController(); final _nameController = TextEditingController(); final _descriptionController = TextEditingController(); - final _priceController = TextEditingController(); - final _weightController = TextEditingController(); - final _quantityController = TextEditingController(text: '1'); + + List<_VariantFormItem> _variants = [_VariantFormItem()]; String? _categoryId; ListingCondition? _condition; @@ -54,9 +55,9 @@ class _ListingSuggestionScreenState _noteController.dispose(); _nameController.dispose(); _descriptionController.dispose(); - _priceController.dispose(); - _weightController.dispose(); - _quantityController.dispose(); + for (final v in _variants) { + v.dispose(); + } super.dispose(); } @@ -492,9 +493,6 @@ class _ListingSuggestionScreenState Widget _listingForm(ListingSuggestionState state) { final theme = Theme.of(context); - final categoryValue = state.categories.any((c) => c.id == _categoryId) - ? _categoryId - : null; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -543,30 +541,7 @@ class _ListingSuggestionScreenState ), ], ), - DropdownButtonFormField( - initialValue: categoryValue, - isExpanded: true, - decoration: _inputDecoration(), - hint: Text( - state.categories.isEmpty - ? 'Chưa tải được danh mục' - : 'Chọn danh mục', - style: TextStyle(color: theme.colorScheme.onSurfaceVariant), - ), - items: state.categories - .map( - (category) => DropdownMenuItem( - value: category.id, - child: Text( - _categoryLabel(category, state.categories), - overflow: TextOverflow.ellipsis, - style: TextStyle(color: theme.colorScheme.onSurface), - ), - ), - ) - .toList(), - onChanged: (value) => setState(() => _categoryId = value), - ), + _buildCategorySelector(context, state), const SizedBox(height: 16), _fieldLabel('Tình trạng *'), @@ -595,61 +570,6 @@ class _ListingSuggestionScreenState ), const SizedBox(height: 16), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _fieldLabel('Giá bán (đ) *'), - TextField( - controller: _priceController, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - style: TextStyle(color: theme.colorScheme.onSurface), - decoration: _inputDecoration(hint: '5000000'), - ), - ], - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _fieldLabel('Khối lượng (g) *'), - TextField( - controller: _weightController, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - style: TextStyle(color: theme.colorScheme.onSurface), - decoration: _inputDecoration(hint: '350'), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - 'Khối lượng là cơ sở để hãng vận chuyển báo giá cho người mua.', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 16), - - _fieldLabel('Số lượng'), - TextField( - controller: _quantityController, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - style: TextStyle(color: theme.colorScheme.onSurface), - decoration: _inputDecoration(hint: '1'), - ), - const SizedBox(height: 16), - _fieldLabel('Cách bán'), Wrap( spacing: 8, @@ -686,10 +606,357 @@ class _ListingSuggestionScreenState _fieldLabel('Thông số'), _specEditor(), + const SizedBox(height: 24), + + _variantsSection(), + ], + ); + } + + Widget _variantsSection() { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'BƯỚC 3 · PHIÊN BẢN', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.bold, + letterSpacing: 0.8, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: 4), + Text( + 'Giá, tồn kho và kiện hàng', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, + fontSize: 18, + ), + ), + const SizedBox(height: 4), + Text( + 'Mỗi phiên bản là một lựa chọn người mua có thể đặt. Phiên bản đầu tiên sẽ làm giá đại diện.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.4, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () { + setState(() { + _variants.add( + _VariantFormItem( + weight: _variants.isNotEmpty + ? _variants.first.weightController.text + : '', + attributes: [_AttributePair()], + ), + ); + }); + }, + icon: const Icon(Icons.add, size: 16), + label: const Text('Thêm phiên bản'), + style: OutlinedButton.styleFrom( + foregroundColor: theme.colorScheme.onSurface, + side: BorderSide(color: _borderColor), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + ..._variants.asMap().entries.map((entry) { + final index = entry.key; + final variant = entry.value; + return _buildVariantCard(variant, index); + }), ], ); } + Widget _buildVariantCard(_VariantFormItem variant, int index) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final priceVal = int.tryParse(variant.priceController.text.trim()) ?? 0; + final priceSubtitle = priceVal > 0 + ? '${MoneyUtils.format(priceVal, currency: _currency)}${index == 0 ? ' · Giá đại diện' : ''}' + : (index == 0 ? 'Chưa nhập giá · Giá đại diện' : 'Chưa nhập giá'); + + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: _cardColor, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _borderColor), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: isDark + ? theme.colorScheme.surfaceContainerHighest.withAlpha(50) + : const Color(0xFFF8FAFC), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(16), + ), + border: Border(bottom: BorderSide(color: _borderColor)), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'PHIÊN BẢN ${index + 1}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: 2), + Text( + priceSubtitle, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + ], + ), + ), + if (_variants.length > 1) + IconButton( + icon: Icon( + Icons.delete_outline, + size: 20, + color: isDark ? const Color(0xFFEF4444) : Colors.red[700], + ), + tooltip: 'Xóa phiên bản', + onPressed: () { + setState(() { + variant.dispose(); + _variants.removeAt(index); + }); + }, + ), + ], + ), + ), + + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Thuộc tính phân biệt + Text( + 'Thuộc tính phân biệt', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, + fontSize: 14, + ), + ), + const SizedBox(height: 4), + Text( + 'Ví dụ Màu sắc · Đen, Dung lượng · 256 GB. Server dùng bộ này để ngăn phiên bản trùng nhau.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 12, + height: 1.4, + ), + ), + const SizedBox(height: 10), + + // Attribute Rows + ...variant.attributes.asMap().entries.map((attrEntry) { + final attrIndex = attrEntry.key; + final attr = attrEntry.value; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: TextField( + controller: attr.keyController, + style: TextStyle( + color: theme.colorScheme.onSurface, + fontSize: 14, + ), + decoration: _inputDecoration( + hint: 'Thuộc tính (ví dụ: Màu sắc)', + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: attr.valueController, + style: TextStyle( + color: theme.colorScheme.onSurface, + fontSize: 14, + ), + decoration: _inputDecoration( + hint: 'Giá trị (ví dụ: Đen)', + ), + ), + ), + if (variant.attributes.length > 1) ...[ + const SizedBox(width: 4), + IconButton( + icon: Icon( + Icons.delete_outline, + size: 20, + color: theme.colorScheme.onSurfaceVariant, + ), + tooltip: 'Xóa thuộc tính', + onPressed: () { + setState(() { + attr.dispose(); + variant.attributes.removeAt(attrIndex); + }); + }, + ), + ], + ], + ), + ); + }), + + const SizedBox(height: 4), + OutlinedButton.icon( + onPressed: () { + setState(() { + variant.attributes.add(_AttributePair()); + }); + }, + icon: const Icon(Icons.add, size: 16), + label: const Text('Thêm thuộc tính'), + style: OutlinedButton.styleFrom( + foregroundColor: theme.colorScheme.onSurface, + side: BorderSide(color: _borderColor), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + ), + const SizedBox(height: 16), + + // Fields: Giá bán, Tồn kho, Khối lượng + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 4, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _fieldLabel('Giá bán (đ) *'), + TextField( + controller: variant.priceController, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + style: TextStyle( + color: theme.colorScheme.onSurface, + fontSize: 14, + ), + decoration: _inputDecoration(hint: '2.990.000'), + onChanged: (_) => setState(() {}), + ), + ], + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _fieldLabel('Tồn kho *'), + TextField( + controller: variant.quantityController, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + style: TextStyle( + color: theme.colorScheme.onSurface, + fontSize: 14, + ), + decoration: _inputDecoration(hint: '1'), + ), + ], + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _fieldLabel('Khối lượng (g)'), + TextField( + controller: variant.weightController, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + style: TextStyle( + color: theme.colorScheme.onSurface, + fontSize: 14, + ), + decoration: _inputDecoration(hint: '500'), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + Widget _tagEditor() { final theme = Theme.of(context); @@ -819,27 +1086,93 @@ class _ListingSuggestionScreenState final notifier = ref.read(listingSuggestionProvider.notifier); final name = _nameController.text.trim(); - final price = int.tryParse(_priceController.text.trim()) ?? 0; - final weight = int.tryParse(_weightController.text.trim()) ?? 0; - final quantity = int.tryParse(_quantityController.text.trim()) ?? 0; - - final complaint = name.isEmpty - ? 'Nhập tên sản phẩm.' - : _categoryId == null - ? 'Chọn danh mục cho sản phẩm.' - : _condition == null - ? 'Chọn tình trạng sản phẩm.' - : price <= 0 - ? 'Nhập giá bán.' - : weight <= 0 - ? 'Nhập khối lượng (gam) để hãng vận chuyển báo giá được.' - : quantity <= 0 - ? 'Nhập số lượng, ít nhất 1.' - : null; - if (complaint != null) { - _snack(complaint, error: true); + + if (name.isEmpty) { + _snack('Nhập tên sản phẩm.', error: true); return; } + if (_categoryId == null) { + _snack('Chọn danh mục cho sản phẩm.', error: true); + return; + } + if (_condition == null) { + _snack('Chọn tình trạng sản phẩm.', error: true); + return; + } + if (_variants.isEmpty) { + _snack('Cần có ít nhất 1 phiên bản sản phẩm.', error: true); + return; + } + + final variantRequests = []; + final seenAttributeSets = {}; + + for (int i = 0; i < _variants.length; i++) { + final v = _variants[i]; + final rawPrice = v.priceController.text.trim(); + final rawQuantity = v.quantityController.text.trim(); + final rawWeight = v.weightController.text.trim(); + + final price = int.tryParse(rawPrice) ?? 0; + final quantity = rawQuantity.isEmpty + ? 1 + : (int.tryParse(rawQuantity) ?? 0); + final weight = rawWeight.isEmpty ? 500 : (int.tryParse(rawWeight) ?? 0); + + if (rawPrice.isEmpty || price <= 0) { + _snack('Nhập giá bán hợp lệ cho Phiên bản ${i + 1}.', error: true); + return; + } + if (quantity <= 0) { + _snack('Tồn kho phải ít nhất là 1 cho Phiên bản ${i + 1}.', error: true); + return; + } + if (weight <= 0) { + _snack('Khối lượng phải lớn hơn 0g cho Phiên bản ${i + 1}.', error: true); + return; + } + + final attrs = {}; + for (final a in v.attributes) { + final k = a.keyController.text.trim(); + final val = a.valueController.text.trim(); + if (k.isNotEmpty && val.isNotEmpty) { + attrs[k] = val; + } else if (k.isNotEmpty || val.isNotEmpty) { + _snack( + 'Vui lòng điền đủ Tên và Giá trị thuộc tính cho Phiên bản ${i + 1}.', + error: true, + ); + return; + } + } + + if (attrs.isEmpty) { + attrs['Phiên bản'] = + _variants.length == 1 ? 'Mặc định' : 'Phiên bản ${i + 1}'; + } + + final attrKey = attrs.entries.map((e) => '${e.key}:${e.value}').toList() + ..sort(); + final attrSig = attrKey.join('|'); + if (seenAttributeSets.contains(attrSig)) { + _snack( + 'Phiên bản ${i + 1} có bộ thuộc tính phân biệt trùng với phiên bản khác. Vui lòng thay đổi giá trị thuộc tính.', + error: true, + ); + return; + } + seenAttributeSets.add(attrSig); + + variantRequests.add( + CreateVariantRequest( + price: price, + quantity: quantity, + attributes: attrs, + packageDetails: {'weight_g': weight}, + ), + ); + } // Publication is what takes the pickup address, so it is asked for here and // only here — a draft has no location to freeze yet. @@ -862,9 +1195,7 @@ class _ListingSuggestionScreenState condition: _condition!, currency: _currency, priceMode: _priceMode, - price: price, - weightG: weight, - quantity: quantity, + variants: variantRequests, attachments: attachments, tags: _tags, specifications: { @@ -924,7 +1255,7 @@ class _ListingSuggestionScreenState ), const SizedBox(height: 4), Text( - 'Đây cũng là vị trí người mua thấy và dùng để lọc tin.', + 'Hãng vận chuyển sẽ tới địa chỉ này để nhận kiện hàng khi có đơn mới.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -1019,10 +1350,14 @@ class _ListingSuggestionScreenState void _applySuggestion(ListingSuggestion suggestion) { _nameController.text = suggestion.name; _descriptionController.text = suggestion.description; - // A value the model could not stand behind comes back null or empty and - // stays a blank box — a wrong number the seller has to notice is worse. - _priceController.text = suggestion.price?.toString() ?? ''; - _weightController.text = suggestion.weightG?.toString() ?? ''; + if (_variants.isNotEmpty) { + if (suggestion.price != null && suggestion.price! > 0) { + _variants.first.priceController.text = suggestion.price.toString(); + } + if (suggestion.weightG != null && suggestion.weightG! > 0) { + _variants.first.weightController.text = suggestion.weightG.toString(); + } + } setState(() { _categoryId = suggestion.categoryId; _condition = _conditionOf(suggestion.condition); @@ -1039,9 +1374,10 @@ class _ListingSuggestionScreenState _noteController.clear(); _nameController.clear(); _descriptionController.clear(); - _priceController.clear(); - _weightController.clear(); - _quantityController.text = '1'; + for (final v in _variants) { + v.dispose(); + } + _variants = [_VariantFormItem()]; setState(() { _categoryId = null; _condition = null; @@ -1051,6 +1387,93 @@ class _ListingSuggestionScreenState }); } + Widget _buildCategorySelector( + BuildContext context, + ListingSuggestionState state, + ) { + final theme = Theme.of(context); + final selectedCategory = state.categories + .where((c) => c.id == _categoryId) + .firstOrNull; + final label = selectedCategory != null + ? _categoryLabel(selectedCategory, state.categories) + : null; + + return InkWell( + onTap: state.categories.isEmpty + ? null + : () => _openCategorySearchSheet(context, state.categories), + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + decoration: BoxDecoration( + color: _cardColor, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: _borderColor), + ), + child: Row( + children: [ + Icon( + Icons.category_outlined, + size: 20, + color: label != null + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + label ?? + (state.categories.isEmpty + ? 'Chưa tải được danh mục' + : 'Chọn danh mục sản phẩm...'), + style: TextStyle( + fontSize: 14, + color: label != null + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurfaceVariant, + fontWeight: label != null ? FontWeight.w500 : FontWeight.normal, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Icon( + Icons.search, + size: 20, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ); + } + + void _openCategorySearchSheet( + BuildContext context, + List categories, + ) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _cardColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) { + return _CategorySearchBottomSheet( + categories: categories, + selectedCategoryId: _categoryId, + categoryLabel: (cat) => _categoryLabel(cat, categories), + onSelect: (selectedId) { + setState(() => _categoryId = selectedId); + Navigator.pop(sheetContext); + }, + ); + }, + ); + } + // --- SMALL HELPERS --- Color get _cardColor => Theme.of(context).brightness == Brightness.dark @@ -1483,3 +1906,251 @@ class _AddSpecDialogState extends State<_AddSpecDialog> { ); } } + +class _VariantFormItem { + final TextEditingController priceController; + final TextEditingController quantityController; + final TextEditingController weightController; + final List<_AttributePair> attributes; + + _VariantFormItem({ + String price = '', + String quantity = '', + String weight = '', + List<_AttributePair>? attributes, + }) : priceController = TextEditingController(text: price), + quantityController = TextEditingController(text: quantity), + weightController = TextEditingController(text: weight), + attributes = attributes ?? [_AttributePair()]; + + void dispose() { + priceController.dispose(); + quantityController.dispose(); + weightController.dispose(); + for (final attr in attributes) { + attr.dispose(); + } + } +} + +class _AttributePair { + final TextEditingController keyController; + final TextEditingController valueController; + + _AttributePair({String key = '', String value = ''}) + : keyController = TextEditingController(text: key), + valueController = TextEditingController(text: value); + + void dispose() { + keyController.dispose(); + valueController.dispose(); + } +} + +class _CategorySearchBottomSheet extends StatefulWidget { + final List categories; + final String? selectedCategoryId; + final String Function(Category) categoryLabel; + final ValueChanged onSelect; + + const _CategorySearchBottomSheet({ + required this.categories, + required this.selectedCategoryId, + required this.categoryLabel, + required this.onSelect, + }); + + @override + State<_CategorySearchBottomSheet> createState() => + _CategorySearchBottomSheetState(); +} + +class _CategorySearchBottomSheetState + extends State<_CategorySearchBottomSheet> { + final _searchController = TextEditingController(); + String _query = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final query = _query.trim().toLowerCase(); + + final filtered = widget.categories.where((cat) { + if (query.isEmpty) return true; + final nameMatch = cat.name.toLowerCase().contains(query); + final labelMatch = + widget.categoryLabel(cat).toLowerCase().contains(query); + return nameMatch || labelMatch; + }).toList(); + + return DraggableScrollableSheet( + initialChildSize: 0.75, + minChildSize: 0.4, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + const SizedBox(height: 12), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: theme.colorScheme.onSurfaceVariant.withAlpha(80), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Expanded( + child: Text( + 'Chọn danh mục', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextField( + controller: _searchController, + autofocus: false, + style: TextStyle(color: theme.colorScheme.onSurface), + decoration: InputDecoration( + hintText: 'Tìm kiếm danh mục (ví dụ: Điện thoại, Áo...)', + hintStyle: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurfaceVariant, + ), + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: _query.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () { + _searchController.clear(); + setState(() => _query = ''); + }, + ) + : null, + filled: true, + fillColor: isDark + ? theme.colorScheme.surfaceContainerHighest + : const Color(0xFFF1F5F9), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + onChanged: (val) => setState(() => _query = val), + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + '${filtered.length} danh mục', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ), + const Divider(height: 1), + Expanded( + child: filtered.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off_outlined, + size: 48, + color: theme.colorScheme.onSurfaceVariant.withAlpha( + 100, + ), + ), + const SizedBox(height: 8), + Text( + 'Không tìm thấy danh mục "$_query"', + style: TextStyle( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 14, + ), + ), + ], + ), + ) + : ListView.separated( + controller: scrollController, + itemCount: filtered.length, + separatorBuilder: (_, __) => + const Divider(height: 1, indent: 16, endIndent: 16), + itemBuilder: (context, index) { + final cat = filtered[index]; + final isSelected = + cat.id == widget.selectedCategoryId; + final label = widget.categoryLabel(cat); + + return ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 4, + ), + title: Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + color: isSelected + ? theme.colorScheme.primary + : theme.colorScheme.onSurface, + ), + ), + trailing: isSelected + ? Icon( + Icons.check_circle_rounded, + color: theme.colorScheme.primary, + size: 20, + ) + : null, + onTap: () => widget.onSelect(cat.id), + ); + }, + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/features/seller/presentation/screens/seller_products_screen.dart b/lib/features/seller/presentation/screens/seller_products_screen.dart index b2a0b93..a93422c 100644 --- a/lib/features/seller/presentation/screens/seller_products_screen.dart +++ b/lib/features/seller/presentation/screens/seller_products_screen.dart @@ -245,7 +245,7 @@ class _SellerProductsScreenState extends ConsumerState { // `taken_down_at` is the only thing that tells the two apart. final isTakenDown = listing.takenDownAt != null; final statusText = isTakenDown - ? 'Bị hạ' + ? 'Đã ẩn' : _statusLabels[listing.status] ?? listing.status.value; final statusBgColor = isTakenDown ? (isDark @@ -518,6 +518,37 @@ class _SellerProductsScreenState extends ConsumerState { ), const SizedBox(width: 4), ], + if (listing.status == ListingStatus.draft) ...[ + FilledButton.icon( + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + icon: const Icon(Icons.send_rounded, size: 13), + label: const Text( + 'Gửi duyệt', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + onPressed: () => _confirmPublishDraft( + context, + listing, + notifier, + ), + ), + const SizedBox(width: 8), + ], IconButton( icon: Icon( Icons.more_vert, @@ -618,6 +649,24 @@ class _SellerProductsScreenState extends ConsumerState { child: Column( mainAxisSize: MainAxisSize.min, children: [ + if (listing.status == ListingStatus.draft) + ListTile( + leading: Icon( + Icons.send_rounded, + color: theme.colorScheme.primary, + ), + title: Text( + 'Gửi duyệt sản phẩm', + style: TextStyle( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + onTap: () { + Navigator.pop(sheetContext); + _confirmPublishDraft(context, listing, notifier); + }, + ), ListTile( leading: Icon( Icons.visibility_outlined, @@ -658,11 +707,11 @@ class _SellerProductsScreenState extends ConsumerState { // chứ không nằm trong "Sửa tin". Người bán đổi hai thứ này hằng tuần. ListTile( leading: Icon( - Icons.sell_outlined, + Icons.layers_outlined, color: theme.colorScheme.onSurface, ), title: Text( - 'Sửa giá & tồn kho', + 'Quản lý phiên bản (giá & tồn kho)', style: TextStyle(color: theme.colorScheme.onSurface), ), onTap: () async { @@ -783,6 +832,88 @@ class _SellerProductsScreenState extends ConsumerState { ); } + Future _confirmPublishDraft( + BuildContext context, + Listing listing, + SellerProductsNotifier notifier, + ) async { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + final confirm = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + backgroundColor: isDark ? AppColors.darkSurface : Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Row( + children: [ + Icon(Icons.send_rounded, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Gửi duyệt sản phẩm', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, + ), + ), + ), + ], + ), + content: Text( + 'Gửi sản phẩm "${listing.name}" đi duyệt? Sau khi gửi, sản phẩm sẽ được chuyển sang tab "Chờ duyệt" để quản trị viên kiểm tra.', + style: TextStyle( + fontSize: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text( + 'Hủy', + style: TextStyle(color: theme.colorScheme.onSurfaceVariant), + ), + ), + ElevatedButton( + onPressed: () => Navigator.pop(dialogContext, true), + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const Text('Gửi duyệt'), + ), + ], + ), + ); + + if (confirm != true || !context.mounted) return; + + try { + await notifier.publishListing(listing.id); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Đã gửi sản phẩm "${listing.name}" đi duyệt. Sản phẩm đã chuyển sang tab Chờ duyệt.', + ), + ), + ); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Không thể gửi duyệt: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + Widget _buildShimmerList(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; final baseColor = isDark ? Colors.grey[800]! : const Color(0xFFE2E8F0); diff --git a/lib/features/seller/presentation/widgets/edit_variant_sheet.dart b/lib/features/seller/presentation/widgets/edit_variant_sheet.dart index b838dea..9ad4424 100644 --- a/lib/features/seller/presentation/widgets/edit_variant_sheet.dart +++ b/lib/features/seller/presentation/widgets/edit_variant_sheet.dart @@ -2,23 +2,16 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shopnexus_flutter_app/api/generated/model/create_variant_request.dart'; import 'package:shopnexus_flutter_app/api/generated/model/listing_detail.dart'; import 'package:shopnexus_flutter_app/api/generated/model/update_variant_request.dart'; import 'package:shopnexus_flutter_app/api/generated/model/variant.dart'; +import 'package:shopnexus_flutter_app/core/theme/app_colors.dart'; import 'package:shopnexus_flutter_app/core/utils/money_utils.dart'; import 'package:shopnexus_flutter_app/features/catalog/data/repositories/catalog_repository.dart'; import 'package:shopnexus_flutter_app/features/seller/data/repositories/seller_repository.dart'; -/// Sửa giá và số lượng còn của một phiên bản. -/// -/// Chỉ hai trường này, không phải "sửa tin": tên, ảnh, mô tả, danh mục thuộc -/// `PATCH /listings/{id}` — một màn khác chưa có. Còn giá và tồn là hai thứ người -/// bán đổi hằng tuần ("hạ giá", "hết size M"), và cho tới giờ họ phải xoá tin đăng -/// lại. -/// -/// Số lượng là **tổng còn trên tay**, không phải cộng thêm: server từ chối nếu đặt -/// thấp hơn `reserved + sold`, nên một món đã có người giữ chỗ không thể bị hạ -/// xuống dưới cái đã hứa. +/// Sheet quản lý & sửa phiên bản (giá, tồn kho, thêm/xóa phiên bản). class EditVariantSheet extends ConsumerStatefulWidget { const EditVariantSheet({ super.key, @@ -29,7 +22,7 @@ class EditVariantSheet extends ConsumerStatefulWidget { final String listingId; final String currency; - /// Trả về true nếu có gì được lưu, để bên gọi nạp lại danh sách. + /// Trả về true nếu có thay đổi để màn danh sách nạp lại dữ liệu. static Future show( BuildContext context, { required String listingId, @@ -39,6 +32,7 @@ class EditVariantSheet extends ConsumerStatefulWidget { context: context, isScrollControlled: true, useSafeArea: true, + backgroundColor: Colors.transparent, builder: (_) => EditVariantSheet(listingId: listingId, currency: currency), ); @@ -49,40 +43,49 @@ class EditVariantSheet extends ConsumerStatefulWidget { } class _EditVariantSheetState extends ConsumerState { - late final Future _future = ref - .read(catalogRepositoryProvider) - .listingDetail(widget.listingId); - - /// Phiên bản đang sửa. Hầu hết tin ở đây chỉ có một, nên mở sẵn nó thì không ai - /// phải chọn một danh sách một dòng. - Variant? _variant; - final _price = TextEditingController(); - final _quantity = TextEditingController(); + late Future _future; + + Variant? _selectedVariant; + final _priceController = TextEditingController(); + final _quantityController = TextEditingController(); bool _saving = false; String? _error; + bool _hasModified = false; + + @override + void initState() { + super.initState(); + _load(); + } + + void _load() { + _future = ref + .read(catalogRepositoryProvider) + .listingDetail(widget.listingId); + } @override void dispose() { - _price.dispose(); - _quantity.dispose(); + _priceController.dispose(); + _quantityController.dispose(); super.dispose(); } void _select(Variant variant) { setState(() { - _variant = variant; - _price.text = variant.price.toString(); - _quantity.text = variant.stock.quantity.toString(); + _selectedVariant = variant; + _priceController.text = variant.price.toString(); + _quantityController.text = variant.stock.quantity.toString(); _error = null; }); } - Future _save() async { - final variant = _variant; + Future _saveCurrentVariant() async { + final variant = _selectedVariant; if (variant == null) return; - final price = int.tryParse(_price.text.trim()); - final quantity = int.tryParse(_quantity.text.trim()); + final price = int.tryParse(_priceController.text.trim()); + final quantity = int.tryParse(_quantityController.text.trim()); if (price == null || price <= 0) { setState(() => _error = 'Giá phải là một số lớn hơn 0'); return; @@ -91,7 +94,7 @@ class _EditVariantSheetState extends ConsumerState { setState(() => _error = 'Số lượng phải là một số không âm'); return; } - // Chặn trước cái server sẽ chặn, vì câu ở đây nói được con số cụ thể. + final committed = variant.stock.reserved + variant.stock.sold; if (quantity < committed) { setState( @@ -105,177 +108,748 @@ class _EditVariantSheetState extends ConsumerState { _saving = true; _error = null; }); + try { - await ref - .read(sellerRepositoryProvider) - .updateVariant( + await ref.read(sellerRepositoryProvider).updateVariant( variant.id, UpdateVariantRequest(price: price, quantity: quantity), ); + _hasModified = true; if (!mounted) return; - Navigator.pop(context, true); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Đã cập nhật phiên bản thành công.')), + ); + setState(() { + _saving = false; + _load(); + }); } catch (error) { if (!mounted) return; setState(() { _saving = false; - _error = _messageOf(error) ?? 'Không lưu được, thử lại sau'; + _error = _messageOf(error) ?? 'Không lưu được, vui lòng thử lại'; + }); + } + } + + Future _confirmDeleteVariant(Variant variant, int totalCount) async { + if (totalCount <= 1) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Sản phẩm phải có ít nhất một phiên bản.'), + backgroundColor: Colors.red, + ), + ); + return; + } + + final isDark = Theme.of(context).brightness == Brightness.dark; + final confirm = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + backgroundColor: isDark ? AppColors.darkSurface : Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: const Row( + children: [ + Icon(Icons.delete_outline, color: Colors.red), + SizedBox(width: 8), + Text('Xóa phiên bản', style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold)), + ], + ), + content: Text( + 'Bạn có chắc chắn muốn xóa phiên bản "${_label(variant)}"?', + style: const TextStyle(fontSize: 14), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Hủy'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(dialogContext, true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('Xóa'), + ), + ], + ), + ); + + if (confirm != true || !mounted) return; + + try { + await ref.read(sellerRepositoryProvider).deleteVariant(variant.id); + _hasModified = true; + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Đã xóa phiên bản thành công.')), + ); + setState(() { + _selectedVariant = null; + _load(); }); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Không thể xóa phiên bản: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + + Future _showAddVariantModal(ListingDetail detail) async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Colors.transparent, + builder: (sheetCtx) => _AddVariantSheet(currency: widget.currency), + ); + + if (result == null || !mounted) return; + + try { + await ref.read(sellerRepositoryProvider).addVariant(widget.listingId, result); + _hasModified = true; + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Đã thêm phiên bản mới thành công.')), + ); + setState(() { + _load(); + }); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Không thể thêm phiên bản: $e'), + backgroundColor: Colors.red, + ), + ); } } static String? _messageOf(Object error) { - final match = RegExp( - r'"message"\s*:\s*"([^"]+)"', - ).firstMatch(error.toString()); + final match = RegExp(r'"message"\s*:\s*"([^"]+)"').firstMatch(error.toString()); return match?.group(1); } @override Widget build(BuildContext context) { final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; - return Padding( - padding: EdgeInsets.only( - left: 20, - right: 20, - top: 20, - bottom: MediaQuery.viewInsetsOf(context).bottom + 20, - ), + return PopScope( + canPop: true, + onPopInvokedWithResult: (didPop, _) { + // Returned through Navigator.pop with _hasModified + }, + child: Container( + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurface : Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 16, + bottom: MediaQuery.viewInsetsOf(context).bottom + 20, + ), child: FutureBuilder( future: _future, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 40), + return const SizedBox( + height: 250, child: Center(child: CircularProgressIndicator()), ); } if (snapshot.hasError || snapshot.data == null) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 40), - child: Center(child: Text('Không tải được tin đăng')), + return SizedBox( + height: 250, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Không tải được thông tin phiên bản'), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () => setState(() => _load()), + child: const Text('Thử lại'), + ), + ], + ), + ), ); } - final variants = snapshot.data!.variants; + final detail = snapshot.data!; + final variants = detail.variants; + if (variants.isEmpty) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 40), - child: Center(child: Text('Tin này chưa có phiên bản nào')), + return const SizedBox( + height: 200, + child: Center(child: Text('Sản phẩm chưa có phiên bản nào')), ); } - // Một tin một phiên bản là trường hợp thường: mở sẵn nó. - if (_variant == null && variants.length == 1) { - WidgetsBinding.instance.addPostFrameCallback( - (_) => _select(variants.first), - ); + + if (_selectedVariant == null) { + _selectedVariant = variants.first; + _priceController.text = _selectedVariant!.price.toString(); + _quantityController.text = _selectedVariant!.stock.quantity.toString(); + } else { + // Keep selection updated + final found = variants.where((v) => v.id == _selectedVariant!.id).firstOrNull; + if (found != null) { + _selectedVariant = found; + } else { + _selectedVariant = variants.first; + _priceController.text = _selectedVariant!.price.toString(); + _quantityController.text = _selectedVariant!.stock.quantity.toString(); + } } return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ + // Handle bar + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: theme.colorScheme.onSurfaceVariant.withAlpha(80), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 14), + + // Header + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Quản lý phiên bản', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Text( + detail.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + OutlinedButton.icon( + onPressed: () => _showAddVariantModal(detail), + icon: const Icon(Icons.add, size: 16), + label: const Text('Thêm phiên bản', style: TextStyle(fontSize: 12)), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.close, size: 20), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () => Navigator.pop(context, _hasModified), + ), + ], + ), + const SizedBox(height: 16), + + // Variants Selector Chips Text( - 'Giá & tồn kho', - style: theme.textTheme.titleLarge?.copyWith( + 'Danh sách phiên bản (${variants.length})', + style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.bold, ), ), - const SizedBox(height: 4), - Text( - snapshot.data!.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + const SizedBox(height: 8), + + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: variants.asMap().entries.map((entry) { + final index = entry.key; + final v = entry.value; + final isSelected = _selectedVariant?.id == v.id; + final label = _label(v); + + return Container( + margin: const EdgeInsets.only(right: 8), + child: ChoiceChip( + avatar: v.isFeatured + ? Icon(Icons.star_rounded, size: 16, color: isSelected ? theme.colorScheme.onPrimary : theme.colorScheme.primary) + : null, + label: Text( + '${index + 1}. $label', + style: TextStyle( + fontSize: 13, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + selected: isSelected, + selectedColor: theme.colorScheme.primary, + labelStyle: TextStyle( + color: isSelected ? theme.colorScheme.onPrimary : theme.colorScheme.onSurface, + ), + onSelected: _saving ? null : (_) => _select(v), + ), + ); + }).toList(), ), ), const SizedBox(height: 16), - if (variants.length > 1) ...[ - Text( - 'Chọn phiên bản', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, + // Selected Variant Details & Edit Form + if (_selectedVariant != null) ...[ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isDark + ? theme.colorScheme.surfaceContainerHighest.withAlpha(50) + : const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isDark + ? theme.colorScheme.surfaceContainerHighest + : const Color(0xFFE2E8F0), + ), ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final variant in variants) - ChoiceChip( - label: Text(_label(variant)), - selected: _variant?.id == variant.id, - onSelected: _saving ? null : (_) => _select(variant), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _label(_selectedVariant!), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + ), + ), + const SizedBox(height: 4), + Wrap( + spacing: 8, + runSpacing: 4, + children: [ + Text( + 'Khối lượng: ${_weightOf(_selectedVariant!)}g', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + Text('·', style: TextStyle(color: theme.colorScheme.onSurfaceVariant)), + Text( + 'Đã bán: ${_selectedVariant!.stock.sold}', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ), + ), + if (variants.length > 1) + IconButton( + icon: const Icon(Icons.delete_outline, size: 20, color: Colors.red), + tooltip: 'Xóa phiên bản này', + onPressed: () => _confirmDeleteVariant(_selectedVariant!, variants.length), + ), + ], + ), + const Divider(height: 20), + + // Form fields + TextField( + controller: _priceController, + enabled: !_saving, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + labelText: 'Giá bán (${widget.currency}) *', + border: const OutlineInputBorder(), + helperText: MoneyUtils.format( + int.tryParse(_priceController.text.trim()) ?? 0, + currency: widget.currency, + ), + ), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 14), + + TextField( + controller: _quantityController, + enabled: !_saving, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + labelText: 'Số lượng tồn kho *', + border: const OutlineInputBorder(), + helperText: + 'Có sẵn ${_selectedVariant!.stock.quantity} · Giữ chỗ ${_selectedVariant!.stock.reserved} · Đã bán ${_selectedVariant!.stock.sold}', + ), + ), + + if (_error != null) ...[ + const SizedBox(height: 10), + Text( + _error!, + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.error, + ), + ), + ], + const SizedBox(height: 16), + + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _saving ? null : _saveCurrentVariant, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + _saving ? 'Đang lưu…' : 'Lưu thay đổi', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), ), - ], + ], + ), ), - const SizedBox(height: 16), ], + ], + ), + ); + }, + ), + ), + ); + } - if (_variant != null) ...[ - TextField( - controller: _price, - enabled: !_saving, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - decoration: InputDecoration( - labelText: 'Giá (${widget.currency})', - border: const OutlineInputBorder(), - helperText: MoneyUtils.format( - int.tryParse(_price.text.trim()) ?? 0, - currency: widget.currency, - ), + static String _label(Variant variant) { + if (variant.attributes.isEmpty) return 'Mặc định'; + return variant.attributes.entries + .map((e) => '${e.key}: ${e.value}') + .join(' · '); + } + + static int _weightOf(Variant variant) { + final raw = variant.packageDetails['weight_g']; + if (raw is num) return raw.toInt(); + return 500; + } +} + +/// Sheet thêm phiên bản mới +class _AddVariantSheet extends StatefulWidget { + final String currency; + + const _AddVariantSheet({required this.currency}); + + @override + State<_AddVariantSheet> createState() => _AddVariantSheetState(); +} + +class _AddVariantSheetState extends State<_AddVariantSheet> { + final _priceController = TextEditingController(); + final _quantityController = TextEditingController(text: '1'); + final _weightController = TextEditingController(text: '500'); + + final List> _attrs = [ + MapEntry( + TextEditingController(text: 'Phiên bản'), + TextEditingController(), + ), + ]; + + @override + void dispose() { + _priceController.dispose(); + _quantityController.dispose(); + _weightController.dispose(); + for (final pair in _attrs) { + pair.key.dispose(); + pair.value.dispose(); + } + super.dispose(); + } + + void _submit() { + final price = int.tryParse(_priceController.text.trim()) ?? 0; + final quantity = int.tryParse(_quantityController.text.trim()) ?? 0; + final weight = int.tryParse(_weightController.text.trim()) ?? 500; + + if (price <= 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Vui lòng nhập giá bán hợp lệ.')), + ); + return; + } + if (quantity <= 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Vui lòng nhập tồn kho (ít nhất 1).')), + ); + return; + } + if (weight <= 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Vui lòng nhập khối lượng (g).')), + ); + return; + } + + final attributes = {}; + for (final pair in _attrs) { + final k = pair.key.text.trim(); + final v = pair.value.text.trim(); + if (k.isNotEmpty && v.isNotEmpty) { + attributes[k] = v; + } + } + + if (attributes.isEmpty) { + attributes['Phiên bản'] = 'Phiên bản mới'; + } + + Navigator.pop( + context, + CreateVariantRequest( + price: price, + quantity: quantity, + attributes: attributes, + packageDetails: {'weight_g': weight}, + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + return Container( + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurface : Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 16, + bottom: MediaQuery.viewInsetsOf(context).bottom + 20, + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: theme.colorScheme.onSurfaceVariant.withAlpha(80), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 14), + + Row( + children: [ + Expanded( + child: Text( + 'Thêm phiên bản mới', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + fontSize: 18, ), - onChanged: (_) => setState(() {}), ), - const SizedBox(height: 12), - TextField( - controller: _quantity, - enabled: !_saving, + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 16), + + Text( + 'Thuộc tính phân biệt', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'Ví dụ: Màu sắc · Trắng, Kích cỡ · XL', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + + ..._attrs.asMap().entries.map((entry) { + final idx = entry.key; + final pair = entry.value; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: pair.key, + decoration: const InputDecoration( + hintText: 'Thuộc tính (vd: Màu)', + border: OutlineInputBorder(), + isDense: true, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: pair.value, + decoration: const InputDecoration( + hintText: 'Giá trị (vd: Đỏ)', + border: OutlineInputBorder(), + isDense: true, + ), + ), + ), + if (_attrs.length > 1) + IconButton( + icon: const Icon(Icons.delete_outline, size: 20), + onPressed: () { + setState(() { + pair.key.dispose(); + pair.value.dispose(); + _attrs.removeAt(idx); + }); + }, + ), + ], + ), + ); + }), + + OutlinedButton.icon( + onPressed: () { + setState(() { + _attrs.add( + MapEntry( + TextEditingController(), + TextEditingController(), + ), + ); + }); + }, + icon: const Icon(Icons.add, size: 16), + label: const Text('Thêm thuộc tính'), + ), + const SizedBox(height: 16), + + Row( + children: [ + Expanded( + child: TextField( + controller: _priceController, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], decoration: InputDecoration( - labelText: 'Tổng số còn trên tay', + labelText: 'Giá bán (${widget.currency}) *', + hintText: '2.990.000', border: const OutlineInputBorder(), - helperText: - 'Đang giữ chỗ ${_variant!.stock.reserved} · ' - 'đã bán ${_variant!.stock.sold}', ), ), - if (_error != null) ...[ - const SizedBox(height: 10), - Text( - _error!, - style: TextStyle( - fontSize: 13, - color: theme.colorScheme.error, - ), - ), - ], - const SizedBox(height: 20), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _saving ? null : _save, - child: Text(_saving ? 'Đang lưu…' : 'Lưu'), + ), + const SizedBox(width: 10), + Expanded( + child: TextField( + controller: _quantityController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + labelText: 'Tồn kho *', + hintText: '1', + border: OutlineInputBorder(), ), ), - ], + ), ], ), - ); - }, + const SizedBox(height: 12), + + TextField( + controller: _weightController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + labelText: 'Khối lượng (g) *', + hintText: '500', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 20), + + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _submit, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text('Thêm phiên bản', style: TextStyle(fontWeight: FontWeight.bold)), + ), + ), + ], + ), ), ); } - - /// Tên phiên bản là các thuộc tính của nó ghép lại — không có trường `name`, vì - /// "Đỏ / L" là thứ được tạo ra từ `attributes` chứ không phải người bán đặt. - static String _label(Variant variant) { - if (variant.attributes.isEmpty) return 'Mặc định'; - return variant.attributes.values.join(' / '); - } }