first commit

This commit is contained in:
2026-01-20 16:34:54 +07:00
commit 2ac772b6de
4186 changed files with 123824 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
import 'package:camera/camera.dart';
class CameraService {
CameraController? controller;
late List<CameraDescription> _cameras;
bool _isFront = true;
bool get isFront => _isFront;
/// Init default
Future<void> init() async {
_cameras = await availableCameras();
final front = _cameras.firstWhere(
(c) => c.lensDirection == CameraLensDirection.front,
orElse: () => _cameras.first,
);
controller = CameraController(
front,
ResolutionPreset.high,
enableAudio: false,
);
await controller!.initialize();
_isFront = true;
}
/// Switch front <-> back
Future<void> switchCamera() async {
if (_cameras.length < 2) return;
final newCam = _cameras.firstWhere(
(c) =>
c.lensDirection ==
(_isFront ? CameraLensDirection.back : CameraLensDirection.front),
);
await controller?.dispose();
controller = CameraController(
newCam,
ResolutionPreset.high,
enableAudio: false,
);
await controller!.initialize();
_isFront = !_isFront;
}
Future<XFile> takePicture() async {
if (controller == null || !controller!.value.isInitialized) {
throw Exception('Camera not ready');
}
return controller!.takePicture();
}
void dispose() {
controller?.dispose();
}
}

View File

@@ -0,0 +1,54 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
class ImageCleanupService {
/// Hapus SEMUA file sementara
Future<void> cleanAllTemp(String tempDir) async {
final dir = Directory(tempDir);
if (!await dir.exists()) return;
for (final file in dir.listSync()) {
if (file is File) {
await file.delete();
}
}
}
/// Simpan maksimal N foto terakhir
Future<void> keepLastN({required String tempDir, int maxFiles = 10}) async {
final dir = Directory(tempDir);
if (!await dir.exists()) return;
final files =
dir
.listSync()
.whereType<File>()
.where((f) => f.path.contains('final_'))
.toList()
..sort(
(a, b) => a.statSync().modified.compareTo(b.statSync().modified),
);
if (files.length <= maxFiles) return;
for (final file in files.take(files.length - maxFiles)) {
await file.delete();
}
}
/// Hapus file tertentu (aman)
Future<void> deleteIfExists(String? path) async {
if (path == null) return;
final file = File(path);
if (await file.exists()) {
await file.delete();
}
}
}
Future<void> cleanTempOnStart() async {
final dir = await getTemporaryDirectory();
final cleanup = ImageCleanupService();
await cleanup.cleanAllTemp(dir.path);
}

View File

@@ -0,0 +1,83 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:image/image.dart' as img;
/// PUBLIC API
class ImageCompositor {
Future<String> composeFinalA5({
required String imagePath,
required Uint8List twibbonBytes,
required String tempDir,
required bool isFrontCamera,
}) {
return compute(_finalIsolate, {
'imagePath': imagePath,
'twibbonBytes': twibbonBytes,
'tempDir': tempDir,
'isFrontCamera': isFrontCamera,
});
}
}
Future<String> _finalIsolate(Map<String, dynamic> args) async {
const int a5Width = 1748;
const int a5Height = 2480;
final String imagePath = args['imagePath'];
final Uint8List twibbonBytes = args['twibbonBytes'];
final String tempDir = args['tempDir'];
final bool isFrontCamera = args['isFrontCamera'];
// 1⃣ Decode foto kamera
final raw = img.decodeImage(await File(imagePath).readAsBytes());
if (raw == null) throw Exception('Invalid photo');
// 2⃣ Samakan dengan preview kamera depan
if (isFrontCamera) {
img.flipHorizontal(raw);
}
// 3⃣ Crop ke rasio A5
final cropped = _cropToA5(raw);
// 4⃣ Resize ke resolusi CETAK A5
final photoA5 = img.copyResize(
cropped,
width: a5Width,
height: a5Height,
interpolation: img.Interpolation.average,
);
// 5⃣ Decode & resize twibbon SEKALI
final twibbon = img.decodeImage(twibbonBytes)!;
final twibbonA5 = img.copyResize(twibbon, width: a5Width, height: a5Height);
// 6⃣ Composite
img.compositeImage(photoA5, twibbonA5);
// 7⃣ Encode JPEG (print-ready)
final jpgBytes = img.encodeJpg(photoA5, quality: 90);
// 8⃣ Simpan FINAL FILE
final file = File(
'$tempDir/final_${DateTime.now().millisecondsSinceEpoch}.jpg',
);
await file.writeAsBytes(jpgBytes);
return file.path;
}
img.Image _cropToA5(img.Image src) {
const double a5Ratio = 148 / 210;
final srcRatio = src.width / src.height;
if (srcRatio > a5Ratio) {
final newWidth = (src.height * a5Ratio).round();
final x = ((src.width - newWidth) / 2).round();
return img.copyCrop(src, x: x, y: 0, width: newWidth, height: src.height);
} else {
final newHeight = (src.width / a5Ratio).round();
final y = ((src.height - newHeight) / 2).round();
return img.copyCrop(src, x: 0, y: y, width: src.width, height: newHeight);
}
}

