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 JSON
  • loadBarangList() → muat dari cache
  • clearCache() → 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

Postingan populer dari blog ini

Jenis-jenis sistem operasi mobile

flutter todo list app