import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; import 'package:niogu_app/core/components/top_back_bar_app.dart'; import 'package:niogu_app/core/constants/app_color.dart'; import 'package:niogu_app/core/constants/app_font_size.dart'; import 'package:niogu_app/core/router/app_route.dart'; import 'package:niogu_app/core/utils/image_service.dart'; import 'package:niogu_app/core/utils/log_message.dart'; import 'package:niogu_app/core/widgets/custom_snackbar.dart'; import 'package:niogu_app/core/widgets/custom_text_form_field.dart'; import 'package:niogu_app/features/outlets/domain/entities/outlet.dart'; import 'package:niogu_app/features/outlets/presentation/providers/outlet_provider.dart'; import 'package:niogu_app/features/outlets/presentation/widgets/staf_admin.dart'; import 'package:sizer/sizer.dart'; class StaffAdminInformation { final TextEditingController nameController; final TextEditingController phoneNumberController; final TextEditingController emailController; final TextEditingController passwordController; final TextEditingController passwordConfirmController; final TextEditingController shiftNameController; bool obsecurePassword; bool obsecurePasswordConfirm; TimeOfDay startTime; TimeOfDay endTime; StaffAdminInformation({ required this.nameController, required this.phoneNumberController, required this.emailController, required this.passwordController, required this.passwordConfirmController, required this.shiftNameController, this.obsecurePassword = true, this.obsecurePasswordConfirm = true, this.startTime = const TimeOfDay(hour: 08, minute: 00), this.endTime = const TimeOfDay(hour: 16, minute: 00), }); } class AddOutletScreen extends ConsumerStatefulWidget { const AddOutletScreen({super.key}); @override ConsumerState createState() => _AddOutletScreenState(); } class _AddOutletScreenState extends ConsumerState { final GlobalKey _generalKey = GlobalKey(); final TextEditingController _nameController = TextEditingController(); final TextEditingController _phoneNumberController = TextEditingController(); final TextEditingController _emailController = TextEditingController(); bool _visibleFirstForm = false; final StaffAdminInformation _firstStaffAdmin = StaffAdminInformation( nameController: TextEditingController(), phoneNumberController: TextEditingController(), emailController: TextEditingController(), passwordController: TextEditingController(), passwordConfirmController: TextEditingController(), shiftNameController: TextEditingController(), ); final GlobalKey _firstAdminKey = GlobalKey(); bool _visbleSecondForm = false; final StaffAdminInformation _secondStaffAdmin = StaffAdminInformation( nameController: TextEditingController(), phoneNumberController: TextEditingController(), emailController: TextEditingController(), passwordController: TextEditingController(), passwordConfirmController: TextEditingController(), shiftNameController: TextEditingController(), ); final GlobalKey _secondAdminKey = GlobalKey(); final ImagePicker _picker = ImagePicker(); final _emailRegex = RegExp( r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', ); final List _imagePathTemps = []; String? _imagePath; @override void initState() { // TODO: implement initState super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { ref.invalidate(mapOutletAddressProvider); }); } @override void dispose() { // TODO: implement dispose _nameController.dispose(); _phoneNumberController.dispose(); _emailController.dispose(); _firstStaffAdmin.nameController.dispose(); _firstStaffAdmin.phoneNumberController.dispose(); _firstStaffAdmin.emailController.dispose(); _firstStaffAdmin.passwordController.dispose(); _firstStaffAdmin.passwordConfirmController.dispose(); _firstStaffAdmin.shiftNameController.dispose(); _secondStaffAdmin.nameController.dispose(); _secondStaffAdmin.phoneNumberController.dispose(); _secondStaffAdmin.emailController.dispose(); _secondStaffAdmin.passwordController.dispose(); _secondStaffAdmin.passwordConfirmController.dispose(); _secondStaffAdmin.shiftNameController.dispose(); super.dispose(); } Future _showPickerOptions() async { final bool isTablet = 100.w >= 600; showModalBottomSheet( context: context, backgroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(2.5.w)), ), constraints: const BoxConstraints(maxWidth: double.infinity), builder: (BuildContext context) { return SafeArea( child: Container( width: 100.w, padding: EdgeInsets.symmetric(vertical: 2.h, horizontal: 5.w), clipBehavior: Clip.hardEdge, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(6.w)), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: Icon(Icons.photo_library, size: 6.w), title: Text( 'Galeri', style: TextStyle( fontSize: isTablet ? AppFontSize.medium.sp : AppFontSize.small.sp, ), ), onTap: () async { await _getImage(ImageSource.gallery); context.pop(); }, ), SizedBox(height: 2.h), ListTile( leading: Icon(Icons.photo_camera, size: 6.w), title: Text( 'Kamera', style: TextStyle( fontSize: isTablet ? AppFontSize.medium.sp : AppFontSize.small.sp, ), ), onTap: () async { await _getImage(ImageSource.camera); context.pop(); }, ), ], ), ), ); }, ); } Future _getImage(ImageSource ImageSource) async { try { final XFile? pickedFile = await _picker.pickImage( source: ImageSource, imageQuality: 70, ); if (pickedFile != null) { File tempFile = File(pickedFile.path); String? imagePath = await ImageService.saveImageToLocalDirectory( tempFile, "outlet_banner_images", ); if (imagePath != null) { _imagePathTemps.add(imagePath); setState(() { _imagePath = imagePath; }); } } } catch (e, st) { LogMessage.log.e(e.toString(), error: e, stackTrace: st); CustomSnackbar.showWarning(context, "Akses ditolak"); } } Future _cleanUpImages() async { for (final image in _imagePathTemps) { if (image != null) { await ImageService.deleteLocalImage(image); } } } Future _selectTime(bool isStart, StaffAdminInformation? admin) async { final TimeOfDay? picked = await showTimePicker( context: context, initialTime: admin != null ? (isStart ? admin.startTime : admin.endTime) : TimeOfDay.now(), builder: (BuildContext context, Widget? child) { return MediaQuery( data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true), child: child!, ); }, ); if (picked != null) { setState(() { if (isStart) { admin?.startTime = picked; } else { admin?.endTime = picked; } }); } } String? _validation( String? value, String message, { StaffAdminInformation? admin, bool isEmail = false, bool isPassword = false, bool isPasswordConfirm = false, }) { if (value == null || value.isEmpty) { return message; } if (isEmail && !_emailRegex.hasMatch(value)) { return "Email tidak valid"; } if (isPassword) { if (value.length < 8) { return "Password minimal harus 8 karakter"; } final passwordRegExp = RegExp( r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#])[A-Za-z\d@$!%*?&#]{8,}$', ); if (!passwordRegExp.hasMatch(value)) { return "Gunakan huruf besar, kecil, angka, dan simbol"; } } if (isPasswordConfirm && value != admin?.passwordController.text.trim()) { return "Konfirmasi password tidak sesuai"; } return null; } int _toMinutes(TimeOfDay time) { return time.hour * 60 + time.minute; } List> _normalizeShift(TimeOfDay start, TimeOfDay end) { final s = _toMinutes(start); final e = _toMinutes(end); if (e > s) { return [ [s, e], ]; } else { return [ [s, 1440], [0, e], ]; } } int _calculateDuration(TimeOfDay start, TimeOfDay end) { final s = _toMinutes(start); final e = _toMinutes(end); if (e > s) { return e - s; } else { return (1440 - s) + e; } } String? _validateShift(TimeOfDay? start, TimeOfDay? end, int shift) { if (start == null && end == null) { return null; } final duration = _calculateDuration(start!, end!); if (duration == 0) { return "Shift $shift tidak boleh 24 jam penuh"; } if (duration <= 0) { return "Jam selesai shift $shift harus berbeda dari jam mulai shift $shift"; } if (duration > 16 * 60) { return "Shift $shift tidak boleh lebih dari 16 jam"; } return null; } bool _isShiftOverlap({ required TimeOfDay start1, required TimeOfDay end1, required TimeOfDay start2, required TimeOfDay end2, }) { final shift1 = _normalizeShift(start1, end1); final shift2 = _normalizeShift(start2, end2); for (final a in shift1) { for (final b in shift2) { final s1 = a[0]; final e1 = a[1]; final s2 = b[0]; final e2 = b[1]; if (s1 < e2 && e1 > s2) { return true; } } } return false; } Future _saveOutlet() async { if (!_generalKey.currentState!.validate()) return; final phoneNumber = _phoneNumberController.text.trim(); String? email; if (_emailController.text.trim().isNotEmpty) { email = _emailController.text.trim(); } final validatePhoneAndEmail = await ref .read(outletRepositoryProvider) .validatePhoneAndEmail(phoneNumber: phoneNumber, email: email); if (validatePhoneAndEmail) { CustomSnackbar.showError( context, 'No. hp atau email outlet telah tersedia', ); return; } final firstFormHasValue = _firstStaffAdmin.nameController.text.isNotEmpty || _firstStaffAdmin.phoneNumberController.text.isNotEmpty || _firstStaffAdmin.emailController.text.isNotEmpty || _firstStaffAdmin.passwordController.text.isNotEmpty || _firstStaffAdmin.passwordConfirmController.text.isNotEmpty || _firstStaffAdmin.shiftNameController.text.isNotEmpty; if (!_visibleFirstForm && !firstFormHasValue) { CustomSnackbar.showError(context, "Informasi staf admin 1 wajib diisi"); return; } if (!_firstAdminKey.currentState!.validate()) return; final bool secondFormHasValue = _secondStaffAdmin.nameController.text.isNotEmpty || _secondStaffAdmin.phoneNumberController.text.isNotEmpty || _secondStaffAdmin.emailController.text.isNotEmpty || _secondStaffAdmin.passwordController.text.isNotEmpty || _secondStaffAdmin.passwordConfirmController.text.isNotEmpty || _secondStaffAdmin.shiftNameController.text.isNotEmpty; if (secondFormHasValue && !_secondAdminKey.currentState!.validate()) { return; } final firstPhoneNumber = _firstStaffAdmin.phoneNumberController.text; final firstEmail = _firstStaffAdmin.emailController.text; final secondPhoneNumber = _secondStaffAdmin.phoneNumberController.text; final secondEmail = _secondStaffAdmin.emailController.text; if (firstPhoneNumber == secondPhoneNumber) { CustomSnackbar.showError(context, "No. hp staf 1 dan 2 tidak boleh sama"); return; } if (firstEmail == secondEmail) { CustomSnackbar.showError(context, "Email staf 1 dan 2 tidak boleh sama"); return; } final validateFirstShift = _validateShift( _firstStaffAdmin.startTime, _firstStaffAdmin.endTime, 1, ); if (validateFirstShift != null) { CustomSnackbar.showError(context, validateFirstShift); return; } final validateSecondShift = _validateShift( _secondStaffAdmin.startTime, _secondStaffAdmin.endTime, 2, ); if (validateSecondShift != null) { CustomSnackbar.showError(context, validateSecondShift); return; } final firstStartShift = _firstStaffAdmin.startTime; final firstEndShift = _firstStaffAdmin.endTime; final secondStartShift = _secondStaffAdmin.startTime; final secondEndShift = _secondStaffAdmin.endTime; if (_isShiftOverlap( start1: firstStartShift, end1: firstEndShift, start2: secondStartShift, end2: secondEndShift, ) && secondFormHasValue) { CustomSnackbar.showError( context, "Jam kerja shift 1 konflik dengan shift 2", ); return; } final validateAddUserStaff = ref .read(outletRepositoryProvider) .validateAddUserStaff( firstPhoneNumber: firstPhoneNumber, firstEmail: firstEmail, secondPhoneNumber: secondPhoneNumber, secondEmail: secondEmail, ); if (await validateAddUserStaff) { CustomSnackbar.showError( context, "No. hp atau email staf telah tersedia", ); return; } final firstStaffAdmin = UpsertStaffAdmin( name: _firstStaffAdmin.nameController.text.trim(), phoneNumber: _firstStaffAdmin.phoneNumberController.text.trim(), email: _firstStaffAdmin.emailController.text.trim(), passwordHash: _firstStaffAdmin.passwordController.text.trim(), shiftName: _firstStaffAdmin.shiftNameController.text.trim(), shiftStartTime: _firstStaffAdmin.startTime.format(context), shiftEndTime: _firstStaffAdmin.endTime.format(context), ); UpsertStaffAdmin? secondStaffAdmin; if (secondFormHasValue) { secondStaffAdmin = UpsertStaffAdmin( name: _secondStaffAdmin.nameController.text.trim(), phoneNumber: _secondStaffAdmin.phoneNumberController.text.trim(), email: _secondStaffAdmin.emailController.text.trim(), passwordHash: _secondStaffAdmin.passwordController.text.trim(), shiftName: _secondStaffAdmin.shiftNameController.text.trim(), shiftStartTime: _secondStaffAdmin.startTime.format(context), shiftEndTime: _secondStaffAdmin.endTime.format(context), ); } final selectedOutletAddress = ref.read(mapOutletAddressProvider); final upsertOutlet = UpsertOutlet( bannerPath: _imagePath, name: _nameController.text.trim(), phoneNumber: _phoneNumberController.text.trim(), email: email, firstStaffAdmin: firstStaffAdmin, secondStaffAdmin: secondStaffAdmin, fullAddress: selectedOutletAddress?.fullAddress, latitude: selectedOutletAddress?.currentLocation.latitude, longitude: selectedOutletAddress?.currentLocation.longitude, ); if (_imagePath == null && _imagePathTemps.isNotEmpty) { await _cleanUpImages(); } else if (_imagePath != null && _imagePathTemps.isNotEmpty && _imagePathTemps.length > 1) { final imagePathTemps = _imagePathTemps .where((i) => i != _imagePath) .toList(); for (final image in imagePathTemps) { if (image != null) { await ImageService.deleteLocalImage(image); } } } try { await ref .read(outletControllerProvider.notifier) .saveOutlet(upsertOutlet); if (!mounted) return; ref.invalidate(mapOutletAddressProvider); CustomSnackbar.showSuccess(context, 'Outlet baru berhasil ditambahkan'); context.pop(); } catch (e, st) { LogMessage.log.e(e.toString(), error: e, stackTrace: st); CustomSnackbar.showError(context, 'Ups, terjadi kesalahan'); } } @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final bool isTablet = 100.w >= 600; final outletControllerState = ref.watch(outletControllerProvider); final mapOutletAddressState = ref.watch(mapOutletAddressProvider); final File imageFile = File(_imagePath ?? "image not found"); final bool imageFileExists = imageFile.existsSync(); return PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) async { if (didPop) return; await _cleanUpImages(); context.pop(); WidgetsBinding.instance.addPostFrameCallback((_) { ref.invalidate(mapOutletAddressProvider); }); }, child: SafeArea( top: false, bottom: true, right: false, left: false, child: Scaffold( backgroundColor: Colors.white, appBar: TopBackBarApp( title: "Tambah Outlet Baru", onTap: () async { await _cleanUpImages(); context.pop(); WidgetsBinding.instance.addPostFrameCallback((_) { ref.invalidate(mapOutletAddressProvider); }); }, ), body: SingleChildScrollView( padding: EdgeInsets.all(5.w), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: EdgeInsets.only(bottom: 1.5.h), child: Text( "Foto Outlet", style: TextStyle( fontSize: (AppFontSize.medium - 1.25).sp, fontWeight: FontWeight.bold, color: Colors.black87, ), ), ), Stack( clipBehavior: Clip.none, children: [ Container( height: 20.h, width: double.infinity, decoration: BoxDecoration( color: Colors.grey.shade100, borderRadius: BorderRadius.circular(3.w), border: Border.all( color: Colors.grey.shade300, style: BorderStyle.none, ), ), child: imageFileExists ? ClipRRect( borderRadius: BorderRadius.circular(2.5.w), child: Image.file( imageFile, fit: BoxFit.cover, width: double.infinity, height: double.infinity, errorBuilder: (context, error, stackTrace) { return Icon( Icons.error, color: Colors.grey[400], size: 10.w, ); }, ), ) : GestureDetector( onTap: _showPickerOptions, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.add_a_photo_outlined, size: 10.w, color: Colors.grey.shade700, ), SizedBox(height: 1.h), Text( "Unggah Foto Landscape", style: TextStyle( fontSize: isTablet ? (AppFontSize.medium - 2).sp : (AppFontSize.small - 2).sp, color: Colors.grey.shade700, ), ), ], ), ), ), if (_imagePath != null) Positioned( top: 1.w, right: 1.w, child: Material( color: Colors.transparent, type: MaterialType.transparency, child: InkWell( onTap: () { setState(() { _imagePath = null; }); }, child: Container( padding: EdgeInsets.all(1.w), decoration: BoxDecoration( color: Colors.red[50], shape: BoxShape.circle, border: Border.all( color: Colors.red.withOpacity(0.2), ), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.1), blurRadius: 2, offset: Offset(0, 1), ), ], ), child: Icon( Icons.remove, color: Colors.red, size: 4.w, ), ), ), ), ), ], ), SizedBox(height: 3.h), Form( key: _generalKey, child: Column( children: [ CustomTextFormField( label: "Nama Outlet", hint: "Contoh: Outlet Perum Gunung Batu", prefixIcon: Icons.store_mall_directory_outlined, controller: _nameController, validator: (value) => _validation(value, "Nama outlet belum diisi"), ), SizedBox(height: 3.h), CustomTextFormField( label: "No. Handphone / WA Outlet", hint: "081234567890", prefixIcon: Icons.phone_android_outlined, controller: _phoneNumberController, keyboardType: TextInputType.phone, validator: (value) => _validation( value, "No. handphone outlet belum diisi", ), ), SizedBox(height: 3.h), CustomTextFormField( label: "Email", hint: "(Opsional)", prefixIcon: Icons.email_outlined, keyboardType: TextInputType.emailAddress, controller: _emailController, validator: (value) { if (value == null || value.isEmpty) { return null; } if (!_emailRegex.hasMatch(value)) { return "Email outlet tidak valid"; } return null; }, ), ], ), ), SizedBox(height: 3.h), Form( key: _firstAdminKey, child: StafAdmin( stafAdminNumber: 1, isFormVisible: _visibleFirstForm, onTap: () { setState(() { _visibleFirstForm = !_visibleFirstForm; }); }, stafAdmin: _firstStaffAdmin, passwordSuffixPressed: () { setState(() { _firstStaffAdmin.obsecurePassword = !_firstStaffAdmin.obsecurePassword; }); }, passwordConfirmSuffixPressed: () { setState(() { _firstStaffAdmin.obsecurePasswordConfirm = !_firstStaffAdmin.obsecurePasswordConfirm; }); }, onStartTimeTap: () => _selectTime(true, _firstStaffAdmin), onEndTimeTap: () => _selectTime(false, _firstStaffAdmin), nameValidator: (value) => _validation(value, "Nama staf belum diisi"), phoneNumberValidator: (value) => _validation( value, "No. Handphone staf belum diisi", ), emailValidator: (value) => _validation( value, "Email staff belum diisi", isEmail: true, ), passwordValidator: (value) => _validation( value, "Password belum diisi", isPassword: true, admin: _firstStaffAdmin, ), passwordConfirmValidator: (value) => _validation( value, "Konfirmasi password belum diisi", isPasswordConfirm: true, admin: _firstStaffAdmin, ), shiftNameValidator: (value) => _validation(value, "Nama shift belum diisi"), ), ), SizedBox(height: 3.h), Form( key: _secondAdminKey, child: StafAdmin( stafAdminNumber: 2, isFormVisible: _visbleSecondForm, onTap: () { setState(() { _visbleSecondForm = !_visbleSecondForm; }); }, stafAdmin: _secondStaffAdmin, passwordSuffixPressed: () { setState(() { _secondStaffAdmin.obsecurePassword = !_secondStaffAdmin.obsecurePassword; }); }, passwordConfirmSuffixPressed: () { setState(() { _secondStaffAdmin.obsecurePasswordConfirm = !_secondStaffAdmin.obsecurePasswordConfirm; }); }, onStartTimeTap: () => _selectTime(true, _secondStaffAdmin), onEndTimeTap: () => _selectTime(false, _secondStaffAdmin), nameValidator: (value) => _validation(value, "Nama staf belum diisi"), phoneNumberValidator: (value) => _validation( value, "No. Handphone staf belum diisi", ), emailValidator: (value) => _validation( value, "Email staff belum diisi", isEmail: true, ), passwordValidator: (value) => _validation( value, "Password belum diisi", isPassword: true, admin: _secondStaffAdmin, ), passwordConfirmValidator: (value) => _validation( value, "Konfirmasi password belum diisi", isPasswordConfirm: true, admin: _secondStaffAdmin, ), shiftNameValidator: (value) => _validation(value, "Nama shift belum diisi"), ), ), SizedBox(height: 3.h), Padding( padding: EdgeInsets.only(bottom: 1.5.h), child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Icons.info_outline, size: 3.5.w, color: AppColor.primaryColor, ), SizedBox(width: 0.75.w), Text( "Informasi alamat dapat dilengkapi nanti oleh staf admin", style: TextStyle( color: AppColor.primaryColor, fontSize: isTablet ? (AppFontSize.medium - 1.25).sp : (AppFontSize.small - 1.25).sp, ), ), ], ), ], ), ), Material( color: Colors.transparent, type: MaterialType.transparency, child: InkWell( onTap: () { context.pushNamed(AppRoute.mapOutletAdressScreen); }, child: Container( width: double.infinity, padding: EdgeInsets.symmetric( horizontal: 4.w, vertical: 2.h, ), decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(2.05.w), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( Icons.map_outlined, color: Colors.black, size: 5.w, ), SizedBox(width: 2.5.w), Expanded( child: Text( mapOutletAddressState != null ? mapOutletAddressState.fullAddress : "Atur alamat outlet", style: TextStyle( fontWeight: mapOutletAddressState != null ? FontWeight.normal : FontWeight.bold, fontSize: isTablet ? AppFontSize.medium.sp : AppFontSize.small.sp, height: 1.4, ), ), ), ], ), ), ), ), SizedBox(height: 3.h), ElevatedButton( onPressed: outletControllerState.isLoading ? null : _saveOutlet, style: ElevatedButton.styleFrom( backgroundColor: AppColor.primaryColor, minimumSize: Size(double.infinity, 7.h), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(3.w), ), disabledBackgroundColor: Colors.grey.shade300, ), child: Text( "Simpan", style: TextStyle( fontSize: AppFontSize.medium.sp, fontWeight: FontWeight.bold, color: Colors.white, ), ), ), SizedBox(height: 3.h), ], ), ), ), ), ); }, ); } }