Praktikum Pemrograman Mobile — Layout Responsif & Navigasi Aplikasi

 Panduan praktikum step by step yang bisa langsung dipakai di modul atau lembar kerja siswa.

Panduan ini sudah mencakup dua praktikum (Layout Responsif + Navigasi Aplikasi) dalam satu project, lengkap dengan penjelasan setiap bagian kodenya.


🧑‍💻 Panduan Praktikum Flutter

Praktikum 1 & 2 — Layout Responsif & Navigasi Aplikasi

Target:

  • Siswa memahami layout responsif menggunakan ColumnRowFlexibleExpanded, dan LayoutBuilder.

  • Siswa mampu membuat navigasi antar-halaman menggunakan Navigator.push() dan BottomNavigationBar.


📌 Langkah-Langkah Praktikum

1️⃣ Membuat Project Baru

  1. Buka zapp.run.

  2. Pilih template Flutter.

  3. Klik Create → Akan terbuka file main.dart.

  4. Hapus kode bawaan dan ganti dengan kode berikut.


2️⃣ Salin Kode Berikut

import 'dart:async';
import 'package:flutter/material.dart';

void main() {
  runApp(const WisataApp());
}

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

  @override
  State<WisataApp> createState() => _WisataAppState();
}

class _WisataAppState extends State<WisataApp> {
  ThemeMode _themeMode = ThemeMode.light;

  void _toggleTheme(bool isDark) {
    setState(() {
      _themeMode = isDark ? ThemeMode.dark : ThemeMode.light;
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Aplikasi Wisata - Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
        useMaterial3: true,
      ),
      darkTheme: ThemeData.dark(useMaterial3: true),
      themeMode: _themeMode,
      home: RootPage(onThemeChanged: _toggleTheme, isDark: _themeMode == ThemeMode.dark),
    );
  }
}

// Model data destinasi wisata
class Destination {
  final String title;
  final String location;
  final List<String> images;
  final String description;

  Destination(
      {required this.title,
      required this.location,
      required this.images,
      required this.description});
}

final destinations = [
  Destination(
    title: 'Pantai Pasir Putih',
    location: 'Pesisir Selatan',
    images: [
      'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=1200',
      'https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=1200',
      'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1200',
    ],
    description: 'Pasir putih lembut, air jernih, sunset menawan.',
  ),
  Destination(
    title: 'Bukit Hijau',
    location: 'Dataran Tinggi',
    images: [
      'https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=1200',
      'https://images.unsplash.com/photo-1508672019048-805c876b67e2?w=1200',
      'https://images.unsplash.com/photo-1470770903676-69b98201ea1c?w=1200',
    ],
    description: 'Panorama lembah dan jalur trekking yang menyejukkan.',
  ),
];

class RootPage extends StatefulWidget {
  final Function(bool) onThemeChanged;
  final bool isDark;
  const RootPage({super.key, required this.onThemeChanged, required this.isDark});

  @override
  State<RootPage> createState() => _RootPageState();
}

class _RootPageState extends State<RootPage> {
  int _selectedIndex = 0;
  final PageController _pageController = PageController();
  int _currentCarouselIndex = 0;
  Timer? _carouselTimer;
  final List<Destination> _favorites = [];

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

  void _startAutoSlide() {
    _carouselTimer = Timer.periodic(const Duration(seconds: 3), (timer) {
      if (_pageController.hasClients) {
        setState(() {
          _currentCarouselIndex =
              (_currentCarouselIndex + 1) % destinations.length;
          _pageController.animateToPage(
            _currentCarouselIndex,
            duration: const Duration(milliseconds: 400),
            curve: Curves.easeInOut,
          );
        });
      }
    });
  }

  void _onItemTapped(int index) {
    setState(() => _selectedIndex = index);
  }

  void _toggleFavorite(Destination d) {
    setState(() {
      if (_favorites.contains(d)) {
        _favorites.remove(d);
      } else {
        _favorites.add(d);
      }
    });
  }

