Team Color, image compression for attendance verification, improved wherebouts

This commit is contained in:
Marc Rejohn Castillano 2026-03-08 12:23:28 +08:00
parent a8751ca728
commit d87b5e73d7
11 changed files with 757 additions and 191 deletions

View File

@ -17,6 +17,7 @@ class Team {
required this.leaderId, required this.leaderId,
required this.officeIds, required this.officeIds,
required this.createdAt, required this.createdAt,
this.color,
}); });
final String id; final String id;
@ -24,6 +25,7 @@ class Team {
final String leaderId; final String leaderId;
final List<String> officeIds; final List<String> officeIds;
final DateTime createdAt; final DateTime createdAt;
final String? color;
factory Team.fromMap(Map<String, dynamic> map) { factory Team.fromMap(Map<String, dynamic> map) {
return Team( return Team(
@ -34,6 +36,7 @@ class Team {
(map['office_ids'] as List?)?.map((e) => e.toString()).toList() ?? (map['office_ids'] as List?)?.map((e) => e.toString()).toList() ??
<String>[], <String>[],
createdAt: DateTime.parse(map['created_at'] as String), createdAt: DateTime.parse(map['created_at'] as String),
color: map['color'] as String?,
); );
} }
} }

View File

@ -24,8 +24,13 @@ import '../../providers/tickets_provider.dart';
import '../../providers/whereabouts_provider.dart'; import '../../providers/whereabouts_provider.dart';
import '../../providers/workforce_provider.dart'; import '../../providers/workforce_provider.dart';
import '../../providers/it_service_request_provider.dart'; import '../../providers/it_service_request_provider.dart';
import '../../providers/teams_provider.dart';
import '../../models/team.dart';
import '../../models/team_member.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../widgets/reconnect_overlay.dart'; import '../../widgets/reconnect_overlay.dart';
import '../../widgets/profile_avatar.dart';
import '../../widgets/app_breakpoints.dart';
import '../../providers/realtime_controller.dart'; import '../../providers/realtime_controller.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
@ -70,6 +75,8 @@ class StaffRowMetrics {
required this.ticketsRespondedToday, required this.ticketsRespondedToday,
required this.tasksClosedToday, required this.tasksClosedToday,
required this.eventsHandledToday, required this.eventsHandledToday,
this.avatarUrl,
this.teamColor,
}); });
final String userId; final String userId;
@ -79,6 +86,8 @@ class StaffRowMetrics {
final int ticketsRespondedToday; final int ticketsRespondedToday;
final int tasksClosedToday; final int tasksClosedToday;
final int eventsHandledToday; final int eventsHandledToday;
final String? avatarUrl;
final String? teamColor;
} }
final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) { final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
@ -94,6 +103,8 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
final passSlipsAsync = ref.watch(passSlipsProvider); final passSlipsAsync = ref.watch(passSlipsProvider);
final isrAssignmentsAsync = ref.watch(itServiceRequestAssignmentsProvider); final isrAssignmentsAsync = ref.watch(itServiceRequestAssignmentsProvider);
final isrAsync = ref.watch(itServiceRequestsProvider); final isrAsync = ref.watch(itServiceRequestsProvider);
final teamsAsync = ref.watch(teamsProvider);
final teamMembersAsync = ref.watch(teamMembersProvider);
final asyncValues = [ final asyncValues = [
ticketsAsync, ticketsAsync,
@ -294,13 +305,29 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
const triageWindow = Duration(minutes: 1); const triageWindow = Duration(minutes: 1);
final triageCutoff = now.subtract(triageWindow); final triageCutoff = now.subtract(triageWindow);
// Pre-index team membership: map user team color (hex string).
final teams = teamsAsync.valueOrNull ?? const <Team>[];
final teamMembers = teamMembersAsync.valueOrNull ?? const <TeamMember>[];
final teamById = {for (final t in teams) t.id: t};
final teamColorByUser = <String, String?>{};
for (final m in teamMembers) {
final team = teamById[m.teamId];
if (team != null) {
teamColorByUser[m.userId] = team.color;
}
}
// Pre-index schedules, logs, and positions by user for efficient lookup. // Pre-index schedules, logs, and positions by user for efficient lookup.
// Include schedules starting today AND overnight schedules from yesterday
// that span into today (e.g. on_call 11 PM 7 AM).
final todaySchedulesByUser = <String, List<DutySchedule>>{}; final todaySchedulesByUser = <String, List<DutySchedule>>{};
for (final s in schedules) { for (final s in schedules) {
// Exclude overtime schedules from regular duty tracking. if (s.shiftType == 'overtime') continue;
if (s.shiftType != 'overtime' && final startsToday =
!s.startTime.isBefore(startOfDay) && !s.startTime.isBefore(startOfDay) && s.startTime.isBefore(endOfDay);
s.startTime.isBefore(endOfDay)) { final overnightFromYesterday =
s.startTime.isBefore(startOfDay) && s.endTime.isAfter(startOfDay);
if (startsToday || overnightFromYesterday) {
todaySchedulesByUser.putIfAbsent(s.userId, () => []).add(s); todaySchedulesByUser.putIfAbsent(s.userId, () => []).add(s);
} }
} }
@ -343,15 +370,22 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
final onTask = staffOnTask.contains(staff.id); final onTask = staffOnTask.contains(staff.id);
final inTriage = lastMessage != null && lastMessage.isAfter(triageCutoff); final inTriage = lastMessage != null && lastMessage.isAfter(triageCutoff);
// Whereabouts from live position.
final livePos = positionByUser[staff.id];
final whereabouts = livePos != null
? (livePos.inPremise ? 'In premise' : 'Outside premise')
: '\u2014';
// Attendance-based status. // Attendance-based status.
final userSchedules = todaySchedulesByUser[staff.id] ?? const []; final userSchedules = todaySchedulesByUser[staff.id] ?? const [];
final userLogs = todayLogsByUser[staff.id] ?? const []; final userLogs = todayLogsByUser[staff.id] ?? const [];
// Whereabouts from live position, with tracking-off inference.
final livePos = positionByUser[staff.id];
final hasActiveCheckIn = userLogs.any((l) => !l.isCheckedOut);
final String whereabouts;
if (livePos != null) {
whereabouts = livePos.inPremise ? 'In premise' : 'Outside premise';
} else if (!staff.allowTracking) {
// Tracking off infer from active check-in (geofence validated).
whereabouts = hasActiveCheckIn ? 'In premise' : 'Tracking off';
} else {
whereabouts = '\u2014';
}
final activeLog = userLogs.where((l) => !l.isCheckedOut).firstOrNull; final activeLog = userLogs.where((l) => !l.isCheckedOut).firstOrNull;
final completedLogs = userLogs.where((l) => l.isCheckedOut).toList(); final completedLogs = userLogs.where((l) => l.isCheckedOut).toList();
@ -370,7 +404,25 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
? 'In triage' ? 'In triage'
: 'Off duty'; : 'Off duty';
} else { } else {
final schedule = userSchedules.first; // Pick the most relevant schedule: prefer one that is currently
// active (now is within startend), then the nearest upcoming one,
// and finally the most recently ended one.
final activeSchedule = userSchedules
.where((s) => !now.isBefore(s.startTime) && now.isBefore(s.endTime))
.firstOrNull;
DutySchedule? upcomingSchedule;
if (activeSchedule == null) {
final upcoming =
userSchedules.where((s) => now.isBefore(s.startTime)).toList()
..sort((a, b) => a.startTime.compareTo(b.startTime));
if (upcoming.isNotEmpty) upcomingSchedule = upcoming.first;
}
final schedule =
activeSchedule ??
upcomingSchedule ??
userSchedules.reduce((a, b) => a.endTime.isAfter(b.endTime) ? a : b);
final isShiftOver = !now.isBefore(schedule.endTime); final isShiftOver = !now.isBefore(schedule.endTime);
final isFullDay = final isFullDay =
schedule.endTime.difference(schedule.startTime).inHours >= 6; schedule.endTime.difference(schedule.startTime).inHours >= 6;
@ -399,10 +451,28 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
} }
} else { } else {
// Not checked in yet, no completed logs. // Not checked in yet, no completed logs.
if (isOnCall) { //
// ON CALL staff don't need to be on premise or check in at a // Check whether ANY of the user's schedules is on_call so we
// specific time they only come when needed. // can apply on-call logic even when the "most relevant"
status = isShiftOver ? 'Off duty' : 'ON CALL'; // schedule that was selected is a regular shift.
final anyOnCallActive = userSchedules.any(
(s) =>
s.shiftType == 'on_call' &&
!now.isBefore(s.startTime) &&
now.isBefore(s.endTime),
);
final onlyOnCallSchedules = userSchedules.every(
(s) => s.shiftType == 'on_call',
);
if (anyOnCallActive) {
// An on_call shift is currently in its window ON CALL.
status = 'ON CALL';
} else if (isOnCall || onlyOnCallSchedules) {
// Selected schedule is on_call, or user has ONLY on_call
// schedules with none currently active Off duty (between
// on-call shifts). On-call staff can never be Late/Absent.
status = 'Off duty';
} else if (isShiftOver) { } else if (isShiftOver) {
// Shift ended with no check-in at all Absent. // Shift ended with no check-in at all Absent.
status = 'Absent'; status = 'Absent';
@ -435,6 +505,8 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
isrAsync.valueOrNull ?? [], isrAsync.valueOrNull ?? [],
now, now,
), ),
avatarUrl: staff.avatarUrl,
teamColor: teamColorByUser[staff.id],
); );
}).toList(); }).toList();
@ -865,14 +937,28 @@ class _StaffTableHeader extends StatelessWidget {
final style = Theme.of( final style = Theme.of(
context, context,
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700); ).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700);
final isMobile = AppBreakpoints.isMobile(context);
return Row( return Row(
children: [ children: [
Expanded(flex: 3, child: Text('IT Staff', style: style)), Expanded(
flex: isMobile ? 2 : 3,
child: Text('IT Staff', style: style),
),
Expanded(flex: 2, child: Text('Status', style: style)), Expanded(flex: 2, child: Text('Status', style: style)),
if (!isMobile)
Expanded(flex: 2, child: Text('Whereabouts', style: style)), Expanded(flex: 2, child: Text('Whereabouts', style: style)),
Expanded(flex: 2, child: Text('Tickets', style: style)), Expanded(
Expanded(flex: 2, child: Text('Tasks', style: style)), flex: isMobile ? 1 : 2,
Expanded(flex: 2, child: Text('Events', style: style)), child: Text(isMobile ? 'Tix' : 'Tickets', style: style),
),
Expanded(
flex: isMobile ? 1 : 2,
child: Text(isMobile ? 'Tsk' : 'Tasks', style: style),
),
Expanded(
flex: isMobile ? 1 : 2,
child: Text(isMobile ? 'Evt' : 'Events', style: style),
),
], ],
); );
} }
@ -935,11 +1021,53 @@ class _StaffRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final valueStyle = Theme.of(context).textTheme.bodySmall; final valueStyle = Theme.of(context).textTheme.bodySmall;
final isMobile = AppBreakpoints.isMobile(context);
// Team color dot
Widget? teamDot;
if (row.teamColor != null) {
final color = Color(int.parse(row.teamColor!, radix: 16) | 0xFF000000);
teamDot = Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
);
}
// IT Staff cell: avatar on mobile, name on desktop, with team color dot
Widget staffCell;
if (isMobile) {
staffCell = Row(
mainAxisSize: MainAxisSize.min,
children: [
if (teamDot != null) ...[teamDot, const SizedBox(width: 4)],
Flexible(
child: Tooltip(
message: row.name,
child: ProfileAvatar(
fullName: row.name,
avatarUrl: row.avatarUrl,
radius: 14,
),
),
),
],
);
} else {
staffCell = Row(
mainAxisSize: MainAxisSize.min,
children: [
if (teamDot != null) ...[teamDot, const SizedBox(width: 6)],
Flexible(child: Text(row.name, style: valueStyle)),
],
);
}
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.symmetric(vertical: 6),
child: Row( child: Row(
children: [ children: [
Expanded(flex: 3, child: Text(row.name, style: valueStyle)), Expanded(flex: isMobile ? 2 : 3, child: staffCell),
Expanded( Expanded(
flex: 2, flex: 2,
child: Align( child: Align(
@ -947,6 +1075,7 @@ class _StaffRow extends StatelessWidget {
child: _PulseStatusPill(label: row.status), child: _PulseStatusPill(label: row.status),
), ),
), ),
if (!isMobile)
Expanded( Expanded(
flex: 2, flex: 2,
child: Text( child: Text(
@ -955,25 +1084,27 @@ class _StaffRow extends StatelessWidget {
color: row.whereabouts == 'In premise' color: row.whereabouts == 'In premise'
? Colors.green ? Colors.green
: row.whereabouts == 'Outside premise' : row.whereabouts == 'Outside premise'
? Colors.orange ? Colors.grey
: row.whereabouts == 'Tracking off'
? Colors.grey
: null, : null,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
), ),
Expanded( Expanded(
flex: 2, flex: isMobile ? 1 : 2,
child: Text( child: Text(
row.ticketsRespondedToday.toString(), row.ticketsRespondedToday.toString(),
style: valueStyle, style: valueStyle,
), ),
), ),
Expanded( Expanded(
flex: 2, flex: isMobile ? 1 : 2,
child: Text(row.tasksClosedToday.toString(), style: valueStyle), child: Text(row.tasksClosedToday.toString(), style: valueStyle),
), ),
Expanded( Expanded(
flex: 2, flex: isMobile ? 1 : 2,
child: Text(row.eventsHandledToday.toString(), style: valueStyle), child: Text(row.eventsHandledToday.toString(), style: valueStyle),
), ),
], ],

View File

@ -277,6 +277,9 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
final itStaff = profiles.where((p) => p.role == 'it_staff').toList(); final itStaff = profiles.where((p) => p.role == 'it_staff').toList();
final nameController = TextEditingController(text: team?.name ?? ''); final nameController = TextEditingController(text: team?.name ?? '');
String? leaderId = team?.leaderId; String? leaderId = team?.leaderId;
Color? teamColor = team?.color != null
? Color(int.parse(team!.color!, radix: 16) | 0xFF000000)
: null;
List<String> selectedOffices = List<String>.from(team?.officeIds ?? []); List<String> selectedOffices = List<String>.from(team?.officeIds ?? []);
List<String> selectedMembers = []; List<String> selectedMembers = [];
final isEdit = team != null; final isEdit = team != null;
@ -305,6 +308,11 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
decoration: const InputDecoration(labelText: 'Team Name'), decoration: const InputDecoration(labelText: 'Team Name'),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_TeamColorPicker(
selectedColor: teamColor,
onColorChanged: (c) => setState(() => teamColor = c),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
initialValue: leaderId, initialValue: leaderId,
items: [ items: [
@ -408,6 +416,11 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
'name': name, 'name': name,
'leader_id': leaderId, 'leader_id': leaderId,
'office_ids': selectedOffices, 'office_ids': selectedOffices,
'color': teamColor != null
? (teamColor!.toARGB32() & 0xFFFFFF)
.toRadixString(16)
.padLeft(6, '0')
: null,
}) })
.eq('id', team.id); .eq('id', team.id);
final upErr = extractSupabaseError(upRes); final upErr = extractSupabaseError(upRes);
@ -454,6 +467,11 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
'name': name, 'name': name,
'leader_id': leaderId, 'leader_id': leaderId,
'office_ids': selectedOffices, 'office_ids': selectedOffices,
'color': teamColor != null
? (teamColor!.toARGB32() & 0xFFFFFF)
.toRadixString(16)
.padLeft(6, '0')
: null,
}) })
.select() .select()
.single(); .single();
@ -657,3 +675,90 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
} }
} }
} }
/// Inline color picker for team color selection.
class _TeamColorPicker extends StatelessWidget {
const _TeamColorPicker({
required this.selectedColor,
required this.onColorChanged,
});
final Color? selectedColor;
final ValueChanged<Color?> onColorChanged;
static const List<Color> _presetColors = [
Color(0xFFE53935), // Red
Color(0xFFD81B60), // Pink
Color(0xFF8E24AA), // Purple
Color(0xFF5E35B1), // Deep Purple
Color(0xFF3949AB), // Indigo
Color(0xFF1E88E5), // Blue
Color(0xFF039BE5), // Light Blue
Color(0xFF00ACC1), // Cyan
Color(0xFF00897B), // Teal
Color(0xFF43A047), // Green
Color(0xFF7CB342), // Light Green
Color(0xFFFDD835), // Yellow
Color(0xFFFFB300), // Amber
Color(0xFFFB8C00), // Orange
Color(0xFFF4511E), // Deep Orange
Color(0xFF6D4C41), // Brown
];
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('Team Color', style: Theme.of(context).textTheme.bodyMedium),
if (selectedColor != null) ...[
const SizedBox(width: 8),
GestureDetector(
onTap: () => onColorChanged(null),
child: Text(
'Clear',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
),
],
],
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _presetColors.map((color) {
final isSelected =
selectedColor != null &&
(selectedColor!.toARGB32() & 0xFFFFFF) ==
(color.toARGB32() & 0xFFFFFF);
return GestureDetector(
onTap: () => onColorChanged(color),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: isSelected
? Border.all(
color: Theme.of(context).colorScheme.onSurface,
width: 3,
)
: null,
),
child: isSelected
? Icon(Icons.check, color: Colors.white, size: 18)
: null,
),
);
}).toList(),
),
],
);
}
}

