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:
614
lib/home.dart
Normal file
614
lib/home.dart
Normal file
@@ -0,0 +1,614 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user