mirror of
https://github.com/vleeuwenmenno/supplements.git
synced 2025-09-11 18:29:12 +02:00
427 lines
13 KiB
Dart
427 lines
13 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
|
import 'package:supplements/providers/supplement_provider.dart';
|
|
import 'package:supplements/services/notification_debug_store.dart';
|
|
import 'package:supplements/services/simple_notification_service.dart';
|
|
|
|
class DebugNotificationsScreen extends StatefulWidget {
|
|
const DebugNotificationsScreen({super.key});
|
|
|
|
@override
|
|
State<DebugNotificationsScreen> createState() => _DebugNotificationsScreenState();
|
|
}
|
|
|
|
class _DebugNotificationsScreenState extends State<DebugNotificationsScreen> {
|
|
bool _loading = true;
|
|
List<PendingNotificationRequest> _pending = const [];
|
|
List<NotificationLogEntry> _logEntries = const [];
|
|
final Map<int, String> _supplementNameCache = {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_load();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
setState(() {
|
|
_loading = true;
|
|
});
|
|
|
|
// Fetch pending from plugin
|
|
final pending = await SimpleNotificationService.instance.getPendingNotifications();
|
|
|
|
// Fetch log from local store
|
|
final logs = await NotificationDebugStore.instance.getAll();
|
|
|
|
// Optionally resolve supplement names for single payloads
|
|
await _resolveSupplementNames(pending, logs);
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_pending = pending;
|
|
_logEntries = logs.reversed.toList(); // newest first
|
|
_loading = false;
|
|
});
|
|
}
|
|
|
|
Future<void> _resolveSupplementNames(
|
|
List<PendingNotificationRequest> pending,
|
|
List<NotificationLogEntry> logs,
|
|
) async {
|
|
final ctx = context;
|
|
if (!mounted) return;
|
|
final provider = Provider.of<SupplementProvider>(ctx, listen: false);
|
|
// Use existing list; attempt load if empty
|
|
if (provider.supplements.isEmpty) {
|
|
try {
|
|
await provider.loadSupplements();
|
|
} catch (_) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
// Collect potential IDs
|
|
final Set<int> ids = {};
|
|
for (final p in pending) {
|
|
final id = _extractSingleIdFromPayload(p.payload);
|
|
if (id != null) ids.add(id);
|
|
}
|
|
for (final e in logs) {
|
|
if (e.singleId != null) ids.add(e.singleId!);
|
|
}
|
|
|
|
// Build cache
|
|
for (final id in ids) {
|
|
try {
|
|
final s = provider.supplements.firstWhere((el) => el.id == id);
|
|
_supplementNameCache[id] = s.name;
|
|
} catch (_) {
|
|
// leave missing
|
|
}
|
|
}
|
|
}
|
|
|
|
int? _extractSingleIdFromPayload(String? payload) {
|
|
if (payload == null || payload.isEmpty) return null;
|
|
try {
|
|
final map = jsonDecode(payload);
|
|
if (map is Map && map['type'] == 'single') {
|
|
final v = map['id'];
|
|
if (v is int) return v;
|
|
}
|
|
} catch (_) {
|
|
// ignore
|
|
}
|
|
return null;
|
|
}
|
|
|
|
String? _extractGroupTimeFromPayload(String? payload) {
|
|
if (payload == null || payload.isEmpty) return null;
|
|
try {
|
|
final map = jsonDecode(payload);
|
|
if (map is Map && map['type'] == 'group') {
|
|
final v = map['time'];
|
|
if (v is String) return v;
|
|
}
|
|
} catch (_) {
|
|
// ignore
|
|
}
|
|
return null;
|
|
}
|
|
|
|
String _extractKindFromPayload(String? payload) {
|
|
if (payload == null || payload.isEmpty) return 'unknown';
|
|
try {
|
|
final map = jsonDecode(payload);
|
|
if (map is Map) {
|
|
final meta = map['meta'];
|
|
if (meta is Map && meta['kind'] is String) return meta['kind'] as String;
|
|
}
|
|
} catch (_) {}
|
|
return 'unknown';
|
|
}
|
|
|
|
DateTime? _estimateScheduledAtFromPayload(String? payload) {
|
|
// For snooze with meta.createdAt/delayMin we can compute when.
|
|
if (payload == null || payload.isEmpty) return null;
|
|
try {
|
|
final map = jsonDecode(payload);
|
|
if (map is Map) {
|
|
final meta = map['meta'];
|
|
if (meta is Map) {
|
|
final kind = meta['kind'];
|
|
if (kind == 'snooze') {
|
|
final createdAt = meta['createdAt'];
|
|
final delayMin = meta['delayMin'];
|
|
if (createdAt is int && delayMin is int) {
|
|
return DateTime.fromMillisecondsSinceEpoch(createdAt)
|
|
.add(Duration(minutes: delayMin));
|
|
}
|
|
}
|
|
}
|
|
// For daily group we can compute next HH:mm
|
|
if (map['type'] == 'group' && map['time'] is String) {
|
|
final timeKey = map['time'] as String;
|
|
final parts = timeKey.split(':');
|
|
if (parts.length == 2) {
|
|
final hour = int.tryParse(parts[0]) ?? 0;
|
|
final minute = int.tryParse(parts[1]) ?? 0;
|
|
final now = DateTime.now();
|
|
DateTime sched = DateTime(now.year, now.month, now.day, hour, minute);
|
|
if (!sched.isAfter(now)) {
|
|
sched = sched.add(const Duration(days: 1));
|
|
}
|
|
return sched;
|
|
}
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
|
|
Future<void> _cancelId(int id) async {
|
|
await SimpleNotificationService.instance.cancelById(id);
|
|
await _load();
|
|
if (!mounted) return;
|
|
ShadSonner.of(context).show(
|
|
ShadToast(
|
|
title: Text('Canceled notification $id'),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _cancelAll() async {
|
|
await SimpleNotificationService.instance.cancelAll();
|
|
await _load();
|
|
if (!mounted) return;
|
|
ShadSonner.of(context).show(
|
|
const ShadToast(
|
|
title: Text('Canceled all notifications'),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _clearLog() async {
|
|
await NotificationDebugStore.instance.clear();
|
|
await _load();
|
|
if (!mounted) return;
|
|
ShadSonner.of(context).show(
|
|
const ShadToast(
|
|
title: Text('Cleared debug log'),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _copyToClipboard(String text) {
|
|
Clipboard.setData(ClipboardData(text: text));
|
|
ShadSonner.of(context).show(
|
|
const ShadToast(
|
|
title: Text('Copied to clipboard'),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final pendingIds = _pending.map((e) => e.id).toSet();
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Debug Notifications'),
|
|
actions: [
|
|
IconButton(
|
|
tooltip: 'Refresh',
|
|
onPressed: _load,
|
|
icon: const Icon(Icons.refresh),
|
|
),
|
|
IconButton(
|
|
tooltip: 'Cancel All',
|
|
onPressed: _cancelAll,
|
|
icon: const Icon(Icons.cancel_schedule_send),
|
|
),
|
|
IconButton(
|
|
tooltip: 'Clear Log',
|
|
onPressed: _clearLog,
|
|
icon: const Icon(Icons.delete_sweep),
|
|
),
|
|
],
|
|
),
|
|
body: _loading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: ListView(
|
|
padding: const EdgeInsets.all(12),
|
|
children: [
|
|
_buildPendingSection(),
|
|
const SizedBox(height: 16),
|
|
_buildTestSnoozeSection(), // Add the test snooze section
|
|
const SizedBox(height: 16),
|
|
_buildLogSection(pendingIds),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPendingSection() {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Pending (${_pending.length})', style: Theme.of(context).textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
if (_pending.isEmpty)
|
|
const Text('No pending notifications'),
|
|
for (final p in _pending) _buildPendingTile(p),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTestSnoozeSection() {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Test Snooze', style: Theme.of(context).textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
ElevatedButton(
|
|
onPressed: () async {
|
|
// Trigger a test snooze notification with snooze actions
|
|
await SimpleNotificationService.instance.showInstant(
|
|
title: 'Test Snooze Notification',
|
|
body: 'This is a test notification for snooze.',
|
|
payload: jsonEncode({
|
|
"type": "single",
|
|
"id": 1, // Use a dummy ID for testing
|
|
"meta": {"kind": "daily"} // Simulate a daily notification
|
|
}),
|
|
includeSnoozeActions: true, // Include snooze actions
|
|
isSingle: true, // This is a single notification
|
|
);
|
|
if (!mounted) return;
|
|
ShadSonner.of(context).show(
|
|
const ShadToast(
|
|
title: Text('Test snooze notification sent!'),
|
|
),
|
|
);
|
|
},
|
|
child: const Text('Send Test Snooze Notification'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPendingTile(PendingNotificationRequest p) {
|
|
final kind = _extractKindFromPayload(p.payload);
|
|
final singleId = _extractSingleIdFromPayload(p.payload);
|
|
final groupTime = _extractGroupTimeFromPayload(p.payload);
|
|
final schedAt = _estimateScheduledAtFromPayload(p.payload);
|
|
|
|
final forStr = singleId != null
|
|
? (_supplementNameCache[singleId] != null
|
|
? '${_supplementNameCache[singleId]} (id=$singleId)'
|
|
: 'Supplement id=$singleId')
|
|
: (groupTime != null ? 'Time $groupTime' : 'unknown');
|
|
|
|
// Build a clearer subtitle with scheduled time prominently displayed
|
|
String subtitle = 'Kind: $kind • For: $forStr';
|
|
if (schedAt != null) {
|
|
final now = DateTime.now();
|
|
final diff = schedAt.difference(now);
|
|
String whenStr;
|
|
if (diff.isNegative) {
|
|
whenStr = 'Overdue (${schedAt.toLocal().toString().substring(0, 16)})';
|
|
} else if (diff.inDays > 0) {
|
|
whenStr = 'In ${diff.inDays}d ${diff.inHours % 24}h (${schedAt.toLocal().toString().substring(0, 16)})';
|
|
} else if (diff.inHours > 0) {
|
|
whenStr = 'In ${diff.inHours}h ${diff.inMinutes % 60}m (${schedAt.toLocal().toString().substring(11, 16)})';
|
|
} else if (diff.inMinutes > 0) {
|
|
whenStr = 'In ${diff.inMinutes}m (${schedAt.toLocal().toString().substring(11, 16)})';
|
|
} else {
|
|
whenStr = 'Very soon (${schedAt.toLocal().toString().substring(11, 16)})';
|
|
}
|
|
subtitle = '$subtitle\n🕒 $whenStr';
|
|
}
|
|
// Removed the "else" block that displayed "Schedule time unknown"
|
|
|
|
return ListTile(
|
|
dense: true,
|
|
title: Text('ID ${p.id} — ${p.title ?? "(no title)"}'),
|
|
subtitle: Text(subtitle),
|
|
trailing: Wrap(
|
|
spacing: 4,
|
|
children: [
|
|
IconButton(
|
|
tooltip: 'Copy payload',
|
|
icon: const Icon(Icons.copy),
|
|
onPressed: () => _copyToClipboard(p.payload ?? ''),
|
|
),
|
|
IconButton(
|
|
tooltip: 'Cancel',
|
|
icon: const Icon(Icons.cancel),
|
|
onPressed: () => _cancelId(p.id),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLogSection(Set<int> pendingIds) {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Schedule Log (${_logEntries.length})', style: Theme.of(context).textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
if (_logEntries.isEmpty)
|
|
const Text('No log entries'),
|
|
for (final e in _logEntries) _buildLogTile(e, pendingIds),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLogTile(NotificationLogEntry e, Set<int> pendingIds) {
|
|
final inQueue = pendingIds.contains(e.id);
|
|
String forStr = 'unknown';
|
|
if (e.type == 'single') {
|
|
if (e.singleId != null) {
|
|
final name = _supplementNameCache[e.singleId!];
|
|
forStr = name != null ? '$name (id=${e.singleId})' : 'id=${e.singleId}';
|
|
}
|
|
} else if (e.type == 'group') {
|
|
if (e.timeKey != null) {
|
|
forStr = 'Time ${e.timeKey}';
|
|
}
|
|
}
|
|
|
|
final scheduledAt = DateTime.fromMillisecondsSinceEpoch(e.whenEpochMs).toLocal();
|
|
final createdAt = DateTime.fromMillisecondsSinceEpoch(e.createdAtEpochMs).toLocal();
|
|
|
|
// Format times more clearly
|
|
final scheduledStr = scheduledAt.toString().substring(0, 16);
|
|
final createdStr = createdAt.toString().substring(0, 16);
|
|
|
|
// Show status and timing info
|
|
final statusStr = inQueue ? '🟡 Pending' : '✅ Completed/Canceled';
|
|
|
|
return ListTile(
|
|
dense: true,
|
|
leading: Icon(inQueue ? Icons.pending : Icons.check, color: inQueue ? Colors.amber : Colors.green),
|
|
title: Text('[${e.kind}] ${e.title} (ID ${e.id})'),
|
|
subtitle: Text('$statusStr • Type: ${e.type} • For: $forStr\n🕒 Scheduled: $scheduledStr\n📝 Created: $createdStr'),
|
|
trailing: Wrap(
|
|
spacing: 4,
|
|
children: [
|
|
IconButton(
|
|
tooltip: 'Copy payload',
|
|
icon: const Icon(Icons.copy),
|
|
onPressed: () => _copyToClipboard(e.payload),
|
|
),
|
|
if (inQueue)
|
|
IconButton(
|
|
tooltip: 'Cancel',
|
|
icon: const Icon(Icons.cancel),
|
|
onPressed: () => _cancelId(e.id),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|