sync page

This commit is contained in:
efrilm 2025-10-24 23:20:41 +07:00
parent 6892895021
commit 79e109cfe4
10 changed files with 669 additions and 63 deletions

View File

@ -189,7 +189,7 @@ class SyncBloc extends Bloc<SyncEvent, SyncState> {
), ),
); );
final result = await _productRepository.getProducts( final result = await _productRepository.getRemoteProducts(
page: page, page: page,
limit: 50, // ambil batch besar biar cepat limit: 50, // ambil batch besar biar cepat
); );

View File

@ -1,5 +1,5 @@
class AppConstant { class AppConstant {
static const String appName = "Apskel POS"; static const String appName = "Apskel POS";
static const String dbName = "apskel_pos.db";
static const int cacheExpire = 10; // in minutes static const int cacheExpire = 10; // in minutes
} }

View File

@ -2,6 +2,8 @@ import 'dart:async';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import '../constant/app_constant.dart';
class DatabaseHelper { class DatabaseHelper {
static Database? _database; static Database? _database;
@ -11,11 +13,11 @@ class DatabaseHelper {
} }
Future<Database> _initDatabase() async { Future<Database> _initDatabase() async {
String path = join(await getDatabasesPath(), 'db_pos.db'); String path = join(await getDatabasesPath(), AppConstant.dbName);
return await openDatabase( return await openDatabase(
path, path,
version: 1, // Updated version for categories table version: 2, // Updated version for categories table
onCreate: _onCreate, onCreate: _onCreate,
onUpgrade: _onUpgrade, onUpgrade: _onUpgrade,
); );
@ -106,46 +108,8 @@ class DatabaseHelper {
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async { Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
if (oldVersion < 2) { 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( await db.execute(
'CREATE INDEX idx_categories_organization_id ON categories(organization_id)', 'ALTER TABLE categories ADD COLUMN "order" INTEGER DEFAULT 0',
);
await db.execute(
'CREATE INDEX idx_categories_is_active ON categories(is_active)',
); );
} }
} }

View File

@ -13,6 +13,13 @@ abstract class IProductRepository {
bool forceRefresh = false, bool forceRefresh = false,
}); });
Future<Either<ProductFailure, ListProduct>> getRemoteProducts({
int page = 1,
int limit = 10,
String? categoryId,
String? search,
});
Future<Either<ProductFailure, List<Product>>> searchProductsOptimized( Future<Either<ProductFailure, List<Product>>> searchProductsOptimized(
String query, String query,
); );

View File

@ -406,7 +406,7 @@ class ProductLocalDataProvider {
Future<double> _getDatabaseSize() async { Future<double> _getDatabaseSize() async {
try { try {
final dbPath = p.join(await getDatabasesPath(), 'db_pos.db'); final dbPath = p.join(await getDatabasesPath(), AppConstant.dbName);
final file = File(dbPath); final file = File(dbPath);
if (await file.exists()) { if (await file.exists()) {
final size = await file.length(); final size = await file.length();

View File

@ -347,4 +347,32 @@ class ProductRepository implements IProductRepository {
return left(ProductFailure.dynamicErrorMessage(e.toString())); return left(ProductFailure.dynamicErrorMessage(e.toString()));
} }
} }
@override
Future<Either<ProductFailure, ListProduct>> 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());
}
}
} }

View File

@ -1,43 +1,173 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.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/extension/extension.dart';
import '../../../common/theme/theme.dart'; import '../../../common/theme/theme.dart';
import '../../../injection.dart';
import '../../components/button/button.dart';
import '../../components/spaces/space.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() @RoutePage()
class SyncPage extends StatelessWidget { class SyncPage extends StatefulWidget implements AutoRouteWrapper {
const SyncPage({super.key}); const SyncPage({super.key});
@override
State<SyncPage> createState() => _SyncPageState();
@override
Widget wrappedRoute(BuildContext context) =>
BlocProvider(create: (context) => getIt<SyncBloc>(), child: this);
}
class _SyncPageState extends State<SyncPage> with TickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _progressAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: Duration(milliseconds: 500),
vsync: this,
);
_progressAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: AppColor.background, backgroundColor: AppColor.background,
body: SafeArea( body: SafeArea(
child: Padding( child: BlocConsumer<SyncBloc, SyncState>(
padding: EdgeInsets.symmetric(horizontal: 32, vertical: 16), listener: (context, state) {
child: Row( // Kalau lagi syncing, update progress animasi
children: [ if (state.isSyncing) {
Expanded( _animationController.animateTo(state.progress);
flex: 2, }
child: Column( // Kalau sudah selesai sukses
mainAxisAlignment: MainAxisAlignment.center, else if (state.stats != null && state.errorMessage == null) {
children: [_buildHeader()], _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() { Widget _buildHeader() {
return Column( return Column(
children: [ children: [
@ -68,4 +198,58 @@ class SyncPage extends StatelessWidget {
], ],
); );
} }
Widget _buildActions(SyncState state) {
if (state.isSyncing) {
return AppElevatedButton.outlined(
onPressed: () {
context.read<SyncBloc>().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<SyncBloc>().add(const SyncEvent.startSync());
},
label: 'Coba Lagi',
),
),
],
);
}
// Default (initial)
return AppElevatedButton.filled(
onPressed: () {
context.read<SyncBloc>().add(const SyncEvent.startSync());
},
label: 'Mulai Sinkronisasi',
);
}
} }

View File

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

View File

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

View File

@ -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<double> 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<Color>(
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';
}
}
}