Mengelola State Flutter Tanpa Ribet: Bedah Kode Aplikasi BMI Calculator dengan Provider
Bagi pengembang aplikasi Flutter, mengelola status (state management) sering kali menjadi tantangan tersendiri. Ketika aplikasi mulai tumbuh kompleks, meneruskan data antar-layar menggunakan constructor atau
setState standar bisa membuat struktur kode menjadi rumit dan sulit dirawat.Artikel ini membedah arsitektur kode aplikasi BMI Calculator sederhana yang ditulis menggunakan bahasa Dart dan framework Flutter, dengan memanfaatkan paket Provider sebagai solusi state management yang bersih dan efisien.
1. Menyiapkan Entry Point dan Provider Root
Langkah pertama dalam menggunakan
provider adalah membungkus hierarki widget paling atas agar state dapat diakses oleh seluruh komponen di bawahnya.Dart
void main() => runApp(const BMICalculatorApp());
class BMICalculatorApp extends StatelessWidget {
const BMICalculatorApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider<BMIState>(
create: (_) => BMIState(),
builder: (context, _) => MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.dark,
scaffoldBackgroundColor: const Color(0xFF0F111A),
cardColor: const Color(0xFF1D1E33),
primaryColor: const Color(0xFF536DFE),
colorScheme: const ColorScheme.dark(
primary: Color(0xFF536DFE),
secondary: Color(0xFFFF4081),
),
),
home: const BMIPage(),
),
);
}
}
Point Penting:
ChangeNotifierProvider: Berfungsi menginstansiasi BMIState dan menyediakannya untuk seluruh child widget.
Penerapan Tema Dark: Menggunakan warna-warna kustom seperti #0F111A (latar belakang) dan #1D1E33 (kartu) untuk memberikan tampilan modern bernuansa dark mode.
2. Memisahkan Logika Bisnis dengan ChangeNotifier
Kelas BMIState bertindak sebagai Model-View-ViewModel (MVVM) mini. Kelas ini menyimpan data, memproses perhitungan, dan memberi tahu UI jika terjadi perubahan data.
Dart
class BMIState extends ChangeNotifier {
double _height = 170.0;
double _weight = 60.0;
double get height => _height;
double get weight => _weight;
set height(double value) {
_height = value;
notifyListeners(); // Memperbarui UI saat nilai berubah
}
set weight(double value) {
_weight = value;
notifyListeners();
}
// Rumus BMI: BB (kg) / (TB (m) * TB (m))
double calculateBMI() => _weight / ((_height / 100) * (_height / 100));
String getSuggestion(double bmi) {
if (bmi < 18.5) {
return "Recommendation: Focus on calorie-dense, nutrient-rich foods...";
} else if (bmi < 25) {
return "Recommendation: You have a healthy weight! Maintain your lifestyle...";
} else {
return "Recommendation: Consider increasing your intake of vegetables...";
}
}
}
Mengapa Pendekatan Ini Bagus?
Dekonsentrasi Logika: Perhitungan matematika ($BMI = \frac{weight}{height^2}$) dan saran kesehatan terpisah penuh dari tampilan UI.
notifyListeners(): Setiap kali slider digeser, metode setter dipanggil dan notifyListeners() akan memicu re-render pada widget yang mendengarkan (subscribe).
3. Konsumsi State di Tampilan Utama (BMIPage)
Pada layar utama, kita menggunakan Consumer<BMIState> agar hanya bagian yang membutuhkan data yang di-render ulang (rebuild) saat data berubah.
Dart
class BMIPage extends StatelessWidget {
const BMIPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("BMI CALCULATOR", style: TextStyle(letterSpacing: 1.2)),
centerTitle: true,
backgroundColor: Colors.transparent,
elevation: 0,
),
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight - 48),
child: Consumer<BMIState>(
builder: (context, state, child) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_SliderControl(
label: "HEIGHT (CM)",
value: state.height,
min: 100,
max: 220,
onChanged: (v) => state.height = v,
),
const SizedBox(height: 24),
_SliderControl(
label: "WEIGHT (KG)",
value: state.weight,
min: 30,
max: 150,
onChanged: (v) => state.weight = v,
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 65,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16)),
),
onPressed: () => _showResult(context),
child: const Text("CALCULATE",
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.bold)),
),
),
],
);
},
),
),
);
},
),
),
);
}
Sorotan Desain UI:
LayoutBuilder & SingleChildScrollView: Mencegah eror overflow (garis kuning-hitam) saat aplikasi dijalankan di perangkat dengan layar kecil atau orientasi lanskap.
Consumer<BMIState>: Menghubungkan slider tinggi dan berat badan langsung ke BMIState.
4. Menampilkan Hasil Menggunakan Bottom Sheet
Ketika tombol CALCULATE ditekan, fungsi _showResult membaca state terkini menggunakan Provider.of<BMIState>(context, listen: false) tanpa perlu melakukan render ulang pada layar utama
Dart
void _showResult(BuildContext context) {
final state = Provider.of<BMIState>(context, listen: false);
final bmi = state.calculateBMI();
String status = bmi < 18.5 ? "Underweight" : bmi < 25 ? "Normal" : "Overweight";
Color color = bmi < 18.5 ? Colors.blue : bmi < 25 ? Colors.green : Colors.redAccent;
String suggestion = state.getSuggestion(bmi);
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => Container(
padding: const EdgeInsets.all(32),
decoration: const BoxDecoration(
color: Color(0xFF1D1E33),
borderRadius: BorderRadius.vertical(top: Radius.circular(30)),
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text("BMI RESULT", style: TextStyle(fontSize: 20, color: Colors.white70)),
const SizedBox(height: 16),
Text(bmi.toStringAsFixed(1), style: const TextStyle(fontSize: 64, fontWeight: FontWeight.bold)),
Text(status.toUpperCase(),
style: TextStyle(fontSize: 20, color: color, letterSpacing: 2, fontWeight: FontWeight.bold)),
const SizedBox(height: 24),
Text(suggestion,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16, color: Colors.white54, height: 1.5)),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
if (Navigator.of(context).mounted) {
Navigator.of(context).pop();
}
},
child: const Text("CLOSE"))
],
),
),
),
);
}
}
5. Komponen Reusable: Custom Slider Control
Untuk menjaga prinsip DRY (Don't Repeat Yourself), kontrol slider untuk tinggi dan berat badan diekstrak menjadi satu widget tersendiri yaitu _SliderControl.
Dart
class _SliderControl extends StatelessWidget {
final String label;
final double value;
final double min;
final double max;
final ValueChanged<double> onChanged;
const _SliderControl({
required this.label,
required this.value,
required this.min,
required this.max,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(24),
),
child: Column(
children: [
Text(label, style: const TextStyle(fontSize: 14, color: Colors.white70, letterSpacing: 1)),
const SizedBox(height: 8),
Text("${value.toInt()}", style: const TextStyle(fontSize: 40, fontWeight: FontWeight.w800)),
Slider(
value: value,
min: min,
max: max,
activeColor: Theme.of(context).colorScheme.secondary,
onChanged: onChanged,
),
],
),
);
}
}
Kesimpulan
Dengan menerapkan pola arsitektur ini, aplikasi tidak hanya memiliki tampilan visual yang rapi (dark theme modern), tetapi juga memiliki struktur kode yang:
Modular: Widget UI dan logika bisnis terpisah dengan jelas.
Efisien: Render ulang hanya terjadi pada komponen yang membutuhkan pembaruan data.
Mudah Dirawat: Penambahan fitur baru (seperti pilihan jenis kelamin atau riwayat perhitungan) dapat dilakukan pada kelas BMIState tanpa merusak struktur UI yang ada.
Berikut adalah kode unit test sederhana menggunakan flutter_test untuk menguji logika bisnis pada kelas BMIState.
Pengujian ini berfokus pada dua aspek utama:
Perhitungan nilai BMI sesuai dengan rumus $BMI = \frac{\text{weight}}{(\text{height}/100)^2}$.
Output rekomendasi/saran berdasarkan kategori BMI (Underweight, Normal, dan Overweight).
Kode Unit Test (test/bmi_state_test.dart)
Dart
import 'package:flutter_test/flutter_test.dart';
// Sesuaikan import di bawah dengan lokasi file BMIState di proyek Anda
// import 'package:nama_project_anda/main.dart';
void main() {
group('BMIState Business Logic Tests', () {
late BMIState bmiState;
setUp(() {
// Inisialisasi ulang instance sebelum tiap skenario tes dijalankan
bmiState = BMIState();
});
test('Nilai awal height dan weight harus sesuai default', () {
expect(bmiState.height, 170.0);
expect(bmiState.weight, 60.0);
});
test('Perhitungan BMI harus akurat', () {
// Setup: Tinggi 170 cm, Berat 60 kg -> BMI ~ 20.76
bmiState.height = 170.0;
bmiState.weight = 60.0;
final bmi = bmiState.calculateBMI();
// Memastikan hasil perhitungan sesuai hingga 2 angka di belakang koma
expect(bmi, closeTo(20.76, 0.01));
});
test('Memastikan setter memperbarui nilai dan memanggil notifyListeners', () {
bool isNotified = false;
bmiState.addListener(() {
isNotified = true;
});
bmiState.height = 180.0;
expect(bmiState.height, 180.0);
expect(isNotified, isTrue);
});
group('Kategori dan Rekomendasi BMI', () {
test('Memberikan rekomendasi yang tepat untuk Underweight (< 18.5)', () {
bmiState.height = 180.0;
bmiState.weight = 50.0; // BMI ~ 15.43
final bmi = bmiState.calculateBMI();
final suggestion = bmiState.getSuggestion(bmi);
expect(bmi, lessThan(18.5));
expect(suggestion, contains("Focus on calorie-dense"));
});
test('Memberikan rekomendasi yang tepat untuk Normal Weight (18.5 - 24.9)', () {
bmiState.height = 170.0;
bmiState.weight = 65.0; // BMI ~ 22.49
final bmi = bmiState.calculateBMI();
final suggestion = bmiState.getSuggestion(bmi);
expect(bmi, greaterThanOrEqualTo(18.5));
expect(bmi, lessThan(25.0));
expect(suggestion, contains("You have a healthy weight!"));
});
test('Memberikan rekomendasi yang tepat untuk Overweight (>= 25)', () {
bmiState.height = 160.0;
bmiState.weight = 80.0; // BMI ~ 31.25
final bmi = bmiState.calculateBMI();
final suggestion = bmiState.getSuggestion(bmi);
expect(bmi, greaterThanOrEqualTo(25.0));
expect(suggestion, contains("Consider increasing your intake of vegetables"));
});
});
});
}
Poin Penting dalam Unit Test Ini:
closeTo() Matcher: Mengingat pembagian bilangan desimal (double) sering kali memiliki presisi mengambang, penggunaan closeTo(20.76, 0.01) memastikan tes tidak flaky (gagal akibat selisih desimal mikroskopis).
Pengujian notifyListeners(): Memastikan bahwa pemanggilan setter height / weight benar-benar memicu notifyListeners(), yang penting untuk pembaruan UI pada Provider.
group() Structure: Mengelompokkan skenario tes secara logis agar output laporan pengujian mudah dibaca di terminal.
Cara Menjalankan Tes
Jalankan perintah berikut di terminal proyek Flutter Anda.
Bash
flutter test test/bmi_state_test.dart

Komentar
Posting Komentar