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'; const String kApiBase = 'https://reservas.madriguera.me/2.0';
/// Cambiar a false para usar la API real (necesita token). /// 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 double? fee;
final List<Person> people; final List<Person> people;
final List<MemberComment> comments; final List<MemberComment> comments;
final Map<int, List<int>> monthlyFees;
const Member({ const Member({
required this.id, required this.id,
@ -126,6 +127,7 @@ class Member {
this.fee, this.fee,
required this.people, required this.people,
required this.comments, required this.comments,
required this.monthlyFees,
}); });
factory Member.fromJson(Map<String, dynamic> json) => Member( factory Member.fromJson(Map<String, dynamic> json) => Member(
@ -157,6 +159,12 @@ class Member {
comments: (json['comments'] as List<dynamic>? ?? []) comments: (json['comments'] as List<dynamic>? ?? [])
.map((c) => MemberComment.fromJson(c as Map<String, dynamic>)) .map((c) => MemberComment.fromJson(c as Map<String, dynamic>))
.toList(), .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'); String get formattedId => id.toString().padLeft(5, '0');

View file

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

View file

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

View file

@ -68,6 +68,12 @@ final _member7 = Member(
retenido: false, retenido: false,
unregDate: null, unregDate: null,
unregReason: '', 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: [ people: [
Person( Person(
memberId: 7, family: 1, memberId: 7, family: 1,
@ -138,6 +144,12 @@ final _member42 = Member(
retenido: false, retenido: false,
unregDate: null, unregDate: null,
unregReason: '', 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: [ people: [
Person( Person(
memberId: 42, family: 1, memberId: 42, family: 1,
@ -191,6 +203,12 @@ final _member85 = Member(
retenido: true, retenido: true,
unregDate: null, unregDate: null,
unregReason: '', 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: [ people: [
Person( Person(
memberId: 85, family: 1, memberId: 85, family: 1,