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:
BACHIR SOULDI
2026-07-16 14:37:28 +01:00
parent 37b0762921
commit 5a483da01d
110 changed files with 8081 additions and 0 deletions

37
lib/auth.dart Normal file
View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:get_storage/get_storage.dart';
import 'package:inventory_app/views/splash_screen.dart';
import 'package:inventory_app/my_app.dart';
import 'package:inventory_app/views/login_view.dart';
import 'package:inventory_app/service/axelor_client.dart';
class Auth extends StatelessWidget {
const Auth({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final box = GetStorage();
String? sessionId = box.read('sessionId');
if (sessionId == null) {
return const LoginView();
}
// Session exists on disk — validate it with the server before granting access
return FutureBuilder<bool>(
future: AxelorClient.create().then((client) => client.isSessionValid()),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const SplashScreen();
} else if (snapshot.hasError || !(snapshot.data ?? false)) {
// Session is expired or invalid — clear it and force re-login
box.remove('sessionId');
box.remove('username');
return const LoginView();
} else {
return MyApp();
}
},
);
}
}

35
lib/constants.dart Normal file
View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
const int kInventoryId = 111062;
TextStyle kLoginTitleStyle(Size size, {Color color = Colors.black}) => GoogleFonts.ubuntu(
fontSize: size.height * 0.060,
fontWeight: FontWeight.bold,
color: color
);
TextStyle kLoginSubtitleStyle(Size size) =>
GoogleFonts.ubuntu(fontSize: size.height * 0.030);
TextStyle kLoginTermsAndPrivacyStyle(Size size) =>
GoogleFonts.ubuntu(fontSize: 15, color: Colors.grey, height: 1.5);
TextStyle kHaveAnAccountStyle(Size size) =>
GoogleFonts.ubuntu(fontSize: size.height * 0.022, color: Colors.black);
TextStyle kLoginOrSignUpTextStyle(Size size) => GoogleFonts.ubuntu(
fontSize: size.height * 0.022,
fontWeight: FontWeight.w500,
color: Colors.deepPurpleAccent,
);
TextStyle kTextFormFieldStyle({
Color color = Colors.black54,
fontSize,
fontWeight,
}) => GoogleFonts.ubuntu(
color: color,
fontSize: fontSize,
fontWeight: fontWeight,
);

View File

@@ -0,0 +1,262 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:inventory_app/constants.dart';
import 'package:inventory_app/models/famille_produit.dart';
import 'package:inventory_app/models/depot.dart';
import 'package:inventory_app/models/inventory_line.dart';
import 'package:inventory_app/models/product.dart';
import 'package:inventory_app/models/tracking_number.dart';
import 'package:inventory_app/service/axelor_client.dart';
import 'package:inventory_app/views/login_view.dart';
enum InventoryLineState { newLine, existingLine }
class HomeController extends GetxController {
final outputController = TextEditingController();
final labelController = TextEditingController();
final qtyController = TextEditingController(text: "1");
final unitController = TextEditingController();
final serialNumberTxt = TextEditingController();
var product = Rxn<Product>();
var selectedOption = 'comptage1'.obs;
var depotList = <Depot>[].obs;
var selectedDepot = Rxn<Depot>();
var trackingNumbers = <TrackingNumber>[].obs;
var selectedTrackingNumber = Rxn<TrackingNumber>();
var isLoading = false.obs;
var imageUrl = ''.obs;
var familleProduits = <FamilleProduit>[].obs;
var selectedFamilleProduit = Rxn<FamilleProduit>();
var sousFamilleProduits = <FamilleProduit>[].obs;
var selectedSousFamilleProduit = Rxn<FamilleProduit>();
var zoneCode = ''.obs;
var selectedState = 'Moyen'.obs;
late AxelorClient client;
@override
void onInit() {
super.onInit();
_initClient();
}
@override
void onReady() {
super.onReady();
_handleArgs();
}
Future<void> _initClient() async {
isLoading(true);
client = await AxelorClient.create();
depotList.value = (await client.fetchLocations()) ?? [];
isLoading(false);
}
Future<void> fetchLocations() async {
isLoading(true);
client = await AxelorClient.create();
depotList.value = (await client.fetchLocations()) ?? [];
isLoading(false);
}
Future<void> _loadProduct(String code) async {
try {
isLoading(true);
Product? p = await client.fetchProductByCode(code);
if (p == null) {
Get.snackbar('Erreur', 'Produit introuvable', backgroundColor: Colors.red);
return;
}
product.value = p;
labelController.text = p.name ?? '';
unitController.text = p.unit?.name ?? '';
imageUrl.value = p.imageUrl ?? '';
trackingNumbers.value =
await client.fetchTrackingNumberByProduct(p.id!) ?? [];
Get.snackbar(
'Produit trouvé',
'Produit: ${p.name}',
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
);
} catch (e) {
Get.snackbar(
'Erreur',
'Impossible de charger le produit',
backgroundColor: Colors.red,
);
} finally {
isLoading(false);
}
}
Future<void> _handleArgs() async {
final args = Get.arguments;
if (args is String) {
outputController.text = args;
await _loadProduct(args);
}
}
void handleScannedCode(String code) async {
outputController.text = code;
trackingNumbers.clear();
selectedTrackingNumber.value = null;
await _loadProduct(code);
}
void handleProductScan(String code) => handleScannedCode(code);
void handleLocationScan(String name) async {
var finalStr = "";
if (name.isNotEmpty) {
finalStr = name.split(":")[0].trim();
}
zoneCode.value = finalStr;
final locationJson = await client.fetchStockLocationByName(name);
if (locationJson == null) {
Get.snackbar(
'Erreur',
'Zone introuvable (Veuillez rafraichir la page et ressayer)',
backgroundColor: Colors.red,
);
return;
}
final depot = Depot.fromJson(locationJson);
selectedDepot.value = depot;
Get.snackbar('Zone trouvée', 'Nom: ${depot.name}', backgroundColor: Colors.blue);
}
Future<void> save() async {
if (outputController.text.isEmpty ||
qtyController.text.isEmpty ||
product.value == null) {
Get.snackbar('Erreur', 'Produit ou quantité invalide', backgroundColor: Colors.red);
return;
}
final now = DateTime.now().toIso8601String();
final qty = double.tryParse(qtyController.text) ?? 1;
final user = await client.fetchUserProfile();
final selected = selectedOption.value;
double first = 0, second = 0, third = 0;
String? firstDate, secondDate, thirdDate;
Map<String, dynamic>? firstUser, secondUser, thirdUser;
switch (selected) {
case "comptage1":
first = qty;
firstDate = now;
firstUser = user;
break;
case "comptage2":
second = qty;
secondDate = now;
secondUser = user;
break;
case "comptage3":
third = qty;
thirdDate = now;
thirdUser = user;
break;
}
final line = InventoryLine(
inventoryId: kInventoryId,
productId: product.value!.id!,
productName: product.value!.name ?? '',
currentQty: qty,
realQty: qty,
unitId: product.value!.unit?.id ?? 2,
description: selectedState.value,
observation: serialNumberTxt.text,
ticketId: "TICKET-${DateTime.now().millisecondsSinceEpoch}",
rack: "A1",
trackingNumberId: selectedTrackingNumber.value?.id,
countingTypeSelect: 1,
stockLocationId: selectedDepot.value?.id ?? 4,
firstCounting: first,
secondCounting: second,
thirdCounting: third,
firstCountingDate: firstDate,
secondCountingDate: secondDate,
thirdCountingDate: thirdDate,
firstCountingByUser: firstUser,
secondCountingByUser: secondUser,
thirdCountingByUser: thirdUser,
);
await client.saveInventoryLine(inventoryLine: line);
_resetForm();
Get.snackbar('Succès', 'Ligne enregistrée avec succès', backgroundColor: Colors.green);
}
void _resetForm() {
outputController.clear();
labelController.clear();
product.value = null;
trackingNumbers.clear();
selectedTrackingNumber.value = null;
serialNumberTxt.text = "";
}
Future<Uint8List?> fetchImageBytes(int imageId) => client.fetchImageBytes(imageId);
Future<void> fetchFamilleProduits() async {
try {
isLoading(true);
final list = await client.getFamilleProduit();
familleProduits.value = list ?? [];
} catch (e) {
familleProduits.clear();
} finally {
isLoading(false);
}
}
Future<void> fetchSousFamilleProduits(int parentId) async {
try {
isLoading(true);
final list = await client.getSousFamilleProduit(parentId);
sousFamilleProduits.value = list ?? [];
} catch (e) {
sousFamilleProduits.clear();
} finally {
isLoading(false);
}
}
void onFamilleSelected(FamilleProduit? famille) async {
selectedFamilleProduit.value = famille;
selectedSousFamilleProduit.value = null;
if (famille?.id != null) {
await fetchSousFamilleProduits(famille!.id!);
} else {
sousFamilleProduits.clear();
}
}
void logout() async {
if (await client.logout()) Get.off(LoginView());
}
}

View File

@@ -0,0 +1,9 @@
import 'package:get/get.dart';
class SimpleUIController extends GetxController {
RxBool isObscure = true.obs;
isObscureActive() {
isObscure.value = !isObscure.value;
}
}

View File

@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
class ThemeController extends GetxController {
final _box = GetStorage();
final _key = 'isDarkMode';
RxBool isDarkMode = false.obs;
@override
void onInit() {
super.onInit();
isDarkMode.value = _loadThemeFromBox();
Get.changeThemeMode(isDarkMode.value ? ThemeMode.dark : ThemeMode.light);
}
bool _loadThemeFromBox() => _box.read(_key) ?? false;
void _saveThemeToBox(bool value) => _box.write(_key, value);
ThemeMode get theme => isDarkMode.value ? ThemeMode.dark : ThemeMode.light;
void toggleTheme() {
isDarkMode.value = !isDarkMode.value;
_saveThemeToBox(isDarkMode.value);
Get.changeThemeMode(isDarkMode.value ? ThemeMode.dark : ThemeMode.light);
}
}

View File

@@ -0,0 +1,25 @@
import 'package:get/get.dart';
import 'package:inventory_app/service/axelor_client.dart';
class UserController extends GetxController {
var isLoading = false.obs;
var user = Rx<Map<String, dynamic>?>(null);
Future<void> loadUser() async {
try {
isLoading.value = true;
final client = await AxelorClient.create();
final profile = await client.fetchUserProfile();
user.value = profile;
} catch (e) {
print("❌ Failed to load user: $e");
} finally {
isLoading.value = false;
}
}
// Optional: clear user when logging out
void clearUser() {
user.value = null;
}
}

614
lib/home.dart Normal file
View 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");
}
}
}

63
lib/main.dart Normal file
View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:inventory_app/auth.dart';
import 'package:inventory_app/controllers/theme_controller.dart';
import 'package:inventory_app/controllers/home_controller.dart';
import 'controllers/user_controller.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await GetStorage.init();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
debugDisableShadows = true;
final themeController = Get.put(ThemeController()); // inject globally
Get.lazyPut(() => HomeController());
Get.put(UserController()); // Register it ONE TIME globally
runApp(MyApp(themeController: themeController));
}
class MyApp extends StatelessWidget {
final ThemeController themeController;
const MyApp({super.key, required this.themeController});
@override
Widget build(BuildContext context) {
return GetMaterialApp(
debugShowCheckedModeBanner: false,
title: 'Inventory App',
themeMode: themeController.theme,
theme: ThemeData(
brightness: Brightness.light,
primarySwatch: Colors.blue,
scaffoldBackgroundColor: const Color(0xFFF6F8FC),
cardColor: Colors.white,
appBarTheme: const AppBarTheme(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
elevation: 0,
titleTextStyle: TextStyle(color: Colors.black),
),
),
darkTheme: ThemeData(
brightness: Brightness.dark,
primarySwatch: Colors.blue,
scaffoldBackgroundColor: const Color(0xFF121212),
cardColor: const Color(0xFF1E1E1E),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF1E1E1E),
foregroundColor: Colors.white,
elevation: 0,
titleTextStyle: TextStyle(color: Colors.white),
),
),
home: const Auth(),
);
}
}

110
lib/models/depot.dart Normal file
View File

@@ -0,0 +1,110 @@
import 'package:inventory_app/utils.dart';
class Depot {
int? id;
String? name;
MetaFile? picture;
Depot({this.id, this.name, this.picture});
Depot.fromJson(Map<dynamic, dynamic> json) {
id = json['id'];
name = json['name'];
picture =
json['picture'] != null ? MetaFile.fromJson(json['picture']) : null;
}
Map<dynamic, dynamic> toJson() {
final Map<dynamic, dynamic> data = {};
data['id'] = this.id;
data['name'] = this.name;
if (picture != null) {
data['picture'] = picture!.toJson();
}
return data;
}
/// 👇 Get full image URL (you can customize baseUrl to be from `AxelorClient.baseUrl`)
String? get imageUrl {
if (picture?.id != null) {
return '${Utils.url}/ws/rest/com.axelor.meta.db.MetaFile/${picture!.id}/content/download';
}
return null;
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Depot && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
}
class Company {
String? code;
String? name;
int? id;
int? version;
Company({this.code, this.name, this.id, this.version});
Company.fromJson(Map<String, dynamic> json) {
code = json['code'];
name = json['name'];
id = json['id'];
version = json['$version'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = new Map<String, dynamic>();
data['code'] = this.code;
data['name'] = this.name;
data['id'] = this.id;
data['$version'] = this.version;
return data;
}
}
class UpdatedBy {
String? code;
String? fullName;
int? id;
int? version;
UpdatedBy({this.code, this.fullName, this.id, this.version});
UpdatedBy.fromJson(Map<String, dynamic> json) {
code = json['code'];
fullName = json['fullName'];
id = json['id'];
version = json['$version'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = new Map<String, dynamic>();
data['code'] = this.code;
data['fullName'] = this.fullName;
data['id'] = this.id;
data['$version'] = this.version;
return data;
}
}
class MetaFile {
String? fileName;
int? id;
int? version;
MetaFile({this.fileName, this.id, this.version});
MetaFile.fromJson(Map<String, dynamic> json) {
fileName = json['fileName'];
id = json['id'];
version = json['$version'];
}
Map<String, dynamic> toJson() {
return {'fileName': fileName, 'id': id, '\$version': version};
}
}

View File

@@ -0,0 +1,31 @@
class FamilleProduit {
int? id;
String? name;
FamilleProduit({this.id, this.name});
FamilleProduit.fromJson(Map<dynamic, dynamic> json) {
id = json['id'];
name = json['name'];
}
Map<dynamic, dynamic> toJson() {
final Map<dynamic, dynamic> data = {};
data['id'] = id;
data['name'] = name;
return data;
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is FamilleProduit &&
runtimeType == other.runtimeType &&
id == other.id;
@override
String toString() => name ?? 'Unknown';
@override
int get hashCode => id.hashCode;
}

View File

@@ -0,0 +1,142 @@
import 'package:inventory_app/models/product.dart';
class InventoryLine {
int? id;
int? version;
final int inventoryId;
final int productId;
final String productName;
final double currentQty;
final double realQty;
final int unitId;
final String? description;
final String? observation;
final String ticketId;
final String? rack;
final int? trackingNumberId;
final int countingTypeSelect;
final int stockLocationId;
double? firstCounting;
double? secondCounting;
double? thirdCounting;
String? firstCountingDate;
String? secondCountingDate;
String? thirdCountingDate;
Map<String, dynamic>? firstCountingByUser;
Map<String, dynamic>? secondCountingByUser;
Map<String, dynamic>? thirdCountingByUser;
Product? product;
InventoryLine({
this.id,
this.version,
required this.inventoryId,
required this.productId,
required this.productName,
required this.currentQty,
required this.realQty,
required this.unitId,
this.description,
this.observation,
required this.ticketId,
this.rack,
this.trackingNumberId,
required this.countingTypeSelect,
required this.stockLocationId,
required this.firstCounting,
required this.secondCounting,
required this.thirdCounting,
this.firstCountingDate,
this.secondCountingDate,
this.thirdCountingDate,
this.firstCountingByUser,
this.secondCountingByUser,
this.thirdCountingByUser,
this.product
});
factory InventoryLine.fromJson(Map<String, dynamic> json) {
double parseDouble(dynamic value) {
if (value == null) return 0.0;
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value) ?? 0.0;
return 0.0;
}
return InventoryLine(
id: json['id'],
version: json['version'],
inventoryId: json['inventory']?['id'] ?? 0,
productId: json['product']?['id'] ?? 0,
productName: json['productName'] ?? '',
currentQty: parseDouble(json['currentQty']),
realQty: parseDouble(json['realQty']),
unitId: json['unit']?['id'] ?? 0,
description: json['description'],
observation: json['observation'],
ticketId: json['ticketId'] ?? '',
rack: json['rack'],
trackingNumberId: json['trackingNumber']?['id'],
countingTypeSelect: json['countingTypeSelect'] ?? 0,
stockLocationId: json['stockLocation']?['id'] ?? 0,
firstCounting: parseDouble(json['firstCounting']),
secondCounting: parseDouble(json['secondCounting']),
thirdCounting: parseDouble(json['thirdCounting']),
firstCountingDate: json['firstCountingDate'],
secondCountingDate: json['secondCountingDate'],
thirdCountingDate: json['thirdCountingDate'],
firstCountingByUser: json['firstCountingByUser'] != null
? Map<String, dynamic>.from(json['firstCountingByUser'])
: null,
secondCountingByUser: json['secondCountingByUser'] != null
? Map<String, dynamic>.from(json['secondCountingByUser'])
: null,
thirdCountingByUser: json['thirdCountingByUser'] != null
? Map<String, dynamic>.from(json['thirdCountingByUser'])
: null,
product: json['product'] != null ? Product.fromJson(json['product']) : null
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> map = {
if (id != null) "id": id,
if (version != null) "version": version,
"inventory": {"id": inventoryId},
"product": {"id": productId},
"productName": productName,
"currentQty": currentQty,
"realQty": realQty,
"unit": {"id": unitId},
"description": description,
"observation": observation,
"ticketId": ticketId,
"rack": rack,
if (trackingNumberId != null) "trackingNumber": {"id": trackingNumberId},
"countingTypeSelect": countingTypeSelect,
"stockLocation": {"id": stockLocationId},
"firstCounting": firstCounting,
"secondCounting": secondCounting,
"thirdCounting": thirdCounting,
};
if (firstCountingDate != null) map['firstCountingDate'] = firstCountingDate;
if (secondCountingDate != null)
map['secondCountingDate'] = secondCountingDate;
if (thirdCountingDate != null) map['thirdCountingDate'] = thirdCountingDate;
if (firstCountingByUser != null)
map['firstCountingByUser'] = firstCountingByUser;
if (secondCountingByUser != null)
map['secondCountingByUser'] = secondCountingByUser;
if (thirdCountingByUser != null)
map['thirdCountingByUser'] = thirdCountingByUser;
return map;
}
@override
String toString() {
return 'InventoryLine{id: $id, version: $version, inventoryId: $inventoryId, productId: $productId, productName: $productName, currentQty: $currentQty, realQty: $realQty, unitId: $unitId, description: $description, observation: $observation, ticketId: $ticketId, rack: $rack, trackingNumberId: $trackingNumberId, countingTypeSelect: $countingTypeSelect, stockLocationId: $stockLocationId, firstCounting: $firstCounting, secondCounting: $secondCounting, thirdCounting: $thirdCounting, firstCountingDate: $firstCountingDate, secondCountingDate: $secondCountingDate, thirdCountingDate: $thirdCountingDate, firstCountingByUser: $firstCountingByUser, secondCountingByUser: $secondCountingByUser, thirdCountingByUser: $thirdCountingByUser}';
}
}

139
lib/models/product.dart Normal file
View File

@@ -0,0 +1,139 @@
import 'package:inventory_app/utils.dart';
class Product {
Unit? unit;
Unit? purchaseUnit;
String? code;
String? name;
int? id;
int? version;
String? productTypeSelect; // must be int, not String
int? familleProduit; // will be sent as object {id: X}
int? sousFamilleProduit; // same
String? internalDescription; // custom logic (not Axelor default field)
String? serialNumber;
String? description;
MetaFile? picture;
String? procurementMethodSelect = 'buy';
Product({
this.unit,
this.purchaseUnit,
this.code,
this.name,
this.id,
this.version,
this.productTypeSelect, // use 0,1,2
this.familleProduit,
this.sousFamilleProduit,
this.internalDescription,
this.serialNumber,
this.description,
this.picture,
this.procurementMethodSelect,
});
Product.fromJson(Map<String, dynamic> json) {
unit = json['unit'] != null ? Unit.fromJson(json['unit']) : null;
purchaseUnit =
json['purchaseUnit'] != null
? Unit.fromJson(json['purchaseUnit'])
: null;
code = json['code'];
name = json['name'];
id = json['id'];
version = json['version'];
productTypeSelect = json['productTypeSelect'];
familleProduit = json['familleProduit']?['id']; // Axelor wraps it in object
sousFamilleProduit = json['sousFamilleProduit']?['id'];
internalDescription = json['internalDescription'];
serialNumber = json['serialNumber'];
description = json['description'];
picture =
json['picture'] != null ? MetaFile.fromJson(json['picture']) : null;
procurementMethodSelect = json['procurementMethodSelect'] ?? 'buy';
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = {};
if (unit != null) {
data['unit'] = unit!.toJson();
}
if (purchaseUnit != null) {
data['purchaseUnit'] = purchaseUnit!.toJson();
}
if (code != null) data['code'] = code;
if (name != null) data['name'] = name;
if (id != null) data['id'] = id;
if (version != null) data['version'] = version;
if (productTypeSelect != null) {
data['productTypeSelect'] = productTypeSelect; // ✅ must be int
}
// ✅ Axelor expects {"id": X}, NOT raw int
if (familleProduit != null) {
data['familleProduit'] = {'id': familleProduit};
}
if (sousFamilleProduit != null) {
data['sousFamilleProduit'] = {'id': sousFamilleProduit};
}
data['internalDescription'] = internalDescription;
data['serialNumber'] = serialNumber;
data['description'] = description;
data['procurementMethodSelect'] = procurementMethodSelect;
return data;
}
@override
String toString() {
return 'Product{unit: $unit, code: $code, name: $name, id: $id, version: $version, productTypeSelect: $productTypeSelect, familleProduit: $familleProduit, sousFamilleProduit: $sousFamilleProduit, internalDescription: $internalDescription, serialNumber: $serialNumber, description: $description}';
}
/// 👇 Get full image URL (you can customize baseUrl to be from `AxelorClient.baseUrl`)
String? get imageUrl {
if (picture?.id != null) {
return '${Utils.url}/ws/rest/com.axelor.meta.db.MetaFile/${picture!.id}/content/download';
}
return null;
}
}
class Unit {
String? name;
int? id;
int? version;
Unit({this.name, this.id, this.version});
Unit.fromJson(Map<String, dynamic> json) {
name = json['name'];
id = json['id'];
version = json['version'];
}
Map<String, dynamic> toJson() {
return {'name': name, 'id': id, 'version': version};
}
}
class MetaFile {
String? fileName;
int? id;
int? version;
MetaFile({this.fileName, this.id, this.version});
MetaFile.fromJson(Map<String, dynamic> json) {
fileName = json['fileName'];
id = json['id'];
version = json['$version'];
}
Map<String, dynamic> toJson() {
return {'fileName': fileName, 'id': id, '\$version': version};
}
}

52
lib/models/profile.dart Normal file
View File

@@ -0,0 +1,52 @@
class Profile {
String? validId;
String? glpiCurrenttime;
int? glpiUseMode;
int? glpiID;
String? glpiisIdsVisible;
String? glpifriendlyname;
String? glpiname;
String? glpirealname;
String? glpifirstname;
int? glpidefaultEntity;
Profile(
{this.validId,
this.glpiCurrenttime,
this.glpiUseMode,
this.glpiID,
this.glpiisIdsVisible,
this.glpifriendlyname,
this.glpiname,
this.glpirealname,
this.glpifirstname,
this.glpidefaultEntity});
Profile.fromJson(Map<String, dynamic> json) {
validId = json['valid_id'];
glpiCurrenttime = json['glpi_currenttime'];
glpiUseMode = json['glpi_use_mode'];
glpiID = json['glpiID'];
glpiisIdsVisible = json['glpiis_ids_visible'];
glpifriendlyname = json['glpifriendlyname'];
glpiname = json['glpiname'];
glpirealname = json['glpirealname'];
glpifirstname = json['glpifirstname'];
glpidefaultEntity = json['glpidefault_entity'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['valid_id'] = validId;
data['glpi_currenttime'] = glpiCurrenttime;
data['glpi_use_mode'] = glpiUseMode;
data['glpiID'] = glpiID;
data['glpiis_ids_visible'] = glpiisIdsVisible;
data['glpifriendlyname'] = glpifriendlyname;
data['glpiname'] = glpiname;
data['glpirealname'] = glpirealname;
data['glpifirstname'] = glpifirstname;
data['glpidefault_entity'] = glpidefaultEntity;
return data;
}
}

View File

@@ -0,0 +1,35 @@
import 'package:inventory_app/models/product.dart';
class TrackingNumber {
int? id;
int? version;
String? trackingNumberSeq;
String? perishableExpirationDate;
Product? product;
TrackingNumber({
this.id,
this.version,
this.trackingNumberSeq,
this.perishableExpirationDate,
this.product,
});
TrackingNumber.fromJson(Map<String, dynamic> json) {
id = json['id'];
version = json['version'];
trackingNumberSeq = json['trackingNumberSeq'];
perishableExpirationDate = json['perishableExpirationDate'];
product = json['product'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = new Map<String, dynamic>();
data['id'] = this.id;
data['version'] = this.version;
data['trackingNumberSeq'] = this.trackingNumberSeq;
data['perishableExpirationDate'] = this.perishableExpirationDate;
data['product'] = this.product;
return data;
}
}

52
lib/my_app.dart Normal file
View File

@@ -0,0 +1,52 @@
import 'package:curved_navigation_bar/curved_navigation_bar.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:inventory_app/home.dart';
import 'package:inventory_app/controllers/home_controller.dart';
import 'package:inventory_app/views/panoramic.dart';
import 'package:inventory_app/views/profile.dart';
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
int _selectedIndex = 0;
late final HomeController controller;
@override
void initState() {
super.initState();
Get.lazyPut(() => HomeController());
}
final List<Widget> _screens = [
Home(), // Your main inventory/home screen
ProfilePage(), // Profile page
Panoramic(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(index: _selectedIndex, children: _screens),
bottomNavigationBar: CurvedNavigationBar(
backgroundColor: Colors.transparent,
color: Colors.blue,
onTap: (index) {
setState(() {
_selectedIndex = index;
});
},
items: const [
Icon(Icons.home, color: Colors.white),
Icon(Icons.person, color: Colors.white),
Icon(Icons.image, color: Colors.white),
],
),
);
}
}

View File

@@ -0,0 +1,640 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:get/get.dart';
import 'package:cookie_jar/cookie_jar.dart';
import 'package:dio/dio.dart';
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
import 'package:get_storage/get_storage.dart';
import 'package:inventory_app/constants.dart';
import 'package:inventory_app/models/famille_produit.dart';
import 'package:inventory_app/models/depot.dart';
import 'package:inventory_app/models/inventory_line.dart';
import 'package:inventory_app/models/product.dart';
import 'package:inventory_app/models/tracking_number.dart';
import 'package:inventory_app/utils.dart';
import 'package:path_provider/path_provider.dart';
class AxelorClient {
late Dio dio;
late PersistCookieJar cookieJar;
static final GetStorage _storage = GetStorage();
AxelorClient._internal();
static Future<AxelorClient> create() async {
final client = AxelorClient._internal();
Directory appDocDir = await getApplicationDocumentsDirectory();
String cookiePath = '${appDocDir.path}/cookies';
client.cookieJar = PersistCookieJar(storage: FileStorage(cookiePath));
client.dio = Dio(BaseOptions(baseUrl: Utils.url));
client.dio.interceptors.add(CookieManager(client.cookieJar));
// Set saved session ID if available
final storedSessionId = _storage.read('sessionId');
if (storedSessionId != null) {
client.dio.options.headers['Cookie'] = 'JSESSIONID=$storedSessionId';
}
return client;
}
Future<bool> login(String username, String password) async {
try {
final response = await dio.post(
'${Utils.url}/login.jsp',
data: {'username': username, 'password': password},
options: Options(
contentType: Headers.formUrlEncodedContentType,
followRedirects: false,
// Make sure redirect is not automatically followed
validateStatus: (status) => status != null && status < 500,
headers: {
'User-Agent': 'Mozilla/5.0 (Android; Flutter App)',
'Accept': '*/*',
},
),
);
// Handle 302 redirect: cookies should be in headers
final cookies = response.headers['set-cookie'];
if (cookies != null) {
// Parse JSESSIONID
String? sessionId;
for (var cookie in cookies) {
if (cookie.contains('JSESSIONID')) {
sessionId = cookie.split(';').first.split('=').last;
break;
}
}
if (sessionId != null && sessionId.isNotEmpty) {
// Set the cookie so the validation request is authenticated
dio.options.headers['Cookie'] = 'JSESSIONID=$sessionId';
// Verify the session is actually authenticated (not just an anonymous session)
final valid = await isSessionValid();
if (!valid) {
dio.options.headers.remove('Cookie');
return false;
}
await _storage.write('sessionId', sessionId);
await _storage.write('username', username);
return true;
}
}
return false;
} catch (e) {}
return false;
}
Future<bool> logout() async {
try {
final response = await dio.get('/logout');
if (response.statusCode == 200) {
await _storage.remove('sessionId');
await _storage.remove('username');
await cookieJar.deleteAll();
return true;
}
} catch (e) {}
return false;
}
Future<List<Depot>?> fetchLocations() async {
try {
final response = await dio.post(
'/ws/rest/com.axelor.apps.stock.db.StockLocation/search',
data: {
"offset": 0,
"limit": 800,
"data": {
"criteria": [
{
"fieldName": "usableOnImmobilisation",
"operator": "=",
"value": true,
},
],
},
},
);
if (response.statusCode == 200 && response.data['data'] != null) {
List<Depot> depots = [];
for (var depot in response.data["data"]) {
depots.add(Depot.fromJson(depot));
}
return depots;
} else {
return [];
}
} catch (e) {}
return null;
}
Future<void> saveInventoryLine({required InventoryLine inventoryLine}) async {
final String url =
'${Utils.url}/ws/rest/com.axelor.apps.stock.db.InventoryLine';
String domain;
if (inventoryLine.trackingNumberId == null) {
domain =
"self.inventory.id = :inventoryId AND self.product.id = :product AND self.trackingNumber IS NULL";
} else {
domain =
"self.inventory.id = :inventoryId AND self.product.id = :product AND self.trackingNumber.id = :trackingNumberId";
}
try {
// 1. Check if InventoryLine already exists
final searchResponse = await dio.post(
'$url/search',
data: {
"data": {
"_domain": domain,
"_domainContext": {
"inventoryId": inventoryLine.inventoryId,
"product": inventoryLine.productId,
"trackingNumberId": inventoryLine.trackingNumberId,
},
"_archived": false,
},
},
);
if (searchResponse.statusCode == 200 &&
searchResponse.data['status'] == 0) {
final existing = searchResponse.data['data'];
if (existing != null && existing.isNotEmpty) {
final existingLine = existing[0];
inventoryLine.id = existingLine['id'];
inventoryLine.version = existingLine['version'];
inventoryLine.firstCounting =
inventoryLine.firstCounting != 0
? inventoryLine.firstCounting
: double.tryParse(
existingLine['firstCounting']?.toString() ?? '',
) ??
0.0;
inventoryLine.secondCounting =
inventoryLine.secondCounting != 0
? inventoryLine.secondCounting
: double.tryParse(
existingLine['secondCounting']?.toString() ?? '',
) ??
0.0;
inventoryLine.thirdCounting =
inventoryLine.thirdCounting != 0
? inventoryLine.thirdCounting
: double.tryParse(
existingLine['thirdCounting']?.toString() ?? '',
) ??
0.0;
inventoryLine.firstCountingDate =
inventoryLine.firstCountingDate ??
existingLine['firstCountingDate'];
inventoryLine.secondCountingDate =
inventoryLine.secondCountingDate ??
existingLine['secondCountingDate'];
inventoryLine.thirdCountingDate =
inventoryLine.thirdCountingDate ??
existingLine['thirdCountingDate'];
inventoryLine.firstCountingByUser =
inventoryLine.firstCountingByUser ??
existingLine['firstCountingByUser'];
inventoryLine.secondCountingByUser =
inventoryLine.secondCountingByUser ??
existingLine['secondCountingByUser'];
inventoryLine.thirdCountingByUser =
inventoryLine.thirdCountingByUser ??
existingLine['thirdCountingByUser'];
} else {}
}
// 2. Create or update the InventoryLine
final saveResponse = await dio.post(
url,
data: jsonEncode({"data": inventoryLine.toJson()}),
);
if (saveResponse.statusCode == 200) {
final result = saveResponse.data;
if (result['status'] == 0) {
} else {}
} else {}
} on DioException catch (e) {
if (e.response != null) {}
} catch (e) {}
}
Future<Product?> fetchProductByCode(String code) async {
try {
final response = await dio.post(
'${Utils.url}/ws/rest/com.axelor.apps.base.db.Product/search',
data: {
"offset": 0,
"limit": 1,
"sortBy": ["code", "name", "unit"],
"data": {
"criteria": [
{"fieldName": "code", "operator": "=", "value": code},
],
},
},
);
if (response.statusCode == 200 && response.data['status'] == 0) {
final list = response.data["data"];
if (list != null && list.isNotEmpty) {
return Product.fromJson(list[0]);
}
}
} catch (e) {}
return null;
}
Future<List<TrackingNumber>?> fetchTrackingNumberByProduct(int id) async {
try {
final response = await dio.post(
'${Utils.url}/ws/rest/com.axelor.apps.stock.db.TrackingNumber/search',
data: {
"offset": 0,
"limit": 100,
"fields": ["trackingNumberSeq", "perishableExpirationDate"],
"data": {
"criteria": [
{"fieldName": "product.id", "operator": "=", "value": id},
],
},
},
);
if (response.statusCode == 200 && response.data['data'] != null) {
List<TrackingNumber> trackingNumbers = [];
for (var trackingNumber in response.data["data"]) {
trackingNumbers.add(TrackingNumber.fromJson(trackingNumber));
}
return trackingNumbers;
} else {
return [];
}
} catch (e) {}
return null;
}
Future<List<InventoryLine>?> fetchInventoryLinesByLocation(
int locationId,
) async {
try {
final response = await dio.post(
'${Utils.url}/ws/rest/com.axelor.apps.stock.db.InventoryLine/search',
data: {
"data": {
"_domain":
"self.stockLocation.id = :locationId and self.inventory.id = :inventoryId",
"_domainContext": {
"locationId": locationId,
"inventoryId": kInventoryId,
},
"_archived": false,
},
},
);
if (response.statusCode == 200 && response.data['data'] != null) {
List<InventoryLine> lines = [];
for (var line in response.data["data"]) {
lines.add(InventoryLine.fromJson(line));
}
return lines;
}
return [];
} catch (e) {
return null;
}
}
Future<bool> isSessionValid() async {
try {
final res = await dio.get(
'/ws/rest/com.axelor.apps.stock.db.StockLocation?offset=0&limit=1',
data: {
"offset": 0,
"limit": 1,
"data": {
"criteria": [
{
"fieldName": "usableOnImmobilisation",
"operator": "=",
"value": true,
},
],
},
},
);
return res.statusCode == 200 && res.data['status'] == 0;
} catch (_) {
return false;
}
}
Future<Map<String, dynamic>?> fetchUserProfile() async {
final username = _storage.read('username');
if (username == null) {
return null;
}
try {
final response = await dio.post(
'/ws/rest/com.axelor.auth.db.User/search',
data: {
"offset": 0,
"limit": 1,
"data": {
"criteria": [
{"fieldName": "code", "operator": "=", "value": username},
],
},
},
);
if (response.statusCode == 200 &&
response.data['status'] == 0 &&
response.data['data'] != null &&
response.data['data'].isNotEmpty) {
final user = response.data['data'][0];
return user;
} else {}
} catch (e) {}
return null;
}
Future<Map<String, dynamic>?> fetchStockLocationByName(String name) async {
try {
final response = await dio.post(
'${Utils.url}/ws/rest/com.axelor.apps.stock.db.StockLocation/search',
data: {
"offset": 0,
"limit": 10000,
"data": {
"criteria": [
{"fieldName": "name", "operator": "=", "value": "$name"},
],
},
},
);
if (response.statusCode == 200 &&
response.data['status'] == 0 &&
response.data['data'] != null &&
response.data['data'].isNotEmpty) {
final location = response.data['data'][0];
return location;
} else {}
} catch (e) {}
return null;
}
Future<Uint8List?> fetchImageBytes(int imageId) async {
final url =
'${Utils.url}/ws/rest/com.axelor.meta.db.MetaFile/$imageId/content/download';
try {
final response = await dio.get(
url,
options: Options(responseType: ResponseType.bytes),
);
final contentType = response.headers.value('content-type');
if (contentType == null ||
(!contentType.startsWith('image/') &&
contentType != 'application/octet-stream')) {
return null;
}
return Uint8List.fromList(response.data);
} catch (e) {
return null;
}
}
Future<Product?> createProducts(Product product, Depot? depot) async {
final String url = '${Utils.url}/ws/rest/com.axelor.apps.base.db.Product';
try {
// Create or update the InventoryLine
final saveResponse = await dio.post(
url,
data: jsonEncode({"data": product.toJson()}),
);
if (saveResponse.statusCode == 200) {
final result = saveResponse.data;
if (result['status'] == 0) {
Product newProduct = Product.fromJson(result["data"][0]);
TrackingNumber? trackingNumber = await createTrackingNumber(
trackingNumber: TrackingNumber(
trackingNumberSeq: product.internalDescription,
product: Product(id: newProduct.id),
),
);
// Create initial inventory line
await createInitialInventoryLine(newProduct, trackingNumber, depot);
return newProduct;
} else {}
} else {}
} on DioException catch (e) {
if (e.response != null) {}
} catch (e) {}
return null;
}
Future<List<FamilleProduit>?> getFamilleProduit() async {
final response = await dio.post(
'${Utils.url}/ws/rest/com.axelor.apps.base.db.FamilleProduit/search',
data: {
"data": {
"criteria": [
{
"operator": "and",
"criteria": [
{"fieldName": "niveau", "operator": "=", "value": 0},
{
"fieldName": "usableOnImmobilisation",
"operator": "=",
"value": true,
},
],
},
],
},
},
);
if (response.statusCode == 200 && response.data['data'] != null) {
List<FamilleProduit> familleProduits = [];
for (var familleProduit in response.data["data"]) {
familleProduits.add(FamilleProduit.fromJson(familleProduit));
}
return familleProduits;
} else {
return [];
}
return null;
}
Future<List<FamilleProduit>?> getSousFamilleProduit(parentId) async {
final response = await dio.post(
'${Utils.url}/ws/rest/com.axelor.apps.base.db.FamilleProduit/search',
data: {
"data": {
"_domain": "self.parente.id = :parente",
"_domainContext": {"parente": parentId},
"_archived": false,
},
},
);
if (response.statusCode == 200 && response.data['data'] != null) {
List<FamilleProduit> sousfamilleProduits = [];
for (var sousfamilleProduit in response.data["data"]) {
sousfamilleProduits.add(FamilleProduit.fromJson(sousfamilleProduit));
}
return sousfamilleProduits;
} else {
return [];
}
}
Future<List<InventoryLine>?> getMyInventoryLines() async {
final user = await fetchUserProfile();
final response = await dio.post(
'${Utils.url}/ws/rest/com.axelor.apps.stock.db.InventoryLine/search',
data: {
"data": {
"_domain":
"self.createdBy.id = :createdBy and self.inventory.id = :inventoryId",
"_domainContext": {
"inventoryId": kInventoryId,
"createdBy": user!['id'],
},
"_archived": false,
},
},
);
if (response.statusCode == 200 && response.data['data'] != null) {
List<InventoryLine>? inventoryLines = [];
for (var inventoryLine in response.data["data"]) {
inventoryLines.add(InventoryLine.fromJson(inventoryLine));
}
return inventoryLines;
} else {
return [];
}
return null;
}
Future<TrackingNumber?> createTrackingNumber({
required TrackingNumber trackingNumber,
}) async {
final String url =
'${Utils.url}/ws/rest/com.axelor.apps.stock.db.TrackingNumber';
try {
// 1. Check if product already exists
final searchResponse = await dio.post(
'${Utils.url}/ws/rest/com.axelor.apps.stock.db.TrackingNumber/search',
data: {
"data": {
"_domain":
"self.trackingNumberSeq = :trackingNumberSeq and self.product.id = :productId",
"_domainContext": {
"trackingNumberSeq": trackingNumber.trackingNumberSeq,
"productId": trackingNumber.product,
},
"_archived": false,
},
},
);
if (searchResponse.statusCode == 200 &&
searchResponse.data['status'] == 0) {
final existing = searchResponse.data['data'];
if (existing != null && existing.isNotEmpty) {
Get.snackbar('Erreur', "N° de serie existe deja");
return null;
} else {}
}
// 2. Create or update the InventoryLine
final saveResponse = await dio.post(
url,
data: jsonEncode({"data": trackingNumber.toJson()}),
);
if (saveResponse.statusCode == 200) {
final result = saveResponse.data;
if (result['status'] == 0) {
return TrackingNumber.fromJson(result["data"][0]);
} else {}
} else {}
} on DioException catch (e) {
if (e.response != null) {}
} catch (e) {}
return null;
}
Future<void> createInitialInventoryLine(
Product product,
TrackingNumber? trackingNumber,
Depot? depot,
) async {
final user = await fetchUserProfile();
final line = InventoryLine(
inventoryId: kInventoryId,
productId: product.id!,
productName: product.name ?? '',
currentQty: 1,
realQty: 1,
description: "Initial entry from mobile",
unitId: product.unit?.id ?? 4,
countingTypeSelect: 1,
firstCounting: 1,
firstCountingDate: DateTime.now().toIso8601String(),
firstCountingByUser: user,
stockLocationId: depot!.id!,
ticketId: product.internalDescription ?? '',
secondCounting: null,
thirdCounting: null,
trackingNumberId: trackingNumber?.id,
);
await saveInventoryLine(inventoryLine: line);
}
}

View File

@@ -0,0 +1,155 @@
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart' as d;
import 'package:flutter/material.dart';
import 'package:get_storage/get_storage.dart';
import 'package:image_picker/image_picker.dart';
import 'package:get/get.dart';
import 'package:inventory_app/models/product.dart';
import 'package:mime/mime.dart';
class ProductImageUpdater {
final d.Dio dio = d.Dio();
final String baseUrl;
final box = GetStorage();
ProductImageUpdater({required this.baseUrl});
final ImagePicker _picker = ImagePicker();
/// Step 1: Pick or take a photo
Future<File?> pickPhoto({bool fromCamera = true}) async {
final XFile? photo = await _picker.pickImage(
source: fromCamera ? ImageSource.camera : ImageSource.gallery,
);
return photo != null ? File(photo.path) : null;
}
Future<int?> uploadMetaFile(File file) async {
final sessionId = box.read('sessionId');
if (sessionId == null) {
Get.snackbar("Erreur", "Session ID introuvable",backgroundColor: Colors.red);
return null;
}
final fileName = file.path.split('/').last;
final fileSize = await file.length();
if (fileSize > 5 * 1024 * 1024) {
Get.snackbar("Erreur", "Fichier trop volumineux. Max: 5MB",backgroundColor: Colors.red);
return null;
}
final mimeType = lookupMimeType(file.path) ?? 'application/octet-stream';
try {
final response = await dio.post(
'$baseUrl/ws/files/upload',
data: file.openRead(), // ✅ Send binary stream
options: d.Options(
headers: {
'Cookie': 'JSESSIONID=$sessionId',
'Content-Type': 'application/octet-stream',
'X-File-Name': fileName,
'X-File-Size': fileSize.toString(),
'X-File-Type': mimeType,
'X-File-Offset': '0',
},
responseType: d.ResponseType.json,
),
);
print('📦 StatusCode: ${response.statusCode}');
print('📦 Response: ${response.data}');
if (response.statusCode == 200 && response != null) {
final int id = response.data['id'];
print('✅ File uploaded, MetaFile ID: $id');
return id;
} else {
print('❌ Upload failed: ${response.data}');
}
} on d.DioException catch (e) {
print('❌ DioException: ${e.message}');
if (e.response != null) {
print('📄 Response data: ${e.response?.data}');
}
} catch (e) {
print('❌ Unexpected error: $e');
}
return null;
}
/// Step 3: Update the product with the image ID
Future<bool> updateProductImage({
required Product product,
required int imageId,
}) async {
final sessionId = box.read('sessionId');
if (sessionId == null) {
Get.snackbar("Erreur", "Session ID introuvable",backgroundColor: Colors.red);
return false;
}
final payload = {
"data": {
"id": product.id,
"version": product.version,
"picture": {"id": imageId},
},
};
print('✅ payload $payload');
try {
final response = await dio.post(
'$baseUrl/ws/rest/com.axelor.apps.base.db.Product/${product.id}',
data: jsonEncode(payload),
options: d.Options(
headers: {
'Cookie': 'JSESSIONID=$sessionId',
'Content-Type': 'application/json',
},
),
);
if (response.statusCode == 200 && response.data['status'] == 0) {
print('✅ Product image updated');
return true;
} else {
print('❌ Update failed: ${response.data}');
}
} catch (e) {
print('❌ d.Dio error: $e');
}
return false;
}
/// Step 4: Combine all actions in one flow
Future<void> updateProductPictureFlow(
BuildContext context,
Product product,
) async {
File? file = await pickPhoto();
if (file == null) {
Get.snackbar("Annulé", "Aucune image sélectionnée",backgroundColor: Colors.red);
return;
}
final imageId = await uploadMetaFile(file);
print('✅ imageId $imageId');
if (imageId == null) {
Get.snackbar("Erreur", "Upload échoué",backgroundColor: Colors.red);
return;
}
final success = await updateProductImage(
product: product,
imageId: imageId,
);
if (success) {
Get.snackbar("Succès", "Image du produit mise à jour",backgroundColor: Colors.green);
}
}
}

6
lib/utils.example.dart Normal file
View File

@@ -0,0 +1,6 @@
// Copy this file to utils.dart and fill in your actual endpoints.
// utils.dart is gitignored so real endpoints never get committed.
class Utils {
static const String url = 'https://your-erp-host.example.com/prod';
static const String ticketUrl = 'https://your-ticket-system.example.com/ticket.form.php';
}

View 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,
),
),
);
}
}

439
lib/views/login_view.dart Normal file
View File

@@ -0,0 +1,439 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:inventory_app/models/depot.dart';
import 'package:inventory_app/my_app.dart';
import 'package:inventory_app/service/axelor_client.dart';
import 'package:lottie/lottie.dart';
import 'package:quickalert/quickalert.dart';
import 'package:inventory_app/home.dart';
import '../constants.dart';
import '../controllers/simple_ui_controller.dart';
import 'package:inventory_app/controllers/theme_controller.dart';
class LoginView extends StatefulWidget {
const LoginView({super.key});
@override
State<LoginView> createState() => _LoginViewState();
}
class _LoginViewState extends State<LoginView> {
TextEditingController nameController = TextEditingController(text: "");
TextEditingController passwordController = TextEditingController(text: "");
final _formKey = GlobalKey<FormState>();
bool _loading = false;
late AxelorClient client;
bool _clientInitialized = false;
SimpleUIController simpleUIController = Get.put(SimpleUIController());
@override
void initState() {
super.initState();
_initClient();
}
Future<void> _initClient() async {
client = await AxelorClient.create();
setState(() => _clientInitialized = true);
}
@override
Widget build(BuildContext context) {
final themeController = Get.find<ThemeController>();
final isDark = themeController.isDarkMode.value;
final size = MediaQuery.of(context).size;
return Scaffold(
resizeToAvoidBottomInset: true,
body: Stack(
children: [
const _FancyBackground(), // 🧊 Adaptive gradient background
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: _GlassCard(
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// 🌊 Animated wave header
Lottie.asset(
'assets/wave.json',
height: size.height * 0.25,
repeat: true,
),
const SizedBox(height: 20),
Text(
"Se connecter",
style: kLoginTitleStyle(size * 0.80).copyWith(
color:
isDark
? Colors.white.withOpacity(0.95)
: Colors.blue.shade700,
),
),
const SizedBox(height: 8),
Text(
"Bienvenue",
style: kLoginSubtitleStyle(size).copyWith(
color:
isDark
? Colors.white
: Colors.black.withOpacity(0.7),
),
),
const SizedBox(height: 28),
// 👤 Username
TextFormField(
controller: nameController,
style: kTextFormFieldStyle(
color:
isDark
? Colors.white.withOpacity(0.9)
: Colors.black87,
),
decoration: _inputDecoration(
hint: 'Nom d\'utilisateur',
icon: Icons.person,
isDark: isDark,
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Entrer un nom d\'utilisateur';
} else if (value.length < 4) {
return 'Au moins 4 caractères';
} else if (value.length > 20) {
return 'Maximum 20 caractères';
}
return null;
},
),
const SizedBox(height: 16),
// 🔒 Password
Obx(
() => TextFormField(
controller: passwordController,
obscureText: simpleUIController.isObscure.value,
style: kTextFormFieldStyle(
color:
isDark
? Colors.white.withOpacity(0.9)
: Colors.black87,
),
decoration: _inputDecoration(
hint: 'Mot de passe',
icon: Icons.lock_open_rounded,
isDark: isDark,
suffix: IconButton(
icon: Icon(
simpleUIController.isObscure.value
? Icons.visibility
: Icons.visibility_off,
color: isDark ? Colors.white70 : Colors.black54,
),
onPressed: simpleUIController.isObscureActive,
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Entrer un mot de passe';
} else if (value.length < 6) {
return 'Au moins 6 caractères';
}
return null;
},
),
),
const SizedBox(height: 14),
Text(
"Contactez votre administrateur si vous avez oublié votre mot de passe",
style: kLoginTermsAndPrivacyStyle(size).copyWith(
color:
isDark
? Colors.white54
: Colors.black.withOpacity(0.5),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// 🚪 Login button
SizedBox(
width: double.infinity,
height: 55,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor:
isDark ? Colors.blue.shade400 : Colors.blue,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
shadowColor: Colors.blue.withOpacity(0.4),
elevation: 8,
),
onPressed:
_loading
? null
: () async {
if (_formKey.currentState!.validate()) {
setState(() => _loading = true);
if (!_clientInitialized) {
QuickAlert.show(
context: context,
type: QuickAlertType.error,
title: 'Erreur',
text:
'Client non initialisé. Veuillez réessayer.',
);
setState(() => _loading = false);
return;
}
final loggedIn = await client.login(
nameController.text.trim(),
passwordController.text.trim(),
);
if (loggedIn) {
// final locations =
// await client.fetchLocations();
Get.off(
() => const MyApp(),
// arguments: locations,
);
} else {
QuickAlert.show(
context: context,
type: QuickAlertType.error,
title: 'Erreur',
text:
'Nom d\'utilisateur ou mot de passe incorrect',
);
}
setState(() => _loading = false);
}
},
child:
_loading
? const Center(
child: CircularProgressIndicator(
color: Colors.white,
),
)
: const Text(
'Se connecter',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: 16),
// 🌗 Toggle theme
Obx(() {
final isDark = themeController.isDarkMode.value;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isDark
? Icons.dark_mode_rounded
: Icons.light_mode_rounded,
color:
isDark
? Colors.white70
: Colors.blue.shade700,
),
Switch(
value: isDark,
onChanged: (v) {
themeController.toggleTheme();
setState(() {});
},
activeColor: Colors.blue,
),
Text(
isDark ? 'Mode sombre' : 'Mode clair',
style: kTextFormFieldStyle(
color: isDark ? Colors.white70 : Colors.black87,
),
),
],
);
}),
],
),
),
),
),
),
],
),
);
}
InputDecoration _inputDecoration({
required String hint,
required IconData icon,
bool isDark = false,
Widget? suffix,
}) {
return InputDecoration(
prefixIcon: Icon(icon, color: isDark ? Colors.white70 : Colors.black54),
suffixIcon: suffix,
hintText: hint,
hintStyle: TextStyle(color: isDark ? Colors.white54 : Colors.black45),
filled: true,
fillColor:
isDark
? Colors.white.withOpacity(0.08)
: Colors.white.withOpacity(0.7),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(15),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
);
}
}
/// Glass card reused
class _GlassCard extends StatelessWidget {
final Widget child;
const _GlassCard({required this.child});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors:
isDark
? [
Colors.white.withOpacity(0.06),
Colors.white.withOpacity(0.03),
]
: [
Colors.white.withOpacity(0.4),
Colors.white.withOpacity(0.2),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
border: Border.all(
color:
isDark
? Colors.white.withOpacity(0.08)
: Colors.white.withOpacity(0.5),
),
boxShadow: [
BoxShadow(
color:
isDark
? Colors.black.withOpacity(0.4)
: Colors.blueGrey.withOpacity(0.15),
blurRadius: 18,
offset: const Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
child: Padding(padding: const EdgeInsets.all(20), child: child),
),
),
);
}
}
/// Gradient background reused
class _FancyBackground extends StatelessWidget {
const _FancyBackground();
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors:
isDark
? [
const Color(0xFF0A0A0A),
const Color(0xFF121212),
const Color(0xFF1E1E1E),
]
: [
const Color(0xFFEEF2FF),
const Color(0xFFE0F2FE),
const Color(0xFFE6FFFA),
],
),
),
child: Stack(
children: [
Positioned(
top: -60,
right: -30,
child: _Blob(
color:
isDark
? Colors.blue.withOpacity(0.1)
: Colors.blue.withOpacity(0.18),
size: 180,
),
),
Positioned(
bottom: -40,
left: -30,
child: _Blob(
color:
isDark
? Colors.cyanAccent.withOpacity(0.08)
: Colors.cyan.withOpacity(0.16),
size: 160,
),
),
],
),
);
}
}
class _Blob extends StatelessWidget {
final Color color;
final double size;
const _Blob({required this.color, required this.size});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: color, blurRadius: 60, spreadRadius: 30)],
),
);
}
}

264
lib/views/my_scans.dart Normal file
View File

@@ -0,0 +1,264 @@
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 MyScans extends StatefulWidget {
const MyScans({Key? key}) : super(key: key);
@override
State<MyScans> createState() => _MyScansState();
}
class _MyScansState extends State<MyScans> {
Future<List<InventoryLine>?>? _futureInventoryLines;
@override
void initState() {
super.initState();
_loadInventoryLines();
}
Future<void> _loadInventoryLines() async {
final client = await AxelorClient.create();
setState(() {
_futureInventoryLines = client.getMyInventoryLines();
});
}
@override
Widget build(BuildContext context) {
final MaterialColor primary = Colors.blue;
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
body: Stack(
children: [
const FancyBackground(),
SafeArea(
child: Column(
children: [
BackAppBar(title: '📦 Mes Scans', icon: Icons.inventory_2_rounded),
Expanded(
child: FutureBuilder<List<InventoryLine>?>(
future: _futureInventoryLines,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Text(
'❌ Erreur: ${snapshot.error}',
style: const TextStyle(color: Colors.redAccent),
),
);
}
final lines = snapshot.data ?? [];
if (lines.isEmpty) {
return const Center(
child: Text(
'Aucun scan trouvé.',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
);
}
return RefreshIndicator(
onRefresh: _loadInventoryLines,
child: ListView.builder(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
itemCount: lines.length,
itemBuilder: (context, index) {
final line = lines[index];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: GlassCard(
padding: const EdgeInsets.all(14),
child: ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.inventory_2_rounded,
color: Colors.blue,
),
),
title: Text(
"${line.product!.code!}-${line.productName}",
style: kTextFormFieldStyle(
fontWeight: FontWeight.bold,
color:
isDark
? Colors.white
: primary.shade700,
),
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 6),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
"Stock ID: ${line.id}",
style: kTextFormFieldStyle(
fontSize: 13.0,
color:
isDark
? Colors.white
: primary.shade700,
),
),
Text(
"Comptage: ${line.countingTypeSelect}",
style: kTextFormFieldStyle(
fontSize: 13.0,
color:
isDark
? Colors.white
: primary.shade700,
),
),
Text(
"Quantité Réelle: ${line.realQty}",
style: kTextFormFieldStyle(
fontSize: 13.0,
color:
isDark
? Colors.white
: primary.shade700,
),
),
if (line.observation != null &&
line.observation!.isNotEmpty)
Text(
"Observation: ${line.observation}",
style: kTextFormFieldStyle(
fontSize: 13.0,
color:
isDark
? Colors.white
: primary.shade700,
),
),
],
),
),
onTap: () {
HapticFeedback.selectionClick();
_showLineDetails(context, line);
},
),
),
);
},
),
);
},
),
),
],
),
),
],
),
);
}
void _showLineDetails(BuildContext context, InventoryLine line) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
barrierColor: Colors.black.withValues(alpha: 0.3),
builder: (_) {
return GlassBottomSheet(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SheetHandle(Colors.blue),
Text(
line.productName,
style: kTextFormFieldStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
color: Colors.blue.shade700,
),
),
SizedBox(height: 8),
_lineDetail("ID", line.id?.toString() ?? "-", context),
_lineDetail("Inventaire", "${line.inventoryId}", context),
_lineDetail(
"Type de comptage",
"${line.countingTypeSelect}",
context,
),
if (line.firstCountingDate != null)
_lineDetail("1er comptage", line.firstCountingDate!, context),
if (line.secondCountingDate != null)
_lineDetail("2ème comptage", line.secondCountingDate!, context),
if (line.thirdCountingDate != null)
_lineDetail("3ème comptage", line.thirdCountingDate!, context),
SizedBox(height: 14),
Align(
alignment: Alignment.centerRight,
child: ElevatedButton.icon(
icon: Icon(Icons.close_rounded, color: Colors.white),
label: Text("Fermer"),
onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
],
),
);
},
);
}
Widget _lineDetail(String label, String value, BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final MaterialColor primary = Colors.blue;
return Padding(
padding: EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: kTextFormFieldStyle(
fontWeight: FontWeight.w600,
color: isDark ? Colors.white : primary.shade700,
),
),
Text(
value,
style: kTextFormFieldStyle(
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : primary.shade700,
),
),
],
),
);
}
}

