Skip to main content
Praktikum 5·
26 Oktober 2026

Arsitektur Navigasi: Named Routes dan Bottom Navigation Bar pada Aplikasi Expense Tracker

Penerapan Named Routes dan BottomNavigationBar untuk menyatukan halaman Dashboard, Riwayat, dan Profil dalam aplikasi Expense Tracker, dilanjutkan dengan penggabungan hasil Praktikum 1–5 menjadi satu aplikasi yang menyimpan data pengguna dan transaksi di SQLite serta dilengkapi fitur register dan login.

ITujuan Praktikum

Setelah menyelesaikan praktikum ini, mahasiswa diharapkan mampu:

  • ✓Menerapkan Named Routes untuk navigasi terpusat dan rapi.
  • ✓Membangun menu bawah (BottomNavigationBar) seperti aplikasi modern.
  • ✓Mengintegrasikan halaman Dashboard, Riwayat, dan Profil dalam satu wadah (MainScreen).
  • ✓Membuat halaman login yang meneruskan email pengguna ke halaman utama melalui arguments pada named route.
  • ✓Menggabungkan hasil Praktikum 1–5 menjadi satu aplikasi dengan penyimpanan lokal SQLite serta fitur register dan login.

IIDasar Teori

Navigator dan Route: Flutter mengelola perpindahan halaman dengan Navigator, yang menyimpan halaman-halaman (route) dalam bentuk tumpukan (stack). push menambahkan halaman baru di atas tumpukan, sedangkan pop menghapus halaman teratas dan kembali ke halaman sebelumnya.

Named Routes: Cara mendefinisikan nama string untuk setiap halaman di dalam MaterialApp, sehingga navigasi terpusat di satu tempat dan tidak menjadi spaghetti code saat jumlah layar bertambah.

  • routes: tabel peta yang memasangkan nama rute dengan widget halamannya.

  • initialRoute: rute pertama yang dimuat saat aplikasi dibuka, biasanya '/'.

  • Navigator.pushNamed: membuka halaman berdasarkan nama rute. Varian pushReplacementNamed mengganti halaman saat ini, sedangkan pushNamedAndRemoveUntil membersihkan tumpukan, misalnya setelah login atau logout.

  • arguments: data yang dikirim bersama rute dan dibaca di halaman tujuan dengan ModalRoute.of(context)?.settings.arguments.

BottomNavigationBar: Menu navigasi di bagian bawah layar yang mengganti isi body tanpa berpindah Scaffold. Properti utamanya adalah currentIndex (indeks tab aktif), items (daftar ikon dan label tab), dan onTap (fungsi yang dipanggil saat tab ditekan). Karena tab aktif selalu berubah, wadahnya dibuat sebagai StatefulWidget dan perubahan indeks dilakukan di dalam setState.

SQLite dan sqflite: SQLite adalah database relasional ringan yang disimpan sebagai satu file di perangkat, sehingga data tetap ada walaupun aplikasi ditutup. Di Flutter, SQLite diakses melalui package sqflite untuk Android dan iOS. Database dibuka dengan openDatabase, tabel dibuat di callback onCreate, lalu data diolah dengan insert, query, rawQuery, dan delete. Parameter whereArgs dipakai agar nilai input tidak disisipkan langsung ke string SQL.

shared_preferences: Penyimpanan lokal berbentuk pasangan key-value untuk data kecil. Pada aplikasi ini dipakai untuk menyimpan id pengguna yang sedang login (sesi), sehingga pengguna tidak perlu login ulang setiap membuka aplikasi.

Hashing password: Password tidak disimpan dalam bentuk asli, tetapi diubah menjadi nilai hash dengan fungsi satu arah SHA-256 (package crypto). Saat login, password yang diketik di-hash ulang lalu dibandingkan dengan hash yang tersimpan.

APK Debug dan Release: Build debug (flutter run) memakai kompilasi JIT dan menyertakan alat debugging untuk kebutuhan pengembangan. Build release (flutter build apk --release) memakai kompilasi AOT ke kode mesin, sehingga aplikasi lebih cepat dan ukurannya lebih kecil. Build release inilah yang dibagikan kepada pengguna.

IIIAlat dan Bahan

Laptop (Windows)
Flutter SDK dan Dart
Visual Studio Code
Terminal / PowerShell
HP Android (TECNO LJ8k) sebagai device uji
SQLite (package sqflite)
OBS Studio dan scrcpy untuk video demo

IVLangkah Kerja Praktikum

1

Mendaftarkan Named Routes

Buka file lib/main.dart. Hapus parameter home pada MaterialApp, lalu ganti dengan initialRoute dan routes. Rute '/' menunjuk ke MainScreen (layar ber-tab), sedangkan rute '/add' menunjuk ke AddTransactionScreen dari Praktikum 4.

lib/main.dart
dart
import 'package:flutter/material.dart';
import 'main_screen.dart';
import 'add_transaction_screen.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Expense Tracker Final',
      theme: ThemeData(primarySwatch: Colors.amber),
      // MENGGUNAKAN NAMED ROUTES
      initialRoute: '/',
      routes: {
        '/': (context) => const MainScreen(),
        '/add': (context) => const AddTransactionScreen(),
      },
    );
  }
}
2

Membuat Penampung Tab (MainScreen)

Buat file baru lib/main_screen.dart. MainScreen dibuat sebagai StatefulWidget karena tab yang aktif selalu berubah. Siapkan variabel _selectedIndex untuk menyimpan tab aktif, sebuah List berisi halaman yang akan ditukar, dan fungsi _onItemTapped yang mengubah indeks di dalam setState.

lib/main_screen.dart
dart
import 'package:flutter/material.dart';
import 'dashboard_screen.dart'; // Dari modul sebelumnya
import 'profile_screen.dart'; // Akan dibuat sebagai tugas akhir mahasiswa

