🚀 Bikin Aplikasi Jadwal Penerbangan Offline Keren dengan Flutter Web (Pakai zapp.run) ✈️

Halo gengs! 👋
Flight Schedule App ala Developer Kekinian

PRO TIP Bukan To Do List biasa, tapi versi premium yang nyimpen data di localStorage. Aman kayak password mantan! 🔒

🔥 Fitur-Fitur Super Keren

🛫

Tambah Jadwal

Input rute & jam, klik Add, langsung muncul di list. Gampang banget!

✏️

Edit Jadwal

Salah ketik? Edit aja langsung, nggak perlu ribet hapus dulu.

✈️

Animasi Pesawat

Ada efek smooth kayak pesawat mau take off. Keren banget!

💻 Kode Lengkap Aplikasi

lib/main.dart

// lib/main.dart
// Flight Schedule — Full Version (Web / zapp.run)
// Features: CRUD, Edit, Delete, Duplicate, Favorite, Search, Filter, Sort,
// Detail view, Confirm delete, Export/Import JSON (copy, download, upload),
// animated plane on scroll, per-item slide+fade, glassmorphism cards,
// gradient background, FAB pulse, dark/light toggle.

import 'dart:convert';
import 'dart:html' as html;
import 'dart:math';
import 'package:flutter/material.dart';

void main() => runApp(const FlightApp());

const String storageKey = 'flight_app_data_v2';

class FlightApp extends StatefulWidget {
  const FlightApp({super.key});
  @override
  State createState() => _FlightAppState();
}

class _FlightAppState extends State {
  bool _isDark = true;
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flight Schedule - Full',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        brightness: _isDark ? Brightness.dark : Brightness.light,
        primarySwatch: Colors.indigo,
        useMaterial3: false,
      ),
      home: FlightHomePage(
        isDark: _isDark,
        onToggleTheme: () => setState(() => _isDark = !_isDark),
      ),
    );
  }
}

class FlightItem {
  String id;
  String code;
  String from;
  String to;
  String time;
  bool favorite;
  int iconIndex;

  FlightItem({
    required this.id,
    required this.code,
    required this.from,
    required this.to,
    required this.time,
    this.favorite = false,
    required this.iconIndex,
  });

  Map toJson() => {
        'id': id,
        'code': code,
        'from': from,
        'to': to,
        'time': time,
        'favorite': favorite,
        'iconIndex': iconIndex,
      };

  factory FlightItem.fromJson(Map j) {
    return FlightItem(
      id: j['id'] ?? UniqueKey().toString(),
      code: j['code'] ?? '',
      from: j['from'] ?? '',
      to: j['to'] ?? '',
      time: j['time'] ?? '',
      favorite: j['favorite'] ?? false,
      iconIndex: j['iconIndex'] ?? 0,
    );
  }
}

class FlightHomePage extends StatefulWidget {
  final bool isDark;
  final VoidCallback onToggleTheme;
  const FlightHomePage({super.key, required this.isDark, required this.onToggleTheme});

  @override
  State createState() => _FlightHomePageState();
}

