Files
ERP-APP-INVENTORY/lib/home.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

615 lines
20 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:inventory_app/models/depot.dart';
import 'package:inventory_app/models/tracking_number.dart';
import 'package:inventory_app/views/qr_view.dart';
import 'package:inventory_app/controllers/home_controller.dart';
import 'package:inventory_app/service/product_image_updater.dart';
import 'package:inventory_app/views/bureau_inventory_page.dart';
import 'package:inventory_app/views/my_scans.dart';
import 'package:inventory_app/views/product_view.dart';
import 'package:inventory_app/widgets/glass_widgets.dart';
import 'package:quickalert/quickalert.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:inventory_app/utils.dart';
import 'controllers/user_controller.dart';
class Home extends StatefulWidget {
const Home({super.key});
@override
State<Home> createState() => _HomeState();
}
class _HomeState extends State<Home> {
late final HomeController controller;
final String _selectedState = 'Neuf';
@override
void initState() {
super.initState();
controller = Get.find<HomeController>(); // IMPORTANT: No new instance
}
@override
Widget build(BuildContext context) {
final primary = Colors.blue;
return Scaffold(
body: RefreshIndicator(
onRefresh: () async {
await controller.fetchLocations();
},
child: Stack(
children: [
const FancyBackground(),
/// Only the loading OR the content rebuild
Obx(() {
if (controller.isLoading.value) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Center(child: CircularProgressIndicator()),
SizedBox(height: 20.0),
OutlinedButton(
onPressed: () async {
await controller.fetchLocations();
},
child: Text("Rafraîchir si c'est trop long"),
),
],
);
}
return _buildContent(context, primary);
}),
],
),
),
floatingActionButton: FancyFAB(
primary: primary,
onTap: () => _showScannerSheet(context, primary),
),
drawer: FancyDrawer(
primary: primary,
onLogout: controller.logout,
onScanBureau: () => _scanAndShowBureauInventory(),
),
);
}
// ---------------- MAIN CONTENT -------------------
Widget _buildContent(BuildContext context, MaterialColor primary) {
return SafeArea(
child: Column(
children: [
GlassAppBar(
title: "Inventaire",
primary: primary,
onLogout: controller.logout,
onCamera:
controller.product.value != null
? () {
final updater = ProductImageUpdater(
baseUrl: Utils.url,
);
updater.updateProductPictureFlow(
context,
controller.product.value!,
);
}
: null,
),
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 6, 16, 100),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Counting
SectionHeader(
icon: Icons.tune_rounded,
title: "Type de comptage",
accent: primary,
),
GlassCard(
child: Obx(
() => Column(
children: [
TileRadio(
value: "comptage1",
groupValue: controller.selectedOption.value,
label: 'Comptage 1',
icon: Icons.filter_1,
onChanged:
(v) => controller.selectedOption.value = v!,
),
TileRadio(
value: "comptage3",
groupValue: controller.selectedOption.value,
label: 'Comptage Contrôle',
icon: Icons.shield_rounded,
onChanged:
(v) => controller.selectedOption.value = v!,
),
],
),
),
),
// Depot
SectionHeader(
icon: Icons.store_mall_directory_rounded,
title: "Zone",
accent: primary,
),
GlassCard(
child: Obx(
() => AppDropdown<Depot>(
value: controller.selectedDepot.value,
items: controller.depotList,
hint: "Choisir une zone",
labelBuilder: (d) => d.name ?? "",
onChanged: (d) => controller.selectedDepot.value = d,
),
),
),
// Product Info
SectionHeader(
icon: Icons.inventory_2_rounded,
title: "Information article",
accent: primary,
),
GlassCard(
child: Column(
children: [
AppTextField(
controller: controller.outputController,
label: "Code article",
icon: Icons.qr_code_2_rounded,
enabled: false,
),
const SizedBox(height: 12),
AppTextField(
controller: controller.labelController,
label: "Libellé article",
icon: Icons.label_rounded,
enabled: false,
),
const SizedBox(height: 12),
AppTextField(
controller: controller.unitController,
label: "Unité article",
icon: Icons.scale_rounded,
enabled: false,
),
const SizedBox(height: 16),
SubLabel("N° de suivi", primary),
const SizedBox(height: 8),
Obx(
() => AppDropdown<TrackingNumber>(
value: controller.selectedTrackingNumber.value,
items: controller.trackingNumbers,
hint: "Sélectionner un N° de suivi",
labelBuilder: (t) => t.trackingNumberSeq ?? "",
onChanged:
(t) =>
controller.selectedTrackingNumber.value = t,
),
),
SubLabel("Serial number ", primary),
FancyTextField(
icon: Icons.segment_rounded,
controller: controller.serialNumberTxt,
label: 'Serial number',
hint: 'Serial number',
),
SubLabel("Etat ", primary),
const SizedBox(height: 8),
AppDropdown<String>(
hint: 'Etat',
value: _selectedState,
items: const ['Ancien', 'Moyen', 'Neuf', 'Réformée'],
onChanged: (val) {
if (val != null) {
HapticFeedback.selectionClick();
setState(
() => controller.selectedState.value = val,
);
}
},
labelBuilder: (String t) => t,
),
],
),
),
const SizedBox(height: 16),
// Product image
Obx(() {
final product = controller.product.value;
if (product?.picture != null) {
return AppImage(
future: controller.fetchImageBytes(
product!.picture!.id!,
),
);
}
return const SizedBox.shrink();
}),
const SizedBox(height: 16),
// Quantity
SectionHeader(
icon: Icons.exposure_plus_1_rounded,
title: "Quantité",
accent: primary,
),
GlassCard(
child: AppTextField(
controller: controller.qtyController,
label: "Quantité",
type: TextInputType.number,
icon: Icons.onetwothree,
),
),
const SizedBox(height: 16),
// Depot Image
Obx(() {
final depot = controller.selectedDepot.value;
if (depot?.picture != null) {
return AppImage(
future: controller.fetchImageBytes(depot!.picture!.id!),
);
}
return const SizedBox.shrink();
}),
const SizedBox(height: 24),
_saveButton(primary),
],
),
),
),
],
),
);
}
// ---------------- SAVE BUTTON -------------------
Widget _saveButton(MaterialColor primary) {
return ElevatedButton.icon(
icon: const Icon(Icons.save, color: Colors.white),
label: const Text("Enregistrer", style: TextStyle(color: Colors.white)),
style: ElevatedButton.styleFrom(
backgroundColor: primary,
minimumSize: const Size.fromHeight(50),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
onPressed: () async {
if (controller.selectedDepot.value == null) {
QuickAlert.show(
context: context,
type: QuickAlertType.error,
title: "Veuillez selectionnez la zone d'abord",
text: "En cours...",
);
return;
}
print('************************');
print(controller.selectedTrackingNumber.value);
QuickAlert.show(
context: context,
type: QuickAlertType.loading,
title: "Enregistrement",
text: "En cours...",
);
HapticFeedback.mediumImpact();
await controller.save();
Navigator.pop(context);
QuickAlert.show(
context: context,
type: QuickAlertType.success,
title: "Succès",
text: "Transaction enregistrée",
);
},
);
}
// ---------------- SCANNER SHEET -------------------
void _showScannerSheet(BuildContext context, MaterialColor primary) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (_) {
return GlassBottomSheet(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SheetHandle(primary),
ListTile(
leading: const Icon(Icons.qr_code_scanner),
title: const Text("Scanner Article"),
onTap: () async {
Navigator.pop(context);
final r = await Get.to(() => const QRViewExample());
if (r is String) {
controller.handleScannedCode(r);
} else
Get.to(() => const CreateProductPage());
},
),
ListTile(
leading: const Icon(Icons.location_searching),
title: const Text("Scanner Bureau"),
onTap: () async {
Navigator.pop(context);
final r = await Get.to(() => const QRViewExample());
if (r is String) controller.handleLocationScan(r);
},
),
ListTile(
leading: const Icon(Icons.business_center_rounded, color: Colors.teal),
title: const Text("Voir Articles d'un Bureau"),
subtitle: const Text("Scanner le QR du bureau pour afficher ses articles"),
onTap: () async {
Navigator.pop(context);
await _scanAndShowBureauInventory();
},
),
],
),
);
},
);
}
// ---------------- BUREAU INVENTORY SCAN -------------------
Future<void> _scanAndShowBureauInventory() async {
final r = await Get.to(() => const QRViewExample());
if (r is! String) return;
Get.dialog(
const Center(child: CircularProgressIndicator()),
barrierDismissible: false,
);
final locationJson = await controller.client.fetchStockLocationByName(r);
Get.back(); // close loading spinner
if (locationJson == null) {
Get.snackbar(
'Erreur',
'Bureau introuvable. Vérifiez le QR code.',
backgroundColor: Colors.red,
colorText: Colors.white,
);
return;
}
final depot = Depot.fromJson(locationJson);
Get.to(
() => BureauInventoryPage(
locationId: depot.id!,
locationName: depot.name ?? r,
),
);
}
}
// -------------------------------------------------------------
// GLASS APP BAR
// -------------------------------------------------------------
class GlassAppBar extends StatelessWidget {
final String title;
final Color primary;
final VoidCallback onLogout;
final VoidCallback? onCamera;
const GlassAppBar({
super.key,
required this.title,
required this.primary,
required this.onLogout,
this.onCamera,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 12, 12),
child: GlassCard(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
Icon(Icons.inventory_2_rounded, color: primary),
const SizedBox(width: 10),
Text(
title,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: Colors.blue.shade700,
),
),
const Spacer(),
if (onCamera != null)
IconButton(
icon: const Icon(Icons.camera_alt_rounded),
onPressed: onCamera,
),
IconButton(
icon: const Icon(Icons.menu_rounded),
onPressed: () => Scaffold.of(context).openDrawer(),
),
IconButton(
icon: const Icon(Icons.logout_rounded),
onPressed: onLogout,
),
],
),
),
);
}
}
// -------------------------------------------------------------
// FANCY DRAWER (modern glass drawer)
// -------------------------------------------------------------
class FancyDrawer extends StatelessWidget {
final Color primary;
final VoidCallback onLogout;
final VoidCallback? onScanBureau;
const FancyDrawer({
super.key,
required this.primary,
required this.onLogout,
this.onScanBureau,
});
@override
Widget build(BuildContext context) {
final dark = Theme.of(context).brightness == Brightness.dark;
final userController = Get.find<UserController>();
final user = userController.user.value;
return Drawer(
backgroundColor:
dark ? Colors.black.withOpacity(.9) : Colors.white.withOpacity(.9),
child: Column(
children: [
DrawerHeader(
margin: EdgeInsets.zero,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [primary.withOpacity(.9), primary.withOpacity(.6)],
),
),
child: Row(
children: [
const CircleAvatar(
radius: 34,
backgroundImage: NetworkImage(
'https://i.pravatar.cc/150?img=3',
),
),
const SizedBox(width: 12),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user?["fullName"] ?? "Utilisateur",
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 4),
Text(
user?["email"] ?? "email inconnu",
style: TextStyle(color: Colors.white70),
),
],
),
],
),
),
// ---- MENU ----
_drawerTile(
icon: Icons.home_rounded,
label: "Accueil",
onTap: () => Navigator.pop(context),
),
user?["group"]?["code"] == 'INV_OFF'
? SizedBox()
: _drawerTile(
icon: Icons.add_box_rounded,
label: "Créer un produit",
onTap: () => Get.to(() => const CreateProductPage()),
),
_drawerTile(
icon: Icons.list_alt,
label: "Mes scans",
onTap: () => Get.to(() => MyScans()),
),
_drawerTile(
icon: Icons.business_center_rounded,
label: "Articles par Bureau",
onTap: () {
Navigator.pop(context);
onScanBureau?.call();
},
),
_drawerTile(
icon: Icons.report_problem_outlined,
label: "Signaler un problème",
onTap: () async => _launchTicketUrl(),
),
const Spacer(),
// ---- LOGOUT ----
_drawerTile(
icon: Icons.logout_rounded,
label: "Déconnexion",
onTap: onLogout,
),
const SizedBox(height: 12),
],
),
);
}
Widget _drawerTile({
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
return ListTile(leading: Icon(icon), title: Text(label), onTap: onTap);
}
Future<void> _launchTicketUrl() async {
final url = Uri.parse(Utils.ticketUrl);
if (!await launchUrl(url)) {
throw Exception("Cannot open ticket system");
}
}
}