584 lines
20 KiB
Dart
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';
|
|
}
|