Team Color, image compression for attendance verification, improved wherebouts
This commit is contained in:
parent
a8751ca728
commit
d87b5e73d7
|
|
@ -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?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 start–end), 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)),
|
||||||
Expanded(flex: 2, child: Text('Whereabouts', style: style)),
|
if (!isMobile)
|
||||||
Expanded(flex: 2, child: Text('Tickets', style: style)),
|
Expanded(flex: 2, child: Text('Whereabouts', style: style)),
|
||||||
Expanded(flex: 2, child: Text('Tasks', style: style)),
|
Expanded(
|
||||||
Expanded(flex: 2, child: Text('Events', style: style)),
|
flex: isMobile ? 1 : 2,
|
||||||
|
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,33 +1075,36 @@ class _StaffRow extends StatelessWidget {
|
||||||
child: _PulseStatusPill(label: row.status),
|
child: _PulseStatusPill(label: row.status),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
if (!isMobile)
|
||||||
flex: 2,
|
Expanded(
|
||||||
child: Text(
|
flex: 2,
|
||||||
row.whereabouts,
|
child: Text(
|
||||||
style: valueStyle?.copyWith(
|
row.whereabouts,
|
||||||
color: row.whereabouts == 'In premise'
|
style: valueStyle?.copyWith(
|
||||||
? Colors.green
|
color: row.whereabouts == 'In premise'
|
||||||
: row.whereabouts == 'Outside premise'
|
? Colors.green
|
||||||
? Colors.orange
|
: row.whereabouts == 'Outside premise'
|
||||||
: null,
|
? Colors.grey
|
||||||
fontWeight: FontWeight.w600,
|
: row.whereabouts == 'Tracking off'
|
||||||
|
? Colors.grey
|
||||||
|
: null,
|
||||||
|
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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
const SizedBox(height: 8),
|
||||||
positionsAsync.when(
|
// Staff Legend Panel (custom widget outside the map)
|
||||||
data: (positions) =>
|
Expanded(
|
||||||
_buildStaffList(context, positions, profileById),
|
flex: 2,
|
||||||
loading: () => const SizedBox.shrink(),
|
child: Padding(
|
||||||
error: (_, _) => const SizedBox.shrink(),
|
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,154 +249,253 @@ 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(
|
||||||
itemBuilder: (context, index) {
|
'No tracked staff',
|
||||||
final pos = legendEntries[index];
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
final p = profileById[pos.userId];
|
color: cs.onSurfaceVariant,
|
||||||
final name = p?.fullName ?? 'Unknown';
|
),
|
||||||
final role = p?.role ?? '-';
|
|
||||||
final isInPremise = pos.inPremise;
|
|
||||||
final pinColor = _roleColor(role);
|
|
||||||
final timeAgo = _timeAgo(pos.updatedAt);
|
|
||||||
// Grey out outside-premise users for privacy.
|
|
||||||
final effectiveColor = isInPremise
|
|
||||||
? pinColor
|
|
||||||
: Colors.grey.shade400;
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
: ListView.separated(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
itemCount: profiles.length,
|
||||||
|
separatorBuilder: (_, _) =>
|
||||||
|
const Divider(height: 1, indent: 56),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final profile = profiles[index];
|
||||||
|
final position = positionByUser[profile.id];
|
||||||
|
final hasActiveCheckIn = activeCheckInUsers.contains(
|
||||||
|
profile.id,
|
||||||
|
);
|
||||||
|
return _StaffLegendTile(
|
||||||
|
profile: profile,
|
||||||
|
position: position,
|
||||||
|
hasActiveCheckIn: hasActiveCheckIn,
|
||||||
|
onTap: position != null && position.inPremise
|
||||||
|
? () => mapController.move(
|
||||||
|
LatLng(position.lat, position.lng),
|
||||||
|
17.0,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _legendDot(Color color) {
|
// ---------------------------------------------------------------------------
|
||||||
return Container(
|
// Role badge (dot + label)
|
||||||
width: 10,
|
// ---------------------------------------------------------------------------
|
||||||
height: 10,
|
|
||||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
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,
|
||||||
|
height: 10,
|
||||||
|
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) {
|
// Individual staff legend tile
|
||||||
switch (role) {
|
// ---------------------------------------------------------------------------
|
||||||
case 'admin':
|
|
||||||
return Colors.blue.shade700;
|
class _StaffLegendTile extends StatelessWidget {
|
||||||
case 'it_staff':
|
const _StaffLegendTile({
|
||||||
return Colors.green.shade700;
|
required this.profile,
|
||||||
case 'dispatcher':
|
required this.position,
|
||||||
return Colors.orange.shade700;
|
required this.hasActiveCheckIn,
|
||||||
default:
|
this.onTap,
|
||||||
return Colors.grey;
|
});
|
||||||
|
|
||||||
|
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 _timeAgo(DateTime dt) {
|
final IconData statusIcon;
|
||||||
final diff = AppTime.now().difference(dt);
|
if (isTrackingOff) {
|
||||||
if (diff.inMinutes < 1) return 'Just now';
|
statusIcon = Icons.location_disabled;
|
||||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
} else if (isActive) {
|
||||||
return '${diff.inHours}h ago';
|
statusIcon = Icons.location_on;
|
||||||
}
|
} else if (hasPosition) {
|
||||||
|
statusIcon = Icons.location_off;
|
||||||
String _roleLabel(String role) {
|
} else {
|
||||||
switch (role) {
|
statusIcon = Icons.location_searching;
|
||||||
case 'admin':
|
|
||||||
return 'Admin';
|
|
||||||
case 'dispatcher':
|
|
||||||
return 'Dispatcher';
|
|
||||||
case 'it_staff':
|
|
||||||
return 'IT Staff';
|
|
||||||
default:
|
|
||||||
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
33
lib/services/image_compress_service.dart
Normal file
33
lib/services/image_compress_service.dart
Normal 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] (1–100).
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
48
pubspec.lock
48
pubspec.lock
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user