diff --git a/lib/core/constants.dart b/lib/core/constants.dart index c562a7c..285b8c9 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -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; diff --git a/lib/models/member.dart b/lib/models/member.dart index f918ff6..34254d6 100644 --- a/lib/models/member.dart +++ b/lib/models/member.dart @@ -100,6 +100,7 @@ class Member { final double? fee; final List people; final List comments; + final Map> 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 json) => Member( @@ -157,6 +159,12 @@ class Member { comments: (json['comments'] as List? ?? []) .map((c) => MemberComment.fromJson(c as Map)) .toList(), + monthlyFees: (json['monthly_fees'] as Map? ?? {}).map( + (year, months) => MapEntry( + int.parse(year), + (months as List).map((v) => (v as num).toInt()).toList(), + ), + ), ); String get formattedId => id.toString().padLeft(5, '0'); diff --git a/lib/screens/members/members_screen.dart b/lib/screens/members/members_screen.dart index 0f66193..fdb5c1d 100644 --- a/lib/screens/members/members_screen.dart +++ b/lib/screens/members/members_screen.dart @@ -12,6 +12,7 @@ class MembersScreen extends StatefulWidget { class _MembersScreenState extends State { final _service = memberRepository(); + final _searchKey = GlobalKey<_MemberSearchState>(); Member? _member; bool _loading = true; String? _error; @@ -60,17 +61,20 @@ class _MembersScreenState extends State { 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 { // ── 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>( @@ -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(1–12) → status. Si está vacío todos los meses son amarillo. - final Map> data; + /// year → lista de 12 valores: -1 amarillo, 1 verde, 0 o 4 rojo. + final Map> 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(), ), + ), ); }), ], diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 8e5260b..bc479e4 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -39,6 +39,7 @@ class AuthService { } Future getToken() async { + return '11e2d9ac6355c3ed64e7651451f8a8f6fbbe9a6fa1a720a6f390dd598533faf5'; final prefs = await SharedPreferences.getInstance(); return prefs.getString(_tokenKey); } diff --git a/lib/services/mock/mock_member_service.dart b/lib/services/mock/mock_member_service.dart index 162c073..942ce96 100644 --- a/lib/services/mock/mock_member_service.dart +++ b/lib/services/mock/mock_member_service.dart @@ -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,