feat: consolidate backend and docker-compose setup
This commit is contained in:
commit
ff3753a745
306 files changed
+35450
No files matched your search
@@ -0,0 +1,404 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../../models/document_model.dart';
|
||||
import '../../main.dart'; // For localStorageProvider
|
||||
import 'pending_documents_provider.dart';
|
||||
import '../editor/pdf_service.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
|
||||
class DocumentsScreen extends ConsumerStatefulWidget {
|
||||
const DocumentsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DocumentsScreen> createState() => _DocumentsScreenState();
|
||||
}
|
||||
|
||||
class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
|
||||
final _searchController = TextEditingController();
|
||||
List<DocumentModel> _allDocuments = [];
|
||||
List<DocumentModel> _filteredDocuments = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDocuments();
|
||||
_searchController.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
Future<void> _loadDocuments() async {
|
||||
final localStorage = ref.read(localStorageProvider);
|
||||
final docs = localStorage.getAllDocuments();
|
||||
setState(() {
|
||||
_allDocuments = docs;
|
||||
_filteredDocuments = docs;
|
||||
});
|
||||
|
||||
try {
|
||||
final apiClient = ApiClient();
|
||||
final response = await apiClient.client.get(AppConfig.fetchDocumentsEndpoint);
|
||||
|
||||
final List serverData = response.data['data'] ?? [];
|
||||
final List<DocumentModel> serverDocs = serverData
|
||||
.map((json) => DocumentModel.fromJson(Map<String, dynamic>.from(json)))
|
||||
.toList();
|
||||
|
||||
await localStorage.documentBox.clear();
|
||||
for (var doc in serverDocs) {
|
||||
await localStorage.saveDocument(doc);
|
||||
}
|
||||
|
||||
final updatedDocs = localStorage.getAllDocuments();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_allDocuments = updatedDocs;
|
||||
_filteredDocuments = updatedDocs;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Offline or failed fetching from server: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
final query = _searchController.text.toLowerCase();
|
||||
setState(() {
|
||||
_filteredDocuments = _allDocuments.where((doc) {
|
||||
return doc.noDo.toLowerCase().contains(query) ||
|
||||
doc.noPo.toString().contains(query) ||
|
||||
doc.noSo.toString().contains(query) ||
|
||||
doc.tanggal.toLowerCase().contains(query) ||
|
||||
doc.kepadaYth.toLowerCase().contains(query);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pendingDocs = ref.watch(pendingDocumentsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Documents'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/camera'),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Sticky Search Bar
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
color: AppConfig.surfaceColor,
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search by No PO, No SO, DO, Date...',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// List of documents
|
||||
Expanded(
|
||||
child: (pendingDocs.isEmpty && _filteredDocuments.isEmpty)
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.folder_open, size: 64, color: AppConfig.textSecondary.withOpacity(0.5)),
|
||||
const SizedBox(height: 16),
|
||||
Text('No documents found', style: AppConfig.bodyLarge),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: [
|
||||
if (pendingDocs.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Text('Pending & Processing', style: AppConfig.h3),
|
||||
),
|
||||
...pendingDocs.map((doc) => _buildPendingCard(doc)),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
if (_filteredDocuments.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Text('Confirmed Documents', style: AppConfig.h3),
|
||||
),
|
||||
..._filteredDocuments.map((doc) => _buildDocumentCard(doc)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPendingCard(PendingDocument doc) {
|
||||
Color statusColor;
|
||||
String statusText;
|
||||
IconData statusIcon;
|
||||
|
||||
switch (doc.status) {
|
||||
case PendingDocumentStatus.uploading:
|
||||
case PendingDocumentStatus.processing:
|
||||
statusColor = Colors.orange;
|
||||
statusText = 'Sedang memproses dokumen...';
|
||||
statusIcon = Icons.cloud_upload;
|
||||
break;
|
||||
case PendingDocumentStatus.success:
|
||||
statusColor = AppConfig.successColor;
|
||||
statusText = 'Ketuk untuk dikonfirmasi';
|
||||
statusIcon = Icons.check_circle;
|
||||
break;
|
||||
case PendingDocumentStatus.error:
|
||||
statusColor = AppConfig.errorColor;
|
||||
statusText = 'Gagal Memproses';
|
||||
statusIcon = Icons.error;
|
||||
break;
|
||||
}
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
margin: const EdgeInsets.only(bottom: 12.0),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: statusColor.withOpacity(0.5)),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (doc.status == PendingDocumentStatus.success) {
|
||||
context.push('/editor', extra: doc.id).then((_) {
|
||||
_loadDocuments();
|
||||
});
|
||||
} else if (doc.status == PendingDocumentStatus.error) {
|
||||
// Show error options
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Wrap(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.refresh),
|
||||
title: const Text('Retry Upload'),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
ref.read(pendingDocumentsProvider.notifier).retryUpload(doc.id);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete, color: AppConfig.errorColor),
|
||||
title: const Text('Delete Document', style: TextStyle(color: AppConfig.errorColor)),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
ref.read(pendingDocumentsProvider.notifier).removeDocument(doc.id);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(
|
||||
File(doc.imagePath),
|
||||
width: 60,
|
||||
height: 60,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
color: Colors.grey.shade200,
|
||||
child: const Icon(Icons.image, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(statusIcon, size: 16, color: statusColor),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
statusText,
|
||||
style: AppConfig.bodyMedium.copyWith(color: statusColor, fontWeight: FontWeight.bold),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (doc.status == PendingDocumentStatus.uploading || doc.status == PendingDocumentStatus.processing)
|
||||
const LinearProgressIndicator(),
|
||||
if (doc.status == PendingDocumentStatus.success)
|
||||
Text('Ketuk untuk mereview dan konfirmasi data', style: AppConfig.bodySmall),
|
||||
if (doc.status == PendingDocumentStatus.error)
|
||||
Text(doc.errorMessage ?? 'Unknown error', style: AppConfig.bodySmall.copyWith(color: AppConfig.errorColor), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (doc.status == PendingDocumentStatus.success)
|
||||
const Icon(Icons.chevron_right, color: AppConfig.primaryColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDocumentCard(DocumentModel doc) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shadowColor: Colors.black.withOpacity(0.05),
|
||||
margin: const EdgeInsets.only(bottom: 16.0),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'DO: ${doc.noDo}',
|
||||
style: AppConfig.h3.copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppConfig.successColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.check_circle, size: 14, color: AppConfig.successColor),
|
||||
const SizedBox(width: 4),
|
||||
Text('Confirmed', style: AppConfig.bodySmall.copyWith(color: AppConfig.successColor, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade100),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(child: _buildInfoItem(Icons.receipt_long, 'PO: ${doc.noPo}')),
|
||||
Expanded(child: _buildInfoItem(Icons.inventory_2, 'SO: ${doc.noSo}')),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(child: _buildInfoItem(Icons.calendar_today, doc.tanggal)),
|
||||
Expanded(child: _buildInfoItem(Icons.person, doc.kepadaYth)),
|
||||
],
|
||||
),
|
||||
if (doc.latitude != null && doc.longitude != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(child: _buildInfoItem(Icons.location_on, 'Lat: ${doc.latitude!.toStringAsFixed(4)}')),
|
||||
Expanded(child: _buildInfoItem(Icons.location_on, 'Lng: ${doc.longitude!.toStringAsFixed(4)}')),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _printReceipt(doc),
|
||||
icon: const Icon(Icons.print, color: AppConfig.primaryColor),
|
||||
label: const Text('Print Receipt', style: TextStyle(color: AppConfig.primaryColor, fontWeight: FontWeight.bold)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: AppConfig.primaryColor),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoItem(IconData icon, String text) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: AppConfig.textSecondary),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: AppConfig.bodyMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _printReceipt(DocumentModel doc) async {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Generating PDF...'),
|
||||
duration: Duration(seconds: 1),
|
||||
backgroundColor: AppConfig.secondaryColor,
|
||||
),
|
||||
);
|
||||
await PdfService.generateAndPrintReceipt(doc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import '../../models/document_model.dart';
|
||||
import '../../core/location/location_service.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../config/app_config.dart';
|
||||
|
||||
enum PendingDocumentStatus { uploading, processing, success, error }
|
||||
|
||||
class PendingDocument {
|
||||
final String id;
|
||||
final String imagePath;
|
||||
final PendingDocumentStatus status;
|
||||
final DocumentModel? document;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final String? errorMessage;
|
||||
final DateTime createdAt;
|
||||
|
||||
PendingDocument({
|
||||
required this.id,
|
||||
required this.imagePath,
|
||||
this.status = PendingDocumentStatus.uploading,
|
||||
this.document,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.errorMessage,
|
||||
DateTime? createdAt,
|
||||
}) : createdAt = createdAt ?? DateTime.now();
|
||||
|
||||
PendingDocument copyWith({
|
||||
PendingDocumentStatus? status,
|
||||
DocumentModel? document,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return PendingDocument(
|
||||
id: id,
|
||||
imagePath: imagePath,
|
||||
status: status ?? this.status,
|
||||
document: document ?? this.document,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
errorMessage: errorMessage,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
|
||||
PendingDocumentsNotifier() : super([]);
|
||||
|
||||
void addDocument(String imagePath, {double? latitude, double? longitude}) {
|
||||
final id = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
final newDoc = PendingDocument(
|
||||
id: id,
|
||||
imagePath: imagePath,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
state = [newDoc, ...state];
|
||||
|
||||
_uploadAndProcess(id, imagePath, latitude: latitude, longitude: longitude);
|
||||
}
|
||||
|
||||
Future<Position?> _determinePosition() async {
|
||||
return await LocationService.determinePosition();
|
||||
}
|
||||
|
||||
Future<void> _uploadAndProcess(String id, String imagePath, {double? latitude, double? longitude}) async {
|
||||
double? lat = latitude;
|
||||
double? lng = longitude;
|
||||
|
||||
if (lat == null || lng == null) {
|
||||
try {
|
||||
final position = await _determinePosition();
|
||||
if (position != null) {
|
||||
lat = position.latitude;
|
||||
lng = position.longitude;
|
||||
if (mounted) {
|
||||
state = [
|
||||
for (final item in state)
|
||||
if (item.id == id)
|
||||
item.copyWith(latitude: lat, longitude: lng)
|
||||
else
|
||||
item
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Gracefully continue without location tags if fetch fails
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// API call to upload and process
|
||||
final apiClient = ApiClient();
|
||||
final formData = FormData.fromMap({
|
||||
'image': await MultipartFile.fromFile(
|
||||
imagePath,
|
||||
filename: imagePath.split(RegExp(r'[/\\]')).last,
|
||||
),
|
||||
if (lat != null) 'latitude': lat.toString(),
|
||||
if (lng != null) 'longitude': lng.toString(),
|
||||
});
|
||||
|
||||
final response = await apiClient.client.post(
|
||||
AppConfig.uploadDocumentEndpoint,
|
||||
data: formData,
|
||||
);
|
||||
|
||||
final doc = DocumentModel.fromJson(response.data['data']);
|
||||
|
||||
if (!mounted) return;
|
||||
state = [
|
||||
for (final item in state)
|
||||
if (item.id == id)
|
||||
item.copyWith(status: PendingDocumentStatus.processing, document: doc)
|
||||
else
|
||||
item
|
||||
];
|
||||
|
||||
// Poll until the document is parsed on the server
|
||||
bool isParsed = false;
|
||||
DocumentModel? parsedDoc;
|
||||
int retries = 0;
|
||||
const maxRetries = 60; // 2 minutes max polling (60 retries * 2s)
|
||||
|
||||
while (!isParsed && retries < maxRetries && mounted) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
final getRes = await apiClient.client.get(AppConfig.fetchDocumentsEndpoint);
|
||||
if (getRes.statusCode == 200 && getRes.data['data'] is List) {
|
||||
final list = (getRes.data['data'] as List)
|
||||
.map((e) => DocumentModel.fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
|
||||
final found = list.firstWhere(
|
||||
(element) => element.id == doc.id,
|
||||
orElse: () => doc,
|
||||
);
|
||||
|
||||
if (found != doc) {
|
||||
isParsed = true;
|
||||
parsedDoc = found;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore network glitch during polling
|
||||
}
|
||||
retries++;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (isParsed && parsedDoc != null) {
|
||||
state = [
|
||||
for (final item in state)
|
||||
if (item.id == id)
|
||||
item.copyWith(status: PendingDocumentStatus.success, document: parsedDoc)
|
||||
else
|
||||
item
|
||||
];
|
||||
} else {
|
||||
state = [
|
||||
for (final item in state)
|
||||
if (item.id == id)
|
||||
item.copyWith(
|
||||
status: PendingDocumentStatus.error,
|
||||
errorMessage: 'Gagal mengekstrak data dari dokumen (Timeout).',
|
||||
)
|
||||
else
|
||||
item
|
||||
];
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
state = [
|
||||
for (final item in state)
|
||||
if (item.id == id)
|
||||
item.copyWith(status: PendingDocumentStatus.error, errorMessage: e.toString())
|
||||
else
|
||||
item
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
void removeDocument(String id) {
|
||||
state = state.where((item) => item.id != id).toList();
|
||||
}
|
||||
|
||||
void retryUpload(String id) {
|
||||
final doc = state.firstWhere((item) => item.id == id);
|
||||
final lat = doc.latitude;
|
||||
final lng = doc.longitude;
|
||||
state = [
|
||||
for (final item in state)
|
||||
if (item.id == id)
|
||||
item.copyWith(
|
||||
status: PendingDocumentStatus.uploading,
|
||||
errorMessage: null,
|
||||
)
|
||||
else
|
||||
item
|
||||
];
|
||||
_uploadAndProcess(id, doc.imagePath, latitude: lat, longitude: lng);
|
||||
}
|
||||
}
|
||||
|
||||
final pendingDocumentsProvider = StateNotifierProvider<PendingDocumentsNotifier, List<PendingDocument>>((ref) {
|
||||
return PendingDocumentsNotifier();
|
||||
});
|
||||
Reference in new issue
Block a user