448 lines
16 KiB
Dart
448 lines
16 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:manager_app/l10n/app_localizations.dart';
|
|
import 'package:manager_app/Models/managerContext.dart';
|
|
import 'package:manager_app/app_context.dart';
|
|
import 'package:manager_app/Components/common_loader.dart';
|
|
import 'package:manager_app/constants.dart';
|
|
import 'package:manager_api_new/api.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
class NotificationsScreen extends StatefulWidget {
|
|
const NotificationsScreen({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
_NotificationsScreenState createState() => _NotificationsScreenState();
|
|
}
|
|
|
|
class _NotificationsScreenState extends State<NotificationsScreen> {
|
|
List<PushNotificationDTO> _notifications = [];
|
|
bool _loading = true;
|
|
String? _statusFilter;
|
|
int _page = 0;
|
|
static const _pageSize = 15;
|
|
|
|
int get _totalCount => _notifications.length;
|
|
int get _sentCount => _notifications.where((n) => n.status == 'Sent').length;
|
|
int get _scheduledCount => _notifications.where((n) => n.status == 'Scheduled').length;
|
|
int get _failedCount => _notifications.where((n) => n.status == 'Failed').length;
|
|
|
|
List<PushNotificationDTO> get _filtered => _statusFilter == null
|
|
? _notifications
|
|
: _notifications.where((n) => n.status == _statusFilter).toList();
|
|
|
|
int get _pageCount => (_filtered.isEmpty ? 1 : (_filtered.length / _pageSize).ceil());
|
|
List<PushNotificationDTO> get _paginated =>
|
|
_filtered.skip(_page * _pageSize).take(_pageSize).toList();
|
|
|
|
void _setFilter(String? status) {
|
|
setState(() {
|
|
_statusFilter = _statusFilter == status ? null : status;
|
|
_page = 0;
|
|
});
|
|
}
|
|
|
|
Future<void> _load(ManagerAppContext ctx) async {
|
|
try {
|
|
final response = await ctx.clientAPI!.notificationApi!.notificationGetWithHttpInfo();
|
|
if (response.statusCode == 200) {
|
|
final List<dynamic> json = jsonDecode(utf8.decode(response.bodyBytes));
|
|
setState(() {
|
|
_notifications = PushNotificationDTO.listFromJson(json);
|
|
_loading = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
setState(() => _loading = false);
|
|
}
|
|
}
|
|
|
|
Future<bool> _send(ManagerAppContext ctx, String title, String body) async {
|
|
final response = await ctx.clientAPI!.notificationApi!.notificationSendWithHttpInfo(
|
|
SendNotificationRequest(title: title, body: body),
|
|
);
|
|
return response.statusCode == 200;
|
|
}
|
|
|
|
Future<bool> _schedule(ManagerAppContext ctx, String title, String body, DateTime scheduledAt) async {
|
|
final response = await ctx.clientAPI!.notificationApi!.notificationScheduleWithHttpInfo(
|
|
ScheduleNotificationRequest(title: title, body: body, scheduledAt: scheduledAt),
|
|
);
|
|
return response.statusCode == 200;
|
|
}
|
|
|
|
Future<bool> _cancel(ManagerAppContext ctx, String id) async {
|
|
final response = await ctx.clientAPI!.notificationApi!.notificationCancelWithHttpInfo(id);
|
|
return response.statusCode == 202;
|
|
}
|
|
|
|
void _showSendModal(BuildContext context, ManagerAppContext ctx) {
|
|
final l = AppLocalizations.of(context)!;
|
|
final titleCtrl = TextEditingController();
|
|
final bodyCtrl = TextEditingController();
|
|
bool isScheduled = false;
|
|
DateTime? scheduledDate;
|
|
TimeOfDay? scheduledTime;
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (_) => StatefulBuilder(builder: (ctx2, setLocal) {
|
|
return AlertDialog(
|
|
title: Text(l.newMessage),
|
|
content: SizedBox(
|
|
width: 480,
|
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
|
TextField(
|
|
controller: titleCtrl,
|
|
decoration: InputDecoration(labelText: l.messageTitle),
|
|
),
|
|
const SizedBox(height: 8),
|
|
TextField(
|
|
controller: bodyCtrl,
|
|
decoration: InputDecoration(labelText: l.messageBody),
|
|
maxLines: 3,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Checkbox(
|
|
value: isScheduled,
|
|
onChanged: (v) => setLocal(() => isScheduled = v ?? false),
|
|
),
|
|
Text(l.schedule),
|
|
],
|
|
),
|
|
if (isScheduled) ...[
|
|
const SizedBox(height: 8),
|
|
Row(children: [
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
icon: const Icon(Icons.calendar_today, size: 16),
|
|
label: Text(scheduledDate != null
|
|
? '${scheduledDate!.day.toString().padLeft(2, '0')}/${scheduledDate!.month.toString().padLeft(2, '0')}/${scheduledDate!.year}'
|
|
: l.date),
|
|
onPressed: () async {
|
|
final d = await showDatePicker(
|
|
context: ctx2,
|
|
initialDate: DateTime.now().add(const Duration(hours: 1)),
|
|
firstDate: DateTime.now(),
|
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
|
);
|
|
if (d != null) setLocal(() => scheduledDate = d);
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
icon: const Icon(Icons.access_time, size: 16),
|
|
label: Text(scheduledTime != null
|
|
? scheduledTime!.format(ctx2)
|
|
: l.time),
|
|
onPressed: () async {
|
|
final t = await showTimePicker(
|
|
context: ctx2,
|
|
initialTime: TimeOfDay.now(),
|
|
);
|
|
if (t != null) setLocal(() => scheduledTime = t);
|
|
},
|
|
),
|
|
),
|
|
]),
|
|
],
|
|
]),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx2),
|
|
child: Text(l.cancel),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: titleCtrl.text.isEmpty ? null : () async {
|
|
Navigator.pop(ctx2);
|
|
bool ok;
|
|
if (isScheduled && scheduledDate != null && scheduledTime != null) {
|
|
final dt = DateTime(
|
|
scheduledDate!.year,
|
|
scheduledDate!.month,
|
|
scheduledDate!.day,
|
|
scheduledTime!.hour,
|
|
scheduledTime!.minute,
|
|
);
|
|
ok = await _schedule(ctx, titleCtrl.text, bodyCtrl.text, dt);
|
|
} else {
|
|
ok = await _send(ctx, titleCtrl.text, bodyCtrl.text);
|
|
}
|
|
if (ok && context.mounted) {
|
|
await _load(ctx);
|
|
}
|
|
},
|
|
child: Text(l.send),
|
|
),
|
|
],
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
|
|
void _confirmCancel(BuildContext context, ManagerAppContext ctx, PushNotificationDTO notif) {
|
|
final l = AppLocalizations.of(context)!;
|
|
showDialog(
|
|
context: context,
|
|
builder: (_) => AlertDialog(
|
|
title: Text(l.cancelNotification),
|
|
content: Text(l.cancelNotificationConfirm(notif.title ?? '')),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(context), child: Text(l.no)),
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
|
onPressed: () async {
|
|
Navigator.pop(context);
|
|
final ok = await _cancel(ctx, notif.id!);
|
|
if (ok && context.mounted) await _load(ctx);
|
|
},
|
|
child: Text(l.cancel, style: const TextStyle(color: Colors.white)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
static String _formatDate(DateTime? dt) {
|
|
if (dt == null) return '—';
|
|
final d = dt.toLocal();
|
|
return '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year} ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
static Widget _statusChip(String status) {
|
|
Color bg;
|
|
Color fg;
|
|
switch (status) {
|
|
case 'Sent':
|
|
bg = Colors.green.shade50;
|
|
fg = Colors.green.shade700;
|
|
break;
|
|
case 'Scheduled':
|
|
bg = Colors.blue.shade50;
|
|
fg = Colors.blue.shade700;
|
|
break;
|
|
case 'Failed':
|
|
bg = Colors.red.shade50;
|
|
fg = Colors.red.shade700;
|
|
break;
|
|
default:
|
|
bg = Colors.grey.shade100;
|
|
fg = Colors.grey.shade700;
|
|
}
|
|
return Chip(
|
|
label: Text(status, style: TextStyle(color: fg, fontSize: 12)),
|
|
backgroundColor: bg,
|
|
side: BorderSide(color: fg.withValues(alpha: 0.3)),
|
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
visualDensity: VisualDensity.compact,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
final ctx = Provider.of<AppContext>(context, listen: false).getContext() as ManagerAppContext;
|
|
_load(ctx);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context)!;
|
|
final appContext = Provider.of<AppContext>(context);
|
|
final ctx = appContext.getContext() as ManagerAppContext;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(l.notificationsTitle,
|
|
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: kPrimaryColor)),
|
|
ElevatedButton.icon(
|
|
onPressed: () => _showSendModal(context, ctx),
|
|
icon: const Icon(Icons.add),
|
|
label: Text(l.newMessage),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
_KpiCard(
|
|
label: l.allNotifications,
|
|
count: _totalCount,
|
|
color: Colors.blueGrey,
|
|
selected: _statusFilter == null,
|
|
onTap: () => _setFilter(null),
|
|
),
|
|
const SizedBox(width: 12),
|
|
_KpiCard(
|
|
label: l.sentNotifications,
|
|
count: _sentCount,
|
|
color: Colors.green,
|
|
selected: _statusFilter == 'Sent',
|
|
onTap: () => _setFilter('Sent'),
|
|
),
|
|
const SizedBox(width: 12),
|
|
_KpiCard(
|
|
label: l.scheduledNotifications,
|
|
count: _scheduledCount,
|
|
color: Colors.blue,
|
|
selected: _statusFilter == 'Scheduled',
|
|
onTap: () => _setFilter('Scheduled'),
|
|
),
|
|
const SizedBox(width: 12),
|
|
_KpiCard(
|
|
label: l.failedNotifications,
|
|
count: _failedCount,
|
|
color: Colors.red,
|
|
selected: _statusFilter == 'Failed',
|
|
onTap: () => _setFilter('Failed'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (_loading)
|
|
const CommonLoader()
|
|
else if (_notifications.isEmpty)
|
|
Center(child: Text(l.noNotifications))
|
|
else if (_filtered.isEmpty)
|
|
Center(child: Text(l.noNotificationsForFilter))
|
|
else
|
|
Expanded(
|
|
child: Card(
|
|
elevation: 0,
|
|
color: Colors.white,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
side: BorderSide(color: Colors.grey.shade200),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Column(
|
|
children: [
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
child: DataTable(
|
|
horizontalMargin: 16,
|
|
columnSpacing: 24,
|
|
headingRowColor: WidgetStateProperty.all(Colors.grey.shade50),
|
|
dividerThickness: 1,
|
|
columns: [
|
|
DataColumn(label: Text(l.messageTitle, style: const TextStyle(fontWeight: FontWeight.w600))),
|
|
DataColumn(label: Text(l.topic, style: const TextStyle(fontWeight: FontWeight.w600))),
|
|
DataColumn(label: Text(l.date, style: const TextStyle(fontWeight: FontWeight.w600))),
|
|
DataColumn(label: Text(l.status, style: const TextStyle(fontWeight: FontWeight.w600))),
|
|
const DataColumn(label: Text('')),
|
|
],
|
|
rows: _paginated.map((n) {
|
|
final isScheduled = n.status == 'Scheduled';
|
|
final date = isScheduled ? n.scheduledAt : n.sentAt;
|
|
return DataRow(cells: [
|
|
DataCell(Text(n.title ?? '')),
|
|
DataCell(Text(n.topic ?? '', style: const TextStyle(color: Colors.grey))),
|
|
DataCell(Text(_formatDate(date), style: const TextStyle(color: Colors.grey))),
|
|
DataCell(_statusChip(n.status ?? '')),
|
|
DataCell(isScheduled
|
|
? IconButton(
|
|
icon: const Icon(Icons.delete_outline, color: Colors.red, size: 20),
|
|
tooltip: l.tooltipCancelNotification,
|
|
onPressed: () => _confirmCancel(context, ctx, n),
|
|
)
|
|
: const SizedBox()),
|
|
]);
|
|
}).toList(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
border: Border(top: BorderSide(color: Colors.grey.shade200)),
|
|
),
|
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.chevron_left),
|
|
onPressed: _page > 0 ? () => setState(() => _page--) : null,
|
|
),
|
|
Text('${_page + 1} / $_pageCount', style: const TextStyle(fontSize: 13)),
|
|
IconButton(
|
|
icon: const Icon(Icons.chevron_right),
|
|
onPressed: _page < _pageCount - 1 ? () => setState(() => _page++) : null,
|
|
),
|
|
const SizedBox(width: 16),
|
|
Text(
|
|
l.resultsCount(_filtered.length),
|
|
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _KpiCard extends StatelessWidget {
|
|
final String label;
|
|
final int count;
|
|
final MaterialColor color;
|
|
final bool selected;
|
|
final VoidCallback onTap;
|
|
|
|
const _KpiCard({
|
|
required this.label,
|
|
required this.count,
|
|
required this.color,
|
|
required this.selected,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 150),
|
|
width: 120,
|
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
|
decoration: BoxDecoration(
|
|
color: selected ? color.shade100 : color.shade50,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: selected ? color.shade400 : color.shade200,
|
|
width: selected ? 2 : 1,
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('$count',
|
|
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: color.shade700)),
|
|
const SizedBox(height: 2),
|
|
Text(label,
|
|
style: TextStyle(fontSize: 12, color: color.shade600)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|