View File

@ -4,14 +4,50 @@ import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart' show LatLng; import 'package:latlong2/latlong.dart' show LatLng;
import '../../models/app_settings.dart'; import '../../models/app_settings.dart';
import '../../models/attendance_log.dart';
import '../../models/live_position.dart'; import '../../models/live_position.dart';
import '../../models/profile.dart'; import '../../models/profile.dart';
import '../../providers/attendance_provider.dart';
import '../../providers/profile_provider.dart'; import '../../providers/profile_provider.dart';
import '../../providers/whereabouts_provider.dart'; import '../../providers/whereabouts_provider.dart';
import '../../providers/workforce_provider.dart'; import '../../providers/workforce_provider.dart';
import '../../theme/app_surfaces.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../utils/app_time.dart'; import '../../utils/app_time.dart';
/// Roles shown in the whereabouts tracker.
const _trackedRoles = {'admin', 'dispatcher', 'it_staff'};
/// Role color mapping shared between map pins and legend.
Color _roleColor(String? role) {
return switch (role) {
'admin' => Colors.blue.shade700,
'it_staff' => Colors.green.shade700,
'dispatcher' => Colors.orange.shade700,
_ => Colors.grey,
};
}
String _roleLabel(String role) {
return switch (role) {
'admin' => 'Admin',
'dispatcher' => 'Dispatcher',
'it_staff' => 'IT Staff',
_ => 'Standard',
};
}
String _timeAgo(DateTime dt) {
final diff = AppTime.now().difference(dt);
if (diff.inMinutes < 1) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
return '${diff.inHours}h ago';
}
// ---------------------------------------------------------------------------
// Whereabouts Screen
// ---------------------------------------------------------------------------
class WhereaboutsScreen extends ConsumerStatefulWidget { class WhereaboutsScreen extends ConsumerStatefulWidget {
const WhereaboutsScreen({super.key}); const WhereaboutsScreen({super.key});
@ -27,10 +63,37 @@ class _WhereaboutsScreenState extends ConsumerState<WhereaboutsScreen> {
final positionsAsync = ref.watch(livePositionsProvider); final positionsAsync = ref.watch(livePositionsProvider);
final profilesAsync = ref.watch(profilesProvider); final profilesAsync = ref.watch(profilesProvider);
final geofenceAsync = ref.watch(geofenceProvider); final geofenceAsync = ref.watch(geofenceProvider);
final logsAsync = ref.watch(attendanceLogsProvider);
final profiles = profilesAsync.valueOrNull ?? const <Profile>[];
final positions = positionsAsync.valueOrNull ?? const <LivePosition>[];
final geofenceConfig = geofenceAsync.valueOrNull;
final allLogs = logsAsync.valueOrNull ?? const <AttendanceLog>[];
final Map<String, Profile> profileById = { final Map<String, Profile> profileById = {
for (final p in profilesAsync.valueOrNull ?? []) p.id: p, for (final p in profiles) p.id: p,
}; };
final Map<String, LivePosition> positionByUser = {
for (final p in positions) p.userId: p,
};
// Build active-check-in set for tracking-off users who are still
// inside the geofence (checked in today and not checked out).
final now = AppTime.now();
final startOfDay = DateTime(now.year, now.month, now.day);
final activeCheckInUsers = <String>{};
for (final log in allLogs) {
if (!log.checkInAt.isBefore(startOfDay) && !log.isCheckedOut) {
activeCheckInUsers.add(log.userId);
}
}
// All admin / dispatcher / it_staff profiles, sorted by name.
final trackedProfiles =
profiles.where((p) => _trackedRoles.contains(p.role)).toList()
..sort((a, b) => a.fullName.compareTo(b.fullName));
final isLoading = positionsAsync.isLoading && !positionsAsync.hasValue;
return ResponsiveBody( return ResponsiveBody(
maxWidth: 1200, maxWidth: 1200,
@ -46,41 +109,69 @@ class _WhereaboutsScreenState extends ConsumerState<WhereaboutsScreen> {
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
), ),
), ),
// Map
Expanded( Expanded(
child: positionsAsync.when( flex: 3,
data: (positions) => _buildMap( child: Padding(
context, padding: const EdgeInsets.symmetric(horizontal: 16),
positions, child: isLoading
profileById, ? const Center(child: CircularProgressIndicator())
geofenceAsync.valueOrNull, : ClipRRect(
), borderRadius: BorderRadius.circular(16),
loading: () => const Center(child: CircularProgressIndicator()), child: _WhereaboutsMap(
error: (e, _) => mapController: _mapController,
Center(child: Text('Failed to load positions: $e')), positions: positions,
profileById: profileById,
geofenceConfig: geofenceConfig,
), ),
), ),
// Staff list below the map
positionsAsync.when(
data: (positions) =>
_buildStaffList(context, positions, profileById),
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
), ),
),
const SizedBox(height: 8),
// Staff Legend Panel (custom widget outside the map)
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _StaffLegendPanel(
profiles: trackedProfiles,
positionByUser: positionByUser,
activeCheckInUsers: activeCheckInUsers,
mapController: _mapController,
),
),
),
const SizedBox(height: 8),
], ],
), ),
); );
} }
}
Widget _buildMap( // ---------------------------------------------------------------------------
BuildContext context, // Map widget
List<LivePosition> positions, // ---------------------------------------------------------------------------
Map<String, Profile> profileById,
GeofenceConfig? geofenceConfig,
) {
// Only pin users who are in-premise (privacy: don't reveal off-site locations).
final inPremisePositions = positions.where((pos) => pos.inPremise).toList();
final markers = inPremisePositions.map((pos) { class _WhereaboutsMap extends StatelessWidget {
const _WhereaboutsMap({
required this.mapController,
required this.positions,
required this.profileById,
required this.geofenceConfig,
});
final MapController mapController;
final List<LivePosition> positions;
final Map<String, Profile> profileById;
final GeofenceConfig? geofenceConfig;
@override
Widget build(BuildContext context) {
// Only pin in-premise users outside-geofence users are greyed out
// in the legend and their exact location is not shown on the map.
final inPremise = positions.where((p) => p.inPremise).toList();
final markers = inPremise.map((pos) {
final profile = profileById[pos.userId]; final profile = profileById[pos.userId];
final name = profile?.fullName ?? 'Unknown'; final name = profile?.fullName ?? 'Unknown';
final pinColor = _roleColor(profile?.role); final pinColor = _roleColor(profile?.role);
@ -117,10 +208,11 @@ class _WhereaboutsScreenState extends ConsumerState<WhereaboutsScreen> {
); );
}).toList(); }).toList();
// Build geofence polygon overlay if available // Geofence polygon overlay
final polygonLayers = <PolygonLayer>[]; final polygonLayers = <PolygonLayer>[];
if (geofenceConfig != null && geofenceConfig.hasPolygon) { final geoConfig = geofenceConfig;
final List<LatLng> points = geofenceConfig.polygon! if (geoConfig != null && geoConfig.hasPolygon) {
final points = geoConfig.polygon!
.map((p) => LatLng(p.lat, p.lng)) .map((p) => LatLng(p.lat, p.lng))
.toList(); .toList();
if (points.isNotEmpty) { if (points.isNotEmpty) {
@ -143,10 +235,10 @@ class _WhereaboutsScreenState extends ConsumerState<WhereaboutsScreen> {
const defaultCenter = LatLng(7.2046, 124.2460); const defaultCenter = LatLng(7.2046, 124.2460);
return FlutterMap( return FlutterMap(
mapController: _mapController, mapController: mapController,
options: MapOptions( options: MapOptions(
initialCenter: positions.isNotEmpty initialCenter: inPremise.isNotEmpty
? LatLng(positions.first.lat, positions.first.lng) ? LatLng(inPremise.first.lat, inPremise.first.lng)
: defaultCenter, : defaultCenter,
initialZoom: 16.0, initialZoom: 16.0,
), ),
@ -157,105 +249,99 @@ class _WhereaboutsScreenState extends ConsumerState<WhereaboutsScreen> {
), ),
...polygonLayers, ...polygonLayers,
MarkerLayer(markers: markers), MarkerLayer(markers: markers),
// OSM attribution (required by OpenStreetMap tile usage policy).
const RichAttributionWidget(
alignment: AttributionAlignment.bottomLeft,
attributions: [TextSourceAttribution('OpenStreetMap contributors')],
),
], ],
); );
} }
}
Widget _buildStaffList( // ---------------------------------------------------------------------------
BuildContext context, // Staff Legend Panel sits outside the map
List<LivePosition> positions, // ---------------------------------------------------------------------------
Map<String, Profile> profileById,
) {
if (positions.isEmpty) return const SizedBox.shrink();
// Only include Admin, IT Staff, and Dispatcher in the legend. class _StaffLegendPanel extends StatelessWidget {
final relevantRoles = {'admin', 'dispatcher', 'it_staff'}; const _StaffLegendPanel({
final legendEntries = positions.where((pos) { required this.profiles,
final role = profileById[pos.userId]?.role; required this.positionByUser,
return role != null && relevantRoles.contains(role); required this.activeCheckInUsers,
}).toList(); required this.mapController,
});
if (legendEntries.isEmpty) return const SizedBox.shrink(); final List<Profile> profiles;
final Map<String, LivePosition> positionByUser;
final Set<String> activeCheckInUsers;
final MapController mapController;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final surfaces = AppSurfaces.of(context);
return Container( return Container(
constraints: const BoxConstraints(maxHeight: 220), clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: cs.surfaceContainerLow,
borderRadius: BorderRadius.circular(surfaces.cardRadius),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Role color legend header // Header with role badges
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row( child: Row(
children: [ children: [
_legendDot(Colors.blue.shade700),
const SizedBox(width: 4),
Text('Admin', style: Theme.of(context).textTheme.labelSmall),
const SizedBox(width: 12),
_legendDot(Colors.green.shade700),
const SizedBox(width: 4),
Text('IT Staff', style: Theme.of(context).textTheme.labelSmall),
const SizedBox(width: 12),
_legendDot(Colors.orange.shade700),
const SizedBox(width: 4),
Text( Text(
'Dispatcher', 'Staff',
style: Theme.of(context).textTheme.labelSmall, style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
), ),
const Spacer(),
_RoleBadge(color: Colors.blue.shade700, label: 'Admin'),
const SizedBox(width: 12),
_RoleBadge(color: Colors.green.shade700, label: 'IT Staff'),
const SizedBox(width: 12),
_RoleBadge(color: Colors.orange.shade700, label: 'Dispatcher'),
], ],
), ),
), ),
const Divider(height: 1),
// Staff entries // Staff entries
Expanded( Expanded(
child: ListView.builder( child: profiles.isEmpty
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), ? Center(
itemCount: legendEntries.length, child: Text(
'No tracked staff',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: cs.onSurfaceVariant,
),
),
)
: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: profiles.length,
separatorBuilder: (_, _) =>
const Divider(height: 1, indent: 56),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final pos = legendEntries[index]; final profile = profiles[index];
final p = profileById[pos.userId]; final position = positionByUser[profile.id];
final name = p?.fullName ?? 'Unknown'; final hasActiveCheckIn = activeCheckInUsers.contains(
final role = p?.role ?? '-'; profile.id,
final isInPremise = pos.inPremise; );
final pinColor = _roleColor(role); return _StaffLegendTile(
final timeAgo = _timeAgo(pos.updatedAt); profile: profile,
// Grey out outside-premise users for privacy. position: position,
final effectiveColor = isInPremise hasActiveCheckIn: hasActiveCheckIn,
? pinColor onTap: position != null && position.inPremise
: Colors.grey.shade400; ? () => mapController.move(
LatLng(position.lat, position.lng),
return ListTile( 17.0,
dense: true, )
leading: CircleAvatar(
radius: 16,
backgroundColor: effectiveColor.withValues(alpha: 0.2),
child: Icon(
isInPremise ? Icons.location_pin : Icons.location_off,
size: 16,
color: effectiveColor,
),
),
title: Text(
name,
style: TextStyle(
color: isInPremise ? null : Colors.grey,
fontWeight: isInPremise
? FontWeight.w600
: FontWeight.normal,
),
),
subtitle: Text(
'${_roleLabel(role)} · ${isInPremise ? timeAgo : 'Outside premise'}',
style: TextStyle(color: isInPremise ? null : Colors.grey),
),
trailing: isInPremise
? Icon(Icons.circle, size: 10, color: pinColor)
: Icon(
Icons.circle,
size: 10,
color: Colors.grey.shade300,
),
onTap: isInPremise
? () =>
_mapController.move(LatLng(pos.lat, pos.lng), 17.0)
: null, : null,
); );
}, },
@ -265,46 +351,151 @@ class _WhereaboutsScreenState extends ConsumerState<WhereaboutsScreen> {
), ),
); );
} }
}
Widget _legendDot(Color color) { // ---------------------------------------------------------------------------
return Container( // Role badge (dot + label)
// ---------------------------------------------------------------------------
class _RoleBadge extends StatelessWidget {
const _RoleBadge({required this.color, required this.label});
final Color color;
final String label;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10, width: 10,
height: 10, height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle), decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 4),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
); );
} }
/// Returns the pin color for a given role.
static Color _roleColor(String? role) {
switch (role) {
case 'admin':
return Colors.blue.shade700;
case 'it_staff':
return Colors.green.shade700;
case 'dispatcher':
return Colors.orange.shade700;
default:
return Colors.grey;
}
} }
String _timeAgo(DateTime dt) { // ---------------------------------------------------------------------------
final diff = AppTime.now().difference(dt); // Individual staff legend tile
if (diff.inMinutes < 1) return 'Just now'; // ---------------------------------------------------------------------------
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
return '${diff.inHours}h ago'; class _StaffLegendTile extends StatelessWidget {
const _StaffLegendTile({
required this.profile,
required this.position,
required this.hasActiveCheckIn,
this.onTap,
});
final Profile profile;
final LivePosition? position;
final bool hasActiveCheckIn;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final roleColor = _roleColor(profile.role);
final hasPosition = position != null;
final isInPremise = position?.inPremise ?? false;
final isTrackingOff = !profile.allowTracking;
// Determine display state
final bool isActive = hasPosition && isInPremise;
final bool isGreyedOut = !isActive;
// For tracking-off users without a live position, infer from check-in.
final bool inferredInPremise =
isTrackingOff && !hasPosition && hasActiveCheckIn;
final effectiveColor = (isActive || inferredInPremise)
? roleColor
: Colors.grey.shade400;
// Build status label
final String statusText;
final Color statusColor;
if (isTrackingOff) {
if (hasPosition) {
statusText = isInPremise
? 'In premise (Tracking off)'
: 'Outside premise (Tracking off)';
statusColor = isInPremise ? Colors.green : Colors.grey;
} else if (inferredInPremise) {
statusText = 'In premise (Checked in)';
statusColor = Colors.green;
} else {
statusText = 'Tracking off';
statusColor = Colors.grey;
}
} else if (isActive) {
statusText = 'In premise \u00b7 ${_timeAgo(position!.updatedAt)}';
statusColor = Colors.green;
} else if (hasPosition) {
statusText = 'Outside premise';
statusColor = Colors.grey;
} else {
statusText = 'No location data';
statusColor = Colors.grey;
} }
String _roleLabel(String role) { final IconData statusIcon;
switch (role) { if (isTrackingOff) {
case 'admin': statusIcon = Icons.location_disabled;
return 'Admin'; } else if (isActive) {
case 'dispatcher': statusIcon = Icons.location_on;
return 'Dispatcher'; } else if (hasPosition) {
case 'it_staff': statusIcon = Icons.location_off;
return 'IT Staff'; } else {
default: statusIcon = Icons.location_searching;
return 'Standard'; }
}
return ListTile(
dense: true,
onTap: onTap,
leading: CircleAvatar(
radius: 18,
backgroundColor: effectiveColor.withValues(alpha: 0.15),
child: Icon(statusIcon, size: 18, color: effectiveColor),
),
title: Text(
profile.fullName.isNotEmpty ? profile.fullName : 'Unknown',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: (isActive || inferredInPremise) ? FontWeight.w600 : null,
color: (isGreyedOut && !inferredInPremise)
? cs.onSurfaceVariant.withValues(alpha: 0.6)
: null,
),
),
subtitle: Text(
'${_roleLabel(profile.role)} \u00b7 $statusText',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: (isGreyedOut && !inferredInPremise)
? cs.onSurfaceVariant.withValues(alpha: 0.5)
: statusColor,
),
),
trailing: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: (isActive || inferredInPremise)
? roleColor
: Colors.grey.shade300,
shape: BoxShape.circle,
),
),
);
} }
} }

View File

@ -54,12 +54,14 @@ void callbackDispatcher() {
/// Initialize Workmanager and register periodic background location task. /// Initialize Workmanager and register periodic background location task.
Future<void> initBackgroundLocationService() async { Future<void> initBackgroundLocationService() async {
if (kIsWeb) return; // Workmanager is not supported on web.
await Workmanager().initialize(callbackDispatcher); await Workmanager().initialize(callbackDispatcher);
} }
/// Register a periodic task to report location every ~15 minutes /// Register a periodic task to report location every ~15 minutes
/// (Android minimum for periodic Workmanager tasks). /// (Android minimum for periodic Workmanager tasks).
Future<void> startBackgroundLocationUpdates() async { Future<void> startBackgroundLocationUpdates() async {
if (kIsWeb) return; // Workmanager is not supported on web.
await Workmanager().registerPeriodicTask( await Workmanager().registerPeriodicTask(
_taskName, _taskName,
_taskName, _taskName,
@ -71,5 +73,6 @@ Future<void> startBackgroundLocationUpdates() async {
/// Cancel the periodic background location task. /// Cancel the periodic background location task.
Future<void> stopBackgroundLocationUpdates() async { Future<void> stopBackgroundLocationUpdates() async {
if (kIsWeb) return; // Workmanager is not supported on web.
await Workmanager().cancelByUniqueName(_taskName); await Workmanager().cancelByUniqueName(_taskName);
} }

View File

@ -0,0 +1,33 @@
import 'dart:typed_data';
import 'package:flutter_image_compress/flutter_image_compress.dart';
/// Compresses a JPEG image to reduce upload size.
///
/// Target: 200 KB for verification selfies. Falls back to the original
/// bytes if compression fails so the upload is never blocked.
class ImageCompressService {
ImageCompressService._();
/// Compress [bytes] (JPEG/PNG) down to [quality] (1100).
/// Resizes the longest side to at most [maxDimension] pixels.
static Future<Uint8List> compress(
Uint8List bytes, {
int quality = 70,
int maxDimension = 800,
}) async {
try {
final result = await FlutterImageCompress.compressWithList(
bytes,
minHeight: maxDimension,
minWidth: maxDimension,
quality: quality,
format: CompressFormat.jpeg,
);
return result;
} catch (_) {
// If compression fails on any platform, return original bytes.
return bytes;
}
}
}

View File

@ -31,6 +31,10 @@ class AppScaffold extends ConsumerWidget {
}, },
orElse: () => 'User', orElse: () => 'User',
); );
final avatarUrl = profileAsync.maybeWhen(
data: (profile) => profile?.avatarUrl,
orElse: () => null,
);
final isStandard = role == 'standard'; final isStandard = role == 'standard';
final location = GoRouterState.of(context).uri.toString(); final location = GoRouterState.of(context).uri.toString();
@ -75,7 +79,11 @@ class AppScaffold extends ConsumerWidget {
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row( child: Row(
children: [ children: [
ProfileAvatar(fullName: displayName, radius: 16), ProfileAvatar(
fullName: displayName,
avatarUrl: avatarUrl,
radius: 16,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(displayName), Text(displayName),
const SizedBox(width: 4), const SizedBox(width: 4),
@ -91,7 +99,11 @@ class AppScaffold extends ConsumerWidget {
IconButton( IconButton(
tooltip: 'Profile', tooltip: 'Profile',
onPressed: () => context.go('/profile'), onPressed: () => context.go('/profile'),
icon: ProfileAvatar(fullName: displayName, radius: 16), icon: ProfileAvatar(
fullName: displayName,
avatarUrl: avatarUrl,
radius: 16,
),
), ),
IconButton( IconButton(
tooltip: 'Sign out', tooltip: 'Sign out',

View File

@ -8,11 +8,20 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/attendance_provider.dart'; import '../providers/attendance_provider.dart';
import '../providers/profile_provider.dart'; import '../providers/profile_provider.dart';
import '../services/face_verification.dart' as face; import '../services/face_verification.dart' as face;
import '../services/image_compress_service.dart';
import '../theme/m3_motion.dart'; import '../theme/m3_motion.dart';
import '../widgets/qr_verification_dialog.dart'; import '../widgets/qr_verification_dialog.dart';
/// Phases of the full-screen face verification overlay. /// Phases of the full-screen face verification overlay.
enum _Phase { liveness, downloading, matching, success, failed, cancelled } enum _Phase {
liveness,
downloading,
matching,
saving,
success,
failed,
cancelled,
}
/// Result returned from the overlay. /// Result returned from the overlay.
class FaceVerificationResult { class FaceVerificationResult {
@ -170,14 +179,19 @@ class _FaceVerificationOverlayState
final score = await face.compareFaces(result.imageBytes, enrolledBytes); final score = await face.compareFaces(result.imageBytes, enrolledBytes);
if (score >= 0.60) { if (score >= 0.60) {
// Success! // Success! Transition to saving phase for compress + upload.
if (!mounted) return;
setState(() {
_phase = _Phase.saving;
_statusText = 'Compressing & saving photo...';
});
await _compressAndUpload(result.imageBytes, 'verified');
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_phase = _Phase.success; _phase = _Phase.success;
_statusText = _statusText =
'Face verified!\n${(score * 100).toStringAsFixed(0)}% match'; 'Face verified!\n${(score * 100).toStringAsFixed(0)}% match';
}); });
await _uploadResult(result.imageBytes, 'verified');
await Future.delayed(const Duration(milliseconds: 1200)); await Future.delayed(const Duration(milliseconds: 1200));
if (mounted) { if (mounted) {
Navigator.of( Navigator.of(
@ -201,13 +215,18 @@ class _FaceVerificationOverlayState
} else { } else {
// All attempts exhausted // All attempts exhausted
if (!mounted) return; if (!mounted) return;
setState(() {
_phase = _Phase.saving;
_statusText = 'Compressing & saving photo...';
});
await _compressAndUpload(result.imageBytes, 'unverified');
if (!mounted) return;
setState(() { setState(() {
_phase = _Phase.failed; _phase = _Phase.failed;
_statusText = _statusText =
'Face did not match after ${widget.maxAttempts} attempts\n' 'Face did not match after ${widget.maxAttempts} attempts\n'
'${(score * 100).toStringAsFixed(0)}% similarity'; '${(score * 100).toStringAsFixed(0)}% similarity';
}); });
await _uploadResult(result.imageBytes, 'unverified');
await Future.delayed(const Duration(milliseconds: 1500)); await Future.delayed(const Duration(milliseconds: 1500));
if (mounted) { if (mounted) {
Navigator.of( Navigator.of(
@ -218,16 +237,17 @@ class _FaceVerificationOverlayState
} }
} }
Future<void> _uploadResult(Uint8List bytes, String status) async { Future<void> _compressAndUpload(Uint8List bytes, String status) async {
if (!widget.uploadAttendanceResult || widget.attendanceLogId == null) { if (!widget.uploadAttendanceResult || widget.attendanceLogId == null) {
return; return;
} }
try { try {
final compressed = await ImageCompressService.compress(bytes);
await ref await ref
.read(attendanceControllerProvider) .read(attendanceControllerProvider)
.uploadVerification( .uploadVerification(
attendanceId: widget.attendanceLogId!, attendanceId: widget.attendanceLogId!,
bytes: bytes, bytes: compressed,
fileName: 'verification.jpg', fileName: 'verification.jpg',
status: status, status: status,
); );
@ -280,6 +300,8 @@ class _FaceVerificationOverlayState
? Colors.green ? Colors.green
: _phase == _Phase.failed : _phase == _Phase.failed
? colors.error ? colors.error
: _phase == _Phase.saving
? colors.tertiary
: colors.onSurface, : colors.onSurface,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@ -304,6 +326,7 @@ class _FaceVerificationOverlayState
_phase == _Phase.liveness || _phase == _Phase.liveness ||
_phase == _Phase.downloading || _phase == _Phase.downloading ||
_phase == _Phase.matching; _phase == _Phase.matching;
final isSaving = _phase == _Phase.saving;
final isSuccess = _phase == _Phase.success; final isSuccess = _phase == _Phase.success;
final isFailed = _phase == _Phase.failed; final isFailed = _phase == _Phase.failed;
@ -312,6 +335,8 @@ class _FaceVerificationOverlayState
ringColor = Colors.green; ringColor = Colors.green;
} else if (isFailed) { } else if (isFailed) {
ringColor = colors.error; ringColor = colors.error;
} else if (isSaving) {
ringColor = colors.tertiary;
} else { } else {
ringColor = colors.primary; ringColor = colors.primary;
} }
@ -324,7 +349,9 @@ class _FaceVerificationOverlayState
children: [ children: [
// Pulsing ring // Pulsing ring
ScaleTransition( ScaleTransition(
scale: isActive ? _pulseAnim : const AlwaysStoppedAnimation(1.0), scale: isActive || isSaving
? _pulseAnim
: const AlwaysStoppedAnimation(1.0),
child: Container( child: Container(
width: 180, width: 180,
height: 180, height: 180,
@ -355,6 +382,8 @@ class _FaceVerificationOverlayState
? Icons.check_circle_rounded ? Icons.check_circle_rounded
: isFailed : isFailed
? Icons.error_rounded ? Icons.error_rounded
: isSaving
? Icons.cloud_upload_rounded
: Icons.face_rounded, : Icons.face_rounded,
key: ValueKey(_phase), key: ValueKey(_phase),
size: 64, size: 64,
@ -400,6 +429,14 @@ class _FaceVerificationOverlayState
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
); );
} }
if (_phase == _Phase.saving) {
return LinearProgressIndicator(
value: null,
backgroundColor: colors.tertiary.withValues(alpha: 0.12),
color: colors.tertiary,
borderRadius: BorderRadius.circular(4),
);
}
return LinearProgressIndicator( return LinearProgressIndicator(
value: null, value: null,
backgroundColor: colors.primary.withValues(alpha: 0.12), backgroundColor: colors.primary.withValues(alpha: 0.12),

View File

@ -12,6 +12,7 @@ import file_picker
import file_selector_macos import file_selector_macos
import firebase_core import firebase_core
import firebase_messaging import firebase_messaging
import flutter_image_compress_macos
import flutter_local_notifications import flutter_local_notifications
import geolocator_apple import geolocator_apple
import printing import printing
@ -27,6 +28,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))

View File

@ -518,6 +518,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.2.1" version: "5.2.1"
flutter_image_compress:
dependency: "direct main"
description:
name: flutter_image_compress
sha256: "51d23be39efc2185e72e290042a0da41aed70b14ef97db362a6b5368d0523b27"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
flutter_image_compress_common:
dependency: transitive
description:
name: flutter_image_compress_common
sha256: c5c5d50c15e97dd7dc72ff96bd7077b9f791932f2076c5c5b6c43f2c88607bfb
url: "https://pub.dev"
source: hosted
version: "1.0.6"
flutter_image_compress_macos:
dependency: transitive
description:
name: flutter_image_compress_macos
sha256: "20019719b71b743aba0ef874ed29c50747461e5e8438980dfa5c2031898f7337"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
flutter_image_compress_ohos:
dependency: transitive
description:
name: flutter_image_compress_ohos
sha256: e76b92bbc830ee08f5b05962fc78a532011fcd2041f620b5400a593e96da3f51
url: "https://pub.dev"
source: hosted
version: "0.0.3"
flutter_image_compress_platform_interface:
dependency: transitive
description:
name: flutter_image_compress_platform_interface
sha256: "579cb3947fd4309103afe6442a01ca01e1e6f93dc53bb4cbd090e8ce34a41889"
url: "https://pub.dev"
source: hosted
version: "1.0.5"
flutter_image_compress_web:
dependency: transitive
description:
name: flutter_image_compress_web
sha256: b9b141ac7c686a2ce7bb9a98176321e1182c9074650e47bb140741a44b6f5a96
url: "https://pub.dev"
source: hosted
version: "0.1.5"
flutter_keyboard_visibility: flutter_keyboard_visibility:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -46,6 +46,7 @@ dependencies:
flutter_liveness_check: ^1.0.3 flutter_liveness_check: ^1.0.3
google_mlkit_face_detection: ^0.13.2 google_mlkit_face_detection: ^0.13.2
qr_flutter: ^4.1.0 qr_flutter: ^4.1.0
flutter_image_compress: ^2.4.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: