Initial Commit: Duty Schedule and Attendance Logbook
This commit is contained in:
@@ -35,11 +35,19 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
'admin',
|
||||
];
|
||||
|
||||
static const List<String> _religions = [
|
||||
'catholic',
|
||||
'islam',
|
||||
'protestant',
|
||||
'other',
|
||||
];
|
||||
|
||||
final _fullNameController = TextEditingController();
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
String? _selectedUserId;
|
||||
String? _selectedRole;
|
||||
String _selectedReligion = 'catholic';
|
||||
final Set<String> _selectedOfficeIds = {};
|
||||
bool _isSaving = false;
|
||||
|
||||
@@ -299,6 +307,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
setState(() {
|
||||
_selectedUserId = profile.id;
|
||||
_selectedRole = profile.role;
|
||||
_selectedReligion = profile.religion;
|
||||
_fullNameController.text = profile.fullName;
|
||||
_selectedOfficeIds
|
||||
..clear()
|
||||
@@ -345,6 +354,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
setState(() {
|
||||
_selectedUserId = null;
|
||||
_selectedRole = null;
|
||||
_selectedReligion = 'catholic';
|
||||
_selectedOfficeIds.clear();
|
||||
_fullNameController.clear();
|
||||
});
|
||||
@@ -377,6 +387,22 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
decoration: const InputDecoration(labelText: 'Role'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
key: ValueKey('religion_${_selectedUserId ?? 'none'}'),
|
||||
initialValue: _selectedReligion,
|
||||
items: _religions
|
||||
.map(
|
||||
(r) => DropdownMenuItem(
|
||||
value: r,
|
||||
child: Text(r[0].toUpperCase() + r.substring(1)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) =>
|
||||
setDialogState(() => _selectedReligion = value ?? 'catholic'),
|
||||
decoration: const InputDecoration(labelText: 'Religion'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Email and lock status are retrieved from auth via Edge Function / admin API.
|
||||
Consumer(
|
||||
@@ -529,7 +555,12 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
try {
|
||||
await ref
|
||||
.read(adminUserControllerProvider)
|
||||
.updateProfile(userId: profile.id, fullName: fullName, role: role);
|
||||
.updateProfile(
|
||||
userId: profile.id,
|
||||
fullName: fullName,
|
||||
role: role,
|
||||
religion: _selectedReligion,
|
||||
);
|
||||
|
||||
final toAdd = _selectedOfficeIds.difference(currentOfficeIds);
|
||||
final toRemove = currentOfficeIds.difference(_selectedOfficeIds);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,229 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart' show LatLng;
|
||||
|
||||
import '../../models/app_settings.dart';
|
||||
import '../../models/live_position.dart';
|
||||
import '../../models/profile.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/whereabouts_provider.dart';
|
||||
import '../../providers/workforce_provider.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
|
||||
class WhereaboutsScreen extends ConsumerStatefulWidget {
|
||||
const WhereaboutsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<WhereaboutsScreen> createState() => _WhereaboutsScreenState();
|
||||
}
|
||||
|
||||
class _WhereaboutsScreenState extends ConsumerState<WhereaboutsScreen> {
|
||||
final _mapController = MapController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final positionsAsync = ref.watch(livePositionsProvider);
|
||||
final profilesAsync = ref.watch(profilesProvider);
|
||||
final geofenceAsync = ref.watch(geofenceProvider);
|
||||
|
||||
final Map<String, Profile> profileById = {
|
||||
for (final p in profilesAsync.valueOrNull ?? []) p.id: p,
|
||||
};
|
||||
|
||||
return ResponsiveBody(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Text(
|
||||
'Whereabouts',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: positionsAsync.when(
|
||||
data: (positions) => _buildMap(
|
||||
context,
|
||||
positions,
|
||||
profileById,
|
||||
geofenceAsync.valueOrNull,
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) =>
|
||||
Center(child: Text('Failed to load positions: $e')),
|
||||
),
|
||||
),
|
||||
// Staff list below the map
|
||||
positionsAsync.when(
|
||||
data: (positions) =>
|
||||
_buildStaffList(context, positions, profileById),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMap(
|
||||
BuildContext context,
|
||||
List<LivePosition> positions,
|
||||
Map<String, Profile> profileById,
|
||||
GeofenceConfig? geofenceConfig,
|
||||
) {
|
||||
final markers = positions.map((pos) {
|
||||
final name = profileById[pos.userId]?.fullName ?? 'Unknown';
|
||||
final inPremise = pos.inPremise;
|
||||
return Marker(
|
||||
point: LatLng(pos.lat, pos.lng),
|
||||
width: 80,
|
||||
height: 60,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.15),
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
name.split(' ').first,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.location_pin,
|
||||
size: 28,
|
||||
color: inPremise ? Colors.green : Colors.orange,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
// Build geofence polygon overlay if available
|
||||
final polygonLayers = <PolygonLayer>[];
|
||||
if (geofenceConfig != null && geofenceConfig.hasPolygon) {
|
||||
final List<LatLng> points = geofenceConfig.polygon!
|
||||
.map((p) => LatLng(p.lat, p.lng))
|
||||
.toList();
|
||||
if (points.isNotEmpty) {
|
||||
polygonLayers.add(
|
||||
PolygonLayer(
|
||||
polygons: [
|
||||
Polygon(
|
||||
points: points,
|
||||
color: Colors.blue.withValues(alpha: 0.1),
|
||||
borderColor: Colors.blue,
|
||||
borderStrokeWidth: 2.0,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Default center: CRMC Cotabato City area
|
||||
const defaultCenter = LatLng(7.2046, 124.2460);
|
||||
|
||||
return FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: positions.isNotEmpty
|
||||
? LatLng(positions.first.lat, positions.first.lng)
|
||||
: defaultCenter,
|
||||
initialZoom: 16.0,
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.tasq.app',
|
||||
),
|
||||
...polygonLayers,
|
||||
MarkerLayer(markers: markers),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStaffList(
|
||||
BuildContext context,
|
||||
List<LivePosition> positions,
|
||||
Map<String, Profile> profileById,
|
||||
) {
|
||||
if (positions.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxHeight: 180),
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
itemCount: positions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final pos = positions[index];
|
||||
final p = profileById[pos.userId];
|
||||
final name = p?.fullName ?? 'Unknown';
|
||||
final role = p?.role ?? '-';
|
||||
final timeAgo = _timeAgo(pos.updatedAt);
|
||||
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: pos.inPremise
|
||||
? Colors.green.shade100
|
||||
: Colors.orange.shade100,
|
||||
child: Icon(
|
||||
pos.inPremise ? Icons.check : Icons.location_off,
|
||||
size: 16,
|
||||
color: pos.inPremise ? Colors.green : Colors.orange,
|
||||
),
|
||||
),
|
||||
title: Text(name),
|
||||
subtitle: Text('${_roleLabel(role)} · $timeAgo'),
|
||||
trailing: Text(
|
||||
pos.inPremise ? 'In premise' : 'Off-site',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: pos.inPremise ? Colors.green : Colors.orange,
|
||||
),
|
||||
),
|
||||
onTap: () => _mapController.move(LatLng(pos.lat, pos.lng), 17.0),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
String _roleLabel(String role) {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return 'Admin';
|
||||
case 'dispatcher':
|
||||
return 'Dispatcher';
|
||||
case 'it_staff':
|
||||
return 'IT Staff';
|
||||
default:
|
||||
return 'Standard';
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user