Flutter with Supabase

catatan apps dengan flutter dan supabase

Berikut adalah tutorial lengkap untuk membangun aplikasi Flutter dengan Supabase dari awal.


1. Pengaturan Proyek Supabase

Pertama, siapkan backend Anda di Supabase.

1.1. Buat Proyek Baru

  1. Buka supabase.com dan buat akun atau login.

  2. Klik "New Project" dan berikan nama serta kata sandi database yang kuat.

  3. Tunggu beberapa menit hingga proyek Anda siap.

1.2. Dapatkan API Keys

  1. Di dasbor proyek Anda, buka Project Settings (ikon roda gigi).

  2. Pilih menu API.

  3. Anda akan memerlukan Project URL dan anon public Key. Simpan keduanya untuk nanti.

1.3. Atur Tabel Database

Kita akan membuat dua tabel: profiles untuk data pengguna dan notes untuk catatan.

  1. Buka Table Editor (ikon tabel) di menu sebelah kiri.

  2. Klik "New table".

  3. Buat tabel profiles:

    • Nama tabel: profiles

    • PENTING: Hapus centang pada "Enable Row Level Security (RLS)" untuk sementara. Kita akan mengaktifkannya nanti.

    • Tambahkan kolom-kolom berikut:

      • id (tipe uuid, Primary Key, default uuid_generate_v4()). Atur ini sebagai Foreign Key ke auth.users.id.

      • username (tipe text).

      • avatar_url (tipe text, nullable).

      • updated_at (tipe timestampz, nullable, default now()).

    • Klik Save.

  4. Buat tabel notes:

    • Nama tabel: notes

    • Hapus centang pada "Enable Row Level Security (RLS)".

    • Tambahkan kolom-kolom berikut:

      • id (tipe int8, Primary Key, is Identity).

      • user_id (tipe uuid). Atur ini sebagai Foreign Key ke auth.users.id.

      • title (tipe text).

      • content (tipe text).

      • image_url (tipe text, nullable).

      • created_at (tipe timestampz, default now()).

    • Klik Save.

    • cara menambah foreign key

1.4. Atur Penyimpanan (Storage)

Kita butuh dua bucket untuk menyimpan gambar.

  1. Buka Storage (ikon kotak) di menu sebelah kiri.

  2. Buat bucket untuk avatar:

    • Klik "New Bucket".

    • Nama bucket: avatars

    • Biarkan sebagai Public bucket.

  3. Buat bucket untuk gambar catatan:

    • Klik "New Bucket".

    • Nama bucket: notes-images

    • Biarkan sebagai Public bucket.

setting permission atau policy storage

untuk uji coba, sementara disable confirmasi email


2. Pengaturan Proyek Flutter

Sekarang kita siapkan aplikasi Flutter.

2.1. Buat Proyek Flutter

Buka terminal dan jalankan:

Bash

flutter create flutter_supabase_crud
cd flutter_supabase_crud

2.2. Tambahkan Dependensi

Buka file pubspec.yaml dan tambahkan dependensi berikut:

YAML

dependencies:
  flutter:
    sdk: flutter
  supabase_flutter: ^2.5.3 # Versi terbaru mungkin berbeda
  get: ^4.6.6
  get_storage: ^2.1.1
  image_picker: ^1.1.2
  path_provider: ^2.1.3 # Dibutuhkan oleh supabase_flutter

Jalankan flutter pub get di terminal.

2.3. Struktur Folder

Buat struktur folder berikut di dalam folder lib:

lib/
├── main.dart
├── models/
│   ├── note_model.dart
│   └── profile_model.dart
├── screens/
│   ├── auth/
│   │   ├── login_screen.dart
│   │   └── register_screen.dart
│   ├── home/
│   │   ├── notes_list_screen.dart
│   │   └── note_form_screen.dart
│   ├── profile/
│   │   └── profile_screen.dart
│   └── splash_screen.dart
├── services/
│   └── supabase_service.dart
├── utils/
│   ├── app_routes.dart
│   └── constants.dart
└── widgets/
    └── custom_input_field.dart

3. Konfigurasi Awal & Kode

Mari kita mulai menulis kode.

