diff --git a/lib/core/common/utils/snackbar_message.dart b/lib/core/common/utils/snackbar_message.dart index 92bbc38..3983fa8 100644 --- a/lib/core/common/utils/snackbar_message.dart +++ b/lib/core/common/utils/snackbar_message.dart @@ -1,19 +1,39 @@ import 'package:flutter/material.dart'; extension SnackbarMessage on BuildContext { + SnackBarAction _dismissAction() { + return SnackBarAction( + label: '', + onPressed: () => ScaffoldMessenger.of(this).hideCurrentSnackBar(), + ); + } + void showSnackbar(String message) { - ScaffoldMessenger.of(this).showSnackBar(SnackBar(content: Text(message))); + ScaffoldMessenger.of(this).showSnackBar( + SnackBar( + content: Text(message), + action: _dismissAction(), + ), + ); } void showErrorSnackbar(String message) { ScaffoldMessenger.of(this).showSnackBar( - SnackBar(content: Text(message), backgroundColor: Theme.of(this).colorScheme.error), + SnackBar( + content: Text(message), + backgroundColor: Theme.of(this).colorScheme.error, + action: _dismissAction(), + ), ); } void showSuccessSnackbar(String message) { ScaffoldMessenger.of(this).showSnackBar( - SnackBar(content: Text(message), backgroundColor: Theme.of(this).colorScheme.primary), + SnackBar( + content: Text(message), + backgroundColor: Theme.of(this).colorScheme.primary, + action: _dismissAction(), + ), ); } } diff --git a/lib/core/common/utils/user_display_name.dart b/lib/core/common/utils/user_display_name.dart new file mode 100644 index 0000000..47cc9ae --- /dev/null +++ b/lib/core/common/utils/user_display_name.dart @@ -0,0 +1,9 @@ +import 'package:anystep/core/features/profile/domain/user_model.dart'; + +String displayNameWithLastInitial(UserModel user) { + final last = user.lastName.trim(); + if (last.isEmpty) { + return user.firstName; + } + return '${user.firstName} ${last[0].toUpperCase()}.'; +} diff --git a/lib/core/config/router/routes.dart b/lib/core/config/router/routes.dart index 820be78..6d7aaf6 100644 --- a/lib/core/config/router/routes.dart +++ b/lib/core/config/router/routes.dart @@ -218,7 +218,8 @@ final routes = [ builder: (context, state) { final id = int.tryParse(state.pathParameters['id'] ?? ''); if (id == null) return _invalidRouteIdScreen(context); - return AddAttendeeScreen(eventId: id); + final userEventId = int.tryParse(state.uri.queryParameters['userEventId'] ?? ''); + return AddAttendeeScreen(eventId: id, userEventId: userEventId); }, ), GoRoute( @@ -231,6 +232,20 @@ final routes = [ name: CreateUserScreen.name, builder: (context, state) => const CreateUserScreen(), ), + GoRoute( + path: ReportDetailScreen.pathAdmin, + name: ReportDetailScreen.nameAdmin, + builder: (context, state) { + final userId = state.pathParameters['userId']; + if (userId == null || userId.isEmpty) return _invalidRouteIdScreen(context); + final startMs = int.tryParse(state.uri.queryParameters['start'] ?? ''); + final endMs = int.tryParse(state.uri.queryParameters['end'] ?? ''); + final now = DateTime.now(); + final start = startMs != null ? DateTime.fromMillisecondsSinceEpoch(startMs) : DateTime(now.year, 1, 1); + final end = endMs != null ? DateTime.fromMillisecondsSinceEpoch(endMs) : now; + return ReportDetailScreen(userId: userId, start: start, end: end); + }, + ), GoRoute( path: BlogChannelDetailScreen.path, name: BlogChannelDetailScreen.name, diff --git a/lib/core/config/theme/colors.dart b/lib/core/config/theme/colors.dart index 0eac481..6b343ba 100644 --- a/lib/core/config/theme/colors.dart +++ b/lib/core/config/theme/colors.dart @@ -4,7 +4,7 @@ class AnyStepColors { static const Color black = Color(0xFF0C1821); static const Color pureBlack = Color(0xFF000000); static const Color gray = Color(0xFFB0B0B0); - static const Color grayDark = Color(0xFF26282C); + static const Color grayDark = Color(0xFF3A3C40); static const Color navyDark = Color(0xFF273043); static const Color blueDeep = Color(0xFF14248A); static const Color blueBright = Color(0xFF47A7ED); diff --git a/lib/core/config/theme/theme.dart b/lib/core/config/theme/theme.dart index cd7004e..438c6b9 100644 --- a/lib/core/config/theme/theme.dart +++ b/lib/core/config/theme/theme.dart @@ -37,6 +37,7 @@ class AnyStepTheme { onSecondary: AnyStepColors.white, surface: AnyStepColors.white, onSurface: AnyStepColors.black, + onSurfaceVariant: AnyStepColors.grayDark, surfaceContainer: AnyStepColors.lightSecondaryContainer, surfaceContainerHighest: AnyStepColors.lightTertiaryContainer, error: AnyStepColors.error, @@ -98,6 +99,7 @@ class AnyStepTheme { onSecondary: AnyStepColors.pureWhite, surface: AnyStepColors.pureWhite, onSurface: AnyStepColors.pureBlack, + onSurfaceVariant: AnyStepColors.grayDark, error: AnyStepColors.error, ), textTheme: _tightTextTheme(AnyStepColors.grayDark), diff --git a/lib/core/features/dashboard/presentation/widgets/dashboard_metrics_card.dart b/lib/core/features/dashboard/presentation/widgets/dashboard_metrics_card.dart index c71fbde..5a87fcb 100644 --- a/lib/core/features/dashboard/presentation/widgets/dashboard_metrics_card.dart +++ b/lib/core/features/dashboard/presentation/widgets/dashboard_metrics_card.dart @@ -164,7 +164,7 @@ class _MetricsHeroRow extends StatelessWidget { String _formatHours(double hours) { final hasDecimal = hours % 1 != 0; - return hours.toStringAsFixed(hasDecimal ? 1 : 0); + return hours.toStringAsFixed(hasDecimal ? 2 : 0); } @override diff --git a/lib/core/features/events/presentation/widgets/attendance_list.dart b/lib/core/features/events/presentation/widgets/attendance_list.dart index 8ceaa1d..14c9508 100644 --- a/lib/core/features/events/presentation/widgets/attendance_list.dart +++ b/lib/core/features/events/presentation/widgets/attendance_list.dart @@ -1,12 +1,16 @@ import 'package:anystep/core/common/constants/spacing.dart'; +import 'package:anystep/core/common/utils/user_display_name.dart'; import 'package:anystep/core/common/widgets/widgets.dart'; import 'package:anystep/core/features/profile/domain/user_role.dart'; import 'package:anystep/core/features/profile/presentation/profile/profile_image.dart'; import 'package:anystep/core/features/user_events/data/user_event_repository.dart'; +import 'package:anystep/core/features/user_events/domain/user_event.dart'; +import 'package:anystep/core/features/user_events/presentation/add_attendee_screen.dart'; import 'package:flutter/material.dart'; import 'package:anystep/l10n/generated/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:anystep/core/config/theme/colors.dart'; +import 'package:go_router/go_router.dart'; /// Sliver-based paginated attendance list (similar pattern to UpcomingEventsFeed) class AttendanceList extends ConsumerWidget { @@ -38,7 +42,7 @@ class AttendanceList extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _TitleRow(isAdmin: isAdmin, onAddAttendee: onAddAttendee), + _TitleRow(total: 0, isAdmin: isAdmin, onAddAttendee: onAddAttendee), const SizedBox(height: AnyStepSpacing.sm8), const Center(child: AnyStepLoadingIndicator()), ], @@ -54,7 +58,7 @@ class AttendanceList extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _TitleRow(isAdmin: isAdmin, onAddAttendee: onAddAttendee), + _TitleRow(total: 0, isAdmin: isAdmin, onAddAttendee: onAddAttendee), const SizedBox(height: AnyStepSpacing.sm8), AnyStepFade( child: Text( @@ -78,7 +82,7 @@ class AttendanceList extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _TitleRow(isAdmin: isAdmin, onAddAttendee: onAddAttendee), + _TitleRow(total: total, isAdmin: isAdmin, onAddAttendee: onAddAttendee), const SizedBox(height: AnyStepSpacing.sm8), Builder(builder: (context) => Text(AppLocalizations.of(context).noAttendees)), ], @@ -101,7 +105,7 @@ class AttendanceList extends ConsumerWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _TitleRow(isAdmin: isAdmin, onAddAttendee: onAddAttendee), + _TitleRow(total: total, isAdmin: isAdmin, onAddAttendee: onAddAttendee), const SizedBox(height: AnyStepSpacing.sm8), ], ); @@ -122,8 +126,14 @@ class AttendanceList extends ConsumerWidget { final userEvent = page.items[indexInPage]; final user = userEvent.user; if (user == null) return const SizedBox.shrink(); + final hoursLabel = _hoursLabel(userEvent); return ListTile( contentPadding: EdgeInsets.zero, + onTap: () { + final id = userEvent.id; + if (id == null) return; + context.push(AddAttendeeScreen.getPath(eventId, userEventId: id)); + }, leading: Stack( children: [ ProfileImage(user: user, size: 20), @@ -148,7 +158,15 @@ class AttendanceList extends ConsumerWidget { ), ], ), - title: Text(user.firstName), + title: Text(displayNameWithLastInitial(user)), + subtitle: Text( + hoursLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), trailing: AnyStepBadge( color: switch (user.role) { UserRole.admin => Theme.of(context).colorScheme.tertiary, @@ -173,9 +191,22 @@ class AttendanceList extends ConsumerWidget { } } +String _hoursLabel(UserEventModel userEvent) { + if (!userEvent.attended) return '0 hours'; + DateTime? start = userEvent.checkInAt ?? userEvent.event?.startTime; + DateTime? end = userEvent.checkOutAt ?? userEvent.event?.endTime; + if (start == null || end == null) return ''; + if (end.isBefore(start)) return ''; + final hours = end.difference(start).inMinutes / 60.0; + final hasDecimal = hours % 1 != 0; + final formatted = hours.toStringAsFixed(hasDecimal ? 1 : 0); + return '$formatted hours'; +} + class _TitleRow extends StatelessWidget { - const _TitleRow({this.onAddAttendee, this.isAdmin = false}); + const _TitleRow({required this.total, this.onAddAttendee, this.isAdmin = false}); + final int total; final VoidCallback? onAddAttendee; final bool isAdmin; @@ -184,7 +215,10 @@ class _TitleRow extends StatelessWidget { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(AppLocalizations.of(context).attendees, style: Theme.of(context).textTheme.titleLarge), + Text( + '${AppLocalizations.of(context).attendees} ($total)', + style: Theme.of(context).textTheme.titleLarge, + ), if (isAdmin && onAddAttendee != null) IconButton( icon: const Icon(Icons.person_add), diff --git a/lib/core/features/events/presentation/widgets/sign_up_list.dart b/lib/core/features/events/presentation/widgets/sign_up_list.dart index 0046985..2855bb4 100644 --- a/lib/core/features/events/presentation/widgets/sign_up_list.dart +++ b/lib/core/features/events/presentation/widgets/sign_up_list.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'package:anystep/core/common/constants/spacing.dart'; +import 'package:anystep/core/common/utils/user_display_name.dart'; import 'package:anystep/core/common/widgets/any_step_badge.dart'; import 'package:anystep/core/common/widgets/any_step_loading_indicator.dart'; import 'package:anystep/core/features/profile/domain/user_role.dart'; @@ -69,7 +70,15 @@ class SignUpList extends ConsumerWidget { } return ListTile( leading: ProfileImage(user: user, size: 20), - title: Text(user.firstName), + title: Text(displayNameWithLastInitial(user)), + subtitle: Text( + user.email, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), trailing: AnyStepBadge( color: switch (user.role) { UserRole.admin => Theme.of(context).colorScheme.tertiary, diff --git a/lib/core/features/profile/presentation/user_feed.dart b/lib/core/features/profile/presentation/user_feed.dart index 10c100f..cbcd401 100644 --- a/lib/core/features/profile/presentation/user_feed.dart +++ b/lib/core/features/profile/presentation/user_feed.dart @@ -29,7 +29,15 @@ class UserFeed extends StatelessWidget { leading: leadingBuilder != null ? leadingBuilder!(user) : ProfileImage(user: user, size: 20), - title: Text(user.firstName), + title: Text(user.fullName), + subtitle: Text( + user.email, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), trailing: AnyStepBadge( color: switch (user.role) { UserRole.admin => Theme.of(context).colorScheme.tertiary, diff --git a/lib/core/features/reports/data/volunteer_hours_providers.dart b/lib/core/features/reports/data/volunteer_hours_providers.dart index 49af7a0..32bb7bf 100644 --- a/lib/core/features/reports/data/volunteer_hours_providers.dart +++ b/lib/core/features/reports/data/volunteer_hours_providers.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'package:anystep/core/features/auth/data/auth_repository.dart'; +import 'package:anystep/core/features/events/data/event_repository.dart'; import 'package:anystep/core/features/reports/domain/volunteer_hours_report.dart'; import 'package:anystep/core/features/user_events/data/user_event_repository.dart'; import 'package:anystep/core/features/user_events/domain/user_event.dart'; @@ -157,9 +158,12 @@ class MonthlyHoursPoint { return (durationHours, baseDate); } -VolunteerHoursSummary _summaryFromReports(List reports) { +VolunteerHoursSummary _summaryFromReports( + List reports, { + int? eventsCountOverride, +}) { final totalHours = reports.fold(0, (sum, r) => sum + r.totalHours); - final totalEvents = reports.fold(0, (sum, r) => sum + r.eventsCount); + final totalEvents = eventsCountOverride ?? reports.fold(0, (sum, r) => sum + r.eventsCount); return VolunteerHoursSummary( totalHours: double.parse(totalHours.toStringAsFixed(2)), eventsCount: totalEvents, @@ -224,13 +228,20 @@ List _takeLastMonths(List points, {int max @riverpod Future volunteerHoursSummaryThisMonth(Ref ref) async { final reports = await ref.watch(volunteerHoursThisMonthProvider.future); - return _summaryFromReports(reports); + final now = DateTime.now(); + final start = DateTime(now.year, now.month, 1); + final end = DateTime(now.year, now.month + 1, 1); + final events = await ref.watch(getEventsInRangeProvider(start: start, end: end).future); + return _summaryFromReports(reports, eventsCountOverride: events.length); } @riverpod Future volunteerHoursSummaryYtd(Ref ref) async { final reports = await ref.watch(volunteerHoursYtdProvider.future); - return _summaryFromReports(reports); + final now = DateTime.now(); + final start = DateTime(now.year, 1, 1); + final events = await ref.watch(getEventsInRangeProvider(start: start, end: now).future); + return _summaryFromReports(reports, eventsCountOverride: events.length); } @riverpod diff --git a/lib/core/features/reports/presentation/report_detail_screen.dart b/lib/core/features/reports/presentation/report_detail_screen.dart new file mode 100644 index 0000000..328fbe0 --- /dev/null +++ b/lib/core/features/reports/presentation/report_detail_screen.dart @@ -0,0 +1,250 @@ +import 'package:anystep/core/common/constants/spacing.dart'; +import 'package:anystep/core/common/widgets/any_step_shimmer.dart'; +import 'package:anystep/core/common/widgets/widgets.dart'; +import 'package:anystep/core/features/events/presentation/event_detail/event_detail_screen.dart'; +import 'package:anystep/core/features/profile/data/user_repository.dart'; +import 'package:anystep/core/features/profile/presentation/profile/profile_image.dart'; +import 'package:anystep/core/features/reports/data/volunteer_hours_providers.dart'; +import 'package:anystep/l10n/generated/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +final _reportUserProvider = FutureProvider.family((ref, String userId) { + return ref.watch(userRepositoryProvider).get(documentId: userId); +}); + +class ReportDetailScreen extends ConsumerWidget { + const ReportDetailScreen({ + super.key, + required this.userId, + required this.start, + required this.end, + }); + + static const pathAdmin = '/admin/reports/:userId'; + static const nameAdmin = 'admin-report-detail'; + + static String getPath(String userId, DateTime start, DateTime end) { + final startMs = start.millisecondsSinceEpoch; + final endMs = end.millisecondsSinceEpoch; + return '/admin/reports/$userId?start=$startMs&end=$endMs'; + } + + final String userId; + final DateTime start; + final DateTime end; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final loc = AppLocalizations.of(context); + final userAsync = ref.watch(_reportUserProvider(userId)); + final reportsAsync = ref.watch(volunteerHoursAggregateProvider(start: start, end: end)); + final eventsAsync = ref.watch( + userEventsInRangeProvider(start: start, end: end, userId: userId, attendedOnly: true), + ); + + return AnyStepScaffold( + appBar: AnyStepAppBar( + title: userAsync.when( + data: (user) => Text(user.fullName), + loading: () => Text(loc.reportsTitle), + error: (_, __) => Text(loc.reportsTitle), + ), + ), + body: ListView( + padding: const EdgeInsets.all(AnyStepSpacing.md16), + children: [ + userAsync.when( + loading: () => const _UserHeaderShimmer(), + error: (_, __) => Text(loc.failedToLoad), + data: (user) { + final phone = user.phoneNumber; + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ProfileImage(user: user, size: 28), + const SizedBox(width: AnyStepSpacing.sm8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user.email, + style: Theme.of(context).textTheme.bodyMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (phone != null && phone.isNotEmpty) + Text( + phone, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ); + }, + ), + const SizedBox(height: AnyStepSpacing.md16), + reportsAsync.when( + loading: () => const _MetricsShimmerRow(), + error: (_, __) => Text(loc.failedToLoad), + data: (reports) { + final report = reports.where((r) => r.user.id == userId).toList(); + final totalHours = report.isEmpty ? 0 : report.first.totalHours; + final totalEvents = report.isEmpty ? 0 : report.first.eventsCount; + return Row( + children: [ + Expanded( + child: _MetricCard( + label: loc.reportTotalHours, + value: totalHours.toStringAsFixed(2), + ), + ), + const SizedBox(width: AnyStepSpacing.sm8), + Expanded( + child: _MetricCard(label: loc.reportTotalEvents, value: totalEvents.toString()), + ), + ], + ); + }, + ), + const SizedBox(height: AnyStepSpacing.md16), + Text(loc.eventsAttended, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: AnyStepSpacing.sm8), + eventsAsync.when( + loading: () => const _EventsListShimmer(), + error: (_, __) => Text(loc.failedToLoad), + data: (userEvents) { + final items = userEvents.where((ue) => ue.event != null).toList() + ..sort((a, b) => b.event!.startTime.compareTo(a.event!.startTime)); + if (items.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AnyStepSpacing.md12), + child: Text(loc.reportNoEvents), + ); + } + final dateFmt = DateFormat('MMM d, yyyy • h:mm a'); + return Column( + children: [ + for (final ue in items) + Card( + child: ListTile( + title: Text(ue.event!.name), + subtitle: Text(dateFmt.format(ue.event!.startTime.toLocal())), + trailing: const Icon(Icons.chevron_right), + onTap: () { + final id = ue.event!.id; + if (id == null) return; + context.push(EventDetailScreen.getPath(id)); + }, + ), + ), + ], + ); + }, + ), + ], + ), + ); + } +} + +class _UserHeaderShimmer extends StatelessWidget { + const _UserHeaderShimmer(); + + @override + Widget build(BuildContext context) { + return const AnyStepShimmer(height: 16, width: 220); + } +} + +class _MetricsShimmerRow extends StatelessWidget { + const _MetricsShimmerRow(); + + @override + Widget build(BuildContext context) { + return Row( + children: const [ + Expanded(child: AnyStepShimmer(height: 72)), + SizedBox(width: AnyStepSpacing.sm8), + Expanded(child: AnyStepShimmer(height: 72)), + ], + ); + } +} + +class _EventsListShimmer extends StatelessWidget { + const _EventsListShimmer(); + + @override + Widget build(BuildContext context) { + return Column( + children: const [ + _EventRowShimmer(), + SizedBox(height: AnyStepSpacing.sm8), + _EventRowShimmer(), + SizedBox(height: AnyStepSpacing.sm8), + _EventRowShimmer(), + ], + ); + } +} + +class _EventRowShimmer extends StatelessWidget { + const _EventRowShimmer(); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AnyStepSpacing.md16, + vertical: AnyStepSpacing.md12, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + AnyStepShimmer(height: 16, width: 220), + SizedBox(height: AnyStepSpacing.sm6), + AnyStepShimmer(height: 12, width: 160), + ], + ), + ), + ); + } +} + +class _MetricCard extends StatelessWidget { + const _MetricCard({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(AnyStepSpacing.md12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest.withAlpha(80), + borderRadius: BorderRadius.circular(AnyStepSpacing.md12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: AnyStepSpacing.sm4), + Text( + value, + style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700), + ), + ], + ), + ); + } +} diff --git a/lib/core/features/reports/presentation/reports_screen.dart b/lib/core/features/reports/presentation/reports_screen.dart index 1b2b887..c9bd7b7 100644 --- a/lib/core/features/reports/presentation/reports_screen.dart +++ b/lib/core/features/reports/presentation/reports_screen.dart @@ -6,12 +6,14 @@ import 'dart:convert'; import 'package:anystep/core/config/theme/colors.dart'; import 'package:anystep/core/features/reports/data/volunteer_hours_providers.dart'; import 'package:anystep/core/features/reports/domain/volunteer_hours_report.dart'; +import 'package:anystep/core/features/reports/presentation/report_detail_screen.dart'; import 'package:anystep/core/features/reports/presentation/volunteer_hours_report_table_cell.dart'; import 'package:anystep/l10n/generated/app_localizations.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:go_router/go_router.dart'; class ReportsScreen extends ConsumerStatefulWidget { const ReportsScreen({super.key}); @@ -30,6 +32,8 @@ class _ReportsScreenState extends ConsumerState { late DateTime _start; late DateTime _end; bool _custom = false; + bool _isSearching = false; + String _query = ''; final _dateFmt = DateFormat('MMM d, yyyy'); @override @@ -143,6 +147,57 @@ class _ReportsScreenState extends ConsumerState { error: (e, st) => IconButton(onPressed: null, icon: const Icon(Icons.error_outline)), ), ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(AnyStepSpacing.xl56), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + child: AnyStepSearchBar( + hintText: loc.searchReports, + initialValue: _query, + onChanged: (value) => setState(() => _query = value), + onFocusChanged: (focused) => + setState(() => _isSearching = focused || _query.isNotEmpty), + ), + ), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + transitionBuilder: (child, animation) { + final offsetAnim = animation.drive( + Tween(begin: const Offset(0.3, 0), end: Offset.zero).chain( + CurveTween(curve: Curves.easeOut), + ), + ); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offsetAnim, child: child), + ); + }, + child: _isSearching + ? Padding( + key: const ValueKey('cancel_btn'), + padding: const EdgeInsets.only(right: AnyStepSpacing.md16), + child: TextButton( + onPressed: () { + setState(() { + _query = ''; + _isSearching = false; + }); + FocusScope.of(context).unfocus(); + }, + child: Text(loc.cancel), + ), + ) + : const SizedBox.shrink(key: ValueKey('cancel_btn_space')), + ), + ], + ), + ), ), body: RefreshIndicator( onRefresh: () async => ref.invalidate(userEventsInRangeProvider), @@ -189,10 +244,26 @@ class _ReportsScreenState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 12.0), child: asyncReports.when( data: (reports) { - if (reports.isEmpty) { + final query = _query.trim().toLowerCase(); + if (_isSearching && query.isEmpty) { return Padding( padding: EdgeInsets.symmetric(vertical: AnyStepSpacing.lg48), - child: Center(child: Text(loc.noDataInRange)), + child: Center(child: Text(loc.enterSearchTermReports)), + ); + } + final filtered = query.isEmpty + ? reports + : reports.where((r) { + final name = r.user.fullName.toLowerCase(); + final email = r.user.email.toLowerCase(); + return name.contains(query) || email.contains(query); + }).toList(); + if (filtered.isEmpty) { + return Padding( + padding: EdgeInsets.symmetric(vertical: AnyStepSpacing.lg48), + child: Center( + child: Text(query.isEmpty ? loc.noDataInRange : loc.noReportsFound), + ), ); } // Condensed list view instead of wide horizontal DataTable @@ -202,11 +273,18 @@ class _ReportsScreenState extends ConsumerState { Padding( padding: const EdgeInsets.only(bottom: AnyStepSpacing.sm8), child: Text( - loc.reportsCount(reports.length), + loc.reportsCount(filtered.length), style: Theme.of(context).textTheme.titleMedium, ), ), - ...reports.map((r) => VolunteerHoursReportTableCell(volunteerHoursReport: r)), + ...filtered.map( + (r) => VolunteerHoursReportTableCell( + volunteerHoursReport: r, + onTap: () { + context.push(ReportDetailScreen.getPath(r.user.id, _start, _end)); + }, + ), + ), ], ); }, diff --git a/lib/core/features/reports/presentation/screens.dart b/lib/core/features/reports/presentation/screens.dart index ff97f81..90264e5 100644 --- a/lib/core/features/reports/presentation/screens.dart +++ b/lib/core/features/reports/presentation/screens.dart @@ -1 +1,2 @@ export 'reports_screen.dart'; +export 'report_detail_screen.dart'; diff --git a/lib/core/features/reports/presentation/volunteer_hours_report_table_cell.dart b/lib/core/features/reports/presentation/volunteer_hours_report_table_cell.dart index 27e63ca..1fcc3f1 100644 --- a/lib/core/features/reports/presentation/volunteer_hours_report_table_cell.dart +++ b/lib/core/features/reports/presentation/volunteer_hours_report_table_cell.dart @@ -4,9 +4,14 @@ import 'package:anystep/core/features/reports/domain/volunteer_hours_report.dart import 'package:flutter/material.dart'; class VolunteerHoursReportTableCell extends StatelessWidget { - const VolunteerHoursReportTableCell({super.key, required this.volunteerHoursReport}); + const VolunteerHoursReportTableCell({ + super.key, + required this.volunteerHoursReport, + this.onTap, + }); final VolunteerHoursReport volunteerHoursReport; + final VoidCallback? onTap; @override Widget build(BuildContext context) { @@ -16,6 +21,7 @@ class VolunteerHoursReportTableCell extends StatelessWidget { return Card( margin: const EdgeInsets.symmetric(vertical: AnyStepSpacing.sm4), child: ListTile( + onTap: onTap, title: Row( children: [ ProfileImage(user: volunteerHoursReport.user, size: AnyStepSpacing.md12), diff --git a/lib/core/features/user_events/presentation/add_attendee_screen.dart b/lib/core/features/user_events/presentation/add_attendee_screen.dart index 3053fec..6f6fad7 100644 --- a/lib/core/features/user_events/presentation/add_attendee_screen.dart +++ b/lib/core/features/user_events/presentation/add_attendee_screen.dart @@ -2,6 +2,8 @@ import 'package:anystep/core/common/constants/spacing.dart'; import 'package:anystep/core/common/widgets/widgets.dart'; import 'package:anystep/core/features/events/data/event_repository.dart'; import 'package:anystep/core/features/profile/domain/user_model.dart'; +import 'package:anystep/core/features/user_events/data/user_event_repository.dart'; +import 'package:anystep/core/features/user_events/domain/user_event.dart'; import 'package:anystep/core/features/user_events/presentation/add_attendee_controller.dart'; import 'package:anystep/core/features/user_events/presentation/attendee_search_form.dart'; import 'package:anystep/l10n/generated/app_localizations.dart'; @@ -12,13 +14,18 @@ import 'package:form_builder_validators/form_builder_validators.dart'; import 'package:go_router/go_router.dart'; class AddAttendeeScreen extends ConsumerStatefulWidget { - const AddAttendeeScreen({super.key, required this.eventId}); + const AddAttendeeScreen({super.key, required this.eventId, this.userEventId}); static const path = '/events/:id/add-attendee'; - static String getPath(int eventId) => '/events/$eventId/add-attendee'; + static String getPath(int eventId, {int? userEventId}) { + final base = '/events/$eventId/add-attendee'; + if (userEventId == null) return base; + return '$base?userEventId=$userEventId'; + } static const name = 'add-attendee'; final int eventId; + final int? userEventId; @override ConsumerState createState() => _AddAttendeeScreenState(); @@ -28,8 +35,10 @@ class _AddAttendeeScreenState extends ConsumerState { final formKey = GlobalKey(); UserModel? _selectedUser; bool _attended = true; + bool _prefilled = false; void _openUserSearch() { + if (widget.userEventId != null) return; context.showModal( AttendeeSearchForm( eventId: widget.eventId, @@ -69,6 +78,9 @@ class _AddAttendeeScreenState extends ConsumerState { @override Widget build(BuildContext context) { final eventAsync = ref.watch(getEventProvider(widget.eventId)); + final userEventAsync = widget.userEventId != null + ? ref.watch(getUserEventProvider(widget.userEventId!)) + : const AsyncValue.data(null); final state = ref.watch(addAttendeeControllerProvider); final loc = AppLocalizations.of(context); @@ -84,8 +96,30 @@ class _AddAttendeeScreenState extends ConsumerState { data: (event) { final initialCheckIn = event.startTime.toLocal(); final initialCheckOut = event.endTime.toLocal(); + final isEditing = widget.userEventId != null; + if (isEditing && !_prefilled) { + userEventAsync.whenData((userEvent) { + if (userEvent == null) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() { + _prefilled = true; + _attended = userEvent.attended; + _selectedUser = userEvent.user; + }); + formKey.currentState?.fields['userId']?.didChange(userEvent.userId); + formKey.currentState?.fields['attended']?.didChange(userEvent.attended); + formKey.currentState?.fields['checkInAt']?.didChange( + userEvent.attended ? userEvent.checkInAt?.toLocal() ?? initialCheckIn : null, + ); + formKey.currentState?.fields['checkOutAt']?.didChange( + userEvent.attended ? userEvent.checkOutAt?.toLocal() ?? initialCheckOut : null, + ); + }); + }); + } return AnyStepScaffold( - appBar: AnyStepAppBar(title: Text(loc.addAttendeeTitle)), + appBar: AnyStepAppBar(title: Text(isEditing ? loc.editAttendeeTitle : loc.addAttendeeTitle)), body: Padding( padding: const EdgeInsets.all(AnyStepSpacing.md16), child: FormBuilder( @@ -101,6 +135,7 @@ class _AddAttendeeScreenState extends ConsumerState { hint: loc.selectUser, selectedUser: _selectedUser, onTap: _openUserSearch, + enabled: !isEditing, ), AnyStepSwitchInput( name: 'attended', @@ -189,12 +224,14 @@ class _UserSelectField extends StatelessWidget { required this.hint, required this.onTap, required this.selectedUser, + this.enabled = true, }); final String label; final String hint; final VoidCallback onTap; final UserModel? selectedUser; + final bool enabled; @override Widget build(BuildContext context) { @@ -215,7 +252,7 @@ class _UserSelectField extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: AnyStepSpacing.sm4), child: InkWell( borderRadius: BorderRadius.circular(AnyStepSpacing.md16), - onTap: onTap, + onTap: enabled ? onTap : null, child: InputDecorator( decoration: InputDecoration( labelText: label, @@ -227,7 +264,10 @@ class _UserSelectField extends StatelessWidget { ), enabledBorder: OutlineInputBorder( borderRadius: const BorderRadius.all(Radius.circular(AnyStepSpacing.md16)), - borderSide: BorderSide(color: primary, width: 1.5), + borderSide: BorderSide( + color: enabled ? primary : primary.withAlpha(80), + width: 1.5, + ), ), focusedBorder: OutlineInputBorder( borderRadius: const BorderRadius.all(Radius.circular(AnyStepSpacing.md16)), @@ -237,7 +277,7 @@ class _UserSelectField extends StatelessWidget { child: Row( children: [ Expanded(child: Text(displayText, style: displayStyle)), - const Icon(Icons.search), + if (enabled) const Icon(Icons.search), ], ), ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5d028de..e7dca30 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -84,6 +84,14 @@ "description": "Heading showing total reports", "placeholders": {"count": {"type": "int", "example": "12"}} }, + "reportTotalHours": "Total Hours", + "@reportTotalHours": {"description": "Report detail metric label for total hours"}, + "reportTotalEvents": "Total Events", + "@reportTotalEvents": {"description": "Report detail metric label for total events"}, + "eventsAttended": "Events Attended", + "@eventsAttended": {"description": "Section header for events attended list"}, + "reportNoEvents": "No events attended in this range", + "@reportNoEvents": {"description": "Empty state for report detail events list"}, "errorLoadingReports": "Error loading reports: {error}", "@errorLoadingReports": { "description": "Error message for reports loading", @@ -176,6 +184,8 @@ "@eventFeed": {"description": "Event feed title"}, "search": "Search", "@search": {"description": "Generic label for search"}, + "searchReports": "Search reports", + "@searchReports": {"description": "Search bar hint text for reports"}, "searchAddress": "Search address", "@searchAddress": {"description": "Label for address autocomplete search field"}, "startTypingAddress": "Start typing an address", @@ -235,6 +245,8 @@ "@noAttendees": {"description": "Empty state for attendee list"}, "addAttendeeTitle": "Add Attendee", "@addAttendeeTitle": {"description": "Title for add attendee screen"}, + "editAttendeeTitle": "Edit Attendee", + "@editAttendeeTitle": {"description": "Title for edit attendee screen"}, "userLabel": "User", "@userLabel": {"description": "Label for user selection field"}, "selectUser": "Select user", @@ -471,6 +483,10 @@ , "enterSearchTerm": "Please enter a search term to find events.", "@enterSearchTerm": {"description": "Text when search field is empty"}, + "enterSearchTermReports": "Please enter a search term to find reports.", + "@enterSearchTermReports": {"description": "Text when report search field is empty"}, + "noReportsFound": "No reports found", + "@noReportsFound": {"description": "Empty state title when no reports match search"}, "volunteerEventLabel": "Volunteer Event", "@volunteerEventLabel": {"description": "Label for volunteer eligible event toggle"}, "volunteerEventHelp": "This event is eligible for volunteer hours", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 8d1dd31..d3f6cb9 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -83,6 +83,14 @@ "description": "Encabezado que muestra el total de informes", "placeholders": {"count": {"type": "int", "example": "12"}} }, + "reportTotalHours": "Horas totales", + "@reportTotalHours": {"description": "Etiqueta de métrica para horas totales en el detalle del informe"}, + "reportTotalEvents": "Eventos totales", + "@reportTotalEvents": {"description": "Etiqueta de métrica para eventos totales en el detalle del informe"}, + "eventsAttended": "Eventos asistidos", + "@eventsAttended": {"description": "Encabezado de la lista de eventos asistidos"}, + "reportNoEvents": "No hay eventos asistidos en este rango", + "@reportNoEvents": {"description": "Estado vacío para la lista de eventos del detalle del informe"}, "errorLoadingReports": "Error al cargar informes: {error}", "@errorLoadingReports": { "description": "Mensaje de error para la carga de informes", @@ -175,6 +183,8 @@ "@eventFeed": {"description": "Título del feed de eventos"}, "search": "Buscar", "@search": {"description": "Para buscador"}, + "searchReports": "Buscar informes", + "@searchReports": {"description": "Texto de sugerencia de búsqueda para informes"}, "searchAddress": "Buscar dirección", "@searchAddress": {"description": "Etiqueta del campo de búsqueda de direcciones"}, "startTypingAddress": "Comienza a escribir una dirección", @@ -183,6 +193,10 @@ "@noMatchesFound": {"description": "Se muestra cuando la búsqueda de direcciones no devuelve resultados"}, "searchEvents": "Buscar eventos", "@searchEvents": {"description": "Texto de sugerencia del buscador de eventos"}, + "enterSearchTermReports": "Por favor ingresa un término de búsqueda para encontrar informes.", + "@enterSearchTermReports": {"description": "Texto cuando la búsqueda de informes está vacía"}, + "noReportsFound": "No se encontraron informes", + "@noReportsFound": {"description": "Estado vacío cuando no hay informes que coincidan"}, "cancel": "Cancelar", "@cancel": {"description": "Etiqueta para cancelar"}, "recentEvents": "Eventos recientes", @@ -234,6 +248,8 @@ "@noAttendees": {"description": "Estado vacío para la lista de asistentes"}, "addAttendeeTitle": "Agregar asistente", "@addAttendeeTitle": {"description": "Título para la pantalla de agregar asistente"}, + "editAttendeeTitle": "Editar asistente", + "@editAttendeeTitle": {"description": "Título para la pantalla de editar asistente"}, "userLabel": "Usuario", "@userLabel": {"description": "Etiqueta para el campo de usuario"}, "selectUser": "Seleccionar usuario", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 72ee854..f70fea2 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -278,6 +278,30 @@ abstract class AppLocalizations { /// **'Reports ({count})'** String reportsCount(int count); + /// Report detail metric label for total hours + /// + /// In en, this message translates to: + /// **'Total Hours'** + String get reportTotalHours; + + /// Report detail metric label for total events + /// + /// In en, this message translates to: + /// **'Total Events'** + String get reportTotalEvents; + + /// Section header for events attended list + /// + /// In en, this message translates to: + /// **'Events Attended'** + String get eventsAttended; + + /// Empty state for report detail events list + /// + /// In en, this message translates to: + /// **'No events attended in this range'** + String get reportNoEvents; + /// Error message for reports loading /// /// In en, this message translates to: @@ -518,6 +542,12 @@ abstract class AppLocalizations { /// **'Search'** String get search; + /// Search bar hint text for reports + /// + /// In en, this message translates to: + /// **'Search reports'** + String get searchReports; + /// Label for address autocomplete search field /// /// In en, this message translates to: @@ -680,6 +710,12 @@ abstract class AppLocalizations { /// **'Add Attendee'** String get addAttendeeTitle; + /// Title for edit attendee screen + /// + /// In en, this message translates to: + /// **'Edit Attendee'** + String get editAttendeeTitle; + /// Label for user selection field /// /// In en, this message translates to: @@ -1340,6 +1376,18 @@ abstract class AppLocalizations { /// **'Please enter a search term to find events.'** String get enterSearchTerm; + /// Text when report search field is empty + /// + /// In en, this message translates to: + /// **'Please enter a search term to find reports.'** + String get enterSearchTermReports; + + /// Empty state title when no reports match search + /// + /// In en, this message translates to: + /// **'No reports found'** + String get noReportsFound; + /// Label for volunteer eligible event toggle /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 060af5c..af2e74b 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -113,6 +113,18 @@ class AppLocalizationsEn extends AppLocalizations { return 'Reports ($count)'; } + @override + String get reportTotalHours => 'Total Hours'; + + @override + String get reportTotalEvents => 'Total Events'; + + @override + String get eventsAttended => 'Events Attended'; + + @override + String get reportNoEvents => 'No events attended in this range'; + @override String errorLoadingReports(String error) { return 'Error loading reports: $error'; @@ -239,6 +251,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get search => 'Search'; + @override + String get searchReports => 'Search reports'; + @override String get searchAddress => 'Search address'; @@ -324,6 +339,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get addAttendeeTitle => 'Add Attendee'; + @override + String get editAttendeeTitle => 'Edit Attendee'; + @override String get userLabel => 'User'; @@ -675,6 +693,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get enterSearchTerm => 'Please enter a search term to find events.'; + @override + String get enterSearchTermReports => + 'Please enter a search term to find reports.'; + + @override + String get noReportsFound => 'No reports found'; + @override String get volunteerEventLabel => 'Volunteer Event'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 6235e60..cb7703e 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -115,6 +115,18 @@ class AppLocalizationsEs extends AppLocalizations { return 'Informes ($count)'; } + @override + String get reportTotalHours => 'Horas totales'; + + @override + String get reportTotalEvents => 'Eventos totales'; + + @override + String get eventsAttended => 'Eventos asistidos'; + + @override + String get reportNoEvents => 'No hay eventos asistidos en este rango'; + @override String errorLoadingReports(String error) { return 'Error al cargar informes: $error'; @@ -244,6 +256,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get search => 'Buscar'; + @override + String get searchReports => 'Buscar informes'; + @override String get searchAddress => 'Buscar dirección'; @@ -329,6 +344,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get addAttendeeTitle => 'Agregar asistente'; + @override + String get editAttendeeTitle => 'Editar asistente'; + @override String get userLabel => 'Usuario'; @@ -683,6 +701,13 @@ class AppLocalizationsEs extends AppLocalizations { String get enterSearchTerm => 'Ingresa un término de búsqueda para encontrar eventos.'; + @override + String get enterSearchTermReports => + 'Por favor ingresa un término de búsqueda para encontrar informes.'; + + @override + String get noReportsFound => 'No se encontraron informes'; + @override String get volunteerEventLabel => 'Evento de voluntariado';