import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; Future 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 createState() => _CatalogScreenState(); } class _CatalogScreenState extends State { late final CatalogApi _api; final _searchController = TextEditingController(); bool _loading = true; String? _error; List _categories = []; List _brands = []; List _products = []; String? _selectedCategoryId; String? _selectedBrandId; @override void initState() { super.initState(); _api = CatalogApi(baseUrl: widget.apiBaseUrl); _loadInitial(); } @override void dispose() { _searchController.dispose(); super.dispose(); } Future _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; _brands = data[1] as List; _products = data[2] as List; }); } catch (e) { setState(() { _error = e.toString(); }); } finally { setState(() { _loading = false; }); } } Future _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 _openProduct(String productId) async { try { final detail = await _api.fetchProductDetail(productId); if (!mounted) { return; } await showModalBottomSheet( 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 categories; final List brands; final String? selectedCategoryId; final String? selectedBrandId; final ValueChanged onCategoryChanged; final ValueChanged 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( 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( 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> fetchCategories() async { final response = await http.get( Uri.parse('$baseUrl/categories?tree=false'), ); _ensureOk(response); final list = jsonDecode(response.body) as List; return list .cast>() .map( (item) => CategoryDto( id: item['id'].toString(), name: item['name'].toString(), ), ) .toList(); } Future> fetchBrands() async { final response = await http.get(Uri.parse('$baseUrl/brands')); _ensureOk(response); final list = jsonDecode(response.body) as List; return list .cast>() .map( (item) => BrandDto( id: item['id'].toString(), name: item['name'].toString(), ), ) .toList(); } Future> fetchProducts({ String? query, String? categoryId, String? brandId, }) async { final params = { '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; final items = data['items'] as List; return items .cast>() .map(ProductCardDto.fromJson) .toList(); } Future fetchProductDetail(String id) async { final response = await http.get(Uri.parse('$baseUrl/products/$id')); _ensureOk(response); return ProductDetailDto.fromJson( jsonDecode(response.body) as Map, ); } 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 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 _parseEnv(String source) { final entries = {}; 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 json) { final category = json['category'] as Map; final brand = json['brand'] as Map?; final price = json['price'] as Map; final stock = json['stock'] as Map; 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 images; final List attributes; factory ProductDetailDto.fromJson(Map json) { final stock = json['stock'] as Map?; final price = json['price'] as Map?; final attrs = (json['attributes'] as List? ?? []) .whereType>() .map(ProductAttributeDto.fromJson) .toList(); final images = (json['images'] as List? ?? []) .whereType>() .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 json) { return ProductAttributeDto( key: json['key']?.toString() ?? '', value: json['value']?.toString() ?? '', unit: json['unit']?.toString(), ); } }