Implement Asia/Manila Time Zone
Handled saving of Relievers
This commit is contained in:
parent
747edbdd8c
commit
678a73a696
|
|
@ -6,12 +6,15 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
import 'providers/notifications_provider.dart';
|
import 'providers/notifications_provider.dart';
|
||||||
|
import 'utils/app_time.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
await dotenv.load(fileName: '.env');
|
await dotenv.load(fileName: '.env');
|
||||||
|
|
||||||
|
AppTime.initialize(location: 'Asia/Manila');
|
||||||
|
|
||||||
final supabaseUrl = dotenv.env['SUPABASE_URL'] ?? '';
|
final supabaseUrl = dotenv.env['SUPABASE_URL'] ?? '';
|
||||||
final supabaseAnonKey = dotenv.env['SUPABASE_ANON_KEY'] ?? '';
|
final supabaseAnonKey = dotenv.env['SUPABASE_ANON_KEY'] ?? '';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,69 @@
|
||||||
class GeofenceConfig {
|
class GeofenceConfig {
|
||||||
GeofenceConfig({
|
GeofenceConfig({this.lat, this.lng, this.radiusMeters, this.polygon});
|
||||||
required this.lat,
|
|
||||||
required this.lng,
|
final double? lat;
|
||||||
required this.radiusMeters,
|
final double? lng;
|
||||||
});
|
final double? radiusMeters;
|
||||||
|
final List<GeofencePoint>? polygon;
|
||||||
|
|
||||||
|
bool get hasPolygon => polygon?.isNotEmpty == true;
|
||||||
|
bool get hasCircle =>
|
||||||
|
lat != null && lng != null && radiusMeters != null && radiusMeters! > 0;
|
||||||
|
|
||||||
|
bool containsPolygon(double pointLat, double pointLng) {
|
||||||
|
final points = polygon;
|
||||||
|
if (points == null || points.isEmpty) return false;
|
||||||
|
var inside = false;
|
||||||
|
for (var i = 0, j = points.length - 1; i < points.length; j = i++) {
|
||||||
|
final xi = points[i].lng;
|
||||||
|
final yi = points[i].lat;
|
||||||
|
final xj = points[j].lng;
|
||||||
|
final yj = points[j].lat;
|
||||||
|
|
||||||
|
final intersects =
|
||||||
|
((yi > pointLat) != (yj > pointLat)) &&
|
||||||
|
(pointLng < (xj - xi) * (pointLat - yi) / (yj - yi) + xi);
|
||||||
|
if (intersects) {
|
||||||
|
inside = !inside;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory GeofenceConfig.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawPolygon = json['polygon'] ?? json['points'];
|
||||||
|
final polygonPoints = <GeofencePoint>[];
|
||||||
|
if (rawPolygon is List) {
|
||||||
|
for (final entry in rawPolygon) {
|
||||||
|
if (entry is Map<String, dynamic>) {
|
||||||
|
polygonPoints.add(GeofencePoint.fromJson(entry));
|
||||||
|
} else if (entry is Map) {
|
||||||
|
polygonPoints.add(
|
||||||
|
GeofencePoint.fromJson(Map<String, dynamic>.from(entry)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return GeofenceConfig(
|
||||||
|
lat: (json['lat'] as num?)?.toDouble(),
|
||||||
|
lng: (json['lng'] as num?)?.toDouble(),
|
||||||
|
radiusMeters: (json['radius_m'] as num?)?.toDouble(),
|
||||||
|
polygon: polygonPoints.isEmpty ? null : polygonPoints,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GeofencePoint {
|
||||||
|
const GeofencePoint({required this.lat, required this.lng});
|
||||||
|
|
||||||
final double lat;
|
final double lat;
|
||||||
final double lng;
|
final double lng;
|
||||||
final double radiusMeters;
|
|
||||||
|
|
||||||
factory GeofenceConfig.fromJson(Map<String, dynamic> json) {
|
factory GeofencePoint.fromJson(Map<String, dynamic> json) {
|
||||||
return GeofenceConfig(
|
return GeofencePoint(
|
||||||
lat: (json['lat'] as num).toDouble(),
|
lat: (json['lat'] as num).toDouble(),
|
||||||
lng: (json['lng'] as num).toDouble(),
|
lng: (json['lng'] as num).toDouble(),
|
||||||
radiusMeters: (json['radius_m'] as num).toDouble(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import '../utils/app_time.dart';
|
||||||
|
|
||||||
class DutySchedule {
|
class DutySchedule {
|
||||||
DutySchedule({
|
DutySchedule({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -9,6 +11,7 @@ class DutySchedule {
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.checkInAt,
|
required this.checkInAt,
|
||||||
required this.checkInLocation,
|
required this.checkInLocation,
|
||||||
|
required this.relieverIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
|
|
@ -20,20 +23,30 @@ class DutySchedule {
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final DateTime? checkInAt;
|
final DateTime? checkInAt;
|
||||||
final Object? checkInLocation;
|
final Object? checkInLocation;
|
||||||
|
final List<String> relieverIds;
|
||||||
|
|
||||||
factory DutySchedule.fromMap(Map<String, dynamic> map) {
|
factory DutySchedule.fromMap(Map<String, dynamic> map) {
|
||||||
|
final relieversRaw = map['reliever_ids'];
|
||||||
|
final relievers = relieversRaw is List
|
||||||
|
? relieversRaw
|
||||||
|
.where((e) => e != null)
|
||||||
|
.map((entry) => entry.toString())
|
||||||
|
.where((s) => s.isNotEmpty)
|
||||||
|
.toList()
|
||||||
|
: <String>[];
|
||||||
return DutySchedule(
|
return DutySchedule(
|
||||||
id: map['id'] as String,
|
id: map['id'] as String,
|
||||||
userId: map['user_id'] as String,
|
userId: map['user_id'] as String,
|
||||||
shiftType: map['shift_type'] as String? ?? 'normal',
|
shiftType: map['shift_type'] as String? ?? 'normal',
|
||||||
startTime: DateTime.parse(map['start_time'] as String),
|
startTime: AppTime.parse(map['start_time'] as String),
|
||||||
endTime: DateTime.parse(map['end_time'] as String),
|
endTime: AppTime.parse(map['end_time'] as String),
|
||||||
status: map['status'] as String? ?? 'scheduled',
|
status: map['status'] as String? ?? 'scheduled',
|
||||||
createdAt: DateTime.parse(map['created_at'] as String),
|
createdAt: AppTime.parse(map['created_at'] as String),
|
||||||
checkInAt: map['check_in_at'] == null
|
checkInAt: map['check_in_at'] == null
|
||||||
? null
|
? null
|
||||||
: DateTime.parse(map['check_in_at'] as String),
|
: AppTime.parse(map['check_in_at'] as String),
|
||||||
checkInLocation: map['check_in_location'],
|
checkInLocation: map['check_in_location'],
|
||||||
|
relieverIds: relievers,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import '../utils/app_time.dart';
|
||||||
|
|
||||||
class NotificationItem {
|
class NotificationItem {
|
||||||
NotificationItem({
|
NotificationItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -32,10 +34,10 @@ class NotificationItem {
|
||||||
taskId: map['task_id'] as String?,
|
taskId: map['task_id'] as String?,
|
||||||
messageId: map['message_id'] as int?,
|
messageId: map['message_id'] as int?,
|
||||||
type: map['type'] as String? ?? 'mention',
|
type: map['type'] as String? ?? 'mention',
|
||||||
createdAt: DateTime.parse(map['created_at'] as String),
|
createdAt: AppTime.parse(map['created_at'] as String),
|
||||||
readAt: map['read_at'] == null
|
readAt: map['read_at'] == null
|
||||||
? null
|
? null
|
||||||
: DateTime.parse(map['read_at'] as String),
|
: AppTime.parse(map['read_at'] as String),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import '../utils/app_time.dart';
|
||||||
|
|
||||||
class SwapRequest {
|
class SwapRequest {
|
||||||
SwapRequest({
|
SwapRequest({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -26,10 +28,10 @@ class SwapRequest {
|
||||||
recipientId: map['recipient_id'] as String,
|
recipientId: map['recipient_id'] as String,
|
||||||
shiftId: map['shift_id'] as String,
|
shiftId: map['shift_id'] as String,
|
||||||
status: map['status'] as String? ?? 'pending',
|
status: map['status'] as String? ?? 'pending',
|
||||||
createdAt: DateTime.parse(map['created_at'] as String),
|
createdAt: AppTime.parse(map['created_at'] as String),
|
||||||
updatedAt: map['updated_at'] == null
|
updatedAt: map['updated_at'] == null
|
||||||
? null
|
? null
|
||||||
: DateTime.parse(map['updated_at'] as String),
|
: AppTime.parse(map['updated_at'] as String),
|
||||||
approvedBy: map['approved_by'] as String?,
|
approvedBy: map['approved_by'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import '../utils/app_time.dart';
|
||||||
|
|
||||||
class Task {
|
class Task {
|
||||||
Task({
|
Task({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -37,14 +39,14 @@ class Task {
|
||||||
status: map['status'] as String? ?? 'queued',
|
status: map['status'] as String? ?? 'queued',
|
||||||
priority: map['priority'] as int? ?? 1,
|
priority: map['priority'] as int? ?? 1,
|
||||||
queueOrder: map['queue_order'] as int?,
|
queueOrder: map['queue_order'] as int?,
|
||||||
createdAt: DateTime.parse(map['created_at'] as String),
|
createdAt: AppTime.parse(map['created_at'] as String),
|
||||||
creatorId: map['creator_id'] as String?,
|
creatorId: map['creator_id'] as String?,
|
||||||
startedAt: map['started_at'] == null
|
startedAt: map['started_at'] == null
|
||||||
? null
|
? null
|
||||||
: DateTime.parse(map['started_at'] as String),
|
: AppTime.parse(map['started_at'] as String),
|
||||||
completedAt: map['completed_at'] == null
|
completedAt: map['completed_at'] == null
|
||||||
? null
|
? null
|
||||||
: DateTime.parse(map['completed_at'] as String),
|
: AppTime.parse(map['completed_at'] as String),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import '../utils/app_time.dart';
|
||||||
|
|
||||||
class TaskAssignment {
|
class TaskAssignment {
|
||||||
TaskAssignment({
|
TaskAssignment({
|
||||||
required this.taskId,
|
required this.taskId,
|
||||||
|
|
@ -13,7 +15,7 @@ class TaskAssignment {
|
||||||
return TaskAssignment(
|
return TaskAssignment(
|
||||||
taskId: map['task_id'] as String,
|
taskId: map['task_id'] as String,
|
||||||
userId: map['user_id'] as String,
|
userId: map['user_id'] as String,
|
||||||
createdAt: DateTime.parse(map['created_at'] as String),
|
createdAt: AppTime.parse(map['created_at'] as String),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import '../utils/app_time.dart';
|
||||||
|
|
||||||
class Ticket {
|
class Ticket {
|
||||||
Ticket({
|
Ticket({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -30,17 +32,17 @@ class Ticket {
|
||||||
description: map['description'] as String? ?? '',
|
description: map['description'] as String? ?? '',
|
||||||
officeId: map['office_id'] as String? ?? '',
|
officeId: map['office_id'] as String? ?? '',
|
||||||
status: map['status'] as String? ?? 'pending',
|
status: map['status'] as String? ?? 'pending',
|
||||||
createdAt: DateTime.parse(map['created_at'] as String),
|
createdAt: AppTime.parse(map['created_at'] as String),
|
||||||
creatorId: map['creator_id'] as String?,
|
creatorId: map['creator_id'] as String?,
|
||||||
respondedAt: map['responded_at'] == null
|
respondedAt: map['responded_at'] == null
|
||||||
? null
|
? null
|
||||||
: DateTime.parse(map['responded_at'] as String),
|
: AppTime.parse(map['responded_at'] as String),
|
||||||
promotedAt: map['promoted_at'] == null
|
promotedAt: map['promoted_at'] == null
|
||||||
? null
|
? null
|
||||||
: DateTime.parse(map['promoted_at'] as String),
|
: AppTime.parse(map['promoted_at'] as String),
|
||||||
closedAt: map['closed_at'] == null
|
closedAt: map['closed_at'] == null
|
||||||
? null
|
? null
|
||||||
: DateTime.parse(map['closed_at'] as String),
|
: AppTime.parse(map['closed_at'] as String),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import '../utils/app_time.dart';
|
||||||
|
|
||||||
class TicketMessage {
|
class TicketMessage {
|
||||||
TicketMessage({
|
TicketMessage({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -22,7 +24,7 @@ class TicketMessage {
|
||||||
taskId: map['task_id'] as String?,
|
taskId: map['task_id'] as String?,
|
||||||
senderId: map['sender_id'] as String?,
|
senderId: map['sender_id'] as String?,
|
||||||
content: map['content'] as String? ?? '',
|
content: map['content'] as String? ?? '',
|
||||||
createdAt: DateTime.parse(map['created_at'] as String),
|
createdAt: AppTime.parse(map['created_at'] as String),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|
||||||
import 'supabase_provider.dart';
|
import 'supabase_provider.dart';
|
||||||
|
import '../utils/app_time.dart';
|
||||||
|
|
||||||
final adminUserControllerProvider = Provider<AdminUserController>((ref) {
|
final adminUserControllerProvider = Provider<AdminUserController>((ref) {
|
||||||
final client = ref.watch(supabaseClientProvider);
|
final client = ref.watch(supabaseClientProvider);
|
||||||
|
|
@ -16,7 +17,7 @@ class AdminUserStatus {
|
||||||
|
|
||||||
bool get isLocked {
|
bool get isLocked {
|
||||||
if (bannedUntil == null) return false;
|
if (bannedUntil == null) return false;
|
||||||
return bannedUntil!.isAfter(DateTime.now().toUtc());
|
return bannedUntil!.isAfter(AppTime.now());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,11 +68,14 @@ class AdminUserController {
|
||||||
);
|
);
|
||||||
final user = (data as Map<String, dynamic>)['user'] as Map<String, dynamic>;
|
final user = (data as Map<String, dynamic>)['user'] as Map<String, dynamic>;
|
||||||
final bannedUntilRaw = user['banned_until'] as String?;
|
final bannedUntilRaw = user['banned_until'] as String?;
|
||||||
|
final bannedUntilParsed = bannedUntilRaw == null
|
||||||
|
? null
|
||||||
|
: DateTime.tryParse(bannedUntilRaw);
|
||||||
return AdminUserStatus(
|
return AdminUserStatus(
|
||||||
email: user['email'] as String?,
|
email: user['email'] as String?,
|
||||||
bannedUntil: bannedUntilRaw == null
|
bannedUntil: bannedUntilParsed == null
|
||||||
? null
|
? null
|
||||||
: DateTime.tryParse(bannedUntilRaw),
|
: AppTime.toAppTime(bannedUntilParsed),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
import '../models/notification_item.dart';
|
import '../models/notification_item.dart';
|
||||||
import 'profile_provider.dart';
|
import 'profile_provider.dart';
|
||||||
import 'supabase_provider.dart';
|
import 'supabase_provider.dart';
|
||||||
|
import '../utils/app_time.dart';
|
||||||
|
|
||||||
final notificationsProvider = StreamProvider<List<NotificationItem>>((ref) {
|
final notificationsProvider = StreamProvider<List<NotificationItem>>((ref) {
|
||||||
final userId = ref.watch(currentUserIdProvider);
|
final userId = ref.watch(currentUserIdProvider);
|
||||||
|
|
@ -69,7 +70,7 @@ class NotificationsController {
|
||||||
Future<void> markRead(String id) async {
|
Future<void> markRead(String id) async {
|
||||||
await _client
|
await _client
|
||||||
.from('notifications')
|
.from('notifications')
|
||||||
.update({'read_at': DateTime.now().toUtc().toIso8601String()})
|
.update({'read_at': AppTime.nowUtc().toIso8601String()})
|
||||||
.eq('id', id);
|
.eq('id', id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,7 +79,7 @@ class NotificationsController {
|
||||||
if (userId == null) return;
|
if (userId == null) return;
|
||||||
await _client
|
await _client
|
||||||
.from('notifications')
|
.from('notifications')
|
||||||
.update({'read_at': DateTime.now().toUtc().toIso8601String()})
|
.update({'read_at': AppTime.nowUtc().toIso8601String()})
|
||||||
.eq('ticket_id', ticketId)
|
.eq('ticket_id', ticketId)
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.filter('read_at', 'is', null);
|
.filter('read_at', 'is', null);
|
||||||
|
|
@ -89,7 +90,7 @@ class NotificationsController {
|
||||||
if (userId == null) return;
|
if (userId == null) return;
|
||||||
await _client
|
await _client
|
||||||
.from('notifications')
|
.from('notifications')
|
||||||
.update({'read_at': DateTime.now().toUtc().toIso8601String()})
|
.update({'read_at': AppTime.nowUtc().toIso8601String()})
|
||||||
.eq('task_id', taskId)
|
.eq('task_id', taskId)
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.filter('read_at', 'is', null);
|
.filter('read_at', 'is', null);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import '../../providers/admin_user_provider.dart';
|
||||||
import '../../providers/profile_provider.dart';
|
import '../../providers/profile_provider.dart';
|
||||||
import '../../providers/tickets_provider.dart';
|
import '../../providers/tickets_provider.dart';
|
||||||
import '../../providers/user_offices_provider.dart';
|
import '../../providers/user_offices_provider.dart';
|
||||||
|
import '../../utils/app_time.dart';
|
||||||
import '../../widgets/mono_text.dart';
|
import '../../widgets/mono_text.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
import '../../widgets/tasq_adaptive_list.dart';
|
import '../../widgets/tasq_adaptive_list.dart';
|
||||||
|
|
@ -122,13 +123,14 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
|
|
||||||
final query = _searchController.text.trim().toLowerCase();
|
final query = _searchController.text.trim().toLowerCase();
|
||||||
final filteredProfiles = query.isEmpty
|
final filteredProfiles = query.isEmpty
|
||||||
? profiles
|
? profiles
|
||||||
: profiles.where((profile) {
|
: profiles.where((profile) {
|
||||||
final label =
|
final label = profile.fullName.isNotEmpty
|
||||||
profile.fullName.isNotEmpty ? profile.fullName : profile.id;
|
? profile.fullName
|
||||||
return label.toLowerCase().contains(query) ||
|
: profile.id;
|
||||||
profile.id.toLowerCase().contains(query);
|
return label.toLowerCase().contains(query) ||
|
||||||
}).toList();
|
profile.id.toLowerCase().contains(query);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
final officeCountByUser = <String, int>{};
|
final officeCountByUser = <String, int>{};
|
||||||
for (final assignment in assignments) {
|
for (final assignment in assignments) {
|
||||||
|
|
@ -156,8 +158,9 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
TasQColumn<Profile>(
|
TasQColumn<Profile>(
|
||||||
header: 'User',
|
header: 'User',
|
||||||
cellBuilder: (context, profile) {
|
cellBuilder: (context, profile) {
|
||||||
final label =
|
final label = profile.fullName.isEmpty
|
||||||
profile.fullName.isEmpty ? profile.id : profile.fullName;
|
? profile.id
|
||||||
|
: profile.fullName;
|
||||||
return Text(label);
|
return Text(label);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -166,8 +169,9 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
cellBuilder: (context, profile) {
|
cellBuilder: (context, profile) {
|
||||||
final status = _statusCache[profile.id];
|
final status = _statusCache[profile.id];
|
||||||
final hasError = _statusErrors.contains(profile.id);
|
final hasError = _statusErrors.contains(profile.id);
|
||||||
final email =
|
final email = hasError
|
||||||
hasError ? 'Unavailable' : (status?.email ?? 'Unknown');
|
? 'Unavailable'
|
||||||
|
: (status?.email ?? 'Unknown');
|
||||||
return Text(email);
|
return Text(email);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -188,8 +192,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
final status = _statusCache[profile.id];
|
final status = _statusCache[profile.id];
|
||||||
final hasError = _statusErrors.contains(profile.id);
|
final hasError = _statusErrors.contains(profile.id);
|
||||||
final isLoading = _statusLoading.contains(profile.id);
|
final isLoading = _statusLoading.contains(profile.id);
|
||||||
final statusLabel =
|
final statusLabel = _userStatusLabel(status, hasError, isLoading);
|
||||||
_userStatusLabel(status, hasError, isLoading);
|
|
||||||
return _StatusBadge(label: statusLabel);
|
return _StatusBadge(label: statusLabel);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -204,8 +207,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
onRowTap: (profile) =>
|
onRowTap: (profile) =>
|
||||||
_showUserDialog(context, profile, offices, assignments),
|
_showUserDialog(context, profile, offices, assignments),
|
||||||
mobileTileBuilder: (context, profile, actions) {
|
mobileTileBuilder: (context, profile, actions) {
|
||||||
final label =
|
final label = profile.fullName.isEmpty ? profile.id : profile.fullName;
|
||||||
profile.fullName.isEmpty ? profile.id : profile.fullName;
|
|
||||||
final status = _statusCache[profile.id];
|
final status = _statusCache[profile.id];
|
||||||
final hasError = _statusErrors.contains(profile.id);
|
final hasError = _statusErrors.contains(profile.id);
|
||||||
final isLoading = _statusLoading.contains(profile.id);
|
final isLoading = _statusLoading.contains(profile.id);
|
||||||
|
|
@ -673,7 +675,7 @@ String _userStatusLabel(
|
||||||
|
|
||||||
String _formatLastActiveLabel(DateTime? value) {
|
String _formatLastActiveLabel(DateTime? value) {
|
||||||
if (value == null) return 'N/A';
|
if (value == null) return 'N/A';
|
||||||
final now = DateTime.now();
|
final now = AppTime.now();
|
||||||
final diff = now.difference(value);
|
final diff = now.difference(value);
|
||||||
if (diff.inMinutes < 1) return 'Just now';
|
if (diff.inMinutes < 1) return 'Just now';
|
||||||
if (diff.inHours < 1) return '${diff.inMinutes}m ago';
|
if (diff.inHours < 1) return '${diff.inMinutes}m ago';
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import '../../providers/tickets_provider.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
import '../../widgets/mono_text.dart';
|
import '../../widgets/mono_text.dart';
|
||||||
import '../../widgets/status_pill.dart';
|
import '../../widgets/status_pill.dart';
|
||||||
|
import '../../utils/app_time.dart';
|
||||||
|
|
||||||
class DashboardMetrics {
|
class DashboardMetrics {
|
||||||
DashboardMetrics({
|
DashboardMetrics({
|
||||||
|
|
@ -90,7 +91,7 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
|
||||||
final assignments = assignmentsAsync.valueOrNull ?? const <TaskAssignment>[];
|
final assignments = assignmentsAsync.valueOrNull ?? const <TaskAssignment>[];
|
||||||
final messages = messagesAsync.valueOrNull ?? const <TicketMessage>[];
|
final messages = messagesAsync.valueOrNull ?? const <TicketMessage>[];
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = AppTime.now();
|
||||||
final startOfDay = DateTime(now.year, now.month, now.day);
|
final startOfDay = DateTime(now.year, now.month, now.day);
|
||||||
|
|
||||||
final staffProfiles = profiles
|
final staffProfiles = profiles
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import '../../providers/profile_provider.dart';
|
||||||
import '../../providers/tasks_provider.dart';
|
import '../../providers/tasks_provider.dart';
|
||||||
import '../../providers/tickets_provider.dart';
|
import '../../providers/tickets_provider.dart';
|
||||||
import '../../providers/typing_provider.dart';
|
import '../../providers/typing_provider.dart';
|
||||||
|
import '../../utils/app_time.dart';
|
||||||
import '../../widgets/app_breakpoints.dart';
|
import '../../widgets/app_breakpoints.dart';
|
||||||
import '../../widgets/mono_text.dart';
|
import '../../widgets/mono_text.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
|
|
@ -396,13 +397,13 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||||
final animateExecution = task.startedAt != null && task.completedAt == null;
|
final animateExecution = task.startedAt != null && task.completedAt == null;
|
||||||
|
|
||||||
if (!animateQueue && !animateExecution) {
|
if (!animateQueue && !animateExecution) {
|
||||||
return _buildTatContent(task, DateTime.now());
|
return _buildTatContent(task, AppTime.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
return StreamBuilder<int>(
|
return StreamBuilder<int>(
|
||||||
stream: Stream.periodic(const Duration(seconds: 1), (tick) => tick),
|
stream: Stream.periodic(const Duration(seconds: 1), (tick) => tick),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
return _buildTatContent(task, DateTime.now());
|
return _buildTatContent(task, AppTime.now());
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import '../../providers/profile_provider.dart';
|
||||||
import '../../providers/tasks_provider.dart';
|
import '../../providers/tasks_provider.dart';
|
||||||
import '../../providers/tickets_provider.dart';
|
import '../../providers/tickets_provider.dart';
|
||||||
import '../../providers/typing_provider.dart';
|
import '../../providers/typing_provider.dart';
|
||||||
|
import '../../utils/app_time.dart';
|
||||||
import '../../widgets/mono_text.dart';
|
import '../../widgets/mono_text.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
import '../../widgets/tasq_adaptive_list.dart';
|
import '../../widgets/tasq_adaptive_list.dart';
|
||||||
|
|
@ -171,8 +172,8 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
||||||
final next = await showDateRangePicker(
|
final next = await showDateRangePicker(
|
||||||
context: context,
|
context: context,
|
||||||
firstDate: DateTime(2020),
|
firstDate: DateTime(2020),
|
||||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
lastDate: AppTime.now().add(const Duration(days: 365)),
|
||||||
currentDate: DateTime.now(),
|
currentDate: AppTime.now(),
|
||||||
initialDateRange: _selectedDateRange,
|
initialDateRange: _selectedDateRange,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
@ -625,12 +626,12 @@ class _StatusSummaryRow extends StatelessWidget {
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final maxWidth = constraints.maxWidth;
|
final maxWidth = constraints.maxWidth;
|
||||||
final maxPerRow = maxWidth >= 1000
|
final maxPerRow = maxWidth >= 1000
|
||||||
? 4
|
? 4
|
||||||
: maxWidth >= 720
|
: maxWidth >= 720
|
||||||
? 3
|
? 3
|
||||||
: maxWidth >= 480
|
: maxWidth >= 480
|
||||||
? 2
|
? 2
|
||||||
: entries.length;
|
: entries.length;
|
||||||
final perRow = entries.length < maxPerRow ? entries.length : maxPerRow;
|
final perRow = entries.length < maxPerRow ? entries.length : maxPerRow;
|
||||||
final spacing = maxWidth < 480 ? 8.0 : 12.0;
|
final spacing = maxWidth < 480 ? 8.0 : 12.0;
|
||||||
final itemWidth = perRow == 0
|
final itemWidth = perRow == 0
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import '../../providers/notifications_provider.dart';
|
||||||
import '../../providers/profile_provider.dart';
|
import '../../providers/profile_provider.dart';
|
||||||
import '../../providers/tickets_provider.dart';
|
import '../../providers/tickets_provider.dart';
|
||||||
import '../../providers/typing_provider.dart';
|
import '../../providers/typing_provider.dart';
|
||||||
|
import '../../utils/app_time.dart';
|
||||||
import '../../widgets/mono_text.dart';
|
import '../../widgets/mono_text.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
import '../../widgets/tasq_adaptive_list.dart';
|
import '../../widgets/tasq_adaptive_list.dart';
|
||||||
|
|
@ -135,8 +136,8 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||||
final next = await showDateRangePicker(
|
final next = await showDateRangePicker(
|
||||||
context: context,
|
context: context,
|
||||||
firstDate: DateTime(2020),
|
firstDate: DateTime(2020),
|
||||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
lastDate: AppTime.now().add(const Duration(days: 365)),
|
||||||
currentDate: DateTime.now(),
|
currentDate: AppTime.now(),
|
||||||
initialDateRange: _selectedDateRange,
|
initialDateRange: _selectedDateRange,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
|
@ -7,6 +9,7 @@ import '../../models/profile.dart';
|
||||||
import '../../models/swap_request.dart';
|
import '../../models/swap_request.dart';
|
||||||
import '../../providers/profile_provider.dart';
|
import '../../providers/profile_provider.dart';
|
||||||
import '../../providers/workforce_provider.dart';
|
import '../../providers/workforce_provider.dart';
|
||||||
|
import '../../utils/app_time.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
|
|
||||||
class WorkforceScreen extends ConsumerWidget {
|
class WorkforceScreen extends ConsumerWidget {
|
||||||
|
|
@ -137,6 +140,10 @@ class _SchedulePanel extends ConsumerWidget {
|
||||||
schedule,
|
schedule,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
),
|
),
|
||||||
|
relieverLabels: _relieverLabelsFromIds(
|
||||||
|
schedule.relieverIds,
|
||||||
|
profileById,
|
||||||
|
),
|
||||||
isMine: schedule.userId == currentUserId,
|
isMine: schedule.userId == currentUserId,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -217,30 +224,46 @@ class _SchedulePanel extends ConsumerWidget {
|
||||||
final weekday = weekdays[value.weekday - 1];
|
final weekday = weekdays[value.weekday - 1];
|
||||||
return '$weekday, $month $day, ${value.year}';
|
return '$weekday, $month $day, ${value.year}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<String> _relieverLabelsFromIds(
|
||||||
|
List<String> relieverIds,
|
||||||
|
Map<String, Profile> profileById,
|
||||||
|
) {
|
||||||
|
if (relieverIds.isEmpty) return const [];
|
||||||
|
return relieverIds
|
||||||
|
.map(
|
||||||
|
(id) => profileById[id]?.fullName.isNotEmpty == true
|
||||||
|
? profileById[id]!.fullName
|
||||||
|
: id,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ScheduleTile extends ConsumerWidget {
|
class _ScheduleTile extends ConsumerWidget {
|
||||||
const _ScheduleTile({
|
const _ScheduleTile({
|
||||||
required this.schedule,
|
required this.schedule,
|
||||||
required this.displayName,
|
required this.displayName,
|
||||||
|
required this.relieverLabels,
|
||||||
required this.isMine,
|
required this.isMine,
|
||||||
});
|
});
|
||||||
|
|
||||||
final DutySchedule schedule;
|
final DutySchedule schedule;
|
||||||
final String displayName;
|
final String displayName;
|
||||||
|
final List<String> relieverLabels;
|
||||||
final bool isMine;
|
final bool isMine;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final currentUserId = ref.watch(currentUserIdProvider);
|
final currentUserId = ref.watch(currentUserIdProvider);
|
||||||
final swaps = ref.watch(swapRequestsProvider).valueOrNull ?? [];
|
final swaps = ref.watch(swapRequestsProvider).valueOrNull ?? [];
|
||||||
final now = DateTime.now();
|
final now = AppTime.now();
|
||||||
final isPast = schedule.startTime.isBefore(now);
|
final isPast = schedule.startTime.isBefore(now);
|
||||||
final canCheckIn =
|
final canCheckIn =
|
||||||
isMine &&
|
isMine &&
|
||||||
schedule.checkInAt == null &&
|
schedule.checkInAt == null &&
|
||||||
(schedule.status == 'scheduled' || schedule.status == 'late') &&
|
(schedule.status == 'scheduled' || schedule.status == 'late') &&
|
||||||
now.isAfter(schedule.startTime.subtract(const Duration(minutes: 15))) &&
|
now.isAfter(schedule.startTime.subtract(const Duration(hours: 2))) &&
|
||||||
now.isBefore(schedule.endTime);
|
now.isBefore(schedule.endTime);
|
||||||
final hasRequestedSwap = swaps.any(
|
final hasRequestedSwap = swaps.any(
|
||||||
(swap) =>
|
(swap) =>
|
||||||
|
|
@ -253,56 +276,79 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Row(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Row(
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
displayName,
|
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'${_formatTime(schedule.startTime)} - ${_formatTime(schedule.endTime)}',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
_statusLabel(schedule.status),
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: _statusColor(context, schedule.status),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
children: [
|
||||||
if (canCheckIn)
|
Expanded(
|
||||||
FilledButton.icon(
|
child: Column(
|
||||||
onPressed: () => _handleCheckIn(context, ref, schedule),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
icon: const Icon(Icons.location_on),
|
children: [
|
||||||
label: const Text('Check in'),
|
Text(
|
||||||
|
displayName,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${_formatTime(schedule.startTime)} - ${_formatTime(schedule.endTime)}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_statusLabel(schedule.status),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: _statusColor(context, schedule.status),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
if (canRequestSwap) ...[
|
),
|
||||||
if (canCheckIn) const SizedBox(height: 8),
|
Column(
|
||||||
OutlinedButton.icon(
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
onPressed: hasRequestedSwap
|
children: [
|
||||||
? () => _openSwapsTab(context)
|
if (canCheckIn)
|
||||||
: () => _requestSwap(context, ref, schedule),
|
FilledButton.icon(
|
||||||
icon: const Icon(Icons.swap_horiz),
|
onPressed: () => _handleCheckIn(context, ref, schedule),
|
||||||
label: Text(
|
icon: const Icon(Icons.location_on),
|
||||||
hasRequestedSwap ? 'Swap Requested' : 'Request swap',
|
label: const Text('Check in'),
|
||||||
),
|
),
|
||||||
),
|
if (canRequestSwap) ...[
|
||||||
],
|
if (canCheckIn) const SizedBox(height: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: hasRequestedSwap
|
||||||
|
? () => _openSwapsTab(context)
|
||||||
|
: () => _requestSwap(context, ref, schedule),
|
||||||
|
icon: const Icon(Icons.swap_horiz),
|
||||||
|
label: Text(
|
||||||
|
hasRequestedSwap ? 'Swap Requested' : 'Request swap',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (relieverLabels.isNotEmpty)
|
||||||
|
ExpansionTile(
|
||||||
|
tilePadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Relievers'),
|
||||||
|
children: [
|
||||||
|
for (final label in relieverLabels)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
bottom: 6,
|
||||||
|
),
|
||||||
|
child: Text(label),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -317,14 +363,22 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
final geofence = await ref.read(geofenceProvider.future);
|
final geofence = await ref.read(geofenceProvider.future);
|
||||||
if (geofence == null) {
|
if (geofence == null) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
_showMessage(context, 'Geofence is not configured.');
|
await _showAlert(
|
||||||
|
context,
|
||||||
|
title: 'Geofence missing',
|
||||||
|
message: 'Geofence is not configured.',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||||
if (!serviceEnabled) {
|
if (!serviceEnabled) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
_showMessage(context, 'Location services are disabled.');
|
await _showAlert(
|
||||||
|
context,
|
||||||
|
title: 'Location disabled',
|
||||||
|
message: 'Location services are disabled.',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -335,28 +389,43 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
if (permission == LocationPermission.denied ||
|
if (permission == LocationPermission.denied ||
|
||||||
permission == LocationPermission.deniedForever) {
|
permission == LocationPermission.deniedForever) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
_showMessage(context, 'Location permission denied.');
|
await _showAlert(
|
||||||
|
context,
|
||||||
|
title: 'Permission denied',
|
||||||
|
message: 'Location permission denied.',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!context.mounted) return;
|
||||||
final position = await Geolocator.getCurrentPosition(
|
final progressContext = await _showCheckInProgress(context);
|
||||||
locationSettings: const LocationSettings(accuracy: LocationAccuracy.high),
|
|
||||||
);
|
|
||||||
|
|
||||||
final distance = Geolocator.distanceBetween(
|
|
||||||
position.latitude,
|
|
||||||
position.longitude,
|
|
||||||
geofence.lat,
|
|
||||||
geofence.lng,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (distance > geofence.radiusMeters) {
|
|
||||||
if (!context.mounted) return;
|
|
||||||
_showMessage(context, 'You are outside the geofence. Wala ka sa CRMC.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final position = await Geolocator.getCurrentPosition(
|
||||||
|
locationSettings: const LocationSettings(
|
||||||
|
accuracy: LocationAccuracy.high,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final isInside = geofence.hasPolygon
|
||||||
|
? geofence.containsPolygon(position.latitude, position.longitude)
|
||||||
|
: geofence.hasCircle &&
|
||||||
|
Geolocator.distanceBetween(
|
||||||
|
position.latitude,
|
||||||
|
position.longitude,
|
||||||
|
geofence.lat!,
|
||||||
|
geofence.lng!,
|
||||||
|
) <=
|
||||||
|
geofence.radiusMeters!;
|
||||||
|
|
||||||
|
if (!isInside) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await _showAlert(
|
||||||
|
context,
|
||||||
|
title: 'Outside geofence',
|
||||||
|
message: 'You are outside the geofence. Wala ka sa CRMC.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final status = await ref
|
final status = await ref
|
||||||
.read(workforceControllerProvider)
|
.read(workforceControllerProvider)
|
||||||
.checkIn(
|
.checkIn(
|
||||||
|
|
@ -366,10 +435,22 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
);
|
);
|
||||||
ref.invalidate(dutySchedulesProvider);
|
ref.invalidate(dutySchedulesProvider);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
_showMessage(context, 'Checked in ($status).');
|
await _showAlert(
|
||||||
|
context,
|
||||||
|
title: 'Checked in',
|
||||||
|
message: 'Checked in ($status).',
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
_showMessage(context, 'Check-in failed: $error');
|
await _showAlert(
|
||||||
|
context,
|
||||||
|
title: 'Check-in failed',
|
||||||
|
message: 'Check-in failed: $error',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (progressContext.mounted) {
|
||||||
|
Navigator.of(progressContext).pop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -446,6 +527,61 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
).showSnackBar(SnackBar(content: Text(message)));
|
).showSnackBar(SnackBar(content: Text(message)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showAlert(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required String message,
|
||||||
|
}) async {
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(title),
|
||||||
|
content: Text(message),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
|
child: const Text('OK'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BuildContext> _showCheckInProgress(BuildContext context) {
|
||||||
|
final completer = Completer<BuildContext>();
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.complete(dialogContext);
|
||||||
|
}
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Validating location'),
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
height: 24,
|
||||||
|
width: 24,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
'Please wait while we verify your location.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
void _openSwapsTab(BuildContext context) {
|
void _openSwapsTab(BuildContext context) {
|
||||||
final controller = DefaultTabController.maybeOf(context);
|
final controller = DefaultTabController.maybeOf(context);
|
||||||
if (controller != null) {
|
if (controller != null) {
|
||||||
|
|
@ -500,13 +636,15 @@ class _DraftSchedule {
|
||||||
required this.shiftType,
|
required this.shiftType,
|
||||||
required this.startTime,
|
required this.startTime,
|
||||||
required this.endTime,
|
required this.endTime,
|
||||||
});
|
List<String>? relieverIds,
|
||||||
|
}) : relieverIds = relieverIds ?? <String>[];
|
||||||
|
|
||||||
final int localId;
|
final int localId;
|
||||||
String userId;
|
String userId;
|
||||||
String shiftType;
|
String shiftType;
|
||||||
DateTime startTime;
|
DateTime startTime;
|
||||||
DateTime endTime;
|
DateTime endTime;
|
||||||
|
List<String> relieverIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RotationEntry {
|
class _RotationEntry {
|
||||||
|
|
@ -669,7 +807,7 @@ class _ScheduleGeneratorPanelState
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickDate({required bool isStart}) async {
|
Future<void> _pickDate({required bool isStart}) async {
|
||||||
final now = DateTime.now();
|
final now = AppTime.now();
|
||||||
final initial = isStart ? _startDate ?? now : _endDate ?? _startDate ?? now;
|
final initial = isStart ? _startDate ?? now : _endDate ?? _startDate ?? now;
|
||||||
final picked = await showDatePicker(
|
final picked = await showDatePicker(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -807,43 +945,73 @@ class _ScheduleGeneratorPanelState
|
||||||
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final draft = _draftSchedules[index];
|
final draft = _draftSchedules[index];
|
||||||
final profile = _profileById()[draft.userId];
|
final profileById = _profileById();
|
||||||
|
final profile = profileById[draft.userId];
|
||||||
final userLabel = profile?.fullName.isNotEmpty == true
|
final userLabel = profile?.fullName.isNotEmpty == true
|
||||||
? profile!.fullName
|
? profile!.fullName
|
||||||
: draft.userId;
|
: draft.userId;
|
||||||
|
final relieverLabels = draft.relieverIds
|
||||||
|
.map(
|
||||||
|
(id) => profileById[id]?.fullName.isNotEmpty == true
|
||||||
|
? profileById[id]!.fullName
|
||||||
|
: id,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
child: Row(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Row(
|
||||||
child: Column(
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${_shiftLabel(draft.shiftType)} · $userLabel',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${_formatDate(draft.startTime)} · ${_formatTime(draft.startTime)} - ${_formatTime(draft.endTime)}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Edit',
|
||||||
|
onPressed: () => _editDraft(draft),
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Delete',
|
||||||
|
onPressed: () => _deleteDraft(draft),
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (relieverLabels.isNotEmpty)
|
||||||
|
ExpansionTile(
|
||||||
|
tilePadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Relievers'),
|
||||||
children: [
|
children: [
|
||||||
Text(
|
for (final label in relieverLabels)
|
||||||
'${_shiftLabel(draft.shiftType)} · $userLabel',
|
Align(
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
alignment: Alignment.centerLeft,
|
||||||
fontWeight: FontWeight.w600,
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
bottom: 6,
|
||||||
|
),
|
||||||
|
child: Text(label),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'${_formatDate(draft.startTime)} · ${_formatTime(draft.startTime)} - ${_formatTime(draft.endTime)}',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Edit',
|
|
||||||
onPressed: () => _editDraft(draft),
|
|
||||||
icon: const Icon(Icons.edit),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Delete',
|
|
||||||
onPressed: () => _deleteDraft(draft),
|
|
||||||
icon: const Icon(Icons.delete_outline),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -864,8 +1032,8 @@ class _ScheduleGeneratorPanelState
|
||||||
setState(() {
|
setState(() {
|
||||||
_draftSchedules.removeWhere((item) => item.localId == draft.localId);
|
_draftSchedules.removeWhere((item) => item.localId == draft.localId);
|
||||||
_warnings = _buildWarnings(
|
_warnings = _buildWarnings(
|
||||||
_startDate ?? DateTime.now(),
|
_startDate ?? AppTime.now(),
|
||||||
_endDate ?? DateTime.now(),
|
_endDate ?? AppTime.now(),
|
||||||
_draftSchedules,
|
_draftSchedules,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -878,7 +1046,7 @@ class _ScheduleGeneratorPanelState
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final start = existing?.startTime ?? _startDate ?? DateTime.now();
|
final start = existing?.startTime ?? _startDate ?? AppTime.now();
|
||||||
var selectedDate = DateTime(start.year, start.month, start.day);
|
var selectedDate = DateTime(start.year, start.month, start.day);
|
||||||
var selectedUserId = existing?.userId ?? staff.first.id;
|
var selectedUserId = existing?.userId ?? staff.first.id;
|
||||||
var selectedShift = existing?.shiftType ?? 'am';
|
var selectedShift = existing?.shiftType ?? 'am';
|
||||||
|
|
@ -1099,6 +1267,7 @@ class _ScheduleGeneratorPanelState
|
||||||
'start_time': draft.startTime.toIso8601String(),
|
'start_time': draft.startTime.toIso8601String(),
|
||||||
'end_time': draft.endTime.toIso8601String(),
|
'end_time': draft.endTime.toIso8601String(),
|
||||||
'status': 'scheduled',
|
'status': 'scheduled',
|
||||||
|
'reliever_ids': draft.relieverIds,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
@ -1249,6 +1418,10 @@ class _ScheduleGeneratorPanelState
|
||||||
final nextWeekPmUserId = staff.isEmpty
|
final nextWeekPmUserId = staff.isEmpty
|
||||||
? null
|
? null
|
||||||
: staff[(pmBaseIndex + 1) % staff.length].id;
|
: staff[(pmBaseIndex + 1) % staff.length].id;
|
||||||
|
final pmRelievers = _buildRelievers(pmBaseIndex, staff);
|
||||||
|
final nextWeekRelievers = staff.isEmpty
|
||||||
|
? <String>[]
|
||||||
|
: _buildRelievers((pmBaseIndex + 1) % staff.length, staff);
|
||||||
var weekendNormalOffset = 0;
|
var weekendNormalOffset = 0;
|
||||||
|
|
||||||
for (
|
for (
|
||||||
|
|
@ -1273,6 +1446,7 @@ class _ScheduleGeneratorPanelState
|
||||||
'normal',
|
'normal',
|
||||||
staff[normalIndex].id,
|
staff[normalIndex].id,
|
||||||
day,
|
day,
|
||||||
|
const [],
|
||||||
);
|
);
|
||||||
weekendNormalOffset += 1;
|
weekendNormalOffset += 1;
|
||||||
}
|
}
|
||||||
|
|
@ -1284,15 +1458,40 @@ class _ScheduleGeneratorPanelState
|
||||||
'on_call',
|
'on_call',
|
||||||
nextWeekPmUserId,
|
nextWeekPmUserId,
|
||||||
day,
|
day,
|
||||||
|
nextWeekRelievers,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (amUserId != null) {
|
if (amUserId != null) {
|
||||||
_tryAddDraft(draft, existing, templates, 'am', amUserId, day);
|
_tryAddDraft(
|
||||||
|
draft,
|
||||||
|
existing,
|
||||||
|
templates,
|
||||||
|
'am',
|
||||||
|
amUserId,
|
||||||
|
day,
|
||||||
|
const [],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (pmUserId != null) {
|
if (pmUserId != null) {
|
||||||
_tryAddDraft(draft, existing, templates, 'pm', pmUserId, day);
|
_tryAddDraft(
|
||||||
_tryAddDraft(draft, existing, templates, 'on_call', pmUserId, day);
|
draft,
|
||||||
|
existing,
|
||||||
|
templates,
|
||||||
|
'pm',
|
||||||
|
pmUserId,
|
||||||
|
day,
|
||||||
|
pmRelievers,
|
||||||
|
);
|
||||||
|
_tryAddDraft(
|
||||||
|
draft,
|
||||||
|
existing,
|
||||||
|
templates,
|
||||||
|
'on_call',
|
||||||
|
pmUserId,
|
||||||
|
day,
|
||||||
|
pmRelievers,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final assignedToday = <String?>[
|
final assignedToday = <String?>[
|
||||||
|
|
@ -1301,7 +1500,15 @@ class _ScheduleGeneratorPanelState
|
||||||
].whereType<String>().toSet();
|
].whereType<String>().toSet();
|
||||||
for (final profile in staff) {
|
for (final profile in staff) {
|
||||||
if (assignedToday.contains(profile.id)) continue;
|
if (assignedToday.contains(profile.id)) continue;
|
||||||
_tryAddDraft(draft, existing, templates, 'normal', profile.id, day);
|
_tryAddDraft(
|
||||||
|
draft,
|
||||||
|
existing,
|
||||||
|
templates,
|
||||||
|
'normal',
|
||||||
|
profile.id,
|
||||||
|
day,
|
||||||
|
const [],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1319,6 +1526,7 @@ class _ScheduleGeneratorPanelState
|
||||||
String shiftType,
|
String shiftType,
|
||||||
String userId,
|
String userId,
|
||||||
DateTime day,
|
DateTime day,
|
||||||
|
List<String> relieverIds,
|
||||||
) {
|
) {
|
||||||
final template = templates[_normalizeShiftType(shiftType)]!;
|
final template = templates[_normalizeShiftType(shiftType)]!;
|
||||||
final start = template.buildStart(day);
|
final start = template.buildStart(day);
|
||||||
|
|
@ -1329,6 +1537,7 @@ class _ScheduleGeneratorPanelState
|
||||||
shiftType: shiftType,
|
shiftType: shiftType,
|
||||||
startTime: start,
|
startTime: start,
|
||||||
endTime: end,
|
endTime: end,
|
||||||
|
relieverIds: relieverIds,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (_hasConflict(candidate, draft, existing)) {
|
if (_hasConflict(candidate, draft, existing)) {
|
||||||
|
|
@ -1364,6 +1573,16 @@ class _ScheduleGeneratorPanelState
|
||||||
return defaultIndex % staff.length;
|
return defaultIndex % staff.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<String> _buildRelievers(int primaryIndex, List<Profile> staff) {
|
||||||
|
if (staff.length <= 1) return const [];
|
||||||
|
final relievers = <String>[];
|
||||||
|
for (var offset = 1; offset < staff.length; offset += 1) {
|
||||||
|
relievers.add(staff[(primaryIndex + offset) % staff.length].id);
|
||||||
|
if (relievers.length == 3) break;
|
||||||
|
}
|
||||||
|
return relievers;
|
||||||
|
}
|
||||||
|
|
||||||
bool _hasConflict(
|
bool _hasConflict(
|
||||||
_DraftSchedule candidate,
|
_DraftSchedule candidate,
|
||||||
List<_DraftSchedule> drafts,
|
List<_DraftSchedule> drafts,
|
||||||
|
|
@ -1545,11 +1764,11 @@ class _SwapRequestsPanel extends ConsumerWidget {
|
||||||
final profilesAsync = ref.watch(profilesProvider);
|
final profilesAsync = ref.watch(profilesProvider);
|
||||||
final currentUserId = ref.watch(currentUserIdProvider);
|
final currentUserId = ref.watch(currentUserIdProvider);
|
||||||
|
|
||||||
final scheduleById = {
|
final Map<String, DutySchedule> scheduleById = {
|
||||||
for (final schedule in schedulesAsync.valueOrNull ?? [])
|
for (final schedule in schedulesAsync.valueOrNull ?? [])
|
||||||
schedule.id: schedule,
|
schedule.id: schedule,
|
||||||
};
|
};
|
||||||
final profileById = {
|
final Map<String, Profile> profileById = {
|
||||||
for (final profile in profilesAsync.valueOrNull ?? [])
|
for (final profile in profilesAsync.valueOrNull ?? [])
|
||||||
profile.id: profile,
|
profile.id: profile,
|
||||||
};
|
};
|
||||||
|
|
@ -1577,6 +1796,9 @@ class _SwapRequestsPanel extends ConsumerWidget {
|
||||||
final subtitle = schedule == null
|
final subtitle = schedule == null
|
||||||
? 'Shift not found'
|
? 'Shift not found'
|
||||||
: '${_shiftLabel(schedule.shiftType)} · ${_formatDate(schedule.startTime)} · ${_formatTime(schedule.startTime)}';
|
: '${_shiftLabel(schedule.shiftType)} · ${_formatDate(schedule.startTime)} · ${_formatTime(schedule.startTime)}';
|
||||||
|
final relieverLabels = schedule == null
|
||||||
|
? const <String>[]
|
||||||
|
: _relieverLabelsFromIds(schedule.relieverIds, profileById);
|
||||||
|
|
||||||
final isPending = item.status == 'pending';
|
final isPending = item.status == 'pending';
|
||||||
final canRespond =
|
final canRespond =
|
||||||
|
|
@ -1600,6 +1822,25 @@ class _SwapRequestsPanel extends ConsumerWidget {
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text('Status: ${item.status}'),
|
Text('Status: ${item.status}'),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
if (relieverLabels.isNotEmpty)
|
||||||
|
ExpansionTile(
|
||||||
|
tilePadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Relievers'),
|
||||||
|
children: [
|
||||||
|
for (final label in relieverLabels)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
bottom: 6,
|
||||||
|
),
|
||||||
|
child: Text(label),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (canRespond) ...[
|
if (canRespond) ...[
|
||||||
|
|
@ -1703,4 +1944,18 @@ class _SwapRequestsPanel extends ConsumerWidget {
|
||||||
final weekday = weekdays[value.weekday - 1];
|
final weekday = weekdays[value.weekday - 1];
|
||||||
return '$weekday, $month $day, ${value.year}';
|
return '$weekday, $month $day, ${value.year}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<String> _relieverLabelsFromIds(
|
||||||
|
List<String> relieverIds,
|
||||||
|
Map<String, Profile> profileById,
|
||||||
|
) {
|
||||||
|
if (relieverIds.isEmpty) return const [];
|
||||||
|
return relieverIds
|
||||||
|
.map(
|
||||||
|
(id) => profileById[id]?.fullName.isNotEmpty == true
|
||||||
|
? profileById[id]!.fullName
|
||||||
|
: id,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
lib/utils/app_time.dart
Normal file
30
lib/utils/app_time.dart
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import 'package:timezone/data/latest.dart' as tz;
|
||||||
|
import 'package:timezone/timezone.dart' as tz;
|
||||||
|
|
||||||
|
class AppTime {
|
||||||
|
static bool _initialized = false;
|
||||||
|
|
||||||
|
static void initialize({String location = 'Asia/Manila'}) {
|
||||||
|
if (_initialized) return;
|
||||||
|
tz.initializeTimeZones();
|
||||||
|
tz.setLocalLocation(tz.getLocation(location));
|
||||||
|
_initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime now() {
|
||||||
|
return tz.TZDateTime.now(tz.local);
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime nowUtc() {
|
||||||
|
return now().toUtc();
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime toAppTime(DateTime value) {
|
||||||
|
final utc = value.isUtc ? value : value.toUtc();
|
||||||
|
return tz.TZDateTime.from(utc, tz.local);
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime parse(String value) {
|
||||||
|
return toAppTime(DateTime.parse(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -821,6 +821,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7"
|
version: "0.7.7"
|
||||||
|
timezone:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: timezone
|
||||||
|
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.4"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ dependencies:
|
||||||
google_fonts: ^6.2.1
|
google_fonts: ^6.2.1
|
||||||
audioplayers: ^6.1.0
|
audioplayers: ^6.1.0
|
||||||
geolocator: ^13.0.1
|
geolocator: ^13.0.1
|
||||||
|
timezone: ^0.9.4
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user