485 lines
16 KiB
Dart
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;
|
|
}
|