89
lib/views/panoramic.dart Normal file
View File

@@ -0,0 +1,89 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:inventory_app/constants.dart';
import 'package:inventory_app/controllers/home_controller.dart';
import 'package:panorama_viewer/panorama_viewer.dart';
import 'package:get/get.dart';
class Panoramic extends StatefulWidget {
const Panoramic({super.key});
@override
State<Panoramic> createState() => _PanoramicState();
}
class _PanoramicState extends State<Panoramic> {
@override
Widget build(BuildContext context) {
final controller = Get.put(HomeController());
Size size = MediaQuery.of(context).size;
return Stack(
children: [
Obx(() {
final depot = controller.selectedDepot.value;
if (depot?.picture != null) {
final pictureId = depot!.picture;
return FutureBuilder<Uint8List?>(
future: controller.fetchImageBytes(pictureId!.id!),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(
child: const Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
),
);
} else if (snapshot.hasData) {
return Padding(
padding: const EdgeInsets.all(2.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: PanoramaViewer(
child: Image.memory(
snapshot.data!,
),
),
),
);
} else {
return const Padding(
padding: EdgeInsets.all(12.0),
child: Text('❌ Failed to load depot image'),
);
}
},
);
} else {
return const SizedBox.shrink(); // No image to show
}
}),
// PanoramaViewer(child: Image.asset("assets/desk.jpg")),
Positioned(
top: 50,
left: 10,
child: Container(
width: size.width - 20,
height: 100,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
topRight: Radius.circular(20),
bottomLeft: Radius.circular(20),
),
),
child: Obx(() {
return Center(
child: Text( controller.selectedDepot.value != null ?
controller.selectedDepot.value!.name.toString() : "Loading .....",
style: kLoginTitleStyle(Size(size.width, size.height * 0.3),color: Colors.blue),
),
);
}),
),
),
],
);
}
}