class _FlightHomePageState extends State
    with SingleTickerProviderStateMixin {
  final ScrollController _scrollController = ScrollController();
  final TextEditingController _searchCtrl = TextEditingController();

  List _flights = [];
  List _visible = [];

  String _sortBy = 'Newest';
  String _filterBy = 'All';
  final Random _rnd = Random();

  final List _planeIcons = [
    Icons.flight_takeoff,
    Icons.airplanemode_active,
    Icons.flight,
    Icons.flight_land,
    Icons.local_airport,
  ];

  late final AnimationController _fabController;
  late final Animation _fabAnim;

  double _planePositionFraction = 0.0;

  @override
  void initState() {
    super.initState();
    _fabController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 900),
    );
    _fabAnim = Tween(begin: 1.0, end: 1.07).animate(
      CurvedAnimation(parent: _fabController, curve: Curves.easeInOut),
    );
    _fabController.repeat(reverse: true);

    _searchCtrl.addListener(_applyFilters);
    _scrollController.addListener(_onScroll);

    _loadData();
  }

  void _onScroll() {
    if (!_scrollController.hasClients) return;
    final max = _scrollController.position.maxScrollExtent;
    final pos = _scrollController.position.pixels;
    final frac = max <= 0 ? 0.0 : (pos / max).clamp(0.0, 1.0);
    setState(() => _planePositionFraction = frac);
  }

  @override
  void dispose() {
    _fabController.dispose();
    _scrollController.dispose();
    _searchCtrl.dispose();
    super.dispose();
  }

  // ---------- Storage ----------
  void _loadData() {
    final raw = html.window.localStorage[storageKey];
    if (raw == null) {
      // seed examples
      _flights = [
        FlightItem(
          id: UniqueKey().toString(),
          code: 'GA123',
          from: 'Jakarta (CGK)',
          to: 'Bali (DPS)',
          time: '2025-08-20 10:30',
          iconIndex: _rnd.nextInt(_planeIcons.length),
        ),
        FlightItem(
          id: UniqueKey().toString(),
          code: 'ID456',
          from: 'Jakarta (CGK)',
          to: 'Surabaya (SUB)',
          time: '2025-08-20 12:00',
          iconIndex: _rnd.nextInt(_planeIcons.length),
        ),
      ];
      _saveData();
    } else {
      try {
        final List decoded = jsonDecode(raw);
        _flights = decoded
            .map((e) => FlightItem.fromJson(Map.from(e)))
            .toList();
      } catch (e) {
        // corrupt -> clear and start new
        html.window.localStorage.remove(storageKey);
        _flights = [];
      }
    }
    _applyFilters();
  }

  void _saveData() {
    final jsonData = _flights.map((f) => f.toJson()).toList();
    html.window.localStorage[storageKey] = jsonEncode(jsonData);
  }

  // ---------- Filters & Sorting ----------
  void _applyFilters() {
    final q = _searchCtrl.text.trim().toLowerCase();
    final f = _filterBy;
    List list = _flights.where((flight) {
      final matchesQuery = q.isEmpty ||
          flight.code.toLowerCase().contains(q) ||
          flight.from.toLowerCase().contains(q) ||
          flight.to.toLowerCase().contains(q);
      final matchesFilter = (f == 'All') ||
          (f == 'Outbound' && flight.from.toLowerCase().contains('jakarta')) ||
          (f == 'Inbound' && !flight.from.toLowerCase().contains('jakarta'));
      return matchesQuery && matchesFilter;
    }).toList();

    list.sort((a, b) {
      if (_sortBy == 'A → Z') return a.to.compareTo(b.to);
      if (_sortBy == 'Z → A') return b.to.compareTo(a.to);
      if (_sortBy == 'Earliest') {
        final da = DateTime.tryParse(a.time);
        final db = DateTime.tryParse(b.time);
        if (da != null && db != null) return da.compareTo(db);
        return a.time.compareTo(b.time);
      }
      if (_sortBy == 'Latest') {
        final da = DateTime.tryParse(a.time);
        final db = DateTime.tryParse(b.time);
        if (da != null && db != null) return db.compareTo(da);
        return b.time.compareTo(a.time);
      }
      // newest by insertion order
      return _flights.indexOf(b).compareTo(_flights.indexOf(a));
    });

    setState(() => _visible = list);
  }

  // ---------- CRUD ----------
  void _openAddEdit({FlightItem? existing, int? index}) {
    final codeCtrl = TextEditingController(text: existing?.code ?? '');
    final fromCtrl = TextEditingController(text: existing?.from ?? '');
    final toCtrl = TextEditingController(text: existing?.to ?? '');
    final timeCtrl = TextEditingController(text: existing?.time ?? '');

    showModalBottomSheet(
      context: context,
      isScrollControlled: true,
      backgroundColor: Colors.transparent,
      builder: (_) {
        return AnimatedPadding(
          duration: const Duration(milliseconds: 200),
          padding:
              EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
          child: Container(
            margin: const EdgeInsets.symmetric(horizontal: 12),
            padding: const EdgeInsets.all(16),
            decoration: BoxDecoration(
              color: Theme.of(context).canvasColor.withOpacity(0.98),
              borderRadius: BorderRadius.circular(12),
              boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.2), blurRadius: 8)],
            ),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                Row(children: [
                  Expanded(
                      child: Text(
                    existing == null ? 'Tambah Jadwal Baru' : 'Edit Jadwal',
                    style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
                  )),
                  IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close))
                ]),
                const SizedBox(height: 8),
                TextField(controller: codeCtrl, decoration: const InputDecoration(labelText: 'Kode (ex: GA123)')),
                const SizedBox(height: 8),
                TextField(controller: fromCtrl, decoration: const InputDecoration(labelText: 'Keberangkatan (ex: Jakarta (CGK))')),
                const SizedBox(height: 8),
                TextField(controller: toCtrl, decoration: const InputDecoration(labelText: 'Tujuan (ex: Bali (DPS))')),
                const SizedBox(height: 8),
                TextField(controller: timeCtrl, decoration: const InputDecoration(labelText: 'Waktu (YYYY-MM-DD HH:MM or text)')),
                const SizedBox(height: 12),
                Row(children: [
                  Expanded(
                    child: ElevatedButton.icon(
                      icon: const Icon(Icons.save),
                      label: Text(existing == null ? 'Tambah' : 'Simpan Perubahan'),
                      onPressed: () {
                        final code = codeCtrl.text.trim();
                        final from = fromCtrl.text.trim();
                        final to = toCtrl.text.trim();
                        final time = timeCtrl.text.trim();
                        if (code.isEmpty || from.isEmpty || to.isEmpty || time.isEmpty) {
                          ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Isi semua field')));
                          return;
                        }

                        if (existing == null) {
                          _flights.add(FlightItem(
                            id: UniqueKey().toString(),
                            code: code,
                            from: from,
                            to: to,
                            time: time,
                            iconIndex: _rnd.nextInt(_planeIcons.length),
                          ));
                        } else {
                          _flights[index!] = FlightItem(
                            id: existing.id,
                            code: code,
                            from: from,
                            to: to,
                            time: time,
                            favorite: existing.favorite,
                            iconIndex: existing.iconIndex,
                          );
                        }

                        _saveData();
                        _applyFilters();
                        Navigator.pop(context);
                      },
                      style: ElevatedButton.styleFrom(backgroundColor: Colors.blueAccent),
                    ),
                  ),
                  const SizedBox(width: 8),
                  if (existing != null)
                    IconButton(
                      icon: const Icon(Icons.delete_forever, color: Colors.redAccent),
                      tooltip: 'Hapus',
                      onPressed: () {
                        Navigator.pop(context);
                        _confirmDelete(index!);
                      },
                    ),
                ]),
              ],
            ),
          ),
        );
      },
    );
  }

  void _confirmDelete(int index) {
    showDialog(
      context: context,
      builder: (_) => AlertDialog(
        title: const Text('Konfirmasi Hapus'),
        content: const Text('Yakin ingin menghapus jadwal ini?'),
        actions: [
          TextButton(onPressed: () => Navigator.pop(context), child: const Text('Batal')),
          ElevatedButton(
            onPressed: () {
              _flights.removeAt(index);
              _saveData();
              _applyFilters();
              Navigator.pop(context);
            },
            style: ElevatedButton.styleFrom(backgroundColor: Colors.redAccent),
            child: const Text('Hapus'),
          ),
        ],
      ),
    );
  }

  void _duplicate(int index) {
    final src = _flights[index];
    final dup = FlightItem(
      id: UniqueKey().toString(),
      code: '${src.code}_copy',
      from: src.from,
      to: src.to,
      time: src.time,
      favorite: src.favorite,
      iconIndex: src.iconIndex,
    );
    _flights.add(dup);
    _saveData();
    _applyFilters();
    ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Jadwal diduplikasi')));
  }

  void _toggleFavorite(int index) {
    _flights[index].favorite = !_flights[index].favorite;
    _saveData();
    _applyFilters();
  }

  // ---------- Export / Import ----------
  void _exportJsonToDialog() {
    final jsonData = jsonEncode(_flights.map((f) => f.toJson()).toList());
    showDialog(
      context: context,
      builder: (_) => AlertDialog(
        title: const Text('Export JSON'),
        content: SizedBox(
          width: double.maxFinite,
          child: SingleChildScrollView(child: SelectableText(jsonData)),
        ),
        actions: [
          TextButton(onPressed: () => Navigator.pop(context), child: const Text('Tutup')),
          ElevatedButton(
            onPressed: () {
              final blob = Uri.dataFromString(jsonData, mimeType: 'application/json', encoding: utf8).toString();
              final anchor = html.AnchorElement(href: blob)
                ..setAttribute('download', 'flight_export.json')
                ..click();
            },
            child: const Text('Download JSON'),
          )
        ],
      ),
    );
  }

  Future _importJsonFromFile() async {
    final input = html.FileUploadInputElement();
    input.accept = '.json,application/json,text/json';
    input.click();
    input.onChange.listen((_) {
      final files = input.files;
      if (files == null || files.isEmpty) return;
      final file = files[0];
      final reader = html.FileReader();
      reader.readAsText(file);
      reader.onLoad.listen((e) {
        try {
          final decoded = jsonDecode(reader.result as String) as List;
          _flights = decoded.map((e) => FlightItem.fromJson(Map.from(e))).toList();
          _saveData();
          _applyFilters();
          ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Import file berhasil')));
        } catch (err) {
          ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('File JSON tidak valid')));
        }
      });
    });
  }

  void _importJsonPasteDialog() {
    final ctrl = TextEditingController();
    showDialog(
      context: context,
      builder: (_) => AlertDialog(
        title: const Text('Import JSON (paste)'),
        content: TextField(controller: ctrl, maxLines: 8, decoration: const InputDecoration(hintText: 'Tempel JSON di sini')),
        actions: [
          TextButton(onPressed: () => Navigator.pop(context), child: const Text('Batal')),
          ElevatedButton(
            onPressed: () {
              try {
                final decoded = jsonDecode(ctrl.text) as List;
                _flights = decoded.map((e) => FlightItem.fromJson(Map.from(e))).toList();
                _saveData();
                _applyFilters();
                Navigator.pop(context);
                ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Import berhasil')));
              } catch (e) {
                ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('JSON tidak valid')));
              }
            },
            child: const Text('Import'),
          ),
        ],
      ),
    );
  }

  // ---------- Detail ----------
  void _openDetail(FlightItem item) {
    Navigator.push(
      context,
      PageRouteBuilder(
        transitionDuration: const Duration(milliseconds: 420),
        pageBuilder: (_, __, ___) => FlightDetailPage(item: item, icon: _planeIcons[item.iconIndex % _planeIcons.length]),
        transitionsBuilder: (_, a, __, child) {
          final curved = Curves.easeOut.transform(a.value);
          return FadeTransition(opacity: a, child: Transform.translate(offset: Offset(0, 40 * (1 - curved)), child: child));
        },
      ),
    );
  }

  // ---------- UI pieces ----------
  Widget _topBar() {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
      child: Row(children: [
        Expanded(
          child: Container(
            padding: const EdgeInsets.symmetric(horizontal: 12),
            decoration: BoxDecoration(color: Colors.white.withOpacity(0.04), borderRadius: BorderRadius.circular(12)),
            child: Row(children: [
              const Icon(Icons.search, color: Colors.white70),
              const SizedBox(width: 8),
              Expanded(
                child: TextField(
                  controller: _searchCtrl,
                  style: const TextStyle(color: Colors.white),
                  decoration: const InputDecoration.collapsed(hintText: 'Cari kode / tujuan / keberangkatan', hintStyle: TextStyle(color: Colors.white54)),
                ),
              ),
              if (_searchCtrl.text.isNotEmpty)
                IconButton(onPressed: () { _searchCtrl.clear(); _applyFilters(); }, icon: const Icon(Icons.clear, color: Colors.white70))
            ]),
          ),
        ),
        const SizedBox(width: 10),
        PopupMenuButton(
          color: Colors.grey[900],
          icon: const Icon(Icons.tune, color: Colors.white),
          onSelected: (v) {
            if (v == 'export_dialog') _exportJsonToDialog();
            else if (v == 'import_file') _importJsonFromFile();
            else if (v == 'import_paste') _importJsonPasteDialog();
            else {
              if (v == 'All' || v == 'Outbound' || v == 'Inbound') _filterBy = v;
              else _sortBy = v;
              _applyFilters();
            }
          },
          itemBuilder: (_) => >[
            const PopupMenuItem(value: 'Newest', child: Text('Sort: Newest')),
            const PopupMenuItem(value: 'Earliest', child: Text('Sort: Earliest')),
            const PopupMenuItem(value: 'Latest', child: Text('Sort: Latest')),
            const PopupMenuItem(value: 'A → Z', child: Text('Sort: Destination A → Z')),
            const PopupMenuItem(value: 'Z → A', child: Text('Sort: Destination Z → A')),
            const PopupMenuDivider(),
            const PopupMenuItem(value: 'All', child: Text('Filter: All')),
            const PopupMenuItem(value: 'Outbound', child: Text('Filter: Outbound (From Jakarta)')),
            const PopupMenuItem(value: 'Inbound', child: Text('Filter: Inbound (Not Jakarta)')),
            const PopupMenuDivider(),
            const PopupMenuItem(value: 'export_dialog', child: Text('Export JSON (dialog & download)')),
            const PopupMenuItem(value: 'import_file', child: Text('Import JSON (file upload)')),
            const PopupMenuItem(value: 'import_paste', child: Text('Import JSON (paste)')),
          ],
        ),
        const SizedBox(width: 8),
        IconButton(
          tooltip: 'Toggle theme',
          onPressed: () => widget.onToggleTheme(),
          icon: Icon(widget.isDark ? Icons.light_mode : Icons.dark_mode, color: Colors.white70),
        ),
      ]),
    );
  }

  Widget _flightCard(FlightItem item, int index) {
    return TweenAnimationBuilder(
      key: ValueKey(item.id),
      tween: Tween(begin: 0.0, end: 1.0),
      duration: Duration(milliseconds: 420 + (index * 40)),
      builder: (context, v, child) {
        return Opacity(opacity: v, child: Transform.translate(offset: Offset(0, 30 * (1 - v)), child: child));
      },
      child: GestureDetector(
        onTap: () => _openDetail(item),
        child: Container(
          margin: const EdgeInsets.only(bottom: 12),
          padding: const EdgeInsets.all(14),
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(14),
            color: Colors.white.withOpacity(0.06),
            border: Border.all(color: Colors.white.withOpacity(0.12)),
            boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.25), blurRadius: 8, offset: const Offset(0, 4))],
          ),
          child: Row(
            children: [
              // Animated plane icon slide-in
              TweenAnimationBuilder(
                tween: Tween(begin: const Offset(-0.6, 0), end: Offset.zero),
                duration: const Duration(milliseconds: 520),
                builder: (context, off, child) => Transform.translate(offset: off * 40, child: child),
                child: Container(
                  width: 56,
                  height: 56,
                  decoration: BoxDecoration(
                    gradient: LinearGradient(colors: [Colors.blue.shade300, Colors.purple.shade300]),
                    shape: BoxShape.circle,
                    boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.18), blurRadius: 6)],
                  ),
                  child: const Icon(Icons.flight_takeoff, color: Colors.white),
                ),
              ),
              const SizedBox(width: 12),
              Expanded(
                child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
                  Text(item.code, style: const TextStyle(color: Colors.white70, fontSize: 12)),
                  const SizedBox(height: 6),
                  Text('${item.from} → ${item.to}', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700)),
                  const SizedBox(height: 6),
                  Text(item.time, style: const TextStyle(color: Colors.white60)),
                ]),
              ),
              Column(children: [
                IconButton(
                  tooltip: 'Favourite',
                  icon: Icon(item.favorite ? Icons.star : Icons.star_border, color: item.favorite ? Colors.amber : Colors.white70),
                  onPressed: () {
                    final idx = _flights.indexWhere((f) => f.id == item.id);
                    if (idx >= 0) _toggleFavorite(idx);
                  },
                ),
                Row(children: [
                  IconButton(
                    tooltip: 'Duplicate',
                    onPressed: () {
                      final idx = _flights.indexWhere((f) => f.id == item.id);
                      if (idx >= 0) _duplicate(idx);
                    },
                    icon: const Icon(Icons.copy, color: Colors.white70),
                  ),
                  IconButton(
                    tooltip: 'Edit',
                    onPressed: () {
                      final idx = _flights.indexWhere((f) => f.id == item.id);
                      if (idx >= 0) _openAddEdit(existing: item, index: idx);
                    },
                    icon: const Icon(Icons.edit, color: Colors.amberAccent),
                  ),
                  IconButton(
                    tooltip: 'Delete',
                    onPressed: () {
                      final idx = _flights.indexWhere((f) => f.id == item.id);
                      if (idx >= 0) _confirmDelete(idx);
                    },
                    icon: const Icon(Icons.delete, color: Colors.redAccent),
                  ),
                ])
              ])
            ],
          ),
        ),
      ),
    );
  }

  // Flying plane widget — moves horizontally across top based on scroll
  Widget _flyingPlane() {
    final width = MediaQuery.of(context).size.width;
    final left = (width - 160) * _planePositionFraction;
    return Positioned(
      left: left,
      top: 12,
      child: Opacity(
        opacity: 0.95,
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
          decoration: BoxDecoration(
            color: Colors.white.withOpacity(0.06),
            borderRadius: BorderRadius.circular(30),
            boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.18), blurRadius: 6)],
          ),
          child: Row(children: const [
            Icon(Icons.airplanemode_active, color: Colors.white),
            SizedBox(width: 6),
            Text('✈', style: TextStyle(color: Colors.white)),
          ]),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    final isDark = Theme.of(context).brightness == Brightness.dark;
    return Scaffold(
      backgroundColor: Colors.transparent,
      body: Stack(children: [
        Container(
          padding: const EdgeInsets.only(top: 36, left: 12, right: 12, bottom: 12),
          decoration: BoxDecoration(
            gradient: LinearGradient(
              colors: isDark
                  ? [Colors.indigo.shade900, Colors.deepPurple.shade800, Colors.pink.shade700]
                  : [Colors.blue.shade200, Colors.purple.shade200, Colors.pink.shade200],
              begin: Alignment.topLeft,
              end: Alignment.bottomRight,
            ),
          ),
          child: Column(children: [
            _topBar(),
            const SizedBox(height: 6),
            Expanded(
              child: _visible.isEmpty
                  ? Center(
                      child: Column(mainAxisSize: MainAxisSize.min, children: [
                        Icon(Icons.airplanemode_active, size: 72, color: Colors.white24),
                        const SizedBox(height: 12),
                        const Text('Tidak ada jadwal', style: TextStyle(color: Colors.white70, fontSize: 18)),
                        const SizedBox(height: 8),
                        const Text('Tekan tombol Tambah untuk menambahkan jadwal baru', style: TextStyle(color: Colors.white54), textAlign: TextAlign.center),
                      ]),
                    )
                  : NotificationListener(
                      onNotification: (n) {
                        if (n.metrics.maxScrollExtent > 0) {
                          final frac = (n.metrics.pixels / n.metrics.maxScrollExtent).clamp(0.0, 1.0);
                          setState(() => _planePositionFraction = frac);
                        }
                        return false;
                      },
                      child: ListView.builder(
                        controller: _scrollController,
                        padding: const EdgeInsets.only(top: 8, bottom: 90),
                        itemCount: _visible.length,
                        itemBuilder: (context, idx) => _flightCard(_visible[idx], idx),
                      ),
                    ),
            ),
          ]),
        ),
        _flyingPlane(),
      ]),
      floatingActionButton: ScaleTransition(
        scale: _fabAnim,
        child: FloatingActionButton.extended(
          backgroundColor: Colors.orangeAccent,
          icon: const Icon(Icons.add),
          label: const Text('Tambah'),
          onPressed: () => _openAddEdit(),
        ),
      ),
    );
  }
}

