// 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 Function(ReportTransaction updatedTx)? onSyncToServer; final List 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 createState() => _EditTransactionDialogState(); } class _EditTransactionDialogState extends State { final _formKey = GlobalKey(); late DateTime _selectedDateTime; late TextEditingController _descController; late TextEditingController _amountController; late TextEditingController _newTagController; late FocusNode _newTagFocusNode; late FocusNode _descFocusNode; late FocusNode _amountFocusNode; late List _tags; List _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.from(widget.transaction.tags); _availableTags = List.from(widget.availableTags); _initCredentialsAndTags(); } @override void dispose() { _descController.dispose(); _amountController.dispose(); _newTagController.dispose(); _newTagFocusNode.dispose(); _descFocusNode.dispose(); _amountFocusNode.dispose(); super.dispose(); } Future _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 _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 = {..._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 _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 _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 _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 _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( textEditingController: _newTagController, focusNode: _newTagFocusNode, optionsBuilder: (TextEditingValue textEditingValue) { final query = textEditingValue.text.trim().toLowerCase(); if (query.isEmpty) { return const Iterable.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 onSelected, Iterable 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, ), ), ], ), ], ), ), ), ), ); } }