View File

@@ -0,0 +1,37 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:pdf/pdf.dart';
import 'package:printing/printing.dart';
class PrintService {
Future<String> generateA5Pdf(String imagePath) async {
final bytes = await File(imagePath).readAsBytes();
final image = pw.MemoryImage(bytes);
final doc = pw.Document();
doc.addPage(
pw.Page(
pageFormat: PdfPageFormat.a5,
margin: pw.EdgeInsets.zero, // ⬅️ NO PADDING
build: (_) => pw.Image(
image,
fit: pw.BoxFit.cover, // ⬅️ FULL PAGE
),
),
);
final dir = await getTemporaryDirectory();
final file = File('${dir.path}/photo_a5.pdf');
await file.writeAsBytes(await doc.save());
return file.path;
}
/// Share PDF ke app printer (USB / Wi-Fi)
Future<void> sharePdf(String pdfPath) async {
final bytes = await File(pdfPath).readAsBytes();
await Printing.sharePdf(bytes: bytes, filename: 'photobooth_a5.pdf');
}
}

26
lib/main.dart Normal file
View File

@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:photobooth_mobile_app/core/image/image_cleanup_service.dart';
import 'package:photobooth_mobile_app/modules/splash/splash_screen.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await cleanTempOnStart();
runApp(PhotoFrameApp());
}
class PhotoFrameApp extends StatelessWidget {
const PhotoFrameApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Photo Frame A5',
theme: ThemeData(
primarySwatch: Colors.deepOrange,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: const SplashScreen(),
);
}
}

View File

