Files
docker-compose-projects/dashboard/lib/main.dart
2026-02-23 16:40:06 +03:00

721 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(),
);
}
}