From 79e109cfe49632a732e54558b496f885c8daf6f8 Mon Sep 17 00:00:00 2001 From: efrilm Date: Fri, 24 Oct 2025 23:20:41 +0700 Subject: [PATCH] sync page --- lib/application/sync/sync_bloc.dart | 2 +- lib/common/constant/app_constant.dart | 2 +- lib/common/database/database_helper.dart | 46 +--- .../repositories/i_product_repository.dart | 7 + .../datasources/local_data_provider.dart | 2 +- .../repositories/product_repository.dart | 28 ++ lib/presentation/pages/sync/sync_page.dart | 222 ++++++++++++++-- .../pages/sync/widgets/sync_completed.dart | 153 +++++++++++ .../pages/sync/widgets/sync_initial.dart | 28 ++ .../pages/sync/widgets/sync_state.dart | 242 ++++++++++++++++++ 10 files changed, 669 insertions(+), 63 deletions(-) create mode 100644 lib/presentation/pages/sync/widgets/sync_completed.dart create mode 100644 lib/presentation/pages/sync/widgets/sync_initial.dart create mode 100644 lib/presentation/pages/sync/widgets/sync_state.dart diff --git a/lib/application/sync/sync_bloc.dart b/lib/application/sync/sync_bloc.dart index 20ba694..9c4b114 100644 --- a/lib/application/sync/sync_bloc.dart +++ b/lib/application/sync/sync_bloc.dart @@ -189,7 +189,7 @@ class SyncBloc extends Bloc { ), ); - final result = await _productRepository.getProducts( + final result = await _productRepository.getRemoteProducts( page: page, limit: 50, // ambil batch besar biar cepat ); diff --git a/lib/common/constant/app_constant.dart b/lib/common/constant/app_constant.dart index 3e05aae..44eacb7 100644 --- a/lib/common/constant/app_constant.dart +++ b/lib/common/constant/app_constant.dart @@ -1,5 +1,5 @@ class AppConstant { static const String appName = "Apskel POS"; - + static const String dbName = "apskel_pos.db"; static const int cacheExpire = 10; // in minutes } diff --git a/lib/common/database/database_helper.dart b/lib/common/database/database_helper.dart index 45bc846..480468c 100644 --- a/lib/common/database/database_helper.dart +++ b/lib/common/database/database_helper.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; +import '../constant/app_constant.dart'; + class DatabaseHelper { static Database? _database; @@ -11,11 +13,11 @@ class DatabaseHelper { } Future _initDatabase() async { - String path = join(await getDatabasesPath(), 'db_pos.db'); + String path = join(await getDatabasesPath(), AppConstant.dbName); return await openDatabase( path, - version: 1, // Updated version for categories table + version: 2, // Updated version for categories table onCreate: _onCreate, onUpgrade: _onUpgrade, ); @@ -106,46 +108,8 @@ class DatabaseHelper { Future _onUpgrade(Database db, int oldVersion, int newVersion) async { if (oldVersion < 2) { - // Add printer table in version 2 - await db.execute(''' - CREATE TABLE printers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - code TEXT UNIQUE NOT NULL, - name TEXT NOT NULL, - address TEXT, - paper TEXT, - type TEXT, - created_at TEXT, - updated_at TEXT - ) - '''); - - await db.execute('CREATE INDEX idx_printers_code ON printers(code)'); - await db.execute('CREATE INDEX idx_printers_type ON printers(type)'); - } - - if (oldVersion < 3) { - // Add categories table in version 3 - await db.execute(''' - CREATE TABLE categories ( - id TEXT PRIMARY KEY, - organization_id TEXT, - name TEXT NOT NULL, - description TEXT, - business_type TEXT, - metadata TEXT, - is_active INTEGER DEFAULT 1, - created_at TEXT, - updated_at TEXT - ) - '''); - - await db.execute('CREATE INDEX idx_categories_name ON categories(name)'); await db.execute( - 'CREATE INDEX idx_categories_organization_id ON categories(organization_id)', - ); - await db.execute( - 'CREATE INDEX idx_categories_is_active ON categories(is_active)', + 'ALTER TABLE categories ADD COLUMN "order" INTEGER DEFAULT 0', ); } } diff --git a/lib/domain/product/repositories/i_product_repository.dart b/lib/domain/product/repositories/i_product_repository.dart index 9b5ae6a..714597d 100644 --- a/lib/domain/product/repositories/i_product_repository.dart +++ b/lib/domain/product/repositories/i_product_repository.dart @@ -13,6 +13,13 @@ abstract class IProductRepository { bool forceRefresh = false, }); + Future> getRemoteProducts({ + int page = 1, + int limit = 10, + String? categoryId, + String? search, + }); + Future>> searchProductsOptimized( String query, ); diff --git a/lib/infrastructure/product/datasources/local_data_provider.dart b/lib/infrastructure/product/datasources/local_data_provider.dart index 82e6fe3..e8a696b 100644 --- a/lib/infrastructure/product/datasources/local_data_provider.dart +++ b/lib/infrastructure/product/datasources/local_data_provider.dart @@ -406,7 +406,7 @@ class ProductLocalDataProvider { Future _getDatabaseSize() async { try { - final dbPath = p.join(await getDatabasesPath(), 'db_pos.db'); + final dbPath = p.join(await getDatabasesPath(), AppConstant.dbName); final file = File(dbPath); if (await file.exists()) { final size = await file.length(); diff --git a/lib/infrastructure/product/repositories/product_repository.dart b/lib/infrastructure/product/repositories/product_repository.dart index c89ab1f..2cebe38 100644 --- a/lib/infrastructure/product/repositories/product_repository.dart +++ b/lib/infrastructure/product/repositories/product_repository.dart @@ -347,4 +347,32 @@ class ProductRepository implements IProductRepository { return left(ProductFailure.dynamicErrorMessage(e.toString())); } } + + @override + Future> getRemoteProducts({ + int page = 1, + int limit = 10, + String? categoryId, + String? search, + }) async { + try { + final result = await _remoteDataProvider.fetchProducts( + page: page, + limit: limit, + categoryId: categoryId, + search: search, + ); + + if (result.hasError) { + return left(result.error!); + } + + final products = result.data!.toDomain(); + + return right(products); + } catch (e, s) { + log('getProducts', name: _logName, error: e, stackTrace: s); + return left(const ProductFailure.unexpectedError()); + } + } } diff --git a/lib/presentation/pages/sync/sync_page.dart b/lib/presentation/pages/sync/sync_page.dart index d1e52c9..3d305a6 100644 --- a/lib/presentation/pages/sync/sync_page.dart +++ b/lib/presentation/pages/sync/sync_page.dart @@ -1,43 +1,173 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../application/sync/sync_bloc.dart'; import '../../../common/extension/extension.dart'; import '../../../common/theme/theme.dart'; +import '../../../injection.dart'; +import '../../components/button/button.dart'; import '../../components/spaces/space.dart'; +import '../../router/app_router.gr.dart'; +import 'widgets/sync_completed.dart'; +import 'widgets/sync_initial.dart'; +import 'widgets/sync_state.dart'; @RoutePage() -class SyncPage extends StatelessWidget { +class SyncPage extends StatefulWidget implements AutoRouteWrapper { const SyncPage({super.key}); + @override + State createState() => _SyncPageState(); + + @override + Widget wrappedRoute(BuildContext context) => + BlocProvider(create: (context) => getIt(), child: this); +} + +class _SyncPageState extends State with TickerProviderStateMixin { + late AnimationController _animationController; + late Animation _progressAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: Duration(milliseconds: 500), + vsync: this, + ); + _progressAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColor.background, body: SafeArea( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 32, vertical: 16), - child: Row( - children: [ - Expanded( - flex: 2, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [_buildHeader()], - ), + child: BlocConsumer( + listener: (context, state) { + // Kalau lagi syncing, update progress animasi + if (state.isSyncing) { + _animationController.animateTo(state.progress); + } + // Kalau sudah selesai sukses + else if (state.stats != null && state.errorMessage == null) { + _animationController.animateTo(1.0); + + // Tunggu sebentar lalu pindah ke dashboard + // Future.delayed(const Duration(seconds: 2), () { + // context.pushReplacement(DashboardPage()); + // }); + } + // Kalau error + else if (state.errorMessage != null) { + _animationController.stop(); + } + }, + builder: (context, state) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 32, vertical: 16), + child: Row( + children: [ + Expanded( + flex: 2, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildHeader(), + SizedBox(height: 20), + _buildActions(state), + ], + ), + ), + SpaceWidth(40), + SizedBox(width: 40), + Expanded( + flex: 3, + child: SizedBox( + height: context.deviceHeight * 0.8, + child: Builder( + builder: (context) { + // Kondisi 1: error + if (state.errorMessage != null) { + return _buildErrorState(state.errorMessage!); + } + + // Kondisi 2: sudah selesai + if (state.stats != null) { + return SyncCompletedWidget(stats: state.stats!); + } + + // Kondisi 3: sedang syncing + if (state.isSyncing) { + return SyncStateWidget( + step: state.currentStep ?? SyncStep.categories, + progress: state.progress, + message: state.errorMessage ?? '', + progressAnimation: _progressAnimation, + ); + } + + // Kondisi default: initial + return SyncInitialWidget(); + }, + ), + ), + ), + ], ), - SpaceWidth(40), - SizedBox(width: 40), - Expanded( - flex: 3, - child: Container(height: context.deviceHeight * 0.8), - ), - ], - ), + ); + }, ), ), ); } + Widget _buildErrorState(String message) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 48, color: Colors.red.shade400), + SizedBox(height: 12), + Text( + 'Sinkronisasi Gagal', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.red.shade600, + ), + ), + SizedBox(height: 8), + Container( + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + message, + style: TextStyle(fontSize: 12, color: Colors.red.shade700), + textAlign: TextAlign.center, + ), + ), + SizedBox(height: 12), + Text( + 'Periksa koneksi internet dan coba lagi', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + textAlign: TextAlign.center, + ), + ], + ); + } + Widget _buildHeader() { return Column( children: [ @@ -68,4 +198,58 @@ class SyncPage extends StatelessWidget { ], ); } + + Widget _buildActions(SyncState state) { + if (state.isSyncing) { + return AppElevatedButton.outlined( + onPressed: () { + context.read().add(const SyncEvent.cancelSync()); + }, + label: 'Batalkan', + ); + } + + // Completed state + if (state.stats != null && state.errorMessage == null) { + return AppElevatedButton.filled( + onPressed: () { + context.router.replace(MainRoute()); + }, + label: 'Lanjutkan ke Aplikasi', + ); + } + + // Error state + if (state.errorMessage != null) { + return Row( + children: [ + Expanded( + child: AppElevatedButton.outlined( + onPressed: () { + context.router.replace(MainRoute()); + }, + label: 'Lewati', + ), + ), + const SizedBox(width: 16), + Expanded( + child: AppElevatedButton.filled( + onPressed: () { + context.read().add(const SyncEvent.startSync()); + }, + label: 'Coba Lagi', + ), + ), + ], + ); + } + + // Default (initial) + return AppElevatedButton.filled( + onPressed: () { + context.read().add(const SyncEvent.startSync()); + }, + label: 'Mulai Sinkronisasi', + ); + } } diff --git a/lib/presentation/pages/sync/widgets/sync_completed.dart b/lib/presentation/pages/sync/widgets/sync_completed.dart new file mode 100644 index 0000000..b6eb5ef --- /dev/null +++ b/lib/presentation/pages/sync/widgets/sync_completed.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; + +import '../../../../application/sync/sync_bloc.dart'; + +class SyncCompletedWidget extends StatelessWidget { + final SyncStats stats; + const SyncCompletedWidget({super.key, required this.stats}); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Success icon + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: Colors.green.shade50, + borderRadius: BorderRadius.circular(40), + ), + child: Icon( + Icons.check_circle, + size: 48, + color: Colors.green.shade600, + ), + ), + + SizedBox(height: 20), + + Text( + 'Sinkronisasi Berhasil!', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.green.shade700, + ), + ), + + SizedBox(height: 8), + + Text( + 'Data berhasil diunduh ke perangkat', + style: TextStyle(fontSize: 14, color: Colors.grey.shade600), + ), + + SizedBox(height: 20), + + // Stats cards + Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200), + ), + child: Column( + children: [ + Text( + 'Data yang Diunduh', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.grey.shade700, + ), + ), + SizedBox(height: 12), + Column( + children: [ + _buildStatItem( + 'Kategori', + '${stats.totalCategories}', + Icons.category, + Colors.blue, + ), + SizedBox(height: 8), + _buildStatItem( + 'Produk', + '${stats.totalProducts}', + Icons.inventory_2, + Colors.green, + ), + SizedBox(height: 8), + _buildStatItem( + 'Variant', + '${stats.totalVariants}', + Icons.tune, + Colors.orange, + ), + ], + ), + ], + ), + ), + + SizedBox(height: 12), + + Container( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'Mengalihkan ke halaman utama...', + style: TextStyle(color: Colors.grey.shade600, fontSize: 10), + ), + ), + ], + ), + ); + } + + Widget _buildStatItem( + String label, + String value, + IconData icon, + Color color, + ) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 20, color: color), + SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: color, + ), + ), + Text( + label, + style: TextStyle(fontSize: 10, color: Colors.grey.shade600), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/presentation/pages/sync/widgets/sync_initial.dart b/lib/presentation/pages/sync/widgets/sync_initial.dart new file mode 100644 index 0000000..24070b5 --- /dev/null +++ b/lib/presentation/pages/sync/widgets/sync_initial.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +import '../../../../common/theme/theme.dart'; + +class SyncInitialWidget extends StatelessWidget { + const SyncInitialWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.download_rounded, size: 48, color: Colors.grey.shade400), + SizedBox(height: 12), + Text( + 'Siap untuk sinkronisasi', + style: AppStyle.lg.copyWith(fontWeight: FontWeight.w500), + ), + SizedBox(height: 4), + Text( + 'Tekan tombol mulai untuk mengunduh data', + style: AppStyle.sm.copyWith(color: Colors.grey.shade600), + textAlign: TextAlign.center, + ), + ], + ); + } +} diff --git a/lib/presentation/pages/sync/widgets/sync_state.dart b/lib/presentation/pages/sync/widgets/sync_state.dart new file mode 100644 index 0000000..7dd5f1e --- /dev/null +++ b/lib/presentation/pages/sync/widgets/sync_state.dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; + +import '../../../../application/sync/sync_bloc.dart'; +import '../../../../common/theme/theme.dart'; + +class SyncStateWidget extends StatelessWidget { + final SyncStep step; + final double progress; + final String message; + final Animation progressAnimation; + const SyncStateWidget({ + super.key, + required this.step, + required this.progress, + required this.message, + required this.progressAnimation, + }); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Progress circle + Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: 100, + height: 100, + child: AnimatedBuilder( + animation: progressAnimation, + builder: (context, child) { + return CircularProgressIndicator( + value: progressAnimation.value, + strokeWidth: 6, + backgroundColor: Colors.grey.shade200, + valueColor: AlwaysStoppedAnimation( + AppColor.primary, + ), + ); + }, + ), + ), + Column( + children: [ + Icon(_getSyncIcon(step), size: 24, color: AppColor.primary), + SizedBox(height: 2), + AnimatedBuilder( + animation: progressAnimation, + builder: (context, child) { + return Text( + '${(progressAnimation.value * 100).toInt()}%', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppColor.primary, + ), + ); + }, + ), + ], + ), + ], + ), + + SizedBox(height: 20), + + // Step indicator + _buildStepIndicator(step), + + SizedBox(height: 12), + + // Current message + Container( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + message, + style: TextStyle( + color: Colors.blue.shade700, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + + SizedBox(height: 12), + + // Sync details + _buildSyncDetails(step, progress), + ], + ), + ); + } + + Widget _buildSyncDetails(SyncStep step, double progress) { + return Container( + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade200), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Status:', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.grey.shade700, + ), + ), + Text( + _getStepLabel(step), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColor.primary, + ), + ), + ], + ), + SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Progress:', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.grey.shade700, + ), + ), + Text( + '${(progress * 100).toInt()}%', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColor.primary, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildStepIndicator(SyncStep currentStep) { + final steps = [ + ('Kategori', SyncStep.categories, Icons.category), + ('Produk', SyncStep.products, Icons.inventory_2), + ('Variant', SyncStep.variants, Icons.tune), + ('Selesai', SyncStep.completed, Icons.check_circle), + ]; + + return Column( + children: steps.map((stepData) { + final (label, step, icon) = stepData; + final isActive = step == currentStep; + final isCompleted = step.index < currentStep.index; + + return Container( + margin: EdgeInsets.symmetric(vertical: 2), + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: isActive + ? AppColor.primary.withOpacity(0.1) + : isCompleted + ? Colors.green.shade50 + : Colors.grey.shade100, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isCompleted ? Icons.check : icon, + size: 12, + color: isActive + ? AppColor.primary + : isCompleted + ? Colors.green.shade600 + : Colors.grey.shade500, + ), + SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: 10, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + color: isActive + ? AppColor.primary + : isCompleted + ? Colors.green.shade600 + : Colors.grey.shade600, + ), + ), + ], + ), + ); + }).toList(), + ); + } + + IconData _getSyncIcon(SyncStep step) { + switch (step) { + case SyncStep.categories: + return Icons.category; + case SyncStep.products: + return Icons.inventory_2; + case SyncStep.variants: + return Icons.tune; + case SyncStep.completed: + return Icons.check_circle; + } + } + + String _getStepLabel(SyncStep step) { + switch (step) { + case SyncStep.categories: + return 'Mengunduh Kategori'; + case SyncStep.products: + return 'Mengunduh Produk'; + case SyncStep.variants: + return 'Mengunduh Variant'; + case SyncStep.completed: + return 'Selesai'; + } + } +}