@@ -0,0 +1,116 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:camera/camera.dart';
import 'package:photobooth_mobile_app/core/camera/camera_service.dart';
import 'package:photobooth_mobile_app/modules/camera/outside_dim_mask.dart';
import 'package:photobooth_mobile_app/modules/preview/preview_screen.dart';
class CameraCaptureScreen extends StatefulWidget {
final String frameAsset;
const CameraCaptureScreen({super.key, required this.frameAsset});
@override
State<CameraCaptureScreen> createState() => _CameraCaptureScreenState();
}
class _CameraCaptureScreenState extends State<CameraCaptureScreen> {
final CameraService _cameraService = CameraService();
bool _ready = false;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
await _cameraService.init();
setState(() => _ready = true);
}
@override
void dispose() {
_cameraService.dispose();
super.dispose();
}
Future<void> _takePicture() async {
final XFile raw = await _cameraService.takePicture();
Navigator.pushReplacement(
context,
CupertinoPageRoute(
builder: (_) => PreviewScreen(
imagePath: raw.path,
frameAsset: widget.frameAsset,
isFrontCamera: _cameraService.isFront,
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: !_ready
? const Center(child: CupertinoActivityIndicator())
: Column(
children: [
Stack(
children: [
CameraPreview(_cameraService.controller!),
const Positioned.fill(child: OutsideDimMask(opacity: 1)),
Positioned.fill(
child: IgnorePointer(
child: AspectRatio(
aspectRatio: 148 / 210,
child: Image.asset(
widget.frameAsset,
fit: BoxFit.contain,
),
),
),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
IconButton(
icon: Icon(
Icons.cameraswitch,
color: Colors.white,
size: 28,
),
onPressed: () async {
await _cameraService.switchCamera();
setState(() {});
},
),
GestureDetector(
onTap: _takePicture,
child: Container(
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4),
),
),
),
IconButton(
onPressed: () => Navigator.pop(context),
icon: Icon(Icons.close, color: Colors.white, size: 28),
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
class OutsideDimMask extends StatelessWidget {
final double opacity;
const OutsideDimMask({super.key, this.opacity = 0.6});
@override
Widget build(BuildContext context) {
const a5Ratio = 148 / 210;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final height = constraints.maxHeight;
double frameWidth;
double frameHeight;
if (width / height > a5Ratio) {
frameHeight = height;
frameWidth = height * a5Ratio;
} else {
frameWidth = width;
frameHeight = width / a5Ratio;
}
final left = (width - frameWidth) / 2;
final top = (height - frameHeight) / 2;
return Stack(
children: [
// Top
Positioned(
left: 0,
right: 0,
top: 0,
height: top,
child: Container(color: Colors.black.withOpacity(opacity)),
),
// Bottom
Positioned(
left: 0,
right: 0,
bottom: 0,
height: top,
child: Container(color: Colors.black.withOpacity(opacity)),
),
// Left
Positioned(
left: 0,
top: top,
width: left,
height: frameHeight,
child: Container(color: Colors.black.withOpacity(opacity)),
),
// Right
Positioned(
right: 0,
top: top,
width: left,
height: frameHeight,
child: Container(color: Colors.black.withOpacity(opacity)),
),
],
);
},
);
}
}

View File

@@ -0,0 +1,99 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:photobooth_mobile_app/modules/camera/camera_screen.dart';
class FrameSelectionScreen extends StatefulWidget {
const FrameSelectionScreen({super.key});
@override
_FrameSelectionScreenState createState() => _FrameSelectionScreenState();
}
class _FrameSelectionScreenState extends State<FrameSelectionScreen> {
String? selectedFrameAsset;
final frames = [
{
'asset': 'assets/images/nurtech_twibbon.png',
'sample': 'assets/images/nurtech_sample.png',
'label': 'Nurtech School',
},
{
'asset': 'assets/images/nuruliman_twibbon.png',
'sample': 'assets/images/nuruliman_sample.png',
'label': 'Nurul Iman',
},
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Pilih Frame')),
body: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Column(
children: frames.map((f) {
final asset = f['asset']!;
final sample = f['sample']!;
final label = f['label']!;
final isSelected = selectedFrameAsset == asset;
return Expanded(
child: GestureDetector(
onTap: () => setState(() => selectedFrameAsset = asset),
child: AnimatedContainer(
duration: Duration(milliseconds: 200),
margin: EdgeInsets.all(8),
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(color: Colors.black12, blurRadius: 6),
],
border: isSelected
? Border.all(color: Colors.deepOrange, width: 2)
: null,
),
child: Column(
children: [
Expanded(
child: Image.asset(sample, fit: BoxFit.contain),
),
SizedBox(height: 8),
Text(label, textAlign: TextAlign.center),
],
),
),
),
);
}).toList(),
),
),
SizedBox(height: 12),
ElevatedButton(
onPressed: selectedFrameAsset == null
? null
: () => Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => CameraCaptureScreen(
frameAsset: selectedFrameAsset!,
),
),
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Text('Lanjutkan', style: TextStyle(fontSize: 16)),
),
),
SizedBox(height: 12),
],
),
),
);
}
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
class OpacityDialog extends StatefulWidget {
final double initial;
const OpacityDialog({super.key, required this.initial});
@override
_OpacityDialogState createState() => _OpacityDialogState();
}
class _OpacityDialogState extends State<OpacityDialog> {
late double val;
@override
void initState() {
super.initState();
val = widget.initial;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Opacity Watermark'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Slider(
value: val,
min: 0.1,
max: 1.0,
divisions: 9,
label: '${(val * 100).toStringAsFixed(0)}%',
onChanged: (v) => setState(() => val = v),
),
SizedBox(height: 8),
Text('Preview: ${(val * 100).toStringAsFixed(0)}%'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Batal'),
),
TextButton(
onPressed: () => Navigator.pop(context, val),
child: Text('OK'),
),
],
);
}
}

