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:
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user