Products page connected to the backend.
This commit is contained in:
parent
595554d541
commit
a978699dfc
@ -23,4 +23,7 @@ class ApiEndpoints {
|
||||
|
||||
///Brands
|
||||
static const String brands = "/v1/brands";
|
||||
|
||||
///Products
|
||||
static const product = '/api/brands/';
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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 ---
|
||||
|
||||
21
lib/data/models/product_model.dart
Normal file
21
lib/data/models/product_model.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
65
lib/data/repositories/product_repository.dart
Normal file
65
lib/data/repositories/product_repository.dart
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
13
lib/domain/entities/product.dart
Normal file
13
lib/domain/entities/product.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
55
lib/presentation/providers/products_provider.dart
Normal file
55
lib/presentation/providers/products_provider.dart
Normal 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([]);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
286
lib/presentation/screens/products/products_screen.dart
Normal file
286
lib/presentation/screens/products/products_screen.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user