  @override
  void dispose() {
    _carouselTimer?.cancel();
    _pageController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final pages = [
      _buildHome(),
      FavoritesPage(favorites: _favorites, onToggle: _toggleFavorite),
      ProfilePage(
        isDark: widget.isDark,
        onThemeToggle: widget.onThemeChanged,
      ),
    ];

    return Scaffold(
      appBar: AppBar(
        title: const Text('Keindahan Wisata Nusantara'),
        centerTitle: true,
      ),
      body: pages[_selectedIndex],
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _selectedIndex,
        onTap: _onItemTapped,
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
          BottomNavigationBarItem(icon: Icon(Icons.favorite), label: 'Favorit'),
          BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profil'),
        ],
      ),
    );
  }

  Widget _buildHome() {
    return SafeArea(
      child: LayoutBuilder(
        builder: (context, constraints) {
          final wide = constraints.maxWidth > 700;
          return Column(
            children: [
              SizedBox(
                height: wide ? 300 : 200,
                child: PageView.builder(
                  controller: _pageController,
                  itemCount: destinations.length,
                  onPageChanged: (i) => setState(() => _currentCarouselIndex = i),
                  itemBuilder: (context, index) {
                    final d = destinations[index];
                    return GestureDetector(
                      onTap: () => Navigator.push(
                          context,
                          MaterialPageRoute(
                              builder: (_) => DetailPage(
                                  destination: d,
                                  onFav: _toggleFavorite,
                                  isFav: _favorites.contains(d)))),
                      child: Stack(
                        fit: StackFit.expand,
                        children: [
                          Image.network(d.images[0], fit: BoxFit.cover),
                          Container(
                            alignment: Alignment.bottomLeft,
                            padding: const EdgeInsets.all(16),
                            color: Colors.black26,
                            child: Text(d.title,
                                style: const TextStyle(
                                    color: Colors.white,
                                    fontSize: 20,
                                    fontWeight: FontWeight.bold)),
                          )
                        ],
                      ),
                    );
                  },
                ),
              ),
              Expanded(
                child: wide
                    ? Row(
                        children: [
                          Expanded(
                              child: DestinationsGrid(
                                  onFav: _toggleFavorite,
                                  favorites: _favorites)),
                          const SizedBox(width: 12),
                          const Expanded(child: PromoPanel()),
                        ],
                      )
                    : Column(
                        children: [
                          Expanded(
                              child: DestinationsGrid(
                                  onFav: _toggleFavorite,
                                  favorites: _favorites)),
                          const PromoPanel(),
                        ],
                      ),
              ),
            ],
          );
        },
      ),
    );
  }
}

class DestinationsGrid extends StatelessWidget {
  final Function(Destination) onFav;
  final List<Destination> favorites;
  const DestinationsGrid(
      {super.key, required this.onFav, required this.favorites});

  @override
  Widget build(BuildContext context) {
    return GridView.builder(
      padding: const EdgeInsets.all(12),
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 2,
        childAspectRatio: 1.5,
        crossAxisSpacing: 8,
        mainAxisSpacing: 8,
      ),
      itemCount: destinations.length,
      itemBuilder: (context, index) {
        final d = destinations[index];
        final isFav = favorites.contains(d);
        return GestureDetector(
          onTap: () => Navigator.push(
              context,
              MaterialPageRoute(
                  builder: (_) =>
                      DetailPage(destination: d, onFav: onFav, isFav: isFav))),
          child: Card(
            shape:
                RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
            child: Column(
              children: [
                Expanded(
                  child: ClipRRect(
                    borderRadius:
                        const BorderRadius.vertical(top: Radius.circular(12)),
                    child: Stack(
                      children: [
                        Image.network(d.images[1],
                            fit: BoxFit.cover, width: double.infinity),
                        Positioned(
                          top: 8,
                          right: 8,
                          child: IconButton(
                            icon: Icon(
                                isFav
                                    ? Icons.favorite
                                    : Icons.favorite_border,
                                color: Colors.red),
                            onPressed: () => onFav(d),
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
                Padding(
                  padding: const EdgeInsets.all(8.0),
                  child:
                      Text(d.title, style: const TextStyle(fontWeight: FontWeight.bold)),
                )
              ],
            ),
          ),
        );
      },
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: const [
            Text('Promo & Info', style: TextStyle(fontWeight: FontWeight.bold)),
            SizedBox(height: 8),
            Text('• Diskon tiket akhir pekan'),
            Text('• Paket edukasi study tour'),
          ],
        ),
      ),
    );
  }
}

class DetailPage extends StatelessWidget {
  final Destination destination;
  final Function(Destination) onFav;
  final bool isFav;
  const DetailPage(
      {super.key,
      required this.destination,
      required this.onFav,
      required this.isFav});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(destination.title),
        actions: [
          IconButton(
            icon: Icon(isFav ? Icons.favorite : Icons.favorite_border),
            onPressed: () => onFav(destination),
          )
        ],
      ),
      body: ListView(
        padding: const EdgeInsets.all(12),
        children: [
          SizedBox(
            height: 250,
            child: PageView(
              children: destination.images
                  .map((img) => ClipRRect(
                      borderRadius: BorderRadius.circular(12),
                      child: Image.network(img, fit: BoxFit.cover)))
                  .toList(),
            ),
          ),
          const SizedBox(height: 12),
          Text(destination.location,
              style: const TextStyle(fontWeight: FontWeight.w600)),
          const SizedBox(height: 8),
          Text(destination.description),
        ],
      ),
    );
  }
}

class FavoritesPage extends StatelessWidget {
  final List<Destination> favorites;
  final Function(Destination) onToggle;
  const FavoritesPage(
      {super.key, required this.favorites, required this.onToggle});

  @override
  Widget build(BuildContext context) {
    if (favorites.isEmpty) {
      return const Center(child: Text('Belum ada destinasi favorit'));
    }
    return ListView.builder(
      padding: const EdgeInsets.all(12),
      itemCount: favorites.length,
      itemBuilder: (context, index) {
        final d = favorites[index];
        return Card(
          child: ListTile(
            leading: ClipRRect(
                borderRadius: BorderRadius.circular(8),
                child: Image.network(d.images[0],
                    width: 60, fit: BoxFit.cover)),
            title: Text(d.title),
            subtitle: Text(d.location),
            trailing: IconButton(
                icon: const Icon(Icons.remove_circle),
                onPressed: () => onToggle(d)),
            onTap: () => Navigator.push(
                context,
                MaterialPageRoute(
                    builder: (_) =>
                        DetailPage(destination: d, onFav: onToggle, isFav: true))),
          ),
        );
      },
    );
  }
}

class ProfilePage extends StatelessWidget {
  final bool isDark;
  final Function(bool) onThemeToggle;

  const ProfilePage(
      {super.key, required this.isDark, required this.onThemeToggle});

  @override
  Widget build(BuildContext context) {
    return ListView(
      padding: const EdgeInsets.all(20),
      children: [
        const CircleAvatar(
          radius: 50,
          backgroundImage: NetworkImage(
              'https://i.pravatar.cc/150?img=3'), // avatar dummy
        ),
        const SizedBox(height: 12),
        const Center(
            child: Text('Budi Santoso',
                style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold))),
        const Center(child: Text('budi.santoso@example.com')),
        const Divider(height: 40),
        SwitchListTile(
          title: const Text('Mode Gelap'),
          value: isDark,
          onChanged: onThemeToggle,
          secondary: const Icon(Icons.dark_mode),
        ),
        const ListTile(
          leading: Icon(Icons.settings),
          title: Text('Pengaturan Akun'),
        ),
        const ListTile(
          leading: Icon(Icons.logout),
          title: Text('Keluar'),
        ),
      ],
    );
  }
}


3️⃣ Jalankan Program

  • Klik tombol ▶ Run di zapp.run.

  • Amati tampilan layout

  • Klik tombol "Home" atau ikon Favorit → akan pindah ke halaman sesuai menu.


📑 Tabel Penjelasan Kode – Aplikasi Wisata Flutter