3.1. Simpan API Key (File Terpisah)

Buat file lib/utils/constants.dart untuk menyimpan kredensial Supabase.

Dart

// lib/utils/constants.dart

// GANTI DENGAN URL DAN ANON KEY DARI PROYEK SUPABASE ANDA
const String supabaseUrl = 'https://YPUR_PROJECT_ID.supabase.co';
const String supabaseAnonKey = 'YOUR_SUPABASE_ANON_KEY';

3.2. Inisialisasi di main.dart

Inisialisasi Supabase dan GetStorage.

Dart

// lib/main.dart

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../utils/app_routes.dart';
import '../../utils/constants.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  // Inisialisasi GetStorage
  await GetStorage.init();

  // Inisialisasi Supabase
  await Supabase.initialize(
    url: supabaseUrl,
    anonKey: supabaseAnonKey,
  );

  runApp(const MyApp());
}

// Helper global untuk akses mudah ke client Supabase
final supabase = Supabase.instance.client;

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

  @override
  Widget build(BuildContext context) {
    return GetMaterialApp(
      title: 'Flutter Supabase',
      theme: ThemeData(
        primarySwatch: Colors.teal,
        useMaterial3: true,
      ),
      initialRoute: AppRoutes.splash,
      getPages: AppRoutes.routes,
      debugShowCheckedModeBanner: false,
    );
  }
}

3.3. Navigasi dengan GetX

Atur semua rute navigasi di satu tempat.

Dart

// lib/utils/app_routes.dart

import 'package:get/get.dart';
import '../../screens/auth/login_screen.dart';
import '../../screens/auth/register_screen.dart';
import '../../screens/home/note_form_screen.dart';
import '../../screens/home/notes_list_screen.dart';
import '../../screens/profile/profile_screen.dart';
import '../../screens/splash_screen.dart';

class AppRoutes {
  static const String splash = '/';
  static const String login = '/login';
  static const String register = '/register';
  static const String profile = '/profile';
  static const String notes = '/notes';
  static const String noteForm = '/note-form';

  static final routes = [
    GetPage(name: splash, page: () => const SplashScreen()),
    GetPage(name: login, page: () => const LoginScreen()),
    GetPage(name: register, page: () => const RegisterScreen()),
    GetPage(name: profile, page: () => const ProfileScreen()),
    GetPage(name: notes, page: () => const NotesListScreen()),
    GetPage(name: noteForm, page: () => NoteFormScreen()),
  ];
}

4. Models (Struktur Data)

4.1. Profile Model

Dart

// lib/models/profile_model.dart

class Profile {
  final String id;
  final String username;
  final String? avatarUrl;

  Profile({
    required this.id,
    required this.username,
    this.avatarUrl,
  });

  factory Profile.fromJson(Map<String, dynamic> json) {
    return Profile(
      id: json['id'],
      username: json['username'],
      avatarUrl: json['avatar_url'],
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'username': username,
      'avatar_url': avatarUrl,
    };
  }
}

4.2. Note Model

Dart

// lib/models/note_model.dart

class Note {
  final int id;
  final String userId;
  final String title;
  final String content;
  final String? imageUrl;
  final DateTime createdAt;

  Note({
    required this.id,
    required this.userId,
    required this.title,
    required this.content,
    this.imageUrl,
    required this.createdAt,
  });

  factory Note.fromJson(Map<String, dynamic> json) {
    return Note(
      id: json['id'],
      userId: json['user_id'],
      title: json['title'],
      content: json['content'],
      imageUrl: json['image_url'],
      createdAt: DateTime.parse(json['created_at']),
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'user_id': userId,
      'title': title,
      'content': content,
      'image_url': imageUrl,
      'created_at': createdAt.toIso8601String(),
    };
  }
}

5. Service (Logika Supabase)

Pusatkan semua interaksi dengan Supabase di sini.

Dart

// lib/services/supabase_service.dart

import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../main.dart'; // Untuk akses `supabase` client

