feat(panic): enhance incident logs model and repository, add recent incidents fetching, and improve statistics view UI
This commit is contained in:
parent
b62c64d36d
commit
c116e5d229
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 =
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue