cashumit/lib/widgets/edit_transaction_dialog.dart

1018 lines
39 KiB
Dart
Raw Normal View History

// 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,
),
),
],
),
],
),
),
),
),
);
}
}