584 lines
20 KiB
Dart

// ignore_for_file: use_build_context_synchronously, deprecated_member_use, prefer_const_constructors
import 'dart:async';
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';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_decorations.dart';
import '../../core/theme/app_text_styles.dart';
import '../../shared/widgets/animations/animations.dart';
// ---------------------------------------------------------------------------
// UserPairingScreen
// ---------------------------------------------------------------------------
//
// Ditampilkan ke akun ROLE_USER.
// - 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.
// ---------------------------------------------------------------------------
class UserPairingScreen extends StatefulWidget {
const UserPairingScreen({super.key});
@override
State<UserPairingScreen> createState() => _UserPairingScreenState();
}
class _UserPairingScreenState extends State<UserPairingScreen> {
String? _pairingCode;
DateTime? _pairingCodeExpiresAt;
int? _pairingCodeSeconds;
bool _regenerating = false;
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 pairing code sementara ini ke Guardian.',
child: StaggerWrapper(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_pairingCode == null || _pairingCode!.isEmpty)
_InfoCard(
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: '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'),
),
const SizedBox(height: 16),
_PairingStatusCard(allowUserResponse: true),
],
),
);
}
}
// ---------------------------------------------------------------------------
// GuardianPairingScreen
// ---------------------------------------------------------------------------
//
// Ditampilkan ke akun ROLE_GUARDIAN.
// - Input field 8-char temporary pairing code.
// - 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 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);
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 8 karakter pairing code aktif dari User.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _id,
textCapitalization: TextCapitalization.characters,
maxLength: 8,
decoration: const InputDecoration(
labelText: 'Pairing Code',
hintText: 'Contoh: A7K9Q2M4',
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);
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 = message;
},
fallback: 'Status pairing belum bisa dicek. Coba refresh lagi.',
);
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);
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 {
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);
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
Widget build(BuildContext context) {
final pending = _data?['status'] == 'PENDING';
final cardColor = _active
? const Color(0xFFF0FDF4)
: pending
? AppColors.softBlueBg
: const Color(0xFFFFFBEB);
final accent = _active
? const Color(0xFF059669)
: pending
? AppColors.primaryBlue
: const Color(0xFFD97706);
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: accent.withValues(alpha: 0.28)),
boxShadow: AppDecorations.cardShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
_active
? Icons.verified_user_outlined
: pending
? Icons.mark_email_unread_outlined
: Icons.info_outline,
color: accent),
),
const SizedBox(width: 12),
Expanded(
child: Text(_status,
style: const TextStyle(
color: AppColors.textDark,
fontWeight: FontWeight.w700,
height: 1.25)),
),
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: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.softBlueBg, Colors.white],
),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TweenAnimationBuilder<double>(
tween: Tween(begin: 14, end: 0),
duration: const Duration(milliseconds: 360),
curve: Curves.easeOutCubic,
builder: (_, offset, child) => Opacity(
opacity: (1 - offset / 14).clamp(0.0, 1.0),
child: Transform.translate(
offset: Offset(0, offset), child: child),
),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
gradient: AppDecorations.blueGradient,
borderRadius: BorderRadius.circular(24),
boxShadow: AppDecorations.cardShadow,
),
child: Row(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color:
const Color(0xFF38BDF8).withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF38BDF8)),
),
child: const Icon(Icons.hub_outlined,
color: Color(0xFFBAE6FD), size: 28),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: AppTextStyles.heading.copyWith(
fontSize: 24,
fontWeight: FontWeight.w900,
color: Colors.white,
)),
if (subtitle != null)
Text(subtitle!,
style: const TextStyle(
color: Color(0xFFCBD5E1), height: 1.25)),
],
),
),
],
),
),
),
const SizedBox(height: 16),
Expanded(child: FadeSlideWrapper(child: child)),
],
),
),
),
);
}
}
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,
this.helper});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: AppDecorations.cardShadow,
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.softBlueBg,
borderRadius: BorderRadius.circular(50),
),
child: Icon(icon, color: AppColors.primaryBlue),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(
color: AppColors.muted, fontWeight: FontWeight.w700)),
SelectableText(value,
style: const TextStyle(
fontSize: 25,
height: 1.1,
letterSpacing: 1.2,
fontWeight: FontWeight.w900,
color: AppColors.textDark)),
if (helper != null) ...[
const SizedBox(height: 6),
Text(helper!,
style: const TextStyle(
color: AppColors.muted, fontSize: 12)),
],
])),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
void _snack(BuildContext context, String message) {
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
}
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';
}