fix: resolve raw JSON rendering and layout overflow in Guardian screens
This commit is contained in:
parent
8a2889633f
commit
282a918b56
@ -36,4 +36,6 @@ agora.app-certificate=${AGORA_APP_CERTIFICATE:}
|
||||
# ===== LOGGING =====
|
||||
logging.level.com.walkguide=DEBUG
|
||||
logging.level.org.springframework.messaging=INFO
|
||||
logging.level.org.springframework.web.socket=INFO
|
||||
logging.level.org.springframework.web.socket=INFO
|
||||
|
||||
spring.profiles.active=dev
|
||||
@ -0,0 +1,540 @@
|
||||
// lib/features/guardian_dashboard/guardian_activity_log_screen.dart
|
||||
// ignore_for_file: use_build_context_synchronously
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../app/injection_container.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
|
||||
Dio get _api => sl<ApiClient>().dio;
|
||||
|
||||
class GuardianActivityLogScreen extends StatefulWidget {
|
||||
const GuardianActivityLogScreen({super.key});
|
||||
|
||||
@override
|
||||
State<GuardianActivityLogScreen> createState() =>
|
||||
_GuardianActivityLogScreenState();
|
||||
}
|
||||
|
||||
class _GuardianActivityLogScreenState extends State<GuardianActivityLogScreen> {
|
||||
List<_LogItem> _items = [];
|
||||
List<_LogItem> _filtered = [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
String _selectedFilter = 'ALL';
|
||||
bool _needsPairing = false;
|
||||
|
||||
static const _filters = [
|
||||
'ALL',
|
||||
'WALKGUIDE',
|
||||
'SOS',
|
||||
'AUTH',
|
||||
'OBSTACLE',
|
||||
'LOCATION',
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
_needsPairing = false;
|
||||
});
|
||||
try {
|
||||
// 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(() {
|
||||
_items = items;
|
||||
_applyFilter(_selectedFilter);
|
||||
_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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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(
|
||||
'User Logs',
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: const Color(0xFF0F172A),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_needsPairing
|
||||
? 'Pairing dulu untuk melihat log'
|
||||
: '${_items.length} aktivitas tercatat',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: const Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
tooltip: 'Refresh',
|
||||
color: const Color(0xFF64748B),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ── Filter chips ─────────────────────────────────────────────────
|
||||
if (!_needsPairing && !_loading && _error == null)
|
||||
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:
|
||||
const Color(0xFF1A56DB).withValues(alpha: 0.12),
|
||||
checkmarkColor: const Color(0xFF1A56DB),
|
||||
labelStyle: GoogleFonts.inter(
|
||||
color: selected
|
||||
? const Color(0xFF1A56DB)
|
||||
: const Color(0xFF64748B),
|
||||
fontWeight:
|
||||
selected ? FontWeight.w700 : FontWeight.normal,
|
||||
fontSize: 12,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
side: BorderSide(
|
||||
color: selected
|
||||
? const Color(0xFF1A56DB)
|
||||
: const Color(0xFFE2E8F0),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
if (!_needsPairing && !_loading && _error == null)
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Body ─────────────────────────────────────────────────────────
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _needsPairing
|
||||
? _buildNoPairingPanel()
|
||||
: _error != null
|
||||
? _buildErrorPanel()
|
||||
: _filtered.isEmpty
|
||||
? _buildEmptyPanel()
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
color: const Color(0xFF1A56DB),
|
||||
child: ListView.builder(
|
||||
itemCount: _filtered.length,
|
||||
itemBuilder: (ctx, i) =>
|
||||
_LogCard(item: _filtered[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNoPairingPanel() {
|
||||
return Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFFBEB),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFFDE68A)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.link_off, color: Color(0xFFD97706), size: 52),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'Belum Pairing',
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF92400E),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Hubungkan akun Guardian dengan User terlebih dahulu untuk melihat log aktivitas.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: const Color(0xFF92400E),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorPanel() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.wifi_off, size: 52, color: Color(0xFF94A3B8)),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
GoogleFonts.inter(fontSize: 13, color: const Color(0xFF64748B)),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
FilledButton.icon(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Coba lagi'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF1A56DB)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyPanel() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.history, size: 64, color: Color(0xFF94A3B8)),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
_selectedFilter == 'ALL'
|
||||
? 'Belum ada aktivitas'
|
||||
: 'Tidak ada aktivitas "$_selectedFilter"',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF94A3B8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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<String, dynamic> j) => _LogItem(
|
||||
id: (j['id'] as num?)?.toInt() ?? 0,
|
||||
logType: j['logType']?.toString() ?? 'UNKNOWN',
|
||||
description: j['description']?.toString(),
|
||||
createdAt:
|
||||
DateTime.tryParse(j['createdAt']?.toString() ?? '')?.toLocal() ??
|
||||
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 + connector line
|
||||
Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: meta.color.withValues(alpha: 0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(meta.icon, color: meta.color, size: 18),
|
||||
),
|
||||
Container(
|
||||
width: 1.5,
|
||||
height: 22,
|
||||
color: const Color(0xFFE2E8F0),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Content
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
meta.label,
|
||||
style: GoogleFonts.inter(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: meta.color,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatTime(item.createdAt),
|
||||
style: GoogleFonts.jetBrainsMono(
|
||||
color: const Color(0xFF94A3B8),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (item.description != null && item.description!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 3),
|
||||
child: Text(
|
||||
item.description!,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: const Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime dt) {
|
||||
final now = DateTime.now();
|
||||
if (dt.day == now.day && dt.month == now.month && dt.year == now.year) {
|
||||
return DateFormat('HH:mm').format(dt);
|
||||
}
|
||||
return DateFormat('dd MMM HH:mm').format(dt);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// LOG METADATA
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
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.toUpperCase()) {
|
||||
case 'LOGIN':
|
||||
return const _LogMeta(
|
||||
icon: Icons.login, color: Color(0xFF16A34A), label: 'Login');
|
||||
case 'LOGOUT':
|
||||
return const _LogMeta(
|
||||
icon: Icons.logout, color: Color(0xFF94A3B8), label: 'Logout');
|
||||
case 'APP_OPEN':
|
||||
return const _LogMeta(
|
||||
icon: Icons.open_in_new,
|
||||
color: Color(0xFF1A56DB),
|
||||
label: 'App Dibuka');
|
||||
case 'APP_CLOSE':
|
||||
return const _LogMeta(
|
||||
icon: Icons.close, color: Color(0xFF94A3B8), label: 'App Ditutup');
|
||||
case 'WALKGUIDE_START':
|
||||
return const _LogMeta(
|
||||
icon: Icons.directions_walk,
|
||||
color: Color(0xFF1A56DB),
|
||||
label: 'WalkGuide Mulai');
|
||||
case 'WALKGUIDE_STOP':
|
||||
return const _LogMeta(
|
||||
icon: Icons.stop_circle,
|
||||
color: Color(0xFF94A3B8),
|
||||
label: 'WalkGuide Berhenti');
|
||||
case 'OBSTACLE_DETECTED':
|
||||
return const _LogMeta(
|
||||
icon: Icons.warning_amber,
|
||||
color: Color(0xFFD97706),
|
||||
label: 'Obstacle Terdeteksi');
|
||||
case 'SOS_TRIGGERED':
|
||||
return const _LogMeta(
|
||||
icon: Icons.sos, color: Color(0xFFDC2626), label: 'SOS Terkirim');
|
||||
case 'SOS_ACKNOWLEDGED':
|
||||
return const _LogMeta(
|
||||
icon: Icons.check_circle,
|
||||
color: Color(0xFF16A34A),
|
||||
label: 'SOS Diakui Guardian');
|
||||
case 'CALL_INITIATED':
|
||||
return const _LogMeta(
|
||||
icon: Icons.call,
|
||||
color: Color(0xFF16A34A),
|
||||
label: 'Panggilan Dimulai');
|
||||
case 'CALL_ENDED':
|
||||
return const _LogMeta(
|
||||
icon: Icons.call_end,
|
||||
color: Color(0xFF94A3B8),
|
||||
label: 'Panggilan Selesai');
|
||||
case 'LOCATION_UPDATE':
|
||||
return const _LogMeta(
|
||||
icon: Icons.location_on,
|
||||
color: Color(0xFF1A56DB),
|
||||
label: 'Lokasi Diperbarui');
|
||||
case 'GEOFENCE_EXIT':
|
||||
return const _LogMeta(
|
||||
icon: Icons.fence,
|
||||
color: Color(0xFFDC2626),
|
||||
label: 'Keluar Area Aman');
|
||||
case 'GEOFENCE_ENTER':
|
||||
return const _LogMeta(
|
||||
icon: Icons.home, color: Color(0xFF16A34A), label: 'Masuk Area Aman');
|
||||
default:
|
||||
return _LogMeta(
|
||||
icon: Icons.circle_outlined,
|
||||
color: const Color(0xFF94A3B8),
|
||||
label: logType);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,637 @@
|
||||
// lib/features/guardian_dashboard/guardian_ai_config_screen.dart
|
||||
// ignore_for_file: use_build_context_synchronously
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
import '../../../app/injection_container.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
|
||||
Dio get _api => sl<ApiClient>().dio;
|
||||
|
||||
class GuardianAiConfigScreen extends StatefulWidget {
|
||||
const GuardianAiConfigScreen({super.key});
|
||||
|
||||
@override
|
||||
State<GuardianAiConfigScreen> createState() => _GuardianAiConfigScreenState();
|
||||
}
|
||||
|
||||
class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
||||
bool _loading = true;
|
||||
bool _saving = false;
|
||||
String? _error;
|
||||
bool _needsPairing = false;
|
||||
|
||||
// Config values
|
||||
double _confidenceThreshold = 0.5;
|
||||
double _alertDistanceClose = 1.5;
|
||||
double _alertDistanceMedium = 3.0;
|
||||
int _maxInferenceFps = 5;
|
||||
String _enabledLabels = 'ALL';
|
||||
|
||||
static const _labelOptions = ['ALL', 'PERSON', 'VEHICLE', 'OBSTACLE'];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
_needsPairing = false;
|
||||
});
|
||||
try {
|
||||
final paired = await _hasActivePairing();
|
||||
if (!paired) {
|
||||
setState(() {
|
||||
_needsPairing = true;
|
||||
_loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final res = await _api
|
||||
.get('/guardian/ai-config')
|
||||
.timeout(const Duration(seconds: 8));
|
||||
final data = res.data['data'];
|
||||
if (data is Map) {
|
||||
setState(() {
|
||||
_confidenceThreshold =
|
||||
(data['confidenceThreshold'] as num?)?.toDouble() ?? 0.5;
|
||||
_alertDistanceClose =
|
||||
(data['alertDistanceClose'] as num?)?.toDouble() ?? 1.5;
|
||||
_alertDistanceMedium =
|
||||
(data['alertDistanceMedium'] as num?)?.toDouble() ?? 3.0;
|
||||
_maxInferenceFps = (data['maxInferenceFps'] as num?)?.toInt() ?? 5;
|
||||
_enabledLabels = data['enabledLabels']?.toString() ?? 'ALL';
|
||||
});
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
setState(() {
|
||||
_error = e.response?.data?['message']?.toString() ??
|
||||
'Gagal memuat konfigurasi AI.';
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _error = 'Timeout / error: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await _api.put('/guardian/ai-config', data: {
|
||||
'confidenceThreshold': _confidenceThreshold,
|
||||
'alertDistanceClose': _alertDistanceClose,
|
||||
'alertDistanceMedium': _alertDistanceMedium,
|
||||
'maxInferenceFps': _maxInferenceFps,
|
||||
'enabledLabels': _enabledLabels,
|
||||
}).timeout(const Duration(seconds: 8));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Konfigurasi AI berhasil disimpan'),
|
||||
backgroundColor: Color(0xFF16A34A),
|
||||
),
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e.response?.data?['message']?.toString() ??
|
||||
'Gagal menyimpan konfigurasi.'),
|
||||
backgroundColor: const Color(0xFFDC2626),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error: $e'),
|
||||
backgroundColor: const Color(0xFFDC2626),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@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(
|
||||
'AI Config',
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: const Color(0xFF0F172A),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Konfigurasi deteksi YOLO untuk User',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: const Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => context.go('/guardian/benchmark'),
|
||||
icon: const Icon(Icons.speed_outlined),
|
||||
tooltip: 'Benchmark',
|
||||
color: const Color(0xFF64748B),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _loading ? null : _load,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
tooltip: 'Refresh',
|
||||
color: const Color(0xFF64748B),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Body ─────────────────────────────────────────────────────────
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _needsPairing
|
||||
? _buildNoPairingPanel()
|
||||
: _error != null
|
||||
? _buildErrorPanel()
|
||||
: _buildConfigForm(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConfigForm() {
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Confidence Threshold ──────────────────────────────────────────
|
||||
_SectionCard(
|
||||
title: 'Confidence Threshold',
|
||||
subtitle:
|
||||
'Minimal keyakinan AI untuk menganggap objek sebagai obstacle',
|
||||
icon: Icons.tune_outlined,
|
||||
iconColor: const Color(0xFF1A56DB),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Nilai saat ini:',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13, color: const Color(0xFF64748B))),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A56DB).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
_confidenceThreshold.toStringAsFixed(2),
|
||||
style: GoogleFonts.jetBrainsMono(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF1A56DB),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Slider(
|
||||
value: _confidenceThreshold,
|
||||
min: 0.1,
|
||||
max: 0.9,
|
||||
divisions: 8,
|
||||
activeColor: const Color(0xFF1A56DB),
|
||||
onChanged: (v) => setState(() => _confidenceThreshold = v),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('0.1 (sensitif)',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 11, color: const Color(0xFF94A3B8))),
|
||||
Text('0.9 (ketat)',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 11, color: const Color(0xFF94A3B8))),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ── Alert Distances ───────────────────────────────────────────────
|
||||
_SectionCard(
|
||||
title: 'Jarak Peringatan',
|
||||
subtitle: 'Batas jarak (meter) untuk level peringatan',
|
||||
icon: Icons.radar_outlined,
|
||||
iconColor: const Color(0xFFD97706),
|
||||
child: Column(
|
||||
children: [
|
||||
// Close
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Color(0xFFDC2626),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text('Jarak Dekat',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13, color: const Color(0xFF0F172A))),
|
||||
]),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFDC2626).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'${_alertDistanceClose.toStringAsFixed(1)} m',
|
||||
style: GoogleFonts.jetBrainsMono(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFFDC2626),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Slider(
|
||||
value: _alertDistanceClose,
|
||||
min: 0.5,
|
||||
max: 3.0,
|
||||
divisions: 5,
|
||||
activeColor: const Color(0xFFDC2626),
|
||||
onChanged: (v) => setState(() => _alertDistanceClose = v),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Medium
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Color(0xFFD97706),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text('Jarak Sedang',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13, color: const Color(0xFF0F172A))),
|
||||
]),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFD97706).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'${_alertDistanceMedium.toStringAsFixed(1)} m',
|
||||
style: GoogleFonts.jetBrainsMono(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFFD97706),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Slider(
|
||||
value: _alertDistanceMedium,
|
||||
min: 1.0,
|
||||
max: 8.0,
|
||||
divisions: 7,
|
||||
activeColor: const Color(0xFFD97706),
|
||||
onChanged: (v) => setState(() => _alertDistanceMedium = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ── Max Inference FPS ─────────────────────────────────────────────
|
||||
_SectionCard(
|
||||
title: 'Max Inference FPS',
|
||||
subtitle:
|
||||
'Maksimal frame per detik untuk inferensi AI (lebih tinggi = lebih boros baterai)',
|
||||
icon: Icons.speed_outlined,
|
||||
iconColor: const Color(0xFF059669),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('FPS saat ini:',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13, color: const Color(0xFF64748B))),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF059669).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'$_maxInferenceFps fps',
|
||||
style: GoogleFonts.jetBrainsMono(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF059669),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Slider(
|
||||
value: _maxInferenceFps.toDouble(),
|
||||
min: 1,
|
||||
max: 30,
|
||||
divisions: 29,
|
||||
activeColor: const Color(0xFF059669),
|
||||
onChanged: (v) =>
|
||||
setState(() => _maxInferenceFps = v.toInt()),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('1 fps (hemat baterai)',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 11, color: const Color(0xFF94A3B8))),
|
||||
Text('30 fps (real-time)',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 11, color: const Color(0xFF94A3B8))),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ── Enabled Labels ────────────────────────────────────────────────
|
||||
_SectionCard(
|
||||
title: 'Label yang Diaktifkan',
|
||||
subtitle: 'Jenis objek yang akan dideteksi AI',
|
||||
icon: Icons.label_outline,
|
||||
iconColor: const Color(0xFF7C3AED),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _labelOptions.map((label) {
|
||||
final selected = _enabledLabels == label;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _enabledLabels = label),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? const Color(0xFF7C3AED) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? const Color(0xFF7C3AED)
|
||||
: const Color(0xFFE2E8F0),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color:
|
||||
selected ? Colors.white : const Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Save button ───────────────────────────────────────────────────
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _saving ? null : _save,
|
||||
icon: _saving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white))
|
||||
: const Icon(Icons.save_outlined),
|
||||
label: Text(_saving ? 'Menyimpan...' : 'Simpan Konfigurasi'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF1A56DB),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
textStyle: GoogleFonts.inter(
|
||||
fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNoPairingPanel() {
|
||||
return Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFFBEB),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFFDE68A)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.link_off, color: Color(0xFFD97706), size: 52),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'Belum Pairing',
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF92400E),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Hubungkan akun Guardian dengan User terlebih dahulu untuk mengatur konfigurasi AI.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13, color: const Color(0xFF92400E)),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorPanel() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.wifi_off, size: 52, color: Color(0xFF94A3B8)),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
GoogleFonts.inter(fontSize: 13, color: const Color(0xFF64748B)),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
FilledButton.icon(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Coba lagi'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF1A56DB)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SECTION CARD
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _SectionCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final Widget child;
|
||||
|
||||
const _SectionCard({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.03),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: iconColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, color: iconColor, size: 18),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF0F172A),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 11,
|
||||
color: const Color(0xFF94A3B8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(height: 1, color: Color(0xFFF1F5F9)),
|
||||
const SizedBox(height: 12),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,18 @@
|
||||
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 '../screens.dart'
|
||||
show
|
||||
GuardianDashboardScreen,
|
||||
GuardianMapScreen,
|
||||
GuardianActivityLogScreen,
|
||||
GuardianSendNotifScreen,
|
||||
GuardianAiConfigScreen,
|
||||
GuardianVoiceCmdScreen,
|
||||
GuardianShortcutScreen,
|
||||
GuardianGeofenceScreen;
|
||||
|
||||
@ -28,6 +28,8 @@ import '../core/services/tts_service.dart';
|
||||
import '../core/services/websocket_service.dart';
|
||||
import '../core/storage/secure_storage.dart';
|
||||
|
||||
export 'guardian_dashboard/guardian_screens.dart';
|
||||
|
||||
Dio get _api => sl<ApiClient>().dio;
|
||||
|
||||
class ServerConnectScreen extends StatefulWidget {
|
||||
@ -779,26 +781,12 @@ class IncomingCallScreen extends StatelessWidget {
|
||||
text: 'Accept or reject incoming guardian calls here.');
|
||||
}
|
||||
|
||||
class GuardianDashboardScreen extends StatelessWidget {
|
||||
const GuardianDashboardScreen({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context) => const _EndpointListScreen(
|
||||
title: 'Guardian Dashboard', endpoint: '/guardian/dashboard');
|
||||
}
|
||||
|
||||
class GuardianMapScreen extends StatelessWidget {
|
||||
const GuardianMapScreen({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context) => const _GuardianMapHistoryScreen();
|
||||
}
|
||||
|
||||
class GuardianActivityLogScreen extends StatelessWidget {
|
||||
const GuardianActivityLogScreen({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context) => const _EndpointListScreen(
|
||||
title: 'User Logs', endpoint: '/guardian/activity-logs');
|
||||
}
|
||||
|
||||
class GuardianSendNotifScreen extends StatefulWidget {
|
||||
const GuardianSendNotifScreen({super.key});
|
||||
|
||||
@ -850,23 +838,6 @@ class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class GuardianAiConfigScreen extends StatelessWidget {
|
||||
const GuardianAiConfigScreen({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _Page(
|
||||
title: 'AI Config',
|
||||
subtitle: '/guardian/ai-config',
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => context.go('/guardian/benchmark'),
|
||||
icon: const Icon(Icons.speed))
|
||||
],
|
||||
child: const _EndpointList(endpoint: '/guardian/ai-config'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GuardianVoiceCmdScreen extends StatelessWidget {
|
||||
const GuardianVoiceCmdScreen({super.key});
|
||||
@override
|
||||
@ -977,7 +948,7 @@ class _GuardianMapHistoryScreen extends StatelessWidget {
|
||||
child: _MapScreenBody(guardianEndpoint: '/guardian/user-location'),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Expanded(flex: 2, child: _LocationTimeline()),
|
||||
Expanded(flex: 2, child: ClipRect(child: _LocationTimeline())),
|
||||
],
|
||||
),
|
||||
);
|
||||
@ -1872,8 +1843,8 @@ class _EmptyPanel extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(minHeight: 180),
|
||||
padding: const EdgeInsets.all(18),
|
||||
constraints: const BoxConstraints(minHeight: 0),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@ -2196,15 +2167,17 @@ class _JsonCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(minHeight: 220),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0))),
|
||||
child: SingleChildScrollView(child: Text(data?.toString() ?? 'No data')),
|
||||
return Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0))),
|
||||
child:
|
||||
SingleChildScrollView(child: Text(data?.toString() ?? 'No data')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user