minor
This commit is contained in:
720
dashboard/lib/main.dart
Normal file
720
dashboard/lib/main.dart
Normal file
@@ -0,0 +1,720 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
final config = await DashboardEnvConfig.load();
|
||||
runApp(CatalogDashboardApp(apiBaseUrl: config.apiBaseUrl));
|
||||
}
|
||||
|
||||
class CatalogDashboardApp extends StatelessWidget {
|
||||
const CatalogDashboardApp({super.key, required this.apiBaseUrl});
|
||||
|
||||
final String apiBaseUrl;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Building Catalog Dashboard',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF0E6D6F),
|
||||
primary: const Color(0xFF0E6D6F),
|
||||
secondary: const Color(0xFFE6853D),
|
||||
surface: const Color(0xFFF6F3EE),
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFFF4EFE6),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: CatalogScreen(apiBaseUrl: apiBaseUrl),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CatalogScreen extends StatefulWidget {
|
||||
const CatalogScreen({super.key, required this.apiBaseUrl});
|
||||
|
||||
final String apiBaseUrl;
|
||||
|
||||
@override
|
||||
State<CatalogScreen> createState() => _CatalogScreenState();
|
||||
}
|
||||
|
||||
class _CatalogScreenState extends State<CatalogScreen> {
|
||||
late final CatalogApi _api;
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
List<CategoryDto> _categories = [];
|
||||
List<BrandDto> _brands = [];
|
||||
List<ProductCardDto> _products = [];
|
||||
|
||||
String? _selectedCategoryId;
|
||||
String? _selectedBrandId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_api = CatalogApi(baseUrl: widget.apiBaseUrl);
|
||||
_loadInitial();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadInitial() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final data = await Future.wait([
|
||||
_api.fetchCategories(),
|
||||
_api.fetchBrands(),
|
||||
_api.fetchProducts(),
|
||||
]);
|
||||
|
||||
setState(() {
|
||||
_categories = data[0] as List<CategoryDto>;
|
||||
_brands = data[1] as List<BrandDto>;
|
||||
_products = data[2] as List<ProductCardDto>;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
});
|
||||
} finally {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _search() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final products = await _api.fetchProducts(
|
||||
query: _searchController.text,
|
||||
categoryId: _selectedCategoryId,
|
||||
brandId: _selectedBrandId,
|
||||
);
|
||||
setState(() {
|
||||
_products = products;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
});
|
||||
} finally {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openProduct(String productId) async {
|
||||
try {
|
||||
final detail = await _api.fetchProductDetail(productId);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => ProductDetailSheet(detail: detail),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Не удалось загрузить карточку товара: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final content = _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(child: Text(_error!))
|
||||
: _products.isEmpty
|
||||
? const Center(child: Text('Ничего не найдено'))
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _products.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final product = _products[index];
|
||||
return ProductCard(
|
||||
product: product,
|
||||
onTap: () => _openProduct(product.id),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Каталог стройматериалов'),
|
||||
backgroundColor: const Color(0xFF0E6D6F),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
FilterBar(
|
||||
searchController: _searchController,
|
||||
categories: _categories,
|
||||
brands: _brands,
|
||||
selectedCategoryId: _selectedCategoryId,
|
||||
selectedBrandId: _selectedBrandId,
|
||||
onCategoryChanged: (value) =>
|
||||
setState(() => _selectedCategoryId = value),
|
||||
onBrandChanged: (value) => setState(() => _selectedBrandId = value),
|
||||
onSearch: _search,
|
||||
),
|
||||
Expanded(child: content),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FilterBar extends StatelessWidget {
|
||||
const FilterBar({
|
||||
required this.searchController,
|
||||
required this.categories,
|
||||
required this.brands,
|
||||
required this.selectedCategoryId,
|
||||
required this.selectedBrandId,
|
||||
required this.onCategoryChanged,
|
||||
required this.onBrandChanged,
|
||||
required this.onSearch,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final TextEditingController searchController;
|
||||
final List<CategoryDto> categories;
|
||||
final List<BrandDto> brands;
|
||||
final String? selectedCategoryId;
|
||||
final String? selectedBrandId;
|
||||
final ValueChanged<String?> onCategoryChanged;
|
||||
final ValueChanged<String?> onBrandChanged;
|
||||
final VoidCallback onSearch;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Color(0xFFECE6DB), Color(0xFFF8F5EF)],
|
||||
),
|
||||
),
|
||||
child: Wrap(
|
||||
runSpacing: 8,
|
||||
spacing: 8,
|
||||
alignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 300,
|
||||
child: TextField(
|
||||
controller: searchController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Поиск по названию/SKU',
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: DropdownButtonFormField<String?>(
|
||||
isExpanded: true,
|
||||
initialValue: selectedCategoryId,
|
||||
items: [
|
||||
const DropdownMenuItem(
|
||||
value: null,
|
||||
child: Text('Все категории'),
|
||||
),
|
||||
...categories.map(
|
||||
(c) => DropdownMenuItem(value: c.id, child: Text(c.name)),
|
||||
),
|
||||
],
|
||||
onChanged: onCategoryChanged,
|
||||
decoration: const InputDecoration(
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 180,
|
||||
child: DropdownButtonFormField<String?>(
|
||||
isExpanded: true,
|
||||
initialValue: selectedBrandId,
|
||||
items: [
|
||||
const DropdownMenuItem(value: null, child: Text('Все бренды')),
|
||||
...brands.map(
|
||||
(b) => DropdownMenuItem(value: b.id, child: Text(b.name)),
|
||||
),
|
||||
],
|
||||
onChanged: onBrandChanged,
|
||||
decoration: const InputDecoration(
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: onSearch,
|
||||
icon: const Icon(Icons.search),
|
||||
label: const Text('Найти'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProductCard extends StatelessWidget {
|
||||
const ProductCard({required this.product, required this.onTap, super.key});
|
||||
|
||||
final ProductCardDto product;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: product.image != null
|
||||
? Image.network(
|
||||
product.image!,
|
||||
width: 96,
|
||||
height: 96,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
const _ImagePlaceholder(),
|
||||
)
|
||||
: const _ImagePlaceholder(),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
product.name,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text('SKU: ${product.sku}'),
|
||||
Text(
|
||||
'${product.categoryName} / ${product.brandName ?? 'без бренда'}',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Chip(
|
||||
label: Text(
|
||||
'${product.price.toStringAsFixed(2)} ${product.currency}',
|
||||
),
|
||||
),
|
||||
Chip(
|
||||
label: Text(
|
||||
product.inStock
|
||||
? 'В наличии: ${product.qty.toStringAsFixed(1)}'
|
||||
: 'Нет в наличии',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProductDetailSheet extends StatelessWidget {
|
||||
const ProductDetailSheet({required this.detail, super.key});
|
||||
|
||||
final ProductDetailDto detail;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(detail.name, style: Theme.of(context).textTheme.headlineSmall),
|
||||
const SizedBox(height: 8),
|
||||
Text(detail.description),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'${detail.price.toStringAsFixed(2)} ${detail.currency} · ${detail.qty.toStringAsFixed(1)} ${detail.unit}',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: detail.attributes
|
||||
.map(
|
||||
(a) => Chip(
|
||||
label: Text(
|
||||
a.unit == null
|
||||
? '${a.key}: ${a.value}'
|
||||
: '${a.key}: ${a.value} ${a.unit}',
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (detail.images.isNotEmpty)
|
||||
SizedBox(
|
||||
height: 150,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: detail.images.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) => ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.network(
|
||||
detail.images[index],
|
||||
width: 180,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
const _ImagePlaceholder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImagePlaceholder extends StatelessWidget {
|
||||
const _ImagePlaceholder();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
color: const Color(0xFFE9DFD1),
|
||||
child: const Icon(Icons.image_not_supported_outlined),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CatalogApi {
|
||||
static const _defaultBaseUrl = 'http://localhost:8080/api/v1';
|
||||
CatalogApi({String? baseUrl}) : baseUrl = baseUrl ?? _defaultBaseUrl;
|
||||
|
||||
final String baseUrl;
|
||||
|
||||
Future<List<CategoryDto>> fetchCategories() async {
|
||||
final response = await http.get(
|
||||
Uri.parse('$baseUrl/categories?tree=false'),
|
||||
);
|
||||
_ensureOk(response);
|
||||
final list = jsonDecode(response.body) as List<dynamic>;
|
||||
return list
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(
|
||||
(item) => CategoryDto(
|
||||
id: item['id'].toString(),
|
||||
name: item['name'].toString(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<List<BrandDto>> fetchBrands() async {
|
||||
final response = await http.get(Uri.parse('$baseUrl/brands'));
|
||||
_ensureOk(response);
|
||||
final list = jsonDecode(response.body) as List<dynamic>;
|
||||
return list
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(
|
||||
(item) => BrandDto(
|
||||
id: item['id'].toString(),
|
||||
name: item['name'].toString(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<List<ProductCardDto>> fetchProducts({
|
||||
String? query,
|
||||
String? categoryId,
|
||||
String? brandId,
|
||||
}) async {
|
||||
final params = <String, String>{
|
||||
'page': '1',
|
||||
'pageSize': '40',
|
||||
'sort': 'name',
|
||||
'order': 'asc',
|
||||
};
|
||||
if (query != null && query.trim().isNotEmpty) {
|
||||
params['q'] = query.trim();
|
||||
}
|
||||
if (categoryId != null && categoryId.isNotEmpty) {
|
||||
params['categoryId'] = categoryId;
|
||||
}
|
||||
if (brandId != null && brandId.isNotEmpty) {
|
||||
params['brandId'] = brandId;
|
||||
}
|
||||
|
||||
final uri = Uri.parse('$baseUrl/products').replace(queryParameters: params);
|
||||
final response = await http.get(uri);
|
||||
_ensureOk(response);
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final items = data['items'] as List<dynamic>;
|
||||
return items
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(ProductCardDto.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<ProductDetailDto> fetchProductDetail(String id) async {
|
||||
final response = await http.get(Uri.parse('$baseUrl/products/$id'));
|
||||
_ensureOk(response);
|
||||
return ProductDetailDto.fromJson(
|
||||
jsonDecode(response.body) as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
void _ensureOk(http.Response response) {
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
return;
|
||||
}
|
||||
throw Exception('HTTP ${response.statusCode}: ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
class DashboardEnvConfig {
|
||||
const DashboardEnvConfig({required this.apiBaseUrl});
|
||||
|
||||
static const _defaultBaseUrl = 'http://localhost:8080/api/v1';
|
||||
|
||||
final String apiBaseUrl;
|
||||
|
||||
static Future<DashboardEnvConfig> load() async {
|
||||
const fromDefine = String.fromEnvironment('API_BASE_URL', defaultValue: '');
|
||||
if (fromDefine.isNotEmpty) {
|
||||
return const DashboardEnvConfig(apiBaseUrl: fromDefine);
|
||||
}
|
||||
|
||||
try {
|
||||
final envRaw = await rootBundle.loadString('assets/env', cache: false);
|
||||
final values = _parseEnv(envRaw);
|
||||
final fromAsset = values['API_BASE_URL'];
|
||||
if (fromAsset != null && fromAsset.isNotEmpty) {
|
||||
return DashboardEnvConfig(apiBaseUrl: fromAsset);
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore missing env asset and use defaults.
|
||||
}
|
||||
|
||||
return const DashboardEnvConfig(apiBaseUrl: _defaultBaseUrl);
|
||||
}
|
||||
|
||||
static Map<String, String> _parseEnv(String source) {
|
||||
final entries = <String, String>{};
|
||||
for (final line in source.split('\n')) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isEmpty || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final separatorIndex = trimmed.indexOf('=');
|
||||
if (separatorIndex <= 0 || separatorIndex == trimmed.length - 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final key = trimmed.substring(0, separatorIndex).trim();
|
||||
final value = trimmed.substring(separatorIndex + 1).trim();
|
||||
if (key.isEmpty || value.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
entries[key] = value;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
class CategoryDto {
|
||||
const CategoryDto({required this.id, required this.name});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
}
|
||||
|
||||
class BrandDto {
|
||||
const BrandDto({required this.id, required this.name});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
}
|
||||
|
||||
class ProductCardDto {
|
||||
const ProductCardDto({
|
||||
required this.id,
|
||||
required this.sku,
|
||||
required this.name,
|
||||
required this.categoryName,
|
||||
required this.brandName,
|
||||
required this.price,
|
||||
required this.currency,
|
||||
required this.qty,
|
||||
required this.inStock,
|
||||
required this.image,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String sku;
|
||||
final String name;
|
||||
final String categoryName;
|
||||
final String? brandName;
|
||||
final double price;
|
||||
final String currency;
|
||||
final double qty;
|
||||
final bool inStock;
|
||||
final String? image;
|
||||
|
||||
factory ProductCardDto.fromJson(Map<String, dynamic> json) {
|
||||
final category = json['category'] as Map<String, dynamic>;
|
||||
final brand = json['brand'] as Map<String, dynamic>?;
|
||||
final price = json['price'] as Map<String, dynamic>;
|
||||
final stock = json['stock'] as Map<String, dynamic>;
|
||||
|
||||
return ProductCardDto(
|
||||
id: json['id'].toString(),
|
||||
sku: json['sku'].toString(),
|
||||
name: json['name'].toString(),
|
||||
categoryName: category['name'].toString(),
|
||||
brandName: brand?['name']?.toString(),
|
||||
price: (price['price'] as num?)?.toDouble() ?? 0,
|
||||
currency: price['currency']?.toString() ?? 'RUB',
|
||||
qty: (stock['qty'] as num?)?.toDouble() ?? 0,
|
||||
inStock: stock['isInStock'] == true,
|
||||
image: json['image']?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProductDetailDto {
|
||||
const ProductDetailDto({
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.currency,
|
||||
required this.price,
|
||||
required this.qty,
|
||||
required this.unit,
|
||||
required this.images,
|
||||
required this.attributes,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String description;
|
||||
final String currency;
|
||||
final double price;
|
||||
final double qty;
|
||||
final String unit;
|
||||
final List<String> images;
|
||||
final List<ProductAttributeDto> attributes;
|
||||
|
||||
factory ProductDetailDto.fromJson(Map<String, dynamic> json) {
|
||||
final stock = json['stock'] as Map<String, dynamic>?;
|
||||
final price = json['price'] as Map<String, dynamic>?;
|
||||
final attrs = (json['attributes'] as List<dynamic>? ?? <dynamic>[])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(ProductAttributeDto.fromJson)
|
||||
.toList();
|
||||
|
||||
final images = (json['images'] as List<dynamic>? ?? <dynamic>[])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((i) => i['url'].toString())
|
||||
.toList();
|
||||
|
||||
return ProductDetailDto(
|
||||
name: json['name']?.toString() ?? '-',
|
||||
description: json['description']?.toString() ?? '',
|
||||
currency: price?['currency']?.toString() ?? 'RUB',
|
||||
price: (price?['price'] as num?)?.toDouble() ?? 0,
|
||||
qty: (stock?['qty'] as num?)?.toDouble() ?? 0,
|
||||
unit: json['unit']?.toString() ?? '',
|
||||
images: images,
|
||||
attributes: attrs,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProductAttributeDto {
|
||||
const ProductAttributeDto({
|
||||
required this.key,
|
||||
required this.value,
|
||||
required this.unit,
|
||||
});
|
||||
|
||||
final String key;
|
||||
final String value;
|
||||
final String? unit;
|
||||
|
||||
factory ProductAttributeDto.fromJson(Map<String, dynamic> json) {
|
||||
return ProductAttributeDto(
|
||||
key: json['key']?.toString() ?? '',
|
||||
value: json['value']?.toString() ?? '',
|
||||
unit: json['unit']?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user