diff --git a/lib/core/constants/api_endpoints.dart b/lib/core/constants/api_endpoints.dart index 93b2e02..84430fb 100644 --- a/lib/core/constants/api_endpoints.dart +++ b/lib/core/constants/api_endpoints.dart @@ -1,5 +1,5 @@ class ApiEndpoints { - static const String baseUrl = 'https://ebay.backend.data4autos.com/api'; + static const String baseUrl = 'https://ebay.backend.data4autos.com'; static const Duration connectTimeout = Duration(seconds: 10); static const Duration receiveTimeout = Duration(seconds: 10); @@ -9,12 +9,18 @@ class ApiEndpoints { }; ///Authentication - static const login = '/auth/login'; - static const signup = '/auth/signup'; - static const forgotPassword = '/auth/forgot-password'; - static const userDetails = '/auth/users/'; + static const login = '/api/auth/login'; + static const signup = '/api/auth/signup'; + static const forgotPassword = '/api/auth/forgot-password'; + static const userDetails = '/api/auth/users/'; + + ///GET Token + static const getToken = '/api/auth/turn14/get-access-token'; ///Turn14 - static const turn14Save = '/auth/turn14/save'; - static const turn14Status = '/auth/turn14/status'; + static const turn14Save = '/api/auth/turn14/save'; + static const turn14Status = '/api/auth/turn14/status'; + + ///Brands + static const String brands = "/v1/brands"; } diff --git a/lib/core/routing/app_router.dart b/lib/core/routing/app_router.dart index 8cc9694..e769276 100644 --- a/lib/core/routing/app_router.dart +++ b/lib/core/routing/app_router.dart @@ -2,6 +2,7 @@ import 'package:autos/core/routing/route_paths.dart'; import 'package:autos/presentation/screens/auth/forgot_password_screen.dart'; import 'package:autos/presentation/screens/auth/login_screen.dart'; 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/store/create_location_screen.dart'; @@ -57,6 +58,9 @@ class AppRouter { case AppRoutePaths.createStoreLocation: return slideRoute(CreateLocationScreen()); + case AppRoutePaths.brands: + return slideRoute(BrandsScreen()); + default: return _defaultFallback(settings); } diff --git a/lib/core/routing/route_paths.dart b/lib/core/routing/route_paths.dart index 31d37c7..8e1b32b 100644 --- a/lib/core/routing/route_paths.dart +++ b/lib/core/routing/route_paths.dart @@ -7,4 +7,5 @@ class AppRoutePaths { static const ebay = '/ebay'; static const store = '/store'; static const createStoreLocation = '/createStoreLocation'; + static const brands = '/brands'; } diff --git a/lib/core/widgets/side_menu.dart b/lib/core/widgets/side_menu.dart index 750524d..6dfdace 100644 --- a/lib/core/widgets/side_menu.dart +++ b/lib/core/widgets/side_menu.dart @@ -64,7 +64,7 @@ class SideMenu extends ConsumerWidget { // --- MANAGE --- _sectionHeader("MANAGE"), - _menuItem(context, "🏷️", "Brands", 'brands'), + _menuItem(context, "🏷️", "Brands", AppRoutePaths.brands), _menuItem(context, "📦", "Products", 'products'), _menuItem(context, "⬇️", "Imports", 'imports'), diff --git a/lib/data/models/brands_model.dart b/lib/data/models/brands_model.dart new file mode 100644 index 0000000..8007a6d --- /dev/null +++ b/lib/data/models/brands_model.dart @@ -0,0 +1,107 @@ +import 'package:autos/domain/entities/brands.dart'; + +class Brand { + final String id; + final String name; + final String logo; + final bool dropship; + final List pricegroups; + + Brand({ + required this.id, + required this.name, + required this.logo, + required this.dropship, + required this.pricegroups, + }); + + factory Brand.fromJson(Map json) { + return Brand( + id: json['id'].toString(), + name: json['name'] ?? '', + logo: json['logo'] ?? '', + dropship: json['dropship'] ?? false, + pricegroups: (json['pricegroups'] as List?) + ?.map((e) => PriceGroup.fromJson(e)) + .toList() ?? + [], + ); + } + + /// 🔥 Model → Entity + BrandEntity toEntity() { + return BrandEntity( + id: id, + name: name, + logo: logo, + dropship: dropship, + pricegroups: pricegroups.map((e) => e.toEntity()).toList(), + ); + } +} + +class PriceGroup { + final String pricegroupId; + final String pricegroupName; + final String pricegroupPrefix; + final List locationRules; + final List purchaseRestrictions; + + PriceGroup({ + required this.pricegroupId, + required this.pricegroupName, + required this.pricegroupPrefix, + required this.locationRules, + required this.purchaseRestrictions, + }); + + factory PriceGroup.fromJson(Map json) { + return PriceGroup( + pricegroupId: json['pricegroup_id'].toString(), + pricegroupName: json['pricegroup_name'] ?? '', + pricegroupPrefix: json['pricegroup_prefix'] ?? '', + locationRules: json['location_rules'] ?? [], + purchaseRestrictions: (json['purchase_restrictions'] as List?) + ?.map((e) => PurchaseRestriction.fromJson(e)) + .toList() ?? + [], + ); + } + + /// 🔥 Model → Entity + PriceGroupEntity toEntity() { + return PriceGroupEntity( + pricegroupId: pricegroupId, + pricegroupName: pricegroupName, + pricegroupPrefix: pricegroupPrefix, + locationRules: locationRules, + purchaseRestrictions: + purchaseRestrictions.map((e) => e.toEntity()).toList(), + ); + } +} + +class PurchaseRestriction { + final String program; + final String yourStatus; + + PurchaseRestriction({ + required this.program, + required this.yourStatus, + }); + + factory PurchaseRestriction.fromJson(Map json) { + return PurchaseRestriction( + program: json['program'] ?? '', + yourStatus: json['your_status'] ?? '', + ); + } + + /// 🔥 Model → Entity + PurchaseRestrictionEntity toEntity() { + return PurchaseRestrictionEntity( + program: program, + yourStatus: yourStatus, + ); + } +} diff --git a/lib/data/repositories/brands_repository_impl.dart b/lib/data/repositories/brands_repository_impl.dart new file mode 100644 index 0000000..5aef60b --- /dev/null +++ b/lib/data/repositories/brands_repository_impl.dart @@ -0,0 +1,91 @@ +import 'dart:convert'; +import 'package:autos/core/constants/api_endpoints.dart'; +import 'package:autos/data/models/brands_model.dart'; +import 'package:autos/data/sources/remote/api_service.dart'; +import 'package:autos/domain/entities/brands.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:autos/data/models/user_model.dart'; + +abstract class BrandsRepository { + Future> getBrands(); +} + +class BrandsRepositoryImpl implements BrandsRepository { + final ApiService _apiService; + final _storage = const FlutterSecureStorage(); + static const _userKey = 'logged_in_user'; + + 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"); + } + } else { + throw Exception(data["message"]); + } + + debugPrint("Turn14 Token: $token"); + return token; +} + + /// ✅ FETCH BRANDS USING FRESH TOKEN EVERY TIME + @override + Future> getBrands() async { + try { + final token = await getTurn14AccessToken(); + + final response = await _apiService.get( + '/v1/brands', + overrideBaseUrl: 'https://turn14.data4autos.com', + headers: { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + }, + ); + + if (response.statusCode != 200) { + throw Exception("Server error: ${response.statusCode}"); + } + + final data = response.data; + + if (data is! Map || data["data"] == null) { + throw Exception("Invalid API response"); + } + + final list = data["data"] as List; + + return list.map((json) => Brand.fromJson(json).toEntity()).toList(); + } on DioException catch (e) { + throw Exception("Network error: ${e.response?.data ?? e.message}"); + } catch (e) { + throw Exception("Unexpected error: $e"); + } + } +} diff --git a/lib/data/repositories/token_repository.dart b/lib/data/repositories/token_repository.dart new file mode 100644 index 0000000..19acc72 --- /dev/null +++ b/lib/data/repositories/token_repository.dart @@ -0,0 +1,57 @@ +import 'package:autos/core/constants/api_endpoints.dart'; +import 'package:autos/data/sources/remote/api_service.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class TokenRepository { + final ApiService apiService; + final _storage = const FlutterSecureStorage(); + + TokenRepository(this.apiService); + + static const _tokenKey = "turn14_token"; + static const _tokenTimeKey = "turn14_token_time"; + + /// ✅ Get Valid Token (Auto Refresh If Expired) + Future getValidToken(String userId) async { + final token = await _storage.read(key: _tokenKey); + final time = await _storage.read(key: _tokenTimeKey); + + if (token != null && time != null) { + final savedTime = DateTime.parse(time); + final diff = DateTime.now().difference(savedTime); + + /// ✅ Token Valid for 50 minutes + if (diff.inMinutes < 50) { + return token; + } + } + + /// 🔄 Token Expired → Refresh + return await _refreshToken(userId); + } + + /// 🔄 Refresh Token API + Future _refreshToken(String userId) async { + final response = await apiService.post( + ApiEndpoints.getToken, + {"userid": userId}, + ); + + final data = response.data; + + if (data["code"] != "TOKEN_UPDATED") { + throw Exception(data["message"]); + } + + final token = data["access_token"]; + + /// ✅ Save New Token + Timestamp + await _storage.write(key: _tokenKey, value: token); + await _storage.write( + key: _tokenTimeKey, + value: DateTime.now().toIso8601String(), + ); + + return token; + } +} diff --git a/lib/data/sources/remote/api_service.dart b/lib/data/sources/remote/api_service.dart index 813c240..ffd59d9 100644 --- a/lib/data/sources/remote/api_service.dart +++ b/lib/data/sources/remote/api_service.dart @@ -1,3 +1,4 @@ +// 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'; @@ -6,14 +7,14 @@ class ApiService { final Dio _dio; ApiService({String? baseUrl}) - : _dio = Dio( - BaseOptions( - baseUrl: baseUrl ?? ApiEndpoints.baseUrl, - connectTimeout: ApiEndpoints.connectTimeout, - receiveTimeout: ApiEndpoints.receiveTimeout, - headers: ApiEndpoints.defaultHeaders, - ), - ) { + : _dio = Dio( + BaseOptions( + baseUrl: baseUrl ?? ApiEndpoints.baseUrl, + connectTimeout: ApiEndpoints.connectTimeout, + receiveTimeout: ApiEndpoints.receiveTimeout, + headers: ApiEndpoints.defaultHeaders, + ), + ) { // Add logging interceptor in debug mode if (kDebugMode) { _dio.interceptors.add( @@ -22,6 +23,7 @@ class ApiService { } } + /// POST request Future post( String endpoint, Map data, { @@ -30,7 +32,21 @@ class ApiService { return await _dio.post(endpoint, data: data, options: options); } - Future get(String endpoint, {Map? params}) async { - return await _dio.get(endpoint, queryParameters: params); - } + /// 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; + + return await _dio.get( + requestUrl, + queryParameters: params, + options: headers != null ? Options(headers: headers) : null, + ); +} + } diff --git a/lib/domain/entities/brands.dart b/lib/domain/entities/brands.dart new file mode 100644 index 0000000..b862221 --- /dev/null +++ b/lib/domain/entities/brands.dart @@ -0,0 +1,41 @@ +class BrandEntity { + final String id; + final String name; + final String logo; + final bool dropship; + final List pricegroups; + + BrandEntity({ + required this.id, + required this.name, + required this.logo, + required this.dropship, + required this.pricegroups, + }); +} + +class PriceGroupEntity { + final String pricegroupId; + final String pricegroupName; + final String pricegroupPrefix; + final List locationRules; + final List purchaseRestrictions; + + PriceGroupEntity({ + required this.pricegroupId, + required this.pricegroupName, + required this.pricegroupPrefix, + required this.locationRules, + required this.purchaseRestrictions, + }); +} + +class PurchaseRestrictionEntity { + final String program; + final String yourStatus; + + PurchaseRestrictionEntity({ + required this.program, + required this.yourStatus, + }); +} diff --git a/lib/presentation/providers/brand_provider.dart b/lib/presentation/providers/brand_provider.dart new file mode 100644 index 0000000..3a41a80 --- /dev/null +++ b/lib/presentation/providers/brand_provider.dart @@ -0,0 +1,55 @@ +import 'package:autos/data/repositories/brands_repository_impl.dart'; +import 'package:autos/data/sources/remote/api_service.dart'; +import 'package:autos/domain/entities/brands.dart'; +import 'package:autos/presentation/providers/user_provider.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; + +/// ✅ API Service Provider +final brandsApiServiceProvider = Provider((ref) => ApiService()); + +/// ✅ Repository Provider +final brandsRepositoryProvider = Provider( + (ref) => BrandsRepositoryImpl(ref.read(brandsApiServiceProvider)), +); + +/// ✅ Brands Notifier +class BrandsNotifier extends StateNotifier>> { + final BrandsRepositoryImpl repository; + final Ref ref; + + BrandsNotifier(this.repository, this.ref) : super(const AsyncValue.loading()); + + /// Fetch brands every time page opens + 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); + } catch (e, st) { + state = AsyncValue.error(e, st); + debugPrint("Brands fetch error: $e"); + } + } +} + +/// ✅ Brands State Provider +final brandsProvider = + StateNotifierProvider>>( + (ref) { + final repo = ref.read(brandsRepositoryProvider); + return BrandsNotifier(repo, ref); + }, +); diff --git a/lib/presentation/providers/token_provider.dart b/lib/presentation/providers/token_provider.dart new file mode 100644 index 0000000..708c799 --- /dev/null +++ b/lib/presentation/providers/token_provider.dart @@ -0,0 +1,7 @@ +import 'package:autos/presentation/providers/user_provider.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:autos/data/repositories/token_repository.dart'; + +final tokenRepositoryProvider = Provider( + (ref) => TokenRepository(ref.read(apiServiceProvider)), +); diff --git a/lib/presentation/screens/brands/brands_screen.dart b/lib/presentation/screens/brands/brands_screen.dart new file mode 100644 index 0000000..4afcac7 --- /dev/null +++ b/lib/presentation/screens/brands/brands_screen.dart @@ -0,0 +1,134 @@ +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'; + +class BrandsScreen extends ConsumerStatefulWidget { + const BrandsScreen({super.key}); + + @override + ConsumerState createState() => _BrandsScreenState(); +} + +class _BrandsScreenState extends ConsumerState { + final GlobalKey _scaffoldKey = GlobalKey(); + String selected = 'brands'; + + @override + void initState() { + super.initState(); + + /// Fetch brands every time page opens + Future.microtask(() { + ref.read(brandsProvider.notifier).fetchBrands(); + }); + } + + @override + Widget build(BuildContext context) { + final topPadding = MediaQuery.of(context).padding.top + 16; + final brandsState = ref.watch(brandsProvider); + + return Scaffold( + key: _scaffoldKey, + drawer: SideMenu( + selected: selected, + onItemSelected: (key) => setState(() => selected = key), + ), + body: Stack( + children: [ + Positioned( + top: topPadding, + left: 0, + right: 0, + child: Center( + child: Text( + "Brands", + style: AppTypo.h2.copyWith(fontWeight: FontWeight.w700), + ), + ), + ), + HamburgerButton(scaffoldKey: _scaffoldKey), + Positioned.fill( + top: topPadding + 60, + 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, + ), + ], + ), + ); + }, + ); + }, + ), + ), + ), + ], + ), + ); + } +}