ADD ALOT OF POLISHING AND UPDATES...

This commit is contained in:
Wowieee4
2026-05-26 05:52:12 +07:00
parent 2e3ecdf1d1
commit b8ad8df993
53 changed files with 3194 additions and 3860 deletions
@@ -7,6 +7,7 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:intl/intl.dart';
import '../../../app/injection_container.dart';
import '../../../core/errors/friendly_error.dart';
import '../../../core/network/api_client.dart';
Dio get _api => sl<ApiClient>().dio;
@@ -48,66 +49,67 @@ class _GuardianActivityLogScreenState extends State<GuardianActivityLogScreen> {
_error = null;
_needsPairing = false;
});
try {
// Cek pairing dulu
final paired = await _hasActivePairing();
if (!paired) {
await runFriendlyAction(
() async {
// Cek pairing dulu
final paired = await _hasActivePairing();
if (!paired) {
setState(() {
_needsPairing = true;
_loading = false;
});
return;
}
final res = await _api.get('/guardian/activity-logs', queryParameters: {
'size': 50,
'page': 0
}).timeout(const Duration(seconds: 10));
// Response bisa berupa list langsung atau paged {content: [...]}
final data = res.data['data'];
List<dynamic> list;
if (data is List) {
list = data;
} else if (data is Map && data['content'] is List) {
list = data['content'] as List;
} else {
list = [];
}
final items = list
.whereType<Map>()
.map((e) => _LogItem.fromJson(Map<String, dynamic>.from(e)))
.toList();
setState(() {
_needsPairing = true;
_items = items;
_applyFilter(_selectedFilter);
_loading = false;
});
return;
}
final res = await _api.get('/guardian/activity-logs', queryParameters: {
'size': 50,
'page': 0
}).timeout(const Duration(seconds: 10));
// Response bisa berupa list langsung atau paged {content: [...]}
final data = res.data['data'];
List<dynamic> list;
if (data is List) {
list = data;
} else if (data is Map && data['content'] is List) {
list = data['content'] as List;
} else {
list = [];
}
final items = list
.whereType<Map>()
.map((e) => _LogItem.fromJson(Map<String, dynamic>.from(e)))
.toList();
setState(() {
_items = items;
_applyFilter(_selectedFilter);
},
onError: (message) => setState(() {
_error = message;
_loading = false;
});
} on DioException catch (e) {
setState(() {
_error = e.response?.data?['message']?.toString() ??
'Gagal memuat activity log.';
_loading = false;
});
} catch (e) {
setState(() {
_error = 'Timeout / error: $e';
_loading = false;
});
}
}),
fallback: 'Gagal memuat activity log. Coba refresh lagi.',
);
}
Future<bool> _hasActivePairing() async {
try {
final res = await _api
.get('/shared/pairing/status')
.timeout(const Duration(seconds: 5));
final data = res.data['data'];
if (data is Map) return data['status'] == 'ACTIVE';
} catch (_) {}
return false;
final active = await runFriendly<bool>(
() async {
final res = await _api
.get('/shared/pairing/status')
.timeout(const Duration(seconds: 5));
final data = res.data['data'];
if (data is Map) return data['status'] == 'ACTIVE';
return false;
},
onError: (_) {},
fallback: 'Status pairing belum bisa dicek.',
);
return active ?? false;
}
void _applyFilter(String filter) {
@@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../app/injection_container.dart';
import '../../../core/errors/friendly_error.dart';
import '../../../core/network/api_client.dart';
Dio get _api => sl<ApiClient>().dio;
@@ -73,11 +74,12 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
}
} on DioException catch (e) {
setState(() {
_error = e.response?.data?['message']?.toString() ??
'Gagal memuat konfigurasi AI.';
_error =
friendlyDioMessage(e, fallback: 'Gagal memuat konfigurasi AI.');
});
} catch (e) {
setState(() => _error = 'Timeout / error: $e');
setState(
() => _error = 'Gagal memuat konfigurasi AI. Coba refresh lagi.');
} finally {
if (mounted) setState(() => _loading = false);
}
@@ -105,8 +107,8 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.response?.data?['message']?.toString() ??
'Gagal menyimpan konfigurasi.'),
content: Text(friendlyDioMessage(e,
fallback: 'Gagal menyimpan konfigurasi.')),
backgroundColor: const Color(0xFFDC2626),
),
);
@@ -114,9 +116,9 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: $e'),
backgroundColor: const Color(0xFFDC2626),
const SnackBar(
content: Text('Gagal menyimpan konfigurasi. Coba lagi.'),
backgroundColor: Color(0xFFDC2626),
),
);
}
@@ -0,0 +1,301 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:intl/intl.dart';
import 'package:latlong2/latlong.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../shared/widgets/feature_page.dart';
class GuardianMapScreen extends StatefulWidget {
const GuardianMapScreen({super.key});
@override
State<GuardianMapScreen> createState() => _GuardianMapScreenState();
}
class _GuardianMapScreenState extends State<GuardianMapScreen> {
bool _loading = true;
String? _error;
Map<String, dynamic>? _lastLocation;
List<Map<String, dynamic>> _history = const [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
await runFriendlyAction(
() async {
final dio = sl<ApiClient>().dio;
final current = await dio
.get('/guardian/user-location')
.timeout(const Duration(seconds: 8));
final history = await dio.get('/guardian/location-history',
queryParameters: {'size': 80}).timeout(const Duration(seconds: 8));
final currentData = current.data is Map ? current.data['data'] : null;
final historyData = history.data is Map ? history.data['data'] : null;
_lastLocation =
currentData is Map ? Map<String, dynamic>.from(currentData) : null;
final content = historyData is Map ? historyData['content'] : null;
_history = content is List
? content
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList()
: const [];
},
onError: (message) => _error = message,
fallback: 'Lokasi User belum bisa dimuat. Coba refresh lagi.',
);
if (mounted) setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
return FeaturePage(
title: 'Live Map',
subtitle: 'Lokasi terakhir User dan timeline perjalanan',
trailing: IconButton(
onPressed: _loading ? null : _load,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
child: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? FeatureErrorPanel(message: _error!, onRetry: _load)
: Column(
children: [
Expanded(
flex: 3,
child: _GuardianMapCard(
location: _lastLocation,
history: _history,
),
),
const SizedBox(height: 12),
Expanded(
flex: 2,
child: _TimelineList(history: _history, onRetry: _load),
),
],
),
);
}
}
class _GuardianMapCard extends StatelessWidget {
final Map<String, dynamic>? location;
final List<Map<String, dynamic>> history;
const _GuardianMapCard({required this.location, required this.history});
@override
Widget build(BuildContext context) {
final points = _pointsFrom(history);
final center = _pointFrom(location) ??
(points.isNotEmpty ? points.first : null) ??
const LatLng(-7.2575, 112.7521);
return ClipRRect(
borderRadius: BorderRadius.circular(20),
child: FlutterMap(
options: MapOptions(initialCenter: center, initialZoom: 16),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.walkguide.app',
),
if (points.length > 1)
PolylineLayer(
polylines: [
Polyline(
points: points,
strokeWidth: 4,
color: const Color(0xFF2563EB),
),
],
),
MarkerLayer(
markers: [
Marker(
point: center,
width: 54,
height: 54,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFF2563EB),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4),
),
child:
const Icon(Icons.person_pin_circle, color: Colors.white),
),
),
],
),
],
),
);
}
}
class _TimelineList extends StatelessWidget {
final List<Map<String, dynamic>> history;
final VoidCallback onRetry;
const _TimelineList({required this.history, required this.onRetry});
@override
Widget build(BuildContext context) {
final segments = _segments(history);
if (segments.isEmpty) {
return FeatureEmptyPanel(
icon: Icons.timeline,
title: 'Belum ada timeline',
message:
'Timeline akan muncul setelah User mengirim beberapa titik lokasi.',
action: OutlinedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Refresh'),
),
);
}
return ListView.separated(
itemCount: segments.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (_, index) => _TimelineCard(segment: segments[index]),
);
}
}
class _TimelineCard extends StatelessWidget {
final _TimelineSegment segment;
const _TimelineCard({required this.segment});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(14),
),
child: Icon(segment.icon, color: const Color(0xFF1D4ED8)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(segment.title,
style: const TextStyle(fontWeight: FontWeight.w800)),
const SizedBox(height: 4),
Text(segment.subtitle,
style: const TextStyle(color: Color(0xFF64748B))),
],
),
),
],
),
);
}
}
class _TimelineSegment {
final String title;
final String subtitle;
final IconData icon;
const _TimelineSegment({
required this.title,
required this.subtitle,
required this.icon,
});
}
List<_TimelineSegment> _segments(List<Map<String, dynamic>> history) {
final items = [...history]..sort((a, b) => _time(a).compareTo(_time(b)));
if (items.length < 2) return const [];
final chunks = <_TimelineSegment>[];
for (var i = 0; i < items.length - 1; i += 6) {
final start = items[i];
final end = items[math.min(i + 5, items.length - 1)];
final distance = _distanceBetween(start, end);
final speed = _avgSpeed(items.sublist(i, math.min(i + 6, items.length)));
final mode = speed > 2.8
? 'Naik kendaraan'
: speed > 1.1
? 'Berjalan cepat'
: 'Berjalan';
chunks.add(
_TimelineSegment(
title: '${_clock(start)} - ${_clock(end)} $mode',
subtitle:
'${distance.toStringAsFixed(0)} m, avg ${speed.toStringAsFixed(1)} m/s',
icon: speed > 2.8 ? Icons.directions_bike : Icons.directions_walk,
),
);
}
return chunks.reversed.toList();
}
List<LatLng> _pointsFrom(List<Map<String, dynamic>> history) {
return history.map(_pointFrom).whereType<LatLng>().toList();
}
LatLng? _pointFrom(Map<String, dynamic>? item) {
if (item == null) return null;
final lat = (item['lat'] as num?)?.toDouble();
final lng = (item['lng'] as num?)?.toDouble();
if (lat == null || lng == null) return null;
return LatLng(lat, lng);
}
DateTime _time(Map<String, dynamic> item) {
return DateTime.tryParse(item['createdAt']?.toString() ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0);
}
String _clock(Map<String, dynamic> item) {
final time = _time(item).toLocal();
return DateFormat('HH:mm').format(time);
}
double _distanceBetween(Map<String, dynamic> a, Map<String, dynamic> b) {
final start = _pointFrom(a);
final end = _pointFrom(b);
if (start == null || end == null) return 0;
return const Distance().as(LengthUnit.Meter, start, end);
}
double _avgSpeed(List<Map<String, dynamic>> items) {
final speeds = items
.map((e) => (e['speed'] as num?)?.toDouble())
.whereType<double>()
.where((speed) => speed >= 0)
.toList();
if (speeds.isEmpty) return 0;
return speeds.reduce((a, b) => a + b) / speeds.length;
}
@@ -1,18 +1,15 @@
export '../home/presentation/guardian_dashboard_screen.dart'
show GuardianDashboardScreen;
export 'guardian_activity_log_screen.dart'
show
GuardianActivityLogScreen;
export 'guardian_ai_config_screen.dart'
show
GuardianAiConfigScreen;
export 'guardian_activity_log_screen.dart' show GuardianActivityLogScreen;
export '../screens.dart'
show
GuardianMapScreen,
GuardianSendNotifScreen,
GuardianVoiceCmdScreen,
GuardianShortcutScreen,
GuardianGeofenceScreen;
export 'guardian_ai_config_screen.dart' show GuardianAiConfigScreen;
export 'guardian_map_screen.dart' show GuardianMapScreen;
export 'guardian_send_notification_screen.dart' show GuardianSendNotifScreen;
export 'guardian_settings_screen.dart' show GuardianSettingsScreen;
export 'guardian_tools_screen.dart'
show GuardianVoiceCmdScreen, GuardianShortcutScreen, GuardianGeofenceScreen;
@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../shared/widgets/feature_page.dart';
class GuardianSendNotifScreen extends StatefulWidget {
const GuardianSendNotifScreen({super.key});
@override
State<GuardianSendNotifScreen> createState() =>
_GuardianSendNotifScreenState();
}
class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
final _message = TextEditingController();
bool _loading = false;
@override
void dispose() {
_message.dispose();
super.dispose();
}
Future<void> _send() async {
final message = _message.text.trim();
if (message.isEmpty) {
_snack('Tulis pesan dulu.');
return;
}
setState(() => _loading = true);
await runFriendlyAction(
() async {
await sl<ApiClient>().dio.post('/guardian/notifications/send', data: {
'notifType': 'TEXT',
'content': message,
}).timeout(const Duration(seconds: 8));
_message.clear();
_snack('Notifikasi terkirim ke User.');
},
onError: _snack,
fallback: 'Gagal mengirim notifikasi. Coba lagi.',
);
if (mounted) setState(() => _loading = false);
}
void _snack(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
return FeaturePage(
title: 'Send Notification',
subtitle: 'Kirim pesan singkat ke User yang sudah pairing',
child: ListView(
children: [
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: [
BoxShadow(
color: const Color(0xFF1E293B).withValues(alpha: 0.06),
blurRadius: 22,
offset: const Offset(0, 12),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _message,
minLines: 5,
maxLines: 8,
decoration: const InputDecoration(
labelText: 'Message',
hintText: 'Contoh: Hati-hati, belok kanan setelah pintu.',
prefixIcon: Icon(Icons.message_outlined),
alignLabelWithHint: true,
),
),
const SizedBox(height: 14),
FilledButton.icon(
onPressed: _loading ? null : _send,
icon: _loading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
label: Text(_loading ? 'Sending...' : 'Send Message'),
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,122 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../app/app_cubit.dart';
import '../../app/injection_container.dart';
import '../../core/constants/app_constants.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/storage/secure_storage.dart';
import '../../shared/widgets/feature_page.dart';
class GuardianSettingsScreen extends StatelessWidget {
const GuardianSettingsScreen({super.key});
Future<void> _logout(BuildContext context) async {
final appCubit = context.read<AppCubit>();
await sl<SecureStorage>().clearAll();
appCubit.clearSession();
unawaited(_notifyBackendLogout());
if (context.mounted) context.go('/login');
}
Future<void> _notifyBackendLogout() async {
await runFriendlyAction(
() async {
await sl<ApiClient>()
.dio
.post('/auth/logout')
.timeout(const Duration(seconds: 3));
},
onError: (_) {},
fallback: 'Logout server belum bisa dikonfirmasi.',
);
}
Future<void> _changeServer(BuildContext context) async {
await AppConstants.clearServerUrl();
await sl<SecureStorage>().clearAll();
if (context.mounted) context.go('/server-connect');
}
@override
Widget build(BuildContext context) {
return FeaturePage(
title: 'Guardian Settings',
subtitle: 'Account, pairing, AI tools, and server',
child: ListView(
children: [
_SettingsTile(
icon: Icons.link,
title: 'Pair User',
subtitle: 'Masukkan Pairing Code User atau cek status pairing.',
onTap: () => context.go('/guardian/pairing'),
),
_SettingsTile(
icon: Icons.speed,
title: 'AI Benchmark',
subtitle: 'Catat capture, inference, notification, dan TTS.',
onTap: () => context.go('/guardian/benchmark'),
),
_SettingsTile(
icon: Icons.tune,
title: 'AI Config',
subtitle: 'Atur threshold deteksi dan label yang aktif.',
onTap: () => context.go('/guardian/ai-config'),
),
const SizedBox(height: 18),
OutlinedButton.icon(
onPressed: () => _changeServer(context),
icon: const Icon(Icons.dns_outlined),
label: const Text('Change server'),
),
const SizedBox(height: 8),
FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFDC2626),
),
onPressed: () => _logout(context),
icon: const Icon(Icons.logout),
label: const Text('Logout'),
),
],
),
);
}
}
class _SettingsTile extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
const _SettingsTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: ListTile(
leading: Icon(icon, color: const Color(0xFF1D4ED8)),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w800)),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
),
);
}
}
@@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../shared/widgets/feature_page.dart';
class GuardianVoiceCmdScreen extends StatelessWidget {
const GuardianVoiceCmdScreen({super.key});
@override
Widget build(BuildContext context) => const _GuardianEndpointScreen(
title: 'Voice Commands',
subtitle: 'Daftar voice command yang aktif untuk User',
endpoint: '/guardian/voice-commands',
icon: Icons.record_voice_over_outlined,
);
}
class GuardianShortcutScreen extends StatelessWidget {
const GuardianShortcutScreen({super.key});
@override
Widget build(BuildContext context) => const _GuardianEndpointScreen(
title: 'Hardware Shortcuts',
subtitle: 'Shortcut tombol untuk aksi cepat WalkGuide',
endpoint: '/guardian/shortcuts',
icon: Icons.keyboard_command_key_outlined,
);
}
class GuardianGeofenceScreen extends StatelessWidget {
const GuardianGeofenceScreen({super.key});
@override
Widget build(BuildContext context) => const _GuardianEndpointScreen(
title: 'Geofence',
subtitle: 'Area aman dan peringatan lokasi User',
endpoint: '/guardian/geofence',
icon: Icons.fence_outlined,
);
}
class _GuardianEndpointScreen extends StatefulWidget {
final String title;
final String subtitle;
final String endpoint;
final IconData icon;
const _GuardianEndpointScreen({
required this.title,
required this.subtitle,
required this.endpoint,
required this.icon,
});
@override
State<_GuardianEndpointScreen> createState() =>
_GuardianEndpointScreenState();
}
class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
bool _loading = true;
String? _error;
List<Map<String, dynamic>> _items = const [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
await runFriendlyAction(
() async {
final res = await sl<ApiClient>().dio.get(widget.endpoint,
queryParameters: {'size': 50}).timeout(const Duration(seconds: 8));
_items = _extractList(res.data);
},
onError: (message) => _error = message,
fallback: 'Data belum bisa dimuat. Coba refresh lagi.',
);
if (mounted) setState(() => _loading = false);
}
List<Map<String, dynamic>> _extractList(dynamic body) {
final data = body is Map ? body['data'] : body;
final raw = data is Map ? data['content'] : data;
if (raw is! List) return const [];
return raw
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.toList();
}
@override
Widget build(BuildContext context) {
return FeaturePage(
title: widget.title,
subtitle: widget.subtitle,
trailing: IconButton(
onPressed: _loading ? null : _load,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
child: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? FeatureErrorPanel(message: _error!, onRetry: _load)
: _items.isEmpty
? FeatureEmptyPanel(
icon: widget.icon,
title: 'Belum ada data',
message:
'Data akan muncul setelah Guardian membuat konfigurasi atau User mulai memakai fitur ini.',
action: OutlinedButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh),
label: const Text('Refresh'),
),
)
: RefreshIndicator(
onRefresh: _load,
child: ListView.separated(
itemCount: _items.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (_, index) => _EndpointCard(
icon: widget.icon,
item: _items[index],
),
),
),
);
}
}
class _EndpointCard extends StatelessWidget {
final IconData icon;
final Map<String, dynamic> item;
const _EndpointCard({required this.icon, required this.item});
@override
Widget build(BuildContext context) {
final title = _firstText(item, ['name', 'command', 'label', 'type']) ??
'Item #${item['id'] ?? '-'}';
final subtitle = _firstText(
item,
['description', 'action', 'shortcut', 'status', 'createdAt'],
) ??
'Data aktif';
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(14),
),
child: Icon(icon, color: const Color(0xFF1D4ED8)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(fontWeight: FontWeight.w800)),
const SizedBox(height: 3),
Text(subtitle,
style: const TextStyle(color: Color(0xFF64748B))),
],
),
),
],
),
);
}
}
String? _firstText(Map<String, dynamic> item, List<String> keys) {
for (final key in keys) {
final value = item[key]?.toString().trim();
if (value != null && value.isNotEmpty && value != 'null') return value;
}
return null;
}