feat(panic): enhance incident logs model and repository, add recent incidents fetching, and improve statistics view UI

This commit is contained in:
vergiLgood1 2025-05-27 17:00:40 +07:00
parent b62c64d36d
commit c116e5d229
5 changed files with 493 additions and 95 deletions

View File

@ -46,12 +46,12 @@ class IncidentLogModel {
json['updated_at'] != null json['updated_at'] != null
? DateTime.parse(json['updated_at']) ? DateTime.parse(json['updated_at'])
: null, : null,
evidence: // evidence:
json['evidence'] != null // json['evidence'] != null
? (json['evidence'] as List) // ? (json['evidence'] as List)
.map((e) => EvidenceModel.fromJson(e as Map<String, dynamic>)) // .map((e) => EvidenceModel.fromJson(e as Map<String, dynamic>))
.toList() // .toList()
: null, // : null,
); );
} }

View File

@ -1,5 +1,6 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/cores/services/supabase_service.dart'; import 'package:sigap/src/cores/services/supabase_service.dart';
import 'package:sigap/src/features/panic-button/data/models/models/incident_logs_model.dart';
import 'package:sigap/src/utils/exceptions/exceptions.dart'; import 'package:sigap/src/utils/exceptions/exceptions.dart';
class IncidentLogsRepository extends GetxController { class IncidentLogsRepository extends GetxController {
@ -20,6 +21,23 @@ class IncidentLogsRepository extends GetxController {
} }
} }
// Get recent incidents
Future<List<IncidentLogModel>> getRecentIncidents() async {
try {
final response = await _supabase
.from('incident_logs')
.select('*, location_id(*), category_id(*)')
.order('time', ascending: false)
.limit(10);
return List<Map<String, dynamic>>.from(
response,
).map((e) => IncidentLogModel.fromJson(e)).toList();
} catch (e) {
throw TExceptions('Failed to fetch recent incidents: ${e.toString()}');
}
}
Future<Map<String, dynamic>> getIncidentById(String id) async { Future<Map<String, dynamic>> getIncidentById(String id) async {
try { try {
final response = final response =

View File

@ -17,13 +17,17 @@ class PanicButtonController extends GetxController {
// Location service // Location service
final LocationService _locationService = Get.find<LocationService>(); final LocationService _locationService = Get.find<LocationService>();
final RxString currentDistrict = ''.obs;
final RxString currentCity = ''.obs;
Timer? _timer; Timer? _timer;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
// Fetch real location
// _fetchLocation(); // Get current district and city
getCurrentDistrict();
// Setup location updates listener // Setup location updates listener
ever(_locationService.currentPosition, (_) => _updateLocationString()); ever(_locationService.currentPosition, (_) => _updateLocationString());
@ -249,8 +253,23 @@ class PanicButtonController extends GetxController {
); );
} }
// Get user's current district
String getCurrentDistrict() { String getCurrentDistrict() {
// Fetch real location
currentCity.value = _locationService.currentCity.value.replaceAll(
RegExp(r'(Kabupaten|Kecamatan)\s*'),
'',
);
currentDistrict.value = _locationService.currentDistrict.value.replaceAll(
RegExp(r'(Kabupaten|Kecamatan)\s*'),
'',
);
return locationString.value =
"${currentCity.value}, ${currentDistrict.value}";
}
// Get user's current district
String updateCurrentDistrict() {
_fetchLocation(); // Refresh location data _fetchLocation(); // Refresh location data
if (_locationService.currentDistrict.value.isEmpty) { if (_locationService.currentDistrict.value.isEmpty) {

View File

@ -56,6 +56,9 @@ class StatisticsViewController extends GetxController {
void onInit() { void onInit() {
super.onInit(); super.onInit();
// init district and city names
getDistrictName();
// Load statistics data // Load statistics data
loadStatisticsData(); loadStatisticsData();
@ -86,9 +89,6 @@ class StatisticsViewController extends GetxController {
// Load crime statistics // Load crime statistics
await _loadCrimeStatistics(); await _loadCrimeStatistics();
// Load recent incidents
await _loadRecentIncidents();
// Update UI components with fetched data // Update UI components with fetched data
_updateStatisticsUI(); _updateStatisticsUI();
} catch (e) { } catch (e) {
@ -172,33 +172,6 @@ class StatisticsViewController extends GetxController {
} }
} }
Future<void> _loadRecentIncidents() async {
try {
final incidents = await _crimeIncidentsRepo.getRecentIncidents();
// Filter to show only incidents from current district if district ID is available
final filteredIncidents =
currentDistrictId.value.isNotEmpty
? incidents.where((incident) {
final locationId =
incident['location_id'] is String
? incident['location_id']
: incident['location_id']?['id'];
// Logic to filter by district would go here, but we'd need to join with locations table
// For now, just return all incidents
return true;
}).toList()
: incidents;
recentIncidents.value =
filteredIncidents
.map((incident) => CrimeIncidentModel.fromJson(incident))
.toList();
} catch (e) {
_logger.e('Error loading recent incidents: $e');
}
}
void _updateStatisticsUI() { void _updateStatisticsUI() {
if (districtCrimes.isEmpty) return; if (districtCrimes.isEmpty) return;

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:sigap/src/features/panic/presentation/controllers/statistics_view_controller.dart'; import 'package:sigap/src/features/panic/presentation/controllers/statistics_view_controller.dart';
import 'package:sigap/src/utils/constants/sizes.dart';
class CrimeStatsHeader extends StatelessWidget { class CrimeStatsHeader extends StatelessWidget {
final String district; final String district;
@ -25,18 +26,19 @@ class CrimeStatsHeader extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Get the statistics controller to access available years // Get the statistics controller to access available years
final statsController = Get.find<StatisticsViewController>(); final statsController = Get.find<StatisticsViewController>();
return Container( return Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(12),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.grey.withOpacity(0.1), color: Colors.grey.withOpacity(0.08),
blurRadius: 5, blurRadius: 8,
spreadRadius: 1, spreadRadius: 0,
offset: const Offset(0, 2),
), ),
], ],
), ),
@ -50,32 +52,45 @@ class CrimeStatsHeader extends StatelessWidget {
child: Text( child: Text(
district, district,
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w600,
color: Colors.black87, color: Colors.black87,
), ),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
IconButton( Material(
icon: const Icon(Icons.refresh, size: 20), color: Colors.transparent,
onPressed: onRefresh, child: InkWell(
tooltip: 'Refresh statistics', borderRadius: BorderRadius.circular(20),
onTap: onRefresh,
child: Container(
padding: const EdgeInsets.all(8),
child: const Icon(
Icons.refresh_rounded,
size: 20,
color: Colors.blue,
),
),
),
), ),
], ],
), ),
const SizedBox(height: 5), const SizedBox(height: 12),
// Date picker row // Date picker section
Row( Container(
children: [ padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
const Icon(Icons.calendar_month, size: 16, color: Colors.blue), child: Row(
const SizedBox(width: 5), children: [
_buildMonthDropdown(), Expanded(child: _buildMonthDropdown()),
const SizedBox(width: 5), const SizedBox(width: TSizes.spaceBtwInputFields / 2),
_buildYearDropdown(statsController.availableYears), Expanded(
], child: _buildYearDropdown(statsController.availableYears),
),
],
),
), ),
], ],
), ),
@ -88,48 +103,421 @@ class CrimeStatsHeader extends StatelessWidget {
'MMMM', 'MMMM',
).format(DateTime(int.parse(year), currentMonth)); ).format(DateTime(int.parse(year), currentMonth));
return DropdownButton<int>( return CustomPopupMenuButton<int>(
value: currentMonth, initialValue: currentMonth,
underline: Container(), // Remove underline buttonBuilder: (context, openMenu, isOpen) {
onChanged: (value) { return Container(
if (value != null) onMonthChanged(value); padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
}, decoration: BoxDecoration(
items: List.generate(12, (index) { color: Colors.white,
final monthNum = index + 1; borderRadius: BorderRadius.circular(8),
final monthName = DateFormat( border: Border.all(
'MMMM', color: isOpen ? Colors.blue.shade300 : Colors.grey.shade300,
).format(DateTime(int.parse(year), monthNum)); width: isOpen ? 2 : 1,
),
return DropdownMenuItem( boxShadow:
value: monthNum, isOpen
child: Text(monthName, style: const TextStyle(fontSize: 14)), ? [
BoxShadow(
color: Colors.blue.withOpacity(0.1),
blurRadius: 4,
spreadRadius: 0,
),
]
: null,
),
child: InkWell(
onTap: openMenu,
borderRadius: BorderRadius.circular(8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
monthName,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
),
AnimatedRotation(
turns: isOpen ? 0.5 : 0,
duration: const Duration(milliseconds: 200),
child: Icon(
Icons.keyboard_arrow_down_rounded,
color: isOpen ? Colors.blue.shade600 : Colors.grey.shade600,
size: 20,
),
),
],
),
),
); );
}), },
itemBuilder: (BuildContext context) {
return List.generate(12, (index) {
final monthNum = index + 1;
final monthName = DateFormat(
'MMMM',
).format(DateTime(int.parse(year), monthNum));
final isSelected = monthNum == currentMonth;
return CustomPopupMenuItem<int>(
value: monthNum,
isSelected: isSelected,
child: Row(
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color:
isSelected ? Colors.blue.shade600 : Colors.transparent,
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
monthName,
style: TextStyle(
fontSize: 14,
fontWeight:
isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected ? Colors.blue.shade700 : Colors.black87,
),
),
),
if (isSelected)
Icon(
Icons.check_rounded,
size: 16,
color: Colors.blue.shade600,
),
],
),
);
});
},
onSelected: (value) {
onMonthChanged(value);
},
); );
} }
Widget _buildYearDropdown(List<int> availableYears) { Widget _buildYearDropdown(List<int> availableYears) {
final int currentYear = int.parse(year); final int currentYear = int.parse(year);
// Use the available years from stats controller
final selectedYear =
availableYears.contains(currentYear)
? currentYear
: availableYears.isNotEmpty
? availableYears.last
: DateTime.now().year;
return DropdownButton<int>( return CustomPopupMenuButton<int>(
value: initialValue: selectedYear,
availableYears.contains(currentYear) buttonBuilder: (context, openMenu, isOpen) {
? currentYear return Container(
: availableYears.last, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
underline: Container(), // Remove underline decoration: BoxDecoration(
onChanged: (value) { color: Colors.white,
if (value != null) onYearChanged(value); borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isOpen ? Colors.blue.shade300 : Colors.grey.shade300,
width: isOpen ? 2 : 1,
),
boxShadow:
isOpen
? [
BoxShadow(
color: Colors.blue.withOpacity(0.1),
blurRadius: 4,
spreadRadius: 0,
),
]
: null,
),
child: InkWell(
onTap: openMenu,
borderRadius: BorderRadius.circular(8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
selectedYear.toString(),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
),
AnimatedRotation(
turns: isOpen ? 0.5 : 0,
duration: const Duration(milliseconds: 200),
child: Icon(
Icons.keyboard_arrow_down_rounded,
color: isOpen ? Colors.blue.shade600 : Colors.grey.shade600,
size: 20,
),
),
],
),
),
);
},
itemBuilder: (BuildContext context) {
// Sort the years in descending order (newest first)
final sortedYears = List<int>.from(availableYears)
..sort((a, b) => b.compareTo(a));
return sortedYears.map((year) {
final isSelected = year == selectedYear;
return CustomPopupMenuItem<int>(
value: year,
isSelected: isSelected,
child: Row(
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color:
isSelected ? Colors.blue.shade600 : Colors.transparent,
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
year.toString(),
style: TextStyle(
fontSize: 14,
fontWeight:
isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected ? Colors.blue.shade700 : Colors.black87,
),
),
),
if (isSelected)
Icon(
Icons.check_rounded,
size: 16,
color: Colors.blue.shade600,
),
],
),
);
}).toList();
},
onSelected: (value) {
onYearChanged(value);
}, },
items: );
availableYears.map((year) { }
return DropdownMenuItem( }
value: year,
child: Text( // Custom popup menu item with selection state
year.toString(), class CustomPopupMenuItem<T> {
style: const TextStyle(fontSize: 14), final T value;
), final Widget child;
); final bool isSelected;
}).toList(),
const CustomPopupMenuItem({
required this.value,
required this.child,
this.isSelected = false,
});
}
// Enhanced custom popup menu button
class CustomPopupMenuButton<T> extends StatefulWidget {
final T initialValue;
final List<CustomPopupMenuItem<T>> Function(BuildContext) itemBuilder;
final Widget Function(BuildContext, VoidCallback, bool) buttonBuilder;
final void Function(T) onSelected;
const CustomPopupMenuButton({
super.key,
required this.initialValue,
required this.itemBuilder,
required this.buttonBuilder,
required this.onSelected,
});
@override
State<CustomPopupMenuButton<T>> createState() =>
_CustomPopupMenuButtonState<T>();
}
class _CustomPopupMenuButtonState<T> extends State<CustomPopupMenuButton<T>>
with TickerProviderStateMixin {
late T selectedValue;
final LayerLink _layerLink = LayerLink();
OverlayEntry? _overlayEntry;
bool _isOpen = false;
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
late Animation<double> _opacityAnimation;
@override
void initState() {
super.initState();
selectedValue = widget.initialValue;
_animationController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_scaleAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeOutBack),
);
_opacityAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
);
}
void _showOverlay() {
if (_isOpen) return;
setState(() {
_isOpen = true;
});
_overlayEntry = _createOverlayEntry();
Overlay.of(context).insert(_overlayEntry!);
_animationController.forward();
}
void _hideOverlay() {
if (!_isOpen) return;
setState(() {
_isOpen = false;
});
_animationController.reverse().then((_) {
_overlayEntry?.remove();
_overlayEntry = null;
});
}
OverlayEntry _createOverlayEntry() {
final RenderBox renderBox = context.findRenderObject() as RenderBox;
final size = renderBox.size;
final offset = renderBox.localToGlobal(Offset.zero);
return OverlayEntry(
builder:
(context) => GestureDetector(
onTap: _hideOverlay,
behavior: HitTestBehavior.translucent,
child: Stack(
children: [
// Invisible overlay to detect taps outside
Positioned.fill(child: Container(color: Colors.transparent)),
// Dropdown menu
Positioned(
left: offset.dx,
top: offset.dy + size.height + 4,
width: size.width,
child: AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
alignment: Alignment.topCenter,
child: Opacity(
opacity: _opacityAnimation.value,
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(12),
color: Colors.white,
shadowColor: Colors.black.withOpacity(0.1),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade200),
borderRadius: BorderRadius.circular(12),
),
constraints: const BoxConstraints(maxHeight: 250),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(
vertical: 8,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children:
widget.itemBuilder(context).map((item) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
_hideOverlay();
widget.onSelected(item.value);
setState(() {
selectedValue = item.value;
});
},
child: Container(
width: double.infinity,
padding:
const EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
decoration: BoxDecoration(
color:
item.isSelected
? Colors.blue
.withOpacity(0.05)
: Colors.transparent,
),
child: item.child,
),
),
);
}).toList(),
),
),
),
),
),
),
);
},
),
),
],
),
),
);
}
@override
void dispose() {
_hideOverlay();
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return CompositedTransformTarget(
link: _layerLink,
child: widget.buttonBuilder(context, () {
if (_isOpen) {
_hideOverlay();
} else {
_showOverlay();
}
}, _isOpen),
); );
} }
} }