1018 lines
39 KiB
Dart
1018 lines
39 KiB
Dart
|
|
// lib/widgets/edit_transaction_dialog.dart
|
||
|
|
|
||
|
|
import 'package:flutter/material.dart';
|
||
|
|
import 'package:intl/intl.dart';
|
||
|
|
import 'package:url_launcher/url_launcher.dart';
|
||
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
||
|
|
import 'package:cashumit/models/report_transaction.dart';
|
||
|
|
import 'package:cashumit/services/tag_cache_service.dart';
|
||
|
|
import 'package:cashumit/services/firefly_api_service.dart';
|
||
|
|
|
||
|
|
class EditTransactionDialog extends StatefulWidget {
|
||
|
|
final ReportTransaction transaction;
|
||
|
|
final void Function(ReportTransaction updatedTx) onSaveLocal;
|
||
|
|
final Future<String?> Function(ReportTransaction updatedTx)? onSyncToServer;
|
||
|
|
final List<String> availableTags;
|
||
|
|
final String? fireflyUrl;
|
||
|
|
final String? accessToken;
|
||
|
|
|
||
|
|
const EditTransactionDialog({
|
||
|
|
super.key,
|
||
|
|
required this.transaction,
|
||
|
|
required this.onSaveLocal,
|
||
|
|
this.onSyncToServer,
|
||
|
|
this.availableTags = const [],
|
||
|
|
this.fireflyUrl,
|
||
|
|
this.accessToken,
|
||
|
|
});
|
||
|
|
|
||
|
|
@override
|
||
|
|
State<EditTransactionDialog> createState() => _EditTransactionDialogState();
|
||
|
|
}
|
||
|
|
|
||
|
|
class _EditTransactionDialogState extends State<EditTransactionDialog> {
|
||
|
|
final _formKey = GlobalKey<FormState>();
|
||
|
|
late DateTime _selectedDateTime;
|
||
|
|
late TextEditingController _descController;
|
||
|
|
late TextEditingController _amountController;
|
||
|
|
late TextEditingController _newTagController;
|
||
|
|
late FocusNode _newTagFocusNode;
|
||
|
|
late FocusNode _descFocusNode;
|
||
|
|
late FocusNode _amountFocusNode;
|
||
|
|
late List<String> _tags;
|
||
|
|
|
||
|
|
List<String> _availableTags = [];
|
||
|
|
bool _isLoadingTags = false;
|
||
|
|
String? _tagLoadError;
|
||
|
|
bool _showAllServerTags = false;
|
||
|
|
|
||
|
|
String? _savedFireflyUrl;
|
||
|
|
String? _savedToken;
|
||
|
|
|
||
|
|
bool _isSyncing = false;
|
||
|
|
String? _syncErrorMessage;
|
||
|
|
|
||
|
|
@override
|
||
|
|
void initState() {
|
||
|
|
super.initState();
|
||
|
|
_selectedDateTime = widget.transaction.date;
|
||
|
|
_descController = TextEditingController(text: widget.transaction.description);
|
||
|
|
_amountController = TextEditingController(
|
||
|
|
text: widget.transaction.amount % 1 == 0
|
||
|
|
? widget.transaction.amount.toInt().toString()
|
||
|
|
: widget.transaction.amount.toString(),
|
||
|
|
);
|
||
|
|
_newTagController = TextEditingController();
|
||
|
|
_newTagFocusNode = FocusNode();
|
||
|
|
_descFocusNode = FocusNode();
|
||
|
|
_amountFocusNode = FocusNode();
|
||
|
|
_tags = List<String>.from(widget.transaction.tags);
|
||
|
|
_availableTags = List<String>.from(widget.availableTags);
|
||
|
|
|
||
|
|
_initCredentialsAndTags();
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
void dispose() {
|
||
|
|
_descController.dispose();
|
||
|
|
_amountController.dispose();
|
||
|
|
_newTagController.dispose();
|
||
|
|
_newTagFocusNode.dispose();
|
||
|
|
_descFocusNode.dispose();
|
||
|
|
_amountFocusNode.dispose();
|
||
|
|
super.dispose();
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _initCredentialsAndTags() async {
|
||
|
|
String? url = widget.fireflyUrl;
|
||
|
|
String? token = widget.accessToken;
|
||
|
|
|
||
|
|
if (url == null || token == null) {
|
||
|
|
try {
|
||
|
|
final prefs = await SharedPreferences.getInstance();
|
||
|
|
url ??= prefs.getString('firefly_url');
|
||
|
|
token ??= prefs.getString('firefly_token');
|
||
|
|
if (mounted) {
|
||
|
|
setState(() {
|
||
|
|
_savedFireflyUrl = url;
|
||
|
|
_savedToken = token;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} catch (_) {}
|
||
|
|
}
|
||
|
|
|
||
|
|
_loadServerTags(url: url, token: token);
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _loadServerTags({String? url, String? token}) async {
|
||
|
|
final effectiveUrl = url ?? widget.fireflyUrl ?? _savedFireflyUrl;
|
||
|
|
final effectiveToken = token ?? widget.accessToken ?? _savedToken;
|
||
|
|
|
||
|
|
if (mounted) {
|
||
|
|
setState(() {
|
||
|
|
_isLoadingTags = true;
|
||
|
|
_tagLoadError = null;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
final tags = await TagCacheService.getTagsWithFallback(() async {
|
||
|
|
if (effectiveUrl != null &&
|
||
|
|
effectiveToken != null &&
|
||
|
|
effectiveUrl.isNotEmpty &&
|
||
|
|
effectiveToken.isNotEmpty) {
|
||
|
|
return await FireflyApiService.fetchTags(
|
||
|
|
baseUrl: effectiveUrl,
|
||
|
|
accessToken: effectiveToken,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return [];
|
||
|
|
});
|
||
|
|
|
||
|
|
if (mounted) {
|
||
|
|
setState(() {
|
||
|
|
final tagSet = <String>{..._availableTags, ...tags};
|
||
|
|
_availableTags = tagSet.toList()
|
||
|
|
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
|
||
|
|
_isLoadingTags = false;
|
||
|
|
_tagLoadError = null;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} catch (_) {
|
||
|
|
if (mounted) {
|
||
|
|
setState(() {
|
||
|
|
_isLoadingTags = false;
|
||
|
|
_tagLoadError = 'Gagal memuat saran tag dari server';
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
String? get _transactionUrl {
|
||
|
|
final baseUrl = (widget.fireflyUrl?.trim().isNotEmpty ?? false)
|
||
|
|
? widget.fireflyUrl!.trim()
|
||
|
|
: _savedFireflyUrl?.trim();
|
||
|
|
if (baseUrl == null || baseUrl.isEmpty) return null;
|
||
|
|
|
||
|
|
final txId = widget.transaction.transactionGroupId ?? widget.transaction.id;
|
||
|
|
if (txId.isEmpty) return null;
|
||
|
|
|
||
|
|
final cleanBase = baseUrl.endsWith('/')
|
||
|
|
? baseUrl.substring(0, baseUrl.length - 1)
|
||
|
|
: baseUrl;
|
||
|
|
|
||
|
|
return '$cleanBase/transactions/show/$txId';
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _openInBrowser(String url) async {
|
||
|
|
try {
|
||
|
|
final uri = Uri.parse(url);
|
||
|
|
final launched =
|
||
|
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||
|
|
if (!launched && mounted) {
|
||
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
||
|
|
const SnackBar(content: Text('Tidak dapat membuka browser')),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
if (mounted) {
|
||
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
||
|
|
SnackBar(content: Text('Gagal membuka link: $e')),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _pickDate() async {
|
||
|
|
final picked = await showDatePicker(
|
||
|
|
context: context,
|
||
|
|
initialDate: _selectedDateTime,
|
||
|
|
firstDate: DateTime(2020),
|
||
|
|
lastDate: DateTime(2035),
|
||
|
|
);
|
||
|
|
if (picked != null) {
|
||
|
|
setState(() {
|
||
|
|
_selectedDateTime = DateTime(
|
||
|
|
picked.year,
|
||
|
|
picked.month,
|
||
|
|
picked.day,
|
||
|
|
_selectedDateTime.hour,
|
||
|
|
_selectedDateTime.minute,
|
||
|
|
_selectedDateTime.second,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _pickTime() async {
|
||
|
|
final initialTime = TimeOfDay(
|
||
|
|
hour: _selectedDateTime.hour,
|
||
|
|
minute: _selectedDateTime.minute,
|
||
|
|
);
|
||
|
|
final picked = await showTimePicker(
|
||
|
|
context: context,
|
||
|
|
initialTime: initialTime,
|
||
|
|
);
|
||
|
|
if (picked != null) {
|
||
|
|
setState(() {
|
||
|
|
_selectedDateTime = DateTime(
|
||
|
|
_selectedDateTime.year,
|
||
|
|
_selectedDateTime.month,
|
||
|
|
_selectedDateTime.day,
|
||
|
|
picked.hour,
|
||
|
|
picked.minute,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
void _addTagValue(String val) {
|
||
|
|
final trimmed = val.trim();
|
||
|
|
if (trimmed.isNotEmpty &&
|
||
|
|
!_tags.any((t) => t.toLowerCase() == trimmed.toLowerCase())) {
|
||
|
|
setState(() {
|
||
|
|
_tags.add(trimmed);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
_newTagController.clear();
|
||
|
|
}
|
||
|
|
|
||
|
|
void _addTagFromInput() {
|
||
|
|
final text = _newTagController.text.trim();
|
||
|
|
if (text.isNotEmpty) {
|
||
|
|
_addTagValue(text);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
void _removeTag(String tag) {
|
||
|
|
setState(() {
|
||
|
|
_tags.remove(tag);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
ReportTransaction _buildUpdatedTransaction() {
|
||
|
|
final amountVal =
|
||
|
|
double.tryParse(_amountController.text.replaceAll(',', '.')) ??
|
||
|
|
widget.transaction.amount;
|
||
|
|
|
||
|
|
return widget.transaction.copyWith(
|
||
|
|
date: _selectedDateTime,
|
||
|
|
description: _descController.text.trim(),
|
||
|
|
amount: amountVal,
|
||
|
|
tags: _tags,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
void _handleSaveLocal() {
|
||
|
|
if (!_formKey.currentState!.validate()) return;
|
||
|
|
|
||
|
|
final updatedTx = _buildUpdatedTransaction();
|
||
|
|
widget.onSaveLocal(updatedTx);
|
||
|
|
Navigator.of(context).pop();
|
||
|
|
|
||
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
||
|
|
const SnackBar(
|
||
|
|
content: Text('Perubahan transaksi disimpan lokal'),
|
||
|
|
backgroundColor: Colors.teal,
|
||
|
|
duration: Duration(seconds: 2),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _handleSyncToServer() async {
|
||
|
|
if (!_formKey.currentState!.validate()) return;
|
||
|
|
|
||
|
|
setState(() {
|
||
|
|
_isSyncing = true;
|
||
|
|
_syncErrorMessage = null;
|
||
|
|
});
|
||
|
|
|
||
|
|
final updatedTx = _buildUpdatedTransaction();
|
||
|
|
|
||
|
|
if (widget.onSyncToServer != null) {
|
||
|
|
final error = await widget.onSyncToServer!(updatedTx);
|
||
|
|
if (!mounted) return;
|
||
|
|
|
||
|
|
if (error == null) {
|
||
|
|
widget.onSaveLocal(updatedTx);
|
||
|
|
Navigator.of(context).pop();
|
||
|
|
|
||
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
||
|
|
const SnackBar(
|
||
|
|
content: Text('Berhasil disinkronkan ke Firefly III'),
|
||
|
|
backgroundColor: Colors.green,
|
||
|
|
duration: Duration(seconds: 2),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
setState(() {
|
||
|
|
_isSyncing = false;
|
||
|
|
_syncErrorMessage = error;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
setState(() {
|
||
|
|
_isSyncing = false;
|
||
|
|
_syncErrorMessage = 'Layanan sinkronisasi server tidak tersedia';
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final theme = Theme.of(context);
|
||
|
|
final colorScheme = theme.colorScheme;
|
||
|
|
final isDark = theme.brightness == Brightness.dark;
|
||
|
|
|
||
|
|
final dateFormat = DateFormat('dd/MM/yyyy');
|
||
|
|
final timeFormat = DateFormat('HH:mm');
|
||
|
|
final isPengeluaran = widget.transaction.isPengeluaran;
|
||
|
|
final txUrl = _transactionUrl;
|
||
|
|
|
||
|
|
final expenseBg = isDark
|
||
|
|
? Colors.red.shade900.withOpacity(0.3)
|
||
|
|
: const Color(0xFFFEE2E2);
|
||
|
|
final expenseColor = isDark
|
||
|
|
? Colors.red.shade300
|
||
|
|
: const Color(0xFFDC2626);
|
||
|
|
|
||
|
|
final incomeBg = isDark
|
||
|
|
? Colors.green.shade900.withOpacity(0.3)
|
||
|
|
: const Color(0xFFDCFCE7);
|
||
|
|
final incomeColor = isDark
|
||
|
|
? Colors.green.shade300
|
||
|
|
: const Color(0xFF16A34A);
|
||
|
|
|
||
|
|
final bannerBg = isDark
|
||
|
|
? colorScheme.primaryContainer.withOpacity(0.2)
|
||
|
|
: const Color(0xFFF0F9FF);
|
||
|
|
final bannerBorder = isDark
|
||
|
|
? colorScheme.primary.withOpacity(0.3)
|
||
|
|
: const Color(0xFFBAE6FD);
|
||
|
|
final primaryAccent = isDark
|
||
|
|
? colorScheme.primary
|
||
|
|
: const Color(0xFF0284C7);
|
||
|
|
|
||
|
|
final chipBg = isDark
|
||
|
|
? colorScheme.surfaceContainerHighest
|
||
|
|
: const Color(0xFFF1F5F9);
|
||
|
|
final suggestionChipBg = isDark
|
||
|
|
? colorScheme.primaryContainer.withOpacity(0.25)
|
||
|
|
: const Color(0xFFE0F2FE);
|
||
|
|
final suggestionChipBorder = isDark
|
||
|
|
? colorScheme.primary.withOpacity(0.3)
|
||
|
|
: const Color(0xFFBAE6FD);
|
||
|
|
final suggestionChipTextColor = isDark
|
||
|
|
? colorScheme.primary
|
||
|
|
: const Color(0xFF0369A1);
|
||
|
|
|
||
|
|
return Dialog(
|
||
|
|
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||
|
|
clipBehavior: Clip.antiAlias,
|
||
|
|
child: ConstrainedBox(
|
||
|
|
constraints: BoxConstraints(
|
||
|
|
maxWidth: 480,
|
||
|
|
maxHeight: MediaQuery.of(context).size.height * 0.88,
|
||
|
|
),
|
||
|
|
child: SingleChildScrollView(
|
||
|
|
padding: const EdgeInsets.all(20),
|
||
|
|
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||
|
|
child: Form(
|
||
|
|
key: _formKey,
|
||
|
|
child: Column(
|
||
|
|
mainAxisSize: MainAxisSize.min,
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
// Header Dialog
|
||
|
|
Row(
|
||
|
|
children: [
|
||
|
|
Semantics(
|
||
|
|
label: isPengeluaran
|
||
|
|
? 'Transaksi Pengeluaran'
|
||
|
|
: 'Transaksi Pemasukan',
|
||
|
|
child: CircleAvatar(
|
||
|
|
radius: 18,
|
||
|
|
backgroundColor: isPengeluaran ? expenseBg : incomeBg,
|
||
|
|
child: Icon(
|
||
|
|
isPengeluaran
|
||
|
|
? Icons.arrow_upward_rounded
|
||
|
|
: Icons.arrow_downward_rounded,
|
||
|
|
size: 20,
|
||
|
|
color: isPengeluaran ? expenseColor : incomeColor,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 12),
|
||
|
|
Expanded(
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
const Text(
|
||
|
|
'Edit Transaksi',
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 16,
|
||
|
|
fontWeight: FontWeight.bold,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 2),
|
||
|
|
Text(
|
||
|
|
isPengeluaran
|
||
|
|
? 'Transaksi Pengeluaran'
|
||
|
|
: 'Transaksi Pemasukan',
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 12,
|
||
|
|
color: theme.hintColor,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
if (txUrl != null)
|
||
|
|
IconButton(
|
||
|
|
icon: Icon(Icons.open_in_browser,
|
||
|
|
size: 22, color: primaryAccent),
|
||
|
|
tooltip: 'Buka di Browser Firefly III',
|
||
|
|
onPressed: () => _openInBrowser(txUrl),
|
||
|
|
visualDensity: VisualDensity.compact,
|
||
|
|
),
|
||
|
|
IconButton(
|
||
|
|
icon: const Icon(Icons.close),
|
||
|
|
tooltip: 'Tutup',
|
||
|
|
onPressed: _isSyncing
|
||
|
|
? null
|
||
|
|
: () => Navigator.of(context).pop(),
|
||
|
|
visualDensity: VisualDensity.compact,
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
const Divider(height: 20),
|
||
|
|
|
||
|
|
// Tombol Buka di Browser (Banner Informatif)
|
||
|
|
if (txUrl != null) ...[
|
||
|
|
Semantics(
|
||
|
|
button: true,
|
||
|
|
label: 'Buka transaksi di browser Firefly III',
|
||
|
|
child: InkWell(
|
||
|
|
onTap: () => _openInBrowser(txUrl),
|
||
|
|
borderRadius: BorderRadius.circular(8),
|
||
|
|
child: Container(
|
||
|
|
padding: const EdgeInsets.symmetric(
|
||
|
|
horizontal: 10, vertical: 8),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: bannerBg,
|
||
|
|
borderRadius: BorderRadius.circular(8),
|
||
|
|
border: Border.all(color: bannerBorder),
|
||
|
|
),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
Icon(Icons.open_in_new,
|
||
|
|
size: 16, color: primaryAccent),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
Expanded(
|
||
|
|
child: Text(
|
||
|
|
'Buka transaksi di browser Firefly III',
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 12,
|
||
|
|
color: primaryAccent,
|
||
|
|
fontWeight: FontWeight.w600,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
Icon(Icons.arrow_forward_ios,
|
||
|
|
size: 12, color: theme.hintColor),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 14),
|
||
|
|
],
|
||
|
|
|
||
|
|
// Pilihan Tanggal & Waktu
|
||
|
|
Text(
|
||
|
|
'Tanggal & Waktu Transaksi',
|
||
|
|
style: theme.textTheme.labelMedium?.copyWith(
|
||
|
|
fontWeight: FontWeight.bold,
|
||
|
|
color: theme.colorScheme.onSurface,
|
||
|
|
) ??
|
||
|
|
const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 6),
|
||
|
|
Row(
|
||
|
|
children: [
|
||
|
|
// Tanggal
|
||
|
|
Expanded(
|
||
|
|
flex: 3,
|
||
|
|
child: OutlinedButton.icon(
|
||
|
|
style: OutlinedButton.styleFrom(
|
||
|
|
padding: const EdgeInsets.symmetric(
|
||
|
|
horizontal: 10, vertical: 12),
|
||
|
|
shape: RoundedRectangleBorder(
|
||
|
|
borderRadius: BorderRadius.circular(8),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
icon: Icon(Icons.calendar_today,
|
||
|
|
size: 16, color: primaryAccent),
|
||
|
|
label: Text(
|
||
|
|
dateFormat.format(_selectedDateTime),
|
||
|
|
style: const TextStyle(fontSize: 13),
|
||
|
|
),
|
||
|
|
onPressed: _isSyncing ? null : _pickDate,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
// Waktu
|
||
|
|
Expanded(
|
||
|
|
flex: 2,
|
||
|
|
child: OutlinedButton.icon(
|
||
|
|
style: OutlinedButton.styleFrom(
|
||
|
|
padding: const EdgeInsets.symmetric(
|
||
|
|
horizontal: 10, vertical: 12),
|
||
|
|
shape: RoundedRectangleBorder(
|
||
|
|
borderRadius: BorderRadius.circular(8),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
icon: Icon(Icons.access_time,
|
||
|
|
size: 16, color: primaryAccent),
|
||
|
|
label: Text(
|
||
|
|
timeFormat.format(_selectedDateTime),
|
||
|
|
style: const TextStyle(fontSize: 13),
|
||
|
|
),
|
||
|
|
onPressed: _isSyncing ? null : _pickTime,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
const SizedBox(height: 14),
|
||
|
|
|
||
|
|
// Field Judul / Uraian
|
||
|
|
TextFormField(
|
||
|
|
controller: _descController,
|
||
|
|
focusNode: _descFocusNode,
|
||
|
|
textInputAction: TextInputAction.next,
|
||
|
|
decoration: InputDecoration(
|
||
|
|
labelText: 'Uraian / Judul Transaksi *',
|
||
|
|
hintText: 'Contoh: Pembelian Komponen Elektronik',
|
||
|
|
prefixIcon: const Icon(Icons.edit_note, size: 20),
|
||
|
|
border: OutlineInputBorder(
|
||
|
|
borderRadius: BorderRadius.circular(8),
|
||
|
|
),
|
||
|
|
contentPadding:
|
||
|
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||
|
|
),
|
||
|
|
validator: (val) {
|
||
|
|
if (val == null || val.trim().isEmpty) {
|
||
|
|
return 'Uraian tidak boleh kosong';
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
},
|
||
|
|
),
|
||
|
|
const SizedBox(height: 14),
|
||
|
|
|
||
|
|
// Field Nominal
|
||
|
|
TextFormField(
|
||
|
|
controller: _amountController,
|
||
|
|
focusNode: _amountFocusNode,
|
||
|
|
textInputAction: TextInputAction.next,
|
||
|
|
keyboardType:
|
||
|
|
const TextInputType.numberWithOptions(decimal: true),
|
||
|
|
decoration: InputDecoration(
|
||
|
|
labelText: 'Nominal Transaksi (Rp) *',
|
||
|
|
prefixText: 'Rp ',
|
||
|
|
prefixIcon: const Icon(Icons.payments_outlined, size: 20),
|
||
|
|
border: OutlineInputBorder(
|
||
|
|
borderRadius: BorderRadius.circular(8),
|
||
|
|
),
|
||
|
|
contentPadding:
|
||
|
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||
|
|
),
|
||
|
|
validator: (val) {
|
||
|
|
if (val == null || val.trim().isEmpty) {
|
||
|
|
return 'Nominal tidak boleh kosong';
|
||
|
|
}
|
||
|
|
final numVal =
|
||
|
|
double.tryParse(val.replaceAll(',', '.'));
|
||
|
|
if (numVal == null || numVal <= 0) {
|
||
|
|
return 'Masukkan nominal yang valid (> 0)';
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
},
|
||
|
|
),
|
||
|
|
const SizedBox(height: 14),
|
||
|
|
|
||
|
|
// Bagian Tag
|
||
|
|
Text(
|
||
|
|
'Tag Transaksi',
|
||
|
|
style: theme.textTheme.labelMedium?.copyWith(
|
||
|
|
fontWeight: FontWeight.bold,
|
||
|
|
color: theme.colorScheme.onSurface,
|
||
|
|
) ??
|
||
|
|
const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 6),
|
||
|
|
|
||
|
|
// Daftar Tag yang Sudah Terpilih
|
||
|
|
if (_tags.isNotEmpty)
|
||
|
|
Padding(
|
||
|
|
padding: const EdgeInsets.only(bottom: 8),
|
||
|
|
child: Wrap(
|
||
|
|
spacing: 6,
|
||
|
|
runSpacing: 4,
|
||
|
|
children: _tags.map((tag) {
|
||
|
|
return Chip(
|
||
|
|
label:
|
||
|
|
Text(tag, style: const TextStyle(fontSize: 11)),
|
||
|
|
deleteIcon: const Icon(Icons.close, size: 14),
|
||
|
|
onDeleted:
|
||
|
|
_isSyncing ? null : () => _removeTag(tag),
|
||
|
|
materialTapTargetSize:
|
||
|
|
MaterialTapTargetSize.shrinkWrap,
|
||
|
|
visualDensity: VisualDensity.compact,
|
||
|
|
backgroundColor: chipBg,
|
||
|
|
shape: RoundedRectangleBorder(
|
||
|
|
borderRadius: BorderRadius.circular(6),
|
||
|
|
side: BorderSide(
|
||
|
|
color: isDark
|
||
|
|
? colorScheme.outlineVariant
|
||
|
|
: Colors.grey.shade300,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}).toList(),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
|
||
|
|
// Input Tag Baru dengan Autocomplete dari Server
|
||
|
|
Row(
|
||
|
|
children: [
|
||
|
|
Expanded(
|
||
|
|
child: RawAutocomplete<String>(
|
||
|
|
textEditingController: _newTagController,
|
||
|
|
focusNode: _newTagFocusNode,
|
||
|
|
optionsBuilder: (TextEditingValue textEditingValue) {
|
||
|
|
final query =
|
||
|
|
textEditingValue.text.trim().toLowerCase();
|
||
|
|
if (query.isEmpty) {
|
||
|
|
return const Iterable<String>.empty();
|
||
|
|
}
|
||
|
|
return _availableTags.where((tag) {
|
||
|
|
final lower = tag.toLowerCase();
|
||
|
|
final alreadySelected =
|
||
|
|
_tags.any((t) => t.toLowerCase() == lower);
|
||
|
|
return lower.contains(query) && !alreadySelected;
|
||
|
|
});
|
||
|
|
},
|
||
|
|
onSelected: (String selection) {
|
||
|
|
_addTagValue(selection);
|
||
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
|
|
_newTagController.clear();
|
||
|
|
});
|
||
|
|
},
|
||
|
|
fieldViewBuilder: (
|
||
|
|
BuildContext context,
|
||
|
|
TextEditingController textEditingController,
|
||
|
|
FocusNode focusNode,
|
||
|
|
VoidCallback onFieldSubmitted,
|
||
|
|
) {
|
||
|
|
return TextFormField(
|
||
|
|
controller: textEditingController,
|
||
|
|
focusNode: focusNode,
|
||
|
|
decoration: InputDecoration(
|
||
|
|
hintText: 'Ketik untuk cari / tambah tag...',
|
||
|
|
border: OutlineInputBorder(
|
||
|
|
borderRadius: BorderRadius.circular(8),
|
||
|
|
),
|
||
|
|
isDense: true,
|
||
|
|
contentPadding: const EdgeInsets.symmetric(
|
||
|
|
horizontal: 10, vertical: 10),
|
||
|
|
prefixIcon:
|
||
|
|
const Icon(Icons.label_outline, size: 18),
|
||
|
|
),
|
||
|
|
onFieldSubmitted: (_) => _addTagFromInput(),
|
||
|
|
);
|
||
|
|
},
|
||
|
|
optionsViewBuilder: (
|
||
|
|
BuildContext context,
|
||
|
|
AutocompleteOnSelected<String> onSelected,
|
||
|
|
Iterable<String> options,
|
||
|
|
) {
|
||
|
|
return Align(
|
||
|
|
alignment: Alignment.topLeft,
|
||
|
|
child: Material(
|
||
|
|
elevation: 6.0,
|
||
|
|
color: isDark ? colorScheme.surface : Colors.white,
|
||
|
|
shape: RoundedRectangleBorder(
|
||
|
|
borderRadius: BorderRadius.circular(8.0),
|
||
|
|
side: BorderSide(
|
||
|
|
color: isDark
|
||
|
|
? colorScheme.outlineVariant
|
||
|
|
: Colors.grey.shade300,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
child: ConstrainedBox(
|
||
|
|
constraints: const BoxConstraints(
|
||
|
|
maxHeight: 180,
|
||
|
|
maxWidth: 320,
|
||
|
|
),
|
||
|
|
child: ListView.separated(
|
||
|
|
padding: const EdgeInsets.symmetric(
|
||
|
|
vertical: 4.0),
|
||
|
|
shrinkWrap: true,
|
||
|
|
itemCount: options.length,
|
||
|
|
separatorBuilder: (_, __) => Divider(
|
||
|
|
height: 1,
|
||
|
|
color: isDark
|
||
|
|
? colorScheme.outlineVariant.withOpacity(0.4)
|
||
|
|
: Colors.grey.shade200,
|
||
|
|
),
|
||
|
|
itemBuilder:
|
||
|
|
(BuildContext context, int index) {
|
||
|
|
final String option =
|
||
|
|
options.elementAt(index);
|
||
|
|
return ListTile(
|
||
|
|
dense: true,
|
||
|
|
visualDensity: VisualDensity.compact,
|
||
|
|
leading: Icon(Icons.tag,
|
||
|
|
size: 16,
|
||
|
|
color: primaryAccent),
|
||
|
|
title: Text(
|
||
|
|
option,
|
||
|
|
style: const TextStyle(
|
||
|
|
fontSize: 13,
|
||
|
|
fontWeight: FontWeight.w500,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
trailing: Icon(Icons.add,
|
||
|
|
size: 16, color: theme.hintColor),
|
||
|
|
onTap: () => onSelected(option),
|
||
|
|
);
|
||
|
|
},
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
},
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
IconButton.filledTonal(
|
||
|
|
icon: const Icon(Icons.add, size: 20),
|
||
|
|
onPressed: _isSyncing ? null : _addTagFromInput,
|
||
|
|
tooltip: 'Tambah Tag',
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
|
||
|
|
// Status Loading Tag Server
|
||
|
|
if (_isLoadingTags)
|
||
|
|
Padding(
|
||
|
|
padding: const EdgeInsets.only(top: 6),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
SizedBox(
|
||
|
|
width: 12,
|
||
|
|
height: 12,
|
||
|
|
child: CircularProgressIndicator(
|
||
|
|
strokeWidth: 1.5,
|
||
|
|
color: primaryAccent,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
Text(
|
||
|
|
'Memuat saran tag dari server...',
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 11,
|
||
|
|
color: theme.hintColor,
|
||
|
|
fontStyle: FontStyle.italic,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
)
|
||
|
|
else if (_tagLoadError != null)
|
||
|
|
Padding(
|
||
|
|
padding: const EdgeInsets.only(top: 6),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
Icon(Icons.info_outline,
|
||
|
|
size: 14, color: colorScheme.error),
|
||
|
|
const SizedBox(width: 6),
|
||
|
|
Text(
|
||
|
|
_tagLoadError!,
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 11, color: colorScheme.error),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 6),
|
||
|
|
InkWell(
|
||
|
|
onTap: () => _loadServerTags(),
|
||
|
|
child: Text(
|
||
|
|
'Coba lagi',
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 11,
|
||
|
|
color: primaryAccent,
|
||
|
|
fontWeight: FontWeight.bold,
|
||
|
|
decoration: TextDecoration.underline,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
|
||
|
|
// Saran Tag dari Server (Suggestion Chips)
|
||
|
|
Builder(
|
||
|
|
builder: (context) {
|
||
|
|
final unselectedServerTags = _availableTags.where(
|
||
|
|
(st) =>
|
||
|
|
!_tags.any((t) => t.toLowerCase() == st.toLowerCase()),
|
||
|
|
).toList();
|
||
|
|
|
||
|
|
if (unselectedServerTags.isEmpty) {
|
||
|
|
return const SizedBox.shrink();
|
||
|
|
}
|
||
|
|
|
||
|
|
final displayedTags = _showAllServerTags
|
||
|
|
? unselectedServerTags
|
||
|
|
: unselectedServerTags.take(6).toList();
|
||
|
|
|
||
|
|
return Padding(
|
||
|
|
padding: const EdgeInsets.only(top: 10),
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Row(
|
||
|
|
mainAxisAlignment:
|
||
|
|
MainAxisAlignment.spaceBetween,
|
||
|
|
children: [
|
||
|
|
Row(
|
||
|
|
children: [
|
||
|
|
Icon(Icons.tips_and_updates_outlined,
|
||
|
|
size: 14, color: primaryAccent),
|
||
|
|
const SizedBox(width: 4),
|
||
|
|
Text(
|
||
|
|
'Saran Tag dari Server:',
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 11.5,
|
||
|
|
fontWeight: FontWeight.w600,
|
||
|
|
color: theme.colorScheme.onSurfaceVariant,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
if (unselectedServerTags.length > 6)
|
||
|
|
InkWell(
|
||
|
|
onTap: () {
|
||
|
|
setState(() {
|
||
|
|
_showAllServerTags =
|
||
|
|
!_showAllServerTags;
|
||
|
|
});
|
||
|
|
},
|
||
|
|
child: Text(
|
||
|
|
_showAllServerTags
|
||
|
|
? 'Ringkas'
|
||
|
|
: '+${unselectedServerTags.length - 6} lainnya',
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 11,
|
||
|
|
color: primaryAccent,
|
||
|
|
fontWeight: FontWeight.bold,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
const SizedBox(height: 6),
|
||
|
|
Wrap(
|
||
|
|
spacing: 6,
|
||
|
|
runSpacing: 4,
|
||
|
|
children: displayedTags.map((tag) {
|
||
|
|
return ActionChip(
|
||
|
|
avatar: Icon(Icons.add,
|
||
|
|
size: 14, color: primaryAccent),
|
||
|
|
label: Text(
|
||
|
|
tag,
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 11,
|
||
|
|
color: suggestionChipTextColor,
|
||
|
|
fontWeight: FontWeight.w500,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
backgroundColor: suggestionChipBg,
|
||
|
|
side: BorderSide(
|
||
|
|
color: suggestionChipBorder),
|
||
|
|
padding:
|
||
|
|
const EdgeInsets.symmetric(horizontal: 4),
|
||
|
|
materialTapTargetSize:
|
||
|
|
MaterialTapTargetSize.shrinkWrap,
|
||
|
|
visualDensity: VisualDensity.compact,
|
||
|
|
tooltip: 'Tambahkan tag $tag',
|
||
|
|
onPressed: _isSyncing
|
||
|
|
? null
|
||
|
|
: () => _addTagValue(tag),
|
||
|
|
);
|
||
|
|
}).toList(),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
},
|
||
|
|
),
|
||
|
|
const SizedBox(height: 16),
|
||
|
|
|
||
|
|
// Notifikasi Error jika Sync Gagal
|
||
|
|
if (_syncErrorMessage != null)
|
||
|
|
Container(
|
||
|
|
margin: const EdgeInsets.only(bottom: 14),
|
||
|
|
padding: const EdgeInsets.all(10),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: isDark
|
||
|
|
? colorScheme.errorContainer.withOpacity(0.3)
|
||
|
|
: const Color(0xFFFEF2F2),
|
||
|
|
border: Border.all(
|
||
|
|
color: isDark
|
||
|
|
? colorScheme.error.withOpacity(0.4)
|
||
|
|
: const Color(0xFFFECACA),
|
||
|
|
),
|
||
|
|
borderRadius: BorderRadius.circular(8),
|
||
|
|
),
|
||
|
|
child: Row(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Icon(Icons.error_outline,
|
||
|
|
size: 18, color: colorScheme.error),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
Expanded(
|
||
|
|
child: Text(
|
||
|
|
_syncErrorMessage!,
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 12,
|
||
|
|
color: isDark
|
||
|
|
? colorScheme.onErrorContainer
|
||
|
|
: const Color(0xFF991B1B),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
|
||
|
|
// Tombol Aksi
|
||
|
|
Row(
|
||
|
|
children: [
|
||
|
|
// Tombol Simpan Lokal / Edit Selesai
|
||
|
|
Expanded(
|
||
|
|
child: OutlinedButton.icon(
|
||
|
|
style: OutlinedButton.styleFrom(
|
||
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||
|
|
shape: RoundedRectangleBorder(
|
||
|
|
borderRadius: BorderRadius.circular(8),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
icon: const Icon(Icons.check, size: 18),
|
||
|
|
label: const Text('Edit Selesai'),
|
||
|
|
onPressed: _isSyncing ? null : _handleSaveLocal,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
// Tombol Sync ke Server
|
||
|
|
Expanded(
|
||
|
|
child: ElevatedButton.icon(
|
||
|
|
style: ElevatedButton.styleFrom(
|
||
|
|
backgroundColor: primaryAccent,
|
||
|
|
foregroundColor: Colors.white,
|
||
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||
|
|
shape: RoundedRectangleBorder(
|
||
|
|
borderRadius: BorderRadius.circular(8),
|
||
|
|
),
|
||
|
|
elevation: 1,
|
||
|
|
),
|
||
|
|
icon: _isSyncing
|
||
|
|
? const SizedBox(
|
||
|
|
width: 16,
|
||
|
|
height: 16,
|
||
|
|
child: CircularProgressIndicator(
|
||
|
|
color: Colors.white,
|
||
|
|
strokeWidth: 2,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
: const Icon(Icons.cloud_upload_outlined,
|
||
|
|
size: 18),
|
||
|
|
label: Text(
|
||
|
|
_isSyncing ? 'Menyinkronkan...' : 'Sync ke Server',
|
||
|
|
style: const TextStyle(
|
||
|
|
fontSize: 12.5, fontWeight: FontWeight.w600),
|
||
|
|
),
|
||
|
|
onPressed: _isSyncing ? null : _handleSyncToServer,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|