diff --git a/walkguide-mobile/walkguide_app/lib/features/activity_log/activity_log_screen.dart b/walkguide-mobile/walkguide_app/lib/features/activity_log/activity_log_screen.dart index 915d267..a24dfd9 100644 --- a/walkguide-mobile/walkguide_app/lib/features/activity_log/activity_log_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/activity_log/activity_log_screen.dart @@ -1 +1,435 @@ -export '../screens.dart' show ActivityLogScreen; +// lib/features/activity_log/activity_log_screen.dart +// ignore_for_file: use_build_context_synchronously + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../../app/injection_container.dart'; +import '../../core/network/api_client.dart'; +import '../../core/theme/app_colors.dart'; + +Dio get _api => sl().dio; + +class ActivityLogScreen extends StatefulWidget { + const ActivityLogScreen({super.key}); + + @override + State createState() => _ActivityLogScreenState(); +} + +class _ActivityLogScreenState extends State { + List<_LogItem> _items = []; + List<_LogItem> _filtered = []; + bool _loading = true; + String? _error; + String _selectedFilter = 'ALL'; + + static const _filters = [ + 'ALL', + 'WALKGUIDE', + 'SOS', + 'AUTH', + 'OBSTACLE', + 'LOCATION', + ]; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final res = await _api + .get('/user/activity-logs') + .timeout(const Duration(seconds: 10)); + final list = (res.data['data'] as List?) ?? []; + final items = list.map((e) => _LogItem.fromJson(e)).toList(); + setState(() { + _items = items; + _applyFilter(_selectedFilter); + }); + } on DioException catch (e) { + setState(() { + _error = e.response?.data?['message']?.toString() ?? + 'Gagal memuat activity log.'; + }); + } catch (e) { + setState(() => _error = 'Timeout / error: $e'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + void _applyFilter(String filter) { + _selectedFilter = filter; + if (filter == 'ALL') { + _filtered = List.from(_items); + } else { + _filtered = _items.where((item) { + switch (filter) { + case 'WALKGUIDE': + return item.logType.contains('WALKGUIDE'); + case 'SOS': + return item.logType.contains('SOS'); + case 'AUTH': + return item.logType == 'LOGIN' || + item.logType == 'LOGOUT' || + item.logType == 'APP_OPEN' || + item.logType == 'APP_CLOSE'; + case 'OBSTACLE': + return item.logType.contains('OBSTACLE'); + case 'LOCATION': + return item.logType.contains('LOCATION') || + item.logType.contains('GEOFENCE'); + default: + return true; + } + }).toList(); + } + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Activity Log', + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith(fontWeight: FontWeight.w800), + ), + Text( + '${_items.length} aktivitas tercatat', + style: const TextStyle(color: AppColors.muted), + ), + ], + ), + ), + IconButton( + onPressed: _load, + icon: const Icon(Icons.refresh), + tooltip: 'Refresh', + ), + ], + ), + const SizedBox(height: 12), + + // Filter chips + SizedBox( + height: 36, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _filters.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (_, i) { + final f = _filters[i]; + final selected = _selectedFilter == f; + return FilterChip( + label: Text(f), + selected: selected, + onSelected: (_) { + setState(() => _applyFilter(f)); + }, + selectedColor: AppColors.primary.withOpacity(0.15), + checkmarkColor: AppColors.primary, + labelStyle: TextStyle( + color: selected ? AppColors.primary : AppColors.muted, + fontWeight: + selected ? FontWeight.w700 : FontWeight.normal, + fontSize: 12, + ), + padding: const EdgeInsets.symmetric(horizontal: 4), + ); + }, + ), + ), + const SizedBox(height: 16), + + // Body + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? _ErrorPanel(message: _error!, onRetry: _load) + : _filtered.isEmpty + ? _EmptyPanel(filter: _selectedFilter) + : RefreshIndicator( + onRefresh: _load, + child: ListView.builder( + itemCount: _filtered.length, + itemBuilder: (ctx, i) => + _LogCard(item: _filtered[i]), + ), + ), + ), + ], + ), + ), + ); + } +} + +// ─── Data model ──────────────────────────────────────────────────────────────── + +class _LogItem { + final int id; + final String logType; + final String? description; + final DateTime createdAt; + + const _LogItem({ + required this.id, + required this.logType, + this.description, + required this.createdAt, + }); + + factory _LogItem.fromJson(Map j) => _LogItem( + id: j['id'] as int, + logType: j['logType']?.toString() ?? 'UNKNOWN', + description: j['description']?.toString(), + createdAt: DateTime.tryParse(j['createdAt']?.toString() ?? '') ?? + DateTime.now(), + ); +} + +// ─── Log card ────────────────────────────────────────────────────────────────── + +class _LogCard extends StatelessWidget { + final _LogItem item; + const _LogCard({required this.item}); + + @override + Widget build(BuildContext context) { + final meta = _logMeta(item.logType); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Timeline dot + line + Column( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: meta.color.withOpacity(0.12), + shape: BoxShape.circle, + ), + child: Icon(meta.icon, color: meta.color, size: 18), + ), + Container( + width: 1.5, + height: 20, + color: const Color(0xFFE2E8F0), + ), + ], + ), + const SizedBox(width: 12), + // Content + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + meta.label, + style: TextStyle( + fontWeight: FontWeight.w700, + color: meta.color, + fontSize: 13, + ), + ), + ), + Text( + _formatTime(item.createdAt), + style: const TextStyle( + color: AppColors.muted, fontSize: 11), + ), + ], + ), + if (item.description != null && item.description!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + item.description!, + style: const TextStyle( + fontSize: 13, color: AppColors.text), + ), + ), + const SizedBox(height: 12), + ], + ), + ), + ), + ], + ), + ); + } + + String _formatTime(DateTime dt) { + final now = DateTime.now(); + if (now.difference(dt).inDays < 1 && dt.day == now.day) { + return DateFormat('HH:mm').format(dt); + } + return DateFormat('dd MMM HH:mm').format(dt); + } +} + +// ─── Log metadata helper ─────────────────────────────────────────────────────── + +class _LogMeta { + final IconData icon; + final Color color; + final String label; + const _LogMeta( + {required this.icon, required this.color, required this.label}); +} + +_LogMeta _logMeta(String logType) { + switch (logType) { + case 'LOGIN': + return const _LogMeta( + icon: Icons.login, color: AppColors.success, label: 'Login'); + case 'LOGOUT': + return const _LogMeta( + icon: Icons.logout, color: AppColors.muted, label: 'Logout'); + case 'APP_OPEN': + return const _LogMeta( + icon: Icons.open_in_new, + color: AppColors.primary, + label: 'App Dibuka'); + case 'APP_CLOSE': + return const _LogMeta( + icon: Icons.close, color: AppColors.muted, label: 'App Ditutup'); + case 'WALKGUIDE_START': + return const _LogMeta( + icon: Icons.directions_walk, + color: AppColors.primary, + label: 'WalkGuide Mulai'); + case 'WALKGUIDE_STOP': + return const _LogMeta( + icon: Icons.stop_circle, + color: AppColors.muted, + label: 'WalkGuide Berhenti'); + case 'OBSTACLE_DETECTED': + return const _LogMeta( + icon: Icons.warning_amber, + color: Color(0xFFD97706), + label: 'Obstacle Terdeteksi'); + case 'CALL_INITIATED': + return const _LogMeta( + icon: Icons.call, + color: AppColors.success, + label: 'Panggilan Dimulai'); + case 'CALL_ENDED': + return const _LogMeta( + icon: Icons.call_end, + color: AppColors.muted, + label: 'Panggilan Selesai'); + case 'SOS_TRIGGERED': + return const _LogMeta( + icon: Icons.sos, color: AppColors.danger, label: 'SOS Terkirim'); + case 'SOS_ACKNOWLEDGED': + return const _LogMeta( + icon: Icons.check_circle, + color: AppColors.success, + label: 'SOS Diakui Guardian'); + case 'LOCATION_UPDATE': + return const _LogMeta( + icon: Icons.location_on, + color: AppColors.primary, + label: 'Lokasi Diperbarui'); + case 'GEOFENCE_EXIT': + return const _LogMeta( + icon: Icons.fence, + color: AppColors.danger, + label: 'Keluar Area Aman'); + case 'GEOFENCE_ENTER': + return const _LogMeta( + icon: Icons.home, color: AppColors.success, label: 'Masuk Area Aman'); + default: + return _LogMeta( + icon: Icons.circle, color: AppColors.muted, label: logType); + } +} + +// ─── Helper widgets ──────────────────────────────────────────────────────────── + +class _ErrorPanel extends StatelessWidget { + final String message; + final VoidCallback onRetry; + const _ErrorPanel({required this.message, required this.onRetry}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.wifi_off, size: 48, color: AppColors.muted), + const SizedBox(height: 12), + Text(message, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.muted)), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Coba lagi'), + ), + ], + ), + ); + } +} + +class _EmptyPanel extends StatelessWidget { + final String filter; + const _EmptyPanel({required this.filter}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.history, size: 64, color: AppColors.muted), + const SizedBox(height: 12), + Text( + filter == 'ALL' + ? 'Belum ada aktivitas' + : 'Tidak ada aktivitas "$filter"', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.muted), + ), + ], + ), + ); + } +}