Sync Page

This commit is contained in:
efrilm 2025-11-10 16:56:12 +07:00
parent f5256fb33d
commit 0a8f8d93bb
9 changed files with 2195 additions and 1 deletions

3
devtools_options.yaml Normal file
View File

@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@ -0,0 +1,234 @@
import 'package:bloc/bloc.dart';
import 'package:dartz/dartz.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:injectable/injectable.dart';
import '../../../domain/category/category.dart';
import '../../../domain/product/product.dart';
part 'sync_setting_event.dart';
part 'sync_setting_state.dart';
part 'sync_setting_bloc.freezed.dart';
@injectable
class SyncSettingBloc extends Bloc<SyncSettingEvent, SyncSettingState> {
final IProductRepository _productRepository;
final ICategoryRepository _categoryRepository;
SyncSettingBloc(this._productRepository, this._categoryRepository)
: super(SyncSettingState.initial()) {
on<SyncSettingEvent>(_onSyncSettingEvent);
}
Future<void> _onSyncSettingEvent(
SyncSettingEvent event,
Emitter<SyncSettingState> emit,
) {
return event.map(
loadStats: (e) => _onLoadStats(emit),
syncAllData: (e) => _onSyncAllData(emit),
clearAllData: (e) => _onClearAllData(emit),
syncProducts: (e) => _onSyncProducts(emit),
syncCategories: (e) => _onSyncCategories(emit),
clearCategories: (e) => _onClearCategories(emit),
clearProducts: (e) => _onClearProducts(emit),
);
}
Future<void> _onLoadStats(Emitter<SyncSettingState> emit) async {
emit(
state.copyWith(
isLoading: true,
failureOptionProduct: none(),
failureOptionCategory: none(),
),
);
final productStats = await _productRepository.getDatabaseStats();
final categoryStats = await _categoryRepository.getDatabaseStats();
productStats.fold(
(failure) {
emit(
state.copyWith(
isLoading: false,
failureOptionProduct: optionOf(failure),
),
);
},
(data) {
emit(
state.copyWith(
isLoading: false,
productStats: data,
failureOptionProduct: none(),
),
);
},
);
categoryStats.fold(
(failure) {
emit(
state.copyWith(
isLoading: false,
failureOptionCategory: optionOf(failure),
),
);
},
(data) {
emit(
state.copyWith(
isLoading: false,
categoryStats: data,
failureOptionCategory: none(),
),
);
},
);
}
Future<void> _onSyncAllData(Emitter<SyncSettingState> emit) async {
emit(
state.copyWith(
isSyncing: true,
failureOptionSyncCategory: none(),
failureOptionSyncProduct: none(),
),
);
// Sync categories first
final categoryResult = await _categoryRepository.syncAllCategories();
bool categorySuccess = false;
categoryResult.fold(
(failure) {
emit(
state.copyWith(failureOptionSyncCategory: optionOf(left(failure))),
);
},
(success) {
categorySuccess = true;
emit(
state.copyWith(failureOptionSyncCategory: optionOf(right(success))),
);
},
);
// Only sync products if categories synced successfully
if (categorySuccess) {
final productResult = await _productRepository.syncAllProducts();
productResult.fold(
(failure) {
emit(
state.copyWith(
isSyncing: false,
failureOptionSyncProduct: optionOf(left(failure)),
),
);
},
(success) {
emit(
state.copyWith(
isSyncing: false,
failureOptionSyncProduct: optionOf(right(success)),
),
);
},
);
} else {
emit(state.copyWith(isSyncing: false));
}
// Reload stats after sync
add(const SyncSettingEvent.loadStats());
}
Future<void> _onClearAllData(Emitter<SyncSettingState> emit) async {
emit(state.copyWith(isLoading: true));
try {
// Clear products and categories
await _productRepository.clearAllProducts();
await _categoryRepository.clearAllCategories();
// Clear caches
_productRepository.clearCache();
_categoryRepository.clearCache();
emit(state.copyWith(isLoading: false));
// Reload stats after clearing
add(const SyncSettingEvent.loadStats());
} catch (e) {
emit(state.copyWith(isLoading: false));
}
}
Future<void> _onSyncProducts(Emitter<SyncSettingState> emit) async {
emit(state.copyWith(isLoading: true, failureOptionSyncProduct: none()));
final result = await _productRepository.syncAllProducts();
result.fold(
(failure) {
emit(
state.copyWith(
isLoading: false,
failureOptionSyncProduct: optionOf(left(failure)),
),
);
},
(success) {
emit(
state.copyWith(
isLoading: false,
failureOptionSyncProduct: optionOf(right(success)),
),
);
},
);
// Reload stats after sync
add(const SyncSettingEvent.loadStats());
}
Future<void> _onSyncCategories(Emitter<SyncSettingState> emit) async {
emit(state.copyWith(isLoading: true, failureOptionSyncCategory: none()));
final result = await _categoryRepository.syncAllCategories();
result.fold(
(failure) {
emit(
state.copyWith(
isLoading: false,
failureOptionSyncCategory: optionOf(left(failure)),
),
);
},
(success) {
emit(
state.copyWith(
isLoading: false,
failureOptionSyncCategory: optionOf(right(success)),
),
);
},
);
// Reload stats after sync
add(const SyncSettingEvent.loadStats());
}
Future<void> _onClearCategories(Emitter<SyncSettingState> emit) async {
await _categoryRepository.clearAllCategories();
_categoryRepository.clearCache();
}
Future<void> _onClearProducts(Emitter<SyncSettingState> emit) async {
await _productRepository.clearAllProducts();
_productRepository.clearCache();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,12 @@
part of 'sync_setting_bloc.dart';
@freezed
class SyncSettingEvent with _$SyncSettingEvent {
const factory SyncSettingEvent.loadStats() = _LoadStats;
const factory SyncSettingEvent.syncAllData() = _SyncAllData;
const factory SyncSettingEvent.clearAllData() = _ClearAllData;
const factory SyncSettingEvent.syncProducts() = _SyncProducts;
const factory SyncSettingEvent.syncCategories() = _SyncCategories;
const factory SyncSettingEvent.clearCategories() = _ClearCategories;
const factory SyncSettingEvent.clearProducts() = _ClearProducts;
}

View File

@ -0,0 +1,24 @@
part of 'sync_setting_bloc.dart';
@freezed
class SyncSettingState with _$SyncSettingState {
factory SyncSettingState({
required Map<String, dynamic> productStats,
required Map<String, dynamic> categoryStats,
required Option<CategoryFailure> failureOptionCategory,
required Option<ProductFailure> failureOptionProduct,
required Option<Either<ProductFailure, String>> failureOptionSyncProduct,
required Option<Either<CategoryFailure, String>> failureOptionSyncCategory,
@Default(false) bool isSyncing,
@Default(false) bool isLoading,
}) = _SyncSettingState;
factory SyncSettingState.initial() => SyncSettingState(
productStats: {},
categoryStats: {},
failureOptionCategory: none(),
failureOptionProduct: none(),
failureOptionSyncProduct: none(),
failureOptionSyncCategory: none(),
);
}

View File

@ -63,6 +63,8 @@ import 'package:apskel_pos_flutter_v2/application/report/report_bloc.dart'
import 'package:apskel_pos_flutter_v2/application/split_bill/split_bill_form/split_bill_form_bloc.dart'
as _i334;
import 'package:apskel_pos_flutter_v2/application/sync/sync_bloc.dart' as _i741;
import 'package:apskel_pos_flutter_v2/application/sync/sync_setting/sync_setting_bloc.dart'
as _i729;
import 'package:apskel_pos_flutter_v2/application/table/table_form/table_form_bloc.dart'
as _i248;
import 'package:apskel_pos_flutter_v2/application/table/table_loader/table_loader_bloc.dart'
@ -348,6 +350,12 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i502.ICategoryRepository>(),
),
);
gh.factory<_i729.SyncSettingBloc>(
() => _i729.SyncSettingBloc(
gh<_i44.IProductRepository>(),
gh<_i502.ICategoryRepository>(),
),
);
gh.factory<_i268.ProductAnalyticLoaderBloc>(
() => _i268.ProductAnalyticLoaderBloc(gh<_i346.IAnalyticRepository>()),
);