579
lib/views/product_view.dart Normal file
View File

@@ -0,0 +1,579 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:inventory_app/models/famille_produit.dart';
import 'package:inventory_app/models/product.dart';
import 'package:inventory_app/service/axelor_client.dart';
import 'package:inventory_app/controllers/home_controller.dart';
import 'package:inventory_app/service/product_image_updater.dart';
import 'package:inventory_app/widgets/glass_widgets.dart';
import 'package:quickalert/quickalert.dart';
import 'package:inventory_app/utils.dart';
class CreateProductPage extends StatefulWidget {
const CreateProductPage({super.key});
@override
State<CreateProductPage> createState() => _CreateProductPageState();
}
class _CreateProductPageState extends State<CreateProductPage>
with SingleTickerProviderStateMixin {
// ----- Controllers -----
final _formKey = GlobalKey<FormState>();
final _nameCtrl = TextEditingController();
final _serialCtrl = TextEditingController();
final _obsCtrl = TextEditingController();
// ----- State -----
final MaterialColor primary = Colors.blue;
String _selectedState = 'Neuf';
bool _saving = false;
// Success animation controller
late final AnimationController _animCtrl;
late final Animation<double> _scale;
late AxelorClient client;
late final HomeController controller;
Product? _createdProduct;
Future<List<FamilleProduit>?> getFamilleProduit() async {
client = await AxelorClient.create();
return client.getFamilleProduit();
}
@override
void initState() {
super.initState();
_animCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 420),
);
_scale = CurvedAnimation(parent: _animCtrl, curve: Curves.easeOutBack);
controller = Get.find<HomeController>();
WidgetsBinding.instance.addPostFrameCallback((_) {
controller.fetchFamilleProduits();
});
}
@override
void dispose() {
_nameCtrl.dispose();
_serialCtrl.dispose();
_obsCtrl.dispose();
_animCtrl.dispose();
super.dispose();
}
// ----- UI -----
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
const FancyBackground(),
SafeArea(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
child: Form(
key: _formKey,
child: Column(
children: [
BackAppBar(
title: 'Créer un article',
icon: Icons.inventory_2_rounded,
),
SectionHeader(
icon: Icons.inventory_2_rounded,
title: 'Informations de base',
accent: primary,
),
GlassCard(
child: Column(
children: [
FancyTextField(
controller: _nameCtrl,
label: "Nom de l'article",
hint: 'Ex: HP LaserJet Pro M404',
icon: Icons.badge_rounded,
validator:
(v) =>
v == null || v.trim().isEmpty
? 'Product name is required'
: null,
),
const SizedBox(height: 12),
// Famille Produit
Obx(() {
if (controller.isLoading.value &&
controller.familleProduits.isEmpty) {
return const Center(
child: CircularProgressIndicator(),
);
}
return _FancyDropdown<FamilleProduit>(
label: "Famille d'article",
value: controller.selectedFamilleProduit.value,
icon: Icons.category_rounded,
items: controller.familleProduits,
onChanged: controller.onFamilleSelected,
validator:
(v) =>
v == null
? 'Sélectionnez une famille'
: null,
);
}),
const SizedBox(height: 12),
Obx(() {
if (controller.isLoading.value &&
controller.sousFamilleProduits.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(8.0),
child: CircularProgressIndicator(),
),
);
}
final items = controller.sousFamilleProduits;
return _FancyDropdown<FamilleProduit>(
label: "Sous-Famille d'article",
value:
controller.selectedSousFamilleProduit.value,
icon: Icons.subdirectory_arrow_right_rounded,
items: items,
onChanged:
items.isEmpty
? null
: (val) =>
controller
.selectedSousFamilleProduit
.value = val,
validator:
(v) =>
v == null
? 'Sélectionnez une sous-famille'
: null,
);
}),
const SizedBox(height: 12),
_FancyDropdown<String>(
label: 'Etat',
value: _selectedState,
icon: Icons.fact_check_rounded,
items: const [
'Ancien',
'Moyen',
'Neuf',
'Réformée',
],
onChanged: (val) {
if (val != null) {
HapticFeedback.selectionClick();
setState(() => _selectedState = val);
}
},
),
],
),
),
const SizedBox(height: 16),
SectionHeader(
icon: Icons.confirmation_number_rounded,
title: 'Identification',
accent: primary,
),
GlassCard(
child: Column(
children: [
FancyTextField(
controller: _serialCtrl,
label: 'N° de serie',
hint: 'e.g. SN-ABC-123456',
icon: Icons.numbers_rounded,
),
const SizedBox(height: 12),
FancyTextField(
controller: _obsCtrl,
label: 'Observation',
hint: 'Notes supplémentaires (optionnel)',
icon: Icons.notes_rounded,
maxLines: 4,
),
],
),
),
const SizedBox(height: 20),
// Buttons Row
Row(
children: [
Expanded(
child: _PrimaryButton(
text: _saving ? 'Enregistrement...' : 'Sauvegarder',
icon: Icons.save_rounded,
primary: primary,
onPressed: _saving ? null : _onSave,
),
),
const SizedBox(width: 12),
_CameraButton(primary: primary, onTap: _onTakeImage),
],
),
],
),
),
),
),
if (_saving) const _SavingOverlay(),
],
),
);
}
// ----- Actions -----
Future<void> _onSave() async {
HapticFeedback.vibrate();
client = await AxelorClient.create();
if (!mounted) return;
if (!_formKey.currentState!.validate()) {
QuickAlert.show(
context: context,
type: QuickAlertType.error,
title: 'Validation',
text: 'Veuillez remplir tous les champs obligatoires.',
);
return;
}
if (controller.selectedFamilleProduit.value?.id == null ||
controller.selectedSousFamilleProduit.value?.id == null) {
QuickAlert.show(
context: context,
type: QuickAlertType.error,
title: 'Missing',
text: 'Sélectionnez la famille et la sous-famille.',
);
return;
}
setState(() => _saving = true);
try {
final product = Product(
name: _nameCtrl.text.trim(),
familleProduit: controller.selectedFamilleProduit.value?.id,
sousFamilleProduit: controller.selectedSousFamilleProduit.value?.id,
productTypeSelect: 'storable',
internalDescription: _serialCtrl.text.trim(),
description: _obsCtrl.text.trim(),
unit: Unit(id: 4),
purchaseUnit: Unit(id: 4),
procurementMethodSelect: 'buy',
);
_createdProduct = await client.createProducts(
product,
controller.selectedDepot.value,
);
if (!mounted) return;
if (_createdProduct != null) {
QuickAlert.show(
context: context,
type: QuickAlertType.success,
title: 'Succès',
text:
"L'article ${_nameCtrl.text.trim()} a été créé avec succès !.",
);
await _showSuccessAnimation();
} else {
if (!mounted) return;
QuickAlert.show(
context: context,
type: QuickAlertType.error,
title: 'Erreur',
text: "L'article ${_nameCtrl.text.trim()} existe déjà.",
);
}
} catch (e) {
if (!mounted) return;
QuickAlert.show(
context: context,
type: QuickAlertType.error,
title: 'Erreur',
text: "Échec de la création de l'article: $e",
);
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _onTakeImage() async {
HapticFeedback.lightImpact();
client = await AxelorClient.create();
if (!mounted) return;
if (!_formKey.currentState!.validate() ||
controller.selectedFamilleProduit.value?.id == null ||
controller.selectedSousFamilleProduit.value?.id == null) {
QuickAlert.show(
context: context,
type: QuickAlertType.info,
title: 'Info',
text: 'Please create the product first (fill required fields).',
);
return;
}
final createdProduct = _createdProduct;
if (createdProduct == null) {
QuickAlert.show(
context: context,
type: QuickAlertType.loading,
title: "Create product first",
);
return;
}
try {
final updater = ProductImageUpdater(
baseUrl: Utils.url,
);
await updater.updateProductPictureFlow(context, createdProduct);
if (!mounted) return;
QuickAlert.show(
context: context,
type: QuickAlertType.success,
title: 'Image Updated',
text: 'Product image captured and uploaded!',
);
} catch (e) {
if (!mounted) return;
QuickAlert.show(
context: context,
type: QuickAlertType.error,
title: 'Upload Failed',
text: 'Could not upload image: $e',
);
}
}
Future<void> _showSuccessAnimation() async {
await _animCtrl.forward();
if (!mounted) return;
await showDialog(
context: context,
barrierDismissible: false,
barrierColor: Colors.black.withValues(alpha: 0.15),
builder:
(_) => Center(
child: ScaleTransition(
scale: _scale,
child: _SuccessBadge(primary: primary),
),
),
);
await Future.delayed(const Duration(milliseconds: 420));
if (mounted) Navigator.of(context).pop();
_animCtrl.reset();
}
}
// ----------------- UI Pieces -----------------
class _FancyDropdown<T> extends StatelessWidget {
final String label;
final T? value;
final List<T> items;
final IconData icon;
final void Function(T?)? onChanged;
final String? Function(T?)? validator;
const _FancyDropdown({
required this.label,
required this.value,
required this.items,
required this.icon,
this.onChanged,
this.validator,
});
@override
Widget build(BuildContext context) {
final border = OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
);
final isDark = Theme.of(context).brightness == Brightness.dark;
return DropdownButtonFormField<T>(
value: value,
isExpanded: true,
dropdownColor:
isDark
? const Color(0xFF1E1E1E)
: Colors.white.withValues(alpha: 0.95),
items:
items
.map(
(e) => DropdownMenuItem<T>(
value: e,
child: Text(
'$e',
style: TextStyle(
color:
isDark
? Colors.white.withValues(alpha: 0.9)
: Colors.black87,
),
),
),
)
.toList(),
onChanged: onChanged,
validator: validator,
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon, color: isDark ? Colors.white70 : Colors.black54),
filled: true,
fillColor:
isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.white.withValues(alpha: 0.75),
enabledBorder: border,
focusedBorder: border,
),
);
}
}
class _PrimaryButton extends StatelessWidget {
final String text;
final IconData icon;
final MaterialColor primary;
final VoidCallback? onPressed;
const _PrimaryButton({
required this.text,
required this.icon,
required this.primary,
this.onPressed,
});
@override
Widget build(BuildContext context) {
return AnimatedOpacity(
duration: const Duration(milliseconds: 180),
opacity: onPressed == null ? 0.6 : 1,
child: ElevatedButton.icon(
icon: Icon(icon, color: Colors.white),
label: Text(text, style: const TextStyle(color: Colors.white)),
style: ElevatedButton.styleFrom(
backgroundColor: primary,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 8,
shadowColor: primary.withValues(alpha: 0.35),
),
onPressed: onPressed,
),
);
}
}
class _CameraButton extends StatelessWidget {
final MaterialColor primary;
final VoidCallback onTap;
const _CameraButton({required this.primary, required this.onTap});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: null,
borderRadius: BorderRadius.circular(16),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [primary.shade400, primary.shade900],
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: primary.withValues(alpha: 0.28),
blurRadius: 16,
offset: const Offset(0, 8),
),
],
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.camera_alt_rounded, color: Colors.white),
],
),
),
);
}
}
class _SavingOverlay extends StatelessWidget {
const _SavingOverlay();
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Container(
color: Colors.black.withValues(alpha: 0.05),
child: const Center(child: CircularProgressIndicator()),
),
);
}
}
class _SuccessBadge extends StatelessWidget {
final MaterialColor primary;
const _SuccessBadge({required this.primary});
@override
Widget build(BuildContext context) {
return Container(
width: 132,
height: 132,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(colors: [primary.shade400, primary.shade600]),
boxShadow: [
BoxShadow(
color: primary.withValues(alpha: 0.35),
blurRadius: 28,
offset: const Offset(0, 10),
),
],
),
child: Center(
child: GestureDetector(
onTap: () => Navigator.pop(context),
child: const Icon(Icons.check_rounded, size: 64, color: Colors.white),
),
),
);
}
}

