diff --git a/app.drawio b/app.drawio index 1c2bffb..7b814fe 100644 --- a/app.drawio +++ b/app.drawio @@ -1,185 +1,180 @@ - + - - + + - - - - - - + - + - - + + - + - - + + - + - - + + - + - - + + - + - - + + - - + + - + - - + + - + - + - - + + - + - - + + - - + + - + - - + + - + - + - - + + - - + + - + - - + + - + - - + + - + - - + + - + - - + + - + - + - - + + - + - + - - - + + + - + - - + + - + - - + + - - + + - - + + - + - - + + - + - - + + - - + + - + - - + + - + - - + + - + @@ -188,72 +183,126 @@ - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + - - + + - + - + + + + + + + + + + + + + - - - - + - + + + + + + + - - + + - - - - - - - + - - - + - - + + - + - - + + - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/newata2/.gitignore b/newata2/.gitignore index 29a3a50..e03339c 100644 --- a/newata2/.gitignore +++ b/newata2/.gitignore @@ -41,3 +41,6 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +main_copy.dart +.env \ No newline at end of file diff --git a/newata2/lib/device_management_page.dart b/newata2/lib/device_management_page.dart index 7a444e7..bc59712 100644 --- a/newata2/lib/device_management_page.dart +++ b/newata2/lib/device_management_page.dart @@ -84,13 +84,26 @@ class _DeviceManagementPageState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Daftar Perangkat Tertaut'), + leading: IconButton( + icon: const Icon(Icons.add_box_outlined), + color: Theme.of(context).primaryColor, + tooltip: 'Tambah Perangkat', + onPressed: _showAddDeviceDialog, + ), + title: const Text('Daftar Perangkat'), + centerTitle: true, backgroundColor: Theme.of(context).scaffoldBackgroundColor, elevation: 0, actions: [ - IconButton( - icon: const Icon(Icons.logout), - onPressed: () async => await context.read().signOut()) + TextButton.icon( + icon: const Icon(Icons.logout), + label: const Text('Logout'), + onPressed: () async => await context.read().signOut(), + style: TextButton.styleFrom( + foregroundColor: Colors.red, + padding: const EdgeInsets.symmetric(horizontal: 16), + ), + ) ], ), body: Consumer( @@ -108,14 +121,15 @@ class _DeviceManagementPageState extends State { Icon(CupertinoIcons.camera_viewfinder, size: 60, color: Colors.white38), SizedBox(height: 16), - Text('Perangkat tidak ditermukan', + Text('Perangkat tidak ditemukan', style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white)), SizedBox(height: 8), + // PERUBAHAN 3: Memperbarui teks bantuan Text( - 'Klik [+] dipojok kanan bawah untuk menambahkan perangkat.', + 'Klik ikon [+] di pojok kiri atas untuk menambahkan perangkat baru.', textAlign: TextAlign.center, style: TextStyle(color: Colors.white70)), ], @@ -150,7 +164,7 @@ class _DeviceManagementPageState extends State { final confirm = await showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Confirm Deletion'), + title: const Text('Konfirmasi Hapus'), content: Text( 'Apakah anda yakin untuk menghapus perangkat ${device['device_name']}?'), actions: [ @@ -175,7 +189,7 @@ class _DeviceManagementPageState extends State { await appState.selectDevice(device); if (mounted) { Navigator.of(context).push(MaterialPageRoute( - builder: (_) => const HomePage())); + builder: (_) => const ControlPage())); } }, ), @@ -185,10 +199,197 @@ class _DeviceManagementPageState extends State { ); }, ), - floatingActionButton: FloatingActionButton( - onPressed: _showAddDeviceDialog, - backgroundColor: Theme.of(context).primaryColor, - child: const Icon(Icons.add, color: Colors.black)), + // PERUBAHAN 4: Menghapus FloatingActionButton + // floatingActionButton: FloatingActionButton( + // onPressed: _showAddDeviceDialog, + // backgroundColor: Theme.of(context).primaryColor, + // child: const Icon(Icons.add, color: Colors.black)), ); } } + + +// class DeviceManagementPage extends StatefulWidget { +// const DeviceManagementPage({super.key}); +// @override +// State createState() => _DeviceManagementPageState(); +// } + +// class _DeviceManagementPageState extends State { +// @override +// void initState() { +// super.initState(); +// Future.microtask(() => context.read().fetchUserDevices()); +// } + +// void _showAddDeviceDialog() { +// final formKey = GlobalKey(); +// final deviceIdController = TextEditingController(); +// final deviceNameController = TextEditingController(); +// showDialog( +// context: context, +// builder: (context) { +// return AlertDialog( +// backgroundColor: Theme.of(context).cardColor, +// title: const Text('Tambah perangkat baru'), +// content: Form( +// key: formKey, +// child: Column( +// mainAxisSize: MainAxisSize.min, +// children: [ +// TextFormField( +// controller: deviceIdController, +// decoration: const InputDecoration(labelText: 'Device ID'), +// validator: (value) => +// value!.isEmpty ? 'Device ID wajib diisi' : null), +// const SizedBox(height: 16), +// TextFormField( +// controller: deviceNameController, +// decoration: const InputDecoration( +// labelText: 'Nama perangkat (e.g., Teras Rumah)'), +// validator: (value) => +// value!.isEmpty ? 'Perangkat harus diberi nama' : null), +// ], +// ), +// ), +// actions: [ +// TextButton( +// onPressed: () => Navigator.of(context).pop(), +// child: const Text('Batal')), +// ElevatedButton( +// onPressed: () async { +// if (formKey.currentState!.validate()) { +// final appState = context.read(); +// final result = await appState.addUserDevice( +// deviceIdController.text.trim(), +// deviceNameController.text.trim()); +// if (mounted) { +// Navigator.of(context).pop(); +// ScaffoldMessenger.of(context).showSnackBar(SnackBar( +// content: +// Text(result ?? 'Perangkat berhasil ditambahkan!'), +// backgroundColor: result == null +// ? Colors.green +// : Theme.of(context).colorScheme.error)); +// } +// } +// }, +// child: const Text('Tambah')), +// ], +// ); +// }, +// ); +// } + +// @override +// Widget build(BuildContext context) { +// return Scaffold( +// appBar: AppBar( +// title: const Text('Daftar Perangkat Tertaut'), +// backgroundColor: Theme.of(context).scaffoldBackgroundColor, +// elevation: 0, +// actions: [ +// IconButton( +// icon: const Icon(Icons.logout), +// onPressed: () async => await context.read().signOut()) +// ], +// ), +// body: Consumer( +// builder: (context, appState, child) { +// if (appState.isLoading && appState.userDevices.isEmpty) { +// return const Center(child: CircularProgressIndicator()); +// } +// if (appState.userDevices.isEmpty) { +// return const Center( +// child: Padding( +// padding: EdgeInsets.all(24.0), +// child: Column( +// mainAxisAlignment: MainAxisAlignment.center, +// children: [ +// Icon(CupertinoIcons.camera_viewfinder, +// size: 60, color: Colors.white38), +// SizedBox(height: 16), +// Text('Perangkat tidak ditermukan', +// style: TextStyle( +// fontSize: 20, +// fontWeight: FontWeight.bold, +// color: Colors.white)), +// SizedBox(height: 8), +// Text( +// 'Klik [+] dipojok kanan bawah untuk menambahkan perangkat.', +// textAlign: TextAlign.center, +// style: TextStyle(color: Colors.white70)), +// ], +// ), +// ), +// ); +// } +// return RefreshIndicator( +// onRefresh: () => appState.fetchUserDevices(), +// child: ListView.builder( +// padding: const EdgeInsets.all(8.0), +// itemCount: appState.userDevices.length, +// itemBuilder: (context, index) { +// final device = appState.userDevices[index]; +// return Card( +// margin: +// const EdgeInsets.symmetric(vertical: 6, horizontal: 8), +// child: ListTile( +// contentPadding: const EdgeInsets.symmetric( +// vertical: 10, horizontal: 20), +// leading: const Icon(CupertinoIcons.camera_fill, +// color: Colors.tealAccent), +// title: Text(device['device_name'], +// style: const TextStyle( +// fontWeight: FontWeight.bold, color: Colors.white)), +// subtitle: Text(device['device_id'], +// style: const TextStyle(color: Colors.white70)), +// trailing: IconButton( +// icon: const Icon(Icons.delete_outline, +// color: Colors.redAccent), +// onPressed: () async { +// final confirm = await showDialog( +// context: context, +// builder: (context) => AlertDialog( +// title: const Text('Confirm Deletion'), +// content: Text( +// 'Apakah anda yakin untuk menghapus perangkat ${device['device_name']}?'), +// actions: [ +// TextButton( +// onPressed: () => +// Navigator.of(context).pop(false), +// child: const Text('Batal')), +// TextButton( +// onPressed: () => +// Navigator.of(context).pop(true), +// child: const Text('Hapus', +// style: TextStyle( +// color: Colors.redAccent))), +// ], +// )); +// if (confirm == true) { +// await appState.removeUserDevice(device['id']); +// } +// }, +// ), +// onTap: () async { +// await appState.selectDevice(device); +// if (mounted) { +// Navigator.of(context).push(MaterialPageRoute( +// builder: (_) => const HomePage())); +// } +// }, +// ), +// ); +// }, +// ), +// ); +// }, +// ), +// floatingActionButton: FloatingActionButton( +// onPressed: _showAddDeviceDialog, +// backgroundColor: Theme.of(context).primaryColor, +// child: const Icon(Icons.add, color: Colors.black)), +// ); +// } +// } diff --git a/newata2/lib/home_page.dart b/newata2/lib/home_page.dart index 65ac7cd..ce8117d 100644 --- a/newata2/lib/home_page.dart +++ b/newata2/lib/home_page.dart @@ -8,8 +8,8 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; -class HomePage extends StatelessWidget { - const HomePage({super.key}); +class ControlPage extends StatelessWidget { + const ControlPage({super.key}); @override Widget build(BuildContext context) { @@ -18,7 +18,7 @@ class HomePage extends StatelessWidget { return Scaffold( appBar: AppBar( - title: Text('Sedang memantau perangkat: $deviceName'), + title: Text('Memantau perangkat: $deviceName'), backgroundColor: Theme.of(context).scaffoldBackgroundColor, elevation: 0, leading: IconButton( diff --git a/newata2/lib/main copy 3.dart b/newata2/lib/main copy 3.dart deleted file mode 100644 index 6455474..0000000 --- a/newata2/lib/main copy 3.dart +++ /dev/null @@ -1,1056 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:intl/date_symbol_data_local.dart'; -import 'package:intl/intl.dart'; -import 'package:provider/provider.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; - -// ================================================================= -// MAIN.DART & SETUP -// ================================================================= - -Future main() async { - WidgetsFlutterBinding.ensureInitialized(); - await dotenv.load(fileName: ".env"); - - await Supabase.initialize( - url: dotenv.env['SUPABASE_URL']!, - anonKey: dotenv.env['SUPABASE_ANON_KEY']!, - ); - - await initializeDateFormatting('id_ID', null); - - runApp( - ChangeNotifierProvider( - create: (context) => AppState(), - child: const MyApp(), - ), - ); -} - -final supabase = Supabase.instance.client; - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Security Monitor', - theme: ThemeData( - brightness: Brightness.dark, - primaryColor: Colors.tealAccent, - scaffoldBackgroundColor: const Color(0xFF1a1a1a), - cardColor: const Color(0xFF2c2c2c), - colorScheme: const ColorScheme.dark( - primary: Colors.tealAccent, - secondary: Colors.teal, - surface: Color(0xFF2c2c2c), - onSurface: Colors.white, - error: Colors.redAccent, - ), - textTheme: const TextTheme( - bodyLarge: TextStyle(color: Colors.white70), - bodyMedium: TextStyle(color: Colors.white70), - titleLarge: - TextStyle(color: Colors.white, fontWeight: FontWeight.bold), - titleMedium: TextStyle(color: Colors.white), - ), - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: Colors.black.withOpacity(0.3), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none), - labelStyle: const TextStyle(color: Colors.white54)), - useMaterial3: true, - ), - home: const AuthWrapper(), - debugShowCheckedModeBanner: false, - ); - } -} - -// ================================================================= -// STATE MANAGEMENT (PROVIDER) -// ================================================================= - -class AppState extends ChangeNotifier { - bool _isLoading = false; - User? _currentUser; - - List> _userDevices = []; - Map? _selectedDevice; - - List> _events = []; - Map? _deviceStatus; - - RealtimeChannel? _eventsChannel; - RealtimeChannel? _deviceStatusChannel; - - bool get isLoading => _isLoading; - User? get currentUser => _currentUser; - List> get userDevices => _userDevices; - Map? get selectedDevice => _selectedDevice; - List> get events => _events; - Map? get deviceStatus => _deviceStatus; - - String? get selectedDeviceId => _selectedDevice?['device_id']; - - AppState() { - _currentUser = supabase.auth.currentUser; - if (_currentUser != null) { - fetchUserDevices(); - } - - supabase.auth.onAuthStateChange.listen((data) { - final session = data.session; - _currentUser = session?.user; - if (_currentUser != null) { - fetchUserDevices(); - } else { - clearState(); - } - notifyListeners(); - }); - } - - void _setLoading(bool value) { - if (_isLoading != value) { - _isLoading = value; - notifyListeners(); - } - } - - void _unsubscribeFromChannels() { - _eventsChannel?.unsubscribe(); - _deviceStatusChannel?.unsubscribe(); - _eventsChannel = null; - _deviceStatusChannel = null; - } - - void clearState() { - _userDevices.clear(); - _selectedDevice = null; - _events.clear(); - _deviceStatus = null; - _unsubscribeFromChannels(); - notifyListeners(); - } - - Future fetchUserDevices() async { - if (_currentUser == null) return; - _setLoading(true); - try { - final response = await supabase - .from('user_devices') - .select() - .eq('user_id', _currentUser!.id) - .order('created_at', ascending: true); - _userDevices = List>.from(response); - } catch (e) { - print('Error fetching user devices: $e'); - _userDevices = []; - } - _setLoading(false); - } - - Future addUserDevice(String deviceId, String deviceName) async { - if (_currentUser == null) return "User not logged in."; - _setLoading(true); - try { - final deviceExists = await supabase - .from('device_status') - .select('device_id') - .eq('device_id', deviceId) - .maybeSingle(); - - if (deviceExists == null) { - _setLoading(false); - return "Device ID tidak ditemukan. Pastikan perangkat Anda sudah online setidaknya sekali."; - } - - await supabase.from('user_devices').insert({ - 'user_id': _currentUser!.id, - 'device_id': deviceId, - 'device_name': deviceName, - }); - - await fetchUserDevices(); - return null; - } on PostgrestException catch (e) { - if (e.code == '23505') { - return "Perangkat ini sudah ada di dalam daftar Anda."; - } - return e.message; - } catch (e) { - return 'Terjadi kesalahan tidak terduga.'; - } finally { - _setLoading(false); - } - } - - Future removeUserDevice(int id) async { - if (_currentUser == null) return "User not logged in."; - _setLoading(true); - try { - await supabase.from('user_devices').delete().eq('id', id); - await fetchUserDevices(); - return null; - } catch (e) { - return "Gagal menghapus perangkat."; - } finally { - _setLoading(false); - } - } - - Future selectDevice(Map device) async { - _setLoading(true); - _selectedDevice = device; - await fetchInitialDataForSelectedDevice(); - _listenToRealtimeChanges(); - _setLoading(false); - } - - void deselectDevice() { - _selectedDevice = null; - _events.clear(); - _deviceStatus = null; - _unsubscribeFromChannels(); - notifyListeners(); - } - - Future fetchInitialDataForSelectedDevice() async { - if (selectedDeviceId == null) return; - _setLoading(true); - await Future.wait([ - fetchEvents(), - fetchDeviceStatus(), - ]); - _setLoading(false); - } - - Future fetchEvents() async { - if (selectedDeviceId == null) return; - try { - final response = await supabase - .from('events') - .select() - .eq('device_id', selectedDeviceId!) - .order('timestamp', ascending: false) - .limit(50); // Batasi jumlah event awal - _events = List>.from(response); - } catch (e) { - print('Error fetching events: $e'); - _events = []; - } - notifyListeners(); - } - - Future fetchDeviceStatus() async { - if (selectedDeviceId == null) return; - try { - final response = await supabase - .from('device_status') - .select() - .eq('device_id', selectedDeviceId!) - .single(); - _deviceStatus = response; - } catch (e) { - print('Error fetching device status: $e'); - _deviceStatus = null; - } - notifyListeners(); - } - - void _listenToRealtimeChanges() { - if (selectedDeviceId == null) return; - _unsubscribeFromChannels(); - - _eventsChannel = - supabase.channel('public:events:device_id=$selectedDeviceId'); - _eventsChannel! - .onPostgresChanges( - event: PostgresChangeEvent.insert, - schema: 'public', - table: 'events', - filter: PostgresChangeFilter( - type: PostgresChangeFilterType.eq, - column: 'device_id', - value: selectedDeviceId!, - ), - callback: (payload) { - final newEvent = payload.newRecord; - if (!_events.any((e) => e['id'] == newEvent['id'])) { - _events.insert(0, newEvent); - notifyListeners(); - } - }, - ) - .subscribe(); - - _deviceStatusChannel = - supabase.channel('public:device_status:device_id=$selectedDeviceId'); - _deviceStatusChannel! - .onPostgresChanges( - event: PostgresChangeEvent - .all, // Dengarkan semua perubahan (insert/update) - schema: 'public', - table: 'device_status', - filter: PostgresChangeFilter( - type: PostgresChangeFilterType.eq, - column: 'device_id', - value: selectedDeviceId!, - ), - callback: (payload) { - _deviceStatus = payload.newRecord; - notifyListeners(); - }, - ) - .subscribe(); - } - - Future signOut() async { - _setLoading(true); - await supabase.auth.signOut(); - _setLoading(false); - } - - Future setSleepSchedule(int durationMicroseconds) async { - if (selectedDeviceId == null) return "Device not identified."; - _setLoading(true); - try { - await supabase.from('device_status').update({ - 'schedule_duration': durationMicroseconds, - 'last_update': DateTime.now().toIso8601String(), - }).eq('device_id', selectedDeviceId!); - await fetchDeviceStatus(); - return null; - } on PostgrestException catch (e) { - return e.message; - } catch (e) { - return 'An unexpected error occurred.'; - } finally { - _setLoading(false); - } - } -} - -// ================================================================= -// AUTH & ROUTING -// ================================================================= - -class AuthWrapper extends StatelessWidget { - const AuthWrapper({super.key}); - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, appState, child) { - if (appState.isLoading && appState.currentUser == null) { - return const Scaffold( - body: Center(child: CircularProgressIndicator())); - } - - if (appState.currentUser == null) { - return const AuthPage(); - } else { - return const DeviceManagementPage(); - } - }, - ); - } -} - -class AuthPage extends StatefulWidget { - const AuthPage({super.key}); - @override - State createState() => _AuthPageState(); -} - -class _AuthPageState extends State { - final _formKey = GlobalKey(); - final _emailController = TextEditingController(); - final _passwordController = TextEditingController(); - final _fullNameController = TextEditingController(); - bool _isLogin = true; - bool _isLoading = false; - - Future _submit() async { - if (!_formKey.currentState!.validate()) return; - setState(() => _isLoading = true); - try { - if (_isLogin) { - await supabase.auth.signInWithPassword( - email: _emailController.text.trim(), - password: _passwordController.text.trim(), - ); - } else { - await supabase.auth.signUp( - email: _emailController.text.trim(), - password: _passwordController.text.trim(), - data: {'full_name': _fullNameController.text.trim()}); - } - } on AuthException catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(e.message), - backgroundColor: Theme.of(context).colorScheme.error)); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('An unexpected error occurred.'), - backgroundColor: Theme.of(context).colorScheme.error)); - } - } - if (mounted) setState(() => _isLoading = false); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: Form( - key: _formKey, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Icon(CupertinoIcons.shield_lefthalf_fill, - size: 80, color: Theme.of(context).primaryColor), - const SizedBox(height: 24), - Text(_isLogin ? 'Welcome Back' : 'Create Account', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - color: Colors.white, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - Text(_isLogin ? 'Sign in to continue' : 'Join us today!', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyLarge), - const SizedBox(height: 32), - if (!_isLogin) - TextFormField( - controller: _fullNameController, - decoration: const InputDecoration(labelText: 'Full Name'), - validator: (value) => - value!.isEmpty ? 'Please enter your full name' : null, - ), - if (!_isLogin) const SizedBox(height: 16), - TextFormField( - controller: _emailController, - decoration: const InputDecoration(labelText: 'Email'), - keyboardType: TextInputType.emailAddress, - validator: (value) => - value!.isEmpty ? 'Please enter an email' : null, - ), - const SizedBox(height: 16), - TextFormField( - controller: _passwordController, - decoration: const InputDecoration(labelText: 'Password'), - obscureText: true, - validator: (value) => value!.length < 6 - ? 'Password must be at least 6 characters' - : null, - ), - const SizedBox(height: 24), - _isLoading - ? const Center(child: CircularProgressIndicator()) - : ElevatedButton( - onPressed: _submit, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - backgroundColor: Theme.of(context).primaryColor, - foregroundColor: Colors.black), - child: Text(_isLogin ? 'LOGIN' : 'SIGN UP', - style: - const TextStyle(fontWeight: FontWeight.bold)), - ), - TextButton( - onPressed: () => setState(() => _isLogin = !_isLogin), - child: Text( - _isLogin - ? 'Don\'t have an account? Sign Up' - : 'Already have an account? Login', - style: const TextStyle(color: Colors.white70)), - ) - ], - ), - ), - ), - ), - ); - } -} - -// ================================================================= -// HALAMAN DEVICE MANAGEMENT -// ================================================================= - -class DeviceManagementPage extends StatefulWidget { - const DeviceManagementPage({super.key}); - @override - State createState() => _DeviceManagementPageState(); -} - -class _DeviceManagementPageState extends State { - @override - void initState() { - super.initState(); - Future.microtask(() => context.read().fetchUserDevices()); - } - - void _showAddDeviceDialog() { - final formKey = GlobalKey(); - final deviceIdController = TextEditingController(); - final deviceNameController = TextEditingController(); - showDialog( - context: context, - builder: (context) { - return AlertDialog( - backgroundColor: Theme.of(context).cardColor, - title: const Text('Add New Device'), - content: Form( - key: formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextFormField( - controller: deviceIdController, - decoration: const InputDecoration(labelText: 'Device ID'), - validator: (value) => - value!.isEmpty ? 'Device ID cannot be empty' : null), - const SizedBox(height: 16), - TextFormField( - controller: deviceNameController, - decoration: const InputDecoration( - labelText: 'Device Name (e.g., Teras Rumah)'), - validator: (value) => - value!.isEmpty ? 'Device Name cannot be empty' : null), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel')), - ElevatedButton( - onPressed: () async { - if (formKey.currentState!.validate()) { - final appState = context.read(); - final result = await appState.addUserDevice( - deviceIdController.text.trim(), - deviceNameController.text.trim()); - if (mounted) { - Navigator.of(context).pop(); - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(result ?? 'Device added successfully!'), - backgroundColor: result == null - ? Colors.green - : Theme.of(context).colorScheme.error)); - } - } - }, - child: const Text('Add')), - ], - ); - }, - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('My Devices'), - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - elevation: 0, - actions: [ - IconButton( - icon: const Icon(Icons.logout), - onPressed: () async => await context.read().signOut()) - ], - ), - body: Consumer( - builder: (context, appState, child) { - if (appState.isLoading && appState.userDevices.isEmpty) { - return const Center(child: CircularProgressIndicator()); - } - if (appState.userDevices.isEmpty) { - return const Center( - child: Padding( - padding: EdgeInsets.all(24.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(CupertinoIcons.camera_viewfinder, - size: 60, color: Colors.white38), - SizedBox(height: 16), - Text('No Devices Found', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.white)), - SizedBox(height: 8), - Text( - 'Click the + button to add your first ESP32-CAM device.', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.white70)), - ], - ), - ), - ); - } - return RefreshIndicator( - onRefresh: () => appState.fetchUserDevices(), - child: ListView.builder( - padding: const EdgeInsets.all(8.0), - itemCount: appState.userDevices.length, - itemBuilder: (context, index) { - final device = appState.userDevices[index]; - return Card( - margin: - const EdgeInsets.symmetric(vertical: 6, horizontal: 8), - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - vertical: 10, horizontal: 20), - leading: const Icon(CupertinoIcons.camera_fill, - color: Colors.tealAccent), - title: Text(device['device_name'], - style: const TextStyle( - fontWeight: FontWeight.bold, color: Colors.white)), - subtitle: Text(device['device_id'], - style: const TextStyle(color: Colors.white70)), - trailing: IconButton( - icon: const Icon(Icons.delete_outline, - color: Colors.redAccent), - onPressed: () async { - final confirm = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Confirm Deletion'), - content: Text( - 'Are you sure you want to remove ${device['device_name']}?'), - actions: [ - TextButton( - onPressed: () => - Navigator.of(context).pop(false), - child: const Text('Cancel')), - TextButton( - onPressed: () => - Navigator.of(context).pop(true), - child: const Text('Delete', - style: TextStyle( - color: Colors.redAccent))), - ], - )); - if (confirm == true) { - await appState.removeUserDevice(device['id']); - } - }, - ), - onTap: () async { - await appState.selectDevice(device); - if (mounted) { - Navigator.of(context).push(MaterialPageRoute( - builder: (_) => const HomePage())); - } - }, - ), - ); - }, - ), - ); - }, - ), - floatingActionButton: FloatingActionButton( - onPressed: _showAddDeviceDialog, - backgroundColor: Theme.of(context).primaryColor, - child: const Icon(Icons.add, color: Colors.black)), - ); - } -} - -// ================================================================= -// HALAMAN UTAMA (MONITORING) -// ================================================================= - -class HomePage extends StatelessWidget { - const HomePage({super.key}); - - @override - Widget build(BuildContext context) { - final appState = Provider.of(context); - final deviceName = appState.selectedDevice?['device_name'] ?? 'Device'; - - return Scaffold( - appBar: AppBar( - title: Text('Monitor: $deviceName'), - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - elevation: 0, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () { - context.read().deselectDevice(); - Navigator.of(context).pop(); - }, - ), - ), - // === PERUBAHAN: MENGGUNAKAN RefreshIndicator === - body: RefreshIndicator( - onRefresh: () => appState.fetchInitialDataForSelectedDevice(), - child: appState.isLoading && appState.events.isEmpty - ? const Center(child: CircularProgressIndicator()) - : ListView( - // Menggunakan ListView agar bisa di-scroll - children: [ - const DeviceStatusCard(), - if (appState.events.isEmpty) - const Padding( - padding: EdgeInsets.only(top: 50.0), - child: Center( - child: Text('No events recorded for this device yet.', - style: TextStyle(color: Colors.white70))), - ), - ...appState.events.map((event) => EventCard(event: event)), - ], - ), - ), - ); - } -} - -// === PERUBAHAN: DESAIN ULANG DeviceStatusCard === -class DeviceStatusCard extends StatelessWidget { - const DeviceStatusCard({super.key}); - - void _showSleepScheduleDialog(BuildContext context) { - TimeOfDay? selectedTime; - String? durationText; - - showDialog( - context: context, - builder: (context) { - return StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return AlertDialog( - backgroundColor: Theme.of(context).cardColor, - title: const Text('Set Deep Sleep Timer'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text("Device will sleep until the selected time."), - const SizedBox(height: 20), - ListTile( - onTap: () async { - final time = await showTimePicker( - context: context, initialTime: TimeOfDay.now()); - if (time != null) { - setState(() { - selectedTime = time; - final now = DateTime.now(); - var scheduledTime = DateTime(now.year, now.month, - now.day, time.hour, time.minute); - if (scheduledTime.isBefore(now) || - scheduledTime.isAtSameMomentAs(now)) { - scheduledTime = - scheduledTime.add(const Duration(days: 1)); - } - final duration = scheduledTime.difference(now); - durationText = _formatDuration(duration); - }); - } - }, - leading: const Icon(CupertinoIcons.clock_fill), - title: Text( - selectedTime?.format(context) ?? 'Select End Time', - style: const TextStyle( - color: Colors.tealAccent, - fontWeight: FontWeight.bold)), - subtitle: Text(durationText ?? "Tap to choose time"), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel')), - ElevatedButton( - onPressed: selectedTime == null - ? null - : () async { - final now = DateTime.now(); - var scheduledTime = DateTime( - now.year, - now.month, - now.day, - selectedTime!.hour, - selectedTime!.minute); - if (scheduledTime.isBefore(now) || - scheduledTime.isAtSameMomentAs(now)) { - scheduledTime = - scheduledTime.add(const Duration(days: 1)); - } - final durationInMicroseconds = - scheduledTime.difference(now).inMicroseconds; - final appState = context.read(); - final result = await appState - .setSleepSchedule(durationInMicroseconds); - if (context.mounted) { - Navigator.of(context).pop(); - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: - Text(result ?? 'Sleep schedule updated!'), - backgroundColor: result == null - ? Colors.green - : Theme.of(context).colorScheme.error, - )); - } - }, - child: const Text('Set'), - ), - ], - ); - }, - ); - }, - ); - } - - String _formatDuration(Duration duration) { - String twoDigits(int n) => n.toString().padLeft(2, "0"); - final hours = twoDigits(duration.inHours); - final minutes = twoDigits(duration.inMinutes.remainder(60)); - return "Duration: $hours hours, $minutes minutes"; - } - - String _formatSleepDuration(int microseconds) { - if (microseconds <= 0) return 'Timer is off'; - final duration = Duration(microseconds: microseconds); - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - return '${hours}h ${minutes}m remaining'; - } - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, appState, child) { - final status = appState.deviceStatus?['status'] as String? ?? 'unknown'; - final lastUpdateStr = appState.deviceStatus?['last_update'] as String?; - final lastUpdate = lastUpdateStr != null - ? DateTime.parse(lastUpdateStr).toLocal() - : null; - final formattedTime = lastUpdate != null - ? DateFormat('d MMM, HH:mm:ss', 'id_ID').format(lastUpdate) - : 'N/A'; - final scheduleMicroseconds = - appState.deviceStatus?['schedule_duration'] as int? ?? 0; - - final isSleeping = status == 'sleeping'; - final sleepDurationText = _formatSleepDuration(scheduleMicroseconds); - final hasActiveTimer = scheduleMicroseconds > 0; - - Color statusColor; - IconData statusIcon; - String statusText; - - if (isSleeping) { - statusColor = Colors.lightBlueAccent; - statusIcon = CupertinoIcons.zzz; - statusText = 'SLEEPING'; - } else if (status == 'active') { - statusColor = Colors.greenAccent; - statusIcon = CupertinoIcons.checkmark_shield_fill; - statusText = 'ACTIVE'; - } else { - statusColor = Colors.redAccent; - statusIcon = CupertinoIcons.xmark_shield_fill; - statusText = 'OFFLINE'; - } - - return Card( - margin: const EdgeInsets.all(16), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment.center, // Menggeser status ke tengah - children: [ - Icon(statusIcon, color: statusColor, size: 28), - const SizedBox(width: 12), - Text(statusText, - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: statusColor)), - ], - ), - const SizedBox(height: 12), - const Divider(color: Colors.white24), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Last Update: $formattedTime', - style: const TextStyle(color: Colors.white70)), - const SizedBox(height: 4), - Text( - hasActiveTimer - ? sleepDurationText - : 'Sleep timer is off', - style: TextStyle( - color: hasActiveTimer - ? Colors.lightBlueAccent - : Colors.white70)), - ], - ), - // === PERUBAHAN: Tombol Set Timer dengan UI Lock === - ElevatedButton.icon( - icon: const Icon(CupertinoIcons.timer, size: 16), - label: const Text('Set Timer'), - style: ElevatedButton.styleFrom( - backgroundColor: isSleeping - ? Colors.grey.shade800 - : Theme.of(context).primaryColor, - foregroundColor: - isSleeping ? Colors.grey.shade400 : Colors.black, - ), - onPressed: isSleeping - ? null - : () => _showSleepScheduleDialog(context), - ), - ], - ), - ], - ), - ), - ); - }, - ); - } -} - -// === PERUBAHAN: Perbaikan pada EventCard === -class EventCard extends StatelessWidget { - final Map event; - const EventCard({super.key, required this.event}); - - String _getImageUrl(String imageRef) { - return supabase.storage.from('captured').getPublicUrl(imageRef); - } - - @override - Widget build(BuildContext context) { - // === PERBAIKAN: Menggunakan type casting untuk keamanan === - final eventType = event['event_type'] as String?; - final location = event['location'] as String? ?? 'Unknown Location'; - final timestampStr = event['timestamp'] as String?; - final timestamp = timestampStr != null - ? DateTime.parse(timestampStr).toLocal() - : DateTime.now(); - final formattedTime = - DateFormat('EEEE, d MMMM yyyy, HH:mm:ss', 'id_ID').format(timestamp); - final imageRef = event['image_ref'] as String?; - final imageUrl = - imageRef != null && imageRef.isNotEmpty ? _getImageUrl(imageRef) : null; - - final String displayEventType; - final Color eventColor; - if (eventType == 'motion_detected') { - displayEventType = 'Motion Detected'; - eventColor = Colors.orange; - } else if (eventType == 'vibration_detected') { - displayEventType = 'Vibration Detected'; - eventColor = Colors.purpleAccent; - } else { - displayEventType = 'Unknown Event'; - eventColor = Colors.grey; - } - - return Card( - margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - clipBehavior: Clip.antiAlias, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (imageUrl != null) - InkWell( - onTap: () => showDialog( - context: context, - builder: (_) => Dialog( - backgroundColor: Colors.transparent, - child: InteractiveViewer( - child: Image.network(imageUrl, - loadingBuilder: (context, child, progress) => - progress == null - ? child - : const Center( - child: CircularProgressIndicator()), - errorBuilder: (context, error, stackTrace) => - const Icon(Icons.error, - color: Colors.red, size: 50))))), - child: Image.network( - imageUrl, - height: 200, - width: double.infinity, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => Container( - height: 200, - color: Colors.black26, - child: const Center( - child: Icon(Icons.broken_image, - color: Colors.white30, size: 40)), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: eventColor, - borderRadius: BorderRadius.circular(8)), - child: Text(displayEventType, - style: const TextStyle( - color: Colors.white, fontWeight: FontWeight.bold)), - ), - const SizedBox(height: 12), - Row(children: [ - const Icon(CupertinoIcons.location_solid, - size: 16, color: Colors.white70), - const SizedBox(width: 8), - Text(location, - style: - const TextStyle(fontSize: 16, color: Colors.white)), - ]), - const SizedBox(height: 8), - Row(children: [ - const Icon(CupertinoIcons.time_solid, - size: 16, color: Colors.white70), - const SizedBox(width: 8), - Text(formattedTime, - style: - const TextStyle(fontSize: 14, color: Colors.white70)), - ]), - ], - ), - ), - ], - ), - ); - } -} diff --git a/newata2/lib/main copy 4.dart b/newata2/lib/main copy 4.dart deleted file mode 100644 index d695420..0000000 --- a/newata2/lib/main copy 4.dart +++ /dev/null @@ -1,1063 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:intl/date_symbol_data_local.dart'; -import 'package:intl/intl.dart'; -import 'package:provider/provider.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; - -// ================================================================= -// MAIN.DART & SETUP -// ================================================================= - -Future main() async { - WidgetsFlutterBinding.ensureInitialized(); - await dotenv.load(fileName: ".env"); - - await Supabase.initialize( - url: dotenv.env['SUPABASE_URL']!, - anonKey: dotenv.env['SUPABASE_ANON_KEY']!, - ); - - await initializeDateFormatting('id_ID', null); - - runApp( - ChangeNotifierProvider( - create: (context) => AppState(), - child: const MyApp(), - ), - ); -} - -final supabase = Supabase.instance.client; - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Security Monitor', - theme: ThemeData( - brightness: Brightness.dark, - primaryColor: Colors.tealAccent, - scaffoldBackgroundColor: const Color(0xFF1a1a1a), - cardColor: const Color(0xFF2c2c2c), - colorScheme: const ColorScheme.dark( - primary: Colors.tealAccent, - secondary: Colors.teal, - surface: Color(0xFF2c2c2c), - onSurface: Colors.white, - error: Colors.redAccent, - ), - textTheme: const TextTheme( - bodyLarge: TextStyle(color: Colors.white70), - bodyMedium: TextStyle(color: Colors.white70), - titleLarge: - TextStyle(color: Colors.white, fontWeight: FontWeight.bold), - titleMedium: TextStyle(color: Colors.white), - ), - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: Colors.black.withOpacity(0.3), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none), - labelStyle: const TextStyle(color: Colors.white54)), - useMaterial3: true, - ), - home: const AuthWrapper(), - debugShowCheckedModeBanner: false, - ); - } -} - -// ================================================================= -// STATE MANAGEMENT (PROVIDER) -// ================================================================= - -class AppState extends ChangeNotifier { - bool _isLoading = false; - User? _currentUser; - - List> _userDevices = []; - Map? _selectedDevice; - - List> _events = []; - Map? _deviceStatus; - - RealtimeChannel? _eventsChannel; - RealtimeChannel? _deviceStatusChannel; - - bool get isLoading => _isLoading; - User? get currentUser => _currentUser; - List> get userDevices => _userDevices; - Map? get selectedDevice => _selectedDevice; - List> get events => _events; - Map? get deviceStatus => _deviceStatus; - - String? get selectedDeviceId => _selectedDevice?['device_id']; - - AppState() { - _currentUser = supabase.auth.currentUser; - if (_currentUser != null) { - fetchUserDevices(); - } - - supabase.auth.onAuthStateChange.listen((data) { - final session = data.session; - _currentUser = session?.user; - if (_currentUser != null) { - fetchUserDevices(); - } else { - clearState(); - } - notifyListeners(); - }); - } - - void _setLoading(bool value) { - if (_isLoading != value) { - _isLoading = value; - notifyListeners(); - } - } - - void _unsubscribeFromChannels() { - _eventsChannel?.unsubscribe(); - _deviceStatusChannel?.unsubscribe(); - _eventsChannel = null; - _deviceStatusChannel = null; - } - - void clearState() { - _userDevices.clear(); - _selectedDevice = null; - _events.clear(); - _deviceStatus = null; - _unsubscribeFromChannels(); - notifyListeners(); - } - - Future fetchUserDevices() async { - if (_currentUser == null) return; - _setLoading(true); - try { - final response = await supabase - .from('user_devices') - .select() - .eq('user_id', _currentUser!.id) - .order('created_at', ascending: true); - _userDevices = List>.from(response); - } catch (e) { - print('Error fetching user devices: $e'); - _userDevices = []; - } - _setLoading(false); - } - - Future addUserDevice(String deviceId, String deviceName) async { - if (_currentUser == null) return "User not logged in."; - _setLoading(true); - try { - final deviceExists = await supabase - .from('device_status') - .select('device_id') - .eq('device_id', deviceId) - .maybeSingle(); - - if (deviceExists == null) { - _setLoading(false); - return "Device ID tidak ditemukan. Pastikan perangkat Anda sudah online setidaknya sekali."; - } - - await supabase.from('user_devices').insert({ - 'user_id': _currentUser!.id, - 'device_id': deviceId, - 'device_name': deviceName, - }); - - await fetchUserDevices(); - return null; - } on PostgrestException catch (e) { - if (e.code == '23505') { - return "Perangkat ini sudah ada di dalam daftar Anda."; - } - return e.message; - } catch (e) { - return 'Terjadi kesalahan tidak terduga.'; - } finally { - _setLoading(false); - } - } - - Future removeUserDevice(int id) async { - if (_currentUser == null) return "User not logged in."; - _setLoading(true); - try { - await supabase.from('user_devices').delete().eq('id', id); - await fetchUserDevices(); - return null; - } catch (e) { - return "Gagal menghapus perangkat."; - } finally { - _setLoading(false); - } - } - - Future selectDevice(Map device) async { - _setLoading(true); - _selectedDevice = device; - await fetchInitialDataForSelectedDevice(); - _listenToRealtimeChanges(); - _setLoading(false); - } - - void deselectDevice() { - _selectedDevice = null; - _events.clear(); - _deviceStatus = null; - _unsubscribeFromChannels(); - notifyListeners(); - } - - Future fetchInitialDataForSelectedDevice() async { - if (selectedDeviceId == null) return; - _setLoading(true); - await Future.wait([ - fetchEvents(), - fetchDeviceStatus(), - ]); - _setLoading(false); - } - - Future fetchEvents() async { - if (selectedDeviceId == null) return; - try { - final response = await supabase - .from('events') - .select() - .eq('device_id', selectedDeviceId!) - .order('timestamp', ascending: false) - .limit(50); // Batasi jumlah event awal - _events = List>.from(response); - } catch (e) { - print('Error fetching events: $e'); - _events = []; - } - notifyListeners(); - } - - Future fetchDeviceStatus() async { - if (selectedDeviceId == null) return; - try { - final response = await supabase - .from('device_status') - .select() - .eq('device_id', selectedDeviceId!) - .single(); - _deviceStatus = response; - } catch (e) { - print('Error fetching device status: $e'); - _deviceStatus = null; - } - notifyListeners(); - } - - void _listenToRealtimeChanges() { - if (selectedDeviceId == null) return; - _unsubscribeFromChannels(); - - _eventsChannel = - supabase.channel('public:events:device_id=$selectedDeviceId'); - _eventsChannel! - .onPostgresChanges( - event: PostgresChangeEvent.insert, - schema: 'public', - table: 'events', - filter: PostgresChangeFilter( - type: PostgresChangeFilterType.eq, - column: 'device_id', - value: selectedDeviceId!, - ), - callback: (payload) { - final newEvent = payload.newRecord; - if (!_events.any((e) => e['id'] == newEvent['id'])) { - _events.insert(0, newEvent); - notifyListeners(); - } - }, - ) - .subscribe(); - - _deviceStatusChannel = - supabase.channel('public:device_status:device_id=$selectedDeviceId'); - _deviceStatusChannel! - .onPostgresChanges( - event: PostgresChangeEvent - .all, // Dengarkan semua perubahan (insert/update) - schema: 'public', - table: 'device_status', - filter: PostgresChangeFilter( - type: PostgresChangeFilterType.eq, - column: 'device_id', - value: selectedDeviceId!, - ), - callback: (payload) { - _deviceStatus = payload.newRecord; - notifyListeners(); - }, - ) - .subscribe(); - } - - Future signOut() async { - _setLoading(true); - await supabase.auth.signOut(); - _setLoading(false); - } - - Future setSleepSchedule(int durationMicroseconds) async { - if (selectedDeviceId == null) return "Device not identified."; - _setLoading(true); - try { - await supabase.from('device_status').update({ - 'schedule_duration': durationMicroseconds, - 'last_update': DateTime.now().toIso8601String(), - }).eq('device_id', selectedDeviceId!); - await fetchDeviceStatus(); - return null; - } on PostgrestException catch (e) { - return e.message; - } catch (e) { - return 'An unexpected error occurred.'; - } finally { - _setLoading(false); - } - } -} - -// ================================================================= -// AUTH & ROUTING -// ================================================================= - -class AuthWrapper extends StatelessWidget { - const AuthWrapper({super.key}); - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, appState, child) { - if (appState.isLoading && appState.currentUser == null) { - return const Scaffold( - body: Center(child: CircularProgressIndicator())); - } - - if (appState.currentUser == null) { - return const AuthPage(); - } else { - return const DeviceManagementPage(); - } - }, - ); - } -} - -class AuthPage extends StatefulWidget { - const AuthPage({super.key}); - @override - State createState() => _AuthPageState(); -} - -class _AuthPageState extends State { - final _formKey = GlobalKey(); - final _emailController = TextEditingController(); - final _passwordController = TextEditingController(); - final _fullNameController = TextEditingController(); - bool _isLogin = true; - bool _isLoading = false; - - Future _submit() async { - if (!_formKey.currentState!.validate()) return; - setState(() => _isLoading = true); - try { - if (_isLogin) { - await supabase.auth.signInWithPassword( - email: _emailController.text.trim(), - password: _passwordController.text.trim(), - ); - } else { - await supabase.auth.signUp( - email: _emailController.text.trim(), - password: _passwordController.text.trim(), - data: {'full_name': _fullNameController.text.trim()}); - } - } on AuthException catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(e.message), - backgroundColor: Theme.of(context).colorScheme.error)); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('An unexpected error occurred.'), - backgroundColor: Theme.of(context).colorScheme.error)); - } - } - if (mounted) setState(() => _isLoading = false); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: Form( - key: _formKey, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Icon(CupertinoIcons.shield_lefthalf_fill, - size: 80, color: Theme.of(context).primaryColor), - const SizedBox(height: 24), - Text(_isLogin ? 'Welcome Back' : 'Create Account', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - color: Colors.white, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - Text(_isLogin ? 'Sign in to continue' : 'Join us today!', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyLarge), - const SizedBox(height: 32), - if (!_isLogin) - TextFormField( - controller: _fullNameController, - decoration: const InputDecoration(labelText: 'Full Name'), - validator: (value) => - value!.isEmpty ? 'Please enter your full name' : null, - ), - if (!_isLogin) const SizedBox(height: 16), - TextFormField( - controller: _emailController, - decoration: const InputDecoration(labelText: 'Email'), - keyboardType: TextInputType.emailAddress, - validator: (value) => - value!.isEmpty ? 'Please enter an email' : null, - ), - const SizedBox(height: 16), - TextFormField( - controller: _passwordController, - decoration: const InputDecoration(labelText: 'Password'), - obscureText: true, - validator: (value) => value!.length < 6 - ? 'Password must be at least 6 characters' - : null, - ), - const SizedBox(height: 24), - _isLoading - ? const Center(child: CircularProgressIndicator()) - : ElevatedButton( - onPressed: _submit, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - backgroundColor: Theme.of(context).primaryColor, - foregroundColor: Colors.black), - child: Text(_isLogin ? 'LOGIN' : 'SIGN UP', - style: - const TextStyle(fontWeight: FontWeight.bold)), - ), - TextButton( - onPressed: () => setState(() => _isLogin = !_isLogin), - child: Text( - _isLogin - ? 'Don\'t have an account? Sign Up' - : 'Already have an account? Login', - style: const TextStyle(color: Colors.white70)), - ) - ], - ), - ), - ), - ), - ); - } -} - -// ================================================================= -// HALAMAN DEVICE MANAGEMENT -// ================================================================= - -class DeviceManagementPage extends StatefulWidget { - const DeviceManagementPage({super.key}); - @override - State createState() => _DeviceManagementPageState(); -} - -class _DeviceManagementPageState extends State { - @override - void initState() { - super.initState(); - Future.microtask(() => context.read().fetchUserDevices()); - } - - void _showAddDeviceDialog() { - final formKey = GlobalKey(); - final deviceIdController = TextEditingController(); - final deviceNameController = TextEditingController(); - showDialog( - context: context, - builder: (context) { - return AlertDialog( - backgroundColor: Theme.of(context).cardColor, - title: const Text('Add New Device'), - content: Form( - key: formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextFormField( - controller: deviceIdController, - decoration: const InputDecoration(labelText: 'Device ID'), - validator: (value) => - value!.isEmpty ? 'Device ID cannot be empty' : null), - const SizedBox(height: 16), - TextFormField( - controller: deviceNameController, - decoration: const InputDecoration( - labelText: 'Device Name (e.g., Teras Rumah)'), - validator: (value) => - value!.isEmpty ? 'Device Name cannot be empty' : null), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel')), - ElevatedButton( - onPressed: () async { - if (formKey.currentState!.validate()) { - final appState = context.read(); - final result = await appState.addUserDevice( - deviceIdController.text.trim(), - deviceNameController.text.trim()); - if (mounted) { - Navigator.of(context).pop(); - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(result ?? 'Device added successfully!'), - backgroundColor: result == null - ? Colors.green - : Theme.of(context).colorScheme.error)); - } - } - }, - child: const Text('Add')), - ], - ); - }, - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('My Devices'), - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - elevation: 0, - actions: [ - IconButton( - icon: const Icon(Icons.logout), - onPressed: () async => await context.read().signOut()) - ], - ), - body: Consumer( - builder: (context, appState, child) { - if (appState.isLoading && appState.userDevices.isEmpty) { - return const Center(child: CircularProgressIndicator()); - } - if (appState.userDevices.isEmpty) { - return const Center( - child: Padding( - padding: EdgeInsets.all(24.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(CupertinoIcons.camera_viewfinder, - size: 60, color: Colors.white38), - SizedBox(height: 16), - Text('No Devices Found', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.white)), - SizedBox(height: 8), - Text( - 'Click the + button to add your first ESP32-CAM device.', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.white70)), - ], - ), - ), - ); - } - return RefreshIndicator( - onRefresh: () => appState.fetchUserDevices(), - child: ListView.builder( - padding: const EdgeInsets.all(8.0), - itemCount: appState.userDevices.length, - itemBuilder: (context, index) { - final device = appState.userDevices[index]; - return Card( - margin: - const EdgeInsets.symmetric(vertical: 6, horizontal: 8), - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - vertical: 10, horizontal: 20), - leading: const Icon(CupertinoIcons.camera_fill, - color: Colors.tealAccent), - title: Text(device['device_name'], - style: const TextStyle( - fontWeight: FontWeight.bold, color: Colors.white)), - subtitle: Text(device['device_id'], - style: const TextStyle(color: Colors.white70)), - trailing: IconButton( - icon: const Icon(Icons.delete_outline, - color: Colors.redAccent), - onPressed: () async { - final confirm = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Confirm Deletion'), - content: Text( - 'Are you sure you want to remove ${device['device_name']}?'), - actions: [ - TextButton( - onPressed: () => - Navigator.of(context).pop(false), - child: const Text('Cancel')), - TextButton( - onPressed: () => - Navigator.of(context).pop(true), - child: const Text('Delete', - style: TextStyle( - color: Colors.redAccent))), - ], - )); - if (confirm == true) { - await appState.removeUserDevice(device['id']); - } - }, - ), - onTap: () async { - await appState.selectDevice(device); - if (mounted) { - Navigator.of(context).push(MaterialPageRoute( - builder: (_) => const HomePage())); - } - }, - ), - ); - }, - ), - ); - }, - ), - floatingActionButton: FloatingActionButton( - onPressed: _showAddDeviceDialog, - backgroundColor: Theme.of(context).primaryColor, - child: const Icon(Icons.add, color: Colors.black)), - ); - } -} - -// ================================================================= -// HALAMAN UTAMA (MONITORING) -// ================================================================= - -class HomePage extends StatelessWidget { - const HomePage({super.key}); - - @override - Widget build(BuildContext context) { - final appState = Provider.of(context); - final deviceName = appState.selectedDevice?['device_name'] ?? 'Device'; - - return Scaffold( - appBar: AppBar( - title: Text('Monitor: $deviceName'), - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - elevation: 0, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () { - context.read().deselectDevice(); - Navigator.of(context).pop(); - }, - ), - ), - body: RefreshIndicator( - onRefresh: () => appState.fetchInitialDataForSelectedDevice(), - child: appState.isLoading && appState.events.isEmpty - ? const Center(child: CircularProgressIndicator()) - : ListView( - children: [ - const DeviceStatusCard(), - if (appState.events.isEmpty) - const Padding( - padding: EdgeInsets.only(top: 50.0), - child: Center( - child: Text('No events recorded for this device yet.', - style: TextStyle(color: Colors.white70))), - ), - ...appState.events.map((event) => EventCard(event: event)), - ], - ), - ), - ); - } -} - -class DeviceStatusCard extends StatelessWidget { - const DeviceStatusCard({super.key}); - - void _showSleepScheduleDialog(BuildContext context) { - TimeOfDay? selectedTime; - String? durationText; - - showDialog( - context: context, - builder: (context) { - return StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return AlertDialog( - backgroundColor: Theme.of(context).cardColor, - title: const Text('Set Deep Sleep Timer'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text("Device will sleep until the selected time."), - const SizedBox(height: 20), - ListTile( - onTap: () async { - final time = await showTimePicker( - context: context, initialTime: TimeOfDay.now()); - if (time != null) { - setState(() { - selectedTime = time; - final now = DateTime.now(); - var scheduledTime = DateTime(now.year, now.month, - now.day, time.hour, time.minute); - if (scheduledTime.isBefore(now) || - scheduledTime.isAtSameMomentAs(now)) { - scheduledTime = - scheduledTime.add(const Duration(days: 1)); - } - final duration = scheduledTime.difference(now); - durationText = _formatDuration(duration); - }); - } - }, - leading: const Icon(CupertinoIcons.clock_fill), - title: Text( - selectedTime?.format(context) ?? 'Select End Time', - style: const TextStyle( - color: Colors.tealAccent, - fontWeight: FontWeight.bold)), - subtitle: Text(durationText ?? "Tap to choose time"), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel')), - ElevatedButton( - onPressed: selectedTime == null - ? null - : () async { - final now = DateTime.now(); - var scheduledTime = DateTime( - now.year, - now.month, - now.day, - selectedTime!.hour, - selectedTime!.minute); - if (scheduledTime.isBefore(now) || - scheduledTime.isAtSameMomentAs(now)) { - scheduledTime = - scheduledTime.add(const Duration(days: 1)); - } - final durationInMicroseconds = - scheduledTime.difference(now).inMicroseconds; - final appState = context.read(); - final result = await appState - .setSleepSchedule(durationInMicroseconds); - if (context.mounted) { - Navigator.of(context).pop(); - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: - Text(result ?? 'Sleep schedule updated!'), - backgroundColor: result == null - ? Colors.green - : Theme.of(context).colorScheme.error, - )); - } - }, - child: const Text('Set'), - ), - ], - ); - }, - ); - }, - ); - } - - String _formatDuration(Duration duration) { - String twoDigits(int n) => n.toString().padLeft(2, "0"); - final hours = twoDigits(duration.inHours); - final minutes = twoDigits(duration.inMinutes.remainder(60)); - return "Duration: $hours hours, $minutes minutes"; - } - - String _formatSleepDuration(int microseconds) { - if (microseconds <= 0) return 'Timer is off'; - final duration = Duration(microseconds: microseconds); - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - return '${hours}h ${minutes}m remaining'; - } - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, appState, child) { - final status = appState.deviceStatus?['status'] as String? ?? 'unknown'; - final lastUpdateStr = appState.deviceStatus?['last_update'] as String?; - final lastUpdate = lastUpdateStr != null - ? DateTime.parse(lastUpdateStr).toLocal() - : null; - final formattedTime = lastUpdate != null - ? DateFormat('d MMM, HH:mm:ss', 'id_ID').format(lastUpdate) - : 'N/A'; - final scheduleMicroseconds = - appState.deviceStatus?['schedule_duration'] as int? ?? 0; - - final isSleeping = status == 'sleeping'; - final sleepDurationText = _formatSleepDuration(scheduleMicroseconds); - final hasActiveTimer = scheduleMicroseconds > 0; - - Color statusColor; - IconData statusIcon; - String statusText; - - if (isSleeping) { - statusColor = Colors.lightBlueAccent; - statusIcon = CupertinoIcons.zzz; - statusText = 'SLEEPING'; - } else if (status == 'active') { - statusColor = Colors.greenAccent; - statusIcon = CupertinoIcons.checkmark_shield_fill; - statusText = 'ACTIVE'; - } else { - statusColor = Colors.redAccent; - statusIcon = CupertinoIcons.xmark_shield_fill; - statusText = 'OFFLINE'; - } - - return Card( - margin: const EdgeInsets.all(16), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Icon(statusIcon, color: statusColor, size: 28), - const SizedBox(width: 12), - Text(statusText, - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: statusColor)), - ], - ), - const SizedBox(height: 12), - const Divider(color: Colors.white24), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Last Update: $formattedTime', - style: const TextStyle(color: Colors.white70)), - const SizedBox(height: 4), - Text( - hasActiveTimer - ? sleepDurationText - : 'Sleep timer is off', - style: TextStyle( - color: hasActiveTimer - ? Colors.lightBlueAccent - : Colors.white70)), - ], - ), - ElevatedButton.icon( - icon: const Icon(CupertinoIcons.timer, size: 16), - label: const Text('Set Timer'), - style: ElevatedButton.styleFrom( - backgroundColor: isSleeping - ? Colors.grey.shade800 - : Theme.of(context).primaryColor, - foregroundColor: - isSleeping ? Colors.grey.shade400 : Colors.black, - ), - onPressed: isSleeping - ? null - : () => _showSleepScheduleDialog(context), - ), - ], - ), - ], - ), - ), - ); - }, - ); - } -} - -// === WIDGET DENGAN PERBAIKAN UTAMA === -class EventCard extends StatelessWidget { - final Map event; - const EventCard({super.key, required this.event}); - - // PERBAIKAN #1: Fungsi _getImageUrl() telah dihapus. - // Alasan: Kolom 'image_ref' di database Anda sudah berisi URL publik yang lengkap. - // Memanggil supabase.storage.from(...).getPublicUrl() akan membuat URL menjadi ganda dan tidak valid. - // Contoh URL tidak valid: https://<...>.co/storage/v1/object/public/captured/https://<...>.co/storage/v1/object/public/captured/image.jpg - // Kita hanya perlu menggunakan nilai dari 'image_ref' secara langsung. - - @override - Widget build(BuildContext context) { - final eventType = event['event_type'] as String?; - final location = event['location'] as String? ?? 'Unknown Location'; - final timestampStr = event['timestamp'] as String?; - final timestamp = timestampStr != null - ? DateTime.parse(timestampStr).toLocal() - : DateTime.now(); - final formattedTime = - DateFormat('EEEE, d MMMM yyyy, HH:mm:ss', 'id_ID').format(timestamp); - - // PERBAIKAN #1 (Lanjutan): Langsung gunakan 'image_ref' sebagai imageUrl. - final imageRef = event['image_ref'] as String?; - final imageUrl = imageRef; // Tidak perlu pemrosesan lagi - - final String displayEventType; - final Color eventColor; - - // PERBAIKAN #2: Mencocokkan nilai 'event_type' dengan yang dikirim oleh ESP32. - // ESP32 mengirim "motion" dan "vibration". Kode sebelumnya memeriksa "motion_detected" - // dan "vibration_detected", yang menyebabkan selalu jatuh ke 'Unknown Event'. - if (eventType == 'motion') { - displayEventType = 'Motion Detected'; - eventColor = Colors.orange; - } else if (eventType == 'vibration') { - displayEventType = 'Vibration Detected'; - eventColor = Colors.purpleAccent; - } else { - displayEventType = 'Unknown Event'; - eventColor = Colors.grey; - } - - return Card( - margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - clipBehavior: Clip.antiAlias, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (imageUrl != null && imageUrl.isNotEmpty) - InkWell( - onTap: () => showDialog( - context: context, - builder: (_) => Dialog( - backgroundColor: Colors.transparent, - child: InteractiveViewer( - child: Image.network(imageUrl, - loadingBuilder: (context, child, progress) => - progress == null - ? child - : const Center( - child: CircularProgressIndicator()), - errorBuilder: (context, error, stackTrace) => - const Icon(Icons.error, - color: Colors.red, size: 50))))), - child: Image.network( - imageUrl, - height: 200, - width: double.infinity, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - // Tambahkan print untuk debugging jika gambar masih gagal dimuat - print("Error loading image: $error"); - print("Image URL: $imageUrl"); - return Container( - height: 200, - color: Colors.black26, - child: const Center( - child: Icon(Icons.broken_image, - color: Colors.white30, size: 40)), - ); - }, - ), - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: eventColor, - borderRadius: BorderRadius.circular(8)), - child: Text(displayEventType, - style: const TextStyle( - color: Colors.white, fontWeight: FontWeight.bold)), - ), - const SizedBox(height: 12), - Row(children: [ - const Icon(CupertinoIcons.location_solid, - size: 16, color: Colors.white70), - const SizedBox(width: 8), - Text(location, - style: - const TextStyle(fontSize: 16, color: Colors.white)), - ]), - const SizedBox(height: 8), - Row(children: [ - const Icon(CupertinoIcons.time_solid, - size: 16, color: Colors.white70), - const SizedBox(width: 8), - Text(formattedTime, - style: - const TextStyle(fontSize: 14, color: Colors.white70)), - ]), - ], - ), - ), - ], - ), - ); - } -} diff --git a/newata2/lib/main copy.dart b/newata2/lib/main copy.dart deleted file mode 100644 index af34f80..0000000 --- a/newata2/lib/main copy.dart +++ /dev/null @@ -1,1051 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:intl/date_symbol_data_local.dart'; -import 'package:intl/intl.dart'; -import 'package:provider/provider.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; - -// ================================================================= -// MAIN.DART & SETUP -// ================================================================= - -Future main() async { - WidgetsFlutterBinding.ensureInitialized(); - await dotenv.load(fileName: ".env"); - - await Supabase.initialize( - url: dotenv.env['SUPABASE_URL']!, - anonKey: dotenv.env['SUPABASE_ANON_KEY']!, - ); - - await initializeDateFormatting('id_ID', null); - - runApp( - ChangeNotifierProvider( - create: (context) => AppState(), - child: const MyApp(), - ), - ); -} - -final supabase = Supabase.instance.client; - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Security Monitor', - theme: ThemeData( - brightness: Brightness.dark, - primaryColor: Colors.tealAccent, - scaffoldBackgroundColor: const Color(0xFF1a1a1a), - cardColor: const Color(0xFF2c2c2c), - colorScheme: const ColorScheme.dark( - primary: Colors.tealAccent, - secondary: Colors.teal, - surface: Color(0xFF2c2c2c), - onSurface: Colors.white, - ), - textTheme: const TextTheme( - bodyLarge: TextStyle(color: Colors.white70), - bodyMedium: TextStyle(color: Colors.white70), - titleLarge: - TextStyle(color: Colors.white, fontWeight: FontWeight.bold), - titleMedium: TextStyle(color: Colors.white), - ), - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: Colors.black.withOpacity(0.3), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none), - labelStyle: const TextStyle(color: Colors.white54)), - useMaterial3: true, - ), - home: const AuthWrapper(), // Menggunakan AuthWrapper untuk logika awal - debugShowCheckedModeBanner: false, - ); - } -} - -// ================================================================= -// STATE MANAGEMENT (PROVIDER) -// ================================================================= - -class AppState extends ChangeNotifier { - bool _isLoading = false; - String? _deviceId; - List> _events = []; - Map? _deviceStatus; - User? _currentUser; - - RealtimeChannel? _eventsChannel; - RealtimeChannel? _deviceStatusChannel; - - bool get isLoading => _isLoading; - String? get deviceId => _deviceId; - List> get events => _events; - Map? get deviceStatus => _deviceStatus; - User? get currentUser => _currentUser; - - AppState() { - _currentUser = supabase.auth.currentUser; - supabase.auth.onAuthStateChange.listen((data) { - final session = data.session; - _currentUser = session?.user; - if (_currentUser == null) { - clearState(); - } - notifyListeners(); - }); - } - - void _unsubscribeFromChannels() { - if (_eventsChannel != null) { - supabase.removeChannel(_eventsChannel!); - _eventsChannel = null; - } - if (_deviceStatusChannel != null) { - supabase.removeChannel(_deviceStatusChannel!); - _deviceStatusChannel = null; - } - } - - void clearState() { - _deviceId = null; - _events.clear(); - _deviceStatus = null; - _unsubscribeFromChannels(); - notifyListeners(); - } - - void _setLoading(bool value) { - _isLoading = value; - notifyListeners(); - } - - Future setDeviceIdAndFetchData(String deviceId) async { - _setLoading(true); - try { - final response = await supabase - .from('device_status') - .select('device_id') - .eq('device_id', deviceId) - .maybeSingle(); - - if (response == null) { - _setLoading(false); - return false; - } - - _deviceId = deviceId; - await fetchInitialData(); - _listenToRealtimeChanges(); - notifyListeners(); - _setLoading(false); - return true; - } catch (e) { - print('Error validating device ID: $e'); - _setLoading(false); - return false; - } - } - - Future fetchInitialData() async { - if (_deviceId == null) return; - _setLoading(true); - await Future.wait([ - fetchEvents(), - fetchDeviceStatus(), - ]); - _setLoading(false); - } - - Future fetchEvents() async { - if (_deviceId == null) return; - try { - final response = await supabase - .from('events') - .select() - .eq('device_id', _deviceId!) - .order('timestamp', ascending: false); - _events = List>.from(response); - } catch (e) { - print('Error fetching events: $e'); - _events = []; - } - notifyListeners(); - } - - Future fetchDeviceStatus() async { - if (_deviceId == null) return; - try { - final response = await supabase - .from('device_status') - .select() - .eq('device_id', _deviceId!) - .single(); - _deviceStatus = response; - } catch (e) { - print('Error fetching device status: $e'); - _deviceStatus = null; - } - notifyListeners(); - } - - void _listenToRealtimeChanges() { - if (_deviceId == null) return; - _unsubscribeFromChannels(); - - _eventsChannel = supabase.channel('public:events:device_id=$_deviceId'); - _eventsChannel! - .onPostgresChanges( - event: PostgresChangeEvent.insert, - schema: 'public', - table: 'events', - filter: PostgresChangeFilter( - type: PostgresChangeFilterType.eq, - column: 'device_id', - value: _deviceId!, - ), - callback: (payload) { - final newEvent = payload.newRecord; - if (!_events.any((e) => e['id'] == newEvent['id'])) { - _events.insert(0, newEvent); - notifyListeners(); - } - }, - ) - .subscribe(); - - _deviceStatusChannel = - supabase.channel('public:device_status:device_id=$_deviceId'); - _deviceStatusChannel! - .onPostgresChanges( - event: PostgresChangeEvent.update, - schema: 'public', - table: 'device_status', - filter: PostgresChangeFilter( - type: PostgresChangeFilterType.eq, - column: 'device_id', - value: _deviceId!, - ), - callback: (payload) { - _deviceStatus = payload.newRecord; - notifyListeners(); - }, - ) - .subscribe(); - } - - Future signOut() async { - _setLoading(true); - await supabase.auth.signOut(); - // clearState() akan dipanggil oleh listener onAuthStateChange - _setLoading(false); - } - - Future setSleepSchedule(int durationMicroseconds) async { - if (_deviceId == null) { - return "Device not identified."; - } - _setLoading(true); - - try { - await supabase.from('device_status').update({ - 'schedule_duration': durationMicroseconds, - 'last_update': DateTime.now().toIso8601String(), - }).eq('device_id', _deviceId!); - - _setLoading(false); - return null; - } on PostgrestException catch (e) { - _setLoading(false); - print('Error setting sleep schedule: ${e.message}'); - return e.message; - } catch (e) { - _setLoading(false); - print('Generic error setting sleep schedule: $e'); - return 'An unexpected error occurred.'; - } - } -} - -// ================================================================= -// AUTH & ROUTING -// ================================================================= - -class AuthWrapper extends StatelessWidget { - const AuthWrapper({super.key}); - - @override - Widget build(BuildContext context) { - return StreamBuilder( - stream: supabase.auth.onAuthStateChange, - builder: (context, snapshot) { - // Tampilkan loading spinner selagi menunggu event auth pertama. - if (!snapshot.hasData) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); - } - - // Sesuai alur aplikasi, selalu mulai dari LoginPage untuk meminta Device ID, - // terlepas dari status sesi sebelumnya. - return const LoginPage(); - }, - ); - } -} - -class RegisterPage extends StatefulWidget { - const RegisterPage({super.key}); - @override - State createState() => _RegisterPageState(); -} - -class _RegisterPageState extends State { - final _fullNameController = TextEditingController(); - final _emailController = TextEditingController(); - final _passwordController = TextEditingController(); - final _formKey = GlobalKey(); - bool _isLoading = false; - - Future _signUp() async { - if (!_formKey.currentState!.validate()) return; - - setState(() => _isLoading = true); - try { - await supabase.auth.signUp( - email: _emailController.text.trim(), - password: _passwordController.text.trim(), - data: {'full_name': _fullNameController.text.trim()}, - ); - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text( - 'Registrasi berhasil! Silakan periksa email untuk verifikasi & login.'), - backgroundColor: Colors.green, - )); - Navigator.pop(context); - } on AuthException catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(e.message), - backgroundColor: Colors.redAccent, - )); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text("Error: ${e.toString()}"), - backgroundColor: Colors.redAccent, - )); - } finally { - if (mounted) setState(() => _isLoading = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(backgroundColor: Colors.transparent, elevation: 0), - body: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: Form( - key: _formKey, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Icon(CupertinoIcons.person_add_solid, - size: 80, color: Colors.tealAccent), - const SizedBox(height: 16), - const Text('Buat Akun Baru', - textAlign: TextAlign.center, - style: - TextStyle(fontSize: 28, fontWeight: FontWeight.bold)), - const SizedBox(height: 48), - TextFormField( - controller: _fullNameController, - decoration: const InputDecoration(labelText: 'Nama Lengkap'), - validator: (value) => - value!.isEmpty ? 'Nama tidak boleh kosong' : null, - ), - const SizedBox(height: 16), - TextFormField( - controller: _emailController, - decoration: const InputDecoration(labelText: 'Email'), - keyboardType: TextInputType.emailAddress, - validator: (value) => - value!.isEmpty ? 'Email tidak boleh kosong' : null, - ), - const SizedBox(height: 16), - TextFormField( - controller: _passwordController, - decoration: const InputDecoration(labelText: 'Password'), - obscureText: true, - validator: (value) => - value!.length < 6 ? 'Password minimal 6 karakter' : null, - ), - const SizedBox(height: 32), - ElevatedButton( - onPressed: _isLoading ? null : _signUp, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - backgroundColor: Colors.tealAccent, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12)), - ), - child: _isLoading - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.black)) - : const Text('Register', - style: TextStyle( - fontSize: 16, fontWeight: FontWeight.bold)), - ), - ], - ), - ), - ), - ), - ); - } -} - -class LoginPage extends StatefulWidget { - final bool showLogoutSuccess; - - const LoginPage({super.key, this.showLogoutSuccess = false}); - @override - State createState() => _LoginPageState(); -} - -class _LoginPageState extends State { - final _emailController = TextEditingController(); - final _passwordController = TextEditingController(); - final _deviceIdController = TextEditingController(); - final _formKey = GlobalKey(); - bool _isLoading = false; - - // PERBAIKAN: Menambahkan initState untuk menampilkan snackbar jika diperlukan. - @override - void initState() { - super.initState(); - if (widget.showLogoutSuccess) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Anda telah berhasil logout.'), - backgroundColor: Colors.green, - behavior: SnackBarBehavior.floating, - ), - ); - } - }); - } - } - - Future _signIn() async { - if (!_formKey.currentState!.validate()) return; - - setState(() => _isLoading = true); - final appState = Provider.of(context, listen: false); - - try { - final authResponse = await supabase.auth.signInWithPassword( - email: _emailController.text.trim(), - password: _passwordController.text.trim(), - ); - - if (authResponse.user == null) { - throw 'Login gagal, pengguna tidak ditemukan.'; - } - - final deviceIdIsValid = await appState - .setDeviceIdAndFetchData(_deviceIdController.text.trim()); - - if (!mounted) return; - - if (deviceIdIsValid) { - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const DashboardPage()), - ); - } else { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text('Login berhasil, tetapi Device ID tidak terdaftar.'), - backgroundColor: Colors.orange, - )); - await appState.signOut(); - } - } on AuthException catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(e.message), - backgroundColor: Colors.redAccent, - )); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Terjadi kesalahan: ${e.toString()}'), - backgroundColor: Colors.redAccent, - )); - } finally { - if (mounted) setState(() => _isLoading = false); - } - } - - @override - Widget build(BuildContext context) { - final userEmail = supabase.auth.currentUser?.email; - if (userEmail != null && _emailController.text.isEmpty) { - _emailController.text = userEmail; - } - - return Scaffold( - body: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: Form( - key: _formKey, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Icon(CupertinoIcons.shield_lefthalf_fill, - size: 80, color: Colors.tealAccent), - const SizedBox(height: 16), - const Text('Security Monitor', - textAlign: TextAlign.center, - style: - TextStyle(fontSize: 28, fontWeight: FontWeight.bold)), - const SizedBox(height: 48), - TextFormField( - controller: _emailController, - decoration: const InputDecoration(labelText: 'Email'), - keyboardType: TextInputType.emailAddress, - validator: (value) => - value!.isEmpty ? 'Email tidak boleh kosong' : null, - ), - const SizedBox(height: 16), - TextFormField( - controller: _passwordController, - decoration: const InputDecoration(labelText: 'Password'), - obscureText: true, - validator: (value) => - value!.isEmpty ? 'Password tidak boleh kosong' : null, - ), - const SizedBox(height: 16), - TextFormField( - controller: _deviceIdController, - decoration: const InputDecoration(labelText: 'Device ID'), - validator: (value) => - value!.isEmpty ? 'Device ID tidak boleh kosong' : null, - ), - const SizedBox(height: 32), - ElevatedButton( - onPressed: _isLoading ? null : _signIn, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - backgroundColor: Colors.tealAccent, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12)), - ), - child: _isLoading - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.black)) - : const Text('Login', - style: TextStyle( - fontSize: 16, fontWeight: FontWeight.bold)), - ), - const SizedBox(height: 24), - TextButton( - onPressed: () => Navigator.push(context, - MaterialPageRoute(builder: (_) => const RegisterPage())), - child: const Text('Belum punya akun? Daftar sekarang', - style: TextStyle(color: Colors.white70)), - ), - ], - ), - ), - ), - ), - ); - } -} - -// ================================================================= -// DASHBOARD PAGE & COMPONENTS -// ================================================================= - -class DashboardPage extends StatelessWidget { - const DashboardPage({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Stack( - children: [ - Consumer( - builder: (context, appState, child) { - if (appState.isLoading && appState.events.isEmpty) { - return const Center(child: CircularProgressIndicator()); - } - return RefreshIndicator( - onRefresh: Provider.of(context, listen: false) - .fetchInitialData, - color: Colors.tealAccent, - backgroundColor: const Color(0xFF2c2c2c), - child: const CustomScrollView( - slivers: [ - SliverToBoxAdapter(child: ProfileBar()), - SliverToBoxAdapter(child: SettingsBar()), - SliverToBoxAdapter(child: SizedBox(height: 20)), - SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 16.0), - child: Text("Footage Gallery", - style: TextStyle( - fontSize: 22, fontWeight: FontWeight.bold)), - )), - SliverToBoxAdapter(child: SizedBox(height: 10)), - FootageGallery(), - ], - ), - ); - }, - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: Consumer( - builder: (context, appState, child) { - return Visibility( - visible: appState.isLoading, - child: const LinearProgressIndicator( - color: Colors.tealAccent, - backgroundColor: Colors.transparent), - ); - }, - ), - ) - ], - ), - ), - ); - } -} - -class ProfileBar extends StatelessWidget { - const ProfileBar({super.key}); - - @override - Widget build(BuildContext context) { - final user = Provider.of(context, listen: false).currentUser; - final fullName = user?.userMetadata?['full_name'] ?? 'No Name'; - final email = user?.email ?? 'No Email'; - - return Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - CircleAvatar( - backgroundColor: Colors.teal, - child: Text(fullName.isNotEmpty ? fullName[0].toUpperCase() : 'U', - style: const TextStyle( - color: Colors.white, fontWeight: FontWeight.bold)), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(fullName, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.white)), - Text(email, - style: - const TextStyle(fontSize: 14, color: Colors.white70)), - ], - ), - ), - IconButton( - tooltip: "Logout", - icon: const Icon(Icons.logout, color: Colors.white70), - onPressed: () async { - final bool? confirmLogout = await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - backgroundColor: const Color(0xFF2c2c2c), - title: const Text('Konfirmasi Logout', - style: TextStyle(color: Colors.white)), - content: const Text('Apakah Anda yakin ingin keluar?', - style: TextStyle(color: Colors.white70)), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Batal', - style: TextStyle(color: Colors.white70)), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Logout', - style: TextStyle(color: Colors.redAccent)), - ), - ], - ); - }, - ); - - if (!context.mounted) return; - - if (confirmLogout == true) { - // PERBAIKAN: Navigasi dulu, baru proses logout di latar belakang. - final appState = Provider.of(context, listen: false); - - Navigator.of(context).pushAndRemoveUntil( - MaterialPageRoute( - builder: (context) => - const LoginPage(showLogoutSuccess: true)), - (route) => false, - ); - - await appState.signOut(); - } - }, - ) - ], - ), - ); - } -} - -class SettingsBar extends StatelessWidget { - const SettingsBar({super.key}); - - void _showSetTimerDialog(BuildContext context, AppState appState) async { - final now = DateTime.now(); - final TimeOfDay? pickedTime = await showTimePicker( - context: context, - initialTime: TimeOfDay.fromDateTime(now.add(const Duration(hours: 1))), - ); - - if (pickedTime != null) { - DateTime scheduledTime = DateTime( - now.year, now.month, now.day, pickedTime.hour, pickedTime.minute); - if (scheduledTime.isBefore(now)) { - scheduledTime = scheduledTime.add(const Duration(days: 1)); - } - - final duration = scheduledTime.difference(now); - final durationMicroseconds = duration.inMicroseconds; - - final error = await appState.setSleepSchedule(durationMicroseconds); - - if (context.mounted) { - if (error == null) { - final formattedTime = DateFormat.jm('id_ID').format(scheduledTime); - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Jadwal deep sleep diatur hingga $formattedTime'), - backgroundColor: Colors.green, - )); - } else { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Gagal mengatur jadwal: $error'), - backgroundColor: Colors.redAccent, - )); - } - } - } - } - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, appState, child) { - final statusData = appState.deviceStatus; - - if (statusData == null) { - return const Padding( - padding: EdgeInsets.symmetric(horizontal: 16.0), - child: Center( - child: Text("Mencari status perangkat...", - style: TextStyle(color: Colors.white24))), - ); - } - - final deviceId = statusData['device_id'] ?? 'N/A'; - final deviceStatus = statusData['status'] ?? 'unknown'; - final isOnline = deviceStatus == 'active'; - final lastUpdateString = - statusData['last_update'] ?? DateTime.now().toIso8601String(); - final lastUpdate = DateTime.parse(lastUpdateString).toLocal(); - final formattedLastUpdate = - DateFormat('dd MMM, HH:mm:ss', 'id_ID').format(lastUpdate); - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - borderRadius: BorderRadius.circular(16), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Device: $deviceId', - style: const TextStyle( - fontSize: 16, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - Row( - children: [ - Icon(Icons.circle, - color: isOnline - ? Colors.greenAccent - : Colors.redAccent, - size: 12), - const SizedBox(width: 8), - Text(isOnline ? 'Active' : 'Inactive', - style: const TextStyle(color: Colors.white)), - ], - ), - const SizedBox(height: 8), - Text( - 'Last update: $formattedLastUpdate', - style: const TextStyle( - fontSize: 12, color: Colors.white54), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - const SizedBox(width: 12), - ElevatedButton( - onPressed: isOnline - ? () => _showSetTimerDialog(context, appState) - : null, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.teal, - foregroundColor: Colors.white, - disabledBackgroundColor: Colors.grey.withOpacity(0.3)), - child: const Text('Set Timer'), - ) - ], - ), - ), - ); - }, - ); - } -} - -class FootageGallery extends StatelessWidget { - const FootageGallery({super.key}); - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, appState, child) { - final events = appState.events; - if (events.isEmpty) { - return const SliverToBoxAdapter( - child: Center( - child: Padding( - padding: EdgeInsets.all(48.0), - child: Column( - children: [ - Icon(CupertinoIcons.photo_on_rectangle, - size: 60, color: Colors.white24), - SizedBox(height: 16), - Text('Belum ada tangkapan', - style: TextStyle(color: Colors.white24, fontSize: 16)), - ], - ), - ), - ), - ); - } - return SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final event = events[index]; - return FootageCard(event: event); - }, - childCount: events.length, - ), - ); - }, - ); - } -} - -class FootageCard extends StatelessWidget { - final Map event; - const FootageCard({super.key, required this.event}); - - @override - Widget build(BuildContext context) { - final imageUrl = event['image_ref'] as String?; - final location = event['location'] ?? 'Unknown Location'; - final eventType = event['event_type'] ?? 'Unknown Event'; - final timestampString = - event['timestamp'] ?? DateTime.now().toIso8601String(); - final timestamp = DateTime.parse(timestampString); - - final formattedTime = DateFormat('EEEE, dd MMMM yyyy, HH:mm:ss', 'id_ID') - .format(timestamp.toLocal()); - - String displayEventType = 'Unknown'; - Color eventColor = Colors.grey; - if (eventType == 'motion') { - displayEventType = 'Motion Detected'; - eventColor = Colors.orange.shade800; - } else if (eventType == 'vibration') { - displayEventType = 'Vibration Detected'; - eventColor = Colors.purple.shade800; - } - - return Container( - margin: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.5), - spreadRadius: 2, - blurRadius: 10, - offset: const Offset(0, 4), - ) - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (imageUrl != null && imageUrl.isNotEmpty) - Image.network( - imageUrl, - fit: BoxFit.cover, - width: double.infinity, - height: 250, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Container( - height: 250, - color: Colors.black12, - child: Center( - child: CircularProgressIndicator( - color: Colors.tealAccent, - value: loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - ), - ), - ); - }, - errorBuilder: (context, error, stackTrace) => Container( - height: 250, - color: Colors.black12, - child: const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(CupertinoIcons.exclamationmark_triangle, - color: Colors.redAccent, size: 50), - SizedBox(height: 8), - Text("Gagal memuat gambar", - style: TextStyle(color: Colors.white70)) - ], - ), - ), - ), - ) - else - Container( - height: 100, - color: Colors.black12, - child: const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(CupertinoIcons.videocam, - size: 30, color: Colors.white24), - SizedBox(height: 8), - Text("No image captured", - style: TextStyle(color: Colors.white24)) - ], - )), - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: eventColor, - borderRadius: BorderRadius.circular(8)), - child: Text( - displayEventType, - style: const TextStyle( - color: Colors.white, fontWeight: FontWeight.bold), - ), - ), - const SizedBox(height: 12), - Row( - children: [ - const Icon(CupertinoIcons.location_solid, - size: 16, color: Colors.white70), - const SizedBox(width: 8), - Text(location, - style: const TextStyle( - fontSize: 16, color: Colors.white)), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - const Icon(CupertinoIcons.time_solid, - size: 16, color: Colors.white70), - const SizedBox(width: 8), - Text(formattedTime, - style: const TextStyle( - fontSize: 14, color: Colors.white70)), - ], - ), - ], - ), - ), - ], - ), - ), - ); - } -}