Add full app source

Adds the Flutter inventory app source, cleaned up for a shared repo:
generic package name, no hardcoded ERP endpoints (moved to gitignored
local config), no dead auth code, no debug logging of session data.
This commit is contained in:
BACHIR SOULDI
2026-07-16 14:37:28 +01:00
parent 37b0762921
commit 5a483da01d
110 changed files with 8081 additions and 0 deletions

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

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