Files
ERP-APP-INVENTORY/lib/views/bureau_inventory_page.dart
BACHIR SOULDI 5a483da01d Add full app source
Adds the Flutter inventory app source, cleaned up for a shared repo:
generic package name, no hardcoded ERP endpoints (moved to gitignored
local config), no dead auth code, no debug logging of session data.
2026-07-16 14:37:28 +01:00

581 lines
20 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:inventory_app/constants.dart';
import 'package:inventory_app/models/inventory_line.dart';
import 'package:inventory_app/service/axelor_client.dart';
import 'package:inventory_app/widgets/glass_widgets.dart';
class BureauInventoryPage extends StatefulWidget {
final int locationId;
final String locationName;
const BureauInventoryPage({
super.key,
required this.locationId,
required this.locationName,
});
@override
State<BureauInventoryPage> createState() => _BureauInventoryPageState();
}
class _BureauInventoryPageState extends State<BureauInventoryPage> {
Future<List<InventoryLine>?>? _future;
List<InventoryLine> _allLines = [];
final TextEditingController _searchController = TextEditingController();
String _query = '';
@override
void initState() {
super.initState();
_searchController.addListener(() {
setState(() => _query = _searchController.text.trim().toLowerCase());
});
_load();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _load() async {
final client = await AxelorClient.create();
setState(() {
_future = client.fetchInventoryLinesByLocation(widget.locationId).then((
lines,
) {
_allLines = lines ?? [];
return lines;
});
});
}
List<InventoryLine> get _filteredLines {
if (_query.isEmpty) return _allLines;
return _allLines.where((line) {
return line.productName.toLowerCase().contains(_query) ||
line.ticketId.toLowerCase().contains(_query) ||
(line.observation?.toLowerCase().contains(_query) ?? false) ||
(line.description?.toLowerCase().contains(_query) ?? false);
}).toList();
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
body: Stack(
children: [
const FancyBackground(),
SafeArea(
child: Column(
children: [
BackAppBar(
title: widget.locationName,
icon: Icons.business_rounded,
),
Expanded(
child: FutureBuilder<List<InventoryLine>?>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError || snapshot.data == null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.error_outline,
color: Colors.redAccent,
size: 48,
),
const SizedBox(height: 12),
Text(
'Impossible de charger les articles.',
style: TextStyle(color: Colors.redAccent),
),
const SizedBox(height: 12),
ElevatedButton.icon(
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
onPressed: _load,
),
],
),
);
}
if (_allLines.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.inventory_2_outlined,
size: 64,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
'Aucun article trouvé dans ce bureau.',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
final filtered = _filteredLines;
return Column(
children: [
_SearchBar(
controller: _searchController,
isDark: isDark,
total: _allLines.length,
shown: filtered.length,
),
Expanded(
child:
filtered.isEmpty
? Center(
child: Text(
'Aucun résultat pour "${_searchController.text}"',
style: TextStyle(
fontSize: 15,
color: Colors.grey.shade500,
),
textAlign: TextAlign.center,
),
)
: RefreshIndicator(
onRefresh: _load,
child: ListView.builder(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(
16,
4,
16,
100,
),
itemCount: filtered.length,
itemBuilder: (context, index) {
return _LineCard(
line: filtered[index],
isDark: isDark,
onTap:
() => _showDetails(
context,
filtered[index],
),
);
},
),
),
),
],
);
},
),
),
],
),
),
],
),
);
}
void _showDetails(BuildContext context, InventoryLine line) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final textColor = isDark ? Colors.white : Colors.blue.shade700;
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
barrierColor: Colors.black.withValues(alpha: 0.3),
isScrollControlled: true,
builder: (_) {
return Padding(
padding: const EdgeInsets.all(12),
child: GlassCard(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SheetHandle(Colors.blue),
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(
line.productName,
style: kTextFormFieldStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
color: Colors.blue.shade700,
),
),
),
const SizedBox(height: 12),
_detail('N° Ticket', line.ticketId, textColor),
if (line.description != null && line.description!.isNotEmpty)
_detail('État', line.description!, textColor),
if (line.observation != null && line.observation!.isNotEmpty)
_detail('N° Série', line.observation!, textColor),
if (line.firstCounting != null && line.firstCounting! > 0)
_detail(
'Comptage 1',
line.firstCounting!.toStringAsFixed(0),
textColor,
),
if (line.secondCounting != null && line.secondCounting! > 0)
_detail(
'Comptage 2',
line.secondCounting!.toStringAsFixed(0),
textColor,
),
if (line.thirdCounting != null && line.thirdCounting! > 0)
_detail(
'Comptage 3',
line.thirdCounting!.toStringAsFixed(0),
textColor,
),
if (line.firstCountingDate != null)
_detail(
'Date C1',
_formatDate(line.firstCountingDate!),
textColor,
),
if (line.firstCountingByUser != null)
_detail(
'Agent C1',
line.firstCountingByUser!['fullName'] ?? '-',
textColor,
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: const Icon(
Icons.close_rounded,
color: Colors.white,
),
label: const Text(
'Fermer',
style: TextStyle(color: Colors.white),
),
onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
),
],
),
),
),
);
},
);
}
Widget _detail(String label, String value, Color textColor) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 110,
child: Text(
label,
style: kTextFormFieldStyle(
fontWeight: FontWeight.w600,
color: textColor,
),
),
),
Expanded(
child: Text(
value,
style: kTextFormFieldStyle(
fontWeight: FontWeight.bold,
color: textColor,
),
),
),
],
),
);
}
String _formatDate(String iso) {
try {
final dt = DateTime.parse(iso).toLocal();
return '${dt.day.toString().padLeft(2, '0')}/${dt.month.toString().padLeft(2, '0')}/${dt.year}';
} catch (_) {
return iso;
}
}
}
// ── Search bar ───────────────────────────────────────────────────────────────
class _SearchBar extends StatelessWidget {
final TextEditingController controller;
final bool isDark;
final int total;
final int shown;
const _SearchBar({
required this.controller,
required this.isDark,
required this.total,
required this.shown,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: TextField(
controller: controller,
style: TextStyle(color: isDark ? Colors.white : Colors.black87),
decoration: InputDecoration(
hintText: 'Rechercher par nom, ticket, état...',
hintStyle: TextStyle(
color: isDark ? Colors.white38 : Colors.black38,
fontSize: 14,
),
prefixIcon: Icon(
Icons.search_rounded,
color: isDark ? Colors.white54 : Colors.black45,
),
suffixIcon:
controller.text.isNotEmpty
? IconButton(
icon: Icon(
Icons.close_rounded,
color: isDark ? Colors.white54 : Colors.black45,
),
onPressed: () => controller.clear(),
)
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
'$total articles',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white38 : Colors.black38,
),
),
),
filled: true,
fillColor:
isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.white.withValues(alpha: 0.75),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(vertical: 12),
),
),
);
}
}
// ── Card for each inventory line ─────────────────────────────────────────────
class _LineCard extends StatelessWidget {
final InventoryLine line;
final bool isDark;
final VoidCallback onTap;
const _LineCard({
required this.line,
required this.isDark,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final textColor = isDark ? Colors.white : Colors.blue.shade700;
final subColor = isDark ? Colors.white70 : Colors.blue.shade400;
final qty =
(line.thirdCounting ?? 0) > 0
? line.thirdCounting!
: (line.secondCounting ?? 0) > 0
? line.secondCounting!
: (line.firstCounting ?? 0);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: GlassCard(
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () {
HapticFeedback.selectionClick();
onTap();
},
child: Padding(
padding: const EdgeInsets.all(5),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.12),
shape: BoxShape.circle,
),
child: const Icon(
Icons.inventory_2_rounded,
color: Colors.blue,
size: 22,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
line.productName,
style: kTextFormFieldStyle(
fontWeight: FontWeight.bold,
color: textColor,
),
),
const SizedBox(height: 4),
if (line.ticketId.isNotEmpty)
Text(
'Ticket: ${line.ticketId}',
style: kTextFormFieldStyle(
fontSize: 12.0,
color: subColor,
),
),
if (line.description != null &&
line.description!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
'État: ${line.description}',
style: kTextFormFieldStyle(
fontSize: 12.0,
color: subColor,
),
),
),
if (line.observation != null &&
line.observation!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
'N° Série: ${line.observation}',
style: kTextFormFieldStyle(
fontSize: 12.0,
color: subColor,
),
),
),
const SizedBox(height: 6),
Wrap(
spacing: 6,
children: [
if ((line.firstCounting ?? 0) > 0)
_CountChip(
label:
'C1: ${line.firstCounting!.toStringAsFixed(0)}',
),
if ((line.secondCounting ?? 0) > 0)
_CountChip(
label:
'C2: ${line.secondCounting!.toStringAsFixed(0)}',
color: Colors.orange,
),
if ((line.thirdCounting ?? 0) > 0)
_CountChip(
label:
'C3: ${line.thirdCounting!.toStringAsFixed(0)}',
color: Colors.green,
),
],
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(
qty.toStringAsFixed(0),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
),
],
),
),
),
),
);
}
}
class _CountChip extends StatelessWidget {
final String label;
final Color color;
const _CountChip({required this.label, this.color = Colors.blue});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: color,
),
),
);
}
}