Update UI + Agora Call
This commit is contained in:
@@ -12,6 +12,7 @@ import '../../app/injection_container.dart';
|
||||
import '../../core/ai/detection_export.dart';
|
||||
import '../../core/ai/obstacle_alert_strategy.dart';
|
||||
import '../../core/errors/friendly_error.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/services/location_reporter_service.dart';
|
||||
import '../../core/services/tts_service.dart';
|
||||
import 'application/walk_guide_cubit.dart';
|
||||
@@ -27,10 +28,15 @@ class WalkGuideScreen extends StatefulWidget {
|
||||
State<WalkGuideScreen> createState() => _WalkGuideScreenState();
|
||||
}
|
||||
|
||||
class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
class _WalkGuideScreenState extends State<WalkGuideScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final WalkGuideCubit _cubit;
|
||||
late final AnimationController _scanCtrl;
|
||||
CameraController? _camera;
|
||||
bool _processingFrame = false;
|
||||
bool _pairingLoading = true;
|
||||
bool _paired = false;
|
||||
String? _pairedName;
|
||||
DateTime _lastInferenceAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
DateTime _lastAlertAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
DateTime _lastModelWarningAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
@@ -39,6 +45,11 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cubit = sl<WalkGuideCubit>();
|
||||
_scanCtrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2200),
|
||||
)..repeat();
|
||||
_loadPairingStatus();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -49,6 +60,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
}
|
||||
_camera?.dispose();
|
||||
sl<LocationReporterService>().stop();
|
||||
_scanCtrl.dispose();
|
||||
_cubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -56,6 +68,8 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
Future<void> _toggle() async {
|
||||
final next = !_cubit.state.active;
|
||||
if (next) {
|
||||
final paired = await _ensurePaired();
|
||||
if (!paired) return;
|
||||
await _startCamera();
|
||||
await sl<LocationReporterService>().start(walkGuideActive: true);
|
||||
await _cubit.start();
|
||||
@@ -69,6 +83,48 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
sl<TtsService>().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan');
|
||||
}
|
||||
|
||||
Future<void> _loadPairingStatus() async {
|
||||
await runFriendlyAction(
|
||||
() async {
|
||||
final res = await sl<ApiClient>()
|
||||
.dio
|
||||
.get('/shared/pairing/status')
|
||||
.timeout(const Duration(seconds: 6));
|
||||
final data = res.data['data'];
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_paired = data is Map && data['status'] == 'ACTIVE';
|
||||
_pairedName = data is Map ? data['pairedWithName']?.toString() : null;
|
||||
_pairingLoading = false;
|
||||
});
|
||||
},
|
||||
onError: (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _pairingLoading = false);
|
||||
},
|
||||
fallback: 'Status pairing belum bisa dicek.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _ensurePaired() async {
|
||||
if (_paired) return true;
|
||||
await _loadPairingStatus();
|
||||
if (_paired) return true;
|
||||
if (!mounted) return false;
|
||||
sl<TtsService>().speak('Hubungkan Guardian terlebih dahulu.');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text(
|
||||
'WalkGuide, SOS, dan panggilan aktif setelah pairing dengan Guardian.'),
|
||||
action: SnackBarAction(
|
||||
label: 'Pairing',
|
||||
onPressed: () => context.go('/user/pairing'),
|
||||
),
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
String _activeStatusText() {
|
||||
final detector = sl<YoloDetector>();
|
||||
if (kIsWeb) {
|
||||
@@ -86,33 +142,33 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
if (_camera != null) return;
|
||||
await runFriendlyAction(
|
||||
() async {
|
||||
final cameras = await availableCameras();
|
||||
if (cameras.isEmpty) return;
|
||||
final backCamera = cameras.firstWhere(
|
||||
(camera) => camera.lensDirection == CameraLensDirection.back,
|
||||
orElse: () => cameras.first,
|
||||
);
|
||||
final controller = CameraController(
|
||||
backCamera,
|
||||
ResolutionPreset.medium,
|
||||
enableAudio: false,
|
||||
imageFormatGroup: ImageFormatGroup.yuv420,
|
||||
);
|
||||
await controller.initialize();
|
||||
if (!mounted) {
|
||||
await controller.dispose();
|
||||
return;
|
||||
}
|
||||
await runFriendlyAction(
|
||||
() => controller.startImageStream(_onCameraImage),
|
||||
onError: (_) {
|
||||
_cubit.updateStatus(kIsWeb
|
||||
? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.'
|
||||
: 'Camera preview aktif, tapi image stream belum tersedia.');
|
||||
},
|
||||
fallback: 'Camera preview aktif, tapi image stream belum tersedia.',
|
||||
);
|
||||
setState(() => _camera = controller);
|
||||
final cameras = await availableCameras();
|
||||
if (cameras.isEmpty) return;
|
||||
final backCamera = cameras.firstWhere(
|
||||
(camera) => camera.lensDirection == CameraLensDirection.back,
|
||||
orElse: () => cameras.first,
|
||||
);
|
||||
final controller = CameraController(
|
||||
backCamera,
|
||||
ResolutionPreset.medium,
|
||||
enableAudio: false,
|
||||
imageFormatGroup: ImageFormatGroup.yuv420,
|
||||
);
|
||||
await controller.initialize();
|
||||
if (!mounted) {
|
||||
await controller.dispose();
|
||||
return;
|
||||
}
|
||||
await runFriendlyAction(
|
||||
() => controller.startImageStream(_onCameraImage),
|
||||
onError: (_) {
|
||||
_cubit.updateStatus(kIsWeb
|
||||
? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.'
|
||||
: 'Camera preview aktif, tapi image stream belum tersedia.');
|
||||
},
|
||||
fallback: 'Camera preview aktif, tapi image stream belum tersedia.',
|
||||
);
|
||||
setState(() => _camera = controller);
|
||||
},
|
||||
onError: (_) => _cubit.updateStatus('Camera unavailable.'),
|
||||
fallback: 'Camera unavailable.',
|
||||
@@ -190,7 +246,9 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
bloc: _cubit,
|
||||
builder: (context, state) => _Page(
|
||||
title: 'WalkGuide',
|
||||
subtitle: 'On-device AI detection surface',
|
||||
subtitle: _paired
|
||||
? 'Connected to ${_pairedName ?? 'Guardian'}'
|
||||
: 'Pair with Guardian to unlock live protection',
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => context.go('/user/benchmark'),
|
||||
@@ -202,64 +260,52 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0F172A),
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (_camera != null && _camera!.value.isInitialized)
|
||||
Positioned.fill(child: CameraPreview(_camera!))
|
||||
else
|
||||
const Center(
|
||||
child: Icon(Icons.videocam_outlined,
|
||||
color: Colors.white30, size: 96)),
|
||||
if (state.latestDetection?.box != null)
|
||||
Positioned.fill(
|
||||
child: CustomPaint(
|
||||
painter:
|
||||
_DetectionOverlayPainter(state.latestDetection!),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 16,
|
||||
left: 16,
|
||||
child: _Pill(
|
||||
text: state.active ? 'AI ACTIVE' : 'STANDBY',
|
||||
color:
|
||||
state.active ? Colors.green : Colors.orange)),
|
||||
if (state.latestDetection != null)
|
||||
Positioned(
|
||||
top: 64,
|
||||
left: 16,
|
||||
child: _Pill(
|
||||
text:
|
||||
'${ObstacleAnalyzer.spokenLabel(state.latestDetection!.label)} ${state.latestDetection!.directionName}',
|
||||
color: Colors.redAccent),
|
||||
),
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: Text(state.status,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700))),
|
||||
],
|
||||
),
|
||||
child: _VisionPanel(
|
||||
state: state,
|
||||
camera: _camera,
|
||||
scanCtrl: _scanCtrl,
|
||||
paired: _paired,
|
||||
pairingLoading: _pairingLoading,
|
||||
onPairingTap: () => context.go('/user/pairing'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_StatusStrip(
|
||||
active: state.active,
|
||||
paired: _paired,
|
||||
latestDetection: state.latestDetection,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _toggle,
|
||||
icon:
|
||||
Icon(state.active ? Icons.stop : Icons.play_arrow),
|
||||
label: Text(state.active ? 'Stop' : 'Start'))),
|
||||
flex: 2,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _pairingLoading ? null : _toggle,
|
||||
icon: Icon(state.active ? Icons.stop : Icons.play_arrow),
|
||||
label: Text(state.active ? 'Stop Scan' : 'Start Scan'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
_ActionSquare(
|
||||
icon: Icons.sos_outlined,
|
||||
color: const Color(0xFFDC2626),
|
||||
onTap: () async {
|
||||
if (await _ensurePaired() && context.mounted) {
|
||||
context.go('/user/sos');
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
_ActionSquare(
|
||||
icon: Icons.call_outlined,
|
||||
color: const Color(0xFF059669),
|
||||
onTap: () async {
|
||||
if (await _ensurePaired() && context.mounted) {
|
||||
context.go('/user/call');
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -269,6 +315,413 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _VisionPanel extends StatelessWidget {
|
||||
final WalkGuideState state;
|
||||
final CameraController? camera;
|
||||
final AnimationController scanCtrl;
|
||||
final bool paired;
|
||||
final bool pairingLoading;
|
||||
final VoidCallback onPairingTap;
|
||||
|
||||
const _VisionPanel({
|
||||
required this.state,
|
||||
required this.camera,
|
||||
required this.scanCtrl,
|
||||
required this.paired,
|
||||
required this.pairingLoading,
|
||||
required this.onPairingTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cameraReady = camera != null && camera!.value.isInitialized;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
child: DecoratedBox(
|
||||
decoration: const BoxDecoration(color: Color(0xFF07111F)),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: cameraReady
|
||||
? CameraPreview(camera!)
|
||||
: const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Color(0xFF07111F),
|
||||
Color(0xFF0E2A3D),
|
||||
Color(0xFF111827),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(child: CustomPaint(painter: _HudGridPainter())),
|
||||
if (state.active)
|
||||
AnimatedBuilder(
|
||||
animation: scanCtrl,
|
||||
builder: (_, __) => Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 28 +
|
||||
(MediaQuery.of(context).size.height *
|
||||
0.38 *
|
||||
scanCtrl.value),
|
||||
child: Container(
|
||||
height: 3,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF22D3EE).withValues(alpha: 0.8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color:
|
||||
const Color(0xFF22D3EE).withValues(alpha: 0.45),
|
||||
blurRadius: 22,
|
||||
spreadRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state.latestDetection?.box != null)
|
||||
Positioned.fill(
|
||||
child: CustomPaint(
|
||||
painter: _DetectionOverlayPainter(state.latestDetection!),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 18,
|
||||
left: 18,
|
||||
right: 18,
|
||||
child: Row(
|
||||
children: [
|
||||
_Pill(
|
||||
text: state.active ? 'LIVE AI SCAN' : 'STANDBY',
|
||||
color: state.active
|
||||
? const Color(0xFF22C55E)
|
||||
: const Color(0xFFF59E0B),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_Pill(
|
||||
text: paired ? 'GUARDIAN LINKED' : 'PAIRING REQUIRED',
|
||||
color: paired
|
||||
? const Color(0xFF38BDF8)
|
||||
: const Color(0xFFF97316),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: AnimatedScale(
|
||||
duration: const Duration(milliseconds: 320),
|
||||
scale: state.active ? 1.0 : 0.92,
|
||||
child: Container(
|
||||
width: 118,
|
||||
height: 118,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black.withValues(alpha: 0.28),
|
||||
border: Border.all(
|
||||
color: (state.active
|
||||
? const Color(0xFF22D3EE)
|
||||
: Colors.white)
|
||||
.withValues(alpha: 0.34),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
cameraReady
|
||||
? Icons.center_focus_strong
|
||||
: Icons.videocam_off,
|
||||
color: Colors.white.withValues(alpha: 0.68),
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!paired && !pairingLoading)
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF020617).withValues(alpha: 0.72),
|
||||
),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 70,
|
||||
height: 70,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFFBEB)
|
||||
.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border:
|
||||
Border.all(color: const Color(0xFFF59E0B)),
|
||||
),
|
||||
child: const Icon(Icons.link_off,
|
||||
color: Color(0xFFFBBF24), size: 34),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const Text(
|
||||
'Guardian belum terhubung',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'Pairing dulu supaya SOS, panggilan, dan pemantauan live punya penerima yang jelas.',
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
TextStyle(color: Colors.white70, height: 1.35),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: onPairingTap,
|
||||
icon: const Icon(Icons.link),
|
||||
label: const Text('Buka Pairing'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 18,
|
||||
right: 18,
|
||||
bottom: 18,
|
||||
child: _GlassStatusBar(
|
||||
status: state.status,
|
||||
detection: state.latestDetection,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GlassStatusBar extends StatelessWidget {
|
||||
final String status;
|
||||
final DetectionResult? detection;
|
||||
|
||||
const _GlassStatusBar({required this.status, required this.detection});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final label = detection == null
|
||||
? status
|
||||
: '${ObstacleAnalyzer.spokenLabel(detection!.label)} detected ${detection!.directionName}';
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.42),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.16)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
detection == null ? Icons.sensors : Icons.warning_amber_rounded,
|
||||
color: detection == null
|
||||
? const Color(0xFF93C5FD)
|
||||
: const Color(0xFFFBBF24),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusStrip extends StatelessWidget {
|
||||
final bool active;
|
||||
final bool paired;
|
||||
final DetectionResult? latestDetection;
|
||||
|
||||
const _StatusStrip({
|
||||
required this.active,
|
||||
required this.paired,
|
||||
required this.latestDetection,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MetricChip(
|
||||
icon: Icons.health_and_safety_outlined,
|
||||
label: 'Guardian',
|
||||
value: paired ? 'Linked' : 'Required',
|
||||
color: paired ? const Color(0xFF059669) : const Color(0xFFD97706),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _MetricChip(
|
||||
icon: Icons.radar_outlined,
|
||||
label: 'Detector',
|
||||
value: active ? 'Scanning' : 'Idle',
|
||||
color: active ? const Color(0xFF2563EB) : const Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _MetricChip(
|
||||
icon: Icons.visibility_outlined,
|
||||
label: 'Obstacle',
|
||||
value: latestDetection == null ? 'Clear' : 'Alert',
|
||||
color: latestDetection == null
|
||||
? const Color(0xFF64748B)
|
||||
: const Color(0xFFDC2626),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetricChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final Color color;
|
||||
|
||||
const _MetricChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 14,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
)),
|
||||
Text(value,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w900,
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionSquare extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ActionSquare({
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: SizedBox(
|
||||
width: 54,
|
||||
height: 50,
|
||||
child: Icon(icon, color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HudGridPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final line = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.045)
|
||||
..strokeWidth = 1;
|
||||
for (double x = 0; x < size.width; x += 42) {
|
||||
canvas.drawLine(Offset(x, 0), Offset(x, size.height), line);
|
||||
}
|
||||
for (double y = 0; y < size.height; y += 42) {
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), line);
|
||||
}
|
||||
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final ring = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.2
|
||||
..color = const Color(0xFF22D3EE).withValues(alpha: 0.16);
|
||||
for (final radius in [64.0, 112.0, 164.0]) {
|
||||
canvas.drawCircle(center, radius, ring);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class _DetectionOverlayPainter extends CustomPainter {
|
||||
final DetectionResult detection;
|
||||
const _DetectionOverlayPainter(this.detection);
|
||||
@@ -356,34 +809,67 @@ class _Page extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.w800)),
|
||||
if (subtitle != null)
|
||||
Text(subtitle!,
|
||||
style: const TextStyle(color: Color(0xFF64748B))),
|
||||
],
|
||||
child: DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFF8FAFC), Color(0xFFEFF6FF)],
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2563EB),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color:
|
||||
const Color(0xFF2563EB).withValues(alpha: 0.28),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(Icons.navigation_rounded,
|
||||
color: Colors.white, size: 26),
|
||||
),
|
||||
),
|
||||
...?actions,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(child: child),
|
||||
],
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
color: const Color(0xFF0F172A),
|
||||
)),
|
||||
if (subtitle != null)
|
||||
Text(subtitle!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Color(0xFF64748B))),
|
||||
],
|
||||
),
|
||||
),
|
||||
...?actions,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user