Root cause behind most "stuck loading" reports: the backend changed shape (schedules became an array, location_preferences a string) while the mobile parser still hard-cast to Map/int. The TypeError was swallowed by a silent catch that returned null, and screens only handled the success state, so a parse failure rendered as a permanent spinner. Same class of bug appeared across service lists via non-null map lookups and a total absence of request timeouts. Ported from prosappweb: - in-app suggestions (POST /suggestions) - policies/terms from GET /settings/policies - configurable appointment length (slot_duration_minutes) - block/unblock calendar slots (POST /services/block) - GPS city detection on the profile (Nominatim) - server-side professional search with haversine distance - retry cooldown after a rejected professional application Reliability: - parse schedules array (day_of_week 0=Mon) and string location_preferences - read times as wall clock, so 08:00 stays 08:00 across timezones - carry minutes into hours in TimeOfDay.add; a minute-based step used to loop forever and freeze the calendar (covered by test/time_slots_test.dart) - semver update check instead of string equality, which blocked every build that did not exactly match the configured version - request timeouts across all repositories - surface HTTP >= 400 instead of reporting failed writes as success - error states with retry instead of an indefinite shimmer Includes pre-existing uncommitted work from the UI redesign. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
745 lines
25 KiB
Dart
745 lines
25 KiB
Dart
import 'dart:io';
|
|
import 'package:flutter/cupertino.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:injector/injector.dart';
|
|
import 'package:professional_repository/professional_repository.dart';
|
|
import 'package:prosappco/blocs/professional_bloc/professional_bloc.dart';
|
|
import 'package:prosappco/blocs/professional_profile_bloc/professional_profile_bloc.dart';
|
|
import 'package:prosappco/screens/professional/professional_map_screen.dart';
|
|
import 'package:prosappco/screens/professional/professional_schedule_screen.dart';
|
|
import 'package:setting_repository/setting_repository.dart';
|
|
|
|
const _kPrimary = Color(0xFF1565C0);
|
|
const _kAccent = Color(0xFF42A4EF);
|
|
|
|
extension _Th on BuildContext {
|
|
ThemeData get _t => Theme.of(this);
|
|
Color get bg => _t.scaffoldBackgroundColor;
|
|
Color get card => _t.cardColor;
|
|
Color get onSurface => _t.colorScheme.onSurface;
|
|
Color get muted => _t.colorScheme.onSurface.withOpacity(0.55);
|
|
Color get subtle => _t.colorScheme.onSurface.withOpacity(0.35);
|
|
bool get isDark => _t.brightness == Brightness.dark;
|
|
Color get shadowSm =>
|
|
isDark ? Colors.transparent : Colors.black.withOpacity(0.05);
|
|
}
|
|
|
|
class ProfessionalProfileScreen extends StatefulWidget {
|
|
const ProfessionalProfileScreen({super.key});
|
|
|
|
@override
|
|
State<ProfessionalProfileScreen> createState() =>
|
|
_ProfessionalProfileScreenState();
|
|
}
|
|
|
|
class _ProfessionalProfileScreenState
|
|
extends State<ProfessionalProfileScreen> {
|
|
final settingRepository = Injector.appInstance.get<SettingRepository>();
|
|
SettingEntity? settings;
|
|
XFile? _imageFile;
|
|
|
|
bool loadFinish = false;
|
|
bool officeValue = false;
|
|
bool deliveryValue = false;
|
|
bool rateValue = false;
|
|
|
|
final _addressController = TextEditingController();
|
|
final _aditionalAddressController = TextEditingController();
|
|
final _rateController = TextEditingController();
|
|
|
|
Schedules schedules = Schedules.empty;
|
|
int _slotDurationMinutes = 120;
|
|
bool isInit = false;
|
|
bool _isSaving = false;
|
|
|
|
double _longitudeController = 0;
|
|
double _latitudeController = 0;
|
|
|
|
bool isNequiActive = false;
|
|
bool isDatafonoActive = false;
|
|
bool isTransferActive = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
settingRepository.getSettings().then(
|
|
(v) => setState(() => settings = v),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_addressController.dispose();
|
|
_aditionalAddressController.dispose();
|
|
_rateController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocListener<ProfessionalProfileBloc, ProfessionalProfileState>(
|
|
listener: (context, saveState) {
|
|
if (saveState is UpdateProfessionalInfoLoading) {
|
|
setState(() => _isSaving = true);
|
|
} else if (saveState is UpdateProfessionalInfoSuccess) {
|
|
setState(() => _isSaving = false);
|
|
ScaffoldMessenger.of(context).clearSnackBars();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Información actualizada correctamente')),
|
|
);
|
|
} else if (saveState is UpdateProfessionalInfoFailure) {
|
|
setState(() => _isSaving = false);
|
|
ScaffoldMessenger.of(context).clearSnackBars();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text(
|
|
'No se pudo guardar. Revisa tu conexión e inténtalo de nuevo.')),
|
|
);
|
|
}
|
|
},
|
|
child: Scaffold(
|
|
backgroundColor: context.bg,
|
|
appBar: AppBar(
|
|
title: const Text('Perfil Profesional'),
|
|
backgroundColor: _kPrimary,
|
|
foregroundColor: Colors.white,
|
|
elevation: 0,
|
|
),
|
|
body: BlocBuilder<ProfessionalBloc, ProfessionalState>(
|
|
builder: (context, state) {
|
|
if (state is LoadedModeProState) {
|
|
if (state.isProModeActive) {
|
|
if (state.proInfo == null) {
|
|
return const Center(child: CircularProgressIndicator(color: _kPrimary));
|
|
}
|
|
if (!isInit) {
|
|
schedules = state.proInfo!.schedules;
|
|
isInit = true;
|
|
}
|
|
return SingleChildScrollView(
|
|
child: _body(context, state.proInfo!),
|
|
);
|
|
}
|
|
}
|
|
if (state is ProfessionalStateFailure) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.error_outline, size: 40, color: context.subtle),
|
|
const SizedBox(height: 12),
|
|
const Text('No se pudo cargar tu perfil profesional'),
|
|
const SizedBox(height: 16),
|
|
OutlinedButton(
|
|
onPressed: () => context
|
|
.read<ProfessionalBloc>()
|
|
.add(const UpdateProfessionalEvent(isProModeActive: true)),
|
|
child: const Text('Reintentar'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return const Center(child: Text('Vuelve atrás'));
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _body(BuildContext context, ProfessionalEntity proInfo) {
|
|
if (!loadFinish) {
|
|
_addressController.text = proInfo.address;
|
|
_aditionalAddressController.text = proInfo.aditionalAddress;
|
|
_longitudeController = proInfo.longitude;
|
|
_latitudeController = proInfo.latitude;
|
|
isNequiActive = proInfo.paymentMethods.nequi;
|
|
isDatafonoActive = proInfo.paymentMethods.datafono;
|
|
isTransferActive = proInfo.paymentMethods.transferencia;
|
|
if (proInfo.locationPreferences == LocationPreferences.both) {
|
|
officeValue = true;
|
|
deliveryValue = true;
|
|
} else if (proInfo.locationPreferences == LocationPreferences.office) {
|
|
officeValue = true;
|
|
deliveryValue = false;
|
|
} else if (proInfo.locationPreferences == LocationPreferences.delivery) {
|
|
officeValue = false;
|
|
deliveryValue = true;
|
|
}
|
|
_rateController.text = proInfo.rate;
|
|
_slotDurationMinutes = proInfo.slotDurationMinutes;
|
|
loadFinish = true;
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_bannerHeader(context, proInfo),
|
|
const SizedBox(height: 16),
|
|
_sectionCard(
|
|
context,
|
|
icon: Icons.place_outlined,
|
|
title: 'Modalidad de servicio',
|
|
child: Column(children: [
|
|
if (settings?.domicilios ?? false)
|
|
_toggleRow(
|
|
context,
|
|
icon: Icons.delivery_dining_outlined,
|
|
title: 'Servicio a domicilio',
|
|
subtitle: 'El profesional se desplaza al cliente',
|
|
value: deliveryValue,
|
|
onChanged: (v) => setState(() {
|
|
deliveryValue = v;
|
|
if (!deliveryValue) officeValue = true;
|
|
}),
|
|
),
|
|
_toggleRow(
|
|
context,
|
|
icon: Icons.storefront_outlined,
|
|
title: 'Servicio en sitio',
|
|
subtitle: 'El cliente asiste al lugar del profesional',
|
|
value: officeValue,
|
|
onChanged: (v) => setState(() {
|
|
if (settings?.domicilios == true) {
|
|
officeValue = v;
|
|
if (!officeValue) deliveryValue = true;
|
|
} else {
|
|
officeValue = true;
|
|
deliveryValue = false;
|
|
}
|
|
}),
|
|
),
|
|
if (officeValue) ...[
|
|
const SizedBox(height: 8),
|
|
_addressField(context),
|
|
],
|
|
]),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_sectionCard(
|
|
context,
|
|
icon: Icons.attach_money_outlined,
|
|
title: 'Tarifa',
|
|
child: Column(children: [
|
|
_toggleRow(
|
|
context,
|
|
icon: Icons.monetization_on_outlined,
|
|
title: 'Mostrar tarifa',
|
|
subtitle: 'Visible para los clientes',
|
|
value: rateValue,
|
|
onChanged: (v) => setState(() {
|
|
rateValue = (settings?.tarifas == true) ? v : false;
|
|
}),
|
|
),
|
|
if (rateValue) ...[
|
|
const SizedBox(height: 8),
|
|
TextField(
|
|
controller: _rateController,
|
|
keyboardType: TextInputType.number,
|
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
|
decoration: InputDecoration(
|
|
prefixIcon:
|
|
Icon(Icons.attach_money, color: context.muted),
|
|
hintText: 'Valor en COP',
|
|
filled: true,
|
|
fillColor: context.isDark
|
|
? Colors.white.withOpacity(0.05)
|
|
: Colors.black.withOpacity(0.04),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
]),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_sectionCard(
|
|
context,
|
|
icon: Icons.credit_card_outlined,
|
|
title: 'Métodos de pago',
|
|
child: Wrap(spacing: 8, runSpacing: 8, children: [
|
|
_PayChip(
|
|
label: 'Datafono',
|
|
icon: Icons.credit_card,
|
|
active: isDatafonoActive,
|
|
onTap: () => setState(() => isDatafonoActive = !isDatafonoActive),
|
|
),
|
|
_PayChip(
|
|
label: 'Nequi',
|
|
icon: Icons.phone_android_outlined,
|
|
active: isNequiActive,
|
|
onTap: () => setState(() => isNequiActive = !isNequiActive),
|
|
),
|
|
_PayChip(
|
|
label: 'Transferencia',
|
|
icon: Icons.swap_horiz,
|
|
active: isTransferActive,
|
|
onTap: () =>
|
|
setState(() => isTransferActive = !isTransferActive),
|
|
),
|
|
]),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_sectionCard(
|
|
context,
|
|
icon: Icons.calendar_month_outlined,
|
|
title: 'Horarios',
|
|
trailing: GestureDetector(
|
|
onTap: _openScheduleEditor,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
color: _kPrimary.withOpacity(0.08),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: const Text('Editar',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: _kPrimary)),
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_slotDurationDropdown(context),
|
|
const SizedBox(height: 8),
|
|
_scheduleRows(context),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: ElevatedButton(
|
|
onPressed: _isSaving ? null : _save,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: _kPrimary,
|
|
foregroundColor: Colors.white,
|
|
disabledBackgroundColor: _kPrimary.withOpacity(0.5),
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12)),
|
|
elevation: 0,
|
|
),
|
|
child: _isSaving
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2, color: Colors.white),
|
|
)
|
|
: const Text('Guardar cambios',
|
|
style:
|
|
TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
|
|
),
|
|
),
|
|
const SizedBox(height: 32),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _bannerHeader(BuildContext context, ProfessionalEntity proInfo) {
|
|
final pathImageFile = _imageFile?.path;
|
|
ImageProvider<Object>? bannerImage;
|
|
if (pathImageFile != null && pathImageFile.isNotEmpty) {
|
|
bannerImage = FileImage(File(pathImageFile));
|
|
} else if (proInfo.bannerPicture.isNotEmpty) {
|
|
bannerImage = NetworkImage(proInfo.bannerPicture);
|
|
}
|
|
|
|
return GestureDetector(
|
|
onTap: _pickBannerImage,
|
|
child: Container(
|
|
height: 180,
|
|
decoration: BoxDecoration(
|
|
gradient: const LinearGradient(
|
|
colors: [_kPrimary, _kAccent],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
image: bannerImage != null
|
|
? DecorationImage(image: bannerImage, fit: BoxFit.cover,
|
|
colorFilter: ColorFilter.mode(
|
|
Colors.black.withOpacity(0.35), BlendMode.darken))
|
|
: null,
|
|
),
|
|
child: Stack(children: [
|
|
Positioned(
|
|
bottom: 14,
|
|
right: 14,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black26,
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: const Row(mainAxisSize: MainAxisSize.min, children: [
|
|
Icon(Icons.camera_alt_outlined, color: Colors.white, size: 14),
|
|
SizedBox(width: 5),
|
|
Text('Cambiar banner',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600)),
|
|
]),
|
|
),
|
|
),
|
|
if (proInfo.profession.isNotEmpty)
|
|
Positioned(
|
|
bottom: 14,
|
|
left: 14,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.2),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: Colors.white38),
|
|
),
|
|
child: Text(proInfo.profession,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600)),
|
|
),
|
|
),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _sectionCard(
|
|
BuildContext context, {
|
|
required IconData icon,
|
|
required String title,
|
|
required Widget child,
|
|
Widget? trailing,
|
|
}) {
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 16),
|
|
decoration: BoxDecoration(
|
|
color: context.card,
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: context.shadowSm,
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 2))
|
|
],
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Row(children: [
|
|
Container(
|
|
width: 32,
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
color: _kPrimary.withOpacity(0.08),
|
|
borderRadius: BorderRadius.circular(8)),
|
|
child: Icon(icon, size: 17, color: _kPrimary),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(title,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
color: context.onSurface)),
|
|
),
|
|
if (trailing != null) trailing,
|
|
]),
|
|
const SizedBox(height: 14),
|
|
child,
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _toggleRow(
|
|
BuildContext context, {
|
|
required IconData icon,
|
|
required String title,
|
|
required String subtitle,
|
|
required bool value,
|
|
required ValueChanged<bool> onChanged,
|
|
}) {
|
|
return Row(children: [
|
|
Icon(icon, size: 18, color: context.muted),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text(title,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: context.onSurface)),
|
|
Text(subtitle,
|
|
style: TextStyle(fontSize: 11, color: context.muted)),
|
|
]),
|
|
),
|
|
Switch(value: value, onChanged: onChanged, activeColor: _kPrimary),
|
|
]);
|
|
}
|
|
|
|
Widget _addressField(BuildContext context) {
|
|
return Column(children: [
|
|
GestureDetector(
|
|
onTap: () async {
|
|
final Map<String, dynamic>? mapInfo = await Navigator.push(
|
|
context,
|
|
CupertinoPageRoute(
|
|
builder: (_) => const ProfessionalMapScreen()),
|
|
);
|
|
if (mapInfo != null) {
|
|
setState(() {
|
|
_addressController.text = mapInfo['address'] ?? '';
|
|
_latitudeController = mapInfo['latitude'] ?? 0;
|
|
_longitudeController = mapInfo['longitude'] ?? 0;
|
|
});
|
|
}
|
|
},
|
|
child: AbsorbPointer(
|
|
child: TextField(
|
|
controller: _addressController,
|
|
decoration: InputDecoration(
|
|
prefixIcon:
|
|
Icon(Icons.location_on_outlined, color: context.muted),
|
|
hintText: 'Dirección',
|
|
filled: true,
|
|
fillColor: context.isDark
|
|
? Colors.white.withOpacity(0.05)
|
|
: Colors.black.withOpacity(0.04),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide: BorderSide.none),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
TextField(
|
|
controller: _aditionalAddressController,
|
|
decoration: InputDecoration(
|
|
prefixIcon: Icon(Icons.house_outlined, color: context.muted),
|
|
hintText: 'Piso / Conjunto / Apartamento',
|
|
filled: true,
|
|
fillColor: context.isDark
|
|
? Colors.white.withOpacity(0.05)
|
|
: Colors.black.withOpacity(0.04),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide: BorderSide.none),
|
|
),
|
|
),
|
|
]);
|
|
}
|
|
|
|
Widget _slotDurationDropdown(BuildContext context) {
|
|
const options = [15, 20, 30, 45, 60, 90, 120];
|
|
final value = options.contains(_slotDurationMinutes)
|
|
? _slotDurationMinutes
|
|
: 120;
|
|
return DropdownButtonFormField<int>(
|
|
value: value,
|
|
decoration: InputDecoration(
|
|
labelText: 'Duración de cada cita',
|
|
prefixIcon: Icon(Icons.timer_outlined, color: context.muted),
|
|
filled: true,
|
|
fillColor: context.isDark
|
|
? Colors.white.withOpacity(0.05)
|
|
: Colors.black.withOpacity(0.04),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
),
|
|
items: options
|
|
.map((m) => DropdownMenuItem(
|
|
value: m,
|
|
child: Text(m < 60 ? '$m min' : '${m ~/ 60}h${m % 60 == 0 ? '' : ' ${m % 60}min'}'),
|
|
))
|
|
.toList(),
|
|
onChanged: (v) {
|
|
if (v != null) setState(() => _slotDurationMinutes = v);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _scheduleRows(BuildContext context) {
|
|
final days = <_DayEntry>[
|
|
_DayEntry('Lunes', schedules.monday),
|
|
_DayEntry('Martes', schedules.tuesday),
|
|
_DayEntry('Miércoles', schedules.wednesday),
|
|
_DayEntry('Jueves', schedules.thursday),
|
|
_DayEntry('Viernes', schedules.friday),
|
|
_DayEntry('Sábado', schedules.saturday),
|
|
_DayEntry('Domingo', schedules.sunday),
|
|
];
|
|
|
|
return Column(
|
|
children: days.map((entry) {
|
|
final name = entry.name;
|
|
final s = entry.schedule;
|
|
final active = s != null && s.enabled;
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 5),
|
|
child: Row(children: [
|
|
Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(
|
|
color: active
|
|
? const Color(0xFF16A34A)
|
|
: context.subtle,
|
|
shape: BoxShape.circle),
|
|
),
|
|
const SizedBox(width: 10),
|
|
SizedBox(
|
|
width: 80,
|
|
child: Text(name,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500,
|
|
color: context.onSurface)),
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
_formatSchedule(s),
|
|
style: TextStyle(fontSize: 12, color: context.muted),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
]),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}
|
|
|
|
String _formatSchedule(ScheduleEntity? s) {
|
|
if (s == null || !s.enabled || s.range1Hour1 == null || s.range2Hour2 == null) {
|
|
return 'Sin horario';
|
|
}
|
|
if (s.continuousDay) {
|
|
return '${ScheduleEntity.getFormatTime(s.range1Hour1)} - ${ScheduleEntity.getFormatTime(s.range2Hour2)}';
|
|
}
|
|
if (s.range1Hour2 == null || s.range2Hour1 == null) return 'Sin horario';
|
|
return '${ScheduleEntity.getFormatTime(s.range1Hour1)} a ${ScheduleEntity.getFormatTime(s.range1Hour2)} · '
|
|
'${ScheduleEntity.getFormatTime(s.range2Hour1)} a ${ScheduleEntity.getFormatTime(s.range2Hour2)}';
|
|
}
|
|
|
|
Future<void> _pickBannerImage() async {
|
|
final picker = ImagePicker();
|
|
final image = await picker.pickImage(
|
|
source: ImageSource.gallery,
|
|
maxHeight: 1000,
|
|
maxWidth: 2000,
|
|
imageQuality: 40,
|
|
);
|
|
if (image != null) setState(() => _imageFile = image);
|
|
}
|
|
|
|
Future<void> _openScheduleEditor() async {
|
|
final result = await Navigator.push<Schedules>(
|
|
context,
|
|
CupertinoPageRoute(
|
|
builder: (_) =>
|
|
ProfessionalScheduleScreen(schedules: schedules)),
|
|
);
|
|
if (result != null) setState(() => schedules = result);
|
|
}
|
|
|
|
void _save() {
|
|
context.read<ProfessionalProfileBloc>().add(UpdateProfessionalProfileInfo(
|
|
address: _addressController.text,
|
|
aditionalAddress: _aditionalAddressController.text,
|
|
latitude: _latitudeController,
|
|
longitude: _longitudeController,
|
|
locationPreferences: officeValue && deliveryValue
|
|
? LocationPreferences.both
|
|
: officeValue && !deliveryValue
|
|
? LocationPreferences.office
|
|
: deliveryValue && !officeValue
|
|
? LocationPreferences.delivery
|
|
: LocationPreferences.office,
|
|
paymentMethods: PaymentMethodEntity(
|
|
datafono: isDatafonoActive,
|
|
nequi: isNequiActive,
|
|
transferencia: isTransferActive,
|
|
),
|
|
schedules: schedules,
|
|
ratePreferences: rateValue,
|
|
rate: _rateController.text,
|
|
slotDurationMinutes: _slotDurationMinutes,
|
|
));
|
|
if (_imageFile != null) {
|
|
context.read<ProfessionalProfileBloc>().add(
|
|
UpdateProfessionalBannerInfo(fileBanner: _imageFile!.path));
|
|
}
|
|
}
|
|
}
|
|
|
|
class _DayEntry {
|
|
final String name;
|
|
final ScheduleEntity? schedule;
|
|
const _DayEntry(this.name, this.schedule);
|
|
}
|
|
|
|
class _PayChip extends StatelessWidget {
|
|
final String label;
|
|
final IconData icon;
|
|
final bool active;
|
|
final VoidCallback onTap;
|
|
const _PayChip({
|
|
required this.label,
|
|
required this.icon,
|
|
required this.active,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final color = active ? _kPrimary : context.subtle;
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 180),
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: active
|
|
? _kPrimary.withOpacity(0.1)
|
|
: context.isDark
|
|
? Colors.white.withOpacity(0.05)
|
|
: Colors.black.withOpacity(0.03),
|
|
borderRadius: BorderRadius.circular(24),
|
|
border: Border.all(
|
|
color: active ? _kPrimary.withOpacity(0.5) : color.withOpacity(0.2)),
|
|
),
|
|
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
|
if (active)
|
|
const Padding(
|
|
padding: EdgeInsets.only(right: 5),
|
|
child: Icon(Icons.check, size: 13, color: _kPrimary),
|
|
),
|
|
Icon(icon, size: 15, color: color),
|
|
const SizedBox(width: 6),
|
|
Text(label,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: color)),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|