This commit is contained in:
2026-02-23 16:40:06 +03:00
commit a51d12bc89
169 changed files with 7973 additions and 0 deletions

720
dashboard/lib/main.dart Normal file
View 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(),
);
}
}