diff --git a/app.drawio b/app.drawio new file mode 100644 index 0000000..1c2bffb --- /dev/null +++ b/app.drawio @@ -0,0 +1,261 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/newata2/.env b/newata2/.env index 194dd47..ba2cfe3 100644 --- a/newata2/.env +++ b/newata2/.env @@ -1,3 +1,3 @@ -SUPABASE_URL=https://gtpvcrpnojbjzoedqemy.supabase.co -SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imd0cHZjcnBub2pianpvZWRxZW15Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEwMDY2NjMsImV4cCI6MjA1NjU4MjY2M30.PDDMcfnpVtZS7FTYi-qXejTS10uGKPmwzrcOXhutUsk -SUPABASE_sECRET_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imd0cHZjcnBub2pianpvZWRxZW15Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEwMDY2NjMsImV4cCI6MjA1NjU4MjY2M30.PDDMcfnpVtZS7FTYi-qXejTS10uGKPmwzrcOXhutUsk \ No newline at end of file +SUPABASE_URL=https://ihuetazxejsioxcjejpj.supabase.co +SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImlodWV0YXp4ZWpzaW94Y2planBqIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTExMDEyMTgsImV4cCI6MjA2NjY3NzIxOH0.IKFyrKioiScfAS_9UouPEcesHHoas0SDZH0mZBCA1ro + diff --git a/newata2/README.md b/newata2/README.md index d6d1ea8..10a927c 100644 --- a/newata2/README.md +++ b/newata2/README.md @@ -14,3 +14,109 @@ A few resources to get you started if this is your first Flutter project: For help getting started with Flutter development, view the [online documentation](https://docs.flutter.dev/), which offers tutorials, samples, guidance on mobile development, and a full API reference. + +---------------------------------------------------------------------------------------------------------------------------------------------------------- + +Penjelasan Detail per Bagian Kode +1. main.dart +Ini adalah titik masuk (entry point) dari aplikasi Anda. + +Future main() async: Fungsi main dibuat async karena kita perlu menunggu proses inisialisasi selesai sebelum aplikasi berjalan. + +WidgetsFlutterBinding.ensureInitialized(): Baris ini wajib ada ketika Anda ingin menjalankan kode sebelum runApp(), memastikan semua binding Flutter siap. + +await dotenv.load(...): Memuat semua variabel dari file .env Anda (seperti URL dan Kunci Supabase) ke dalam memori. + +await Supabase.initialize(...): Menginisialisasi koneksi ke proyek Supabase Anda. Ini adalah langkah krusial. + +ChangeNotifierProvider(...): Ini berasal dari library provider. Ini "membungkus" seluruh aplikasi Anda dengan AppState, sehingga semua halaman di bawahnya dapat "mendengarkan" dan bereaksi terhadap perubahan data di AppState. + +MyApp: Widget utama yang mengatur tema global aplikasi dan mendefinisikan halaman awal. + +2. AppState (lib/provider/app_state.dart) +Ini adalah "otak" dari aplikasi Anda. Kelas ini mengelola semua data dan logika bisnis aplikasi (state management). + +class AppState extends ChangeNotifier: ChangeNotifier adalah kelas dari Flutter yang memungkinkan AppState untuk "memberi tahu" para pendengarnya (widget) ketika ada data yang berubah, menggunakan notifyListeners(). + +Variabel State (_): Variabel yang diawali dengan _ (contoh: _isLoading) bersifat private. Data ini hanya bisa diubah dari dalam kelas AppState itu sendiri. Widget lain hanya bisa membacanya melalui getters (contoh: isLoading). + +Constructor AppState(): Di sini kita mengatur pendengar onAuthStateChange. Ini adalah pendengar realtime dari Supabase yang aktif setiap kali ada perubahan status login (login, logout). Jika pengguna logout (_currentUser == null), kita memanggil clearState() untuk membersihkan semua data sesi sebelumnya. + +_setLoading(bool value): Fungsi internal untuk mengontrol tampilan CircularProgressIndicator di seluruh aplikasi. + +setDeviceIdAndFetchData(...): Fungsi ini dipanggil setelah login berhasil. Ia menyimpan deviceId, kemudian memanggil fungsi lain untuk mengambil data awal dan mulai mendengarkan perubahan realtime. + +fetchEvents() & fetchDeviceStatus(): Fungsi ini mengambil data dari tabel Supabase (sensor_events dan device_status) menggunakan select(). Data ini kemudian disimpan dalam variabel state. + +_listenToRealtimeChanges() (PERBAIKAN ERROR): + +Fungsi ini sekarang menggunakan sintaks baru dari supabase_flutter v2. + +supabase.channel(...): Membuat channel komunikasi realtime. + +.onPostgresChanges(...): Ini adalah fungsi spesifik untuk mendengarkan perubahan pada database PostgreSQL Anda. + +event: PostgresChangeEvent.insert: Hanya mendengarkan event INSERT (ketika ada data baru masuk). + +schema: 'public', table: '...': Menentukan tabel mana yang ingin dipantau. + +filter: ...: Filter tambahan agar kita hanya mendapatkan notifikasi untuk deviceId yang sedang dipantau. + +.listen((payload) { ... }): Callback yang akan dieksekusi ketika ada data baru. payload.new berisi data baris yang baru saja ditambahkan. + +setSleepSchedule(...): Fungsi ini dipanggil saat pengguna menekan tombol "Set Timer". Ia melakukan update() pada tabel device_status, mengisi kolom schedule_duration_microseconds dan setter_user_id dengan nilai yang baru. + +3. Halaman SplashScreen (Baru) +StatefulWidget: Diperlukan karena kita perlu mengelola state selama proses navigasi. + +initState(): Metode ini dipanggil sekali saat widget pertama kali dibuat. Di sinilah kita memulai timer. + +Future.delayed(const Duration(seconds: 3), ...): Menjalankan kode di dalamnya setelah jeda 3 detik. + +Navigator.pushReplacement(...): Setelah 3 detik, kita pindah ke AuthWrapper. pushReplacement digunakan agar pengguna tidak bisa kembali ke splash screen dengan menekan tombol "back". + +4. AuthWrapper +Seperti yang dijelaskan, ini adalah widget tanpa tampilan yang berfungsi sebagai router cerdas. + +Consumer: Widget ini secara otomatis "mendengarkan" AppState. Setiap kali notifyListeners() dipanggil di AppState, bagian builder dari Consumer ini akan dieksekusi ulang. + +if (appState.currentUser != null && appState.deviceId != null): Logika inti. Jika ada pengguna yang login DAN deviceId sudah diatur, tampilkan DashboardPage. Jika tidak, tampilkan LoginPage. + +5. LoginPage +StatefulWidget & TextEditingController: Digunakan untuk mengelola input teks dari pengguna. + +_formKey: Digunakan untuk validasi form (memastikan kolom tidak kosong). + +_signIn(): + +Memeriksa validasi form. + +Memanggil supabase.auth.signInWithPassword(...). + +Jika berhasil, ia memanggil Provider.of(context, listen: false).setDeviceIdAndFetchData(...) untuk memberi tahu AppState bahwa pengguna telah login dengan deviceId tertentu, sehingga AppState bisa mulai mengambil data. + +Menangani AuthException jika login gagal (misalnya, password salah). + +6. RegisterPage +Mirip dengan LoginPage, tetapi memanggil supabase.auth.signUp(...). + +data: {'full_name': ...}: Ini adalah bagian penting. Di sinilah kita menyimpan data tambahan (nama lengkap) ke dalam kolom raw_user_meta_data di tabel auth.users Supabase. + +7. DashboardPage & Komponen-komponennya +DashboardPage: Menggunakan CustomScrollView dan Sliver untuk layout yang efisien dan memungkinkan adanya refresh indicator. + +ProfileBar: Mengambil data pengguna (fullName, email) dari AppState dan menampilkannya. Tombol logout memanggil appState.signOut(). + +SettingsBar: + +Menampilkan status online/offline berdasarkan data dari _deviceStatus di AppState. + +Tombol "Set Timer" hanya aktif jika isOnline == true. + +_showSetTimerDialog(): Memunculkan TimePicker bawaan Flutter. Logikanya menghitung selisih antara waktu sekarang dan waktu yang dipilih pengguna, mengonversinya ke mikrodetik, lalu memanggil appState.setSleepSchedule() untuk mengirim data ke Supabase. + +FootageGallery & FootageCard: + +FootageGallery menampilkan SliverList dari FootageCard berdasarkan data _events di AppState. + +FootageCard adalah kartu individu yang menampilkan gambar (Image.network), lokasi, tipe event, dan waktu kejadian. Desain kartu dibuat dengan BoxDecoration untuk memberikan efek bayangan (mirip 3D) dan sudut yang tumpul (borderRadius). \ No newline at end of file diff --git a/newata2/db_scheme.sql b/newata2/db_scheme.sql new file mode 100644 index 0000000..9ae47c0 --- /dev/null +++ b/newata2/db_scheme.sql @@ -0,0 +1,31 @@ +-- 1. TABEL UNTUK MENCATAT SEMUA EVENT DARI SENSOR +CREATE TABLE public.sensor_events ( + id TEXT PRIMARY KEY NOT NULL, -- Format: device_id + epoch. Contoh: garasi_01_1672531200 + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + device_id TEXT NOT NULL, + event_timestamp TIMESTAMPTZ NOT NULL, -- Waktu asli saat event terjadi (dari RTC) + event_type TEXT NOT NULL, -- Contoh: 'GERAKAN' atau 'GETARAN' + location_name TEXT, + image_ref TEXT -- URL publik ke gambar di Supabase Storage +); +COMMENT ON TABLE public.sensor_events IS 'Mencatat setiap event yang terdeteksi oleh sensor.'; + +-- 2. TABEL UNTUK MEMANTAU STATUS PERANGKAT DAN JADWAL SLEEP +CREATE TABLE public.device_status ( + device_id TEXT PRIMARY KEY NOT NULL, -- Contoh: 'garasi_01' + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + is_online BOOLEAN DEFAULT false, + last_status_update TIMESTAMPTZ, + schedule_duration_microseconds BIGINT DEFAULT 0, -- Durasi sleep dalam mikrodetik + setter_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL -- Foreign Key ke tabel pengguna +); +COMMENT ON TABLE public.device_status IS 'Memantau status online/offline dan jadwal deep sleep perangkat.'; + +-- Tambahkan trigger untuk otomatis memperbarui kolom 'updated_at' +create extension if not exists moddatetime schema extensions; + +create trigger handle_updated_at before update on public.device_status + for each row execute procedure extensions.moddatetime (updated_at); + +create trigger handle_updated_at_events before update on public.sensor_events + for each row execute procedure extensions.moddatetime (created_at); diff --git a/newata2/images/cctv.png b/newata2/images/cctv.png new file mode 100644 index 0000000..3597b19 Binary files /dev/null and b/newata2/images/cctv.png differ diff --git a/newata2/lib/auth_wrapper.dart b/newata2/lib/auth_wrapper.dart new file mode 100644 index 0000000..de883a2 --- /dev/null +++ b/newata2/lib/auth_wrapper.dart @@ -0,0 +1,31 @@ +import 'package:CCTV_App/login_page.dart'; +import 'package:CCTV_App/main.dart'; +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +// ================================================================= +// 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(); + }, + ); + } +} diff --git a/newata2/lib/constant.dart b/newata2/lib/constant.dart deleted file mode 100644 index aa7e866..0000000 --- a/newata2/lib/constant.dart +++ /dev/null @@ -1,4 +0,0 @@ -class AppConstants { - static const String footageBucket = - 'newata-footage-bucket'; // Ganti dengan nama bucket Anda -} diff --git a/newata2/lib/dashboard_page.dart b/newata2/lib/dashboard_page.dart new file mode 100644 index 0000000..6d121b7 --- /dev/null +++ b/newata2/lib/dashboard_page.dart @@ -0,0 +1,473 @@ +// ================================================================= +// DASHBOARD PAGE & COMPONENTS +// ================================================================= + +import 'package:CCTV_App/login_page.dart'; +import 'package:CCTV_App/provider.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +// ================================================================= +// 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)), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/newata2/lib/login_page.dart b/newata2/lib/login_page.dart new file mode 100644 index 0000000..99dfa33 --- /dev/null +++ b/newata2/lib/login_page.dart @@ -0,0 +1,175 @@ +// import 'package:CCTV_App/dashboard_page.dart'; +import 'package:CCTV_App/dashboard_page.dart'; +import 'package:CCTV_App/main.dart'; +import 'package:CCTV_App/provider.dart'; +import 'package:CCTV_App/register_page.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +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)), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/newata2/lib/main copy 4.dart b/newata2/lib/main copy 4.dart new file mode 100644 index 0000000..319c7d9 --- /dev/null +++ b/newata2/lib/main copy 4.dart @@ -0,0 +1,2155 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:intl/intl.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:provider/provider.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'dart:async'; + +// ================================================================= +// 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 SplashScreen(), + debugShowCheckedModeBanner: false, + ); + } +} + +// ================================================================= +// STATE MANAGEMENT (PROVIDER) +// ================================================================= + +class AppState extends ChangeNotifier { + // State variables + bool _isLoading = false; + String? _deviceId; + List> _events = []; + // PENYESUAIAN: Nama variabel diubah agar lebih jelas + Map? _scheduleStatus; + User? _currentUser; + + // Realtime channel subscriptions + RealtimeChannel? _sensorEventsChannel; + RealtimeChannel? _scheduleStatusChannel; // PENYESUAIAN: Nama channel diubah + + // Getters + bool get isLoading => _isLoading; + String? get deviceId => _deviceId; + List> get events => _events; + // PENYESUAIAN: Nama getter diubah + Map? get scheduleStatus => _scheduleStatus; + User? get currentUser => _currentUser; + + // Constructor + AppState() { + _currentUser = supabase.auth.currentUser; + supabase.auth.onAuthStateChange.listen((data) { + _currentUser = data.session?.user; + if (_currentUser == null) { + clearState(); + } + notifyListeners(); + }); + } + + void _unsubscribeFromChannels() { + if (_sensorEventsChannel != null) { + supabase.removeChannel(_sensorEventsChannel!); + _sensorEventsChannel = null; + } + // PENYESUAIAN: Channel yang di-unsubscribe disesuaikan + if (_scheduleStatusChannel != null) { + supabase.removeChannel(_scheduleStatusChannel!); + _scheduleStatusChannel = null; + } + } + + void clearState() { + _deviceId = null; + _events.clear(); + _scheduleStatus = null; + _unsubscribeFromChannels(); + notifyListeners(); + } + + void _setLoading(bool value) { + _isLoading = value; + notifyListeners(); + } + + // PENYESUAIAN: Fungsi ini sekarang menangani validasi device ID + // dan mengembalikan boolean yang menandakan keberhasilan. + Future setDeviceIdAndFetchData(String deviceId) async { + // FITUR BARU: Validasi Device ID + try { + final response = await supabase + .from('devices') + .select('id') + .eq('id', deviceId) + .maybeSingle(); // maybeSingle() mengembalikan null jika tidak ditemukan + + if (response == null) { + return false; // Device ID tidak valid + } + + // Jika valid, lanjutkan seperti biasa + _deviceId = deviceId; + await fetchInitialData(); + _listenToRealtimeChanges(); + notifyListeners(); + return true; // Sukses + } catch (e) { + print('Error validating device ID: $e'); + return false; + } + } + + Future fetchInitialData() async { + if (_deviceId == null) return; + _setLoading(true); + await Future.wait([ + fetchEvents(), + fetchScheduleStatus(), // PENYESUAIAN: Nama fungsi diubah + ]); + _setLoading(false); + } + + Future fetchEvents() async { + if (_deviceId == null) return; + try { + final response = await supabase + .from('sensor_events') + .select() + .eq('device_id', _deviceId!) + .order('created_at', ascending: false); // Kolom diubah ke created_at + _events = List>.from(response); + } catch (e) { + print('Error fetching events: $e'); + _events = []; + } + notifyListeners(); + } + + // PENYESUAIAN: Nama fungsi diubah dan logika disederhanakan + Future fetchScheduleStatus() async { + try { + // Mengambil dari tabel 'device_schedules' dengan id=1 + final response = await supabase + .from('device_schedules') + .select() + .eq('id', 1) + .single(); + _scheduleStatus = response; + } catch (e) { + print('Error fetching schedule status: $e'); + _scheduleStatus = null; + } + notifyListeners(); + } + + // DIHAPUS: Fungsi fetchSetterUserDetails tidak lagi diperlukan + // karena nama sudah ada di tabel device_schedules. + + void _listenToRealtimeChanges() { + if (_deviceId == null) return; + _unsubscribeFromChannels(); + + // Channel untuk sensor_events tetap sama + _sensorEventsChannel = supabase.channel('public:sensor_events'); + _sensorEventsChannel! + .onPostgresChanges( + event: PostgresChangeEvent.insert, + schema: 'public', + table: 'sensor_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(); + + // PENYESUAIAN: Channel untuk mendengarkan perubahan jadwal + _scheduleStatusChannel = supabase.channel('public:device_schedules'); + _scheduleStatusChannel! + .onPostgresChanges( + event: PostgresChangeEvent.update, // Cukup dengarkan UPDATE + schema: 'public', + table: 'device_schedules', + filter: PostgresChangeFilter( + type: PostgresChangeFilterType.eq, + column: 'id', // Filter berdasarkan ID baris + value: 1, // Hanya baris dengan id=1 + ), + callback: (payload) { + // Logika disederhanakan, langsung update status + _scheduleStatus = payload.newRecord; + notifyListeners(); + }, + ) + .subscribe(); + } + + Future signOut() async { + _setLoading(true); + await supabase.auth.signOut(); + // clearState() akan dipanggil otomatis oleh listener onAuthStateChange + _setLoading(false); + } + + // PENYESUAIAN: Fungsi ini sekarang mengirim nama lengkap pengguna + Future setSleepSchedule(int durationMicroseconds) async { + if (_currentUser == null) { + return "User not identified."; + } + _setLoading(true); + + final fullName = _currentUser!.userMetadata?['full_name'] ?? 'Unknown User'; + + try { + // Update tabel 'device_schedules' baris id=1 + await supabase.from('device_schedules').update({ + 'schedule_duration_microseconds': durationMicroseconds, + 'setter_user_id': _currentUser!.id, + 'setter_full_name': fullName, // FITUR BARU: Kirim nama lengkap + 'updated_at': DateTime.now().toIso8601String(), + }).eq('id', 1); + + _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.'; + } + } +} + +// ================================================================= +// SPLASH SCREEN, AUTH WRAPPER, REGISTER PAGE (Tidak ada perubahan signifikan) +// ================================================================= + +class SplashScreen extends StatefulWidget { + const SplashScreen({super.key}); + @override + State createState() => _SplashScreenState(); +} + +class _SplashScreenState extends State { + @override + void initState() { + super.initState(); + _redirect(); + } + + Future _redirect() async { + await Future.delayed(const Duration(seconds: 2)); + if (mounted) { + final session = supabase.auth.currentSession; + final deviceId = Provider.of(context, listen: false).deviceId; + + // Jika ada sesi DAN deviceId sudah di-set, langsung ke Dashboard + if (session != null && deviceId != null && deviceId.isNotEmpty) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const DashboardPage())); + } else { + // Jika tidak, ke halaman login + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const LoginPage())); + } + } + } + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(CupertinoIcons.lock_shield_fill, + size: 120, color: Colors.tealAccent), + SizedBox(height: 24), + Text('Security Monitor', + style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)), + SizedBox(height: 80), + CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white54)), + ], + ), + ), + ); + } +} + +class AuthWrapper extends StatelessWidget { + const AuthWrapper({super.key}); + @override + Widget build(BuildContext context) { + // Disederhanakan: Logika redirect kini ditangani di SplashScreen dan LoginPage + // AuthWrapper bisa dihapus atau disederhanakan jika mau, + // tapi kita biarkan untuk potensi routing di masa depan. + final appState = Provider.of(context); + if (appState.currentUser != null && appState.deviceId != null) { + return const DashboardPage(); + } else { + 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()) { + 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 Anda untuk verifikasi dan kemudian 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, + )); + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + } + + @override + void dispose() { + _fullNameController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @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)), + ), + ], + ), + ), + ), + ), + ); + } +} + +// ================================================================= +// LOGIN PAGE +// ================================================================= + +class LoginPage extends StatefulWidget { + const LoginPage({super.key}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _deviceIdController = TextEditingController(); + final _formKey = GlobalKey(); + bool _isLoading = false; + + // PENYESUAIAN: Logika sign in diubah total untuk validasi Device ID + Future _signIn() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + + try { + // 1. Coba login dengan email dan password + final authResponse = await supabase.auth.signInWithPassword( + email: _emailController.text.trim(), + password: _passwordController.text.trim(), + ); + + if (authResponse.user == null) { + // Ini seharusnya tidak terjadi jika tidak ada AuthException, tapi sebagai penjaga + throw 'Login failed unexpectedly.'; + } + + // 2. Jika login berhasil, validasi Device ID + final appState = Provider.of(context, listen: false); + final deviceIdIsValid = await appState.setDeviceIdAndFetchData( + _deviceIdController.text.trim() + ); + + if (!mounted) return; + + if (deviceIdIsValid) { + // 3. Jika Device ID valid, navigasi ke Dashboard + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const DashboardPage()), + ); + } else { + // 4. Jika Device ID TIDAK valid, tampilkan error dan logout lagi + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('Login berhasil, tetapi Device ID tidak ditemukan.'), + backgroundColor: Colors.orange, + )); + await supabase.auth.signOut(); // Logout untuk mencegah state aneh + } + + } 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 + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _deviceIdController.dispose(); + super.dispose(); + } + + @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: [ + 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 appState = Provider.of(context); + final user = appState.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: () { + Provider.of(context, listen: false).signOut(); + // Navigasi kembali ke LoginPage setelah logout + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (context) => const LoginPage()), + (route) => false, + ); + }, + ) + ], + ), + ); + } +} + + +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); + // ESP32-CAM menggunakan mikrodetik untuk deep sleep + 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) { + // PENYESUAIAN: Mengambil data dari 'scheduleStatus' + final status = appState.scheduleStatus; + final deviceId = appState.deviceId ?? 'N/A'; + + if (status == null) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: Center( + child: Text("Mencari status perangkat...", + style: TextStyle(color: Colors.white24))), + ); + } + + // Asumsi perangkat online jika aplikasi bisa mengambil data. + // Logika status on/off bisa dibuat lebih kompleks jika diperlukan. + final bool isOnline = true; + + // FITUR BARU: Mengambil nama dari kolom baru 'setter_full_name' + final String setterName = status['setter_full_name'] ?? 'Belum pernah diatur'; + + 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 ? 'Online' : 'Offline', + style: const TextStyle(color: Colors.white)), + ], + ), + const SizedBox(height: 8), + // PENYESUAIAN: Menampilkan nama setter + Text( + 'Last Setter: $setterName', + 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'] ?? ''; + final location = event['location_name'] ?? 'Unknown Location'; + final eventType = event['event_type'] ?? 'Unknown Event'; + + // PENYESUAIAN: Menggunakan kolom 'created_at' yang pasti ada + final timestampString = event['created_at'] ?? DateTime.now().toIso8601String(); + final timestamp = DateTime.parse(timestampString); + + final formattedTime = DateFormat('EEEE, dd MMMM yyyy, HH:mm:ss', 'id_ID') + .format(timestamp.toLocal()); + + 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.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)) + ], + ), + ), + ), + ), + 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: eventType == 'GERAKAN' + ? Colors.orange.shade800 + : Colors.purple.shade800, + borderRadius: BorderRadius.circular(8)), + child: Text( + eventType, + 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)), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} + + + + + + + + + + + + + + + + + + + + + + + +// import 'package:flutter/material.dart'; +// import 'package:flutter/cupertino.dart'; +// import 'package:flutter_dotenv/flutter_dotenv.dart'; +// import 'package:intl/intl.dart'; +// import 'package:intl/date_symbol_data_local.dart'; // <-- IMPORT PENTING +// import 'package:provider/provider.dart'; +// import 'package:supabase_flutter/supabase_flutter.dart'; +// import 'dart:async'; + +// // ================================================================= +// // MAIN.DART & SETUP +// // ================================================================= + +// Future main() async { +// // Pastikan semua binding Flutter siap sebelum menjalankan kode async +// WidgetsFlutterBinding.ensureInitialized(); + +// // Muat environment variables dari file .env +// await dotenv.load(fileName: ".env"); + +// // Inisialisasi Supabase +// await Supabase.initialize( +// url: dotenv.env['SUPABASE_URL']!, +// anonKey: dotenv.env['SUPABASE_ANON_KEY']!, +// ); + +// // ================================================================= +// // PERBAIKAN UTAMA: Inisialisasi locale untuk package 'intl' +// // Ini akan memuat data format tanggal untuk Bahasa Indonesia ('id_ID'). +// // Panggilan ini sangat penting untuk mencegah LocaleDataException. +// // ================================================================= +// await initializeDateFormatting('id_ID', null); + +// runApp( +// ChangeNotifierProvider( +// create: (context) => AppState(), +// child: const MyApp(), +// ), +// ); +// } + +// // Global Supabase client +// 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, +// ), +// // Set SplashScreen sebagai halaman awal +// home: const SplashScreen(), +// debugShowCheckedModeBanner: false, +// ); +// } +// } + +// // ================================================================= +// // STATE MANAGEMENT (PROVIDER) +// // ================================================================= + +// class AppState extends ChangeNotifier { +// // State variables +// bool _isLoading = false; +// String? _deviceId; +// List> _events = []; +// Map? _deviceStatus; +// User? _currentUser; +// Map? _setterUserDetails; + +// // Realtime channel subscriptions +// RealtimeChannel? _sensorEventsChannel; +// RealtimeChannel? _deviceStatusChannel; + +// // Getters +// bool get isLoading => _isLoading; +// String? get deviceId => _deviceId; +// List> get events => _events; +// Map? get deviceStatus => _deviceStatus; +// User? get currentUser => _currentUser; +// Map? get setterUserDetails => _setterUserDetails; + +// // Constructor +// AppState() { +// _currentUser = supabase.auth.currentUser; +// supabase.auth.onAuthStateChange.listen((data) { +// _currentUser = data.session?.user; +// if (_currentUser == null) { +// clearState(); +// } +// notifyListeners(); +// }); +// } + +// void _unsubscribeFromChannels() { +// if (_sensorEventsChannel != null) { +// supabase.removeChannel(_sensorEventsChannel!); +// _sensorEventsChannel = null; +// } +// if (_deviceStatusChannel != null) { +// supabase.removeChannel(_deviceStatusChannel!); +// _deviceStatusChannel = null; +// } +// } + +// void clearState() { +// _deviceId = null; +// _events.clear(); +// _deviceStatus = null; +// _setterUserDetails = null; +// _unsubscribeFromChannels(); // Unsubscribe saat logout +// notifyListeners(); +// } + +// void _setLoading(bool value) { +// _isLoading = value; +// notifyListeners(); +// } + +// Future setDeviceIdAndFetchData(String deviceId) async { +// _deviceId = deviceId; +// if (_deviceId != null && _deviceId!.isNotEmpty) { +// await fetchInitialData(); +// _listenToRealtimeChanges(); +// } +// notifyListeners(); +// } + +// 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('sensor_events') +// .select() +// .eq('device_id', _deviceId!) +// .order('event_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; +// if (_deviceStatus != null && _deviceStatus!['setter_user_id'] != null) { +// await fetchSetterUserDetails(_deviceStatus!['setter_user_id']); +// } else { +// _setterUserDetails = null; +// } +// } catch (e) { +// print('Error fetching device status: $e'); +// _deviceStatus = {'device_id': _deviceId, 'is_online': false}; +// } +// notifyListeners(); +// } + +// Future fetchSetterUserDetails(String userId) async { +// try { +// final response = await supabase +// .from('users') +// .select('raw_user_meta_data') +// .eq('id', userId) +// .single(); +// _setterUserDetails = response['raw_user_meta_data']; +// } catch (e) { +// print('Error fetching setter user details: $e'); +// _setterUserDetails = {'full_name': 'Unknown', 'email': 'Unknown'}; +// } +// notifyListeners(); +// } + +// void _listenToRealtimeChanges() { +// if (_deviceId == null) return; + +// _unsubscribeFromChannels(); + +// _sensorEventsChannel = supabase.channel('public:sensor_events:$_deviceId'); +// _sensorEventsChannel! +// .onPostgresChanges( +// event: PostgresChangeEvent.insert, +// schema: 'public', +// table: 'sensor_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:$_deviceId'); +// _deviceStatusChannel! +// .onPostgresChanges( +// event: PostgresChangeEvent.all, +// schema: 'public', +// table: 'device_status', +// filter: PostgresChangeFilter( +// type: PostgresChangeFilterType.eq, +// column: 'device_id', +// value: _deviceId!, +// ), +// callback: (payload) async { +// final newStatus = payload.newRecord; +// _deviceStatus = newStatus; +// if (_deviceStatus != null && +// _deviceStatus!['setter_user_id'] != null) { +// await fetchSetterUserDetails(_deviceStatus!['setter_user_id']); +// } else { +// _setterUserDetails = null; +// } +// notifyListeners(); +// }, +// ) +// .subscribe(); +// } + +// Future signOut() async { +// _setLoading(true); +// await supabase.auth.signOut(); +// _setLoading(false); +// } + +// Future setSleepSchedule(int durationMicroseconds) async { +// if (_deviceId == null || _currentUser == null) { +// return "User or Device not identified."; +// } +// _setLoading(true); +// try { +// await supabase.from('device_status').update({ +// 'schedule_duration_microseconds': durationMicroseconds, +// 'setter_user_id': _currentUser!.id, +// }).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.'; +// } +// } +// } + +// // ================================================================= +// // SPLASH SCREEN PAGE +// // ================================================================= + +// class SplashScreen extends StatefulWidget { +// const SplashScreen({super.key}); + +// @override +// State createState() => _SplashScreenState(); +// } + +// class _SplashScreenState extends State { +// @override +// void initState() { +// super.initState(); +// Future.delayed(const Duration(seconds: 3), () { +// if (mounted) { +// Navigator.of(context).pushReplacement( +// MaterialPageRoute(builder: (_) => const AuthWrapper())); +// } +// }); +// } + +// @override +// Widget build(BuildContext context) { +// return Scaffold( +// body: Center( +// child: Column( +// mainAxisAlignment: MainAxisAlignment.center, +// children: [ +// const Icon(CupertinoIcons.lock_shield_fill, +// size: 120, color: Colors.tealAccent), +// const SizedBox(height: 24), +// Text( +// 'Security Monitor', +// style: Theme.of(context) +// .textTheme +// .titleLarge +// ?.copyWith(fontSize: 28), +// ), +// const SizedBox(height: 80), +// const CircularProgressIndicator( +// valueColor: AlwaysStoppedAnimation(Colors.white54), +// ), +// ], +// ), +// ), +// ); +// } +// } + +// // ================================================================= +// // AUTH WRAPPER (Gatekeeper) +// // ================================================================= + +// class AuthWrapper extends StatelessWidget { +// const AuthWrapper({super.key}); + +// @override +// Widget build(BuildContext context) { +// return Consumer( +// builder: (context, appState, child) { +// if (appState.currentUser != null && appState.deviceId != null) { +// return const DashboardPage(); +// } +// return const LoginPage(); +// }, +// ); +// } +// } + +// // ================================================================= +// // LOGIN PAGE +// // ================================================================= + +// class LoginPage extends StatefulWidget { +// const LoginPage({super.key}); + +// @override +// State createState() => _LoginPageState(); +// } + +// class _LoginPageState extends State { +// final _emailController = TextEditingController(); +// final _passwordController = TextEditingController(); +// final _deviceIdController = TextEditingController(); +// final _formKey = GlobalKey(); +// bool _isLoading = false; + +// Future _signIn() async { +// if (_formKey.currentState!.validate()) { +// setState(() => _isLoading = true); +// try { +// final authResponse = await supabase.auth.signInWithPassword( +// email: _emailController.text.trim(), +// password: _passwordController.text.trim(), +// ); +// if (authResponse.user != null) { +// if (!mounted) return; +// await Provider.of(context, listen: false) +// .setDeviceIdAndFetchData(_deviceIdController.text.trim()); +// } +// } 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(const SnackBar( +// content: Text('An unexpected error occurred.'), +// backgroundColor: Colors.redAccent, +// )); +// } finally { +// if (mounted) { +// setState(() => _isLoading = false); +// } +// } +// } +// } + +// @override +// void dispose() { +// _emailController.dispose(); +// _passwordController.dispose(); +// _deviceIdController.dispose(); +// super.dispose(); +// } + +// @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: [ +// 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)), +// ), +// ], +// ), +// ), +// ), +// ), +// ); +// } +// } + +// // ================================================================= +// // REGISTER PAGE +// // ================================================================= +// 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()) { +// 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 Anda untuk verifikasi dan kemudian 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(const SnackBar( +// content: Text('An unexpected error occurred.'), +// backgroundColor: Colors.redAccent, +// )); +// } finally { +// if (mounted) { +// setState(() => _isLoading = false); +// } +// } +// } +// } + +// @override +// void dispose() { +// _fullNameController.dispose(); +// _emailController.dispose(); +// _passwordController.dispose(); +// super.dispose(); +// } + +// @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)), +// ), +// ], +// ), +// ), +// ), +// ), +// ); +// } +// } + +// // ================================================================= +// // DASHBOARD PAGE +// // ================================================================= +// 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(), +// ], +// ), +// ); +// }, +// ), +// // Indikator loading linear di bagian atas +// 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, +// ), +// ); +// }, +// ), +// ) +// ], +// ), +// ), +// ); +// } +// } + +// // ================================================================= +// // DASHBOARD COMPONENTS: PROFILE BAR +// // ================================================================= +// class ProfileBar extends StatelessWidget { +// const ProfileBar({super.key}); + +// @override +// Widget build(BuildContext context) { +// final appState = Provider.of(context); +// final user = appState.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: () { +// Provider.of(context, listen: false).signOut(); +// }, +// ) +// ], +// ), +// ); +// } +// } + +// // ================================================================= +// // DASHBOARD COMPONENTS: SETTINGS BAR +// // ================================================================= +// 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) { +// // PERBAIKAN: Menggunakan DateFormat yang sudah diinisialisasi +// 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 status = appState.deviceStatus; +// if (status == null) { +// return const Padding( +// padding: EdgeInsets.symmetric(horizontal: 16.0), +// child: Center( +// child: Text("Mencari status perangkat...", +// style: TextStyle(color: Colors.white24))), +// ); +// } + +// final bool isOnline = status['is_online'] ?? false; +// final String deviceId = status['device_id'] ?? 'N/A'; +// final setterName = appState.setterUserDetails?['full_name'] ?? 'N/A'; +// final setterEmail = +// appState.setterUserDetails?['email'] ?? 'Belum pernah diatur'; + +// 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 ? 'Online' : 'Offline', +// style: const TextStyle(color: Colors.white)), +// ], +// ), +// const SizedBox(height: 8), +// Text( +// 'Last Setter: $setterName ($setterEmail)', +// 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'), +// ) +// ], +// ), +// ), +// ); +// }, +// ); +// } +// } + +// // ================================================================= +// // DASHBOARD COMPONENTS: FOOTAGE GALLERY +// // ================================================================= + +// 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 rekaman', +// 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'] ?? ''; +// final location = event['location_name'] ?? 'Unknown Location'; +// final eventType = event['event_type'] ?? 'Unknown Event'; +// final timestamp = DateTime.parse(event['event_timestamp']); + +// // ================================================================= +// // PERBAIKAN: Menggunakan DateFormat langsung di sini. +// // Locale 'id_ID' sudah dijamin siap karena inisialisasi di main(). +// // Format tahun (yyyy) juga sudah diperbaiki. +// // ================================================================= +// final formattedTime = DateFormat('EEEE, dd MMMM yyyy, HH:mm:ss', 'id_ID') +// .format(timestamp.toLocal()); + +// 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.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)) +// ], +// ), +// ), +// ), +// ), +// 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: eventType == 'GERAKAN' +// ? Colors.orange.shade800 +// : Colors.purple.shade800, +// borderRadius: BorderRadius.circular(8)), +// child: Text( +// eventType, +// 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 5.dart b/newata2/lib/main copy 5.dart new file mode 100644 index 0000000..e33eb40 --- /dev/null +++ b/newata2/lib/main copy 5.dart @@ -0,0 +1,1051 @@ +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_microseconds': 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)), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/newata2/lib/main.dart b/newata2/lib/main.dart index a4ae449..fc0e793 100644 --- a/newata2/lib/main.dart +++ b/newata2/lib/main.dart @@ -1,63 +1,32 @@ -// main.dart +import 'package:CCTV_App/auth_wrapper.dart'; +import 'package:CCTV_App/provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:intl/date_symbol_data_local.dart'; import 'package:provider/provider.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -import 'package:intl/date_symbol_data_local.dart'; -// Providers -import 'providers/auth_provider.dart'; -import 'providers/pantau_footage_provider.dart'; -import 'providers/schedule_provider.dart'; - -// Screens -import 'screens/splash_screen.dart'; -import 'screens/login_screen.dart'; -import 'screens/register_screen.dart'; -import 'screens/dashboard_screen.dart'; -import 'screens/video_player_screen.dart'; // Pastikan path benar -import 'screens/image_viewer_screen.dart'; // Pastikan path benar +// ================================================================= +// MAIN.DART & SETUP +// ================================================================= Future main() async { WidgetsFlutterBinding.ensureInitialized(); - print("main: WidgetsFlutterBinding initialized."); // Log + await dotenv.load(fileName: ".env"); - try { - await dotenv.load(fileName: ".env"); - print("main: .env file loaded successfully."); // Log - } catch (e) { - print("main: Error loading .env file: $e"); // Log error - } + await Supabase.initialize( + url: dotenv.env['SUPABASE_URL']!, + anonKey: dotenv.env['SUPABASE_ANON_KEY']!, + ); - final supabaseUrl = dotenv.env['SUPABASE_URL']; - final supabaseAnonKey = dotenv.env['SUPABASE_ANON_KEY']; + await initializeDateFormatting('id_ID', null); - if (supabaseUrl == null || supabaseAnonKey == null) { - print( - "main: Supabase URL or Anon Key not found in .env file. App cannot initialize Supabase."); // Log error - return; - } - print("main: Supabase URL and Anon Key found."); // Log - - try { - await Supabase.initialize( - url: supabaseUrl, - anonKey: supabaseAnonKey, - ); - print("main: Supabase initialized successfully."); // Log - } catch (e) { - print("main: Error initializing Supabase: $e"); // Log error - return; - } - - try { - await initializeDateFormatting('id_ID', null); - print("main: Date formatting initialized."); // Log - } catch (e) { - print("main: Error initializing date formatting: $e"); // Log error - } - - runApp(const MyApp()); + runApp( + ChangeNotifierProvider( + create: (context) => AppState(), + child: const MyApp(), + ), + ); } final supabase = Supabase.instance.client; @@ -67,137 +36,42 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - print("MyApp: Building..."); // Log - return MultiProvider( - providers: [ - ChangeNotifierProvider(create: (_) => AuthProvider()), - ChangeNotifierProxyProvider( - create: (_) => FootageProvider(null), - update: (_, auth, previousFootage) => - FootageProvider(auth.currentUser?.id), + 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, ), - ChangeNotifierProxyProvider( - create: (_) => ScheduleProvider(null), - update: (_, auth, previousSchedule) => - ScheduleProvider(auth.currentUser?.id), + 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), ), - ], - child: MaterialApp( - title: 'CCTV IoT App', - theme: ThemeData( - // Tema tetap sama - primarySwatch: Colors.teal, - visualDensity: VisualDensity.adaptivePlatformDensity, - fontFamily: 'Inter', - scaffoldBackgroundColor: Colors.grey[100], - appBarTheme: AppBarTheme( - backgroundColor: Colors.teal[600], - elevation: 2, - iconTheme: - const IconThemeData(color: Colors.white), // Warna ikon AppBar - titleTextStyle: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.w500) // Style title AppBar - ), - bottomNavigationBarTheme: BottomNavigationBarThemeData( - selectedItemColor: Colors.teal[700], - unselectedItemColor: Colors.grey[600], - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.teal[500], - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), - )), - cardTheme: CardTheme( - elevation: 1, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), - ), - inputDecorationTheme: InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.grey[400]!), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: Colors.teal), - ), + inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: Colors.white, - contentPadding: - const EdgeInsets.symmetric(vertical: 14.0, horizontal: 12.0), - ), - listTileTheme: ListTileThemeData( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - textButtonTheme: TextButtonThemeData( - style: TextButton.styleFrom( - foregroundColor: Colors.teal[600], - ), - ), - dialogTheme: DialogTheme( - // Style untuk dialog (misal loading) - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10.0), - ), - elevation: 5, - ), - timePickerTheme: TimePickerThemeData( - // Style untuk TimePicker - backgroundColor: Colors.white, - hourMinuteShape: RoundedRectangleBorder( - borderRadius: const BorderRadius.all(Radius.circular(8)), - side: BorderSide(color: Colors.grey[300]!, width: 1), - ), - dayPeriodShape: RoundedRectangleBorder( - borderRadius: const BorderRadius.all(Radius.circular(8)), - side: BorderSide(color: Colors.grey[300]!, width: 1), - ), - dayPeriodColor: Colors.teal[50], - dayPeriodTextColor: Colors.teal[800], - hourMinuteColor: WidgetStateColor.resolveWith((states) => - states.contains(WidgetState.selected) - ? Colors.teal[100]! - : Colors.grey[100]!), - hourMinuteTextColor: WidgetStateColor.resolveWith((states) => - states.contains(WidgetState.selected) - ? Colors.teal[900]! - : Colors.black54), - dialHandColor: Colors.teal[300], - dialBackgroundColor: Colors.teal[50], - dialTextColor: WidgetStateColor.resolveWith((states) => - states.contains(WidgetState.selected) - ? Colors.white - : Colors.teal[900]!), - entryModeIconColor: Colors.teal[600], - helpTextStyle: TextStyle(color: Colors.teal[800]), - ), - ), - debugShowCheckedModeBanner: false, - home: SplashScreen(), - routes: { - '/login': (context) => const LoginScreen(), - '/register': (context) => const RegisterScreen(), - '/dashboard': (context) => const DashboardScreen(), - '/video_player': (context) => VideoPlayerScreen( - videoUrl: - ModalRoute.of(context)?.settings.arguments as String? ?? '', - ), - '/image_viewer': (context) => ImageViewerScreen( - imageUrl: - ModalRoute.of(context)?.settings.arguments as String? ?? '', - ), - }, + 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, ); } } + + +// SUPABASE_URL=https://ihuetazxejsioxcjejpj.supabase.co +// SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImlodWV0YXp4ZWpzaW94Y2planBqIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTExMDEyMTgsImV4cCI6MjA2NjY3NzIxOH0.IKFyrKioiScfAS_9UouPEcesHHoas0SDZH0mZBCA1ro + diff --git a/newata2/lib/provider.dart b/newata2/lib/provider.dart new file mode 100644 index 0000000..ef53d09 --- /dev/null +++ b/newata2/lib/provider.dart @@ -0,0 +1,206 @@ +// ================================================================= +// STATE MANAGEMENT (PROVIDER) +// ================================================================= + +import 'package:CCTV_App/main.dart'; +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +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_microseconds': 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.'; + } + } +} diff --git a/newata2/lib/providers/auth_provider.dart b/newata2/lib/providers/auth_provider.dart deleted file mode 100644 index dc30443..0000000 --- a/newata2/lib/providers/auth_provider.dart +++ /dev/null @@ -1,106 +0,0 @@ -// providers/auth_provider.dart (Sudah Lengkap) -import 'package:flutter/foundation.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; - -class AuthProvider extends ChangeNotifier { - final SupabaseClient _supabase = Supabase.instance.client; - - bool _isLoading = false; - bool get isLoading => _isLoading; - - String? _errorMessage; - String? get errorMessage => _errorMessage; - - User? _currentUser; - User? get currentUser => _currentUser; - - AuthProvider() { - print("AuthProvider: Initializing..."); - _currentUser = _supabase.auth.currentUser; - print("AuthProvider: Initial user state: ${_currentUser?.id ?? 'null'}"); - - _supabase.auth.onAuthStateChange.listen((data) { - final AuthChangeEvent event = data.event; - final Session? session = data.session; - print( - "AuthProvider: onAuthStateChange event: $event, session: ${session?.user.id ?? 'null'}"); - _currentUser = session?.user; - _errorMessage = null; - _isLoading = false; - notifyListeners(); - }); - print("AuthProvider: Listener attached."); - } - - void _setState({bool? loading, String? error}) { - bool changed = false; - final prevLoading = _isLoading; - final prevError = _errorMessage; - - if (loading != null && _isLoading != loading) { - _isLoading = loading; - changed = true; - } - if (_errorMessage != error) { - _errorMessage = error; - changed = true; - } - if (changed) { - print( - "AuthProvider: State changed - isLoading: $prevLoading -> $_isLoading, error: '$prevError' -> '$_errorMessage'"); - notifyListeners(); - } - } - - Future signUp(String email, String password) async { - print("AuthProvider: signUp called with email: $email"); - _setState(loading: true, error: null); - try { - final AuthResponse res = - await _supabase.auth.signUp(email: email, password: password); - print( - "AuthProvider: signUp successful for ${res.user?.email}. User: ${res.user?.id}, Session: ${res.session?.accessToken ?? 'null'}"); - _setState(loading: false, error: null); - return true; - } on AuthException catch (e) { - print('AuthProvider: signUp AuthException: ${e.message}'); - _setState(loading: false, error: 'Registrasi gagal: ${e.message}'); - return false; - } catch (e) { - print('AuthProvider: signUp Unknown Error: $e'); - _setState(loading: false, error: 'Terjadi kesalahan tidak diketahui.'); - return false; - } - } - - Future signIn(String email, String password) async { - print("AuthProvider: signIn called with email: $email"); - _setState(loading: true, error: null); - try { - final AuthResponse res = await _supabase.auth - .signInWithPassword(email: email, password: password); - print( - "AuthProvider: signIn successful for ${res.user?.email}. User: ${res.user?.id}, Session: ${res.session?.accessToken ?? 'null'}"); - _setState(loading: false, error: null); - } on AuthException catch (e) { - print('AuthProvider: signIn AuthException: ${e.message}'); - _setState(loading: false, error: 'Login gagal: ${e.message}'); - } catch (e) { - print('AuthProvider: signIn Unknown Error: $e'); - _setState(loading: false, error: 'Terjadi kesalahan tidak diketahui.'); - } - } - - Future signOut() async { - print("AuthProvider: signOut called for user: ${_currentUser?.id}"); - _setState(loading: true, error: null); - try { - await _supabase.auth.signOut(); - print("AuthProvider: signOut successful."); - _setState(loading: false, error: null); - } catch (e) { - print('AuthProvider: signOut Error: $e'); - _setState(loading: false, error: 'Logout gagal: ${e.toString()}'); - } - } -} diff --git a/newata2/lib/providers/pantau_footage_provider.dart b/newata2/lib/providers/pantau_footage_provider.dart deleted file mode 100644 index 7e37f08..0000000 --- a/newata2/lib/providers/pantau_footage_provider.dart +++ /dev/null @@ -1,71 +0,0 @@ -// providers/footage_provider.dart (Sudah Lengkap) -import 'package:flutter/foundation.dart'; - -import '../constant.dart'; -import '../main.dart'; - -class FootageProvider extends ChangeNotifier { - final String? userId; - FootageProvider(this.userId) { - print("FootageProvider: Initialized with userId: $userId"); - } - - List> _mediaItems = []; - List> get mediaItems => _mediaItems; - - bool _isLoading = false; - bool get isLoading => _isLoading; - - String? _errorMessage; - String? get errorMessage => _errorMessage; - - Future fetchMediaItems() async { - print("FootageProvider: fetchMediaItems called for userId: $userId"); - if (userId == null) { - print("FootageProvider: fetchMediaItems aborted, userId is null."); - _errorMessage = "User tidak terautentikasi."; - notifyListeners(); - return; - } - - _isLoading = true; - _errorMessage = null; - notifyListeners(); - - try { - final response = await supabase - .from('videos') - .select( - 'id, user_id, storage_object_path, uploaded_at, media_type, metadata') - .eq('user_id', userId!) - .order('uploaded_at', ascending: false); - - _mediaItems = List>.from(response as List); - print( - "FootageProvider: fetchMediaItems successful, found ${_mediaItems.length} items."); - } catch (e) { - print('FootageProvider: Error fetching media items: $e'); - _errorMessage = 'Gagal memuat daftar media: ${e.toString()}'; - _mediaItems = []; - } finally { - _isLoading = false; - notifyListeners(); - } - } - - Future getMediaUrl(String storagePath) async { - print("FootageProvider: getMediaUrl called for path: $storagePath"); - try { - final signedUrlResponse = await supabase.storage - .from(AppConstants.footageBucket) - .createSignedUrl(storagePath, 60 * 5); // Expire dalam 5 menit - print("FootageProvider: Signed URL generated successfully."); - return signedUrlResponse; - } catch (e) { - print('FootageProvider: Error getting media URL: $e'); - _errorMessage = 'Gagal mendapatkan URL media.'; - notifyListeners(); - return null; - } - } -} diff --git a/newata2/lib/providers/schedule_provider.dart b/newata2/lib/providers/schedule_provider.dart deleted file mode 100644 index 3b707d5..0000000 --- a/newata2/lib/providers/schedule_provider.dart +++ /dev/null @@ -1,120 +0,0 @@ -// providers/schedule_provider.dart (Sudah Lengkap) -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; - -import '../main.dart'; - -class ScheduleProvider extends ChangeNotifier { - final String? userId; - ScheduleProvider(this.userId) { - print("ScheduleProvider: Initialized with userId: $userId"); - } - - TimeOfDay? _startTime; - TimeOfDay? get startTime => _startTime; - - TimeOfDay? _endTime; - TimeOfDay? get endTime => _endTime; - - bool _isLoading = false; - bool get isLoading => _isLoading; - - String? _errorMessage; - String? get errorMessage => _errorMessage; - - final _dbTimeFormat = DateFormat('HH:mm:ss'); - - Future fetchSchedule() async { - print("ScheduleProvider: fetchSchedule called for userId: $userId"); - if (userId == null) { - print("ScheduleProvider: fetchSchedule aborted, userId is null."); - _errorMessage = "User tidak terautentikasi."; - notifyListeners(); - return; - } - _isLoading = true; - _errorMessage = null; - notifyListeners(); - - try { - final response = await supabase - .from('device_schedule') - .select('inactive_start_time, inactive_end_time') - .eq('user_id', userId!) - .maybeSingle(); - - if (response != null) { - final data = response; - final startTimeString = data['inactive_start_time'] as String?; - final endTimeString = data['inactive_end_time'] as String?; - - _startTime = (startTimeString != null) - ? TimeOfDay.fromDateTime(_dbTimeFormat.parse(startTimeString)) - : null; - _endTime = (endTimeString != null) - ? TimeOfDay.fromDateTime(_dbTimeFormat.parse(endTimeString)) - : null; - print( - "ScheduleProvider: fetchSchedule successful. Start: $_startTime, End: $_endTime"); - } else { - _startTime = null; - _endTime = null; - print("ScheduleProvider: fetchSchedule - No schedule found."); - } - } catch (e) { - print('ScheduleProvider: Error fetching schedule: $e'); - _errorMessage = 'Gagal memuat jadwal: ${e.toString()}'; - _startTime = null; - _endTime = null; - } finally { - _isLoading = false; - notifyListeners(); - } - } - - Future saveSchedule( - TimeOfDay newStartTime, TimeOfDay newEndTime) async { - print( - "ScheduleProvider: saveSchedule called. Start: $newStartTime, End: $newEndTime"); - if (userId == null) { - print("ScheduleProvider: saveSchedule aborted, userId is null."); - _errorMessage = "User tidak terautentikasi."; - notifyListeners(); - return false; - } - _isLoading = true; - _errorMessage = null; - notifyListeners(); - - try { - final now = DateTime.now(); - final startDateTime = DateTime( - now.year, now.month, now.day, newStartTime.hour, newStartTime.minute); - final endDateTime = DateTime( - now.year, now.month, now.day, newEndTime.hour, newEndTime.minute); - final startTimeString = _dbTimeFormat.format(startDateTime); - final endTimeString = _dbTimeFormat.format(endDateTime); - - await supabase.from('device_schedule').upsert({ - 'user_id': userId!, - 'inactive_start_time': startTimeString, - 'inactive_end_time': endTimeString, - 'updated_at': DateTime.now().toIso8601String(), - }, onConflict: 'user_id'); - - _startTime = newStartTime; - _endTime = newEndTime; - _isLoading = false; - print("ScheduleProvider: saveSchedule successful."); - notifyListeners(); - return true; - } catch (e) { - print('ScheduleProvider: Error saving schedule: $e'); - _errorMessage = 'Gagal menyimpan jadwal: ${e.toString()}'; - _isLoading = false; - notifyListeners(); - return false; - } - } -} diff --git a/newata2/lib/register_page.dart b/newata2/lib/register_page.dart new file mode 100644 index 0000000..2e77f93 --- /dev/null +++ b/newata2/lib/register_page.dart @@ -0,0 +1,124 @@ +import 'package:CCTV_App/main.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +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)), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/newata2/lib/screens/control_screen.dart b/newata2/lib/screens/control_screen.dart deleted file mode 100644 index 125b136..0000000 --- a/newata2/lib/screens/control_screen.dart +++ /dev/null @@ -1,722 +0,0 @@ -// screens/control_screen.dart (Sudah Lengkap) -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../providers/schedule_provider.dart'; // Pastikan path benar - -class ControlScreen extends StatefulWidget { - const ControlScreen({super.key}); - - @override - _ControlScreenState createState() => _ControlScreenState(); -} - -class _ControlScreenState extends State { - // Hapus TextEditingController dan FormKey - // final _startTimeController = TextEditingController(); - // final _endTimeController = TextEditingController(); - // final _formKey = GlobalKey(); - - // Gunakan state lokal untuk menyimpan waktu yang DIPILIH user (sebelum disimpan) - TimeOfDay? _selectedStartTime; - TimeOfDay? _selectedEndTime; - - // State lokal untuk menyimpan waktu dari provider (untuk tampilan jadwal saat ini) - TimeOfDay? _fetchedStartTime; - TimeOfDay? _fetchedEndTime; - - @override - void initState() { - super.initState(); - print("ControlScreen: initState called."); - WidgetsBinding.instance.addPostFrameCallback((_) { - print("ControlScreen: Requesting fetchSchedule."); - final provider = context.read(); - provider.fetchSchedule().then((_) { - if (mounted) { - print( - "ControlScreen: fetchSchedule completed. Updating local state."); - // Set state lokal dari provider saat pertama kali load - setState(() { - _fetchedStartTime = provider.startTime; - _fetchedEndTime = provider.endTime; - // Juga set waktu terpilih awal agar tombol menampilkan nilai yang ada - _selectedStartTime = provider.startTime; - _selectedEndTime = provider.endTime; - }); - } - }); - }); - } - - // Hapus dispose untuk controller - // @override - // void dispose() { - // _startTimeController.dispose(); - // _endTimeController.dispose(); - // super.dispose(); - // } - - // Fungsi untuk menampilkan Time Picker - Future _selectTime(BuildContext context, bool isStartTime) async { - print( - "ControlScreen: Opening time picker for ${isStartTime ? 'start' : 'end'} time."); - // Tentukan waktu awal untuk picker - final TimeOfDay initialTime = isStartTime - ? (_selectedStartTime ?? _fetchedStartTime ?? TimeOfDay.now()) - : (_selectedEndTime ?? - _fetchedEndTime ?? - TimeOfDay( - hour: (TimeOfDay.now().hour + 1) % 24, - minute: TimeOfDay.now().minute)); // Default end time +1 jam - - final TimeOfDay? picked = await showTimePicker( - context: context, - initialTime: initialTime, - helpText: isStartTime ? 'PILIH WAKTU MULAI' : 'PILIH WAKTU SELESAI', - // Gunakan tema dari MaterialApp - // builder: (context, child) { - // return Theme( - // data: Theme.of(context), // Menggunakan tema utama - // child: child!, - // ); - // }, - ); - print("ControlScreen: Time picker closed. Picked: $picked"); - - if (picked != null) { - setState(() { - if (isStartTime) { - _selectedStartTime = picked; - } else { - _selectedEndTime = picked; - } - }); - } - } - - // Format TimeOfDay ke string HH:mm (24 jam) - String _formatTimeOfDay(TimeOfDay? time) { - if (time == null) return 'Belum diatur'; - final hour = time.hour.toString().padLeft(2, '0'); - final minute = time.minute.toString().padLeft(2, '0'); - return '$hour:$minute'; - } - - // Fungsi untuk menyimpan jadwal - Future _saveNewSchedule() async { - print("ControlScreen: Save button pressed."); - // Validasi sederhana: pastikan kedua waktu sudah dipilih - if (_selectedStartTime == null || _selectedEndTime == null) { - print("ControlScreen: Save aborted, start or end time not selected."); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Harap pilih waktu mulai dan selesai.'), - backgroundColor: Colors.orangeAccent), - ); - return; - } - - // Validasi tambahan (opsional): cek apakah waktu mulai sebelum waktu selesai - final startTimeMinutes = - _selectedStartTime!.hour * 60 + _selectedStartTime!.minute; - final endTimeMinutes = - _selectedEndTime!.hour * 60 + _selectedEndTime!.minute; - if (startTimeMinutes >= endTimeMinutes) { - print("ControlScreen: Save aborted, start time is not before end time."); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Waktu mulai harus sebelum waktu selesai.'), - backgroundColor: Colors.orangeAccent), - ); - return; - } - - final provider = context.read(); - final success = - await provider.saveSchedule(_selectedStartTime!, _selectedEndTime!); - print("ControlScreen: saveSchedule result: $success"); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(success - ? 'Jadwal berhasil disimpan!' - : provider.errorMessage ?? 'Gagal menyimpan jadwal.'), - backgroundColor: success ? Colors.green[600] : Colors.redAccent, - behavior: SnackBarBehavior.floating, - ), - ); - if (success) { - // Update state fetched time setelah berhasil simpan - setState(() { - _fetchedStartTime = _selectedStartTime; - _fetchedEndTime = _selectedEndTime; - }); - } - } - } - - @override - Widget build(BuildContext context) { - print("ControlScreen: Building UI."); - return Scaffold( - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Consumer( - builder: (context, provider, child) { - // Sinkronisasi state lokal jika provider berubah (misal setelah fetch awal) - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && - (_fetchedStartTime != provider.startTime || - _fetchedEndTime != provider.endTime)) { - print( - "ControlScreen Consumer: Provider state changed (fetch), updating local state."); - setState(() { - _fetchedStartTime = provider.startTime; - _fetchedEndTime = provider.endTime; - // Set juga selected time jika belum pernah dipilih user - _selectedStartTime ??= provider.startTime; - _selectedEndTime ??= provider.endTime; - }); - } - }); - - print( - "ControlScreen Consumer: Building content. isLoading: ${provider.isLoading}, error: ${provider.errorMessage}"); - - // Logika tampilan jadwal saat ini (sama seperti sebelumnya) - String currentScheduleDisplay; - bool isScheduleSet = - _fetchedStartTime != null && _fetchedEndTime != null; - bool hasSchedulePassedToday = false; - if (isScheduleSet) { - final now = TimeOfDay.now(); - final nowInMinutes = now.hour * 60 + now.minute; - final endTimeInMinutes = - _fetchedEndTime!.hour * 60 + _fetchedEndTime!.minute; - if (nowInMinutes > endTimeInMinutes) { - hasSchedulePassedToday = true; - } - } - if (!isScheduleSet || (isScheduleSet && hasSchedulePassedToday)) { - currentScheduleDisplay = - "Belum diatur atau jadwal hari ini telah lewat."; - } else { - currentScheduleDisplay = - '${_formatTimeOfDay(_fetchedStartTime)} - ${_formatTimeOfDay(_fetchedEndTime)}'; - } - - return RefreshIndicator( - onRefresh: () async { - print("ControlScreen: Refresh requested."); - await provider.fetchSchedule(); - // Update state lokal setelah refresh - setState(() { - _fetchedStartTime = provider.startTime; - _fetchedEndTime = provider.endTime; - _selectedStartTime = - provider.startTime; // Reset pilihan user ke data terbaru - _selectedEndTime = provider.endTime; - }); - }, - color: Colors.teal[600]!, - child: SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - // Hapus Form widget - // child: Form( - // key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'Jadwal Nonaktif CCTV', - style: Theme.of(context) - .textTheme - .headlineSmall - ?.copyWith( - fontWeight: FontWeight.bold, - color: Colors.teal[800]), - ), - const SizedBox(height: 8), - Text( - 'Atur rentang waktu kapan perangkat CCTV tidak akan merekam atau mengirim data.', - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: Colors.grey[700]), - ), - const SizedBox(height: 25), - - // Tampilkan jadwal saat ini - Card( - /* ... (UI Card sama, menampilkan currentScheduleDisplay) ... */ - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Row(/* ... (Header Card sama) ... */), - const SizedBox(height: 10), - if (provider.isLoading && !isScheduleSet) - const Padding( - padding: EdgeInsets.symmetric(vertical: 10.0), - child: Center( - child: CircularProgressIndicator( - strokeWidth: 3, color: Colors.teal)), - ) - else if (provider.errorMessage != null && - !isScheduleSet) - Text(provider.errorMessage!, - style: - const TextStyle(color: Colors.redAccent)) - else - Text( - currentScheduleDisplay, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: (isScheduleSet && - !hasSchedulePassedToday) - ? Colors.black87 - : Colors.grey[600]), - ), - ], - ), - ), - ), - const SizedBox(height: 25), - - // Tombol untuk memilih waktu (kembali ke OutlinedButton) - Text( - 'Setel Jadwal Baru:', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, color: Colors.teal[700]), - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - icon: const Icon(Icons.timer_outlined, size: 18), - label: Text(_selectedStartTime != null - ? _formatTimeOfDay(_selectedStartTime) - : 'Pilih Mulai'), - onPressed: () => _selectTime( - context, true), // Panggil _selectTime - style: OutlinedButton.styleFrom( - foregroundColor: Colors.teal, - side: BorderSide(color: Colors.teal[200]!), - padding: - const EdgeInsets.symmetric(vertical: 12)), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Text("-", - style: TextStyle( - fontSize: 24, color: Colors.grey[600])), - ), - Expanded( - child: OutlinedButton.icon( - icon: - const Icon(Icons.timer_off_outlined, size: 18), - label: Text(_selectedEndTime != null - ? _formatTimeOfDay(_selectedEndTime) - : 'Pilih Selesai'), - onPressed: () => _selectTime( - context, false), // Panggil _selectTime - style: OutlinedButton.styleFrom( - foregroundColor: Colors.teal, - side: BorderSide(color: Colors.teal[200]!), - padding: - const EdgeInsets.symmetric(vertical: 12)), - ), - ), - ], - ), - const SizedBox(height: 30), - - // Tombol Simpan - Center( - child: provider.isLoading - ? CircularProgressIndicator(color: Colors.teal[600]) - : ElevatedButton.icon( - icon: - const Icon(Icons.save_alt_outlined, size: 20), - label: const Text('Simpan Jadwal'), - onPressed: (_selectedStartTime == null || - _selectedEndTime == null) - ? null // Disable jika waktu belum dipilih - : _saveNewSchedule, // Panggil fungsi simpan - ), - ), - // Tampilkan error saving jika ada - if (provider.errorMessage != null && - !provider.isLoading && - (_selectedStartTime != null || - _selectedEndTime != - null)) // Tampilkan jika ada error DAN user sudah mencoba memilih - Padding( - padding: const EdgeInsets.only(top: 15.0), - child: Center( - child: Text(provider.errorMessage!, - style: const TextStyle(color: Colors.redAccent), - textAlign: TextAlign.center), - ), - ), - const SizedBox(height: 20), - ], - ), - // ), // Tutup Form (dihapus) - ), - ); - }, - ), - ), - ); - } -} - -// class ControlScreen extends StatefulWidget { -// const ControlScreen({super.key}); - -// @override -// _ControlScreenState createState() => _ControlScreenState(); -// } - -// class _ControlScreenState extends State { -// TimeOfDay? _selectedStartTime; -// TimeOfDay? _selectedEndTime; - -// @override -// void initState() { -// super.initState(); -// print("ControlScreen: initState called."); -// WidgetsBinding.instance.addPostFrameCallback((_) { -// print("ControlScreen: Requesting fetchSchedule."); -// final provider = context.read(); -// provider.fetchSchedule().then((_) { -// if (mounted) { -// print( -// "ControlScreen: fetchSchedule completed. Updating local state."); -// setState(() { -// _selectedStartTime = provider.startTime; -// _selectedEndTime = provider.endTime; -// }); -// } -// }); -// }); -// } - -// Future _selectTime( -// BuildContext context, TimeOfDay? initialTime) async { -// print("ControlScreen: Opening time picker."); -// final TimeOfDay? picked = await showTimePicker( -// context: context, -// initialTime: initialTime ?? TimeOfDay.now(), -// helpText: 'PILIH WAKTU', // Ubah teks helper -// builder: (context, child) { -// // Gunakan tema dari MaterialApp -// return child!; -// // Atau override tema spesifik di sini jika perlu -// // return Theme( -// // data: Theme.of(context).copyWith( -// // timePickerTheme: TimePickerThemeData(...) // Override spesifik -// // ), -// // child: child!, -// // ); -// }, -// ); -// print("ControlScreen: Time picker closed. Picked: $picked"); -// return picked; -// } - -// String _formatTimeOfDay(TimeOfDay? time) { -// if (time == null) return 'Belum diatur'; -// final now = DateTime.now(); -// final dt = DateTime(now.year, now.month, now.day, time.hour, time.minute); -// return DateFormat('HH:mm', 'id_ID') -// .format(dt); // Gunakan format 24 jam agar lebih jelas -// } - -// @override -// Widget build(BuildContext context) { -// print("ControlScreen: Building UI."); -// return Scaffold( -// // AppBar tidak perlu karena sudah ada di Dashboard -// body: Padding( -// padding: const EdgeInsets.all(16.0), -// child: Consumer( -// builder: (context, provider, child) { -// // Sinkronisasi state lokal -// WidgetsBinding.instance.addPostFrameCallback((_) { -// if (mounted && -// (_selectedStartTime != provider.startTime || -// _selectedEndTime != provider.endTime)) { -// print( -// "ControlScreen Consumer: Provider state changed, updating local state."); -// setState(() { -// _selectedStartTime = provider.startTime; -// _selectedEndTime = provider.endTime; -// }); -// } -// }); - -// print( -// "ControlScreen Consumer: Building content. isLoading: ${provider.isLoading}, error: ${provider.errorMessage}"); - -// return RefreshIndicator( -// // Tambahkan RefreshIndicator -// onRefresh: () async { -// print("ControlScreen: Refresh requested."); -// await provider.fetchSchedule(); -// }, -// color: Colors.teal, -// child: SingleChildScrollView( -// physics: -// const AlwaysScrollableScrollPhysics(), // Agar bisa refresh walau konten pendek -// child: Column( -// crossAxisAlignment: -// CrossAxisAlignment.stretch, // Buat elemen memenuhi lebar -// children: [ -// Text( -// 'Jadwal Nonaktif CCTV', -// style: Theme.of(context) -// .textTheme -// .headlineSmall -// ?.copyWith( -// fontWeight: FontWeight.bold, -// color: Colors.teal[800]), -// ), -// const SizedBox(height: 8), -// Text( -// 'Atur rentang waktu kapan perangkat CCTV tidak akan merekam atau mengirim data.', -// style: Theme.of(context) -// .textTheme -// .bodyMedium -// ?.copyWith(color: Colors.grey[700]), -// ), -// const SizedBox(height: 25), - -// // Tampilkan jadwal saat ini -// Card( -// child: Padding( -// padding: const EdgeInsets.all(16.0), -// child: Column( -// crossAxisAlignment: CrossAxisAlignment.start, -// children: [ -// Row( -// // Tambahkan ikon dan tombol refresh -// mainAxisAlignment: MainAxisAlignment.spaceBetween, -// children: [ -// Text( -// 'Jadwal Saat Ini:', -// style: Theme.of(context) -// .textTheme -// .titleMedium -// ?.copyWith( -// fontWeight: FontWeight.w600, -// color: Colors.teal[700]), -// ), -// // Tombol refresh kecil -// IconButton( -// icon: Icon(Icons.refresh, -// color: Colors.grey[500]), -// tooltip: 'Muat Ulang Jadwal', -// iconSize: 20, -// onPressed: provider.isLoading -// ? null -// : () async { -// print( -// "ControlScreen: Inline refresh pressed."); -// await provider.fetchSchedule(); -// }, -// ) -// ], -// ), -// const SizedBox(height: 10), -// if (provider.isLoading && -// _selectedStartTime == null) -// const Padding( -// padding: EdgeInsets.symmetric(vertical: 10.0), -// child: Center( -// child: CircularProgressIndicator( -// strokeWidth: 3, color: Colors.teal)), -// ) -// else if (provider.errorMessage != null && -// _selectedStartTime == null) -// Text(provider.errorMessage!, -// style: -// const TextStyle(color: Colors.redAccent)) -// else -// Row( -// mainAxisAlignment: -// MainAxisAlignment.spaceAround, // Beri jarak -// children: [ -// _buildTimeDisplay( -// 'Mulai Nonaktif', _selectedStartTime), -// Icon(Icons.arrow_forward, -// color: Colors -// .grey[400]), // Panah penanda rentang -// _buildTimeDisplay( -// 'Selesai Nonaktif', _selectedEndTime), -// ], -// ), -// ], -// ), -// ), -// ), -// const SizedBox(height: 25), - -// // Tombol untuk memilih waktu -// Text( -// 'Setel Jadwal Baru:', -// style: Theme.of(context).textTheme.titleMedium?.copyWith( -// fontWeight: FontWeight.w600, color: Colors.teal[700]), -// ), -// const SizedBox(height: 10), -// Row( -// children: [ -// Expanded( -// child: OutlinedButton.icon( -// icon: const Icon(Icons.timer_outlined, -// size: 18), // Kecilkan ikon -// label: Text(_selectedStartTime != null -// ? _formatTimeOfDay(_selectedStartTime) -// : 'Pilih Mulai'), -// onPressed: () async { -// final time = await _selectTime( -// context, _selectedStartTime); -// if (time != null) { -// setState(() { -// _selectedStartTime = time; -// }); -// } -// }, -// style: OutlinedButton.styleFrom( -// foregroundColor: Colors.teal, -// side: BorderSide(color: Colors.teal[200]!), -// padding: const EdgeInsets.symmetric( -// vertical: 12) // Sesuaikan padding -// ), -// ), -// ), -// const SizedBox(width: 10), -// Expanded( -// child: OutlinedButton.icon( -// icon: const Icon(Icons.timer_off_outlined, -// size: 18), // Kecilkan ikon -// label: Text(_selectedEndTime != null -// ? _formatTimeOfDay(_selectedEndTime) -// : 'Pilih Selesai'), -// onPressed: () async { -// final time = -// await _selectTime(context, _selectedEndTime); -// if (time != null) { -// setState(() { -// _selectedEndTime = time; -// }); -// } -// }, -// style: OutlinedButton.styleFrom( -// foregroundColor: Colors.teal, -// side: BorderSide(color: Colors.teal[200]!), -// padding: const EdgeInsets.symmetric( -// vertical: 12) // Sesuaikan padding -// ), -// ), -// ), -// ], -// ), -// const SizedBox( -// height: 30), // Beri jarak lebih sebelum tombol simpan - -// // Tombol Simpan -// Center( -// child: provider.isLoading -// ? const CircularProgressIndicator(color: Colors.teal) -// : ElevatedButton.icon( -// icon: -// const Icon(Icons.save_alt_outlined, size: 20), -// label: const Text('Simpan Jadwal', -// style: TextStyle(fontSize: 16)), -// style: ElevatedButton.styleFrom( -// padding: const EdgeInsets.symmetric( -// vertical: 14, -// horizontal: -// 30)), // Buat tombol lebih besar -// onPressed: (_selectedStartTime == null || -// _selectedEndTime == null) -// ? null // Disable jika waktu belum dipilih -// : () async { -// print( -// "ControlScreen: Save button pressed."); -// final success = await context -// .read() -// .saveSchedule(_selectedStartTime!, -// _selectedEndTime!); -// print( -// "ControlScreen: saveSchedule result: $success"); - -// if (mounted) { -// ScaffoldMessenger.of(context) -// .showSnackBar( -// SnackBar( -// content: Text(success -// ? 'Jadwal berhasil disimpan!' -// : context -// .read< -// ScheduleProvider>() -// .errorMessage ?? -// 'Gagal menyimpan jadwal.'), -// backgroundColor: success -// ? Colors.green[600] -// : Colors.redAccent, -// behavior: SnackBarBehavior -// .floating, // SnackBar mengambang -// ), -// ); -// } -// }, -// ), -// ), -// // Tampilkan error saving jika ada -// if (provider.errorMessage != null && - // !provider.isLoading && -// _selectedStartTime != -// null) // Tampilkan hanya jika user mencoba menyimpan -// Padding( -// padding: const EdgeInsets.only(top: 15.0), -// child: Center( -// child: Text(provider.errorMessage!, -// style: const TextStyle(color: Colors.redAccent), -// textAlign: TextAlign.center), -// ), -// ), -// const SizedBox(height: 20), // Padding bawah -// ], -// ), -// ), -// ); -// }, -// ), -// ), -// ); -// } - -// // Helper widget untuk menampilkan waktu -// Widget _buildTimeDisplay(String label, TimeOfDay? time) { -// return Column( -// crossAxisAlignment: CrossAxisAlignment.center, -// children: [ -// Text(label, style: TextStyle(color: Colors.grey[600], fontSize: 12)), -// const SizedBox(height: 2), -// Text( -// _formatTimeOfDay(time), -// style: const TextStyle( -// fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black87), -// ), -// ], -// ); -// } -// } diff --git a/newata2/lib/screens/dashboard_screen.dart b/newata2/lib/screens/dashboard_screen.dart deleted file mode 100644 index 9305596..0000000 --- a/newata2/lib/screens/dashboard_screen.dart +++ /dev/null @@ -1,82 +0,0 @@ -// screens/dashboard_screen.dart (Sudah Lengkap) -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../providers/auth_provider.dart'; // Pastikan path benar -import 'footage_screen.dart'; // Pastikan path benar -import 'control_screen.dart'; // Pastikan path benar - -class DashboardScreen extends StatefulWidget { - const DashboardScreen({super.key}); - - @override - _DashboardScreenState createState() => _DashboardScreenState(); -} - -class _DashboardScreenState extends State { - int _selectedIndex = 0; - - static final List _widgetOptions = [ - const FootageScreen(), - const ControlScreen(), - ]; - - void _onItemTapped(int index) { - print("DashboardScreen: Tab tapped, index: $index"); - setState(() { - _selectedIndex = index; - }); - } - - @override - Widget build(BuildContext context) { - print("DashboardScreen: Building UI."); - // Gunakan watch agar UI rebuild jika user berubah (meski tidak mungkin di screen ini) - final userIdentifier = - context.watch().currentUser?.email ?? 'Pengguna'; - print("DashboardScreen: Current user identifier: $userIdentifier"); - - return Scaffold( - appBar: AppBar( - title: Text('Halo, $userIdentifier!'), - actions: [ - IconButton( - icon: const Icon(Icons.logout), - tooltip: 'Keluar', - onPressed: () async { - print("DashboardScreen: Logout button pressed."); - await context.read().signOut(); - if (mounted && context.read().currentUser == null) { - print( - "DashboardScreen: User is null after sign out, navigating to /login"); - Navigator.pushNamedAndRemoveUntil( - context, '/login', (route) => false); - } - }, - ), - ], - ), - body: IndexedStack( - // Pertahankan state tiap halaman saat ganti tab - index: _selectedIndex, - children: _widgetOptions, - ), - bottomNavigationBar: BottomNavigationBar( - type: BottomNavigationBarType.fixed, // Tipe agar label selalu terlihat - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.video_library_outlined), - activeIcon: Icon(Icons.video_library), - label: 'Footage', - ), - BottomNavigationBarItem( - icon: Icon(Icons.settings_remote_outlined), - activeIcon: Icon(Icons.settings_remote), - label: 'Kontrol Alat', - ), - ], - currentIndex: _selectedIndex, - onTap: _onItemTapped, - ), - ); - } -} diff --git a/newata2/lib/screens/footage_screen.dart b/newata2/lib/screens/footage_screen.dart deleted file mode 100644 index fccf24b..0000000 --- a/newata2/lib/screens/footage_screen.dart +++ /dev/null @@ -1,404 +0,0 @@ -// screens/footage_screen.dart (Sudah Lengkap) -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:intl/intl.dart'; -import '../providers/pantau_footage_provider.dart'; // Pastikan path benar - -// class FootageScreen extends StatefulWidget { -// const FootageScreen({super.key}); - -// @override -// _FootageScreenState createState() => _FootageScreenState(); -// } - -// class _FootageScreenState extends State { -// @override -// void initState() { -// super.initState(); -// print("FootageScreen: initState called."); -// WidgetsBinding.instance.addPostFrameCallback((_) { -// print("FootageScreen: Requesting fetchMediaItems."); -// context.read().fetchMediaItems(); -// }); -// } - -// Future _refreshMediaItems() async { -// print("FootageScreen: Refresh requested."); -// await context.read().fetchMediaItems(); -// } - -// void _viewMedia(BuildContext context, Map mediaItem) async { -// final storagePath = mediaItem['storage_object_path'] as String?; -// final mediaType = mediaItem['media_type'] as String?; - -// if (storagePath == null) { -// print("FootageScreen: View media failed, storage path is null."); -// ScaffoldMessenger.of(context).showSnackBar( -// const SnackBar( -// content: Text('Path media tidak valid.'), -// backgroundColor: Colors.orangeAccent), -// ); -// return; -// } -// print( -// "FootageScreen: Viewing media type '$mediaType' at path '$storagePath'"); - -// showDialog( -// context: context, -// barrierDismissible: false, -// builder: (BuildContext context) => const Dialog( -// child: Padding( -// padding: EdgeInsets.all(20.0), -// child: Row(mainAxisSize: MainAxisSize.min, children: [ -// CircularProgressIndicator(color: Colors.teal), -// SizedBox(width: 20), -// Text("Memuat media...") -// ])), -// ), -// ); - -// final url = await context.read().getMediaUrl(storagePath); -// if (mounted) Navigator.pop(context); // Tutup dialog loading - -// if (url != null && mounted) { -// print("FootageScreen: URL obtained: $url"); -// if (mediaType == 'video') { -// print("FootageScreen: Navigating to /video_player"); -// Navigator.pushNamed(context, '/video_player', arguments: url); -// } else if (mediaType == 'image') { -// print("FootageScreen: Navigating to /image_viewer"); -// Navigator.pushNamed(context, '/image_viewer', arguments: url); -// } else { -// print("FootageScreen: Unknown media type '$mediaType', cannot open."); -// ScaffoldMessenger.of(context).showSnackBar( -// SnackBar( -// content: Text('Tipe media tidak dikenali: $mediaType'), -// backgroundColor: Colors.orangeAccent), -// ); -// } -// } else if (mounted) { -// print("FootageScreen: Failed to get URL."); -// ScaffoldMessenger.of(context).showSnackBar( -// SnackBar( -// content: Text(context.read().errorMessage ?? -// 'Gagal memuat URL media.'), -// backgroundColor: Colors.redAccent), -// ); -// } -// } - -// @override -// Widget build(BuildContext context) { -// print("FootageScreen: Building UI."); -// final dateTimeFormat = -// DateFormat('EEEE, dd MMMM yyyy HH:mm', 'id_ID'); // Tambah tahun - -// return Scaffold( -// // AppBar tidak perlu karena sudah ada di Dashboard -// body: Consumer( -// builder: (context, provider, child) { -// print( -// "FootageScreen Consumer: Building list. isLoading: ${provider.isLoading}, itemCount: ${provider.mediaItems.length}, error: ${provider.errorMessage}"); - -// if (provider.isLoading && provider.mediaItems.isEmpty) { -// return const Center( -// child: CircularProgressIndicator(color: Colors.teal)); -// } - -// if (provider.errorMessage != null) { -// return Center( -// child: Padding( -// padding: const EdgeInsets.all(16.0), -// child: Column( -// mainAxisAlignment: MainAxisAlignment.center, -// children: [ -// const Icon(Icons.error_outline, -// color: Colors.redAccent, size: 50), -// const SizedBox(height: 10), -// Text('Oops! Terjadi Kesalahan', -// style: Theme.of(context).textTheme.titleLarge, -// textAlign: TextAlign.center), -// const SizedBox(height: 5), -// Text(provider.errorMessage!, -// textAlign: TextAlign.center, -// style: TextStyle(color: Colors.grey[700])), -// const SizedBox(height: 20), -// ElevatedButton.icon( -// icon: const Icon(Icons.refresh), -// label: const Text('Coba Lagi'), -// onPressed: _refreshMediaItems) -// ], -// ), -// ), -// ); -// } - -// if (provider.mediaItems.isEmpty && !provider.isLoading) { -// return Center( -// child: Column( -// mainAxisAlignment: MainAxisAlignment.center, -// children: [ -// Icon(Icons.videocam_off_outlined, -// size: 60, color: Colors.grey[400]), -// const SizedBox(height: 10), -// Text('Belum ada rekaman media', -// style: Theme.of(context) -// .textTheme -// .titleMedium -// ?.copyWith(color: Colors.grey[600])), -// const SizedBox(height: 20), -// ElevatedButton.icon( -// icon: const Icon(Icons.refresh), -// label: const Text('Refresh'), -// onPressed: _refreshMediaItems, -// style: ElevatedButton.styleFrom( -// backgroundColor: Colors.grey[300], -// foregroundColor: Colors.grey[800])) -// ], -// )); -// } - -// // Tampilkan list media -// return RefreshIndicator( -// onRefresh: _refreshMediaItems, -// color: Colors.teal, -// child: ListView.builder( -// padding: const EdgeInsets.all(8.0), -// itemCount: provider.mediaItems.length, -// itemBuilder: (context, index) { -// final mediaItem = provider.mediaItems[index]; -// final storagePath = mediaItem['storage_object_path'] as String?; -// final uploadedAtString = mediaItem['uploaded_at'] as String?; -// final mediaType = mediaItem['media_type'] as String?; -// DateTime? uploadedAt = (uploadedAtString != null) -// ? DateTime.tryParse(uploadedAtString)?.toLocal() -// : null; -// final fileName = -// storagePath?.split('/').last ?? 'Media Tidak Dikenal'; - -// IconData leadingIconData = Icons.perm_media_outlined; -// Color iconColor = Colors.teal[700]!; -// if (mediaType == 'video') { -// leadingIconData = Icons.play_circle_outline; -// } else if (mediaType == 'image') { -// leadingIconData = Icons.image_outlined; -// iconColor = Colors.indigo[700]!; -// } - -// return Card( -// clipBehavior: Clip -// .antiAlias, // Agar ripple effect tidak keluar batas card -// child: ListTile( -// leading: CircleAvatar( -// backgroundColor: iconColor.withOpacity(0.1), -// child: Icon(leadingIconData, color: iconColor), -// ), -// title: Text( -// fileName, -// style: const TextStyle(fontWeight: FontWeight.w500), -// maxLines: 1, -// overflow: TextOverflow.ellipsis, -// ), -// subtitle: Text( -// uploadedAt != null -// ? dateTimeFormat.format(uploadedAt) -// : 'Tanggal tidak diketahui', -// style: TextStyle(color: Colors.grey[600], fontSize: 12), -// ), -// trailing: -// Icon(Icons.chevron_right, color: Colors.grey[400]), -// onTap: () => _viewMedia(context, mediaItem), -// ), -// ); -// }, -// ), -// ); -// }, -// ), -// ); -// } -// } - -class FootageScreen extends StatefulWidget { - const FootageScreen({super.key}); - - @override - _FootageScreenState createState() => _FootageScreenState(); -} - -class _FootageScreenState extends State { - @override - void initState() { - super.initState(); - print("FootageScreen: initState called."); - WidgetsBinding.instance.addPostFrameCallback((_) { - print("FootageScreen: Requesting fetchMediaItems."); - context.read().fetchMediaItems(); - }); - } - - Future _refreshMediaItems() async { - print("FootageScreen: Refresh requested."); - await context.read().fetchMediaItems(); - } - - void _viewMedia(BuildContext context, Map mediaItem) async { - final storagePath = mediaItem['storage_object_path'] as String?; - final mediaType = mediaItem['media_type'] as String?; - - if (storagePath == null) { - print("FootageScreen: View media failed, storage path is null."); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Path media tidak valid.'), - backgroundColor: Colors.orangeAccent), - ); - return; - } - print( - "FootageScreen: Viewing media type '$mediaType' at path '$storagePath'"); - - showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext context) => Dialog( - backgroundColor: Colors.white, // Background dialog - child: Padding( - padding: - const EdgeInsets.symmetric(vertical: 24.0, horizontal: 20.0), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - CircularProgressIndicator(color: Colors.teal[600]), - const SizedBox(width: 24), - const Text("Memuat media...", style: TextStyle(fontSize: 16)), - ]), - )), - ); - - final url = await context.read().getMediaUrl(storagePath); - if (mounted) Navigator.pop(context); - - if (url != null && mounted) { - /* ... (logika navigasi sama) ... */ - } else if (mounted) {/* ... (logika error sama) ... */} - } - - @override - Widget build(BuildContext context) { - print("FootageScreen: Building UI."); - // final dateTimeFormat = - // DateFormat('EEEE, dd MMMM yyyy HH:mm', 'id_ID'); // Tambah tahun - - return Scaffold( - body: Consumer( - builder: (context, provider, child) { - print( - "FootageScreen Consumer: Building list. isLoading: ${provider.isLoading}, itemCount: ${provider.mediaItems.length}, error: ${provider.errorMessage}"); - - if (provider.isLoading && provider.mediaItems.isEmpty) { - return Center( - child: CircularProgressIndicator(color: Colors.teal[600])); - } - - if (provider.errorMessage != null) { - return const Center( - /* ... (Widget error sama, mungkin perbesar font) ... */); - } - - if (provider.mediaItems.isEmpty && !provider.isLoading) { - return const Center( - /* ... (Widget 'belum ada rekaman' sama, mungkin perbesar font/ikon) ... */); - } - - // Gunakan GridView untuk tampilan lebih modern dan mengisi ruang - return RefreshIndicator( - onRefresh: _refreshMediaItems, - color: Colors.teal[600]!, - child: GridView.builder( - padding: - const EdgeInsets.all(12.0), // Padding lebih besar untuk grid - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: MediaQuery.of(context).size.width > 600 - ? 3 - : 2, // 2 kolom di HP, 3 di tablet - crossAxisSpacing: 12.0, - mainAxisSpacing: 12.0, - childAspectRatio: - 1.0, // Buat item kotak, atau sesuaikan (misal 3/2 untuk landscape) - ), - itemCount: provider.mediaItems.length, - itemBuilder: (context, index) { - final mediaItem = provider.mediaItems[index]; - final storagePath = mediaItem['storage_object_path'] as String?; - final uploadedAtString = mediaItem['uploaded_at'] as String?; - final mediaType = mediaItem['media_type'] as String?; - DateTime? uploadedAt = (uploadedAtString != null) - ? DateTime.tryParse(uploadedAtString)?.toLocal() - : null; - final fileName = - storagePath?.split('/').last ?? 'Media Tidak Dikenal'; - - IconData itemIconData = Icons.perm_media_outlined; - Color itemIconColor = Colors.teal[700]!; - if (mediaType == 'video') { - itemIconData = Icons.play_circle_filled_outlined; - } else if (mediaType == 'image') { - itemIconData = Icons.image; - itemIconColor = Colors.indigo[700]!; - } - - return Card( - clipBehavior: Clip.antiAlias, - elevation: 3, // Shadow lebih tegas - child: InkWell( - // Tambahkan InkWell untuk ripple effect - onTap: () => _viewMedia(context, mediaItem), - child: GridTile( - footer: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, vertical: 6.0), - color: Colors.black.withOpacity(0.6), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - fileName, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w500, - fontSize: 13), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (uploadedAt != null) - Text( - DateFormat('dd MMM yy, HH:mm', 'id_ID') - .format(uploadedAt), - style: TextStyle( - color: Colors.grey[300], fontSize: 11), - ), - ], - ), - ), - child: Container( - // Latar belakang untuk ikon jika tidak ada thumbnail - color: itemIconColor.withOpacity(0.05), - child: Center( - child: Icon(itemIconData, - size: 50, color: itemIconColor.withOpacity(0.7)), - ), - // TODO: Jika Anda punya URL thumbnail, ganti dengan Image.network di sini - // child: mediaItem['thumbnail_url'] != null - // ? Image.network(mediaItem['thumbnail_url'], fit: BoxFit.cover) - // : Center(child: Icon(itemIconData, size: 50, color: itemIconColor.withOpacity(0.7))), - ), - ), - ), - ); - }, - ), - ); - }, - ), - ); - } -} diff --git a/newata2/lib/screens/image_viewer_screen.dart b/newata2/lib/screens/image_viewer_screen.dart deleted file mode 100644 index 16a7e32..0000000 --- a/newata2/lib/screens/image_viewer_screen.dart +++ /dev/null @@ -1,60 +0,0 @@ -// screens/image_viewer_screen.dart (Sudah Lengkap) -import 'package:flutter/material.dart'; - -class ImageViewerScreen extends StatelessWidget { - final String imageUrl; - - const ImageViewerScreen({super.key, required this.imageUrl}); - - @override - Widget build(BuildContext context) { - print("ImageViewerScreen: Building UI with URL: $imageUrl"); - return Scaffold( - backgroundColor: Colors.black, - appBar: AppBar( - title: const Text('Penampil Gambar'), - backgroundColor: Colors.black.withOpacity(0.7), - elevation: 0, - ), - body: Center( - child: InteractiveViewer( - panEnabled: true, - minScale: 0.5, - maxScale: 4.0, - child: Image.network( - imageUrl, - fit: BoxFit.contain, - loadingBuilder: (BuildContext context, Widget child, - ImageChunkEvent? loadingProgress) { - if (loadingProgress == null) return child; - return Center( - child: CircularProgressIndicator( - value: loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - color: Colors.white, - ), - ); - }, - errorBuilder: (BuildContext context, Object exception, - StackTrace? stackTrace) { - print("ImageViewerScreen: Error loading image: $exception"); - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.broken_image_outlined, - color: Colors.grey[600], size: 60), - const SizedBox(height: 10), - Text('Gagal memuat gambar', - style: TextStyle(color: Colors.grey[400])), - ], - )); - }, - ), - ), - ), - ); - } -} diff --git a/newata2/lib/screens/login_screen.dart b/newata2/lib/screens/login_screen.dart deleted file mode 100644 index f281412..0000000 --- a/newata2/lib/screens/login_screen.dart +++ /dev/null @@ -1,207 +0,0 @@ -// screens/login_screen.dart (Sudah Lengkap) -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../providers/auth_provider.dart'; // Pastikan path benar - -class LoginScreen extends StatefulWidget { - const LoginScreen({super.key}); - - @override - _LoginScreenState createState() => _LoginScreenState(); -} - -class _LoginScreenState extends State { - final _formKey = GlobalKey(); - final _emailController = TextEditingController(); - final _passwordController = TextEditingController(); - bool _isPasswordVisible = false; - - @override - void dispose() { - _emailController.dispose(); - _passwordController.dispose(); - super.dispose(); - } - - Future _performLogin() async { - print("LoginScreen: _performLogin called."); - FocusScope.of(context).unfocus(); - - if (_formKey.currentState!.validate()) { - print("LoginScreen: Form is valid."); - final authProvider = context.read(); - await authProvider.signIn( - _emailController.text.trim(), - _passwordController.text.trim(), - ); - } else { - print("LoginScreen: Form is invalid."); - } - } - - @override - Widget build(BuildContext context) { - print("LoginScreen: Building UI."); - return Scaffold( - body: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(30.0), - child: Form( - key: _formKey, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.cut_outlined, size: 80, color: Colors.teal[400]), - const SizedBox(height: 20), - Text( - 'Selamat Datang Kembali!', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, color: Colors.teal[800]), - ), - const SizedBox(height: 8), - Text( - 'Masuk ke akun Anda', - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith(color: Colors.grey[600]), - textAlign: TextAlign.center, - ), - const SizedBox(height: 30), - - // Input Email - TextFormField( - controller: _emailController, - decoration: InputDecoration( - labelText: 'Email', - hintText: 'Masukkan email Anda', // Tambah hint - prefixIcon: - Icon(Icons.email_outlined, color: Colors.grey[600]), - ), - keyboardType: TextInputType.emailAddress, - autovalidateMode: AutovalidateMode - .onUserInteraction, // Validasi saat interaksi - validator: (value) { - if (value == null || value.isEmpty) - return 'Email tidak boleh kosong'; - if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) - return 'Masukkan format email yang valid'; - return null; - }, - ), - const SizedBox(height: 15), - - // Input Password - TextFormField( - controller: _passwordController, - decoration: InputDecoration( - labelText: 'Password', - hintText: 'Masukkan password Anda', // Tambah hint - prefixIcon: - Icon(Icons.lock_outline, color: Colors.grey[600]), - suffixIcon: IconButton( - icon: Icon( - _isPasswordVisible - ? Icons.visibility_off_outlined - : Icons.visibility_outlined, - color: Colors.grey[600], - ), - onPressed: () => setState( - () => _isPasswordVisible = !_isPasswordVisible)), - ), - obscureText: !_isPasswordVisible, - autovalidateMode: AutovalidateMode - .onUserInteraction, // Validasi saat interaksi - validator: (value) { - if (value == null || value.isEmpty) - return 'Password tidak boleh kosong'; - if (value.length < 6) return 'Password minimal 6 karakter'; - return null; - }, - ), - const SizedBox(height: 25), - - // Tombol Login & Loading Indicator - Consumer( - builder: (context, authProvider, child) { - if (authProvider.currentUser != null && - !authProvider.isLoading) { - print( - "LoginScreen Consumer: User detected (${authProvider.currentUser!.id}), navigating to dashboard."); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && - ModalRoute.of(context)?.isCurrent == true) { - Navigator.pushReplacementNamed(context, '/dashboard'); - } - }); - return const Padding( - padding: EdgeInsets.symmetric(vertical: 12.0), - child: CircularProgressIndicator(color: Colors.teal), - ); - } - - return authProvider.isLoading - ? const Padding( - padding: EdgeInsets.symmetric(vertical: 12.0), - child: - CircularProgressIndicator(color: Colors.teal), - ) - : SizedBox( - width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - vertical: - 14)), // Buat tombol lebih tinggi - child: const Text('Login', - style: TextStyle(fontSize: 16)), - onPressed: _performLogin, - ), - ); - }, - ), - const SizedBox(height: 15), - - // Link ke Halaman Register - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text("Belum punya akun?", - style: TextStyle(color: Colors.grey[700])), - TextButton( - child: const Text('Daftar di sini'), - onPressed: () { - print("LoginScreen: Navigating to /register"); - Navigator.pushNamed(context, '/register'); - }, - ), - ], - ), - const SizedBox(height: 10), - - // Tampilkan Pesan Error - Consumer( - builder: (context, auth, child) { - if (auth.errorMessage != null && !auth.isLoading) { - print( - "LoginScreen Consumer: Displaying error message: ${auth.errorMessage}"); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - auth.errorMessage!, - style: const TextStyle(color: Colors.redAccent), - textAlign: TextAlign.center, - ), - ); - } - return const SizedBox.shrink(); - }, - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/newata2/lib/screens/register_screen.dart b/newata2/lib/screens/register_screen.dart deleted file mode 100644 index 8d75902..0000000 --- a/newata2/lib/screens/register_screen.dart +++ /dev/null @@ -1,239 +0,0 @@ -// screens/register_screen.dart (Sudah Lengkap) -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../providers/auth_provider.dart'; // Pastikan path benar - -class RegisterScreen extends StatefulWidget { - const RegisterScreen({super.key}); - - @override - _RegisterScreenState createState() => _RegisterScreenState(); -} - -class _RegisterScreenState extends State { - final _formKey = GlobalKey(); - final _emailController = TextEditingController(); - final _passwordController = TextEditingController(); - final _confirmPasswordController = TextEditingController(); - bool _isPasswordVisible = false; - bool _isConfirmPasswordVisible = false; - String? _successMessage; - - @override - void dispose() { - _emailController.dispose(); - _passwordController.dispose(); - _confirmPasswordController.dispose(); - super.dispose(); - } - - Future _performRegister() async { - print("RegisterScreen: _performRegister called."); - setState(() { - _successMessage = null; - }); - FocusScope.of(context).unfocus(); - - if (_formKey.currentState!.validate()) { - print("RegisterScreen: Form is valid."); - final authProvider = context.read(); - final bool success = await authProvider.signUp( - _emailController.text.trim(), - _passwordController.text.trim(), - ); - - if (mounted && success) { - print("RegisterScreen: SignUp reported success from provider."); - setState(() { - _successMessage = - "Registrasi berhasil! Jika konfirmasi email aktif, silakan cek email Anda sebelum login."; - }); - _formKey.currentState?.reset(); - _emailController.clear(); - _passwordController.clear(); - _confirmPasswordController.clear(); - } else if (mounted) { - print("RegisterScreen: SignUp reported failure from provider."); - } - } else { - print("RegisterScreen: Form is invalid."); - } - } - - @override - Widget build(BuildContext context) { - print("RegisterScreen: Building UI."); - return Scaffold( - appBar: AppBar( - title: const Text('Daftar Akun Baru'), - backgroundColor: Colors.transparent, - elevation: 0, - foregroundColor: Colors.teal[800], - iconTheme: IconThemeData(color: Colors.teal[800]), // Warna ikon back - ), - body: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(30.0), - child: Form( - key: _formKey, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.person_add_alt_1_outlined, - size: 60, color: Colors.teal[400]), - const SizedBox(height: 15), - Text( - 'Buat Akun Anda', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, color: Colors.teal[800]), - ), - const SizedBox(height: 25), - - // Input Email - TextFormField( - controller: _emailController, - decoration: const InputDecoration( - labelText: 'Email', - hintText: 'Masukkan email Anda', - prefixIcon: Icon(Icons.email_outlined)), - keyboardType: TextInputType.emailAddress, - autovalidateMode: AutovalidateMode.onUserInteraction, - validator: (value) { - if (value == null || value.isEmpty) - return 'Email tidak boleh kosong'; - if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) - return 'Masukkan format email yang valid'; - return null; - }, - ), - const SizedBox(height: 15), - - // Input Password - TextFormField( - controller: _passwordController, - decoration: InputDecoration( - labelText: 'Password', - hintText: 'Minimal 6 karakter', - prefixIcon: const Icon(Icons.lock_outline), - suffixIcon: IconButton( - icon: Icon(_isPasswordVisible - ? Icons.visibility_off_outlined - : Icons.visibility_outlined), - onPressed: () => setState( - () => _isPasswordVisible = !_isPasswordVisible))), - obscureText: !_isPasswordVisible, - autovalidateMode: AutovalidateMode.onUserInteraction, - validator: (value) { - if (value == null || value.isEmpty) - return 'Password tidak boleh kosong'; - if (value.length < 6) return 'Password minimal 6 karakter'; - return null; - }, - ), - const SizedBox(height: 15), - - // Input Konfirmasi Password - TextFormField( - controller: _confirmPasswordController, - decoration: InputDecoration( - labelText: 'Konfirmasi Password', - hintText: 'Ulangi password', - prefixIcon: const Icon(Icons.lock_clock_outlined), - suffixIcon: IconButton( - icon: Icon(_isConfirmPasswordVisible - ? Icons.visibility_off_outlined - : Icons.visibility_outlined), - onPressed: () => setState(() => - _isConfirmPasswordVisible = - !_isConfirmPasswordVisible))), - obscureText: !_isConfirmPasswordVisible, - autovalidateMode: AutovalidateMode.onUserInteraction, - validator: (value) { - if (value == null || value.isEmpty) - return 'Konfirmasi password tidak boleh kosong'; - if (value != _passwordController.text) - return 'Password tidak cocok'; - return null; - }, - ), - const SizedBox(height: 25), - - // Tampilkan Pesan Sukses jika ada - if (_successMessage != null) - Padding( - padding: const EdgeInsets.only(bottom: 15.0), - child: Text( - _successMessage!, - style: TextStyle( - color: Colors.green[700], - fontWeight: FontWeight.bold), - textAlign: TextAlign.center, - ), - ), - - // Tombol Daftar & Loading Indicator - Consumer( - builder: (context, authProvider, child) { - if (_successMessage != null) { - // Jika sukses, tampilkan tombol kembali ke login - return SizedBox( - width: double.infinity, - child: OutlinedButton( - onPressed: () => Navigator.pop(context), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.teal, - side: BorderSide(color: Colors.teal[200]!), - padding: - const EdgeInsets.symmetric(vertical: 14)), - child: const Text('Kembali ke Login'), - ), - ); - } - return authProvider.isLoading - ? const Padding( - padding: EdgeInsets.symmetric(vertical: 12.0), - child: - CircularProgressIndicator(color: Colors.teal), - ) - : SizedBox( - width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - padding: - const EdgeInsets.symmetric(vertical: 14)), - onPressed: _performRegister, - child: const Text('Daftar', - style: TextStyle(fontSize: 16)), - ), - ); - }, - ), - const SizedBox(height: 15), - - // Tampilkan Pesan Error (Hanya jika tidak ada pesan sukses) - if (_successMessage == null) - Consumer( - builder: (context, auth, child) { - if (auth.errorMessage != null && !auth.isLoading) { - print( - "RegisterScreen Consumer: Displaying error message: ${auth.errorMessage}"); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - auth.errorMessage!, - style: const TextStyle(color: Colors.redAccent), - textAlign: TextAlign.center, - ), - ); - } - return const SizedBox.shrink(); - }, - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/newata2/lib/screens/splash_screen.dart b/newata2/lib/screens/splash_screen.dart deleted file mode 100644 index 3d81e18..0000000 --- a/newata2/lib/screens/splash_screen.dart +++ /dev/null @@ -1,57 +0,0 @@ -// screens/splash_screen.dart (Sudah Lengkap) -import 'package:CCTV_App/main.dart'; -import 'package:flutter/material.dart'; - - -class SplashScreen extends StatefulWidget { - @override - _SplashScreenState createState() => _SplashScreenState(); -} - -class _SplashScreenState extends State { - @override - void initState() { - super.initState(); - print("SplashScreen: initState called."); - _redirect(); - } - - Future _redirect() async { - await Future.delayed(Duration.zero); - if (!mounted) return; - print("SplashScreen: Starting redirection check..."); - - try { - final session = supabase.auth.currentSession; - print("SplashScreen: Current session: ${session?.user.id ?? 'null'}"); - - if (session != null) { - print("SplashScreen: Session found, redirecting to /dashboard"); - Navigator.pushReplacementNamed(context, '/dashboard'); - } else { - print("SplashScreen: No session found, redirecting to /login"); - Navigator.pushReplacementNamed(context, '/login'); - } - } catch (e) { - print("SplashScreen: Error during redirection check: $e"); - Navigator.pushReplacementNamed(context, '/login'); - } - } - - @override - Widget build(BuildContext context) { - print("SplashScreen: Building UI."); - return const Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator(color: Colors.teal), - SizedBox(height: 20), - Text("Memuat...", style: TextStyle(color: Colors.teal)), - ], - ), - ), - ); - } -} diff --git a/newata2/lib/screens/video_player_screen.dart b/newata2/lib/screens/video_player_screen.dart deleted file mode 100644 index 9dc07f0..0000000 --- a/newata2/lib/screens/video_player_screen.dart +++ /dev/null @@ -1,152 +0,0 @@ -// screens/video_player_screen.dart (Sudah Lengkap) -import 'package:flutter/material.dart'; -import 'package:video_player/video_player.dart'; -import 'package:chewie/chewie.dart'; - -class VideoPlayerScreen extends StatefulWidget { - final String videoUrl; - const VideoPlayerScreen({super.key, required this.videoUrl}); - - @override - _VideoPlayerScreenState createState() => _VideoPlayerScreenState(); -} - -class _VideoPlayerScreenState extends State { - VideoPlayerController? _videoPlayerController; - ChewieController? _chewieController; - bool _isLoading = true; - String? _errorMessage; - - @override - void initState() { - super.initState(); - print("VideoPlayerScreen: initState called with URL: ${widget.videoUrl}"); - _initializePlayer(); - } - - Future _initializePlayer() async { - if (widget.videoUrl.isEmpty || !Uri.tryParse(widget.videoUrl)!.isAbsolute) { - print("VideoPlayerScreen: Invalid URL provided."); - setState(() { - _isLoading = false; - _errorMessage = "URL Video tidak valid."; - }); - return; - } - try { - print("VideoPlayerScreen: Initializing video controller..."); - _videoPlayerController = - VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl)); - await _videoPlayerController!.initialize(); - print("VideoPlayerScreen: Video controller initialized."); - - print("VideoPlayerScreen: Initializing Chewie controller..."); - _chewieController = ChewieController( - videoPlayerController: _videoPlayerController!, - autoPlay: true, - looping: false, - materialProgressColors: ChewieProgressColors( - playedColor: Colors.teal, - handleColor: Colors.teal[300]!, - bufferedColor: Colors.teal[100]!, - backgroundColor: - Colors.blueGrey[600]!, // Sesuaikan warna background progress - ), - placeholder: Container( - color: Colors.black, - child: const Center( - child: CircularProgressIndicator( - color: Colors.white70)), // Buat lebih redup - ), - autoInitialize: true, - // Opsi tambahan: - // showControlsOnInitialize: false, - // allowedScreenSleep: false, // Jaga layar tetap nyala - errorBuilder: (context, errorMessage) { - print( - "VideoPlayerScreen: Chewie errorBuilder: $errorMessage"); // Log error Chewie - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, - color: Colors.white70, size: 40), - const SizedBox(height: 8), - const Text('Gagal memutar video', - style: TextStyle(color: Colors.white70)), - Text( - errorMessage, - style: const TextStyle(color: Colors.white54, fontSize: 12), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ); - }, - ); - print("VideoPlayerScreen: Chewie controller initialized."); - setState(() { - _isLoading = false; - }); - } catch (e, stackTrace) { - print("VideoPlayerScreen: Error initializing video player: $e"); - print(stackTrace); - setState(() { - _isLoading = false; - _errorMessage = "Gagal memuat video."; - }); - } - } - - @override - void dispose() { - print("VideoPlayerScreen: dispose called."); - _videoPlayerController?.dispose(); - _chewieController?.dispose(); // Chewie controller juga perlu di-dispose - super.dispose(); - } - - @override - Widget build(BuildContext context) { - print( - "VideoPlayerScreen: Building UI. isLoading: $_isLoading, error: $_errorMessage"); - return Scaffold( - backgroundColor: Colors.black, - appBar: AppBar( - title: const Text('Pemutar Video'), - backgroundColor: - Colors.black.withOpacity(0.7), // AppBar sedikit transparan - elevation: 0, - ), - body: Center( - child: _isLoading - ? const CircularProgressIndicator(color: Colors.white) - : _errorMessage != null - ? Padding( - padding: const EdgeInsets.all(20.0), - child: Text( - _errorMessage!, - style: const TextStyle(color: Colors.white, fontSize: 16), - textAlign: TextAlign.center, - ), - ) - // Pastikan chewieController tidak null SEBELUM menampilkannya - : _chewieController != null - ? Chewie(controller: _chewieController!) - : const Column( - // Fallback jika chewie controller gagal dibuat - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.error_outline, - color: Colors.white70, size: 40), - SizedBox(height: 10), - Text("Gagal menyiapkan pemutar video.", - style: TextStyle(color: Colors.white70)), - ], - ), - ), - ); - } -} diff --git a/newata2/lib/splash_screen.dart b/newata2/lib/splash_screen.dart new file mode 100644 index 0000000..af158bf --- /dev/null +++ b/newata2/lib/splash_screen.dart @@ -0,0 +1,54 @@ +// ================================================================= +// SPLASH SCREEN PAGE +// ================================================================= + +import 'package:CCTV_App/auth_wrapper.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class SplashScreen extends StatefulWidget { + const SplashScreen({super.key}); + + @override + State createState() => _SplashScreenState(); +} + +class _SplashScreenState extends State { + @override + void initState() { + super.initState(); + Future.delayed(const Duration(seconds: 3), () { + if (mounted) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const AuthWrapper())); + } + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(CupertinoIcons.lock_shield_fill, + size: 120, color: Colors.tealAccent), + const SizedBox(height: 24), + Text( + 'Smart Anti Theft', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontSize: 28), + ), + const SizedBox(height: 80), + const CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white54), + ), + ], + ), + ), + ); + } +} diff --git a/newata2/macos/Flutter/GeneratedPluginRegistrant.swift b/newata2/macos/Flutter/GeneratedPluginRegistrant.swift index db1fad0..92b6497 100644 --- a/newata2/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/newata2/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,19 +6,13 @@ import FlutterMacOS import Foundation import app_links -import package_info_plus import path_provider_foundation import shared_preferences_foundation import url_launcher_macos -import video_player_avfoundation -import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) - FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) - FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) - WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) } diff --git a/newata2/notify-all-user.ts b/newata2/notify-all-user.ts new file mode 100644 index 0000000..8206207 --- /dev/null +++ b/newata2/notify-all-user.ts @@ -0,0 +1,76 @@ +// import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; +// import { Resend } from 'https://esm.sh/resend@3.2.0'; +// console.log("Edge Function 'notify-all-users' is initializing."); +// Deno.serve(async (req)=>{ +// try { +// // 1. Ambil data event dari request yang dikirim oleh Trigger Postgres +// const { record } = await req.json(); +// console.log(`New event received for device: ${record.device_id}`); +// // 2. Ambil Kunci API Resend dari environment variables +// const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY"); +// if (!RESEND_API_KEY) { +// throw new Error("RESEND_API_KEY is not set in Supabase secrets."); +// } +// const resend = new Resend(RESEND_API_KEY); +// // 3. Buat Supabase client di dalam Edge Function untuk mengambil daftar email +// const supabaseAdmin = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''); +// // 4. Ambil semua email dari tabel 'profiles' +// const { data: profiles, error: profileError } = await supabaseAdmin.from('profiles').select('email'); +// if (profileError) { +// throw new Error(`Failed to fetch profiles: ${profileError.message}`); +// } +// if (!profiles || profiles.length === 0) { +// console.log("No user profiles found to notify."); +// return new Response(JSON.stringify({ +// message: "No users to notify." +// }), { +// headers: { +// "Content-Type": "application/json" +// }, +// status: 200 +// }); +// } +// const emails = profiles.map((p)=>p.email).filter(Boolean); // Ambil email dan filter null/kosong +// console.log(`Found ${emails.length} emails to notify:`, emails); +// // 5. Kirim email ke semua pengguna menggunakan Resend +// const { data, error } = await resend.emails.send({ +// from: 'Sistem Smart Anti Theft ', +// to: emails, +// subject: `⚠️ Peringatan Ancaman: Terdeteksi ${record.event_type} di ${record.location_name}!`, +// html: ` +//

