๐Ÿš€ 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

Jenis jenis sistem operasi mobile