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.
581 lines
20 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|