diff --git a/apps/mobile/lib/features/marketplace/warga/batik_camera_page.dart b/apps/mobile/lib/features/marketplace/warga/batik_camera_page.dart new file mode 100644 index 0000000..364ee43 --- /dev/null +++ b/apps/mobile/lib/features/marketplace/warga/batik_camera_page.dart @@ -0,0 +1,658 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:camera/camera.dart'; +import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; +import 'package:image/image.dart' as img; + +class BatikCameraPage extends StatefulWidget { + const BatikCameraPage({super.key}); + + @override + State createState() => _BatikCameraPageState(); +} + +class _BatikCameraPageState extends State { + CameraController? _cameraController; + List? _cameras; + bool _isInitialized = false; + bool _isDetecting = false; + Timer? _autoDetectTimer; + + // Detection state + String? _detectedMotif; + double? _confidence; + bool _isAutoMode = false; + DateTime? _lastDetectionTime; + + @override + void initState() { + super.initState(); + _initializeCamera(); + } + + Future _initializeCamera() async { + try { + _cameras = await availableCameras(); + if (_cameras == null || _cameras!.isEmpty) { + throw Exception('No cameras available'); + } + + _cameraController = CameraController( + _cameras![0], + ResolutionPreset.high, + enableAudio: false, + imageFormatGroup: ImageFormatGroup.jpeg, + ); + + await _cameraController!.initialize(); + await _cameraController!.setFlashMode(FlashMode.off); + + if (mounted) { + setState(() { + _isInitialized = true; + }); + } + } catch (e) { + print('Error initializing camera: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Gagal membuka kamera: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + void _toggleAutoMode() { + setState(() { + _isAutoMode = !_isAutoMode; + }); + + if (_isAutoMode) { + // Start auto detection every 3 seconds + _autoDetectTimer = Timer.periodic(const Duration(seconds: 3), (timer) { + if (!_isDetecting) { + _captureAndDetect(); + } + }); + } else { + // Stop auto detection + _autoDetectTimer?.cancel(); + _autoDetectTimer = null; + } + } + + Future _captureAndDetect() async { + if (_cameraController == null || !_cameraController!.value.isInitialized) { + return; + } + + if (_isDetecting) return; + + setState(() { + _isDetecting = true; + }); + + try { + // Capture image + final XFile image = await _cameraController!.takePicture(); + + // Resize for better quality (800x800 instead of 200x200) + final File processedImage = await _resizeImage(File(image.path)); + + // Process and detect + await _detectBatik(processedImage); + + setState(() { + _lastDetectionTime = DateTime.now(); + }); + } catch (e) { + print('Error capturing/detecting: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: $e'), + backgroundColor: Colors.red, + duration: const Duration(seconds: 2), + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isDetecting = false; + }); + } + } + } + + Future _detectBatik(File imageFile) async { + try { + final sessionHash = DateTime.now().millisecondsSinceEpoch.toString(); + + // Step 1: Upload file + final uploadUrl = 'https://rimsj-batik-classifier.hf.space/upload'; + var uploadRequest = http.MultipartRequest('POST', Uri.parse(uploadUrl)); + uploadRequest.files.add( + await http.MultipartFile.fromPath('files', imageFile.path), + ); + + final uploadResponse = await uploadRequest.send().timeout( + const Duration(seconds: 10), + ); + final uploadResult = await http.Response.fromStream(uploadResponse); + + if (uploadResult.statusCode != 200) { + throw Exception('Upload failed'); + } + + final uploadData = jsonDecode(uploadResult.body); + Map? fileData; + + if (uploadData is List && uploadData.isNotEmpty) { + if (uploadData[0] is Map) { + fileData = uploadData[0] as Map; + } else if (uploadData[0] is String) { + fileData = {'path': uploadData[0]}; + } + } + + if (fileData == null) throw Exception('Upload failed'); + + // Step 2: Join queue + final queueUrl = 'https://rimsj-batik-classifier.hf.space/queue/join'; + final callResponse = await http + .post( + Uri.parse(queueUrl), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'data': [fileData], + 'fn_index': 0, + 'session_hash': sessionHash, + }), + ) + .timeout(const Duration(seconds: 5)); + + final callResult = jsonDecode(callResponse.body); + final eventId = callResult['event_id']; + + // Step 3: Get result + final resultUrl = + 'https://rimsj-batik-classifier.hf.space/queue/data?session_hash=$sessionHash'; + final resultResponse = await http + .get(Uri.parse(resultUrl)) + .timeout(const Duration(seconds: 30)); + + if (resultResponse.statusCode == 200) { + final lines = resultResponse.body.split('\n'); + Map? successResult; + + for (var line in lines) { + if (line.startsWith('data: ')) { + final dataLine = line.substring(6); + if (dataLine.trim().isEmpty) continue; + + try { + final event = jsonDecode(dataLine); + if (event['msg'] == 'process_completed' && + event['success'] == true) { + successResult = event; + break; + } + } catch (e) { + continue; + } + } + } + + if (successResult != null) { + final output = successResult['output']; + if (output != null && output['data'] != null) { + final data = output['data']; + if (data is List && data.isNotEmpty && data[0] is Map) { + final result = data[0] as Map; + + if (result.containsKey('label')) { + String topLabel = result['label'] as String; + double topConfidence = 0.0; + + if (result['confidences'] is List) { + final confidencesList = result['confidences'] as List; + for (var item in confidencesList) { + if (item is Map && + item['label'] == topLabel && + item['confidence'] is num) { + topConfidence = (item['confidence'] as num).toDouble(); + break; + } + } + } + + // Clean nama motif + String cleanMotif = topLabel; + if (topLabel.contains('_')) { + cleanMotif = topLabel.split('_').last; + } + + setState(() { + _detectedMotif = cleanMotif; + _confidence = topConfidence; + }); + + return; + } + } + } + } + } + + throw Exception('Detection failed'); + } catch (e) { + print('Detection error: $e'); + rethrow; + } + } + + Future _resizeImage(File imageFile) async { + try { + // Read image + final bytes = await imageFile.readAsBytes(); + img.Image? image = img.decodeImage(bytes); + + if (image == null) return imageFile; + + // Resize to 800x800 maintaining aspect ratio with high quality + img.Image resized = img.copyResize( + image, + width: 800, + height: 800, + interpolation: img.Interpolation.cubic, + ); + + // Encode with high quality (95%) + final resizedBytes = img.encodeJpg(resized, quality: 95); + + // Save to temp file + final tempDir = await getTemporaryDirectory(); + final tempPath = path.join( + tempDir.path, + 'batik_${DateTime.now().millisecondsSinceEpoch}.jpg', + ); + final tempFile = File(tempPath); + await tempFile.writeAsBytes(resizedBytes); + + return tempFile; + } catch (e) { + print('Resize error: $e'); + return imageFile; // Return original if resize fails + } + } + + @override + void dispose() { + _autoDetectTimer?.cancel(); + _cameraController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + // Camera Preview + if (_isInitialized && _cameraController != null) + Positioned.fill(child: CameraPreview(_cameraController!)) + else + const Center(child: CircularProgressIndicator(color: Colors.white)), + + // Top Bar + Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + 10, + left: 16, + right: 16, + bottom: 16, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black.withOpacity(0.7), Colors.transparent], + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon( + Icons.close, + color: Colors.white, + size: 28, + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.6), + borderRadius: BorderRadius.circular(20), + ), + child: const Row( + children: [ + Icon(Icons.camera_alt, color: Colors.white, size: 20), + SizedBox(width: 8), + Text( + 'Deteksi Batik', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const SizedBox(width: 48), + ], + ), + ), + ), + + // Detection Result Overlay (Top Center) + if (_detectedMotif != null) + Positioned( + top: MediaQuery.of(context).padding.top + 80, + left: 20, + right: 20, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.8), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFF6366F1), width: 2), + ), + child: Column( + children: [ + Row( + children: [ + const Icon( + Icons.check_circle, + color: Colors.green, + size: 24, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _detectedMotif!, + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Confidence: ${(_confidence! * 100).toStringAsFixed(1)}%', + style: TextStyle( + color: Colors.grey[300], + fontSize: 14, + ), + ), + if (_lastDetectionTime != null) + Text( + '${DateTime.now().difference(_lastDetectionTime!).inSeconds}s ago', + style: TextStyle( + color: Colors.grey[400], + fontSize: 12, + ), + ), + ], + ), + ], + ), + ), + ), + + // Bottom Controls + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: EdgeInsets.only( + left: 16, + right: 16, + bottom: MediaQuery.of(context).padding.bottom + 20, + top: 20, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [Colors.black.withOpacity(0.8), Colors.transparent], + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Auto Mode Toggle + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: _isAutoMode + ? const Color(0xFF6366F1).withOpacity(0.3) + : Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _isAutoMode + ? const Color(0xFF6366F1) + : Colors.white.withOpacity(0.3), + width: 2, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + _isAutoMode ? Icons.auto_mode : Icons.touch_app, + color: Colors.white, + size: 20, + ), + const SizedBox(width: 8), + Text( + _isAutoMode + ? 'Mode Auto (Setiap 3 detik)' + : 'Mode Manual', + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(height: 20), + + // Capture/Detect Buttons + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Toggle Auto Mode + Column( + children: [ + GestureDetector( + onTap: _toggleAutoMode, + child: Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: _isAutoMode + ? const Color(0xFF6366F1) + : Colors.white.withOpacity(0.2), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: 3, + ), + ), + child: Icon( + _isAutoMode ? Icons.pause : Icons.play_arrow, + color: Colors.white, + size: 30, + ), + ), + ), + const SizedBox(height: 8), + Text( + _isAutoMode ? 'Stop' : 'Auto', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ], + ), + + // Manual Capture + Column( + children: [ + GestureDetector( + onTap: _isDetecting ? null : _captureAndDetect, + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: _isDetecting + ? Colors.grey + : Colors.white, + shape: BoxShape.circle, + border: Border.all( + color: const Color(0xFF6366F1), + width: 4, + ), + ), + child: _isDetecting + ? const Padding( + padding: EdgeInsets.all(20), + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: + AlwaysStoppedAnimation( + Color(0xFF6366F1), + ), + ), + ) + : const Icon( + Icons.camera, + color: Color(0xFF6366F1), + size: 40, + ), + ), + ), + const SizedBox(height: 8), + const Text( + 'Deteksi', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + + // View Result Detail + Column( + children: [ + GestureDetector( + onTap: _detectedMotif != null + ? () { + Navigator.pop(context, _detectedMotif); + } + : null, + child: Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: _detectedMotif != null + ? Colors.green + : Colors.white.withOpacity(0.2), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: 3, + ), + ), + child: const Icon( + Icons.check, + color: Colors.white, + size: 30, + ), + ), + ), + const SizedBox(height: 8), + const Text( + 'Selesai', + style: TextStyle(color: Colors.white, fontSize: 12), + ), + ], + ), + ], + ), + ], + ), + ), + ), + + // Processing Indicator + if (_isDetecting && !_isAutoMode) + Positioned.fill( + child: Container( + color: Colors.black.withOpacity(0.5), + child: const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + SizedBox(height: 16), + Text( + 'Mendeteksi motif batik...', + style: TextStyle(color: Colors.white, fontSize: 16), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/features/marketplace/warga/batik_detection_page.dart b/apps/mobile/lib/features/marketplace/warga/batik_detection_page.dart new file mode 100644 index 0000000..854966c --- /dev/null +++ b/apps/mobile/lib/features/marketplace/warga/batik_detection_page.dart @@ -0,0 +1,989 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:http/http.dart' as http; + +class BatikDetectionPage extends StatefulWidget { + const BatikDetectionPage({super.key}); + + @override + State createState() => _BatikDetectionPageState(); +} + +class _BatikDetectionPageState extends State { + File? _image; + final ImagePicker _picker = ImagePicker(); + bool _isProcessing = false; + Map? _detectionResult; + String? _errorMessage; + + // Info motif batik untuk ditampilkan setelah deteksi + final Map> batikInfo = { + 'Parang': { + 'origin': 'Yogyakarta & Solo', + 'meaning': 'Simbol kekuatan, keteguhan, dan keberanian', + 'characteristics': 'Motif miring dengan pola parang berjajar diagonal', + }, + 'Kawung': { + 'origin': 'Solo & Yogyakarta', + 'meaning': 'Simbol kesempurnaan, kesucian, dan kebijaksanaan', + 'characteristics': + 'Pola bulat oval yang tersusun simetris seperti buah kawung', + }, + 'Mega Mendung': { + 'origin': 'Cirebon, Jawa Barat', + 'meaning': 'Simbol kesabaran, ketenangan, dan pembawa hujan', + 'characteristics': 'Motif awan dengan gradasi warna biru yang khas', + }, + 'Truntum': { + 'origin': 'Solo, Jawa Tengah', + 'meaning': 'Simbol cinta yang tumbuh kembali dan kesetiaan', + 'characteristics': 'Motif bunga kecil yang rapat menyebar', + }, + 'Sido Mukti': { + 'origin': 'Yogyakarta & Solo', + 'meaning': 'Simbol kemakmuran dan kebahagiaan hidup', + 'characteristics': 'Motif simetris dengan pola sayap dan bunga', + }, + 'Sekar Jagad': { + 'origin': 'Yogyakarta', + 'meaning': 'Simbol keindahan dan keberagaman dunia', + 'characteristics': 'Gabungan berbagai motif batik dalam satu kain', + }, + 'Ceplok': { + 'origin': 'Jawa Tengah', + 'meaning': 'Simbol keseimbangan dan harmoni', + 'characteristics': + 'Motif geometris berbentuk lingkaran atau kotak berulang', + }, + 'Tambal': { + 'origin': 'Yogyakarta', + 'meaning': 'Simbol penyembuhan dan harapan', + 'characteristics': 'Gabungan potongan-potongan berbagai motif batik', + }, + }; + + Future _pickImage(ImageSource source) async { + try { + final XFile? pickedFile = await _picker.pickImage( + source: source, + maxWidth: 800, + maxHeight: 800, + imageQuality: 95, + ); + + if (pickedFile != null) { + setState(() { + _image = File(pickedFile.path); + _detectionResult = null; + _errorMessage = null; + }); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Gagal mengambil gambar: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _processImage() async { + if (_image == null) return; + + setState(() { + _isProcessing = true; + _errorMessage = null; + _detectionResult = null; + }); + + try { + print('Starting image processing...'); + + final sessionHash = DateTime.now().millisecondsSinceEpoch.toString(); + + // Step 1: Upload file ke Gradio + final uploadUrl = 'https://rimsj-batik-classifier.hf.space/upload'; + print('Step 1: Uploading file to: $uploadUrl'); + + var uploadRequest = http.MultipartRequest('POST', Uri.parse(uploadUrl)); + uploadRequest.files.add( + await http.MultipartFile.fromPath( + 'files', + _image!.path, + filename: 'batik.jpg', + ), + ); + + final uploadResponse = await uploadRequest.send().timeout( + const Duration(seconds: 30), + onTimeout: () { + throw Exception('Upload timeout'); + }, + ); + + final uploadResult = await http.Response.fromStream(uploadResponse); + print('Upload status: ${uploadResult.statusCode}'); + print('Upload response: ${uploadResult.body}'); + + if (uploadResult.statusCode != 200) { + throw Exception('Upload failed: ${uploadResult.statusCode}'); + } + + // Parse upload response untuk dapat file path + final uploadData = jsonDecode(uploadResult.body); + print('Upload data: $uploadData'); + + // Gradio upload response format: [{"path": "...", "size": ..., "orig_name": "..."}] + Map? fileData; + + if (uploadData is List && uploadData.isNotEmpty) { + if (uploadData[0] is Map) { + fileData = uploadData[0] as Map; + } else if (uploadData[0] is String) { + // Jika cuma string path, buat FileData object + fileData = {'path': uploadData[0]}; + } + } + + if (fileData == null || !fileData.containsKey('path')) { + throw Exception('Failed to get uploaded file data'); + } + + print('Uploaded file data: $fileData'); + + // Step 2: Join queue dengan FileData object + final queueUrl = 'https://rimsj-batik-classifier.hf.space/queue/join'; + print('Step 2: Joining queue at: $queueUrl'); + + final callResponse = await http + .post( + Uri.parse(queueUrl), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'data': [fileData], // Kirim sebagai FileData object, bukan string + 'fn_index': 0, + 'session_hash': sessionHash, + }), + ) + .timeout( + const Duration(seconds: 10), + onTimeout: () { + throw Exception('Call timeout'); + }, + ); + + print('Call response status: ${callResponse.statusCode}'); + print('Call response body: ${callResponse.body}'); + + if (callResponse.statusCode != 200) { + throw Exception('Call failed: ${callResponse.statusCode}'); + } + + final callResult = jsonDecode(callResponse.body); + final eventId = callResult['event_id']; + + if (eventId == null) { + throw Exception('No event_id in response'); + } + + print('Got event_id: $eventId'); + + // Step 2: Poll result dari /queue/data dengan SSE + final resultUrl = + 'https://rimsj-batik-classifier.hf.space/queue/data?session_hash=$sessionHash'; + print('Step 2: Fetching result from: $resultUrl'); + + final resultResponse = await http + .get(Uri.parse(resultUrl)) + .timeout( + const Duration(seconds: 60), + onTimeout: () { + throw Exception('Result timeout'); + }, + ); + + print('Result response status: ${resultResponse.statusCode}'); + print('Result response body: ${resultResponse.body}'); + + if (resultResponse.statusCode == 200) { + // Parse SSE response - cari event process_completed yang success + final lines = resultResponse.body.split('\n'); + Map? successResult; + + for (var line in lines) { + if (line.startsWith('data: ')) { + final dataLine = line.substring(6); // Remove 'data: ' prefix + if (dataLine.trim().isEmpty) continue; + + try { + final event = jsonDecode(dataLine); + print('Event: ${event['msg']}'); + + // Cari event process_completed yang success + if (event['msg'] == 'process_completed' && + event['success'] == true) { + successResult = event; + print('Found success event: $successResult'); + break; + } + } catch (e) { + continue; + } + } + } + + if (successResult == null) { + // Coba print semua events untuk debugging + print('All SSE lines:'); + for (var line in lines) { + if (line.startsWith('data: ')) { + print(line); + } + } + throw Exception( + 'Model gagal memproses gambar. Coba gambar yang lebih kecil.', + ); + } + + // Extract data dari output + final output = successResult['output']; + if (output == null || output['data'] == null) { + throw Exception('No data in output'); + } + + final data = output['data']; + print('Output data: $data'); + + // Gradio response format: data: [results_dict, markdown_text] + if (data is List && data.isNotEmpty) { + if (data[0] is Map) { + final result = data[0] as Map; + + print('Result map: $result'); + + // Gradio Label component format: {label: "xxx", confidences: [{label: "xxx", confidence: 0.xx}, ...]} + String? topLabel; + double topConfidence = 0.0; + List> allConfidences = []; + + if (result.containsKey('label') && + result.containsKey('confidences')) { + topLabel = result['label'] as String?; + + if (result['confidences'] is List) { + final confidencesList = result['confidences'] as List; + + for (var item in confidencesList) { + if (item is Map) { + final label = item['label']?.toString() ?? ''; + final conf = (item['confidence'] is num) + ? (item['confidence'] as num).toDouble() + : 0.0; + + // Clean nama (hapus prefix wilayah) + String cleanLabel = label; + if (label.contains('_')) { + cleanLabel = label.split('_').last; + } + + allConfidences.add({ + 'label': cleanLabel, + 'confidence': conf, + }); + + // Get top confidence + if (label == topLabel) { + topConfidence = conf; + } + } + } + } + } + + if (topLabel == null || topLabel.isEmpty) { + throw Exception('Tidak ada label prediksi'); + } + + // Clean nama motif (hapus prefix wilayah) + String detectedMotif = topLabel; + if (topLabel.contains('_')) { + detectedMotif = topLabel.split('_').last; + } + + // Cari info motif + final info = _findBatikInfo(detectedMotif); + + setState(() { + _detectionResult = { + 'name': detectedMotif, + 'confidence': topConfidence, + 'origin': info['origin'], + 'meaning': info['meaning'], + 'characteristics': info['characteristics'], + 'allConfidences': allConfidences, + }; + _isProcessing = false; + }); + + print( + 'Success! Detected: $detectedMotif with confidence: ${topConfidence * 100}%', + ); + } else { + throw Exception('Format response tidak sesuai'); + } + } else { + throw Exception('Response tidak mengandung data valid'); + } + } else if (resultResponse.statusCode == 500) { + throw Exception( + 'Server error (500) - Coba refresh halaman web Hugging Face dulu', + ); + } else if (resultResponse.statusCode == 503) { + throw Exception( + 'Service unavailable (503) - Server cold start, tunggu 1 menit', + ); + } else { + throw Exception( + 'HTTP ${resultResponse.statusCode}: ${resultResponse.body}', + ); + } + } catch (e) { + print('Error details: $e'); + setState(() { + _isProcessing = false; + _errorMessage = 'Gagal mendeteksi: $e'; + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: ${e.toString()}'), + backgroundColor: Colors.red, + duration: Duration(seconds: 3), + ), + ); + } + } + } + + Map _findBatikInfo(String motifName) { + // Cari info yang cocok dengan nama motif + for (var key in batikInfo.keys) { + if (motifName.toLowerCase().contains(key.toLowerCase())) { + return batikInfo[key]!; + } + } + // Default info jika tidak ditemukan + return { + 'origin': 'Indonesia', + 'meaning': 'Motif batik tradisional Indonesia', + 'characteristics': 'Motif batik dengan keunikan tersendiri', + }; + } + + void _showImageSourceDialog() { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => Container( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + const Text( + 'Pilih Sumber Gambar', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: _buildImageSourceButton( + icon: Icons.camera_alt, + label: 'Kamera', + onTap: () { + Navigator.pop(context); + _pickImage(ImageSource.camera); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildImageSourceButton( + icon: Icons.photo_library, + label: 'Galeri', + onTap: () { + Navigator.pop(context); + _pickImage(ImageSource.gallery); + }, + ), + ), + ], + ), + const SizedBox(height: 20), + ], + ), + ), + ); + } + + Widget _buildImageSourceButton({ + required IconData icon, + required String label, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[300]!), + ), + child: Column( + children: [ + Icon(icon, size: 40, color: const Color(0xFF6366F1)), + const SizedBox(height: 8), + Text( + label, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text( + 'Deteksi Motif Batik', + style: TextStyle(fontWeight: FontWeight.bold), + ), + backgroundColor: Colors.white, + elevation: 0, + ), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Info Card + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)], + ), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: const [ + Icon(Icons.info_outline, color: Colors.white), + SizedBox(width: 8), + Text( + 'Cara Menggunakan', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 12), + const Text( + '1. Ambil foto atau pilih gambar batik\n' + '2. Klik tombol "Deteksi Motif"\n' + '3. Lihat hasil deteksi dan informasi motif\n' + '4. Cari produk dengan motif yang sama', + style: TextStyle( + color: Colors.white, + fontSize: 14, + height: 1.5, + ), + ), + ], + ), + ), + const SizedBox(height: 12), + + // AI Model Info + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.green[50], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.green[200]!), + ), + child: Row( + children: [ + Icon(Icons.smart_toy, color: Colors.green[700], size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + 'VGG16 Model • 111 Motif Batik Indonesia', + style: TextStyle( + color: Colors.green[700], + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // Image Preview Area + Container( + height: 300, + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[300]!, width: 2), + ), + child: _image == null + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.image_outlined, + size: 80, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + 'Belum ada gambar', + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 8), + Text( + 'Tap tombol di bawah untuk mengambil foto', + style: TextStyle( + fontSize: 12, + color: Colors.grey[500], + ), + ), + ], + ), + ) + : Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SizedBox( + width: double.infinity, + height: double.infinity, + child: Image.file(_image!, fit: BoxFit.cover), + ), + ), + if (_isProcessing) + Container( + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + borderRadius: BorderRadius.circular(10), + ), + child: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + color: Colors.white, + strokeWidth: 3, + ), + SizedBox(height: 16), + Text( + 'Menganalisis motif batik...', + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + + // Error Message + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.red[50], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red[200]!), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.error_outline, + color: Colors.red[700], + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle( + color: Colors.red[700], + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + if (_errorMessage!.contains('500') || + _errorMessage!.contains('503')) + Padding( + padding: const EdgeInsets.only(top: 8, left: 28), + child: Text( + 'Tips: Server Hugging Face mungkin cold start. Tunggu 1-2 menit dan coba lagi.', + style: TextStyle( + color: Colors.red[600], + fontSize: 11, + ), + ), + ), + ], + ), + ), + + // Action Buttons + if (_image == null) + ElevatedButton.icon( + onPressed: _showImageSourceDialog, + icon: const Icon(Icons.add_a_photo), + label: const Text('Ambil/Pilih Gambar'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6366F1), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 2, + ), + ) + else + Column( + children: [ + // Detect Button + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isProcessing ? null : _processImage, + icon: _isProcessing + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.auto_awesome), + label: Text( + _isProcessing ? 'Mendeteksi...' : 'Deteksi Motif', + style: const TextStyle(fontSize: 16), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6366F1), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 2, + ), + ), + ), + const SizedBox(height: 12), + // Change Image Button + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _isProcessing + ? null + : () { + setState(() { + _image = null; + _detectionResult = null; + _errorMessage = null; + }); + }, + icon: const Icon(Icons.refresh), + label: const Text('Ganti Gambar'), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF6366F1), + side: const BorderSide(color: Color(0xFF6366F1)), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + + // Detection Result + if (_detectionResult != null) ...[ + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.green[300]!), + boxShadow: [ + BoxShadow( + color: Colors.green.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.green[50], + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.check_circle, + color: Colors.green[600], + ), + ), + const SizedBox(width: 12), + const Text( + 'Hasil Deteksi', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 20), + + // Detected Motif Name + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + const Color(0xFF6366F1).withOpacity(0.1), + const Color(0xFF8B5CF6).withOpacity(0.1), + ], + ), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + children: [ + Text( + _detectionResult!['name'], + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Color(0xFF6366F1), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: _getConfidenceColor( + _detectionResult!['confidence'], + ), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + 'Akurasi: ${(_detectionResult!['confidence'] * 100).toStringAsFixed(1)}%', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + _buildResultRow( + 'Asal Daerah', + _detectionResult!['origin'], + ), + _buildResultRow('Makna', _detectionResult!['meaning']), + _buildResultRow( + 'Karakteristik', + _detectionResult!['characteristics'], + ), + + // Top Predictions (if available) + if (_detectionResult!['allConfidences'] != null && + (_detectionResult!['allConfidences'] as List).length > + 1) ...[ + const SizedBox(height: 16), + const Text( + 'Prediksi Lainnya:', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.grey, + ), + ), + const SizedBox(height: 8), + ...(_detectionResult!['allConfidences'] as List) + .skip(1) + .take(4) + .map( + (conf) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + conf['label'], + style: TextStyle( + fontSize: 13, + color: Colors.grey[600], + ), + ), + Text( + '${(conf['confidence'] * 100).toStringAsFixed(1)}%', + style: TextStyle( + fontSize: 13, + color: Colors.grey[600], + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ], + + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + Navigator.pop(context, _detectionResult!['name']); + }, + icon: const Icon(Icons.shopping_bag), + label: const Text('Cari Produk dengan Motif Ini'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF10B981), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: 24), + ], + ), + ), + ), + ); + } + + Color _getConfidenceColor(double confidence) { + if (confidence >= 0.8) { + return Colors.green; + } else if (confidence >= 0.6) { + return Colors.orange; + } else { + return Colors.red; + } + } + + Widget _buildResultRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + label, + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + value, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/features/marketplace/warga/detail_produk_batik.dart b/apps/mobile/lib/features/marketplace/warga/detail_produk_batik.dart new file mode 100644 index 0000000..1b8786f --- /dev/null +++ b/apps/mobile/lib/features/marketplace/warga/detail_produk_batik.dart @@ -0,0 +1,498 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class DetailProdukBatik extends StatefulWidget { + final Map product; + + const DetailProdukBatik({super.key, required this.product}); + + @override + State createState() => _DetailProdukBatikState(); +} + +class _DetailProdukBatikState extends State { + int quantity = 1; + String selectedSize = 'M'; + final List sizes = ['S', 'M', 'L', 'XL', 'XXL']; + + void _addToCart() { + Get.snackbar( + 'Berhasil', + '${widget.product['name']} ditambahkan ke keranjang', + backgroundColor: Colors.green[100], + colorText: Colors.green[900], + icon: const Icon(Icons.check_circle, color: Colors.green), + snackPosition: SnackPosition.BOTTOM, + margin: const EdgeInsets.all(16), + duration: const Duration(seconds: 2), + ); + } + + void _buyNow() { + Get.snackbar( + 'Proses Pembelian', + 'Fitur checkout akan segera tersedia', + backgroundColor: Colors.blue[100], + colorText: Colors.blue[900], + icon: const Icon(Icons.shopping_bag, color: Colors.blue), + snackPosition: SnackPosition.BOTTOM, + margin: const EdgeInsets.all(16), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: CustomScrollView( + slivers: [ + // App Bar with Image + SliverAppBar( + expandedHeight: 350, + pinned: true, + backgroundColor: Colors.white, + leading: Container( + margin: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + ), + ], + ), + child: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black), + onPressed: () => Get.back(), + ), + ), + actions: [ + Container( + margin: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + ), + ], + ), + child: IconButton( + icon: const Icon(Icons.share, color: Colors.black), + onPressed: () {}, + ), + ), + Container( + margin: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + ), + ], + ), + child: IconButton( + icon: const Icon(Icons.favorite_border, color: Colors.black), + onPressed: () {}, + ), + ), + ], + flexibleSpace: FlexibleSpaceBar( + background: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.grey[300]!, + Colors.grey[100]!, + ], + ), + ), + child: Image.network( + widget.product['image'], + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Center( + child: Icon( + Icons.image_outlined, + size: 80, + color: Colors.grey[400], + ), + ); + }, + ), + ), + ), + ), + + // Product Details + SliverToBoxAdapter( + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Product Name + Text( + widget.product['name'], + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + + // Motif Badge + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: const Color(0xFF6366F1).withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + 'Motif ${widget.product['motif']}', + style: const TextStyle( + color: Color(0xFF6366F1), + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 16), + + // Price + Row( + children: [ + Text( + 'Rp ${widget.product['price'].toString().replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}', + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Color(0xFF6366F1), + ), + ), + ], + ), + const SizedBox(height: 16), + + // Rating and Sold + Row( + children: [ + const Icon(Icons.star, color: Colors.amber, size: 20), + const SizedBox(width: 4), + Text( + widget.product['rating'].toString(), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 16), + Container( + width: 1, + height: 16, + color: Colors.grey[300], + ), + const SizedBox(width: 16), + Text( + '${widget.product['sold']} Terjual', + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + const SizedBox(width: 16), + Container( + width: 1, + height: 16, + color: Colors.grey[300], + ), + const SizedBox(width: 16), + Text( + 'Stok: ${widget.product['stock']}', + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + ], + ), + const SizedBox(height: 24), + + // Seller Info + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[200]!), + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: const Color(0xFF6366F1).withOpacity(0.1), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.store, + color: Color(0xFF6366F1), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.product['seller'], + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'Penjual Terpercaya', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + ], + ), + ), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.chevron_right), + ), + ], + ), + ), + const SizedBox(height: 24), + + // Size Selection + const Text( + 'Pilih Ukuran', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + children: sizes.map((size) { + final isSelected = selectedSize == size; + return GestureDetector( + onTap: () { + setState(() { + selectedSize = size; + }); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + decoration: BoxDecoration( + color: isSelected + ? const Color(0xFF6366F1) + : Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected + ? const Color(0xFF6366F1) + : Colors.grey[300]!, + ), + ), + child: Text( + size, + style: TextStyle( + color: isSelected + ? Colors.white + : Colors.black, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 24), + + // Quantity + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Jumlah', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + Row( + children: [ + IconButton( + onPressed: quantity > 1 + ? () { + setState(() { + quantity--; + }); + } + : null, + icon: const Icon(Icons.remove_circle_outline), + color: const Color(0xFF6366F1), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey[300]!), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + quantity.toString(), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + onPressed: quantity < widget.product['stock'] + ? () { + setState(() { + quantity++; + }); + } + : null, + icon: const Icon(Icons.add_circle_outline), + color: const Color(0xFF6366F1), + ), + ], + ), + ], + ), + const SizedBox(height: 24), + + // Description + const Text( + 'Deskripsi Produk', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Text( + widget.product['description'], + style: TextStyle( + fontSize: 14, + color: Colors.grey[700], + height: 1.6, + ), + ), + const SizedBox(height: 100), // Space for bottom bar + ], + ), + ), + ], + ), + ), + ), + ], + ), + + // Bottom Action Bar + bottomNavigationBar: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, -4), + ), + ], + ), + child: SafeArea( + child: Row( + children: [ + // Add to Cart Button + Expanded( + child: OutlinedButton( + onPressed: _addToCart, + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF6366F1), + side: const BorderSide(color: Color(0xFF6366F1), width: 2), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + 'Keranjang', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 12), + // Buy Now Button + Expanded( + flex: 2, + child: ElevatedButton( + onPressed: _buyNow, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6366F1), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 2, + ), + child: const Text( + 'Beli Sekarang', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile/lib/features/marketplace/warga/marketplace_warga.dart b/apps/mobile/lib/features/marketplace/warga/marketplace_warga.dart new file mode 100644 index 0000000..624257a --- /dev/null +++ b/apps/mobile/lib/features/marketplace/warga/marketplace_warga.dart @@ -0,0 +1,422 @@ +import 'package:flutter/material.dart'; +import 'batik_detection_page.dart'; +import 'batik_camera_page.dart'; +import 'detail_produk_batik.dart'; +import 'widget/product_card_warga.dart'; +import 'widget/category_chip.dart'; + +class MarketplaceWarga extends StatefulWidget { + const MarketplaceWarga({super.key}); + + @override + State createState() => _MarketplaceWargaState(); +} + +class _MarketplaceWargaState extends State { + String selectedCategory = 'Semua'; + final TextEditingController _searchController = TextEditingController(); + + // Dummy data produk batik + final List> products = [ + { + 'id': 1, + 'name': 'Batik Parang Rusak', + 'motif': 'Parang', + 'price': 250000, + 'seller': 'Toko Batik Jaya', + 'image': 'https://via.placeholder.com/150', + 'rating': 4.5, + 'sold': 120, + 'stock': 15, + 'description': 'Batik tulis dengan motif parang rusak khas Jogja', + }, + { + 'id': 2, + 'name': 'Batik Kawung Premium', + 'motif': 'Kawung', + 'price': 350000, + 'seller': 'Batik Nusantara', + 'image': 'https://via.placeholder.com/150', + 'rating': 4.8, + 'sold': 85, + 'stock': 10, + 'description': 'Batik cap motif kawung dengan kualitas premium', + }, + { + 'id': 3, + 'name': 'Batik Mega Mendung', + 'motif': 'Mega Mendung', + 'price': 300000, + 'seller': 'Cirebon Batik', + 'image': 'https://via.placeholder.com/150', + 'rating': 4.6, + 'sold': 95, + 'stock': 20, + 'description': 'Batik mega mendung khas Cirebon dengan warna cerah', + }, + { + 'id': 4, + 'name': 'Batik Truntum', + 'motif': 'Truntum', + 'price': 280000, + 'seller': 'Batik Heritage', + 'image': 'https://via.placeholder.com/150', + 'rating': 4.7, + 'sold': 110, + 'stock': 12, + 'description': 'Batik truntum dengan makna cinta yang tumbuh', + }, + ]; + + List categories = [ + 'Semua', + 'Parang', + 'Kawung', + 'Mega Mendung', + 'Truntum', + 'Sido Mukti', + ]; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + List> get filteredProducts { + if (selectedCategory == 'Semua') { + return products; + } + return products.where((p) => p['motif'] == selectedCategory).toList(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.grey[100], + appBar: AppBar( + title: const Text( + 'Marketplace Batik', + style: TextStyle(fontWeight: FontWeight.bold), + ), + backgroundColor: Colors.white, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.shopping_cart_outlined), + onPressed: () { + // Navigate to cart + }, + ), + ], + ), + body: Column( + children: [ + // Search Bar & Camera Button + Container( + color: Colors.white, + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Cari produk batik...', + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey[300]!), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey[300]!), + ), + filled: true, + fillColor: Colors.grey[50], + ), + onChanged: (value) { + setState(() {}); + }, + ), + ), + const SizedBox(width: 12), + // Camera Button for Batik Detection + Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)], + ), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: const Color(0xFF6366F1).withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + // Show option: Camera Realtime atau Upload + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + builder: (context) => Container( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + const Text( + 'Deteksi Motif Batik', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 20), + // Camera Real-time + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color( + 0xFF6366F1, + ).withOpacity(0.1), + borderRadius: BorderRadius.circular( + 8, + ), + ), + child: const Icon( + Icons.camera_alt, + color: Color(0xFF6366F1), + ), + ), + title: const Text( + 'Camera Real-time', + style: TextStyle( + fontWeight: FontWeight.w600, + ), + ), + subtitle: const Text( + 'Deteksi otomatis setiap 3 detik', + style: TextStyle(fontSize: 12), + ), + trailing: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular( + 12, + ), + ), + child: const Text( + 'NEW', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + onTap: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const BatikCameraPage(), + ), + ); + }, + ), + const Divider(), + // Upload/Gallery + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular( + 8, + ), + ), + child: Icon( + Icons.photo_library, + color: Colors.grey[700], + ), + ), + title: const Text( + 'Upload Foto', + style: TextStyle( + fontWeight: FontWeight.w600, + ), + ), + subtitle: const Text( + 'Pilih dari galeri atau ambil foto', + style: TextStyle(fontSize: 12), + ), + onTap: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const BatikDetectionPage(), + ), + ); + }, + ), + const SizedBox(height: 10), + ], + ), + ), + ); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + child: const Icon( + Icons.camera_alt, + color: Colors.white, + size: 28, + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + // Info banner untuk fitur kamera + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF6366F1).withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: const Color(0xFF6366F1).withOpacity(0.3), + ), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + color: const Color(0xFF6366F1), + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Tap ikon kamera untuk mendeteksi motif batik!', + style: TextStyle( + color: const Color(0xFF6366F1), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + ), + ), + + // Category Filter + Container( + height: 50, + color: Colors.white, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: categories.length, + itemBuilder: (context, index) { + return CategoryChip( + label: categories[index], + isSelected: selectedCategory == categories[index], + onTap: () { + setState(() { + selectedCategory = categories[index]; + }); + }, + ); + }, + ), + ), + + // Products Grid + Expanded( + child: filteredProducts.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.inventory_2_outlined, + size: 80, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + 'Tidak ada produk', + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + ), + ], + ), + ) + : GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 0.7, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + itemCount: filteredProducts.length, + itemBuilder: (context, index) { + final product = filteredProducts[index]; + return ProductCardWarga( + product: product, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + DetailProdukBatik(product: product), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/features/marketplace/warga/widget/category_chip.dart b/apps/mobile/lib/features/marketplace/warga/widget/category_chip.dart new file mode 100644 index 0000000..87f49dc --- /dev/null +++ b/apps/mobile/lib/features/marketplace/warga/widget/category_chip.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +class CategoryChip extends StatelessWidget { + final String label; + final bool isSelected; + final VoidCallback onTap; + + const CategoryChip({ + super.key, + required this.label, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFF6366F1) : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected ? const Color(0xFF6366F1) : Colors.grey[300]!, + ), + boxShadow: isSelected + ? [ + BoxShadow( + color: const Color(0xFF6366F1).withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Text( + label, + style: TextStyle( + color: isSelected ? Colors.white : Colors.grey[700], + fontWeight: isSelected ? FontWeight.bold : FontWeight.w500, + fontSize: 13, + ), + ), + ), + ), + ); + } +} diff --git a/apps/mobile/lib/features/marketplace/warga/widget/product_card_warga.dart b/apps/mobile/lib/features/marketplace/warga/widget/product_card_warga.dart new file mode 100644 index 0000000..78f5458 --- /dev/null +++ b/apps/mobile/lib/features/marketplace/warga/widget/product_card_warga.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; + +class ProductCardWarga extends StatelessWidget { + final Map product; + final VoidCallback onTap; + + const ProductCardWarga({ + super.key, + required this.product, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Product Image + Container( + height: 140, + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: const BorderRadius.vertical( + top: Radius.circular(12), + ), + ), + child: Stack( + children: [ + ClipRRect( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(12), + ), + child: Image.network( + product['image'], + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Center( + child: Icon( + Icons.image_outlined, + size: 40, + color: Colors.grey[400], + ), + ); + }, + ), + ), + // Favorite Button + Positioned( + top: 8, + right: 8, + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 4, + ), + ], + ), + child: const Icon( + Icons.favorite_border, + size: 16, + color: Colors.red, + ), + ), + ), + // Motif Badge + Positioned( + bottom: 8, + left: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.6), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + product['motif'], + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + + // Product Info + Expanded( + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Product Name + Text( + product['name'], + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + height: 1.3, + ), + ), + const SizedBox(height: 4), + + // Price + Text( + 'Rp ${product['price'].toString().replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Color(0xFF6366F1), + ), + ), + const Spacer(), + + // Rating and Sold + Row( + children: [ + const Icon( + Icons.star, + size: 12, + color: Colors.amber, + ), + const SizedBox(width: 2), + Text( + product['rating'].toString(), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 4), + Text( + '| ${product['sold']}', + style: TextStyle( + fontSize: 11, + color: Colors.grey[600], + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/lib/models/buttom_navbar.dart b/apps/mobile/lib/models/buttom_navbar.dart index 24a1ddb..7957214 100644 --- a/apps/mobile/lib/models/buttom_navbar.dart +++ b/apps/mobile/lib/models/buttom_navbar.dart @@ -37,10 +37,7 @@ class _AppBottomNavBarState extends State { page = const LainnyaPage(); } - Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (_) => page), - ); + Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => page)); } @override @@ -63,13 +60,41 @@ class _AppBottomNavBarState extends State { borderRadius: BorderRadius.circular(32), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14), - child: Row(children: const [ - Expanded(child: _NavItem(icon: Icons.home, label: "Beranda", index: 0)), - Expanded(child: _NavItem(icon: Icons.people, label: "Kependudukan", index: 1)), - Expanded(child: _NavItem(icon: Icons.account_balance, label: "Keuangan", index: 2)), - Expanded(child: _NavItem(icon: Icons.store, label: "Marketplace", index: 3)), - Expanded(child: _NavItem(icon: Icons.more_horiz, label: "Lainnya", index: 4)), - ]), + child: Row( + children: const [ + Expanded( + child: _NavItem(icon: Icons.home, label: "Beranda", index: 0), + ), + Expanded( + child: _NavItem( + icon: Icons.people, + label: "Kependudukan", + index: 1, + ), + ), + Expanded( + child: _NavItem( + icon: Icons.account_balance, + label: "Keuangan", + index: 2, + ), + ), + Expanded( + child: _NavItem( + icon: Icons.store, + label: "Marketplace", + index: 3, + ), + ), + Expanded( + child: _NavItem( + icon: Icons.more_horiz, + label: "Lainnya", + index: 4, + ), + ), + ], + ), ), ), ); @@ -114,7 +139,7 @@ class _NavItem extends StatelessWidget { Shadow( color: Colors.cyan.withOpacity(0.4), blurRadius: 6, - ) + ), ] : [], ), diff --git a/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift index 14b5f7c..b878e03 100644 --- a/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,7 +6,9 @@ import FlutterMacOS import Foundation import file_selector_macos +import path_provider_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) } diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index f91fe27..d5a65b0 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" async: dependency: transitive description: @@ -17,6 +25,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + camera: + dependency: "direct main" + description: + name: camera + sha256: dfa8fc5a1adaeb95e7a54d86a5bd56f4bb0e035515354c8ac6d262e35cec2ec8 + url: "https://pub.dev" + source: hosted + version: "0.10.6" + camera_android: + dependency: transitive + description: + name: camera_android + sha256: "50c0d1c4b122163e3d7cdfcd6d4cd8078aac27d0f1cd1e7b3fa69e6b3f06f4b7" + url: "https://pub.dev" + source: hosted + version: "0.10.10+14" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "035b90c1e33c2efad7548f402572078f6e514d4f82be0a315cd6c6af7e855aa8" + url: "https://pub.dev" + source: hosted + version: "0.9.22+6" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: "98cfc9357e04bad617671b4c1f78a597f25f08003089dd94050709ae54effc63" + url: "https://pub.dev" + source: hosted + version: "2.12.0" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: "3bc7bb1657a0f29c34116453c5d5e528c23efcf5e75aac0a3387cf108040bf65" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" characters: dependency: transitive description: @@ -49,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.5+1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -81,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" file_selector_linux: dependency: transitive description: @@ -152,8 +216,16 @@ packages: description: flutter source: sdk version: "0.0.0" + get: + dependency: "direct main" + description: + name: get + sha256: "5ed34a7925b85336e15d472cc4cfe7d9ebf4ab8e8b9f688585bf6b50f4c3d79a" + url: "https://pub.dev" + source: hosted + version: "4.7.3" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -161,13 +233,21 @@ packages: source: hosted version: "1.6.0" http_parser: - dependency: transitive + dependency: "direct main" description: name: http_parser sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: "direct main" + description: + name: image + sha256: "51555e36056541237b15b57afc31a0f53d4f9aefd9bd00873a6dc0090e54e332" + url: "https://pub.dev" + source: hosted + version: "4.6.0" image_picker: dependency: "direct main" description: @@ -305,13 +385,77 @@ packages: source: hosted version: "2.0.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + url: "https://pub.dev" + source: hosted + version: "2.5.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -320,6 +464,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" sky_engine: dependency: transitive description: flutter @@ -349,6 +501,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -405,6 +565,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" sdks: dart: ">=3.9.2 <4.0.0" flutter: ">=3.35.0" diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index 7cc728c..cdecb0e 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -12,9 +12,16 @@ dependencies: sdk: flutter intl: ^0.20.2 image_picker: ^1.0.7 + camera: ^0.10.5+5 + path_provider: ^2.1.1 + path: ^1.8.3 cupertino_icons: ^1.0.8 dropdown_search: ^6.0.1 fl_chart: ^1.1.1 + get: ^4.6.5 + http: ^1.2.0 + http_parser: ^4.0.2 + image: ^4.0.17 dev_dependencies: flutter_test: