diff --git a/Source Code Mobile/control_history_page.dart b/Source Code Mobile/control_history_page.dart new file mode 100644 index 0000000..9339fc6 --- /dev/null +++ b/Source Code Mobile/control_history_page.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../providers/history_provider.dart'; + +class ControlHistoryPage extends StatefulWidget { + const ControlHistoryPage({super.key}); + + @override + State createState() => _ControlHistoryPageState(); +} + +class _ControlHistoryPageState extends State { + @override + void initState() { + super.initState(); + + context.read().startControlListener(); + } + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + + return Scaffold( + appBar: AppBar( + title: const Text('Histori Kontrol'), + actions: [ + IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.redAccent), + tooltip: 'Hapus Histori', + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Hapus Histori'), + content: const Text('Apakah Anda yakin ingin menghapus semua data histori kontrol? Tindakan ini tidak dapat dibatalkan.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + TextButton( + onPressed: () async { + Navigator.pop(context); + try { + await context.read().clearControlHistory(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Histori kontrol berhasil dihapus')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal menghapus histori: $e')), + ); + } + } + }, + child: const Text('Hapus', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + }, + ), + ], + ), + body: provider.controlHistory.isEmpty + ? const Center( + child: Text( + 'Belum ada data histori kontrol.', + style: TextStyle(color: Colors.grey), + ), + ) + : ListView.builder( + itemCount: provider.controlHistory.length, + itemBuilder: (context, index) { + final item = provider.controlHistory[index]; + + return Card( + child: ListTile( + title: Text(item.action), + subtitle: Text('${item.source}\n${item.timestamp}'), + ), + ); + }, + ), + ); + } +} diff --git a/Source Code Mobile/dashboard_page.dart b/Source Code Mobile/dashboard_page.dart new file mode 100644 index 0000000..80010a1 --- /dev/null +++ b/Source Code Mobile/dashboard_page.dart @@ -0,0 +1,1092 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../providers/sensor_provider.dart'; +import '../providers/ac_provider.dart'; +import '../providers/ac_config_provider.dart'; +import '../providers/ac_setup_provider.dart'; +import '../providers/auth_provider.dart'; +import '../models/sensor_model.dart'; +import '../models/ac_model.dart'; +import 'monitoring_history_page.dart'; +import 'profile_page.dart'; +import 'login_page.dart'; +import '../core/app_color.dart'; + +class DashboardPage extends StatefulWidget { + const DashboardPage({super.key}); + + @override + State createState() => _DashboardPageState(); +} + +class _DashboardPageState extends State { + int currentIndex = 0; + + @override + void initState() { + super.initState(); + context.read().startListening(); + context.read().startListening(); + context.read().startListening(); + context.read().startListening(); + } + + @override + Widget build(BuildContext context) { + final sensorProvider = context.watch(); + final sensor = sensorProvider.sensor; + final acProvider = context.watch(); + final ac = acProvider.ac; + final acConfig = context.watch(); + final setupProvider = context.watch(); + + final pages = [ + _buildDashboard( + sensor, + sensorProvider.isLoading, + sensorProvider.errorMessage, + ac, + acProvider, + acConfig, + setupProvider, + ), + const MonitoringHistoryPage(), + const ProfilePage(), + ]; + + return Scaffold( + body: pages[currentIndex], + bottomNavigationBar: BottomNavigationBar( + currentIndex: currentIndex, + onTap: (value) => setState(() => currentIndex = value), + selectedItemColor: Colors.blueAccent, + type: BottomNavigationBarType.fixed, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.dashboard), + label: 'Dashboard', + ), + BottomNavigationBarItem(icon: Icon(Icons.history), label: 'Histori'), + BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profil'), + ], + ), + ); + } + + // ====================== DASHBOARD BUILD ====================== + + Widget _buildDashboard( + SensorModel? sensor, + bool isLoading, + String? errorMessage, + ACModel ac, + ACProvider acProvider, + ACConfigProvider acConfig, + ACSetupProvider setupProvider, + ) { + Widget bodyContent; + + if (isLoading) { + bodyContent = const Center( + child: CircularProgressIndicator(color: Colors.blueAccent), + ); + } else if (sensor == null) { + bodyContent = Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.cloud_off, size: 80, color: Colors.grey), + const SizedBox(height: 16), + const Text( + 'Data Tidak Tersedia', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + errorMessage ?? 'Database kosong atau koneksi ke IoT terputus.', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.grey), + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () { + context.read().startListening(); + context.read().startListening(); + }, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blueAccent, + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + ); + } else { + final isAirGood = + sensor.overallStatus == 'Sangat Baik' || + sensor.overallStatus == 'Baik'; + + bodyContent = ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: [ + // Header + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: const BoxDecoration( + color: Colors.blueAccent, + shape: BoxShape.circle, + ), + child: const Icon(Icons.ac_unit, color: Colors.white, size: 24), + ), + const SizedBox(width: 12), + const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Smart AC', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + Text( + 'IoT Dashboard', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + ], + ), + const Spacer(), + // Live Badge + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + border: Border.all( + color: Colors.green.withValues(alpha: 0.5), + ), + borderRadius: BorderRadius.circular(16), + ), + child: const Row( + children: [ + Icon( + Icons.fiber_manual_record, + color: Colors.green, + size: 10, + ), + SizedBox(width: 4), + Text( + 'Live', + style: TextStyle( + color: Colors.green, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.logout, color: Colors.white70), + onPressed: () async { + await context.read().logout(); + if (!mounted) return; + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const LoginPage()), + (route) => false, + ); + }, + ), + ], + ), + const SizedBox(height: 16), + + // Air Quality Banner + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isAirGood ? Colors.green : Colors.red, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Icon( + isAirGood + ? Icons.check_circle_outline + : Icons.warning_amber_outlined, + color: Colors.white, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + isAirGood + ? 'Kualitas udara baik — semua parameter normal' + : 'Kualitas udara buruk — Buka ventilasi ruangan', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + + const Text( + 'Sensor Data', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white70, + ), + ), + const SizedBox(height: 10), + + // 2x2 Sensor Grid + Row( + children: [ + Expanded( + child: _buildSensorCard( + title: 'Suhu', + value: '${sensor.temperature.toStringAsFixed(1)} °C', + status: sensor.temperatureStatus, + icon: Icons.thermostat, + iconColor: Colors.blue, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildSensorCard( + title: 'Kelembaban', + value: '${sensor.humidity.toStringAsFixed(1)} %', + status: sensor.humidityStatus, + icon: Icons.water_drop, + iconColor: Colors.green, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildSensorCard( + title: 'PM2.5', + value: '${sensor.pm25.toStringAsFixed(2)} µg/m³', + status: sensor.pm25Status, + icon: Icons.grain, + iconColor: Colors.amber, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildSensorCard( + title: 'Karbon Dioksida', + value: '${sensor.co2.toStringAsFixed(0)} ppm', + status: sensor.co2Status, + icon: Icons.co2, + iconColor: Colors.redAccent, + ), + ), + ], + ), + const SizedBox(height: 24), + + // ============ AC CARD (inline setup + manual control) ============ + _buildACCard(ac, acProvider, acConfig, setupProvider), + + const SizedBox(height: 24), + ], + ); + } + + return Scaffold( + backgroundColor: AppColor.background, + body: SafeArea(child: bodyContent), + ); + } + + // ====================== AC CARD ====================== + // This single card handles both setup flow and manual control inline. + + Widget _buildACCard( + ACModel ac, + ACProvider acProvider, + ACConfigProvider acConfig, + ACSetupProvider setupProvider, + ) { + // Determine if we're currently in setup flow + final bool isInSetup = setupProvider.mode == 'config' || + setupProvider.mode == 'sweep'; + + // If in active setup flow, show the setup UI + if (isInSetup) { + return _buildSetupFlowCard(setupProvider); + } + + // If has config, show manual control + if (acConfig.hasConfig) { + return _buildManualControlCard(ac, acProvider, setupProvider); + } + + // No config and not in setup — show "start config" prompt + return _buildStartConfigCard(setupProvider); + } + + // ====================== START CONFIG CARD ====================== + + Widget _buildStartConfigCard(ACSetupProvider setupProvider) { + return Card( + color: AppColor.cardBg, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCardHeader( + icon: Icons.tune, + title: 'Konfigurasi AC', + subtitle: 'Pilih merk lalu uji model hingga cocok', + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: setupProvider.isSending + ? null + : () => setupProvider.startConfigMode(), + icon: setupProvider.isSending + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.settings), + label: Text( + setupProvider.isSending + ? 'Mengirim...' + : 'Mulai Konfigurasi AC', + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blueAccent, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ), + const SizedBox(height: 12), + const Text( + 'Langkah singkat:', + style: TextStyle(color: Colors.grey), + ), + const SizedBox(height: 6), + const Text( + '1) Tekan "Mulai Konfigurasi".\n' + '2) Pilih merek AC dari daftar.\n' + '3) Untuk tiap model tekan "YA" bila AC merespon.', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ), + ), + ); + } + + // ====================== SETUP FLOW CARD ====================== + + Widget _buildSetupFlowCard(ACSetupProvider setupProvider) { + return Card( + color: AppColor.cardBg, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCardHeader( + icon: Icons.tune, + title: 'Konfigurasi AC', + subtitle: setupProvider.message, + trailing: TextButton.icon( + onPressed: setupProvider.isSending + ? null + : () => setupProvider.cancelConfig(), + icon: const Icon(Icons.close, size: 16, color: Colors.redAccent), + label: const Text( + 'Batal', + style: TextStyle(color: Colors.redAccent, fontSize: 12), + ), + ), + ), + const SizedBox(height: 16), + + // Step: select_brand — show brand list + if (setupProvider.step == 'select_brand') + _buildBrandGrid(setupProvider), + + // Step: confirm — show sweep confirmation + if (setupProvider.step == 'confirm') + _buildSweepConfirm(setupProvider), + ], + ), + ), + ); + } + + Widget _buildBrandGrid(ACSetupProvider setupProvider) { + if (setupProvider.brands.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(20), + child: Column( + children: [ + CircularProgressIndicator(color: Colors.blueAccent), + SizedBox(height: 12), + Text( + 'Memuat daftar merek...', + style: TextStyle(color: Colors.grey), + ), + ], + ), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Pilih Merek AC:', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + const SizedBox(height: 12), + ...setupProvider.brands.map((brand) { + final int index = brand['index'] ?? 0; + final String desc = brand['description'] ?? brand['name'] ?? ''; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: setupProvider.isSending + ? null + : () => setupProvider.selectBrand(index), + icon: const Icon(Icons.air, size: 18), + label: Text(desc), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.blueAccent, + side: BorderSide( + color: Colors.blueAccent.withValues(alpha: 0.4), + ), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ); + }), + ], + ); + } + + Widget _buildSweepConfirm(ACSetupProvider setupProvider) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Show current brand/model being tested + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blueAccent.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.blueAccent.withValues(alpha: 0.3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Menguji: ${setupProvider.brandDesc}', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + if (setupProvider.modelName.isNotEmpty) + Text( + 'Model: ${setupProvider.modelName}', + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ), + ), + const SizedBox(height: 16), + const Text( + 'Apakah AC merespon sinyal ini?', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: setupProvider.isSending + ? null + : () => setupProvider.confirm('y'), + icon: const Icon(Icons.check, size: 18), + label: const Text('YA'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + onPressed: setupProvider.isSending + ? null + : () => setupProvider.confirm('n'), + icon: const Icon(Icons.close, size: 18), + label: const Text('TIDAK'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton( + onPressed: setupProvider.isSending + ? null + : () => setupProvider.confirm('skip'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white70, + side: const BorderSide(color: Colors.white30), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text('SKIP'), + ), + ), + ], + ), + ], + ); + } + + // ====================== MANUAL CONTROL CARD ====================== + + Widget _buildManualControlCard( + ACModel ac, + ACProvider acProvider, + ACSetupProvider setupProvider, + ) { + return Card( + color: AppColor.cardBg, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Section header with reset button + _buildCardHeader( + icon: Icons.tune, + title: 'Kontrol Manual', + subtitle: 'Kirim sinyal IR ke AC via ESP32', + trailing: IconButton( + icon: const Icon(Icons.settings, color: Colors.blueAccent, size: 20), + tooltip: 'Konfigurasi Ulang', + onPressed: () => _showResetConfirmation(setupProvider), + ), + ), + const SizedBox(height: 20), + + // Power, Swing, & Control Mode (Switch) Button Row + Row( + children: [ + Expanded( + flex: 5, + child: OutlinedButton.icon( + onPressed: () => acProvider.setPower(!ac.power), + icon: Icon( + ac.power ? Icons.power_off : Icons.power_settings_new, + color: ac.power ? Colors.red : Colors.green, + size: 18, + ), + label: Text( + ac.power ? 'Matikan' : 'Nyalakan', + style: TextStyle( + color: ac.power ? Colors.red : Colors.green, + fontSize: 11, + ), + ), + style: OutlinedButton.styleFrom( + side: BorderSide( + color: ac.power + ? Colors.red.withValues(alpha: 0.5) + : Colors.green.withValues(alpha: 0.5), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 10), + ), + ), + ), + const SizedBox(width: 6), + Expanded( + flex: 5, + child: OutlinedButton.icon( + onPressed: () => acProvider.toggleSwing(), + icon: Icon( + Icons.swap_vert, + color: ac.swing ? Colors.blueAccent : Colors.white60, + size: 18, + ), + label: Text( + ac.swing ? 'Swing ON' : 'Swing OFF', + style: TextStyle( + color: ac.swing ? Colors.blueAccent : Colors.white60, + fontSize: 11, + ), + ), + style: OutlinedButton.styleFrom( + side: BorderSide( + color: ac.swing + ? Colors.blueAccent.withValues(alpha: 0.5) + : Colors.white30, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 10), + ), + ), + ), + const SizedBox(width: 6), + Expanded( + flex: 4, + child: Container( + height: 44, // Match the height of OutlinedButton with vertical padding 10 + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + ac.controlMode == 'auto' ? 'Auto' : 'Manual', + style: TextStyle( + color: ac.controlMode == 'auto' ? Colors.blueAccent : Colors.orangeAccent, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 1), + SizedBox( + height: 20, + child: Transform.scale( + scale: 0.65, + child: Switch.adaptive( + value: ac.controlMode == 'auto', + activeThumbColor: Colors.blueAccent, + activeTrackColor: Colors.blueAccent.withValues(alpha: 0.3), + inactiveThumbColor: Colors.orangeAccent, + inactiveTrackColor: Colors.orangeAccent.withValues(alpha: 0.3), + onChanged: (bool value) { + acProvider.setControlMode(value ? 'auto' : 'manual'); + }, + ), + ), + ), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 16), + + // Mode & Fan cyclic buttons + Temp in a row + Row( + children: [ + // Mode cyclic button + Expanded( + child: _buildCyclicButton( + label: 'Mode', + value: ac.mode, + icon: _modeIcon(ac.mode), + color: Colors.blueAccent, + onTap: () => acProvider.cycleMode(), + ), + ), + const SizedBox(width: 8), + // Fan cyclic button + Expanded( + child: _buildCyclicButton( + label: 'Kipas', + value: ac.fan, + icon: Icons.air, + color: Colors.tealAccent, + onTap: () => acProvider.cycleFan(), + ), + ), + const SizedBox(width: 8), + // Temperature control + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4), + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Suhu', + style: TextStyle( + color: Colors.grey, + fontSize: 9, + ), + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: + MainAxisAlignment.spaceEvenly, + children: [ + GestureDetector( + onTap: () => + acProvider.setTemp(ac.temp - 1), + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + border: Border.all( + color: Colors.white30, + ), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.remove, + size: 12, + color: Colors.white, + ), + ), + ), + Text( + '${ac.temp}°', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + GestureDetector( + onTap: () => + acProvider.setTemp(ac.temp + 1), + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + border: Border.all( + color: Colors.white30, + ), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.add, + size: 12, + color: Colors.white, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + // ====================== RESET CONFIRMATION ====================== + + void _showResetConfirmation(ACSetupProvider setupProvider) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColor.cardBg, + title: const Text( + 'Konfigurasi Ulang AC', + style: TextStyle(color: Colors.white), + ), + content: const Text( + 'Konfigurasi saat ini akan dihapus dan Anda harus memilih merek AC lagi. Lanjutkan?', + style: TextStyle(color: Colors.white70), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Batal', style: TextStyle(color: Colors.grey)), + ), + TextButton( + onPressed: () async { + Navigator.of(ctx).pop(); + await setupProvider.clearConfig(); + await setupProvider.startConfigMode(); + }, + child: const Text('Ya, Reset', style: TextStyle(color: Colors.redAccent)), + ), + ], + ), + ); + } + + // ====================== CARD HEADER ====================== + + Widget _buildCardHeader({ + required IconData icon, + required String title, + required String subtitle, + Widget? trailing, + }) { + return Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, color: Colors.blue, size: 20), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + Text( + subtitle, + style: const TextStyle(fontSize: 11, color: Colors.grey), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ?trailing, + ], + ); + } + + // ====================== CYCLIC BUTTON ====================== + + Widget _buildCyclicButton({ + required String label, + required String value, + required IconData icon, + required Color color, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Column( + children: [ + Icon(icon, color: color, size: 16), + const SizedBox(height: 2), + Text( + label, + style: const TextStyle(color: Colors.grey, fontSize: 9), + ), + const SizedBox(height: 2), + Text( + value, + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Icon(Icons.refresh, color: color.withValues(alpha: 0.5), size: 10), + ], + ), + ), + ); + } + + IconData _modeIcon(String mode) { + switch (mode.toUpperCase()) { + case 'COOL': + return Icons.ac_unit; + case 'DRY': + return Icons.water_drop_outlined; + case 'FAN': + return Icons.air; + case 'HEAT': + return Icons.local_fire_department; + case 'AUTO': + return Icons.auto_mode; + default: + return Icons.ac_unit; + } + } + + // ====================== SENSOR CARD ====================== + + Widget _buildSensorCard({ + required String title, + required String value, + required String status, + required IconData icon, + required Color iconColor, + }) { + return Card( + color: AppColor.cardBg, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, color: iconColor, size: 20), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + color: Colors.grey, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + value, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 10), + _buildStatusChip(status), + ], + ), + ), + ); + } + + Widget _buildStatusChip(String status) { + Color color; + if (status == 'Sangat Baik' || status == 'Baik') { + color = AppColor.success; + } else if (status == 'Buruk') { + color = AppColor.danger; + } else { + color = AppColor.warning; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withValues(alpha: 0.3), width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Text( + status.isEmpty ? 'Normal' : status, + style: TextStyle( + color: color, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } +} diff --git a/Source Code Mobile/login_page.dart b/Source Code Mobile/login_page.dart new file mode 100644 index 0000000..2f260b4 --- /dev/null +++ b/Source Code Mobile/login_page.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../providers/auth_provider.dart'; +import 'dashboard_page.dart'; +import 'register_page.dart'; + +class LoginPage extends StatefulWidget { + const LoginPage({super.key}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final _emailController = TextEditingController(); + + final _passwordController = TextEditingController(); + + final _formKey = GlobalKey(); + + Future login() async { + if (!_formKey.currentState!.validate()) { + return; + } + + final auth = context.read(); + + final result = await auth.login( + email: _emailController.text.trim(), + password: _passwordController.text, + ); + + if (!mounted) return; + + if (result == null) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const DashboardPage()), + ); + } else { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(result))); + } + } + + @override + Widget build(BuildContext context) { + final auth = context.watch(); + + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + children: [ + const Icon(Icons.air, size: 100, color: Colors.green), + + const SizedBox(height: 20), + + const Text( + 'Smart Air Quality', + style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold), + ), + + const SizedBox(height: 50), + + TextFormField( + controller: _emailController, + decoration: const InputDecoration( + labelText: 'Email', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Email wajib diisi'; + } + return null; + }, + ), + + const SizedBox(height: 20), + + TextFormField( + controller: _passwordController, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Password', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Password wajib diisi'; + } + return null; + }, + ), + + const SizedBox(height: 30), + + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: auth.isLoading ? null : login, + child: auth.isLoading + ? const CircularProgressIndicator() + : const Text('Login'), + ), + ), + + const SizedBox(height: 20), + + TextButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const RegisterPage()), + ); + }, + child: const Text('Belum punya akun? Register'), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/Source Code Mobile/monitoring_history_page.dart b/Source Code Mobile/monitoring_history_page.dart new file mode 100644 index 0000000..2905b0a --- /dev/null +++ b/Source Code Mobile/monitoring_history_page.dart @@ -0,0 +1,257 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../providers/history_provider.dart'; +import '../core/app_color.dart'; +import 'control_history_page.dart'; + +class MonitoringHistoryPage extends StatefulWidget { + const MonitoringHistoryPage({super.key}); + + @override + State createState() => _MonitoringHistoryPageState(); +} + +class _MonitoringHistoryPageState extends State { + @override + void initState() { + super.initState(); + context.read().startMonitoringListener(); + } + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final history = provider.monitoringHistory; + + return Scaffold( + backgroundColor: AppColor.background, + appBar: AppBar( + title: const Text('Histori Monitoring'), + actions: [ + IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.redAccent), + tooltip: 'Hapus Histori', + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Hapus Histori'), + content: const Text('Apakah Anda yakin ingin menghapus semua data histori monitoring? Tindakan ini tidak dapat dibatalkan.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + TextButton( + onPressed: () async { + Navigator.pop(context); + try { + await context.read().clearMonitoringHistory(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Histori monitoring berhasil dihapus')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal menghapus histori: $e')), + ); + } + } + }, + child: const Text('Hapus', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + }, + ), + ], + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Table Header Title + Button to Control History + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Riwayat (setiap 5 menit)', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Colors.white70, + ), + ), + TextButton.icon( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ControlHistoryPage()), + ); + }, + icon: const Icon(Icons.history, size: 16, color: Colors.blueAccent), + label: const Text( + 'Histori Kontrol', + style: TextStyle(color: Colors.blueAccent, fontSize: 13, fontWeight: FontWeight.bold), + ), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Main Table Card + Expanded( + child: Card( + color: AppColor.cardBg, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + children: [ + // Column Headers + Row( + children: const [ + Expanded( + flex: 4, + child: Text( + 'Waktu', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey, + fontSize: 12, + ), + ), + ), + Expanded( + flex: 2, + child: Text( + 'PM2.5', + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey, + fontSize: 12, + ), + ), + ), + Expanded( + flex: 2, + child: Text( + 'CO₂', + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey, + fontSize: 12, + ), + ), + ), + Expanded( + flex: 3, + child: Text( + 'Suhu / Lembap', + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey, + fontSize: 12, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + const Divider(color: Colors.white24, height: 1), + const SizedBox(height: 10), + + // Rows List + Expanded( + child: history.isEmpty + ? const Center( + child: Text( + 'Belum ada data histori.', + style: TextStyle(color: Colors.grey), + ), + ) + : ListView.separated( + itemCount: history.length, + separatorBuilder: (context, i) => const Divider( + color: Colors.white12, + height: 16, + ), + itemBuilder: (context, index) { + final item = history[index]; + // Format timestamp for display (shorten year/seconds if needed) + String displayTime = item.timestamp; + if (displayTime.length > 16) { + displayTime = displayTime.substring(0, 16); + } + + return Row( + children: [ + Expanded( + flex: 4, + child: Text( + displayTime, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + ), + ), + ), + Expanded( + flex: 2, + child: Text( + '${item.pm25}', + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + ), + ), + ), + Expanded( + flex: 2, + child: Text( + '${item.co2.round()}', + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + ), + ), + ), + Expanded( + flex: 3, + child: Text( + '${item.temperature.round()}°C / ${item.humidity.round()}%', + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + ), + ), + ), + ], + ); + }, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/Source Code Mobile/profile_page.dart b/Source Code Mobile/profile_page.dart new file mode 100644 index 0000000..a545bfc --- /dev/null +++ b/Source Code Mobile/profile_page.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../providers/auth_provider.dart'; +import 'login_page.dart'; + +class ProfilePage extends StatelessWidget { + const ProfilePage({super.key}); + + @override + Widget build(BuildContext context) { + final auth = context.watch(); + + return Scaffold( + appBar: AppBar(title: const Text('Profil')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Nama : ${auth.userData?['name'] ?? '-'}'), + const SizedBox(height: 10), + Text('Email : ${auth.userData?['email'] ?? '-'}'), + const SizedBox(height: 30), + ElevatedButton( + onPressed: () async { + await auth.logout(); + + if (!context.mounted) { + return; + } + + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const LoginPage()), + (route) => false, + ); + }, + child: const Text('Logout'), + ), + ], + ), + ), + ); + } +}