Peringatan Ancaman

+//

Halo,

+//

Sistem kami telah mendeteksi adanya ${record.event_type} pada perangkat ${record.device_id} yang berlokasi di ${record.location_name}.

+//

Sebuah gambar telah berhasil ditangkap sebagai referensi.

+// Captured Image +//

Silakan periksa aplikasi Anda untuk detail lebih lanjut.

+//
+//

Terima kasih,

+//

Tim Keamanan Cerdas Anda

+// ` +// }); +// if (error) { +// console.error("Error sending email:", error); +// throw new Error(JSON.stringify(error)); +// } +// console.log("Successfully sent emails:", data); +// return new Response(JSON.stringify({ +// data +// }), { +// headers: { +// "Content-Type": "application/json" +// }, +// status: 200 +// }); +// } catch (error) { +// console.error("Critical error in Edge Function:", error.message); +// return new Response(JSON.stringify({ +// error: error.message +// }), { +// headers: { +// "Content-Type": "application/json" +// }, +// status: 500 +// }); +// } +// }); diff --git a/newata2/pubspec.lock b/newata2/pubspec.lock index 4085e0b..627e059 100644 --- a/newata2/pubspec.lock +++ b/newata2/pubspec.lock @@ -33,14 +33,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" async: dependency: transitive description: @@ -65,14 +57,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" - chewie: - dependency: "direct main" - description: - name: chewie - sha256: "645fbca3f22309381edb5af59a4c8aa544a3d3872d7b7b7c986c2b18b3bdd265" - url: "https://pub.dev" - source: hosted - version: "1.10.0" clock: dependency: transitive description: @@ -97,14 +81,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" - csslib: - dependency: transitive - description: - name: csslib - sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" - url: "https://pub.dev" - source: hosted - version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -113,14 +89,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" - dbus: - dependency: transitive - description: - name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" - url: "https://pub.dev" - source: hosted - version: "0.7.11" fake_async: dependency: transitive description: @@ -200,14 +168,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" - html: - dependency: transitive - description: - name: html - sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" - url: "https://pub.dev" - source: hosted - version: "0.15.6" http: dependency: transitive description: @@ -320,22 +280,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - package_info_plus: - dependency: transitive - description: - name: package_info_plus - sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191" - url: "https://pub.dev" - source: hosted - version: "8.3.0" - package_info_plus_platform_interface: - dependency: transitive - description: - name: package_info_plus_platform_interface - sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c" - url: "https://pub.dev" - source: hosted - version: "3.2.0" path: dependency: transitive description: @@ -392,14 +336,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 - url: "https://pub.dev" - source: hosted - version: "6.0.2" platform: dependency: transitive description: @@ -669,46 +605,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" - video_player: - dependency: "direct main" - description: - name: video_player - sha256: "7d78f0cfaddc8c19d4cb2d3bebe1bfef11f2103b0a03e5398b303a1bf65eeb14" - url: "https://pub.dev" - source: hosted - version: "2.9.5" - video_player_android: - dependency: transitive - description: - name: video_player_android - sha256: "391e092ba4abe2f93b3e625bd6b6a6ec7d7414279462c1c0ee42b5ab8d0a0898" - url: "https://pub.dev" - source: hosted - version: "2.7.16" - video_player_avfoundation: - dependency: transitive - description: - name: video_player_avfoundation - sha256: "9ee764e5cd2fc1e10911ae8ad588e1a19db3b6aa9a6eb53c127c42d3a3c3f22f" - url: "https://pub.dev" - source: hosted - version: "2.7.1" - video_player_platform_interface: - dependency: transitive - description: - name: video_player_platform_interface - sha256: df534476c341ab2c6a835078066fc681b8265048addd853a1e3c78740316a844 - url: "https://pub.dev" - source: hosted - version: "6.3.0" - video_player_web: - dependency: transitive - description: - name: video_player_web - sha256: e8bba2e5d1e159d5048c9a491bb2a7b29c535c612bb7d10c1e21107f5bd365ba - url: "https://pub.dev" - source: hosted - version: "2.3.5" vm_service: dependency: transitive description: @@ -717,22 +613,6 @@ packages: url: "https://pub.dev" source: hosted version: "14.2.5" - wakelock_plus: - dependency: transitive - description: - name: wakelock_plus - sha256: a474e314c3e8fb5adef1f9ae2d247e57467ad557fa7483a2b895bc1b421c5678 - url: "https://pub.dev" - source: hosted - version: "1.3.2" - wakelock_plus_platform_interface: - dependency: transitive - description: - name: wakelock_plus_platform_interface - sha256: e10444072e50dbc4999d7316fd303f7ea53d31c824aa5eb05d7ccbdd98985207 - url: "https://pub.dev" - source: hosted - version: "1.2.3" web: dependency: transitive description: @@ -757,14 +637,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" - win32: - dependency: transitive - description: - name: win32 - sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e - url: "https://pub.dev" - source: hosted - version: "5.10.1" xdg_directories: dependency: transitive description: @@ -773,14 +645,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" - xml: - dependency: transitive - description: - name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 - url: "https://pub.dev" - source: hosted - version: "6.5.0" yet_another_json_isolate: dependency: transitive description: diff --git a/newata2/pubspec.yaml b/newata2/pubspec.yaml index e336ba3..23cc0df 100644 --- a/newata2/pubspec.yaml +++ b/newata2/pubspec.yaml @@ -32,8 +32,8 @@ dependencies: sdk: flutter supabase_flutter: ^2.0.0 # Cek versi terbaru provider: ^6.0.0 # Cek versi terbaru - video_player: ^2.0.0 # Cek versi terbaru - chewie: ^1.5.0 # Cek versi terbaru (sesuaikan dengan video_player) + # video_player: ^2.0.0 # Cek versi terbaru + # chewie: ^1.5.0 # Cek versi terbaru (sesuaikan dengan video_player) flutter_dotenv: ^5.0.0 # Cek versi terbaru intl: ^0.19.0 # Cek versi terbaru (untuk format tanggal/waktu) @@ -65,7 +65,7 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - .env - # - images/a_dot_burr.jpeg + # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg # An image asset can refer to one or more resolution-specific "variants", see