class SupabaseService {
  // Upload Gambar (Bekerja untuk Web dan Mobile)
  Future<String> uploadImage(
      File file, String bucketName, String fileName) async {
    final bytes = await file.readAsBytes();
    await supabase.storage.from(bucketName).uploadBinary(
          fileName,
          bytes,
          fileOptions: const FileOptions(cacheControl: '3600', upsert: false),
        );
    // Mendapatkan URL publik dari gambar yang diupload
    final String publicUrl =
        supabase.storage.from(bucketName).getPublicUrl(fileName);
    return publicUrl;
  }
  
  // Versi lain untuk web menggunakan XFile
  Future<String> uploadImageBytes(
      Uint8List bytes, String bucketName, String fileName) async {
    await supabase.storage.from(bucketName).uploadBinary(
          fileName,
          bytes,
          fileOptions: const FileOptions(cacheControl: '3600', upsert: false),
        );
    final String publicUrl =
        supabase.storage.from(bucketName).getPublicUrl(fileName);
    return publicUrl;
  }

  // --- CRUD Catatan ---

  Future<List<Map<String, dynamic>>> getNotes() async {
    final userId = supabase.auth.currentUser!.id;
    final data = await supabase
        .from('notes')
        .select()
        .eq('user_id', userId)
        .order('created_at', ascending: false);
    return data;
  }

  Future<void> addNote({
    required String title,
    required String content,
    String? imageUrl,
  }) async {
    final userId = supabase.auth.currentUser!.id;
    await supabase.from('notes').insert({
      'user_id': userId,
      'title': title,
      'content': content,
      'image_url': imageUrl,
    });
  }

  Future<void> updateNote({
    required int id,
    required String title,
    required String content,
    String? imageUrl,
  }) async {
    final updates = {
      'title': title,
      'content': content,
      'image_url': imageUrl,
    };
    await supabase.from('notes').update(updates).eq('id', id);
  }

  Future<void> deleteNote(int id) async {
    await supabase.from('notes').delete().eq('id', id);
  }

  // --- Profil Pengguna ---

  Future<Map<String, dynamic>?> getProfile() async {
    final userId = supabase.auth.currentUser!.id;
    final data =
        await supabase.from('profiles').select().eq('id', userId).single();
    return data;
  }

  Future<void> updateProfile({
    required String username,
    String? avatarUrl,
  }) async {
    final userId = supabase.auth.currentUser!.id;
    final updates = {
      'id': userId,
      'username': username,
      'avatar_url': avatarUrl,
      'updated_at': DateTime.now().toIso8601String(),
    };
    await supabase.from('profiles').upsert(updates);
  }
}

6. Screens (Tampilan Aplikasi)

6.1. Splash Screen (Pengecekan Sesi)

supabase_flutter secara otomatis menyimpan sesi. Kita hanya perlu mengecek apakah sesi ada atau tidak.

Dart

// lib/screens/splash_screen.dart

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../main.dart'; // Untuk akses `supabase`
import '../../utils/app_routes.dart';

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

  @override
  State<SplashScreen> createState() => _SplashScreenState();
}

class _SplashScreenState extends State<SplashScreen> {
  @override
  void initState() {
    super.initState();
    // Tunggu frame pertama selesai render sebelum navigasi
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _redirect();
    });
  }

  Future<void> _redirect() async {
    // Tunggu sedikit untuk menampilkan splash screen
    await Future.delayed(const Duration(seconds: 1));
    
    // `mounted` check
    if (!mounted) return;

    final session = supabase.auth.currentSession;
    if (session != null) {
      Get.offAllNamed(AppRoutes.profile);
    } else {
      Get.offAllNamed(AppRoutes.login);
    }
  }

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            CircularProgressIndicator(),
            SizedBox(height: 20),
            Text('Memuat...'),
          ],
        ),
      ),
    );
  }
}

6.2. Login Screen

Dart

