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