class MainScreen extends StatefulWidget {
  const MainScreen({super.key});

  @override
  State<MainScreen> createState() => _MainScreenState();
}

class _MainScreenState extends State<MainScreen> {
  int _selectedIndex = 0; // Tab pertama aktif secara default (Beranda)

  // List kumpulan halaman
  final List<Widget> _pages = [
    const DashboardScreen(),
    const Center(child: Text("Halaman Riwayat (Kosong)", style: TextStyle(fontSize: 24))),
    const ProfileScreen(), // Tantangan UI profil untuk mahasiswa
  ];

  void _onItemTapped(int index) {
    setState(() {
      _selectedIndex = index;
    });
  }

Kode ini mengimpor profile_screen.dart yang belum ada di modul. File tersebut dibuat pada Tugas 2, sehingga aplikasi baru bisa dijalankan setelah halaman profil selesai dibuat.

3

Merangkai BottomNavigationBar

Lengkapi method build di _MainScreenState. body menampilkan halaman sesuai _selectedIndex, sedangkan BottomNavigationBar menampilkan tiga tab: Beranda, Riwayat, dan Profil. Saat tab ditekan, onTap memanggil _onItemTapped sehingga indeks berubah dan halaman ikut berganti.

lib/main_screen.dart
dart
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: _pages[_selectedIndex], // Ganti layar sesuai index
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _selectedIndex,
        onTap: _onItemTapped,
        selectedItemColor: Colors.amber[800],
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Beranda'),
          BottomNavigationBarItem(icon: Icon(Icons.history), label: 'Riwayat'),
          BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profil'),
        ],
      ),
    );
  }
}
4

Memperbarui Tombol FAB di Dashboard

Pada lib/dashboard_screen.dart dari Praktikum 4, logika tombol FAB yang sebelumnya memakai Navigator.push(context, MaterialPageRoute(...)) diganti dengan Navigator.pushNamed ke rute '/add' yang sudah didaftarkan di main.dart. Import add_transaction_screen.dart di file ini juga dihapus karena tidak dipakai lagi. Warna merah dari Praktikum 4 tetap dipertahankan.

lib/dashboard_screen.dart
dart
      // Tombol mengambang (FAB) untuk tambah transaksi
      floatingActionButton: FloatingActionButton(
        backgroundColor: Colors.red,
        foregroundColor: Colors.white,
        child: const Icon(Icons.add),
        onPressed: () {
          // Memanggil Rute yang sudah didaftarkan di main.dart
          Navigator.pushNamed(context, '/add');
        },
      ),
5

Menjalankan Aplikasi

Sambungkan HP Android dengan USB debugging aktif, lalu jalankan aplikasi dari folder project.

Terminal
bash
cd D:\appmobile\praktikum5
flutter run

VLatihan dan Tugas

1

Tugas 1: Halaman Riwayat Transaksi

Halaman riwayat dibuat di lib/history_screen.dart dan dipasang sebagai tab kedua pada MainScreen. Data transaksi disimpan dalam list konstan berisi objek _RiwayatItem (judul, tanggal, nominal, jenis, ikon), lalu ditampilkan dengan ListView.separated. Pengeluaran ditandai warna merah dengan tanda minus, pemasukan warna hijau dengan tanda plus. Nominal diformat menjadi rupiah dengan pemisah ribuan menggunakan RegExp.

lib/history_screen.dart
dart
import 'package:flutter/material.dart';

class _RiwayatItem {
  final String judul;
  final String tanggal;
  final int nominal;
  final bool pengeluaran;
  final IconData ikon;

  const _RiwayatItem({
    required this.judul,
    required this.tanggal,
    required this.nominal,
    required this.pengeluaran,
    required this.ikon,
  });
}

class HistoryScreen extends StatelessWidget {
  const HistoryScreen({super.key});

  static const List<_RiwayatItem> _daftarRiwayat = [
    _RiwayatItem(
      judul: 'Makan Siang',
      tanggal: '26 Okt 2026',
      nominal: 50000,
      pengeluaran: true,
      ikon: Icons.restaurant,
    ),
    _RiwayatItem(
      judul: 'Gaji Bulanan',
      tanggal: '25 Okt 2026',
      nominal: 5000000,
      pengeluaran: false,
      ikon: Icons.account_balance_wallet,
    ),
    _RiwayatItem(
      judul: 'Transportasi',
      tanggal: '24 Okt 2026',
      nominal: 25000,
      pengeluaran: true,
      ikon: Icons.directions_car,
    ),
    _RiwayatItem(
      judul: 'Belanja Bulanan',
      tanggal: '23 Okt 2026',
      nominal: 350000,
      pengeluaran: true,
      ikon: Icons.shopping_cart,
    ),
    _RiwayatItem(
      judul: 'Transfer Masuk',
      tanggal: '22 Okt 2026',
      nominal: 200000,
      pengeluaran: false,
      ikon: Icons.swap_horiz,
    ),
  ];

  String _formatRupiah(int nominal) {
    final String angka = nominal.toString().replaceAllMapped(
          RegExp(r'(\d)(?=(\d{3})+(?!\d))'),
          (match) => '${match[1]}.',
        );
    return 'Rp $angka';
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Riwayat Transaksi'),
        centerTitle: true,
        backgroundColor: Colors.amber,
      ),
      body: ListView.separated(
        padding: const EdgeInsets.symmetric(vertical: 8),
        itemCount: _daftarRiwayat.length,
        separatorBuilder: (context, index) => const Divider(height: 1),
        itemBuilder: (context, index) {
          final _RiwayatItem item = _daftarRiwayat[index];
          final Color warna = item.pengeluaran ? Colors.red : Colors.green;
          final String tanda = item.pengeluaran ? '-' : '+';

          return ListTile(
            leading: CircleAvatar(
              backgroundColor:
                  item.pengeluaran ? Colors.red[50] : Colors.green[50],
              child: Icon(item.ikon, color: warna),
            ),
            title: Text(
              item.judul,
              style: const TextStyle(fontWeight: FontWeight.w600),
            ),
            subtitle: Text(item.tanggal),
            trailing: Text(
              '$tanda ${_formatRupiah(item.nominal)}',
              style: TextStyle(color: warna, fontWeight: FontWeight.bold),
            ),
          );
        },
      ),
    );
  }
}
2

