From a978699dfc81e1349c96d4a2a94b8942e081ad58 Mon Sep 17 00:00:00 2001 From: bala Date: Sat, 29 Nov 2025 15:04:40 +0530 Subject: [PATCH] Products page connected to the backend. --- lib/core/constants/api_endpoints.dart | 3 + lib/core/routing/app_router.dart | 4 + lib/core/routing/route_paths.dart | 4 + lib/core/widgets/side_menu.dart | 2 +- lib/data/models/product_model.dart | 21 + .../repositories/brands_repository_impl.dart | 96 +++-- lib/data/repositories/product_repository.dart | 65 +++ lib/data/sources/remote/api_service.dart | 52 ++- lib/domain/entities/product.dart | 13 + .../providers/brand_provider.dart | 28 +- .../providers/products_provider.dart | 55 +++ .../screens/brands/brands_screen.dart | 377 ++++++++++++++---- .../screens/products/products_screen.dart | 286 +++++++++++++ 13 files changed, 878 insertions(+), 128 deletions(-) create mode 100644 lib/data/models/product_model.dart create mode 100644 lib/data/repositories/product_repository.dart create mode 100644 lib/domain/entities/product.dart create mode 100644 lib/presentation/providers/products_provider.dart create mode 100644 lib/presentation/screens/products/products_screen.dart diff --git a/lib/core/constants/api_endpoints.dart b/lib/core/constants/api_endpoints.dart index 84430fb..ed7420e 100644 --- a/lib/core/constants/api_endpoints.dart +++ b/lib/core/constants/api_endpoints.dart @@ -23,4 +23,7 @@ class ApiEndpoints { ///Brands static const String brands = "/v1/brands"; + + ///Products + static const product = '/api/brands/'; } diff --git a/lib/core/routing/app_router.dart b/lib/core/routing/app_router.dart index e769276..b5d8491 100644 --- a/lib/core/routing/app_router.dart +++ b/lib/core/routing/app_router.dart @@ -5,6 +5,7 @@ import 'package:autos/presentation/screens/auth/sign_up_screen.dart'; import 'package:autos/presentation/screens/brands/brands_screen.dart'; import 'package:autos/presentation/screens/dashboard/dashboard_screen.dart'; import 'package:autos/presentation/screens/ebay/ebay_screen.dart'; +import 'package:autos/presentation/screens/products/products_screen.dart'; import 'package:autos/presentation/screens/store/create_location_screen.dart'; import 'package:autos/presentation/screens/store/store.dart'; import 'package:autos/presentation/screens/turn14_screen/turn14_screen.dart'; @@ -61,6 +62,9 @@ class AppRouter { case AppRoutePaths.brands: return slideRoute(BrandsScreen()); + case AppRoutePaths.products: + return slideRoute(ProductsScreen()); + default: return _defaultFallback(settings); } diff --git a/lib/core/routing/route_paths.dart b/lib/core/routing/route_paths.dart index 8e1b32b..883d1eb 100644 --- a/lib/core/routing/route_paths.dart +++ b/lib/core/routing/route_paths.dart @@ -1,11 +1,15 @@ class AppRoutePaths { + ///Auth static const auth = '/auth'; static const signup = '/signup'; static const forgotPassword = '/forgotPassword'; + + ///Screens static const dashboard = '/dashboard'; static const turn14 = '/turn14'; static const ebay = '/ebay'; static const store = '/store'; static const createStoreLocation = '/createStoreLocation'; static const brands = '/brands'; + static const products = '/products'; } diff --git a/lib/core/widgets/side_menu.dart b/lib/core/widgets/side_menu.dart index 6dfdace..b3e176b 100644 --- a/lib/core/widgets/side_menu.dart +++ b/lib/core/widgets/side_menu.dart @@ -65,7 +65,7 @@ class SideMenu extends ConsumerWidget { // --- MANAGE --- _sectionHeader("MANAGE"), _menuItem(context, "đŸˇī¸", "Brands", AppRoutePaths.brands), - _menuItem(context, "đŸ“Ļ", "Products", 'products'), + _menuItem(context, "đŸ“Ļ", "Products", AppRoutePaths.products), _menuItem(context, "âŦ‡ī¸", "Imports", 'imports'), // --- ACCOUNT --- diff --git a/lib/data/models/product_model.dart b/lib/data/models/product_model.dart new file mode 100644 index 0000000..12971a0 --- /dev/null +++ b/lib/data/models/product_model.dart @@ -0,0 +1,21 @@ + + +import 'package:autos/domain/entities/product.dart'; + +class ProductModel extends ProductEntity { + ProductModel({ + required super.id, + required super.name, + required super.image, + required super.price, + }); + + factory ProductModel.fromJson(Map json) { + return ProductModel( + id: json['id'], + name: json['name'], + image: json['image'], + price: (json['price'] as num).toDouble(), + ); + } +} diff --git a/lib/data/repositories/brands_repository_impl.dart b/lib/data/repositories/brands_repository_impl.dart index 5aef60b..6b7de6b 100644 --- a/lib/data/repositories/brands_repository_impl.dart +++ b/lib/data/repositories/brands_repository_impl.dart @@ -20,39 +20,39 @@ class BrandsRepositoryImpl implements BrandsRepository { BrandsRepositoryImpl(this._apiService); /// ✅ ALWAYS CALL TOKEN API (FORCE REFRESH) -Future getTurn14AccessToken() async { - final jsonString = await _storage.read(key: _userKey); - if (jsonString == null) { - throw Exception("User not found. Please login again."); - } - - final user = UserModel.fromJson(jsonDecode(jsonString)); - - final response = await _apiService.post(ApiEndpoints.getToken, { - "userid": user.id, - }); - - final data = response.data; - - String token; - - if (data["code"] == "TOKEN_UPDATED") { - // ✅ New token returned - token = data["access_token"]; - await _storage.write(key: 'turn14_token', value: token); - } else if (data["code"] == "TOKEN_VALID") { - // ✅ Token still valid, read from storage - token = await _storage.read(key: 'turn14_token') ?? ''; - if (token.isEmpty) { - throw Exception("Token missing in storage"); + Future getTurn14AccessToken() async { + final jsonString = await _storage.read(key: _userKey); + if (jsonString == null) { + throw Exception("User not found. Please login again."); } - } else { - throw Exception(data["message"]); - } - debugPrint("Turn14 Token: $token"); - return token; -} + final user = UserModel.fromJson(jsonDecode(jsonString)); + + final response = await _apiService.post(ApiEndpoints.getToken, { + "userid": user.id, + }); + + final data = response.data; + + String token; + + if (data["code"] == "TOKEN_UPDATED") { + // ✅ New token returned + token = data["access_token"]; + await _storage.write(key: 'turn14_token', value: token); + } else if (data["code"] == "TOKEN_VALID") { + // ✅ Token still valid, read from storage + token = await _storage.read(key: 'turn14_token') ?? ''; + if (token.isEmpty) { + throw Exception("Token missing in storage"); + } + } else { + throw Exception(data["message"]); + } + + debugPrint("Turn14 Token: $token"); + return token; + } /// ✅ FETCH BRANDS USING FRESH TOKEN EVERY TIME @override @@ -88,4 +88,38 @@ Future getTurn14AccessToken() async { throw Exception("Unexpected error: $e"); } } + + /// ✅ BULK INSERT SELECTED BRANDS + Future> saveSelectedBrands({ + required String userId, + required List brands, + }) async { + try { + final payload = { + "userid": userId, + "brands": brands + .map( + (brand) => { + "id": brand.id.toString(), + "name": brand.name, + "logo": brand.logo, + "dropship": brand.dropship, + }, + ) + .toList(), + }; + + final response = await _apiService.postWithOptions( + "/api/brands/bulk-insert", + overrideBaseUrl: "https://ebay.backend.data4autos.com", + data: payload, + ); + + return response.data; + } on DioException catch (e) { + throw Exception("Save failed: ${e.response?.data ?? e.message}"); + } catch (e) { + throw Exception("Unexpected error: $e"); + } + } } diff --git a/lib/data/repositories/product_repository.dart b/lib/data/repositories/product_repository.dart new file mode 100644 index 0000000..643420a --- /dev/null +++ b/lib/data/repositories/product_repository.dart @@ -0,0 +1,65 @@ +import 'package:autos/core/constants/api_endpoints.dart'; +import 'package:autos/data/models/product_model.dart'; +import 'package:autos/data/sources/remote/api_service.dart'; +import 'package:autos/domain/entities/product.dart'; +import 'package:flutter/material.dart'; + +abstract class ProductRepository { + Future> getProducts(String userId); +} + +class ProductRepositoryImpl implements ProductRepository { + final ApiService apiService; + + ProductRepositoryImpl(this.apiService); + + @override + Future> getProducts(String userId) async { + try { + final url = '${ApiEndpoints.product}$userId'; + + /// ✅ DEBUG PRINTS + debugPrint("🌍 PRODUCT API HIT"); + debugPrint("âžĄī¸ URL: $url"); + + final response = await apiService.get(url); + + debugPrint("✅ RAW PRODUCT API RESPONSE:"); + debugPrint(response.data.toString()); + + /// ✅ SAFETY: If backend returns null → return empty list + if (response.data == null) { + debugPrint("âš ī¸ PRODUCT API RETURNED NULL → using empty list"); + return []; + } + + /// ✅ SAFETY: Ensure response is really a List + if (response.data is! List) { + debugPrint("âš ī¸ PRODUCT API RETURNED NON-LIST DATA"); + return []; + } + + final List data = response.data as List; + + debugPrint("✅ PARSED PRODUCT COUNT: ${data.length}"); + + /// ✅ EMPTY LIST IS PERFECTLY VALID + if (data.isEmpty) { + debugPrint("â„šī¸ NO PRODUCTS FOUND (EMPTY LIST)"); + return []; + } + + /// ✅ SAFE MODEL PARSING + return data + .map((e) => ProductModel.fromJson(e)) + .toList(); + } catch (e, st) { + debugPrint("❌ PRODUCT API REAL ERROR:"); + debugPrint(e.toString()); + debugPrint(st.toString()); + + /// ✅ ONLY THROW FOR REAL FAILURES + throw Exception("Products fetch error: $e"); + } + } +} diff --git a/lib/data/sources/remote/api_service.dart b/lib/data/sources/remote/api_service.dart index ffd59d9..a5c4847 100644 --- a/lib/data/sources/remote/api_service.dart +++ b/lib/data/sources/remote/api_service.dart @@ -1,4 +1,3 @@ -// lib/data/sources/remote/api_service.dart import 'package:autos/core/constants/api_endpoints.dart'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; @@ -15,7 +14,6 @@ class ApiService { headers: ApiEndpoints.defaultHeaders, ), ) { - // Add logging interceptor in debug mode if (kDebugMode) { _dio.interceptors.add( LogInterceptor(requestBody: true, responseBody: true), @@ -23,7 +21,7 @@ class ApiService { } } - /// POST request + // ✅ KEEP OLD POST (so existing files DO NOT BREAK) Future post( String endpoint, Map data, { @@ -32,21 +30,39 @@ class ApiService { return await _dio.post(endpoint, data: data, options: options); } - /// GET request with optional overrideBaseUrl and headers -Future get( - String endpoint, { - Map? params, - Map? headers, // ✅ NOW SUPPORTED - String? overrideBaseUrl, -}) async { - final String requestUrl = - overrideBaseUrl != null ? overrideBaseUrl + endpoint : endpoint; + // ✅ NEW POST (for overrideBaseUrl + headers + params) + Future postWithOptions( + String endpoint, { + dynamic data, + Map? params, + Map? headers, + String? overrideBaseUrl, + }) async { + final String requestUrl = + overrideBaseUrl != null ? overrideBaseUrl + endpoint : endpoint; - return await _dio.get( - requestUrl, - queryParameters: params, - options: headers != null ? Options(headers: headers) : null, - ); -} + return await _dio.post( + requestUrl, + data: data, + queryParameters: params, + options: headers != null ? Options(headers: headers) : null, + ); + } + // ✅ GET (unchanged) + Future get( + String endpoint, { + Map? params, + Map? headers, + String? overrideBaseUrl, + }) async { + final String requestUrl = + overrideBaseUrl != null ? overrideBaseUrl + endpoint : endpoint; + + return await _dio.get( + requestUrl, + queryParameters: params, + options: headers != null ? Options(headers: headers) : null, + ); + } } diff --git a/lib/domain/entities/product.dart b/lib/domain/entities/product.dart new file mode 100644 index 0000000..7c8d361 --- /dev/null +++ b/lib/domain/entities/product.dart @@ -0,0 +1,13 @@ +class ProductEntity { + final int id; + final String name; + final String image; + final double price; + + ProductEntity({ + required this.id, + required this.name, + required this.image, + required this.price, + }); +} diff --git a/lib/presentation/providers/brand_provider.dart b/lib/presentation/providers/brand_provider.dart index 3a41a80..db93468 100644 --- a/lib/presentation/providers/brand_provider.dart +++ b/lib/presentation/providers/brand_provider.dart @@ -25,16 +25,11 @@ class BrandsNotifier extends StateNotifier>> { Future fetchBrands() async { state = const AsyncValue.loading(); try { - // 1ī¸âƒŖ Get user safely final userAsync = ref.read(userDetailsProvider); final user = userAsync.value; if (user == null) throw Exception("User not logged in"); - // 2ī¸âƒŖ Always fetch fresh token - final token = await repository.getTurn14AccessToken(); - - // 3ī¸âƒŖ Fetch brands final brands = await repository.getBrands(); state = AsyncValue.data(brands); @@ -43,9 +38,30 @@ class BrandsNotifier extends StateNotifier>> { debugPrint("Brands fetch error: $e"); } } + + /// SAVE SELECTED BRANDS TO SERVER (Bulk Insert) +Future saveSelectedBrands(List selectedBrands) async { + try { + final userAsync = ref.read(userDetailsProvider); + final user = userAsync.value; + + if (user == null) throw Exception("User not logged in"); + + final response = await repository.saveSelectedBrands( + userId: user.id, + brands: selectedBrands, + ); + + debugPrint("✅ Save Success: ${response["message"]}"); + } catch (e) { + debugPrint("❌ Save Error: $e"); + rethrow; + } } -/// ✅ Brands State Provider +} + +/// Brands State Provider final brandsProvider = StateNotifierProvider>>( (ref) { diff --git a/lib/presentation/providers/products_provider.dart b/lib/presentation/providers/products_provider.dart new file mode 100644 index 0000000..908dc8f --- /dev/null +++ b/lib/presentation/providers/products_provider.dart @@ -0,0 +1,55 @@ +import 'package:autos/data/repositories/product_repository.dart'; +import 'package:autos/data/sources/remote/api_service.dart'; +import 'package:autos/domain/entities/product.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; + +/// ✅ API SERVICE PROVIDER +final productApiServiceProvider = + Provider((ref) => ApiService()); + +/// ✅ REPOSITORY PROVIDER +final productRepositoryProvider = Provider((ref) { + return ProductRepositoryImpl(ref.read(productApiServiceProvider)); +}); + +/// ✅ PRODUCT STATE PROVIDER +final productProvider = StateNotifierProvider>>((ref) { + final repository = ref.read(productRepositoryProvider); + return ProductNotifier(repository); +}); + +/// ✅ PRODUCT NOTIFIER +class ProductNotifier + extends StateNotifier>> { + final ProductRepositoryImpl repository; + + ProductNotifier(this.repository) + : super(const AsyncValue.loading()); + + /// ✅ FETCH PRODUCTS (EMPTY LIST IS NOT AN ERROR!) + Future getProducts(String userId) async { + try { + state = const AsyncValue.loading(); + + debugPrint("🚀 FETCHING PRODUCTS FOR USER ID = $userId"); + + final products = await repository.getProducts(userId); + + debugPrint("✅ PRODUCTS RECEIVED = ${products.length}"); + + // ✅ EVEN IF LIST IS EMPTY → SUCCESS + state = AsyncValue.data(products); + } catch (e, st) { + debugPrint("❌ PRODUCT API REAL ERROR = $e"); + state = AsyncValue.error(e, st); + } + } + + /// ✅ CLEAR PRODUCTS SAFELY + void clear() { + state = const AsyncValue.data([]); + } +} diff --git a/lib/presentation/screens/brands/brands_screen.dart b/lib/presentation/screens/brands/brands_screen.dart index 4afcac7..15eef48 100644 --- a/lib/presentation/screens/brands/brands_screen.dart +++ b/lib/presentation/screens/brands/brands_screen.dart @@ -1,7 +1,6 @@ import 'package:autos/core/theme/app_typography.dart'; import 'package:autos/core/widgets/hamburger_button.dart'; import 'package:autos/core/widgets/side_menu.dart'; -import 'package:autos/domain/entities/brands.dart'; import 'package:autos/presentation/providers/brand_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -15,13 +14,19 @@ class BrandsScreen extends ConsumerStatefulWidget { class _BrandsScreenState extends ConsumerState { final GlobalKey _scaffoldKey = GlobalKey(); + String selected = 'brands'; + String searchQuery = ''; + bool dropshipOnly = false; + + /// ✅ NEW: Selected Brand IDs + final Set selectedBrandIds = {}; + + bool isSaving = false; @override void initState() { super.initState(); - - /// Fetch brands every time page opens Future.microtask(() { ref.read(brandsProvider.notifier).fetchBrands(); }); @@ -40,90 +45,318 @@ class _BrandsScreenState extends ConsumerState { ), body: Stack( children: [ + /// ✅ TITLE (UNCHANGED) Positioned( top: topPadding, left: 0, right: 0, child: Center( child: Text( - "Brands", + "Turn14 Brands", style: AppTypo.h2.copyWith(fontWeight: FontWeight.w700), ), ), ), + + /// ✅ HAMBURGER (UNCHANGED) HamburgerButton(scaffoldKey: _scaffoldKey), + Positioned.fill( - top: topPadding + 60, + top: topPadding + 50, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: brandsState.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (err, st) => Center( - child: Text( - err.toString(), - style: const TextStyle(color: Colors.red), - ), - ), - data: (brands) { - if (brands.isEmpty) { - return const Center(child: Text("No brands available.")); - } - - return GridView.builder( - padding: const EdgeInsets.only(bottom: 20), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - childAspectRatio: 1, - ), - itemCount: brands.length, - itemBuilder: (context, index) { - final BrandEntity brand = brands[index]; - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: const [ - BoxShadow( - color: Colors.black12, - blurRadius: 6, - offset: Offset(0, 3), - ), - ], - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - brand.logo, - height: 60, - width: 60, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => - const Icon(Icons.image, size: 40), - ), - ), - const SizedBox(height: 12), - Text( - brand.name, - textAlign: TextAlign.center, - style: AppTypo.h3.copyWith( - fontWeight: FontWeight.w600, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ); + child: Column( + children: [ + /// ✅ SEARCH (UNCHANGED) + TextField( + onChanged: (value) { + setState(() => searchQuery = value.toLowerCase()); }, - ); - }, + decoration: InputDecoration( + hintText: "Search brands...", + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + + const SizedBox(height: 8), + + /// ✅ FILTER + SELECT ALL (UI SAME) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Text("Dropship Only"), + Switch( + value: dropshipOnly, + onChanged: (val) { + setState(() => dropshipOnly = val); + }, + activeColor: Colors.green, + ), + ], + ), + + /// ✅ SELECT ALL (LOGIC ONLY) + Row( + children: [ + const Text("Select All"), + Checkbox( + value: brandsState.maybeWhen( + data: (brands) => + brands.isNotEmpty && + selectedBrandIds.length == brands.length, + orElse: () => false, + ), + onChanged: (val) { + brandsState.whenData((brands) { + setState(() { + if (val == true) { + selectedBrandIds.addAll( + brands.map((e) => e.id), + ); + } else { + selectedBrandIds.clear(); + } + }); + }); + }, + ), + ], + ), + ], + ), + + /// ✅ SAVE BUTTON (NEW FEATURE ONLY) + if (selectedBrandIds.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ElevatedButton( + onPressed: isSaving + ? null + : () async { + try { + setState(() => isSaving = true); + + final allBrands = brandsState.value!; + final selectedBrands = allBrands + .where( + (b) => selectedBrandIds.contains(b.id), + ) + .toList(); + + await ref + .read(brandsProvider.notifier) + .saveSelectedBrands(selectedBrands); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + "✅ Collections Saved Successfully", + ), + ), + ); + + setState(() { + selectedBrandIds.clear(); + }); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("❌ Save Failed: $e"), + ), + ); + } finally { + setState(() => isSaving = false); + } + }, + child: isSaving + ? const CircularProgressIndicator() + : Text( + "Save Collections (${selectedBrandIds.length})", + ), + ), + ), + + /// ✅ GRID (UI UNCHANGED) + Expanded( + child: brandsState.when( + loading: () => + const Center(child: CircularProgressIndicator()), + error: (err, _) => Center( + child: Text( + (() { + final raw = err.toString(); + + // Match 'message: ...' from string + final match = RegExp( + r'message[: ]+([^}]+)', + ).firstMatch(raw); + if (match != null) { + return match + .group(1)! + .trim(); // ✅ just the message + } + + // Fallback: remove 'Exception: ' prefix + return raw.replaceFirst( + RegExp(r'^Exception:\s*'), + '', + ); + })(), + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red), + ), + ), + + data: (brands) { + final filteredBrands = brands.where((brand) { + final nameMatch = brand.name.toLowerCase().contains( + searchQuery, + ); + final idMatch = brand.id.toString().contains( + searchQuery, + ); + final dropshipMatch = dropshipOnly + ? brand.dropship + : true; + return (nameMatch || idMatch) && dropshipMatch; + }).toList(); + + return GridView.builder( + padding: const EdgeInsets.only(bottom: 20), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 0.95, + ), + itemCount: filteredBrands.length, + itemBuilder: (context, index) { + final brand = filteredBrands[index]; + final isSelected = selectedBrandIds.contains( + brand.id, + ); + + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 6, + offset: Offset(0, 3), + ), + ], + ), + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 100, + width: 100, + child: ClipRRect( + borderRadius: + BorderRadius.circular(8), + child: Image.network( + brand.logo, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => + const Icon(Icons.image), + ), + ), + ), + const SizedBox(height: 12), + Text("ID: ${brand.id}"), + const SizedBox(height: 4), + Flexible( + child: Text( + brand.name, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ), + + /// ✅ DROPSHIP TAG (UNCHANGED) + if (brand.dropship) + Align( + alignment: Alignment.topLeft, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: const BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(12), + bottomRight: Radius.circular(12), + ), + ), + child: const Text( + 'Dropship', + style: TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + ), + + /// ✅ CHECKBOX ICON (UNCHANGED POSITION) + Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.all(8), + child: GestureDetector( + onTap: () { + setState(() { + if (isSelected) { + selectedBrandIds.remove(brand.id); + } else { + selectedBrandIds.add(brand.id); + } + }); + }, + child: Icon( + isSelected + ? Icons.check_box + : Icons.square_outlined, + color: isSelected + ? Colors.green + : Colors.grey, + ), + ), + ), + ), + ], + ), + ); + }, + ); + }, + ), + ), + ], ), ), ), diff --git a/lib/presentation/screens/products/products_screen.dart b/lib/presentation/screens/products/products_screen.dart new file mode 100644 index 0000000..4fd9ccc --- /dev/null +++ b/lib/presentation/screens/products/products_screen.dart @@ -0,0 +1,286 @@ +import 'package:autos/core/theme/app_typography.dart'; +import 'package:autos/core/widgets/hamburger_button.dart'; +import 'package:autos/core/widgets/side_menu.dart'; +import 'package:autos/presentation/providers/products_provider.dart'; +import 'package:autos/presentation/providers/user_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class ProductsScreen extends ConsumerStatefulWidget { + const ProductsScreen({super.key}); + + @override + ConsumerState createState() => _ProductsScreenState(); +} + +class _ProductsScreenState extends ConsumerState { + final GlobalKey _scaffoldKey = GlobalKey(); + String selected = 'products'; + + bool _hasFetched = false; // ✅ prevents multiple API calls + + // Filter state + String _searchText = ''; + bool _inStockOnly = false; + + @override + void initState() { + super.initState(); + + /// ✅ Wait for widget + provider to be fully ready + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadUserFromProvider(); + }); + } + + /// ✅ LOAD USER FROM RIVERPOD (NOT SECURE STORAGE) + void _loadUserFromProvider() { + final userState = ref.read(userProvider); + final user = userState.value; + + if (user == null || user.id == null || user.id!.isEmpty) { + debugPrint("❌ USER NOT FOUND IN PROVIDER"); + return; + } + + if (_hasFetched) return; // ✅ prevent duplicate API calls + _hasFetched = true; + + debugPrint(" USER FROM PROVIDER ID: ${user.id}"); + + ref.read(productProvider.notifier).getProducts(user.id!); + } + + /// Filter products based on search text and stock + List productsFilter(List products) { + return products.where((product) { + final matchesSearch = _searchText.isEmpty || + product.name.toLowerCase().contains(_searchText.toLowerCase()); + final matchesStock = !_inStockOnly || (product.inStock ?? true); + return matchesSearch && matchesStock; + }).toList(); + } + + @override + Widget build(BuildContext context) { + final productsState = ref.watch(productProvider); + final double topPadding = MediaQuery.of(context).padding.top + 16; + + return Scaffold( + key: _scaffoldKey, + drawer: SideMenu( + selected: selected, + onItemSelected: (key) { + setState(() => selected = key); + }, + ), + body: Stack( + children: [ + /// ✅ Page Title + Positioned( + top: topPadding, + left: 0, + right: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "Brand Products", + style: AppTypo.h2.copyWith(fontWeight: FontWeight.w700), + ), + ], + ), + ), + + /// ✅ Main Content + SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: EdgeInsets.fromLTRB(16, topPadding + 55, 16, 20), + child: productsState.when( + /// ✅ LOADING + loading: () => const Center( + child: Padding( + padding: EdgeInsets.only(top: 120), + child: CircularProgressIndicator(), + ), + ), + + /// ✅ ERROR + error: (e, _) => Center( + child: Padding( + padding: const EdgeInsets.only(top: 120), + child: Text( + (() { + final raw = e.toString(); + // Extract backend message if exists + final match = RegExp(r'message[: ]+([^}]+)').firstMatch(raw); + if (match != null) return match.group(1)!.trim(); + return raw.replaceFirst(RegExp(r'^Exception:\s*'), ''); + })(), + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red), + ), + ), + ), + + /// ✅ SUCCESS + data: (products) { + if (products.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.only(top: 120), + child: Text("No products found"), + ), + ); + } + + // Apply search / stock filter + final filteredProducts = productsFilter(products); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + /// ✅ Search + Filter + Count Row + Row( + children: [ + // Search bar + Expanded( + child: TextField( + onChanged: (value) { + setState(() => _searchText = value); + }, + decoration: InputDecoration( + hintText: "Search products", + prefixIcon: const Icon(Icons.search), + contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.grey), + ), + filled: true, + fillColor: Colors.white, + ), + ), + ), + const SizedBox(width: 12), + + // In Stock Only button + ElevatedButton( + onPressed: () { + setState(() => _inStockOnly = !_inStockOnly); + }, + style: ElevatedButton.styleFrom( + backgroundColor: _inStockOnly ? Colors.green : Colors.grey, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + "In Stock", + style: const TextStyle(fontSize: 12), + ), + ), + const SizedBox(width: 12), + + // Count button + ElevatedButton( + onPressed: () {}, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.deepPurple, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + "${filteredProducts.length} products", + style: const TextStyle(fontSize: 12), + ), + ), + ], + ), + const SizedBox(height: 16), + + /// ✅ Products Grid + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: filteredProducts.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 0.68, + ), + itemBuilder: (context, index) { + final product = filteredProducts[index]; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + blurRadius: 10, + color: Colors.black.withOpacity(0.05), + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + /// ✅ Product Image + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.network( + product.image, + width: double.infinity, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + const Icon(Icons.image_not_supported), + ), + ), + ), + const SizedBox(height: 10), + + /// ✅ Product Name + Text( + product.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + + /// ✅ Price + Text( + "₹ ${product.price}", + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.green, + ), + ), + ], + ), + ); + }, + ), + ], + ); + }, + ), + ), + + /// ✅ Hamburger Button + HamburgerButton(scaffoldKey: _scaffoldKey), + ], + ), + ); + } +}