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
@@ -2,10 +2,10 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/storage/secure_storage.dart';
@@ -14,7 +14,7 @@ import '../../core/storage/secure_storage.dart';
// ---------------------------------------------------------------------------
//
// Ditampilkan ke akun ROLE_USER.
// - Tampilkan uniqueUserId mereka (besar, bisa di-copy/share).
// - Tampilkan pairing code sementara yang bisa di-copy/share.
// - Jika ada pending invite → tampilkan nama Guardian + tombol Accept / Reject.
// - Jika sudah paired → tampilkan info Guardian + tombol Unpair.
// ---------------------------------------------------------------------------
@@ -28,6 +28,10 @@ class UserPairingScreen extends StatefulWidget {
class _UserPairingScreenState extends State<UserPairingScreen> {
String? _uniqueId;
String? _pairingCode;
DateTime? _pairingCodeExpiresAt;
int? _pairingCodeSeconds;
bool _regenerating = false;
@override
void initState() {
@@ -38,36 +42,87 @@ class _UserPairingScreenState extends State<UserPairingScreen> {
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 (_) {}
await runFriendlyAction(
() async {
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();
},
onError: (_) {},
fallback: 'Profil belum bisa dimuat.',
);
}
if (mounted) setState(() => _uniqueId = value);
}
Future<void> _regeneratePairingCode() async {
setState(() => _regenerating = true);
await runFriendlyAction(
() async {
final res = await sl<ApiClient>()
.dio
.post('/shared/pairing/code/regenerate')
.timeout(const Duration(seconds: 8));
_applyPairingCode(res.data['data']);
_snack(context, 'Pairing code baru sudah dibuat.');
},
onError: (message) => _snack(context, message),
fallback: 'Gagal membuat pairing code baru.',
);
if (mounted) setState(() => _regenerating = false);
}
void _applyPairingCode(dynamic raw) {
if (raw is! Map) return;
final expires = DateTime.tryParse(raw['expiresAt']?.toString() ?? '');
setState(() {
_pairingCode = raw['pairingCode']?.toString();
_pairingCodeExpiresAt = expires;
_pairingCodeSeconds = int.tryParse(raw['expiresInSeconds'].toString());
});
}
@override
Widget build(BuildContext context) {
return _Page(
title: 'Pairing',
subtitle: 'Bagikan Unique ID ini ke Guardian untuk terhubung.',
subtitle: 'Bagikan pairing code sementara ini ke Guardian.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_uniqueId == null || _uniqueId!.isEmpty)
if (_pairingCode == null || _pairingCode!.isEmpty)
_InfoCard(
title: 'Your Unique ID',
value: 'Login sebagai User untuk melihat ID',
icon: Icons.qr_code_2)
title: 'Pairing Code',
value: 'Tap Generate',
icon: Icons.qr_code_2,
helper:
'Kode dibuat saat dibutuhkan, berlaku sementara, dan bisa dibuat ulang kapan saja.')
else
_InfoCard(
title: 'Your Unique ID',
value: _uniqueId!,
icon: Icons.qr_code_2),
title: 'Pairing Code',
value: _pairingCode!,
icon: Icons.qr_code_2,
helper:
'Valid ${_formatRemaining(_pairingCodeSeconds, _pairingCodeExpiresAt)}. Kode ini akan berubah dan kadaluarsa otomatis.'),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: _regenerating ? null : _regeneratePairingCode,
icon: _regenerating
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.autorenew),
label: Text(_regenerating ? 'Generating...' : 'Generate New Code'),
),
if (_uniqueId != null && _uniqueId!.isNotEmpty) ...[
const SizedBox(height: 8),
Text('Account ID: $_uniqueId',
style: const TextStyle(color: Color(0xFF64748B), fontSize: 12)),
],
const SizedBox(height: 16),
_PairingStatusCard(allowUserResponse: true),
],
@@ -81,7 +136,7 @@ class _UserPairingScreenState extends State<UserPairingScreen> {
// ---------------------------------------------------------------------------
//
// Ditampilkan ke akun ROLE_GUARDIAN.
// - Input field 12-char User ID.
// - Input field 8-char temporary pairing code.
// - Tombol "Send Invite".
// - Status card: jika sudah paired → info User + tombol Unpair.
// Jika pending → waiting state.
@@ -100,51 +155,46 @@ class _GuardianPairingScreenState extends State<GuardianPairingScreen> {
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.');
final pairingCode = _id.text.trim().toUpperCase();
if (pairingCode.isEmpty || pairingCode.length != 8) {
_snack(context, 'Pairing code harus 8 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);
}
await runFriendlyAction(
() async {
final res = await sl<ApiClient>().dio.post('/shared/pairing/invite',
data: {
'pairingCode': pairingCode
}).timeout(const Duration(seconds: 8));
_snack(
context,
res.data['message']?.toString() ??
'Invite terkirim. Minta User buka menu Pairing lalu Accept.');
setState(() => _statusReload++);
},
onError: (message) => _snack(context, message),
fallback:
'Invite gagal. Pastikan kamu login sebagai Guardian dan pairing code benar.',
);
if (mounted) setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
return _Page(
title: 'Pair User',
subtitle: 'Masukkan 12 karakter Unique ID milik User.',
subtitle: 'Masukkan 8 karakter pairing code aktif dari User.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _id,
textCapitalization: TextCapitalization.characters,
maxLength: 12,
maxLength: 8,
decoration: const InputDecoration(
labelText: 'Unique User ID',
hintText: 'Contoh: AB1C2D3E4F5G',
labelText: 'Pairing Code',
hintText: 'Contoh: A7K9Q2M4',
prefixIcon: Icon(Icons.link),
)),
FilledButton.icon(
@@ -192,50 +242,42 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
Future<void> _load() async {
setState(() => _loading = true);
try {
final token = await sl<SecureStorage>().getAccessToken();
if (token == null || token.isEmpty) {
await runFriendlyAction(
() async {
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 pairing code aktif ke Guardian.';
}
},
onError: (message) {
_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);
}
_status = message;
},
fallback: 'Status pairing belum bisa dicek. Coba refresh lagi.',
);
if (mounted) setState(() => _loading = false);
}
Future<void> _respond(bool accept) async {
@@ -245,25 +287,23 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
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);
}
await runFriendlyAction(
() async {
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();
},
onError: (message) => _snack(context, message),
fallback: 'Gagal merespons pairing.',
);
if (mounted) setState(() => _responding = false);
}
Future<void> _unpair() async {
@@ -287,19 +327,19 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
);
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);
}
await runFriendlyAction(
() async {
await sl<ApiClient>()
.dio
.delete('/shared/pairing/unpair')
.timeout(const Duration(seconds: 8));
_snack(context, 'Pairing telah diputus.');
await _load();
},
onError: (message) => _snack(context, message),
fallback: 'Gagal memutus pairing.',
);
if (mounted) setState(() => _responding = false);
}
@override
@@ -424,8 +464,12 @@ class _InfoCard extends StatelessWidget {
final String title;
final String value;
final IconData icon;
final String? helper;
const _InfoCard(
{required this.title, required this.value, required this.icon});
{required this.title,
required this.value,
required this.icon,
this.helper});
@override
Widget build(BuildContext context) {
@@ -445,7 +489,13 @@ class _InfoCard extends StatelessWidget {
Text(title),
SelectableText(value,
style: const TextStyle(
fontSize: 22, fontWeight: FontWeight.w800))
fontSize: 22, fontWeight: FontWeight.w800)),
if (helper != null) ...[
const SizedBox(height: 4),
Text(helper!,
style: const TextStyle(
color: Color(0xFF64748B), fontSize: 12)),
],
])),
],
),
@@ -464,21 +514,11 @@ void _snack(BuildContext context, String 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;
String _formatRemaining(int? seconds, DateTime? expiresAt) {
final value = seconds ?? expiresAt?.difference(DateTime.now()).inSeconds;
if (value == null || value <= 0) return 'sudah kadaluarsa';
final minutes = value ~/ 60;
final secs = value % 60;
if (minutes <= 0) return '$secs detik';
return '$minutes menit ${secs.toString().padLeft(2, '0')} detik';
}