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.
580 lines
18 KiB
Dart
580 lines
18 KiB
Dart
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),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|