Aplikasi Manajemen Barang Pinjaman dengan Flutter + Fitur Upload Gambar
Daftar Barang Pinjaman: Aplikasi Flutter dengan Penyimpanan Lokal
Aplikasi sederhana untuk mencatat barang yang dipinjamkan, lengkap dengan foto, status, dan notifikasi keterlambatan.
🎯 Fitur Utama
- Tambah, edit, dan hapus data barang pinjaman
- Pencarian real-time berdasarkan nama barang atau peminjam
- Filter untuk menampilkan hanya barang yang belum dikembalikan
- Notifikasi jika barang terlambat (lebih dari 7 hari)
- Upload foto barang (khusus di web)
- Catatan tambahan untuk setiap barang
- Status kembali dan tanggal pengembalian
- Penyimpanan lokal menggunakan
SharedPreferences - Tema konsisten dengan nuansa hijau toska dan latar lembut
💻 Kode Lengkap Aplikasi (main.dart)
Berikut adalah kode lengkap aplikasi Daftar Barang Pinjaman dalam satu file main.dart. Kamu bisa menyalinnya langsung ke proyek Flutter kamu.
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
import 'dart:typed_data';
import 'dart:html' as html;
import 'package:flutter/foundation.dart' show kIsWeb;
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Daftar Barang Pinjaman',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: MaterialColor(0xFF2E7D7E, {
50: Color(0xFFE0F2F1),
100: Color(0xFFB2DFDB),
200: Color(0xFF80CBC4),
300: Color(0xFF4DB6AC),
400: Color(0xFF26A69A),
500: Color(0xFF2E7D7E), // Deep Teal
600: Color(0xFF00796B),
700: Color(0xFF00695C),
800: Color(0xFF004D40),
900: Color(0xFF00332C),
}),
primaryColor: Color(0xFF2E7D7E),
scaffoldBackgroundColor: Color(0xFFFAF9F6), // Light Ivory
appBarTheme: AppBarTheme(
backgroundColor: Color(0xFF2E7D7E),
foregroundColor: Colors.white,
elevation: 0,
),
cardTheme: CardTheme(
elevation: 4,
color: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: Color(0xFF2E7D7E),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: EdgeInsets.symmetric(vertical: 12, horizontal: 24),
),
),
),
home: HomePage(),
);
}
}
class BarangPinjaman {
String id;
String namaBarang;
String namaPeminjam;
DateTime tanggalPinjam;
DateTime? tanggalKembali;
bool sudahKembali;
Uint8List? fotoBytes;
String? catatan;
BarangPinjaman({
required this.id,
required this.namaBarang,
required this.namaPeminjam,
required this.tanggalPinjam,
this.tanggalKembali,
this.sudahKembali = false,
this.fotoBytes,
this.catatan,
});
Map toJson() {
return {
'id': id,
'namaBarang': namaBarang,
'namaPeminjam': namaPeminjam,
'tanggalPinjam': tanggalPinjam.toIso8601String(),
'tanggalKembali': tanggalKembali?.toIso8601String(),
'sudahKembali': sudahKembali,
'fotoBytes': fotoBytes != null ? base64Encode(fotoBytes!) : null,
'catatan': catatan,
};
}
factory BarangPinjaman.fromJson(Map json) {
return BarangPinjaman(
id: json['id'],
namaBarang: json['namaBarang'],
namaPeminjam: json['namaPeminjam'],
tanggalPinjam: DateTime.parse(json['tanggalPinjam']),
tanggalKembali: json['tanggalKembali'] != null
? DateTime.parse(json['tanggalKembali'])
: null,
sudahKembali: json['sudahKembali'] ?? false,
fotoBytes: json['fotoBytes'] != null
? base64Decode(json['fotoBytes'])
: null,
catatan: json['catatan'],
);
}
int get hariTerlambat {
if (sudahKembali) return 0;
final now = DateTime.now();
final difference = now.difference(tanggalPinjam).inDays;
return difference > 7 ? difference - 7 : 0;
}
}
class CacheService {
static const String _keyBarangPinjaman = 'daftar_barang_pinjaman';
static Future saveBarangList(List barangList) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonList = barangList.map((barang) => barang.toJson()).toList();
final jsonString = jsonEncode(jsonList);
await prefs.setString(_keyBarangPinjaman, jsonString);
print('Data berhasil disimpan: ${barangList.length} items');
} catch (e) {
print('Error saving data: $e');
}
}
static Future> loadBarangList() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_keyBarangPinjaman);
if (jsonString == null || jsonString.isEmpty) {
print('No cached data found');
return [];
}
final jsonList = jsonDecode(jsonString) as List;
final barangList = jsonList
.map((json) => BarangPinjaman.fromJson(json as Map))
.toList();
print('Data berhasil dimuat: ${barangList.length} items');
return barangList;
} catch (e) {
print('Error loading data: $e');
return [];
}
}
static Future clearCache() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyBarangPinjaman);
print('Cache cleared');
} catch (e) {
print('Error clearing cache: $e');
}
}
}
class HomePage extends StatefulWidget {
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State {
List daftarBarang = [];
List filteredBarang = [];
String searchQuery = '';
bool showOnlyBelumKembali = false;
bool isLoading = true;
@override
void initState() {
super.initState();
loadData();
}
Future loadData() async {
setState(() {
isLoading = true;
});
try {
final loadedData = await CacheService.loadBarangList();
setState(() {
daftarBarang = loadedData;
isLoading = false;
});
filterBarang();
} catch (e) {
setState(() {
isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error memuat data: $e'),
backgroundColor: Colors.red,
),
);
}
}
Future saveData() async {
try {
await CacheService.saveBarangList(daftarBarang);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error menyimpan data: $e'),
backgroundColor: Colors.red,
),
);
}
}
void filterBarang() {
setState(() {
filteredBarang = daftarBarang.where((barang) {
final matchesSearch = barang.namaBarang.toLowerCase().contains(searchQuery.toLowerCase()) ||
barang.namaPeminjam.toLowerCase().contains(searchQuery.toLowerCase());
final matchesFilter = !showOnlyBelumKembali || !barang.sudahKembali;
return matchesSearch && matchesFilter;
}).toList();
});
}
void tambahBarang(BarangPinjaman barang) async {
setState(() {
daftarBarang.add(barang);
});
await saveData();
filterBarang();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Barang berhasil ditambahkan'),
backgroundColor: Colors.green,
),
);
}
void updateBarang(BarangPinjaman barang) async {
setState(() {
final index = daftarBarang.indexWhere((b) => b.id == barang.id);
if (index != -1) {
daftarBarang[index] = barang;
}
});
await saveData();
filterBarang();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Barang berhasil diupdate'),
backgroundColor: Colors.green,
),
);
}
void deleteBarang(String id) async {
setState(() {
daftarBarang.removeWhere((barang) => barang.id == id);
});
await saveData();
filterBarang();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Barang berhasil dihapus'),
backgroundColor: Colors.orange,
),
);
}
void tandaiKembali(String id) async {
setState(() {
final index = daftarBarang.indexWhere((barang) => barang.id == id);
if (index != -1) {
daftarBarang[index].sudahKembali = true;
daftarBarang[index].tanggalKembali = DateTime.now();
}
});
await saveData();
filterBarang();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Barang ditandai sudah kembali'),
backgroundColor: Colors.green,
),
);
}
@override
Widget build(BuildContext context) {
final barangTerlambat = filteredBarang.where((b) => !b.sudahKembali && b.hariTerlambat > 0).length;
return Scaffold(
appBar: AppBar(
title: Text('Daftar Barang Pinjaman'),
actions: [
IconButton(
icon: Icon(Icons.refresh),
onPressed: () {
loadData();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Data berhasil dimuat ulang'),
duration: Duration(seconds: 1),
),
);
},
),
PopupMenuButton(
onSelected: (value) {
if (value == 'clear_cache') {
_showClearCacheDialog();
}
},
itemBuilder: (BuildContext context) {
return [
PopupMenuItem(
value: 'clear_cache',
child: Row(
children: [
Icon(Icons.delete_forever, color: Colors.red),
SizedBox(width: 8),
Text('Hapus Semua Data'),
],
),
),
];
},
),
if (barangTerlambat > 0)
Container(
margin: EdgeInsets.only(right: 16),
child: Stack(
children: [
IconButton(
icon: Icon(Icons.notifications),
onPressed: () => _showNotificationDialog(context),
),
Positioned(
right: 8,
top: 8,
child: Container(
padding: EdgeInsets.all(2),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(10),
),
constraints: BoxConstraints(
minWidth: 16,
minHeight: 16,
),
child: Text(
'$barangTerlambat',
style: TextStyle(
color: Colors.white,
fontSize: 12,
),
textAlign: TextAlign.center,
),
),
),
],
),
),
],
),
body: Column(
children: [
Container(
padding: EdgeInsets.all(16),
child: Column(
children: [
TextField(
decoration: InputDecoration(
hintText: 'Cari barang atau nama peminjam...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
filled: true,
fillColor: Colors.white,
),
onChanged: (value) {
searchQuery = value;
filterBarang();
},
),
SizedBox(height: 12),
Row(
children: [
FilterChip(
label: Text('Belum Kembali'),
selected: showOnlyBelumKembali,
onSelected: (bool selected) {
setState(() {
showOnlyBelumKembali = selected;
});
filterBarang();
},
selectedColor: Color(0xFF84AE92).withOpacity(0.3),
checkmarkColor: Color(0xFF5A827E),
),
SizedBox(width: 8),
Expanded(
child: Text(
'${filteredBarang.length} barang ditemukan',
style: TextStyle(color: Colors.grey[600]),
),
),
],
),
],
),
),
Expanded(
child: isLoading
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
color: Color(0xFF5A827E),
),
SizedBox(height: 16),
Text(
'Memuat data...',
style: TextStyle(
color: Colors.grey[600],
fontSize: 16,
),
),
],
),
)
: filteredBarang.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inbox_outlined,
size: 80,
color: Colors.grey[400],
),
SizedBox(height: 16),
Text(
searchQuery.isNotEmpty || showOnlyBelumKembali
? 'Tidak ada barang ditemukan'
: 'Belum ada barang pinjaman',
style: TextStyle(
fontSize: 18,
color: Colors.grey[600],
),
),
SizedBox(height: 8),
Text(
searchQuery.isNotEmpty || showOnlyBelumKembali
? 'Coba ubah kata kunci pencarian atau filter'
: 'Mulai tambah barang dengan menekan tombol + di bawah',
style: TextStyle(
fontSize: 14,
color: Colors.grey[500],
),
textAlign: TextAlign.center,
),
],
),
)
: ListView.builder(
padding: EdgeInsets.symmetric(horizontal: 16),
itemCount: filteredBarang.length,
itemBuilder: (context, index) {
final barang = filteredBarang[index];
return BarangCard(
barang: barang,
onTandaiKembali: () => tandaiKembali(barang.id),
onEdit: () => _navigateToForm(context, barang),
onDelete: () => _showDeleteDialog(context, barang.id),
);
},
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _navigateToForm(context),
backgroundColor: Color(0xFF5A827E),
child: Icon(Icons.add, color: Colors.white),
),
);
}
void _navigateToForm(BuildContext context, [BarangPinjaman? barang]) async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => FormBarangPage(barang: barang),
),
);
if (result != null) {
if (barang == null) {
tambahBarang(result);
} else {
updateBarang(result);
}
}
}
void _showDeleteDialog(BuildContext context, String id) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('Hapus Barang'),
content: Text('Apakah Anda yakin ingin menghapus barang ini?'),
actions: [
TextButton(
child: Text('Batal'),
onPressed: () => Navigator.pop(context),
),
TextButton(
child: Text('Hapus', style: TextStyle(color: Colors.red)),
onPressed: () {
deleteBarang(id);
Navigator.pop(context);
},
),
],
);
},
);
}
void _showClearCacheDialog() {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Row(
children: [
Icon(Icons.warning, color: Colors.orange),
SizedBox(width: 8),
Text('Hapus Semua Data'),
],
),
content: Text('Apakah Anda yakin ingin menghapus semua data barang pinjaman? Tindakan ini tidak dapat dibatalkan.'),
actions: [
TextButton(
child: Text('Batal'),
onPressed: () => Navigator.pop(context),
),
TextButton(
child: Text('Hapus Semua', style: TextStyle(color: Colors.red)),
onPressed: () async {
await CacheService.clearCache();
setState(() {
daftarBarang.clear();
});
filterBarang();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Semua data berhasil dihapus'),
backgroundColor: Colors.red,
),
);
},
),
],
);
},
);
}
void _showNotificationDialog(BuildContext context) {
final barangTerlambat = filteredBarang.where((b) => !b.sudahKembali && b.hariTerlambat > 0).toList();
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Row(
children: [
Icon(Icons.warning, color: Colors.orange),
SizedBox(width: 8),
Text('Notifikasi Terlambat'),
],
),
content: Container(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: barangTerlambat.length,
itemBuilder: (context, index) {
final barang = barangTerlambat[index];
return ListTile(
leading: Icon(Icons.schedule, color: Colors.red),
title: Text(barang.namaBarang),
subtitle: Text('${barang.namaPeminjam} - ${barang.hariTerlambat} hari terlambat'),
);
},
),
),
actions: [
TextButton(
child: Text('Tutup'),
onPressed: () => Navigator.pop(context),
),
],
);
},
);
}
}
class BarangCard extends StatelessWidget {
final BarangPinjaman barang;
final VoidCallback onTandaiKembali;
final VoidCallback onEdit;
final VoidCallback onDelete;
const BarangCard({
Key? key,
required this.barang,
required this.onTandaiKembali,
required this.onEdit,
required this.onDelete,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final hariPinjam = DateTime.now().difference(barang.tanggalPinjam).inDays;
final statusColor = barang.sudahKembali
? Color(0xFF4CAF50) // Green
: (barang.hariTerlambat > 0
? Color(0xFFE53935) // Red
: Color(0xFFF9A825)); // Orange
return Card(
margin: EdgeInsets.only(bottom: 12),
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (barang.fotoBytes != null) ...[
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
image: DecorationImage(
image: MemoryImage(barang.fotoBytes!),
fit: BoxFit.cover,
),
),
),
SizedBox(width: 12),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
barang.namaBarang,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF5A827E),
),
),
SizedBox(height: 4),
Row(
children: [
Icon(Icons.person, size: 16, color: Colors.grey[600]),
SizedBox(width: 4),
Text(
barang.namaPeminjam,
style: TextStyle(
fontSize: 16,
color: Colors.grey[800],
),
),
],
),
],
),
),
Container(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: statusColor),
),
child: Text(
barang.sudahKembali
? 'Sudah Kembali'
: (barang.hariTerlambat > 0
? '${barang.hariTerlambat} hari terlambat'
: 'Belum Kembali'),
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
],
),
SizedBox(height: 12),
Row(
children: [
Icon(Icons.calendar_today, size: 16, color: Colors.grey[600]),
SizedBox(width: 4),
Text(
'Dipinjam: ${_formatDate(barang.tanggalPinjam)} ($hariPinjam hari)',
style: TextStyle(color: Colors.grey[600], fontSize: 14),
),
],
),
if (barang.tanggalKembali != null) ...[
SizedBox(height: 4),
Row(
children: [
Icon(Icons.check_circle, size: 16, color: Colors.green),
SizedBox(width: 4),
Text(
'Dikembalikan: ${_formatDate(barang.tanggalKembali!)}',
style: TextStyle(color: Colors.grey[600], fontSize: 14),
),
],
),
],
if (barang.catatan != null && barang.catatan!.isNotEmpty) ...[
SizedBox(height: 8),
Container(
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.note, size: 16, color: Colors.grey[600]),
SizedBox(width: 4),
Expanded(
child: Text(
barang.catatan!,
style: TextStyle(
color: Colors.grey[700],
fontSize: 14,
fontStyle: FontStyle.italic,
),
),
),
],
),
),
],
SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: onEdit,
icon: Icon(Icons.edit, size: 18),
label: Text('Edit'),
style: TextButton.styleFrom(
foregroundColor: Color(0xFF5A827E),
),
),
SizedBox(width: 8),
if (!barang.sudahKembali)
ElevatedButton.icon(
onPressed: onTandaiKembali,
icon: Icon(Icons.check, size: 18),
label: Text('Tandai Kembali'),
style: ElevatedButton.styleFrom(
backgroundColor: Color(0xFF84AE92),
foregroundColor: Colors.white,
),
),
SizedBox(width: 8),
TextButton.icon(
onPressed: onDelete,
icon: Icon(Icons.delete, size: 18),
label: Text('Hapus'),
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
),
],
),
],
),
),
);
}
String _formatDate(DateTime date) {
final months = [
'Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun',
'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'
];
return '${date.day} ${months[date.month - 1]} ${date.year}';
}
}
class FormBarangPage extends StatefulWidget {
final BarangPinjaman? barang;
const FormBarangPage({Key? key, this.barang}) : super(key: key);
@override
_FormBarangPageState createState() => _FormBarangPageState();
}
class _FormBarangPageState extends State {
final _formKey = GlobalKey();
final _namaBarangController = TextEditingController();
final _namaPeminjamController = TextEditingController();
final _catatanController = TextEditingController();
DateTime _tanggalPinjam = DateTime.now();
Uint8List? _fotoBytes;
String? _fileName;
int? _fileSizeBytes;
@override
void initState() {
super.initState();
if (widget.barang != null) {
_namaBarangController.text = widget.barang!.namaBarang;
_namaPeminjamController.text = widget.barang!.namaPeminjam;
_catatanController.text = widget.barang!.catatan ?? '';
_tanggalPinjam = widget.barang!.tanggalPinjam;
_fotoBytes = widget.barang!.fotoBytes;
}
}
Future _pickImageWeb() async {
final html.FileUploadInputElement input = html.FileUploadInputElement();
input.accept = 'image/*';
input.click();
input.onChange.listen((event) {
final files = input.files;
if (files == null || files.isEmpty) return;
final html.File file = files[0];
final reader = html.FileReader();
reader.onLoad.first.then((_) {
final result = reader.result as String;
try {
final base64Data = result.split(',').last;
final bytes = base64Decode(base64Data);
setState(() {
_fotoBytes = bytes;
_fileName = file.name;
_fileSizeBytes = bytes.length;
});
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gagal membaca gambar: $e')),
);
}
});
reader.readAsDataUrl(file);
});
}
void _selectImage() {
if (kIsWeb) {
_pickImageWeb();
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Hanya untuk web di zapp.run')),
);
}
}
Future _selectDate() async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _tanggalPinjam,
firstDate: DateTime(2020),
lastDate: DateTime.now(),
);
if (picked != null && picked != _tanggalPinjam) {
setState(() {
_tanggalPinjam = picked;
});
}
}
void _saveBarang() {
if (_formKey.currentState!.validate()) {
final barang = BarangPinjaman(
id: widget.barang?.id ?? DateTime.now().millisecondsSinceEpoch.toString(),
namaBarang: _namaBarangController.text,
namaPeminjam: _namaPeminjamController.text,
tanggalPinjam: _tanggalPinjam,
catatan: _catatanController.text.isEmpty ? null : _catatanController.text,
fotoBytes: _fotoBytes,
sudahKembali: widget.barang?.sudahKembali ?? false,
tanggalKembali: widget.barang?.tanggalKembali,
);
Navigator.pop(context, barang);
}
}
String _formatDate(DateTime date) {
final months = [
'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'
];
return '${date.day} ${months[date.month - 1]} ${date.year}';
}
@override
Widget build(BuildContext context) {
final isEdit = widget.barang != null;
return Scaffold(
appBar: AppBar(
title: Text(isEdit ? 'Edit Barang' : 'Tambah Barang'),
),
body: SingleChildScrollView(
padding: EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Informasi Barang',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF5A827E),
),
),
SizedBox(height: 16),
TextFormField(
controller: _namaBarangController,
decoration: InputDecoration(
labelText: 'Nama Barang *',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
prefixIcon: Icon(Icons.inventory),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Nama barang harus diisi';
}
return null;
},
),
SizedBox(height: 16),
TextFormField(
controller: _namaPeminjamController,
decoration: InputDecoration(
labelText: 'Nama Peminjam *',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
prefixIcon: Icon(Icons.person),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Nama peminjam harus diisi';
}
return null;
},
),
SizedBox(height: 16),
TextFormField(
controller: _catatanController,
decoration: InputDecoration(
labelText: 'Catatan (Opsional)',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
prefixIcon: Icon(Icons.note),
),
maxLines: 3,
),
],
),
),
),
SizedBox(height: 16),
Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tanggal Pinjam',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF5A827E),
),
),
SizedBox(height: 16),
InkWell(
onTap: _selectDate,
child: Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey[400]!),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.calendar_today, color: Color(0xFF5A827E)),
SizedBox(width: 12),
Text(
_formatDate(_tanggalPinjam),
style: TextStyle(fontSize: 16),
),
Spacer(),
Icon(Icons.arrow_drop_down),
],
),
),
),
],
),
),
),
SizedBox(height: 16),
Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Foto Barang (Opsional)',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF5A827E),
),
),
SizedBox(height: 16),
Container(
width: double.infinity,
height: 120,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey[400]!, style: BorderStyle.solid),
borderRadius: BorderRadius.circular(12),
color: Colors.grey[100],
),
child: InkWell(
onTap: _selectImage,
borderRadius: BorderRadius.circular(12),
child: _fotoBytes != null
? Image.memory(_fotoBytes!, fit: BoxFit.cover)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.camera_alt,
size: 40,
color: Color(0xFF5A827E),
),
SizedBox(height: 8),
Text(
'Tap untuk pilih foto',
style: TextStyle(
color: Color(0xFF5A827E),
fontWeight: FontWeight.w500,
),
),
],
),
),
),
if (_fileName != null) ...[
SizedBox(height: 8),
Text(
'File: $_fileName',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
if (_fileSizeBytes != null) ...[
Text(
'Size: ${(_fileSizeBytes! / 1024).toStringAsFixed(2)} KB',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
],
),
),
),
SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _saveBarang,
style: ElevatedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
isEdit ? 'Update Barang' : 'Simpan Barang',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
),
],
),
),
),
);
}
@override
void dispose() {
_namaBarangController.dispose();
_namaPeminjamController.dispose();
_catatanController.dispose();
super.dispose();
}
}
📌 Catatan: Kode ini dapat dijalankan langsung di Flutter Web (misalnya di zapp.run). Untuk mobile, tambahkan paket image_picker agar bisa upload foto.
🧱 Struktur Kode Aplikasi
1. main() dan MyApp
Aplikasi dimulai dengan runApp(MyApp()). Tema menggunakan warna primer #2E7D7E (teal tua) dan latar belakang #FAF9F6 (krem lembut).
2. Model: BarangPinjaman
Kelas ini merepresentasikan satu entri barang pinjaman:
class BarangPinjaman {
String id;
String namaBarang;
String namaPeminjam;
DateTime tanggalPinjam;
DateTime? tanggalKembali;
bool sudahKembali;
Uint8List? fotoBytes;
String? catatan;
}
Dilengkapi dengan toJson() dan fromJson() untuk serialisasi, serta properti hariTerlambat untuk menghitung keterlambatan (lebih dari 7 hari).
3. CacheService – Penyimpanan Lokal
Menggunakan SharedPreferences untuk menyimpan dan memuat data:
saveBarangList()→ simpan list ke JSONloadBarangList()→ muat dari cacheclearCache()→ hapus semua data
Foto disimpan dalam format Base64 karena SharedPreferences hanya mendukung string.
4. HomePage – Halaman Utama
Menampilkan daftar barang dengan fitur:
- Pencarian langsung saat mengetik
- Filter "Belum Kembali" dengan
FilterChip - Badge notifikasi merah jika ada barang terlambat
- Floating Action Button untuk tambah barang
- Menu untuk hapus semua data dengan konfirmasi
5. BarangCard – Tampilan Kartu
Setiap barang ditampilkan dalam kartu dengan:
- Nama barang dan peminjam
- Status: Sudah Kembali, Belum Kembali, Terlambat
- Tanggal pinjam dan kembali
- Foto barang (jika diunggah)
- Catatan (opsional)
- Tombol: Edit, Tandai Kembali, Hapus
6. FormBarangPage – Form Input
Halaman untuk menambah atau mengedit barang:
- Input nama barang dan peminjam (wajib)
- Pilih tanggal pinjam dengan
showDatePicker - Upload foto (khusus web, menggunakan
dart:html) - Catatan opsional
- Validasi form sebelum disimpan
🌐 Upload Foto di Web
Karena Flutter Web tidak mendukung image_picker, kode menggunakan dart:html untuk membuat input file:
final html.FileUploadInputElement input = html.FileUploadInputElement();
input.accept = 'image/*';
input.click();
input.onChange.listen((event) {
final file = input.files![0];
final reader = html.FileReader();
reader.readAsDataUrl(file);
reader.onLoad.first.then((_) {
final base64Data = (reader.result as String).split(',').last;
_fotoBytes = base64Decode(base64Data);
});
});
🛠️ Teknologi yang Digunakan
| Komponen | Kegunaan |
|---|---|
Flutter |
Framework UI lintas platform |
SharedPreferences |
Penyimpanan lokal persisten |
dart:convert |
Serialisasi JSON |
dart:html |
Upload file di web |
kIsWeb |
Deteksi platform (web vs mobile) |
🚀 Potensi Pengembangan
- Sinkronisasi ke Firebase / Cloud
- Notifikasi push otomatis
- Login multi-pengguna
- Scan QR code untuk identifikasi barang
- Dukungan upload gambar di mobile (
image_picker)
✅ Kesimpulan
Aplikasi ini menunjukkan bagaimana Flutter bisa digunakan untuk membuat aplikasi produktivitas sederhana namun sangat fungsional. Dengan hanya menggunakan SharedPreferences, kita bisa membuat aplikasi yang offline-first, cepat, dan mudah dikembangkan.
Kode ini sangat cocok sebagai starter project untuk belajar Flutter, terutama bagi pemula yang ingin memahami:
- Manajemen state
- Form dan validasi
- Navigasi antar halaman
- Serialisasi data
- Upload file di web
Hasil Lihat disini
Komentar
Posting Komentar