// Detail page
class FlightDetailPage extends StatelessWidget {
  final FlightItem item;
  final IconData icon;
  const FlightDetailPage({super.key, required this.item, required this.icon});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(item.code), backgroundColor: Colors.transparent, elevation: 0),
      backgroundColor: Colors.transparent,
      body: Container(
        padding: const EdgeInsets.all(18),
        decoration: BoxDecoration(gradient: LinearGradient(colors: [Colors.blue.shade900, Colors.purple.shade800], begin: Alignment.topLeft, end: Alignment.bottomRight)),
        child: Center(
          child: Container(
            padding: const EdgeInsets.all(18),
            constraints: const BoxConstraints(maxWidth: 720),
            decoration: BoxDecoration(color: Colors.white.withOpacity(0.06), borderRadius: BorderRadius.circular(16), border: Border.all(color: Colors.white.withOpacity(0.12)), boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.25), blurRadius: 12, offset: const Offset(0, 6))]),
            child: Column(mainAxisSize: MainAxisSize.min, children: [
              Icon(icon, size: 72, color: Colors.white),
              const SizedBox(height: 12),
              Text(item.code, style: const TextStyle(color: Colors.white70)),
              const SizedBox(height: 8),
              Text('${item.from} → ${item.to}', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 20)),
              const SizedBox(height: 8),
              Text(item.time, style: const TextStyle(color: Colors.white60)),
              const SizedBox(height: 14),
              const Divider(color: Colors.white12),
              const SizedBox(height: 8),
              Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [
                _infoPill(Icons.event_seat, 'Gate A1'),
                _infoPill(Icons.timer, 'On time'),
                _infoPill(Icons.airline_stops, 'Non-stop'),
              ]),
              const SizedBox(height: 14),
              ElevatedButton.icon(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.arrow_back), label: const Text('Kembali'), style: ElevatedButton.styleFrom(backgroundColor: Colors.blueAccent)),
            ]),
          ),
        ),
      ),
    );
  }

  static Widget _infoPill(IconData icon, String text) {
    return Column(children: [Icon(icon, color: Colors.white54), const SizedBox(height: 6), Text(text, style: const TextStyle(color: Colors.white70))]);
  }
}

        

✨ Cara Menggunakan Kode Ini

1

Buka zapp.run

2

Buat project Flutter Web baru

3

Ganti kode di lib/main.dart

4

Tekan Run untuk melihat hasil

💡 Kesimpulan

Dengan Flutter Web + zapp.run, kita bisa bikin aplikasi offline yang:

  1. Ringan - Nggak berat buat browser
  2. Keren - Desainnya modern dan menarik
  3. Bisa diakses dari browser tanpa download - Praktis banget
  4. Punya desain yang bikin betah lihatnya - UI/UX diperhatikan

Serius, ini pengalaman ngoding yang bikin nagih.
Kalau mau belajar sambil seneng-seneng, cobain deh bikin kayak gini.

Bagaimana menurut kalian? Tertarik buat coba bikin juga? 👀

Komentar

Postingan populer dari blog ini