Bagian Kode Penjelasan Konsep Flutter
runApp(const WisataApp()); Menjalankan widget utama WisataApp. Entry Point aplikasi Flutter.
class WisataApp extends StatelessWidget Widget utama yang mengatur tema aplikasi dan halaman awal (home: RootPage()). StatelessWidget – tidak menyimpan state, hanya membangun UI.
Destination class Model data untuk destinasi wisata (judul, lokasi, gambar, deskripsi). Model Data untuk memudahkan pengelolaan informasi.
sampleDestinations List berisi contoh data destinasi wisata. List & Object di Dart.
class RootPage extends StatefulWidget Halaman utama yang menyimpan state (index navigasi, controller karosel). StatefulWidget – dapat berubah sesuai interaksi user.
_selectedIndex Menyimpan halaman aktif (Home, Favorites, Profile). State Management sederhana.
_carouselController Mengontrol pergerakan PageView pada karosel. PageController – mengatur slide.
_buildHomeContent() Menyusun tampilan halaman Home: sidebar, karosel, grid destinasi, panel promo. Column, Row, Expanded, LayoutBuilder untuk layout responsif.
PageView.builder(...) Menampilkan karosel gambar destinasi. PageView – membuat slide horizontal.
Expanded(...) Membuat widget mengambil sisa ruang yang tersedia. Expanded – konsep layout fleksibel.
LayoutBuilder(...) Mengecek lebar layar (constraints.maxWidth) untuk menentukan apakah layout lebar atau sempit. Responsif – menyesuaikan layout untuk desktop/tablet/HP.
_buildFavoritesPage() Menampilkan placeholder jika belum ada destinasi favorit. Column + Icon + Text dalam Center.
_buildProfilePage() Menampilkan informasi pengguna dan deskripsi aplikasi. SingleChildScrollView agar halaman dapat digeser ke bawah.
BottomNavigationBar(...) Navigasi bawah untuk berpindah antara Home, Favorites, Profile. BottomNavigationBar + setState() untuk mengubah halaman.
SideBar & AppDrawer Sidebar untuk layar lebar, Drawer untuk layar kecil. Drawer – menu geser dari samping.
DestinationsGrid(...) Menampilkan daftar destinasi dalam bentuk grid responsif. GridView.builder – list grid dengan jumlah kolom dinamis.
DestinationCard(...) Kartu destinasi: gambar kiri + teks kanan + tombol. Card + Row + Flexible untuk layout responsif.
DetailPage(...) Halaman detail destinasi, menampilkan karosel gambar & deskripsi lengkap. Navigator.push() – pindah halaman.
DetailImageCarousel(...) Karosel di halaman detail dengan dot indicator. StatefulWidget + PageView untuk menampilkan slide dengan indikator aktif.
SafeArea(...) Melindungi konten dari area tertutup (notch, status bar, bottom nav). SafeArea – membuat UI aman dari overlap.

🎯 Poin Pembelajaran untuk Siswa

  • Layout Responsif: Gunakan Column, Row, Expanded, Flexible, dan LayoutBuilder untuk menyesuaikan tampilan di berbagai ukuran layar.

  • Navigasi: Gunakan Navigator.push() untuk pindah ke halaman baru (misal DetailPage) dan BottomNavigationBar untuk navigasi antar-halaman utama.

  • State Management: Gunakan StatefulWidget dan setState() untuk mengubah tampilan saat user berinteraksi (misal berpindah tab).

  • Komponen UI: Kenali penggunaan Card, GridView, Drawer, dan PageView untuk membuat aplikasi menarik dan interaktif.


🎯 Tugas Siswa

  1. Tambahkan 1 destinasi baru dengan gambar dan deskripsi sendiri.
  2. Ubah durasi auto-slide carousel menjadi 5 detik.
  3. Buat halaman profil dengan nama & foto kalian.

💡 Tips Guru

  • Ajak siswa membandingkan hasil di layar HP dan di layar PC untuk memahami konsep responsif.

  • Diskusikan kapan sebaiknya menggunakan Column + Row dibanding GridView.

  • Tantang siswa untuk membuat tema warna sendiri menggunakan ThemeData.


Salam : Redaksi

Share:

Tidak ada komentar:

Posting Komentar

Blogger Tricks

Blogger Themes