This commit is contained in:
Daniel Esteban 2026-03-18 23:20:31 +01:00
parent 34e7cbc382
commit 425ee590fa
5 changed files with 127 additions and 15 deletions

View file

@ -1,4 +1,4 @@
const String kApiBase = 'https://reservas.madriguera.me/2.0';
/// Cambiar a false para usar la API real (necesita token).
const bool kUseMock = true;
const bool kUseMock = false;

View file

@ -100,6 +100,7 @@ class Member {
final double? fee;
final List<Person> people;
final List<MemberComment> comments;
final Map<int, List<int>> monthlyFees;
const Member({
required this.id,
@ -126,6 +127,7 @@ class Member {
this.fee,
required this.people,
required this.comments,
required this.monthlyFees,
});
factory Member.fromJson(Map<String, dynamic> json) => Member(
@ -157,6 +159,12 @@ class Member {
comments: (json['comments'] as List<dynamic>? ?? [])
.map((c) => MemberComment.fromJson(c as Map<String, dynamic>))
.toList(),
monthlyFees: (json['monthly_fees'] as Map<String, dynamic>? ?? {}).map(
(year, months) => MapEntry(
int.parse(year),
(months as List<dynamic>).map((v) => (v as num).toInt()).toList(),
),
),
);
String get formattedId => id.toString().padLeft(5, '0');

View file

@ -12,6 +12,7 @@ class MembersScreen extends StatefulWidget {
class _MembersScreenState extends State<MembersScreen> {
final _service = memberRepository();
final _searchKey = GlobalKey<_MemberSearchState>();
Member? _member;
bool _loading = true;
String? _error;
@ -60,17 +61,20 @@ class _MembersScreenState extends State<MembersScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_NavBar(
searchKey: _searchKey,
member: _member,
loading: _loading,
service: _service,
onFirst: () => _load(_service.first()),
onFirst: () { _searchKey.currentState?.clear(); _load(_service.first()); },
onPrev: () {
_searchKey.currentState?.clear();
if (_member != null) _load(_service.prev(_member!.id));
},
onNext: () {
_searchKey.currentState?.clear();
if (_member != null) _load(_service.next(_member!.id));
},
onLast: () => _load(_service.last()),
onLast: () { _searchKey.currentState?.clear(); _load(_service.last()); },
onSearch: (id) => _load(_service.show(id)),
onAction: _handleAction,
),
@ -92,6 +96,7 @@ class _MembersScreenState extends State<MembersScreen> {
// Nav bar
class _NavBar extends StatelessWidget {
final GlobalKey<_MemberSearchState> searchKey;
final Member? member;
final bool loading;
final MemberRepository service;
@ -100,6 +105,7 @@ class _NavBar extends StatelessWidget {
final void Function(String action) onAction;
const _NavBar({
required this.searchKey,
required this.member,
required this.loading,
required this.service,
@ -122,7 +128,7 @@ class _NavBar extends StatelessWidget {
child: Row(
children: [
Expanded(
child: _MemberSearch(service: service, onSelected: onSearch),
child: _MemberSearch(key: searchKey, service: service, onSelected: onSearch),
),
const SizedBox(width: 4),
IconButton(
@ -207,13 +213,17 @@ class _MemberSearch extends StatefulWidget {
final MemberRepository service;
final void Function(int id) onSelected;
const _MemberSearch({required this.service, required this.onSelected});
const _MemberSearch({super.key, required this.service, required this.onSelected});
@override
State<_MemberSearch> createState() => _MemberSearchState();
}
class _MemberSearchState extends State<_MemberSearch> {
TextEditingController? _controller;
void clear() => _controller?.clear();
@override
Widget build(BuildContext context) {
return Autocomplete<MapEntry<String, String>>(
@ -227,11 +237,23 @@ class _MemberSearchState extends State<_MemberSearch> {
// key format: 5-digit member_id + 2-digit family index
final memberId = int.parse(entry.key.substring(0, entry.key.length - 2));
widget.onSelected(memberId);
_controller?.clear();
},
fieldViewBuilder: (context, controller, focusNode, onSubmit) {
_controller = controller;
return TextField(
controller: controller,
focusNode: focusNode,
onTap: () => controller.clear(),
onSubmitted: (text) {
final n = int.tryParse(text.trim());
if (n != null) {
widget.onSelected(n);
controller.clear();
} else {
onSubmit();
}
},
decoration: const InputDecoration(
hintText: 'Buscar abonado...',
prefixIcon: Icon(Icons.search),
@ -447,7 +469,7 @@ class _InfoCard extends StatelessWidget {
const SizedBox(height: 14),
// Franjas de años
_YearStrips(),
_YearStrips(data: member.monthlyFees),
// Info de baja si está dado de baja
if (!member.isActive && member.unregDate != null) ...[
@ -468,8 +490,8 @@ class _InfoCard extends StatelessWidget {
enum _MonthStatus { unknown, paid, unpaid }
class _YearStrips extends StatelessWidget {
/// year month(112) status. Si está vacío todos los meses son amarillo.
final Map<int, Map<int, _MonthStatus>> data;
/// year lista de 12 valores: -1 amarillo, 1 verde, 0 o 4 rojo.
final Map<int, List<int>> data;
const _YearStrips({this.data = const {}});
@ -478,6 +500,12 @@ class _YearStrips extends StatelessWidget {
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
];
static _MonthStatus _toStatus(int v) => switch (v) {
1 => _MonthStatus.paid,
0 || 4 => _MonthStatus.unpaid,
_ => _MonthStatus.unknown,
};
@override
Widget build(BuildContext context) {
final now = DateTime.now();
@ -485,7 +513,7 @@ class _YearStrips extends StatelessWidget {
return Column(
children: years.map((year) {
final yearData = data[year] ?? {};
final months = data[year] ?? [];
return Padding(
padding: const EdgeInsets.only(bottom: 3),
child: Row(
@ -498,7 +526,9 @@ class _YearStrips extends StatelessWidget {
color: Theme.of(context).colorScheme.outline)),
),
...List.generate(12, (i) {
final status = yearData[i + 1] ?? _MonthStatus.unknown;
final status = i < months.length
? _toStatus(months[i])
: _MonthStatus.unknown;
return Expanded(child: _MonthCell(month: _months[i], status: status));
}),
],
@ -589,6 +619,44 @@ class _CommentsCard extends StatelessWidget {
final Member member;
const _CommentsCard({required this.member});
void _showAddCommentDialog(BuildContext context) {
final controller = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Añadir comentario'),
content: TextField(
controller: controller,
maxLines: 4,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Escribe el comentario...',
border: OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Cancelar'),
),
FilledButton(
onPressed: () {
Navigator.of(ctx).pop();
// TODO: llamar al endpoint de creación de comentario
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Guardar comentario — pendiente de implementar'),
duration: Duration(seconds: 2),
),
);
},
child: const Text('Guardar'),
),
],
),
);
}
bool get _hasContent =>
member.comment.isNotEmpty ||
member.comment2.isNotEmpty ||
@ -604,8 +672,22 @@ class _CommentsCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Comentarios',
style: Theme.of(context).textTheme.titleMedium),
Row(
children: [
Expanded(
child: Text('Comentarios',
style: Theme.of(context).textTheme.titleMedium),
),
TextButton.icon(
onPressed: () => _showAddCommentDialog(context),
icon: const Icon(Icons.add, size: 16),
label: const Text('Añadir'),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
),
const Divider(height: 20),
if (!_hasContent)
Text('Sin comentarios',
@ -688,9 +770,11 @@ class _FamiliarsCard extends StatelessWidget {
fontSize: 13))
else
LayoutBuilder(builder: (context, constraints) {
return SizedBox(
width: constraints.maxWidth,
child: DataTable(
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: DataTable(
headingRowHeight: 36,
dataRowMinHeight: 52,
dataRowMaxHeight: 60,
@ -712,6 +796,7 @@ class _FamiliarsCard extends StatelessWidget {
],
rows: people.map((p) => _personRow(context, p)).toList(),
),
),
);
}),
],

View file

@ -39,6 +39,7 @@ class AuthService {
}
Future<String?> getToken() async {
return '11e2d9ac6355c3ed64e7651451f8a8f6fbbe9a6fa1a720a6f390dd598533faf5';
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_tokenKey);
}

View file

@ -68,6 +68,12 @@ final _member7 = Member(
retenido: false,
unregDate: null,
unregReason: '',
monthlyFees: {
2025: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
2026: [1, 1, 0, -1, -1, -1, -1, -1, -1, -1, -1, -1],
2027: [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
2028: [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
},
people: [
Person(
memberId: 7, family: 1,
@ -138,6 +144,12 @@ final _member42 = Member(
retenido: false,
unregDate: null,
unregReason: '',
monthlyFees: {
2025: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
2026: [1, 1, 1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
2027: [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
2028: [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
},
people: [
Person(
memberId: 42, family: 1,
@ -191,6 +203,12 @@ final _member85 = Member(
retenido: true,
unregDate: null,
unregReason: '',
monthlyFees: {
2025: [1, 1, 1, 1, 1, 0, 4, 0, 0, 0, 0, 0],
2026: [0, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
2027: [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
2028: [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
},
people: [
Person(
memberId: 85, family: 1,