Products page connected to the backend.

This commit is contained in:
bala 2025-11-29 15:04:40 +05:30
parent 595554d541
commit a978699dfc
13 changed files with 878 additions and 128 deletions

View File

@ -23,4 +23,7 @@ class ApiEndpoints {
///Brands
static const String brands = "/v1/brands";
///Products
static const product = '/api/brands/';
}

View File

@ -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);
}

View File

@ -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';
}

View File

@ -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 ---

View File

@ -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<String, dynamic> json) {
return ProductModel(
id: json['id'],
name: json['name'],
image: json['image'],
price: (json['price'] as num).toDouble(),
);
}
}

View File

@ -20,39 +20,39 @@ class BrandsRepositoryImpl implements BrandsRepository {
BrandsRepositoryImpl(this._apiService);
/// ALWAYS CALL TOKEN API (FORCE REFRESH)
Future<String> 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<String> 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<String> getTurn14AccessToken() async {
throw Exception("Unexpected error: $e");
}
}
/// BULK INSERT SELECTED BRANDS
Future<Map<String, dynamic>> saveSelectedBrands({
required String userId,
required List<BrandEntity> 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");
}
}
}

View File

@ -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<List<ProductEntity>> getProducts(String userId);
}
class ProductRepositoryImpl implements ProductRepository {
final ApiService apiService;
ProductRepositoryImpl(this.apiService);
@override
Future<List<ProductEntity>> 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");
}
}
}

View File

@ -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<Response> post(
String endpoint,
Map<String, dynamic> data, {
@ -32,21 +30,39 @@ class ApiService {
return await _dio.post(endpoint, data: data, options: options);
}
/// GET request with optional overrideBaseUrl and headers
Future<Response> get(
String endpoint, {
Map<String, dynamic>? params,
Map<String, dynamic>? headers, // NOW SUPPORTED
String? overrideBaseUrl,
}) async {
final String requestUrl =
overrideBaseUrl != null ? overrideBaseUrl + endpoint : endpoint;
// NEW POST (for overrideBaseUrl + headers + params)
Future<Response> postWithOptions(
String endpoint, {
dynamic data,
Map<String, dynamic>? params,
Map<String, dynamic>? 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<Response> get(
String endpoint, {
Map<String, dynamic>? params,
Map<String, dynamic>? 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,
);
}
}

View File

@ -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,
});
}

View File

@ -25,16 +25,11 @@ class BrandsNotifier extends StateNotifier<AsyncValue<List<BrandEntity>>> {
Future<void> 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<AsyncValue<List<BrandEntity>>> {
debugPrint("Brands fetch error: $e");
}
}
/// SAVE SELECTED BRANDS TO SERVER (Bulk Insert)
Future<void> saveSelectedBrands(List<BrandEntity> 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<BrandsNotifier, AsyncValue<List<BrandEntity>>>(
(ref) {

View File

@ -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<ApiService>((ref) => ApiService());
/// REPOSITORY PROVIDER
final productRepositoryProvider = Provider<ProductRepositoryImpl>((ref) {
return ProductRepositoryImpl(ref.read(productApiServiceProvider));
});
/// PRODUCT STATE PROVIDER
final productProvider = StateNotifierProvider<ProductNotifier,
AsyncValue<List<ProductEntity>>>((ref) {
final repository = ref.read(productRepositoryProvider);
return ProductNotifier(repository);
});
/// PRODUCT NOTIFIER
class ProductNotifier
extends StateNotifier<AsyncValue<List<ProductEntity>>> {
final ProductRepositoryImpl repository;
ProductNotifier(this.repository)
: super(const AsyncValue.loading());
/// FETCH PRODUCTS (EMPTY LIST IS NOT AN ERROR!)
Future<void> 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([]);
}
}

View File

@ -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<BrandsScreen> {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
String selected = 'brands';
String searchQuery = '';
bool dropshipOnly = false;
/// NEW: Selected Brand IDs
final Set<String> 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<BrandsScreen> {
),
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,
),
),
),
),
],
),
);
},
);
},
),
),
],
),
),
),

View File

@ -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<ProductsScreen> createState() => _ProductsScreenState();
}
class _ProductsScreenState extends ConsumerState<ProductsScreen> {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
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),
],
),
);
}
}