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.
This commit is contained in:
580
lib/views/bureau_inventory_page.dart
Normal file
580
lib/views/bureau_inventory_page.dart
Normal file
@@ -0,0 +1,580 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user