Tugas 2: Halaman Profil

Halaman profil di lib/profile_screen.dart berisi avatar (CircleAvatar), nama, email, dan tombol logout. Nama diambil dari bagian email sebelum tanda @. Tombol logout menampilkan AlertDialog konfirmasi, lalu memanggil Navigator.pushNamedAndRemoveUntil ke halaman login sambil menghapus seluruh tumpukan halaman, sehingga pengguna tidak bisa kembali ke halaman utama dengan tombol back.

lib/profile_screen.dart
dart
import 'package:flutter/material.dart';

class ProfileScreen extends StatelessWidget {
  final String email;

  const ProfileScreen({super.key, required this.email});

  String get _nama {
    final String bagianDepan = email.split('@').first;
    if (bagianDepan.isEmpty) return 'Pengguna';
    return bagianDepan[0].toUpperCase() + bagianDepan.substring(1);
  }

  void _konfirmasiLogout(BuildContext context) {
    showDialog(
      context: context,
      builder: (dialogContext) => AlertDialog(
        title: const Text('Logout'),
        content: const Text('Yakin ingin keluar dari akun ini?'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(dialogContext),
            child: const Text('Batal'),
          ),
          TextButton(
            onPressed: () {
              Navigator.pop(dialogContext);
              ScaffoldMessenger.of(context).showSnackBar(
                const SnackBar(content: Text('Berhasil logout')),
              );
              Navigator.pushNamedAndRemoveUntil(
                context,
                '/',
                (route) => false,
              );
            },
            child: const Text('Logout', style: TextStyle(color: Colors.red)),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Profil'),
        centerTitle: true,
        backgroundColor: Colors.amber,
      ),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          children: [
            const SizedBox(height: 16),
            CircleAvatar(
              radius: 50,
              backgroundColor: Colors.amber[100],
              child: Icon(Icons.person, size: 60, color: Colors.amber[800]),
            ),
            const SizedBox(height: 16),
            Text(
              _nama,
              style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 4),
            Text(
              email.isEmpty ? '-' : email,
              style: TextStyle(fontSize: 16, color: Colors.grey[600]),
            ),
            const Spacer(),
            SizedBox(
              width: double.infinity,
              child: ElevatedButton.icon(
                onPressed: () => _konfirmasiLogout(context),
                icon: const Icon(Icons.logout),
                label: const Text('Logout'),
                style: ElevatedButton.styleFrom(
                  backgroundColor: Colors.red,
                  foregroundColor: Colors.white,
                  padding: const EdgeInsets.symmetric(vertical: 14),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}
3

Tugas 3: Halaman Login dan Menampilkan Email

Halaman login berisi field email dan password dengan validasi format. Saat tombol login ditekan, email dikirim ke halaman utama melalui arguments pada named route, lalu ditampilkan di SnackBar dan di halaman profil.

#1 Halaman Login

Email divalidasi harus mengandung @ dan password minimal 6 karakter. Setelah valid, Navigator.pushReplacementNamed membuka rute '/main' dengan email sebagai arguments. Halaman login diganti, bukan ditumpuk, sehingga tombol back tidak kembali ke form login.

lib/login_screen.dart
dart
import 'package:flutter/material.dart';

class LoginScreen extends StatefulWidget {
  const LoginScreen({super.key});

  @override
  State<LoginScreen> createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  bool _sembunyikanPassword = true;

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  void _login() {
    if (!_formKey.currentState!.validate()) return;

    final String email = _emailController.text.trim();

    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('Login berhasil sebagai $email')),
    );
    Navigator.pushReplacementNamed(context, '/main', arguments: email);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Center(
          child: SingleChildScrollView(
            padding: const EdgeInsets.all(24),
            child: Form(
              key: _formKey,
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: [
                  Icon(
                    Icons.account_balance_wallet,
                    size: 80,
                    color: Colors.amber[800],
                  ),
                  const SizedBox(height: 16),
                  const Text(
                    'Expense Tracker',
                    textAlign: TextAlign.center,
                    style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold),
                  ),
                  const SizedBox(height: 8),
                  Text(
                    'Masuk untuk melanjutkan',
                    textAlign: TextAlign.center,
                    style: TextStyle(fontSize: 16, color: Colors.grey[600]),
                  ),
                  const SizedBox(height: 32),
                  TextFormField(
                    controller: _emailController,
                    keyboardType: TextInputType.emailAddress,
                    decoration: const InputDecoration(
                      labelText: 'Email',
                      prefixIcon: Icon(Icons.email),
                      border: OutlineInputBorder(),
                    ),
                    validator: (value) {
                      if (value == null || value.trim().isEmpty) {
                        return 'Email wajib diisi';
                      }
                      if (!value.contains('@')) {
                        return 'Format email tidak valid';
                      }
                      return null;
                    },
                  ),
                  const SizedBox(height: 16),
                  TextFormField(
                    controller: _passwordController,
                    obscureText: _sembunyikanPassword,
                    decoration: InputDecoration(
                      labelText: 'Password',
                      prefixIcon: const Icon(Icons.lock),
                      border: const OutlineInputBorder(),
                      suffixIcon: IconButton(
                        icon: Icon(
                          _sembunyikanPassword
                              ? Icons.visibility
                              : Icons.visibility_off,
                        ),
                        onPressed: () {
                          setState(() {
                            _sembunyikanPassword = !_sembunyikanPassword;
                          });
                        },
                      ),
                    ),
                    validator: (value) {
                      if (value == null || value.isEmpty) {
                        return 'Password wajib diisi';
                      }
                      if (value.length < 6) {
                        return 'Password minimal 6 karakter';
                      }
                      return null;
                    },
                  ),
                  const SizedBox(height: 24),
                  ElevatedButton(
                    onPressed: _login,
                    style: ElevatedButton.styleFrom(
                      backgroundColor: Colors.amber,
                      foregroundColor: Colors.black,
                      padding: const EdgeInsets.symmetric(vertical: 14),
                    ),
                    child: const Text(
                      'Login',
                      style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

#2 Perubahan main.dart

Rute '/' kini menunjuk ke LoginScreen, dan MainScreen dipindah ke rute '/main'. Halaman login sengaja tidak dijadikan initialRoute: '/login': untuk nama rute yang diawali garis miring, Flutter ikut memuat rute '/' di bawahnya, sehingga pengguna bisa melewati login dengan tombol back.

lib/main.dart
dart
import 'package:flutter/material.dart';
import 'login_screen.dart';
import 'main_screen.dart';
import 'add_transaction_screen.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Expense Tracker Final',
      theme: ThemeData(primarySwatch: Colors.amber),
      initialRoute: '/',
      routes: {
        '/': (context) => const LoginScreen(),
        '/main': (context) => const MainScreen(),
        '/add': (context) => const AddTransactionScreen(),
      },
    );
  }
}

#3 Perubahan main_screen.dart

Email dibaca dengan ModalRoute.of(context)?.settings.arguments. Karena nilai ini baru tersedia saat build, daftar halaman dipindah dari field final ke dalam method build agar ProfileScreen bisa menerima parameter email.

lib/main_screen.dart
dart
import 'package:flutter/material.dart';
import 'dashboard_screen.dart';
import 'history_screen.dart';
import 'profile_screen.dart';

class MainScreen extends StatefulWidget {
  const MainScreen({super.key});

  @override
  State<MainScreen> createState() => _MainScreenState();
}

class _MainScreenState extends State<MainScreen> {
  int _selectedIndex = 0;

  void _onItemTapped(int index) {
    setState(() {
      _selectedIndex = index;
    });
  }

  @override
  Widget build(BuildContext context) {
    final String email =
        ModalRoute.of(context)?.settings.arguments as String? ?? '';

    final List<Widget> pages = [
      const DashboardScreen(),
      const HistoryScreen(),
      ProfileScreen(email: email),
    ];

    return Scaffold(
      body: pages[_selectedIndex],
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _selectedIndex,
        onTap: _onItemTapped,
        selectedItemColor: Colors.amber[800],
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Beranda'),
          BottomNavigationBarItem(icon: Icon(Icons.history), label: 'Riwayat'),
          BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profil'),
        ],
      ),
    );
  }
}
4

Tugas Tambahan: Penggabungan Praktikum 1–5 dengan Penyimpanan Lokal

Tugas dari dosen: menggabungkan hasil Praktikum 1–5 menjadi satu project dengan ketentuan (1) menambahkan local storage agar data tidak hilang, (2) menambahkan form login dan register yang datanya disimpan di local storage, (3) membuat video penjelasan aplikasi, dan (4) membuat file APK. Desain UI/UX dipertahankan, kecuali halaman login dan register. Project gabungan dibuat di folder baru tugas_praktikum_5 agar hasil asli tiap praktikum tetap utuh.

Bagian yang diambil dari tiap praktikum:

  • Praktikum 1: field form pendaftaran (nama, email, password, konfirmasi password) menjadi halaman Register.

  • Praktikum 2: sapaan pengguna, kartu saldo dengan tombol mata, serta tombol Pemasukan, Pengeluaran, dan Transfer.

  • Praktikum 3: dropdown kategori pada form tambah transaksi.

  • Praktikum 4: dashboard bertema merah, form transaksi dengan validasi tanggal dan date picker, serta halaman detail transaksi.

  • Praktikum 5: named routes, BottomNavigationBar, halaman riwayat, profil, dan login.

#1 Struktur Project dan Dependency

Empat package ditambahkan ke pubspec.yaml: sqflite untuk SQLite, path untuk menyusun lokasi file database, shared_preferences untuk sesi login, dan crypto untuk hashing password. Nama package diubah menjadi tugas_praktikum_5, dan applicationId Android menjadi com.example.tugas_praktikum_5 agar APK tidak menimpa aplikasi praktikum sebelumnya.

pubspec.yaml
yaml
dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.8
  sqflite: ^2.4.0
  path: ^1.9.0
  shared_preferences: ^2.3.0
  crypto: ^3.0.6
Struktur folder lib/
lib/
├── main.dart                     # named routes + AuthGate
├── login_screen.dart             # login (baru)
├── register_screen.dart          # register (baru, field dari Praktikum 1)
├── main_screen.dart              # BottomNavigationBar (Praktikum 5)
├── dashboard_screen.dart         # dashboard (Praktikum 4 + widget Praktikum 2)
├── add_transaction_screen.dart   # form transaksi (Praktikum 3 + 4)
├── history_screen.dart           # riwayat (tugas Praktikum 5)
├── transaction_detail_screen.dart# detail (Praktikum 4) + hapus
├── profile_screen.dart           # profil (tugas Praktikum 5)
├── database/database_helper.dart # koneksi & query SQLite
├── models/app_user.dart          # model tabel users
├── models/transaction_item.dart  # model tabel transactions
├── services/auth_service.dart    # register, login, sesi
├── utils/formatters.dart         # format rupiah & tanggal
└── widgets/dashboard_widgets.dart# sapaan, kartu saldo, tombol aksi (Praktikum 2)

#2 Database SQLite

DatabaseHelper memakai pola singleton sehingga seluruh aplikasi berbagi satu koneksi database. Saat pertama dibuka, onCreate membuat dua tabel: users (email bersifat UNIQUE) dan transactions yang terhubung ke users lewat foreign key user_id. Saldo dihitung langsung dengan query SQL: total pemasukan dikurangi total pengeluaran milik pengguna yang login.

lib/database/database_helper.dart
dart
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
import '../models/app_user.dart';
import '../models/transaction_item.dart';

class DatabaseHelper {
  DatabaseHelper._();

  static final DatabaseHelper instance = DatabaseHelper._();

  static const String _namaDatabase = 'expense_tracker.db';
  static const int _versiDatabase = 1;

  Database? _database;

  Future<Database> get database async {
    _database ??= await _bukaDatabase();
    return _database!;
  }

  Future<Database> _bukaDatabase() async {
    final String lokasi = join(await getDatabasesPath(), _namaDatabase);
    return openDatabase(
      lokasi,
      version: _versiDatabase,
      onConfigure: (db) async {
        await db.execute('PRAGMA foreign_keys = ON');
      },
      onCreate: _buatTabel,
    );
  }

  Future<void> _buatTabel(Database db, int version) async {
    await db.execute('''
      CREATE TABLE users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        email TEXT NOT NULL UNIQUE,
        password_hash TEXT NOT NULL,
        created_at TEXT NOT NULL
      )
    ''');
    await db.execute('''
      CREATE TABLE transactions (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id INTEGER NOT NULL,
        title TEXT NOT NULL,
        amount INTEGER NOT NULL,
        type TEXT NOT NULL,
        category TEXT NOT NULL,
        date TEXT NOT NULL,
        created_at TEXT NOT NULL,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
      )
    ''');
  }

  Future<int> insertUser(AppUser user) async {
    final Database db = await database;
    return db.insert('users', user.toMap());
  }

  Future<AppUser?> getUserByEmail(String email) async {
    final Database db = await database;
    final List<Map<String, Object?>> rows = await db.query(
      'users',
      where: 'email = ?',
      whereArgs: [email],
      limit: 1,
    );
    if (rows.isEmpty) return null;
    return AppUser.fromMap(rows.first);
  }

  Future<AppUser?> getUserById(int id) async {
    final Database db = await database;
    final List<Map<String, Object?>> rows = await db.query(
      'users',
      where: 'id = ?',
      whereArgs: [id],
      limit: 1,
    );
    if (rows.isEmpty) return null;
    return AppUser.fromMap(rows.first);
  }

  Future<int> insertTransaction(TransactionItem item) async {
    final Database db = await database;
    return db.insert('transactions', {
      ...item.toMap(),
      'created_at': DateTime.now().toIso8601String(),
    });
  }

  Future<List<TransactionItem>> getTransactions(int userId, {int? limit}) async {
    final Database db = await database;
    final List<Map<String, Object?>> rows = await db.query(
      'transactions',
      where: 'user_id = ?',
      whereArgs: [userId],
      orderBy: 'date DESC, id DESC',
      limit: limit,
    );
    return rows.map(TransactionItem.fromMap).toList();
  }

  Future<int> getSaldo(int userId) async {
    final Database db = await database;
    final List<Map<String, Object?>> rows = await db.rawQuery(
      'SELECT COALESCE(SUM(CASE WHEN type = ? THEN amount ELSE -amount END), 0) AS saldo '
      'FROM transactions WHERE user_id = ?',
      [TransactionItem.tipePemasukan, userId],
    );
    return (rows.first['saldo'] as num?)?.toInt() ?? 0;
  }

  Future<int> deleteTransaction(int id, int userId) async {
    final Database db = await database;
    return db.delete(
      'transactions',
      where: 'id = ? AND user_id = ?',
      whereArgs: [id, userId],
    );
  }
}

#3 Logika Register, Login, dan Sesi

AuthService menangani register, login, logout, dan pemulihan sesi. Saat register, email dinormalkan ke huruf kecil dan dicek agar tidak terdaftar dua kali. Password disimpan sebagai hash SHA-256. Saat login berhasil, id pengguna disimpan ke shared_preferences, sehingga restoreSession bisa memulihkan sesi ketika aplikasi dibuka kembali.

lib/services/auth_service.dart
dart
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../database/database_helper.dart';
import '../models/app_user.dart';

class AuthException implements Exception {
  final String message;

  const AuthException(this.message);

  @override
  String toString() => message;
}

class AuthService {
  AuthService._();

  static final AuthService instance = AuthService._();

  static const String _kunciSesi = 'logged_in_user_id';

  AppUser? _currentUser;

  AppUser? get currentUser => _currentUser;

  bool get isLoggedIn => _currentUser != null;

  String _hashPassword(String password) {
    return sha256.convert(utf8.encode(password)).toString();
  }

  String _normalisasiEmail(String email) => email.trim().toLowerCase();

  Future<void> restoreSession() async {
    final SharedPreferences prefs = await SharedPreferences.getInstance();
    final int? userId = prefs.getInt(_kunciSesi);
    if (userId == null) return;

    final AppUser? user = await DatabaseHelper.instance.getUserById(userId);
    if (user == null) {
      await prefs.remove(_kunciSesi);
      return;
    }
    _currentUser = user;
  }

  Future<AppUser> register({
    required String name,
    required String email,
    required String password,
  }) async {
    final String emailBersih = _normalisasiEmail(email);
    final AppUser? sudahAda =
        await DatabaseHelper.instance.getUserByEmail(emailBersih);
    if (sudahAda != null) {
      throw const AuthException('Email sudah terdaftar, silakan login');
    }

    final AppUser userBaru = AppUser(
      name: name.trim(),
      email: emailBersih,
      passwordHash: _hashPassword(password),
      createdAt: DateTime.now().toIso8601String(),
    );
    final int id = await DatabaseHelper.instance.insertUser(userBaru);
    return userBaru.copyWith(id: id);
  }

  Future<AppUser> login({
    required String email,
    required String password,
  }) async {
    final AppUser? user =
        await DatabaseHelper.instance.getUserByEmail(_normalisasiEmail(email));
    if (user == null || user.passwordHash != _hashPassword(password)) {
      throw const AuthException('Email atau password salah');
    }

    final SharedPreferences prefs = await SharedPreferences.getInstance();
    await prefs.setInt(_kunciSesi, user.id!);
    _currentUser = user;
    return user;
  }

  Future<void> logout() async {
    final SharedPreferences prefs = await SharedPreferences.getInstance();
    await prefs.remove(_kunciSesi);
    _currentUser = null;
  }
}

#4 Halaman Register

Field halaman register diambil dari form pendaftaran Praktikum 1, termasuk validasi konfirmasi password. Setelah pendaftaran berhasil, halaman ditutup dengan Navigator.pop(context, user.email), dan halaman login otomatis mengisi email yang baru didaftarkan.

lib/register_screen.dart
dart
import 'package:flutter/material.dart';
import 'services/auth_service.dart';

class RegisterScreen extends StatefulWidget {
  const RegisterScreen({super.key});

  @override
  State<RegisterScreen> createState() => _RegisterScreenState();
}

class _RegisterScreenState extends State<RegisterScreen> {
  final _formKey = GlobalKey<FormState>();
  final _nameController = TextEditingController();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  final _confirmPasswordController = TextEditingController();
  bool _sembunyikanPassword = true;
  bool _sedangProses = false;

  @override
  void dispose() {
    _nameController.dispose();
    _emailController.dispose();
    _passwordController.dispose();
    _confirmPasswordController.dispose();
    super.dispose();
  }

  Future<void> _register() async {
    if (!_formKey.currentState!.validate()) return;

    setState(() => _sedangProses = true);
    try {
      final user = await AuthService.instance.register(
        name: _nameController.text,
        email: _emailController.text,
        password: _passwordController.text,
      );
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text('Pendaftaran ${user.name} berhasil, silakan login'),
          backgroundColor: Colors.green,
        ),
      );
      Navigator.pop(context, user.email);
    } on AuthException catch (e) {
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(e.message), backgroundColor: Colors.red),
      );
    } catch (e) {
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Terjadi kesalahan: $e'), backgroundColor: Colors.red),
      );
    } finally {
      if (mounted) setState(() => _sedangProses = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        elevation: 0,
        foregroundColor: Colors.black,
      ),
      body: SafeArea(
        child: Center(
          child: SingleChildScrollView(
            padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
            child: Form(
              key: _formKey,
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: [
                  const Icon(Icons.person_add, size: 72, color: Colors.red),
                  const SizedBox(height: 16),
                  const Text(
                    'Buat Akun Baru',
                    textAlign: TextAlign.center,
                    style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold),
                  ),
                  const SizedBox(height: 8),
                  Text(
                    'Data akun disimpan di perangkat ini',
                    textAlign: TextAlign.center,
                    style: TextStyle(fontSize: 16, color: Colors.grey[600]),
                  ),
                  const SizedBox(height: 32),
                  TextFormField(
                    controller: _nameController,
                    keyboardType: TextInputType.name,
                    textCapitalization: TextCapitalization.words,
                    decoration: const InputDecoration(
                      labelText: 'Nama',
                      prefixIcon: Icon(Icons.person),
                      border: OutlineInputBorder(),
                    ),
                    validator: (value) {
                      if (value == null || value.trim().isEmpty) {
                        return 'Masukkan nama anda';
                      }
                      return null;
                    },
                  ),
                  const SizedBox(height: 16),
                  TextFormField(
                    controller: _emailController,
                    keyboardType: TextInputType.emailAddress,
                    decoration: const InputDecoration(
                      labelText: 'Email',
                      prefixIcon: Icon(Icons.email),
                      border: OutlineInputBorder(),
                    ),
                    validator: (value) {
                      if (value == null || value.trim().isEmpty) {
                        return 'Masukkan email anda';
                      }
                      final bool formatValid = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
                          .hasMatch(value.trim());
                      if (!formatValid) {
                        return 'Email tidak valid';
                      }
                      return null;
                    },
                  ),
                  const SizedBox(height: 16),
                  TextFormField(
                    controller: _passwordController,
                    obscureText: _sembunyikanPassword,
                    decoration: InputDecoration(
                      labelText: 'Password',
                      prefixIcon: const Icon(Icons.lock),
                      border: const OutlineInputBorder(),
                      suffixIcon: IconButton(
                        icon: Icon(
                          _sembunyikanPassword
                              ? Icons.visibility
                              : Icons.visibility_off,
                        ),
                        onPressed: () {
                          setState(() {
                            _sembunyikanPassword = !_sembunyikanPassword;
                          });
                        },
                      ),
                    ),
                    validator: (value) {
                      if (value == null || value.isEmpty) {
                        return 'Masukkan password anda';
                      }
                      if (value.length < 6) {
                        return 'Password minimal 6 karakter';
                      }
                      return null;
                    },
                  ),
                  const SizedBox(height: 16),
                  TextFormField(
                    controller: _confirmPasswordController,
                    obscureText: _sembunyikanPassword,
                    decoration: const InputDecoration(
                      labelText: 'Confirm Password',
                      prefixIcon: Icon(Icons.lock_outline),
                      border: OutlineInputBorder(),
                    ),
                    validator: (value) {
                      if (value == null || value.isEmpty) {
                        return 'Masukkan confirm password anda';
                      }
                      if (value != _passwordController.text) {
                        return 'Password dan confirm password tidak sama';
                      }
                      return null;
                    },
                  ),
                  const SizedBox(height: 24),
                  ElevatedButton(
                    onPressed: _sedangProses ? null : _register,
                    style: ElevatedButton.styleFrom(
                      backgroundColor: Colors.red,
                      foregroundColor: Colors.white,
                      padding: const EdgeInsets.symmetric(vertical: 14),
                    ),
                    child: _sedangProses
                        ? const SizedBox(
                            height: 20,
                            width: 20,
                            child: CircularProgressIndicator(
                              strokeWidth: 2,
                              color: Colors.white,
                            ),
                          )
                        : const Text(
                            'Daftar',
                            style: TextStyle(
                                fontSize: 16, fontWeight: FontWeight.bold),
                          ),
                  ),
                  const SizedBox(height: 12),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      const Text('Sudah punya akun?'),
                      TextButton(
                        onPressed:
                            _sedangProses ? null : () => Navigator.pop(context),
                        child: const Text(
                          'Login',
                          style: TextStyle(
                            color: Colors.red,
                            fontWeight: FontWeight.bold,
                          ),
                        ),
                      ),
                    ],
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

#5 Routing dan Pengecekan Sesi

Sebelum runApp, restoreSession dipanggil untuk memeriksa sesi tersimpan. Rute '/' diarahkan ke AuthGate, yang menampilkan MainScreen bila pengguna sudah login atau LoginScreen bila belum. Login dan logout memakai pushNamedAndRemoveUntil agar tumpukan halaman bersih.

lib/main.dart
dart
import 'package:flutter/material.dart';
import 'services/auth_service.dart';
import 'login_screen.dart';
import 'register_screen.dart';
import 'main_screen.dart';
import 'add_transaction_screen.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  try {
    await AuthService.instance.restoreSession();
  } catch (e) {
    debugPrint('Gagal memulihkan sesi: $e');
  }
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Expense Tracker',
      theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.red)),
      // MENGGUNAKAN NAMED ROUTES
      initialRoute: '/',
      routes: {
        '/': (context) => const AuthGate(),
        '/login': (context) => const LoginScreen(),
        '/register': (context) => const RegisterScreen(),
        '/main': (context) => const MainScreen(),
        '/add': (context) => const AddTransactionScreen(),
      },
    );
  }
}

class AuthGate extends StatelessWidget {
  const AuthGate({super.key});

  @override
  Widget build(BuildContext context) {
    return AuthService.instance.isLoggedIn
        ? const MainScreen()
        : const LoginScreen();
  }
}

#6 Dashboard dengan Data dari Database

Dashboard Praktikum 4 diubah menjadi StatefulWidget yang memuat saldo dan lima transaksi terbaru dari database di initState. Komponen Praktikum 2 (sapaan, kartu saldo dengan tombol mata, dan tombol aksi) dipindah ke widgets/dashboard_widgets.dart. Tombol Pemasukan, Pengeluaran, dan Transfer membuka rute '/add' dengan arguments berisi jenis dan kategori awal. Bila form mengembalikan true, dashboard memuat ulang data.

lib/dashboard_screen.dart
dart
import 'package:flutter/material.dart';
import 'database/database_helper.dart';
import 'models/transaction_item.dart';
import 'services/auth_service.dart';
import 'transaction_detail_screen.dart';
import 'utils/formatters.dart';
import 'widgets/dashboard_widgets.dart';

class DashboardScreen extends StatefulWidget {
  const DashboardScreen({super.key});

  @override
  State<DashboardScreen> createState() => _DashboardScreenState();
}

class _DashboardScreenState extends State<DashboardScreen> {
  int _saldo = 0;
  List<TransactionItem> _transaksiTerakhir = [];
  bool _sedangMemuat = true;

  @override
  void initState() {
    super.initState();
    _muatData();
  }

  Future<void> _muatData() async {
    final user = AuthService.instance.currentUser;
    if (user == null) return;

    final int saldo = await DatabaseHelper.instance.getSaldo(user.id!);
    final List<TransactionItem> transaksi =
        await DatabaseHelper.instance.getTransactions(user.id!, limit: 5);
    if (!mounted) return;
    setState(() {
      _saldo = saldo;
      _transaksiTerakhir = transaksi;
      _sedangMemuat = false;
    });
  }

  Future<void> _bukaFormTransaksi([Map<String, String>? preset]) async {
    // Memanggil Rute yang sudah didaftarkan di main.dart
    final result = await Navigator.pushNamed(context, '/add', arguments: preset);
    if (result == true) _muatData();
  }

  Future<void> _bukaDetail(TransactionItem item) async {
    final bool berubah = await TransactionDetailScreen.open(context, item);
    if (berubah) _muatData();
  }

  @override
  Widget build(BuildContext context) {
    final String nama = AuthService.instance.currentUser?.name ?? 'Pengguna';

    return Scaffold(
      appBar: AppBar(
        title: const Text('Dashboard Keuangan'),
        backgroundColor: Colors.red,
        foregroundColor: Colors.white,
      ),
      // Tombol mengambang (FAB) untuk tambah transaksi
      floatingActionButton: FloatingActionButton(
        backgroundColor: Colors.red,
        foregroundColor: Colors.white,
        child: const Icon(Icons.add),
        onPressed: () => _bukaFormTransaksi(),
      ),
      body: RefreshIndicator(
        onRefresh: _muatData,
        child: SingleChildScrollView(
          physics: const AlwaysScrollableScrollPhysics(),
          padding: const EdgeInsets.all(16.0),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              GreetingWidget(name: nama),
              const SizedBox(height: 20),
              BalanceCardWidget(saldo: _saldo),
              const SizedBox(height: 20),
              ActionButtonsWidget(
                onPemasukan: () => _bukaFormTransaksi(
                  {'type': TransactionItem.tipePemasukan},
                ),
                onPengeluaran: () => _bukaFormTransaksi(
                  {'type': TransactionItem.tipePengeluaran},
                ),
                onTransfer: () => _bukaFormTransaksi(
                  {'type': TransactionItem.tipePengeluaran, 'category': 'Transfer'},
                ),
              ),
              const SizedBox(height: 20),
              const Text('Transaksi Terakhir', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
              const SizedBox(height: 12),
              _buildTransactionList(),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildTransactionList() {
    if (_sedangMemuat) {
      return const Center(
        child: Padding(
          padding: EdgeInsets.all(24),
          child: CircularProgressIndicator(color: Colors.red),
        ),
      );
    }

    if (_transaksiTerakhir.isEmpty) {
      return const Card(
        elevation: 2,
        child: Padding(
          padding: EdgeInsets.all(24),
          child: Center(
            child: Text(
              'Belum ada transaksi.\nTekan tombol + untuk mencatat transaksi.',
              textAlign: TextAlign.center,
              style: TextStyle(color: Colors.grey),
            ),
          ),
        ),
      );
    }

    final List<Widget> daftar = [];
    for (int i = 0; i < _transaksiTerakhir.length; i++) {
      final TransactionItem item = _transaksiTerakhir[i];
      if (i > 0) daftar.add(const Divider(height: 1));
      daftar.add(
        ListTile(
          leading: CircleAvatar(backgroundColor: item.color, child: Icon(item.icon, color: Colors.white)),
          title: Text(item.title),
          subtitle: Text(formatTanggalPendek(item.date)),
          trailing: Text(
            formatRupiahBertanda(item.amount, item.isPengeluaran),
            style: TextStyle(
              color: item.isPengeluaran ? Colors.red : Colors.green,
              fontWeight: FontWeight.bold,
            ),
          ),
          onTap: () => _bukaDetail(item),
        ),
      );
    }

    return Card(
      elevation: 2,
      child: Column(children: daftar),
    );
  }
}

#7 Menyimpan Transaksi

Form transaksi menggabungkan form Praktikum 4 (validasi dan date picker tanggal) dengan dropdown kategori Praktikum 3, ditambah dropdown jenis transaksi. Saat disimpan, input diubah menjadi objek TransactionItem, dimasukkan ke tabel transactions, lalu halaman ditutup dengan nilai true.

lib/add_transaction_screen.dart
dart
  // Menyimpan transaksi ke database SQLite
  Future<void> _simpanTransaksi() async {
    if (!_formKey.currentState!.validate()) return;

    final user = AuthService.instance.currentUser;
    if (user == null) return;

    setState(() => _sedangMenyimpan = true);
    final TransactionItem item = TransactionItem(
      userId: user.id!,
      title: _titleController.text.trim(),
      amount: int.parse(_amountController.text.trim()),
      type: _selectedType,
      category: _selectedCategory,
      date: _parseDate(_dateController.text.trim())!,
    );

    try {
      await DatabaseHelper.instance.insertTransaction(item);
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Transaksi Berhasil Disimpan!'), backgroundColor: Colors.green),
      );
      // NAVIGASI POP: Kembali ke halaman sebelumnya (Dashboard)
      Navigator.pop(context, true);
    } catch (e) {
      if (!mounted) return;
      setState(() => _sedangMenyimpan = false);
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Gagal menyimpan: $e'), backgroundColor: Colors.red),
      );
    }
  }

#8 Riwayat, Detail, dan Hapus Transaksi

Tab Riwayat kini membaca semua transaksi pengguna dari database. Setiap item bisa diketuk untuk membuka halaman detail dari Praktikum 4, yang diberi tombol hapus di AppBar. Setelah transaksi dihapus, riwayat dan saldo diperbarui.

#9 Pengujian Penyimpanan Data

Pengujian dilakukan di HP Android: register akun, login, menambah beberapa transaksi, lalu menutup paksa aplikasi lewat menu recent apps. Saat dibuka kembali, aplikasi langsung masuk ke Beranda tanpa login ulang, dan saldo serta semua transaksi masih tersedia. Hasil ini menunjukkan data tersimpan di SQLite, bukan hanya di memori.

#10 Kendala Build Android

Build pertama gagal karena dua hal. Pertama, folder NDK versi 28.2.13676358 terunduh tidak lengkap, sehingga folder itu dihapus lalu diunduh ulang otomatis oleh Gradle. Kedua, incremental compilation Kotlin gagal karena project berada di drive D: sedangkan Pub Cache berada di drive C:. Masalah kedua diatasi dengan mematikan incremental compilation Kotlin di android/gradle.properties, lalu menjalankan flutter clean.

android/gradle.properties
.env
kotlin.incremental=false

#11 Build APK Release

APK dibuat dengan mode release. File hasilnya berada di build/app/outputs/flutter-apk/app-release.apk, lalu dipasang di HP untuk pengujian dan perekaman video demo.

Terminal
bash
cd D:\appmobile\tugas_praktikum_5
flutter build apk --release

VIKesimpulan

Dari Praktikum 5 dan tugas penggabungan ini, dapat ditarik beberapa kesimpulan:

  1. Named Routes membuat navigasi terpusat di MaterialApp, sehingga perpindahan halaman cukup memanggil nama rute dan data dapat dikirim melalui arguments.

  2. BottomNavigationBar memungkinkan beberapa halaman utama berada dalam satu Scaffold. Halaman yang tampil cukup dikendalikan oleh satu indeks di StatefulWidget.

  3. Penggunaan pushReplacementNamed dan pushNamedAndRemoveUntil penting pada alur login dan logout agar pengguna tidak bisa kembali ke halaman yang seharusnya tertutup.

  4. SQLite melalui sqflite membuat data pengguna dan transaksi tetap tersimpan walaupun aplikasi ditutup, sedangkan shared_preferences cukup untuk menyimpan sesi login.

  5. Aplikasi masih memiliki keterbatasan: hash password belum memakai salt, data ikut terhapus bila aplikasi di-uninstall, dan transaksi belum bisa diedit.