View File

@ -15,6 +15,7 @@ import '../application/printer/print_struck/print_struck_bloc.dart';
import '../application/printer/printer_form/printer_form_bloc.dart';
import '../application/printer/printer_loader/printer_loader_bloc.dart';
import '../application/product/product_loader/product_loader_bloc.dart';
import '../application/sync/sync_setting/sync_setting_bloc.dart';
import '../application/table/table_form/table_form_bloc.dart';
import '../application/table/table_loader/table_loader_bloc.dart';
import '../application/void/void_form/void_form_bloc.dart';
@ -55,6 +56,7 @@ class _AppWidgetState extends State<AppWidget> {
BlocProvider(create: (context) => getIt<PrinterFormBloc>()),
BlocProvider(create: (context) => getIt<PrinterLoaderBloc>()),
BlocProvider(create: (context) => getIt<PrintStruckBloc>()),
BlocProvider(create: (context) => getIt<SyncSettingBloc>()),
],
child: MaterialApp.router(
debugShowCheckedModeBanner: false,

View File

@ -0,0 +1,511 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../../application/sync/sync_setting/sync_setting_bloc.dart';
import '../../../../../../common/theme/theme.dart';
import '../../../../../../injection.dart';
import '../../../../../components/button/button.dart';
import '../../../../../components/loader/loader_with_text.dart';
import '../../../../../components/page/page_title.dart';
import '../../../../../components/spaces/space.dart';
import '../../../../../components/toast/flushbar.dart';
class SettingSyncSection extends StatelessWidget {
const SettingSyncSection({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) =>
getIt<SyncSettingBloc>()..add(const SyncSettingEvent.loadStats()),
child: const _SettingSyncPageContent(),
);
}
}
class _SettingSyncPageContent extends StatelessWidget {
const _SettingSyncPageContent();
@override
Widget build(BuildContext context) {
return BlocConsumer<SyncSettingBloc, SyncSettingState>(
listener: (context, state) {
// Handle product stats failure
state.failureOptionProduct.fold(() {}, (failure) {
AppFlushbar.showError(
context,
'Gagal memuat stats produk: ${failure.toString()}',
);
});
// Handle category stats failure
state.failureOptionCategory.fold(() {}, (failure) {
AppFlushbar.showError(
context,
'Gagal memuat stats kategori: ${failure.toString()}',
);
});
// Handle sync product result
state.failureOptionSyncProduct.fold(() {}, (result) {
result.fold(
(failure) {
AppFlushbar.showError(
context,
'Gagal sync produk: ${failure.toString()}',
);
},
(success) {
AppFlushbar.showSuccess(context, success);
},
);
});
// Handle sync category result
state.failureOptionSyncCategory.fold(() {}, (result) {
result.fold(
(failure) {
AppFlushbar.showError(
context,
'Gagal sync kategori: ${failure.toString()}',
);
},
(success) {
AppFlushbar.showSuccess(context, success);
},
);
});
},
builder: (context, state) {
return Column(
children: [
PageTitle(
title: 'Sinkronisasi',
subtitle: 'Sinkronisasi data dengan server',
isBack: false,
),
Expanded(
child: Material(
color: AppColor.background,
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
SpaceHeight(24),
_buildQuickActions(context, state),
SpaceHeight(24),
_buildSyncTables(context, state),
SpaceHeight(24),
_buildDatabaseStats(context, state),
],
),
),
),
),
],
);
},
);
}
Widget _buildQuickActions(BuildContext context, SyncSettingState state) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Aksi Cepat',
style: AppStyle.lg.copyWith(fontWeight: FontWeight.w600),
),
SpaceHeight(16),
Row(
children: [
Expanded(
child: AppElevatedButton.filled(
onPressed: state.isSyncing || state.isLoading
? null
: () => _syncAllData(context),
label: state.isSyncing
? 'Menyinkronkan...'
: 'Sync Semua Data',
),
),
SpaceWidth(12),
Expanded(
child: AppElevatedButton.outlined(
onPressed: state.isLoading || state.isSyncing
? null
: () => _clearAllData(context),
label: 'Hapus Semua Data',
textColor: Colors.red,
),
),
],
),
],
),
);
}
Widget _buildSyncTables(BuildContext context, SyncSettingState state) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sinkronisasi per Tabel',
style: AppStyle.lg.copyWith(fontWeight: FontWeight.w600),
),
SpaceHeight(16),
_buildSyncTableItem(
context: context,
state: state,
title: 'Kategori',
subtitle: 'Sinkronkan data kategori produk',
icon: Icons.category,
color: Colors.blue,
count: state.categoryStats['total_categories'] ?? 0,
onSync: () {
context.read<SyncSettingBloc>().add(
const SyncSettingEvent.syncCategories(),
);
},
onClear: () => _clearCategories(context),
),
SpaceHeight(12),
const Divider(),
SpaceHeight(12),
_buildSyncTableItem(
context: context,
state: state,
title: 'Produk',
subtitle: 'Sinkronkan data produk dan variant',
icon: Icons.inventory_2,
color: Colors.green,
count: state.productStats['total_products'] ?? 0,
onSync: () {
context.read<SyncSettingBloc>().add(
const SyncSettingEvent.syncProducts(),
);
},
onClear: () => _clearProducts(context),
),
],
),
);
}
Widget _buildSyncTableItem({
required BuildContext context,
required SyncSettingState state,
required String title,
required String subtitle,
required IconData icon,
required Color color,
required int count,
required VoidCallback onSync,
required VoidCallback onClear,
}) {
return Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color, size: 24),
),
SpaceWidth(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
title,
style: AppStyle.md.copyWith(fontWeight: FontWeight.w600),
),
SpaceWidth(8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'$count',
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.w600,
color: color,
),
),
),
],
),
Text(
subtitle,
style: AppStyle.sm.copyWith(color: Colors.grey.shade600),
),
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: state.isLoading || state.isSyncing ? null : onSync,
icon: const Icon(Icons.sync, size: 20),
tooltip: 'Sync $title',
style: IconButton.styleFrom(
backgroundColor: color.withOpacity(0.1),
foregroundColor: color,
),
),
SpaceWidth(4),
IconButton(
onPressed: state.isLoading || state.isSyncing ? null : onClear,
icon: const Icon(Icons.delete_outline, size: 20),
tooltip: 'Hapus $title',
style: IconButton.styleFrom(
backgroundColor: Colors.red.withOpacity(0.1),
foregroundColor: Colors.red,
),
),
],
),
],
);
}
Widget _buildDatabaseStats(BuildContext context, SyncSettingState state) {
final totalCacheEntries =
(state.productStats['cache_entries'] ?? 0) +
(state.categoryStats['cache_entries'] ?? 0);
final totalDatabaseSize =
(state.productStats['database_size_mb'] ?? 0.0) +
(state.categoryStats['database_size_mb'] ?? 0.0);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Statistik Database',
style: AppStyle.lg.copyWith(fontWeight: FontWeight.w600),
),
const Spacer(),
if (state.isLoading)
Center(child: LoaderWithText())
else
IconButton(
onPressed: () {
context.read<SyncSettingBloc>().add(
const SyncSettingEvent.loadStats(),
);
},
icon: const Icon(Icons.refresh, size: 20),
tooltip: 'Refresh Stats',
),
],
),
SpaceHeight(16),
if (state.isLoading)
const Center(child: LoaderWithText())
else
Column(
children: [
_buildStatRow(
'Kategori',
state.categoryStats['total_categories']?.toString() ?? '0',
Icons.category,
Colors.blue,
),
SpaceHeight(8),
_buildStatRow(
'Produk',
state.productStats['total_products']?.toString() ?? '0',
Icons.inventory_2,
Colors.green,
),
SpaceHeight(8),
_buildStatRow(
'Variant',
state.productStats['total_variants']?.toString() ?? '0',
Icons.tune,
Colors.orange,
),
SpaceHeight(8),
_buildStatRow(
'Cache Entries',
'$totalCacheEntries',
Icons.memory,
Colors.purple,
),
SpaceHeight(8),
_buildStatRow(
'Ukuran Database',
'${totalDatabaseSize.toStringAsFixed(2)} MB',
Icons.storage,
Colors.grey.shade600,
),
],
),
],
),
);
}
Widget _buildStatRow(String label, String value, IconData icon, Color color) {
return Row(
children: [
Icon(icon, size: 16, color: color),
SpaceWidth(8),
Expanded(
child: Text(
label,
style: AppStyle.md.copyWith(color: Colors.grey.shade700),
),
),
Text(
value,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w600,
color: color,
),
),
],
);
}
Future<void> _syncAllData(BuildContext context) async {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SpaceHeight(16),
Text('Sinkronisasi semua data...'),
],
),
),
);
context.read<SyncSettingBloc>().add(const SyncSettingEvent.syncAllData());
// Wait a bit for the sync to complete, then close dialog
await Future.delayed(const Duration(milliseconds: 500));
if (context.mounted) {
Navigator.of(context).pop();
}
}
Future<void> _clearAllData(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Hapus Semua Data'),
content: const Text(
'Apakah Anda yakin ingin menghapus semua data lokal? Tindakan ini tidak dapat dibatalkan.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Batal'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(
'Hapus',
style: AppStyle.md.copyWith(color: Colors.red),
),
),
],
),
);
if (confirmed == true && context.mounted) {
context.read<SyncSettingBloc>().add(
const SyncSettingEvent.clearAllData(),
);
}
}
Future<void> _clearCategories(BuildContext context) async {
final confirmed = await _showClearConfirmation(context, 'kategori');
if (confirmed && context.mounted) {
context.read<SyncSettingBloc>().add(
const SyncSettingEvent.clearCategories(),
);
AppFlushbar.showSuccess(context, 'Data kategori berhasil dihapus');
context.read<SyncSettingBloc>().add(const SyncSettingEvent.loadStats());
}
}
Future<void> _clearProducts(BuildContext context) async {
final confirmed = await _showClearConfirmation(context, 'produk');
if (confirmed && context.mounted) {
context.read<SyncSettingBloc>().add(
const SyncSettingEvent.clearProducts(),
);
AppFlushbar.showSuccess(context, 'Data produk berhasil dihapus');
context.read<SyncSettingBloc>().add(const SyncSettingEvent.loadStats());
}
}
Future<bool> _showClearConfirmation(
BuildContext context,
String dataType,
) async {
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Hapus Data $dataType'),
content: Text(
'Apakah Anda yakin ingin menghapus semua data $dataType?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Batal'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(
'Hapus',
style: AppStyle.md.copyWith(color: Colors.red),
),
),
],
),
) ??
false;
}
}

View File

@ -6,6 +6,7 @@ import '../../../../../application/printer/printer_bloc.dart';
import '../../../../../common/theme/theme.dart';
import '../../../../../injection.dart';
import 'sections/setting_printer_section.dart';
import 'sections/setting_sync_section.dart';
import 'widgets/setting_left_panel.dart';
@RoutePage()
@ -25,7 +26,7 @@ class SettingPage extends StatelessWidget implements AutoRouteWrapper {
flex: 4,
child: switch (state.index) {
0 => SettingPrinterSection(),
1 => Container(),
1 => SettingSyncSection(),
_ => Container(),
},
),