209
lib/views/profile.dart Normal file
View File

@@ -0,0 +1,209 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter/services.dart';
import 'package:inventory_app/controllers/user_controller.dart';
import 'package:inventory_app/home.dart';
import 'package:inventory_app/widgets/glass_widgets.dart';
import 'package:inventory_app/service/axelor_client.dart';
import 'package:inventory_app/controllers/theme_controller.dart';
import '../constants.dart';
class ProfilePage extends StatefulWidget {
const ProfilePage({Key? key}) : super(key: key);
@override
State<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage> {
Map<String, dynamic>? userData;
bool loading = true;
@override
void initState() {
super.initState();
Get.find<UserController>().loadUser();
// loadProfile();
}
Future<void> loadProfile() async {
setState(() => loading = true);
try {
final client = await AxelorClient.create();
final data = await client.fetchUserProfile();
setState(() {
userData = data;
loading = false;
});
} catch (e) {
setState(() => loading = false);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Failed to load profile: $e")));
}
}
@override
Widget build(BuildContext context) {
final userController = Get.find<UserController>();
final primary = Colors.blue;
return Scaffold(
drawer: FancyDrawer(primary: primary, onLogout: () {}),
body: Stack(
children: [
const FancyBackground(),
Obx(() {
if (userController.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
final user = userController.user.value;
if (user == null) {
return const Center(
child: Text("Impossible de charger le profil."),
);
}
return _buildContent(primary, user);
}),
],
),
);
}
Widget _buildContent(MaterialColor primary, Map<String, dynamic> userData) {
final themeController = Get.find<ThemeController>();
final isDark = themeController.isDarkMode.value;
return SafeArea(
child: Column(
children: [
GlassAppBar(
title: "Mon Profil",
primary: primary,
onLogout: () {},
onCamera: null,
),
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 6, 16, 50),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionHeader(
icon: Icons.person_rounded,
title: "Informations personnelles",
accent: primary,
),
const SizedBox(height: 8),
GlassCard(
child: Column(
children: [
_profileField(
icon: Icons.person,
label: "Nom complet",
value: userData?["fullName"],
color: isDark ? Colors.white : primary.shade700,
),
_profileField(
icon: Icons.badge_rounded,
label: "Login",
value: userData?["code"],
color: isDark ? Colors.white : primary.shade700,
),
_profileField(
icon: Icons.email_rounded,
label: "Email",
value: userData?["email"],
color: isDark ? Colors.white : primary.shade700,
),
_profileField(
icon: Icons.language_rounded,
label: "Langue",
value: userData?["language"],
color: isDark ? Colors.white : primary.shade700,
),
_profileField(
icon: Icons.group,
label: "Groupe",
value: userData?["group"]?["name"],
color: isDark ? Colors.white : primary.shade700,
),
_profileField(
icon: Icons.lock_person_rounded,
label: "Bloqué",
value: userData?["blocked"] == true ? "Oui" : "Non",
color: isDark ? Colors.white : primary.shade700,
),
],
),
),
const SizedBox(height: 25),
Center(
child: ElevatedButton.icon(
icon: const Icon(
Icons.refresh_rounded,
color: Colors.white,
),
label: const Text(
"Actualiser",
style: TextStyle(color: Colors.white),
),
style: ElevatedButton.styleFrom(
backgroundColor: primary,
elevation: 8,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
onPressed: () {
HapticFeedback.lightImpact();
Get.find<UserController>().loadUser(); // ✅ correct
},
),
),
const SizedBox(height: 40),
],
),
),
),
],
),
);
}
Widget _profileField({
required IconData icon,
required String label,
required String? value,
required Color color,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(icon, color: Colors.blue),
const SizedBox(width: 12),
Expanded(
child: Text(
"$label : ${value ?? 'N/A'}",
style: kTextFormFieldStyle(fontSize: 16.0, color: color),
),
),
],
),
);
}
}

182
lib/views/qr_view.dart Normal file
View File

@@ -0,0 +1,182 @@
import 'dart:io';
import 'dart:ui' show ImageFilter;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart';
import 'package:inventory_app/constants.dart';
class QRViewExample extends StatefulWidget {
const QRViewExample({super.key});
@override
State<StatefulWidget> createState() => _QRViewExampleState();
}
class _QRViewExampleState extends State<QRViewExample> {
Barcode? result;
QRViewController? controller;
final GlobalKey qrKey = GlobalKey(debugLabel: 'QR');
@override
void reassemble() {
super.reassemble();
if (Platform.isAndroid) {
controller!.pauseCamera();
}
controller!.resumeCamera();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: <Widget>[
Expanded(flex: 4, child: _buildQrView(context)),
Expanded(
flex: 1,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.white.withOpacity(0.08),
Colors.white.withOpacity(0.04),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withOpacity(0.12)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.15),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: FittedBox(
fit: BoxFit.contain,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
if (result != null)
Text(
'Barcode Type: ${describeEnum(result!.format)} Data: ${result!.code}',
style: kTextFormFieldStyle(),
)
else
Text('Scan a code', style: kTextFormFieldStyle()),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
_glassButton(
child: FutureBuilder(
future: controller?.getFlashStatus(),
builder: (context, snapshot) =>
Text('Flash: ${snapshot.data}'),
),
onPressed: () async {
await controller?.toggleFlash();
setState(() {});
},
),
_glassButton(
child: FutureBuilder(
future: controller?.getCameraInfo(),
builder: (context, snapshot) =>
snapshot.data != null
? Text('Camera facing ${describeEnum(snapshot.data!)}')
: const Text('loading'),
),
onPressed: () async {
await controller?.flipCamera();
setState(() {});
},
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
_glassButton(
child: const Text('pause', style: TextStyle(fontSize: 20)),
onPressed: () async => controller?.pauseCamera(),
),
_glassButton(
child: const Text('resume', style: TextStyle(fontSize: 20)),
onPressed: () async => controller?.resumeCamera(),
),
],
),
],
),
),
),
),
),
),
),
],
),
);
}
Widget _glassButton({required Widget child, required VoidCallback onPressed}) {
return Container(
margin: const EdgeInsets.all(8),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
backgroundColor: Colors.white.withOpacity(0.06),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.white.withOpacity(0.12)),
),
),
onPressed: onPressed,
child: child,
),
);
}
Widget _buildQrView(BuildContext context) {
final scanArea =
(MediaQuery.of(context).size.width < 500 ||
MediaQuery.of(context).size.height < 500)
? 300.0
: 450.0;
return QRView(
key: qrKey,
onQRViewCreated: _onQRViewCreated,
overlay: QrScannerOverlayShape(
borderColor: Colors.blue,
borderRadius: 10,
borderLength: 30,
borderWidth: 10,
cutOutSize: scanArea,
),
onPermissionSet: (ctrl, p) => _onPermissionSet(context, ctrl, p),
);
}
void _onQRViewCreated(QRViewController controller) {
setState(() => this.controller = controller);
controller.scannedDataStream.listen((scanData) {
setState(() => result = scanData);
Get.back(result: result!.code);
});
}
void _onPermissionSet(BuildContext context, QRViewController ctrl, bool p) {
if (!p) Get.snackbar("Permission", 'no Permission');
}
}

302
lib/views/sign_up_view.dart Normal file
View File

@@ -0,0 +1,302 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:lottie/lottie.dart';
import 'package:inventory_app/controllers/simple_ui_controller.dart';
import '../views/login_view.dart';
import '../constants.dart';
class SignUpView extends StatefulWidget {
const SignUpView({Key? key}) : super(key: key);
@override
State<SignUpView> createState() => _SignUpViewState();
}
class _SignUpViewState extends State<SignUpView> {
TextEditingController nameController = TextEditingController();
TextEditingController emailController = TextEditingController();
TextEditingController passwordController = TextEditingController();
final _formKey = GlobalKey<FormState>();
@override
void dispose() {
nameController.dispose();
emailController.dispose();
passwordController.dispose();
super.dispose();
}
SimpleUIController simpleUIController = Get.put(SimpleUIController());
@override
Widget build(BuildContext context) {
var size = MediaQuery.of(context).size;
var theme = Theme.of(context);
return GestureDetector(
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
child: Scaffold(
backgroundColor: Colors.white,
resizeToAvoidBottomInset: false,
body: LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth > 600) {
return _buildLargeScreen(size, simpleUIController, theme);
} else {
return _buildSmallScreen(size, simpleUIController, theme);
}
},
)),
);
}
/// For large screens
Widget _buildLargeScreen(
Size size, SimpleUIController simpleUIController, ThemeData theme) {
return Row(
children: [
Expanded(
flex: 4,
child: RotatedBox(
quarterTurns: 3,
child: Lottie.asset(
'assets/coin.json',
height: size.height * 0.3,
width: double.infinity,
fit: BoxFit.fill,
),
),
),
SizedBox(width: size.width * 0.06),
Expanded(
flex: 5,
child: _buildMainBody(size, simpleUIController, theme),
),
],
);
}
/// For Small screens
Widget _buildSmallScreen(
Size size, SimpleUIController simpleUIController, ThemeData theme) {
return Center(
child: _buildMainBody(size, simpleUIController, theme),
);
}
/// Main Body
Widget _buildMainBody(
Size size, SimpleUIController simpleUIController, ThemeData theme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment:
size.width > 600 ? MainAxisAlignment.center : MainAxisAlignment.start,
children: [
size.width > 600
? Container()
: Lottie.asset(
'assets/wave.json',
height: size.height * 0.2,
width: size.width,
fit: BoxFit.fill,
),
SizedBox(
height: size.height * 0.03,
),
Padding(
padding: const EdgeInsets.only(left: 20.0),
child: Text(
'Sign Up',
style: kLoginTitleStyle(size),
),
),
const SizedBox(
height: 10,
),
Padding(
padding: const EdgeInsets.only(left: 20.0),
child: Text(
'Create Account',
style: kLoginSubtitleStyle(size),
),
),
SizedBox(
height: size.height * 0.03,
),
Padding(
padding: const EdgeInsets.only(left: 20.0, right: 20),
child: Form(
key: _formKey,
child: Column(
children: [
/// username
TextFormField(
style: kTextFormFieldStyle(),
decoration: const InputDecoration(
prefixIcon: Icon(Icons.person),
hintText: 'Username',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(15)),
),
),
controller: nameController,
// The validator receives the text that the user has entered.
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter username';
} else if (value.length < 4) {
return 'at least enter 4 characters';
} else if (value.length > 13) {
return 'maximum character is 13';
}
return null;
},
),
SizedBox(
height: size.height * 0.02,
),
/// Gmail
TextFormField(
style: kTextFormFieldStyle(),
controller: emailController,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.email_rounded),
hintText: 'gmail',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(15)),
),
),
// The validator receives the text that the user has entered.
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter gmail';
} else if (!value.endsWith('@gmail.com')) {
return 'please enter valid gmail';
}
return null;
},
),
SizedBox(
height: size.height * 0.02,
),
/// password
Obx(
() => TextFormField(
style: kTextFormFieldStyle(),
controller: passwordController,
obscureText: simpleUIController.isObscure.value,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.lock_open),
suffixIcon: IconButton(
icon: Icon(
simpleUIController.isObscure.value
? Icons.visibility
: Icons.visibility_off,
),
onPressed: () {
simpleUIController.isObscureActive();
},
),
hintText: 'Password',
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(15)),
),
),
// The validator receives the text that the user has entered.
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter some text';
} else if (value.length < 7) {
return 'at least enter 6 characters';
} else if (value.length > 13) {
return 'maximum character is 13';
}
return null;
},
),
),
SizedBox(
height: size.height * 0.01,
),
Text(
'Creating an account means you\'re okay with our Terms of Services and our Privacy Policy',
style: kLoginTermsAndPrivacyStyle(size),
textAlign: TextAlign.center,
),
SizedBox(
height: size.height * 0.02,
),
/// SignUp Button
signUpButton(theme),
SizedBox(
height: size.height * 0.03,
),
/// Navigate To Login Screen
GestureDetector(
onTap: () {
Navigator.push(
context,
CupertinoPageRoute(
builder: (ctx) => const LoginView()));
nameController.clear();
emailController.clear();
passwordController.clear();
_formKey.currentState?.reset();
simpleUIController.isObscure.value = true;
},
child: RichText(
text: TextSpan(
text: 'Already have an account?',
style: kHaveAnAccountStyle(size),
children: [
TextSpan(
text: " Login",
style: kLoginOrSignUpTextStyle(size)),
],
),
),
),
],
),
),
),
],
);
}
// SignUp Button
Widget signUpButton(ThemeData theme) {
return SizedBox(
width: double.infinity,
height: 55,
child: ElevatedButton(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(Colors.deepPurpleAccent),
shape: MaterialStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
),
),
onPressed: () {
// Validate returns true if the form is valid, or false otherwise.
if (_formKey.currentState!.validate()) {
// ... Navigate To your Home Page
}
},
child: const Text('Sign up'),
),
);
}
}