// lib/screens/auth/login_screen.dart

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../main.dart';
import '../../utils/app_routes.dart';
import '../../widgets/custom_input_field.dart';

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

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

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

  Future<void> _signIn() async {
    if (_formKey.currentState!.validate()) {
      setState(() {
        _isLoading = true;
      });
      try {
        await supabase.auth.signInWithPassword(
          email: _emailController.text.trim(),
          password: _passwordController.text.trim(),
        );
        if (mounted) {
          Get.offAllNamed(AppRoutes.profile);
        }
      } on AuthException catch (e) {
        Get.snackbar('Error', e.message,
            backgroundColor: Colors.red, colorText: Colors.white);
      } catch (e) {
        Get.snackbar('Error', 'Terjadi kesalahan tidak terduga',
            backgroundColor: Colors.red, colorText: Colors.white);
      } finally {
        if (mounted) {
          setState(() {
            _isLoading = false;
          });
        }
      }
    }
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Login')),
      body: Center(
        child: SingleChildScrollView(
          padding: const EdgeInsets.all(16.0),
          child: Form(
            key: _formKey,
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Icon(Icons.lock, size: 80, color: Colors.teal),
                const SizedBox(height: 20),
                CustomInputField(
                  controller: _emailController,
                  labelText: 'Email',
                  keyboardType: TextInputType.emailAddress,
                  validator: (value) {
                    if (value == null || !GetUtils.isEmail(value)) {
                      return 'Masukkan email yang valid';
                    }
                    return null;
                  },
                ),
                const SizedBox(height: 16),
                CustomInputField(
                  controller: _passwordController,
                  labelText: 'Password',
                  obscureText: true,
                  validator: (value) {
                    if (value == null || value.length < 6) {
                      return 'Password minimal 6 karakter';
                    }
                    return null;
                  },
                ),
                const SizedBox(height: 24),
                _isLoading
                    ? const CircularProgressIndicator()
                    : SizedBox(
                        width: double.infinity,
                        child: ElevatedButton(
                          onPressed: _signIn,
                          style: ElevatedButton.styleFrom(
                              padding: const EdgeInsets.symmetric(vertical: 16)),
                          child: const Text('Login'),
                        ),
                      ),
                TextButton(
                  onPressed: () => Get.toNamed(AppRoutes.register),
                  child: const Text('Belum punya akun? Daftar di sini'),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

6.3. Register Screen

Dart

// lib/screens/auth/register_screen.dart

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../main.dart';
import '../../widgets/custom_input_field.dart';

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

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

class _RegisterScreenState extends State<RegisterScreen> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  final _usernameController = TextEditingController();
  final _formKey = GlobalKey<FormState>();
  bool _isLoading = false;

  Future<void> _signUp() async {
    if (_formKey.currentState!.validate()) {
      setState(() {
        _isLoading = true;
      });
      try {
        final AuthResponse res = await supabase.auth.signUp(
          email: _emailController.text.trim(),
          password: _passwordController.text.trim(),
          data: {'username': _usernameController.text.trim()},
        );

        if (res.user != null) {
          await supabase.from('profiles').insert({
            'id': res.user!.id,
            'username': _usernameController.text.trim(),
          });
        }
        
        if (mounted) {
          Get.snackbar(
            'Sukses',
            'Pendaftaran berhasil! Silakan cek email Anda untuk konfirmasi.',
            backgroundColor: Colors.green,
            colorText: Colors.white,
          );
          Get.back(); // Kembali ke halaman login
        }
      } on AuthException catch (e) {
        Get.snackbar('Error', e.message,
            backgroundColor: Colors.red, colorText: Colors.white);
      } catch (e) {
        Get.snackbar('Error', 'Terjadi kesalahan tidak terduga: $e',
            backgroundColor: Colors.red, colorText: Colors.white);
      } finally {
        if (mounted) {
          setState(() {
            _isLoading = false;
          });
        }
      }
    }
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Daftar')),
      body: Center(
        child: SingleChildScrollView(
          padding: const EdgeInsets.all(16.0),
          child: Form(
            key: _formKey,
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Icon(Icons.person_add, size: 80, color: Colors.teal),
                const SizedBox(height: 20),
                CustomInputField(
                  controller: _usernameController,
                  labelText: 'Username',
                  validator: (value) {
                    if (value == null || value.isEmpty) {
                      return 'Masukkan username';
                    }
                    return null;
                  },
                ),
                const SizedBox(height: 16),
                CustomInputField(
                  controller: _emailController,
                  labelText: 'Email',
                  keyboardType: TextInputType.emailAddress,
                  validator: (value) {
                    if (value == null || !GetUtils.isEmail(value)) {
                      return 'Masukkan email yang valid';
                    }
                    return null;
                  },
                ),
                const SizedBox(height: 16),
                CustomInputField(
                  controller: _passwordController,
                  labelText: 'Password',
                  obscureText: true,
                  validator: (value) {
                    if (value == null || value.length < 6) {
                      return 'Password minimal 6 karakter';
                    }
                    return null;
                  },
                ),
                const SizedBox(height: 24),
                _isLoading
                    ? const CircularProgressIndicator()
                    : SizedBox(
                        width: double.infinity,
                        child: ElevatedButton(
                          onPressed: _signUp,
                          style: ElevatedButton.styleFrom(
                              padding: const EdgeInsets.symmetric(vertical: 16)),
                          child: const Text('Daftar'),
                        ),
                      ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

6.4. Profile Screen

Dart

// lib/screens/profile/profile_screen.dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import '../../main.dart';
import '../../models/profile_model.dart';
import '../../services/supabase_service.dart';
import '../../utils/app_routes.dart';
import 'dart:io';

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

  @override
  State<ProfileScreen> createState() => _ProfileScreenState();
}

class _ProfileScreenState extends State<ProfileScreen> {
  final SupabaseService _supabaseService = SupabaseService();
  Profile? _profile;
  bool _isLoading = true;
  final _usernameController = TextEditingController();

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

  Future<void> _loadProfile() async {
    setState(() {
      _isLoading = true;
    });
    try {
      final data = await _supabaseService.getProfile();
      if (data != null) {
        setState(() {
          _profile = Profile.fromJson(data);
          _usernameController.text = _profile!.username;
        });
      }
    } catch (e) {
      Get.snackbar('Error', 'Gagal memuat profil: $e',
          backgroundColor: Colors.red, colorText: Colors.white);
    } finally {
      setState(() {
        _isLoading = false;
      });
    }
  }

  Future<void> _updateProfile() async {
    if (_usernameController.text.isEmpty) {
      Get.snackbar('Error', 'Username tidak boleh kosong',
          backgroundColor: Colors.red, colorText: Colors.white);
      return;
    }
    setState(() {
      _isLoading = true;
    });
    try {
      await _supabaseService.updateProfile(username: _usernameController.text);
      Get.snackbar('Sukses', 'Profil berhasil diperbarui',
          backgroundColor: Colors.green, colorText: Colors.white);
      _loadProfile();
    } catch (e) {
      Get.snackbar('Error', 'Gagal memperbarui profil: $e',
          backgroundColor: Colors.red, colorText: Colors.white);
    } finally {
      if (mounted) {
        setState(() {
          _isLoading = false;
        });
      }
    }
  }

  Future<void> _uploadAvatar() async {
    final picker = ImagePicker();
    final imageFile = await picker.pickImage(
      source: ImageSource.gallery,
      maxWidth: 300,
      maxHeight: 300,
    );

    if (imageFile == null) return;

    setState(() {
      _isLoading = true;
    });

    try {
      final fileName =
          '${supabase.auth.currentUser!.id}/${DateTime.now().millisecondsSinceEpoch}.jpg';
      String imageUrl;

      if (kIsWeb) {
        final imageBytes = await imageFile.readAsBytes();
        imageUrl = await _supabaseService.uploadImageBytes(
          imageBytes,
          'avatars',
          fileName,
        );
      } else {
        final file = File(imageFile.path);
        imageUrl = await _supabaseService.uploadImage(
          file,
          'avatars',
          fileName,
        );
      }

      await _supabaseService.updateProfile(
        username: _usernameController.text,
        avatarUrl: imageUrl,
      );
      _loadProfile();
      Get.snackbar('Sukses', 'Avatar berhasil diupload',
          backgroundColor: Colors.green, colorText: Colors.white);
    } catch (e) {
      Get.snackbar('Error', 'Gagal mengupload avatar: $e',
          backgroundColor: Colors.red, colorText: Colors.white);
    } finally {
      if (mounted) {
        setState(() {
          _isLoading = false;
        });
      }
    }
  }

  Future<void> _signOut() async {
    await supabase.auth.signOut();
    Get.offAllNamed(AppRoutes.login);
  }

  @override
  void dispose() {
    _usernameController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Profil Saya'),
        actions: [
          IconButton(onPressed: _signOut, icon: const Icon(Icons.logout))
        ],
      ),
      body: _isLoading
          ? const Center(child: CircularProgressIndicator())
          : _profile == null
              ? const Center(child: Text('Gagal memuat profil.'))
              : SingleChildScrollView(
                  padding: const EdgeInsets.all(16.0),
                  child: Column(
                    children: [
                      GestureDetector(
                        onTap: _uploadAvatar,
                        child: CircleAvatar(
                          radius: 60,
                          backgroundImage: (_profile?.avatarUrl != null)
                              ? NetworkImage(_profile!.avatarUrl!)
                              : null,
                          child: (_profile?.avatarUrl == null)
                              ? const Icon(Icons.person, size: 60)
                              : null,
                        ),
                      ),
                      const SizedBox(height: 8),
                      TextButton.icon(
                        onPressed: _uploadAvatar,
                        icon: const Icon(Icons.camera_alt),
                        label: const Text('Ganti Avatar'),
                      ),
                      const SizedBox(height: 20),
                      TextField(
                        controller: _usernameController,
                        decoration: const InputDecoration(
                          labelText: 'Username',
                          border: OutlineInputBorder(),
                        ),
                      ),
                      const SizedBox(height: 20),
                      SizedBox(
                        width: double.infinity,
                        child: ElevatedButton.icon(
                          onPressed: _updateProfile,
                          icon: const Icon(Icons.save),
                          label: const Text('Simpan Perubahan'),
                          style: ElevatedButton.styleFrom(
                              padding: const EdgeInsets.symmetric(vertical: 12)),
                        ),
                      ),
                      const SizedBox(height: 20),
                      const Divider(),
                      const SizedBox(height: 20),
                      SizedBox(
                        width: double.infinity,
                        child: OutlinedButton.icon(
                          onPressed: () => Get.toNamed(AppRoutes.notes),
                          icon: const Icon(Icons.note),
                          label: const Text('Lihat Catatan Saya'),
                          style: OutlinedButton.styleFrom(
                              padding: const EdgeInsets.symmetric(vertical: 12)),
                        ),
                      ),
                    ],
                  ),
                ),
    );
  }
}

6.5. Notes List Screen (Read & Delete)

Dart

// lib/screens/home/notes_list_screen.dart

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../models/note_model.dart';
import '../../services/supabase_service.dart';
import '../../utils/app_routes.dart';

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

  @override
  State<NotesListScreen> createState() => _NotesListScreenState();
}

class _NotesListScreenState extends State<NotesListScreen> {
  final SupabaseService _supabaseService = SupabaseService();
  List<Note> _notes = [];
  bool _isLoading = true;

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

  Future<void> _fetchNotes() async {
    setState(() {
      _isLoading = true;
    });
    try {
      final data = await _supabaseService.getNotes();
      setState(() {
        _notes = data.map((item) => Note.fromJson(item)).toList();
      });
    } catch (e) {
      Get.snackbar('Error', 'Gagal memuat catatan: $e',
          backgroundColor: Colors.red, colorText: Colors.white);
    } finally {
      if (mounted) {
        setState(() {
          _isLoading = false;
        });
      }
    }
  }

  Future<void> _deleteNote(int id) async {
    try {
      await _supabaseService.deleteNote(id);
      Get.snackbar('Sukses', 'Catatan berhasil dihapus',
          backgroundColor: Colors.green, colorText: Colors.white);
      _fetchNotes(); // Muat ulang daftar catatan
    } catch (e) {
      Get.snackbar('Error', 'Gagal menghapus catatan: $e',
          backgroundColor: Colors.red, colorText: Colors.white);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Catatan Saya'),
      ),
      body: _isLoading
          ? const Center(child: CircularProgressIndicator())
          : _notes.isEmpty
              ? const Center(
                  child: Text('Anda belum memiliki catatan. Buat satu!'),
                )
              : ListView.builder(
                  itemCount: _notes.length,
                  itemBuilder: (context, index) {
                    final note = _notes[index];
                    return Card(
                      margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
                      child: ListTile(
                        leading: note.imageUrl != null
                            ? Image.network(note.imageUrl!, width: 50, height: 50, fit: BoxFit.cover)
                            : const Icon(Icons.note, size: 40),
                        title: Text(note.title, style: const TextStyle(fontWeight: FontWeight.bold)),
                        subtitle: Text(note.content, maxLines: 2, overflow: TextOverflow.ellipsis),
                        trailing: Row(
                          mainAxisSize: MainAxisSize.min,
                          children: [
                            IconButton(
                              icon: const Icon(Icons.edit, color: Colors.blue),
                              onPressed: () async {
                                // Tunggu hasil dari halaman edit, jika true, muat ulang
                                final result = await Get.toNamed(AppRoutes.noteForm, arguments: note);
                                if (result == true) {
                                  _fetchNotes();
                                }
                              },
                            ),
                            IconButton(
                              icon: const Icon(Icons.delete, color: Colors.red),
                              onPressed: () => _showDeleteConfirmation(note.id),
                            ),
                          ],
                        ),
                      ),
                    );
                  },
                ),
      floatingActionButton: FloatingActionButton(
        onPressed: () async {
          final result = await Get.toNamed(AppRoutes.noteForm);
          if (result == true) {
            _fetchNotes();
          }
        },
        child: const Icon(Icons.add),
      ),
    );
  }

  void _showDeleteConfirmation(int id) {
    Get.defaultDialog(
      title: "Hapus Catatan",
      middleText: "Apakah Anda yakin ingin menghapus catatan ini?",
      textConfirm: "Ya, Hapus",
      textCancel: "Batal",
      confirmTextColor: Colors.white,
      onConfirm: () {
        Get.back(); // Tutup dialog
        _deleteNote(id);
      },
    );
  }
}

6.6. Note Form Screen (Create & Update)

Dart

// lib/screens/home/note_form_screen.dart

import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import '../../main.dart';
import '../../models/note_model.dart';
import '../../services/supabase_service.dart';
import '../../widgets/custom_input_field.dart';

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

  @override
  State<NoteFormScreen> createState() => _NoteFormScreenState();
}

class _NoteFormScreenState extends State<NoteFormScreen> {
  final SupabaseService _supabaseService = SupabaseService();
  final _formKey = GlobalKey<FormState>();
  final _titleController = TextEditingController();
  final _contentController = TextEditingController();

  bool _isLoading = false;
  Note? _existingNote;
  XFile? _imageFile;
  String? _existingImageUrl;

  @override
  void initState() {
    super.initState();
    // Cek apakah kita sedang mengedit note yang sudah ada
    if (Get.arguments is Note) {
      _existingNote = Get.arguments as Note;
      _titleController.text = _existingNote!.title;
      _contentController.text = _existingNote!.content;
      _existingImageUrl = _existingNote!.imageUrl;
    }
  }

  Future<void> _pickImage() async {
    final picker = ImagePicker();
    final imageFile = await picker.pickImage(source: ImageSource.gallery);
    if (imageFile != null) {
      setState(() {
        _imageFile = imageFile;
      });
    }
  }

  Future<void> _submit() async {
    if (_formKey.currentState!.validate()) {
      setState(() {
        _isLoading = true;
      });

      try {
        String? imageUrl = _existingImageUrl;

        // Jika ada gambar baru yang dipilih, upload
        if (_imageFile != null) {
          final fileName =
              '${supabase.auth.currentUser!.id}/${DateTime.now().millisecondsSinceEpoch}.jpg';
          if (kIsWeb) {
            final imageBytes = await _imageFile!.readAsBytes();
            imageUrl = await _supabaseService.uploadImageBytes(
                imageBytes, 'notes-images', fileName);
          } else {
            final file = File(_imageFile!.path);
            imageUrl = await _supabaseService.uploadImage(
                file, 'notes-images', fileName);
          }
        }
        
        if (_existingNote != null) {
          // Update note
          await _supabaseService.updateNote(
            id: _existingNote!.id,
            title: _titleController.text,
            content: _contentController.text,
            imageUrl: imageUrl,
          );
        } else {
          // Tambah note baru
          await _supabaseService.addNote(
            title: _titleController.text,
            content: _contentController.text,
            imageUrl: imageUrl,
          );
        }
        // Kembalikan `true` untuk menandakan sukses
        Get.back(result: true);
      } catch (e) {
        Get.snackbar('Error', 'Gagal menyimpan catatan: $e',
            backgroundColor: Colors.red, colorText: Colors.white);
      } finally {
        if (mounted) {
          setState(() {
            _isLoading = false;
          });
        }
      }
    }
  }

  @override
  void dispose() {
    _titleController.dispose();
    _contentController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(_existingNote == null ? 'Tambah Catatan' : 'Edit Catatan'),
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16.0),
        child: Form(
          key: _formKey,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              // Pratinjau Gambar
              _imageFile != null
                  ? (kIsWeb
                      ? Image.network(_imageFile!.path, height: 150, fit: BoxFit.cover)
                      : Image.file(File(_imageFile!.path), height: 150, fit: BoxFit.cover))
                  : (_existingImageUrl != null
                      ? Image.network(_existingImageUrl!, height: 150, fit: BoxFit.cover)
                      : Container(
                          height: 150,
                          color: Colors.grey[200],
                          child: const Icon(Icons.image, size: 50, color: Colors.grey),
                        )),
              const SizedBox(height: 8),
              TextButton.icon(
                onPressed: _pickImage,
                icon: const Icon(Icons.image),
                label: const Text('Pilih Gambar'),
              ),
              const SizedBox(height: 16),
              CustomInputField(
                controller: _titleController,
                labelText: 'Judul',
                validator: (value) => value!.isEmpty ? 'Judul tidak boleh kosong' : null,
              ),
              const SizedBox(height: 16),
              CustomInputField(
                controller: _contentController,
                labelText: 'Isi Catatan',
                maxLines: 5,
                validator: (value) => value!.isEmpty ? 'Isi catatan tidak boleh kosong' : null,
              ),
              const SizedBox(height: 24),
              _isLoading
                  ? const Center(child: CircularProgressIndicator())
                  : ElevatedButton.icon(
                      onPressed: _submit,
                      icon: const Icon(Icons.save),
                      label: const Text('Simpan Catatan'),
                      style: ElevatedButton.styleFrom(
                          padding: const EdgeInsets.symmetric(vertical: 16)),
                    ),
            ],
          ),
        ),
      ),
    );
  }
}

7. Custom Widget

Widget yang dapat digunakan kembali untuk input form.

Dart

// lib/widgets/custom_input_field.dart

import 'package:flutter/material.dart';

class CustomInputField extends StatelessWidget {
  final TextEditingController controller;
  final String labelText;
  final bool obscureText;
  final TextInputType keyboardType;
  final String? Function(String?)? validator;
  final int? maxLines;

  const CustomInputField({
    super.key,
    required this.controller,
    required this.labelText,
    this.obscureText = false,
    this.keyboardType = TextInputType.text,
    this.validator,
    this.maxLines = 1,
  });

  @override
  Widget build(BuildContext context) {
    return TextFormField(
      controller: controller,
      obscureText: obscureText,
      keyboardType: keyboardType,
      validator: validator,
      maxLines: maxLines,
      decoration: InputDecoration(
        labelText: labelText,
        border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(8.0),
        ),
        filled: true,
        fillColor: Colors.grey[100],
      ),
    );
  }
}

Jalankan aplikasi Anda dengan perintah flutter run di terminal.

dapat juga "f5" atau run -> start debugging

Aplikasi sekarang memiliki semua fitur yang diminta: alur otentikasi, manajemen profil dengan gambar, dan CRUD lengkap untuk catatan dengan gambar.

link github https://github.com/triyono777/flutter_supabase_notes_app

apabila menggunakan git clone

jangan lupa

di terminal jalankan "flutter pub get"

apabila ada terkendala sdk silakan ubah berikut sesuai versi sdk kalian

Last updated

Was this helpful?