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