View File

@@ -0,0 +1,103 @@
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:photobooth_mobile_app/core/image/image_cleanup_service.dart';
import 'package:photobooth_mobile_app/core/image/image_compositor.dart';
import 'package:photobooth_mobile_app/core/print/print_service.dart';
class PreviewScreen extends StatefulWidget {
final String imagePath;
final String frameAsset;
final bool isFrontCamera;
const PreviewScreen({
super.key,
required this.imagePath,
required this.frameAsset,
required this.isFrontCamera,
});
@override
State<PreviewScreen> createState() => _PreviewScreenState();
}
class _PreviewScreenState extends State<PreviewScreen> {
final ImageCompositor _compositor = ImageCompositor();
final PrintService _printer = PrintService();
final ImageCleanupService _cleanup = ImageCleanupService();
late String _finalPath;
String? _lastFinalPath;
bool _loading = true;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
final dir = await getTemporaryDirectory();
final twibbonData = await rootBundle.load(widget.frameAsset);
await _cleanup.deleteIfExists(_lastFinalPath);
final newPath = await _compositor.composeFinalA5(
imagePath: widget.imagePath,
twibbonBytes: twibbonData.buffer.asUint8List(),
tempDir: dir.path,
isFrontCamera: widget.isFrontCamera,
);
_lastFinalPath = newPath;
_finalPath = newPath;
await _cleanup.keepLastN(tempDir: dir.path, maxFiles: 10);
if (mounted) {
setState(() => _loading = false);
}
}
@override
void dispose() {
_cleanup.deleteIfExists(_finalPath);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Pratinjau')),
body: Column(
children: [
Expanded(
child: _loading
? const Center(child: CupertinoActivityIndicator())
: Center(
child: AspectRatio(
aspectRatio: 148 / 210,
child: Image.file(File(_finalPath), fit: BoxFit.contain),
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: ElevatedButton.icon(
icon: const Icon(Icons.print),
label: Text(_loading ? 'Menyiapkan...' : 'Cetak'),
onPressed: _loading
? null
: () async {
final pdfPath = await _printer.generateA5Pdf(_finalPath);
await _printer.sharePdf(pdfPath);
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,47 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:photobooth_mobile_app/modules/frame/select_frame_screen.dart';
class SplashScreen extends StatelessWidget {
const SplashScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Container(
width: double.infinity,
padding: EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FlutterLogo(size: 96),
SizedBox(height: 24),
Text(
'Photo Frame',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
SizedBox(height: 12),
Text(
'Pilih frame, ambil foto, cetak A5',
textAlign: TextAlign.center,
),
SizedBox(height: 36),
ElevatedButton(
onPressed: () => Navigator.of(context).push(
CupertinoPageRoute(
builder: (_) => const FrameSelectionScreen(),
),
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Text('Mulai', style: TextStyle(fontSize: 16)),
),
),
],
),
),
),
);
}
}