View File

@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:lottie/lottie.dart';
class SplashScreen extends StatelessWidget {
const SplashScreen({super.key});
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
return Scaffold(
body: SizedBox(
width: size.width,
height: size.height,
child: Center(
child: Lottie.asset(
'assets/wave.json',
width: size.width,
height: size.height,
fit: BoxFit.fitWidth,
),
),
),
);
}
}

View File

@@ -0,0 +1,558 @@
import 'dart:typed_data';
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:inventory_app/constants.dart';
// ── Gradient background ───────────────────────────────────────────────────────
class FancyBackground extends StatelessWidget {
const FancyBackground({super.key});
@override
Widget build(BuildContext context) {
final dark = Theme.of(context).brightness == Brightness.dark;
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors:
dark
? [
const Color(0xFF0A0A0A),
const Color(0xFF121212),
const Color(0xFF1E1E1E),
]
: [
const Color(0xFFEEF2FF),
const Color(0xFFE0F2FE),
const Color(0xFFE6FFFA),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Stack(
children: [
Positioned(
top: -60,
right: -40,
child: _Blob(
color:
dark
? Colors.blue.withOpacity(0.1)
: Colors.blue.withOpacity(0.18),
size: 170,
),
),
Positioned(
bottom: -40,
left: -40,
child: _Blob(
color:
dark
? Colors.cyanAccent.withOpacity(0.08)
: Colors.cyan.withOpacity(0.16),
size: 150,
),
),
],
),
);
}
}
class _Blob extends StatelessWidget {
final Color color;
final double size;
const _Blob({required this.color, required this.size});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color,
boxShadow: [BoxShadow(color: color, blurRadius: 60, spreadRadius: 30)],
),
);
}
}
// ── Glass card ────────────────────────────────────────────────────────────────
class GlassCard extends StatelessWidget {
final Widget child;
final EdgeInsets? padding;
const GlassCard({super.key, required this.child, this.padding});
@override
Widget build(BuildContext context) {
final dark = Theme.of(context).brightness == Brightness.dark;
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors:
dark
? [
Colors.white.withOpacity(0.06),
Colors.white.withOpacity(0.03),
]
: [
Colors.white.withOpacity(0.42),
Colors.white.withOpacity(0.18),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
border: Border.all(
color:
dark
? Colors.white.withOpacity(0.08)
: Colors.white.withOpacity(0.5),
),
boxShadow: [
BoxShadow(
color:
dark
? Colors.black.withOpacity(0.4)
: Colors.blueGrey.withOpacity(0.15),
blurRadius: 18,
offset: const Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14),
child: Padding(
padding: padding ?? const EdgeInsets.all(16),
child: child,
),
),
),
);
}
}
// ── App bar for sub-pages (with back button) ──────────────────────────────────
class BackAppBar extends StatelessWidget {
final String title;
final IconData? icon;
final List<Widget>? actions;
const BackAppBar({super.key, required this.title, this.icon, this.actions});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
child: GlassCard(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => Get.back(),
color: isDark ? Colors.white70 : Colors.black87,
),
if (icon != null) Icon(icon!, color: Colors.blue),
if (icon != null) const SizedBox(width: 8),
Expanded(
child: Text(
title,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: isDark ? Colors.white : Colors.blue.shade700,
),
),
),
if (actions != null) ...actions!,
],
),
),
);
}
}
// ── Glass bottom sheet ────────────────────────────────────────────────────────
class GlassBottomSheet extends StatelessWidget {
final Widget child;
const GlassBottomSheet({super.key, required this.child});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(12),
child: GlassCard(child: child),
);
}
}
class SheetHandle extends StatelessWidget {
final Color color;
const SheetHandle(this.color, {super.key});
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: 50,
height: 6,
margin: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: color.withOpacity(.4),
borderRadius: BorderRadius.circular(10),
),
),
);
}
}
// ── Text field ────────────────────────────────────────────────────────────────
class AppTextField extends StatelessWidget {
final TextEditingController controller;
final String label;
final bool enabled;
final IconData? icon;
final TextInputType? type;
const AppTextField({
super.key,
required this.controller,
required this.label,
this.enabled = true,
this.icon,
this.type,
});
@override
Widget build(BuildContext context) {
final dark = Theme.of(context).brightness == Brightness.dark;
return TextField(
controller: controller,
enabled: enabled,
keyboardType: type,
style: kTextFormFieldStyle(
color: dark ? Colors.white.withOpacity(.9) : Colors.black87,
),
decoration: InputDecoration(
labelText: label,
prefixIcon:
icon != null
? Icon(icon, color: dark ? Colors.white70 : Colors.black54)
: null,
filled: true,
fillColor: dark ? Colors.white12 : Colors.white.withOpacity(.8),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
),
);
}
}
class FancyTextField extends StatelessWidget {
final TextEditingController controller;
final String label;
final String hint;
final IconData icon;
final int maxLines;
final String? Function(String?)? validator;
const FancyTextField({
super.key,
required this.controller,
required this.label,
required this.hint,
required this.icon,
this.maxLines = 1,
this.validator,
});
@override
Widget build(BuildContext context) {
final border = OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
);
final isDark = Theme.of(context).brightness == Brightness.dark;
return TextFormField(
controller: controller,
maxLines: maxLines,
validator: validator,
style: TextStyle(
color: isDark ? Colors.white.withOpacity(0.9) : Colors.black87,
),
decoration: InputDecoration(
labelText: label,
hintText: hint,
prefixIcon: Icon(icon, color: isDark ? Colors.white70 : Colors.black54),
filled: true,
fillColor:
isDark
? Colors.white.withOpacity(0.08)
: Colors.white.withOpacity(0.75),
enabledBorder: border,
focusedBorder: border,
),
);
}
}
// ── Dropdown ──────────────────────────────────────────────────────────────────
class AppDropdown<T> extends StatelessWidget {
final T? value;
final List<T> items;
final String hint;
final String Function(T) labelBuilder;
final ValueChanged<T?> onChanged;
const AppDropdown({
super.key,
required this.value,
required this.items,
required this.hint,
required this.labelBuilder,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final dark = Theme.of(context).brightness == Brightness.dark;
return DropdownButtonFormField<T>(
value: value,
isExpanded: true,
items:
items
.map(
(e) => DropdownMenuItem(
value: e,
child: Text(
labelBuilder(e),
overflow: TextOverflow.ellipsis,
style: kTextFormFieldStyle(
color: dark ? Colors.white : Colors.black87,
),
),
),
)
.toList(),
onChanged: onChanged,
decoration: InputDecoration(
labelText: hint,
filled: true,
fillColor: dark ? Colors.white12 : Colors.white.withOpacity(.8),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)),
),
);
}
}
// ── Image loader ──────────────────────────────────────────────────────────────
class AppImage extends StatelessWidget {
final Future<Uint8List?> future;
const AppImage({super.key, required this.future});
@override
Widget build(BuildContext context) {
return FutureBuilder<Uint8List?>(
future: future,
builder: (_, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const GlassCard(
child: Center(
child: Padding(
padding: EdgeInsets.all(12),
child: CircularProgressIndicator(),
),
),
);
}
if (!snapshot.hasData) return const SizedBox.shrink();
return GlassCard(
padding: EdgeInsets.zero,
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Image.memory(
snapshot.data!,
height: 200,
width: double.infinity,
fit: BoxFit.cover,
),
),
);
},
);
}
}
// ── Section header ────────────────────────────────────────────────────────────
class SectionHeader extends StatelessWidget {
final IconData icon;
final String title;
final Color accent;
const SectionHeader({
super.key,
required this.icon,
required this.title,
required this.accent,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: accent.withOpacity(.15),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: accent),
),
const SizedBox(width: 10),
Text(
title,
style: kTextFormFieldStyle(
fontSize: 18.0,
fontWeight: FontWeight.w700,
color: Colors.blue.shade700,
),
),
],
);
}
}
class SubLabel extends StatelessWidget {
final String text;
final MaterialColor color;
const SubLabel(this.text, this.color, {super.key});
@override
Widget build(BuildContext context) {
return Text(
text,
style: kTextFormFieldStyle(
fontSize: 14.0,
fontWeight: FontWeight.w600,
color: color.shade700,
),
);
}
}
// ── Radio tile ────────────────────────────────────────────────────────────────
class TileRadio extends StatelessWidget {
final String value;
final String? groupValue;
final String label;
final IconData icon;
final ValueChanged<String?> onChanged;
const TileRadio({
super.key,
required this.value,
required this.groupValue,
required this.label,
required this.icon,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final selected = value == groupValue;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration(
color:
selected
? Colors.white.withOpacity(.85)
: Colors.white.withOpacity(.6),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color:
selected
? Colors.blue.withOpacity(.6)
: Colors.white.withOpacity(.4),
),
),
child: ListTile(
leading: Icon(icon, color: selected ? Colors.blue : Colors.black54),
title: Text(label, style: kTextFormFieldStyle()),
trailing: Radio<String>(
value: value,
groupValue: groupValue,
onChanged: onChanged,
),
onTap: () => onChanged(value),
),
);
}
}
// ── FAB ───────────────────────────────────────────────────────────────────────
class FancyFAB extends StatelessWidget {
final Color primary;
final VoidCallback onTap;
const FancyFAB({super.key, required this.primary, required this.onTap});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(colors: [primary, primary.withOpacity(.8)]),
borderRadius: BorderRadius.circular(28),
boxShadow: [
BoxShadow(
color: primary.withOpacity(.35),
blurRadius: 24,
offset: const Offset(0, 10),
),
],
),
child: FloatingActionButton.extended(
backgroundColor: Colors.transparent,
elevation: 0,
onPressed: onTap,
icon: const Icon(Icons.document_scanner, color: Colors.white),
label: const Text("Scanner", style: TextStyle(color: Colors.white)),
),
);
}
}