2026-05-17 18:40:03 +07:00

485 lines
16 KiB
Dart

// ignore_for_file: use_build_context_synchronously, deprecated_member_use, prefer_const_constructors
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import '../../app/injection_container.dart';
import '../../core/network/api_client.dart';
import '../../core/storage/secure_storage.dart';
// ---------------------------------------------------------------------------
// UserPairingScreen
// ---------------------------------------------------------------------------
//
// Ditampilkan ke akun ROLE_USER.
// - Tampilkan uniqueUserId mereka (besar, bisa di-copy/share).
// - Jika ada pending invite → tampilkan nama Guardian + tombol Accept / Reject.
// - Jika sudah paired → tampilkan info Guardian + tombol Unpair.
// ---------------------------------------------------------------------------
class UserPairingScreen extends StatefulWidget {
const UserPairingScreen({super.key});
@override
State<UserPairingScreen> createState() => _UserPairingScreenState();
}
class _UserPairingScreenState extends State<UserPairingScreen> {
String? _uniqueId;
@override
void initState() {
super.initState();
_loadUniqueId();
}
Future<void> _loadUniqueId() async {
var value = await sl<SecureStorage>().getUniqueUserId();
if (value == null || value.isEmpty) {
try {
final res = await sl<ApiClient>()
.dio
.get('/user/profile')
.timeout(const Duration(seconds: 5));
final data = res.data['data'];
if (data is Map) value = data['uniqueUserId']?.toString();
} catch (_) {}
}
if (mounted) setState(() => _uniqueId = value);
}
@override
Widget build(BuildContext context) {
return _Page(
title: 'Pairing',
subtitle: 'Bagikan Unique ID ini ke Guardian untuk terhubung.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_uniqueId == null || _uniqueId!.isEmpty)
_InfoCard(
title: 'Your Unique ID',
value: 'Login sebagai User untuk melihat ID',
icon: Icons.qr_code_2)
else
_InfoCard(
title: 'Your Unique ID',
value: _uniqueId!,
icon: Icons.qr_code_2),
const SizedBox(height: 16),
_PairingStatusCard(allowUserResponse: true),
],
),
);
}
}
// ---------------------------------------------------------------------------
// GuardianPairingScreen
// ---------------------------------------------------------------------------
//
// Ditampilkan ke akun ROLE_GUARDIAN.
// - Input field 12-char User ID.
// - Tombol "Send Invite".
// - Status card: jika sudah paired → info User + tombol Unpair.
// Jika pending → waiting state.
// ---------------------------------------------------------------------------
class GuardianPairingScreen extends StatefulWidget {
const GuardianPairingScreen({super.key});
@override
State<GuardianPairingScreen> createState() => _GuardianPairingScreenState();
}
class _GuardianPairingScreenState extends State<GuardianPairingScreen> {
final _id = TextEditingController();
bool _loading = false;
int _statusReload = 0;
Future<void> _invite() async {
final uniqueId = _id.text.trim().toUpperCase();
if (uniqueId.isEmpty || uniqueId.length != 12) {
_snack(context, 'Unique ID harus 12 karakter dari akun User.');
return;
}
setState(() => _loading = true);
try {
final res = await sl<ApiClient>().dio.post('/shared/pairing/invite',
data: {'uniqueUserId': uniqueId}).timeout(const Duration(seconds: 8));
_snack(
context,
res.data['message']?.toString() ??
'Invite terkirim. Minta User buka menu Pairing lalu Accept.');
setState(() => _statusReload++);
} on DioException catch (e) {
_snack(
context,
_friendlyDioMessage(e,
fallback:
'Invite gagal. Pastikan kamu login sebagai Guardian dan ID User benar.'));
} on TimeoutException {
_snack(context,
'Server terlalu lama merespons invite. Coba Refresh status, jangan klik berkali-kali.');
} catch (e) {
_snack(context, 'Invite gagal: $e');
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return _Page(
title: 'Pair User',
subtitle: 'Masukkan 12 karakter Unique ID milik User.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _id,
textCapitalization: TextCapitalization.characters,
maxLength: 12,
decoration: const InputDecoration(
labelText: 'Unique User ID',
hintText: 'Contoh: AB1C2D3E4F5G',
prefixIcon: Icon(Icons.link),
)),
FilledButton.icon(
onPressed: _loading ? null : _invite,
icon: _loading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.link),
label: Text(_loading ? 'Sending...' : 'Send Invite'),
),
const SizedBox(height: 20),
_PairingStatusCard(key: ValueKey(_statusReload)),
],
),
);
}
}
// ---------------------------------------------------------------------------
// _PairingStatusCard (shared between both screens)
// ---------------------------------------------------------------------------
class _PairingStatusCard extends StatefulWidget {
final bool allowUserResponse;
const _PairingStatusCard({super.key, this.allowUserResponse = false});
@override
State<_PairingStatusCard> createState() => _PairingStatusCardState();
}
class _PairingStatusCardState extends State<_PairingStatusCard> {
String _status = 'Mengecek status pairing...';
bool _active = false;
bool _loading = false;
bool _responding = false;
Map<String, dynamic>? _data;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final token = await sl<SecureStorage>().getAccessToken();
if (token == null || token.isEmpty) {
_active = false;
_data = null;
_status = 'Belum login. Login dulu supaya status pairing bisa dicek.';
return;
}
final res = await sl<ApiClient>()
.dio
.get('/shared/pairing/status')
.timeout(const Duration(seconds: 5));
final data = res.data['data'];
_data = data is Map ? Map<String, dynamic>.from(data) : null;
_active = data is Map && data['status'] == 'ACTIVE';
if (data is Map && data['status'] == 'ACTIVE') {
_active = true;
_status =
'Sudah pairing dengan ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'akun lain'}.';
} else if (data is Map && data['status'] == 'PENDING') {
_status = widget.allowUserResponse
? 'Ada undangan pairing dari ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'Guardian'}.'
: 'Invite sudah terkirim. Tunggu User membuka menu Pairing lalu Accept.';
} else {
_status = 'Belum pairing. Bagikan Unique ID kamu ke Guardian.';
}
} on DioException catch (e) {
_active = false;
_data = null;
_status = _friendlyDioMessage(e,
fallback:
'Belum bisa mengecek server, tapi Unique ID tetap bisa dibagikan.');
} on TimeoutException {
_active = false;
_data = null;
_status =
'Server terlalu lama merespons status pairing. Cek backend masih running dan URL server benar.';
} catch (e) {
_active = false;
_data = null;
_status = 'Status pairing belum bisa dicek: $e';
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _respond(bool accept) async {
final pairingId = _data?['pairingId'];
if (pairingId == null) {
_snack(context, 'Tidak ada invite yang bisa direspons.');
return;
}
setState(() => _responding = true);
try {
final res =
await sl<ApiClient>().dio.post('/shared/pairing/respond', data: {
'pairingId': pairingId,
'accept': accept,
}).timeout(const Duration(seconds: 8));
_snack(
context,
res.data['message']?.toString() ??
(accept ? 'Pairing diterima.' : 'Pairing ditolak.'));
await _load();
} on DioException catch (e) {
_snack(context,
_friendlyDioMessage(e, fallback: 'Gagal merespons pairing.'));
} on TimeoutException {
_snack(context, 'Server terlalu lama merespons pairing.');
} finally {
if (mounted) setState(() => _responding = false);
}
}
Future<void> _unpair() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Putus Pairing?'),
content: const Text(
'Semua konfigurasi voice command, shortcut, dan AI config akan dihapus.'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Batal')),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFDC2626)),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Putus')),
],
),
);
if (confirmed != true) return;
setState(() => _responding = true);
try {
await sl<ApiClient>()
.dio
.delete('/shared/pairing/unpair')
.timeout(const Duration(seconds: 8));
_snack(context, 'Pairing telah diputus.');
await _load();
} on DioException catch (e) {
_snack(
context, _friendlyDioMessage(e, fallback: 'Gagal memutus pairing.'));
} finally {
if (mounted) setState(() => _responding = false);
}
}
@override
Widget build(BuildContext context) {
final pending = _data?['status'] == 'PENDING';
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _active ? const Color(0xFFF0FDF4) : const Color(0xFFFFFBEB),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _active ? const Color(0xFFBBF7D0) : const Color(0xFFFDE68A)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(_active ? Icons.link : Icons.info_outline,
color: _active
? const Color(0xFF16A34A)
: const Color(0xFFD97706)),
const SizedBox(width: 12),
Expanded(child: Text(_status)),
IconButton(
onPressed: _loading ? null : _load,
icon: _loading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.refresh)),
],
),
if (widget.allowUserResponse && pending) ...[
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _responding ? null : () => _respond(true),
icon: const Icon(Icons.check),
label: const Text('Accept'),
),
),
const SizedBox(width: 10),
Expanded(
child: OutlinedButton.icon(
onPressed: _responding ? null : () => _respond(false),
icon: const Icon(Icons.close),
label: const Text('Reject'),
),
),
],
),
],
if (_active) ...[
const SizedBox(height: 12),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFDC2626),
side: const BorderSide(color: Color(0xFFDC2626))),
onPressed: _responding ? null : _unpair,
icon: const Icon(Icons.link_off),
label: const Text('Putus Pairing'),
),
],
],
),
);
}
}
// ---------------------------------------------------------------------------
// Shared private widgets
// ---------------------------------------------------------------------------
class _Page extends StatelessWidget {
final String title;
final String? subtitle;
final Widget child;
const _Page({required this.title, required this.child, this.subtitle});
@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))),
],
),
),
],
),
const SizedBox(height: 16),
Expanded(child: child),
],
),
),
);
}
}
class _InfoCard extends StatelessWidget {
final String title;
final String value;
final IconData icon;
const _InfoCard(
{required this.title, required this.value, required this.icon});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(12)),
child: Row(
children: [
Icon(icon, color: const Color(0xFF1A56DB)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title),
SelectableText(value,
style: const TextStyle(
fontSize: 22, fontWeight: FontWeight.w800))
])),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
void _snack(BuildContext context, String message) {
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
}
String _friendlyDioMessage(DioException e, {required String fallback}) {
final data = e.response?.data;
if (data is Map && data['message'] != null) return data['message'].toString();
if (e.response?.statusCode == 401) {
return 'Sesi login habis. Logout lalu login ulang.';
}
if (e.response?.statusCode == 403) {
return 'Role akun tidak cocok untuk fitur ini. Pastikan User/Guardian benar.';
}
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout) {
return 'Server terlalu lama merespons. Pastikan backend masih running dan URL server benar.';
}
if (e.type == DioExceptionType.connectionError) {
return 'Tidak bisa ke server. Di Chrome pakai http://localhost:8080. Di HP pakai IP laptop/server, bukan localhost.';
}
return fallback;
}