Files
prosappco/lib/screens/professional/professional_profile_screen.dart
T
Lizandro GuarnizoandClaude Sonnet 4.6 a65e671603 feat(ui): port web UI/UX improvements to mobile app
Port the 3 redesigned views from prosappweb to prosappco:

- Solicitudes: modern cards with 4px colored left strip, status badge with
  icon+border, avatar with status-colored ring, date/time row, empty state
- Calendario: card-wrapped TableCalendar with custom CalendarStyle (blue
  selected/ring today), day header card with date box + occupied/available
  stat pills, slot cards with colored strip and time badge instead of
  gradient circles
- Perfil profesional: gradient banner header with profession pill + camera
  overlay, section cards with icon+title, payment method chips (animated)
  instead of checkboxes, schedule rows with colored active/inactive dots,
  theme-aware colors throughout (respects dark/light mode)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 22:38:55 -05:00

653 lines
22 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;
bool isInit = 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 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!),
);
}
}
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;
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: _scheduleRows(context),
),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ElevatedButton(
onPressed: _save,
style: ElevatedButton.styleFrom(
backgroundColor: _kPrimary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
elevation: 0,
),
child: 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 _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,
));
context.read<ProfessionalProfileBloc>().add(
UpdateProfessionalBannerInfo(fileBanner: _imageFile?.path));
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Información actualizada correctamente')),
);
}
}
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)),
]),
),
);
}
}