Belajar membuat website whatsapp sender
🔥 Membuat Website WhatsApp Sender Pro dengan Flutter (DartPad Compatible)
Project ini memungkinkan kalian untuk:
📩 Kirim pesan WhatsApp tanpa simpan nomor
🌍 Pilih & cari kode negara (lengkap + search!)
🇮🇩 Dilengkapi flag tiap negara
⭐ Simpan nomor favorit
📜 Menyimpan riwayat pesan
🌙 Toggle dark mode
💾 Data tersimpan otomatis di browser (localStorage)
🖥️ Tampilan Aplikasi
Aplikasi ini punya UI modern dengan:
Warna dominan biru cerah 💙
Card design clean & minimalis
Navigasi bawah (Kirim, Favorit, Riwayat)
Responsive untuk web
⚙️ Cara Kerja
Aplikasi ini menggunakan link resmi WhatsApp:
https://wa.me/nomor?text=pesan
Jadi saat klik tombol kirim:
➡️ Otomatis membuka WhatsApp Web
➡️ Pesan langsung terisi
➡️ Tinggal tekan kirim di WhatsApp
🧠 Fitur Utama
🔍 1. Search Negara
Bisa cari berdasarkan nama negara / kode
UX mirip aplikasi profesional
⭐ 2. Favorit
Simpan nomor penting
Bisa klik langsung untuk isi otomatis
📜 3. History Pesan
Menyimpan riwayat pengiriman
Ada timestamp
Bisa dibersihkan kapan saja
🎯 4. Smart Input
Auto format nomor (hapus simbol aneh)
Validasi input sebelum kirim
💡 Kelebihan Project Ini
❌ Tidak perlu backend
❌ Tidak perlu database
✅ Bisa jalan di DartPad
✅ Ringan & cepat
✅ Cocok buat portfolio anak RPL 😎
🚀 Cara Menjalankan
Buka DartPad (Flutter mode)
Copy paste kode ini:
import 'dart:convert';
import 'dart:html' as html;
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart'; // Import for kIsWeb
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'WhatsApp Sender Pro',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const HomePage(),
);
}
}
class CountryItem {
final String code;
final String name;
final String flag;
const CountryItem({
required this.code,
required this.name,
required this.flag,
});
Map<String, dynamic> toJson() => {
'code': code,
'name': name,
'flag': flag,
};
factory CountryItem.fromJson(Map<String, dynamic> json) => CountryItem(
code: json['code'] as String,
name: json['name'] as String,
flag: json['flag'] as String,
);
}
class FavoriteContact {
final String phone;
final String countryCode;
final String countryName;
final String flag;
final String label;
const FavoriteContact({
required this.phone,
required this.countryCode,
required this.countryName,
required this.flag,
required this.label,
});
Map<String, dynamic> toJson() => {
'phone': phone,
'countryCode': countryCode,
'countryName': countryName,
'flag': flag,
'label': label,
};
factory FavoriteContact.fromJson(Map<String, dynamic> json) => FavoriteContact(
phone: json['phone'] as String,
countryCode: json['countryCode'] as String,
countryName: json['countryName'] as String,
flag: json['flag'] as String,
label: json['label'] as String,
);
}
class MessageHistoryItem {
final String phone;
final String countryCode;
final String countryName;
final String flag;
final String message;
final DateTime time;
const MessageHistoryItem({
required this.phone,
required this.countryCode,
required this.countryName,
required this.flag,
required this.message,
required this.time,
});
Map<String, dynamic> toJson() => {
'phone': phone,
'countryCode': countryCode,
'countryName': countryName,
'flag': flag,
'message': message,
'time': time.toIso8601String(),
};
factory MessageHistoryItem.fromJson(Map<String, dynamic> json) => MessageHistoryItem(
phone: json['phone'] as String,
countryCode: json['countryCode'] as String,
countryName: json['countryName'] as String,
flag: json['flag'] as String,
message: json['message'] as String,
time: DateTime.tryParse(json['time'] as String? ?? '') ?? DateTime.now(),
);
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
static const String favoritesKey = 'wa_favorites_v1';
static const String historyKey = 'wa_history_v1';
final TextEditingController phoneController = TextEditingController();
final TextEditingController messageController = TextEditingController();
final TextEditingController countrySearchController = TextEditingController();
final TextEditingController favoriteLabelController = TextEditingController();
bool isDarkMode = false;
int selectedTab = 0;
bool useBoldMessagePreview = false;
late List<CountryItem> countryCodes;
late CountryItem selectedCountry;
List<FavoriteContact> favorites = [];
List<MessageHistoryItem> history = [];
@override
void initState() {
super.initState();
countryCodes = _buildCountries();
selectedCountry = countryCodes.firstWhere((c) => c.code == '+62', orElse: () => countryCodes.first);
// Load saved data; helper methods will handle web-only context and potential errors.
_loadSavedData();
}
@override
void dispose() {
phoneController.dispose();
messageController.dispose();
countrySearchController.dispose();
favoriteLabelController.dispose();
super.dispose();
}
List<CountryItem> _buildCountries() {
return const [
CountryItem(code: '+62', name: 'Indonesia', flag: '🇮🇩'),
CountryItem(code: '+1', name: 'United States / Canada', flag: '🇺🇸'),
CountryItem(code: '+44', name: 'United Kingdom', flag: '🇬🇧'),
CountryItem(code: '+91', name: 'India', flag: '🇮🇳'),
CountryItem(code: '+81', name: 'Japan', flag: '🇯🇵'),
CountryItem(code: '+82', name: 'South Korea', flag: '🇰🇷'),
CountryItem(code: '+86', name: 'China', flag: '🇨🇳'),
CountryItem(code: '+49', name: 'Germany', flag: '🇩🇪'),
CountryItem(code: '+33', name: 'France', flag: '🇫🇷'),
CountryItem(code: '+39', name: 'Italy', flag: '🇮🇹'),
CountryItem(code: '+34', name: 'Spain', flag: '🇪🇸'),
CountryItem(code: '+7', name: 'Russia', flag: '🇷🇺'),
CountryItem(code: '+55', name: 'Brazil', flag: '🇧🇷'),
CountryItem(code: '+61', name: 'Australia', flag: '🇦🇺'),
CountryItem(code: '+64', name: 'New Zealand', flag: '🇳🇿'),
CountryItem(code: '+27', name: 'South Africa', flag: '🇿🇦'),
CountryItem(code: '+20', name: 'Egypt', flag: '🇪🇬'),
CountryItem(code: '+90', name: 'Turkey', flag: '🇹🇷'),
CountryItem(code: '+966', name: 'Saudi Arabia', flag: '🇸🇦'),
CountryItem(code: '+971', name: 'United Arab Emirates', flag: '🇦🇪'),
CountryItem(code: '+65', name: 'Singapore', flag: '🇸🇬'),
CountryItem(code: '+60', name: 'Malaysia', flag: '🇲🇾'),
CountryItem(code: '+66', name: 'Thailand', flag: '🇹🇭'),
CountryItem(code: '+63', name: 'Philippines', flag: '🇵🇭'),
CountryItem(code: '+84', name: 'Vietnam', flag: '🇻🇳'),
CountryItem(code: '+880', name: 'Bangladesh', flag: '🇧🇩'),
CountryItem(code: '+92', name: 'Pakistan', flag: '🇵🇰'),
CountryItem(code: '+234', name: 'Nigeria', flag: '🇳🇬'),
CountryItem(code: '+254', name: 'Kenya', flag: '🇰🇪'),
CountryItem(code: '+351', name: 'Portugal', flag: '🇵🇹'),
CountryItem(code: '+46', name: 'Sweden', flag: '🇸🇪'),
CountryItem(code: '+47', name: 'Norway', flag: '🇳🇴'),
CountryItem(code: '+45', name: 'Denmark', flag: '🇩🇰'),
CountryItem(code: '+41', name: 'Switzerland', flag: '🇨🇭'),
CountryItem(code: '+31', name: 'Netherlands', flag: '🇳🇱'),
CountryItem(code: '+32', name: 'Belgium', flag: '🇧🇪'),
CountryItem(code: '+48', name: 'Poland', flag: '🇵🇱'),
CountryItem(code: '+420', name: 'Czech Republic', flag: '🇨🇿'),
CountryItem(code: '+30', name: 'Greece', flag: '🇬🇷'),
CountryItem(code: '+98', name: 'Iran', flag: '🇮🇷'),
CountryItem(code: '+972', name: 'Israel', flag: '🇮🇱'),
CountryItem(code: '+52', name: 'Mexico', flag: '🇲🇽'),
CountryItem(code: '+54', name: 'Argentina', flag: '🇦🇷'),
CountryItem(code: '+56', name: 'Chile', flag: '🇨🇱'),
CountryItem(code: '+57', name: 'Colombia', flag: '🇨🇴'),
CountryItem(code: '+233', name: 'Ghana', flag: '🇬🇭'),
CountryItem(code: '+212', name: 'Morocco', flag: '🇲🇦'),
CountryItem(code: '+358', name: 'Finland', flag: '🇫🇮'),
CountryItem(code: '+353', name: 'Ireland', flag: '🇮🇪'),
CountryItem(code: '+43', name: 'Austria', flag: '🇦🇹'),
CountryItem(code: '+385', name: 'Croatia', flag: '🇭🇷'),
];
}
// Helper function to safely access localStorage
String? _getLocalStorageItem(String key) {
if (!kIsWeb) return null; // localStorage is only available on web
try {
return html.window.localStorage[key];
} on html.DomException catch (e) {
debugPrint('SecurityError accessing localStorage for key "$key": ${e.message}');
// This happens in sandboxed iframes or file:// protocol.
// Treat as if the item doesn't exist.
return null;
} catch (e) {
debugPrint('Error accessing localStorage for key "$key": $e');
return null;
}
}
// Helper function to safely write to localStorage
void _setLocalStorageItem(String key, String value) {
if (!kIsWeb) return; // localStorage is only available on web
try {
html.window.localStorage[key] = value;
} on html.DomException catch (e) {
debugPrint('SecurityError writing to localStorage for key "$key": ${e.message}');
} catch (e) {
debugPrint('Error writing to localStorage for key "$key": $e');
}
}
// Helper function to safely remove from localStorage
void _removeLocalStorageItem(String key) {
if (!kIsWeb) return; // localStorage is only available on web
try {
html.window.localStorage.remove(key);
} on html.DomException catch (e) {
debugPrint('SecurityError removing from localStorage for key "$key": ${e.message}');
} catch (e) {
debugPrint('Error removing from localStorage for key "$key": $e');
}
}
void _loadSavedData() {
// Use the safe helper to get data
final favRaw = _getLocalStorageItem(favoritesKey);
final histRaw = _getLocalStorageItem(historyKey);
if (favRaw != null && favRaw.isNotEmpty) {
try {
final decoded = jsonDecode(favRaw) as List<dynamic>;
favorites = decoded
.map((e) => FavoriteContact.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
} catch (e) {
debugPrint('Error decoding favorites from localStorage: $e');
favorites = []; // Clear on decode error
}
} else {
favorites = [];
}
if (histRaw != null && histRaw.isNotEmpty) {
try {
final decoded = jsonDecode(histRaw) as List<dynamic>;
history = decoded
.map((e) => MessageHistoryItem.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
} catch (e) {
debugPrint('Error decoding history from localStorage: $e');
history = []; // Clear on decode error
}
} else {
history = [];
}
setState(() {});
}
void _saveFavorites() {
// Use the safe helper to set data
_setLocalStorageItem(favoritesKey, jsonEncode(favorites.map((e) => e.toJson()).toList()));
}
void _saveHistory() {
// Use the safe helper to set data
_setLocalStorageItem(historyKey, jsonEncode(history.map((e) => e.toJson()).toList()));
}
String _normalizePhone(String input) {
return input.replaceAll(RegExp(r'[^0-9]'), '');
}
String _buildWaUrl({required String phone, required String message}) {
final fullPhone = '${selectedCountry.code}${_normalizePhone(phone)}';
return 'https://wa.me/$fullPhone?text=${Uri.encodeComponent(message)}';
}
void sendWhatsApp() {
final phone = phoneController.text.trim();
final message = messageController.text.trim();
if (phone.isEmpty || message.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Isi nomor dan pesan dulu bro')),
);
return;
}
final url = _buildWaUrl(phone: phone, message: message);
html.window.open(url, '_blank');
history.insert(
0,
MessageHistoryItem(
phone: phone,
countryCode: selectedCountry.code,
countryName: selectedCountry.name,
flag: selectedCountry.flag,
message: message,
time: DateTime.now(),
),
);
if (history.length > 30) {
history = history.sublist(0, 30);
}
_saveHistory(); // Uses the safe helper
setState(() {});
}
void openChatWithoutMessage() {
final phone = phoneController.text.trim();
if (phone.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Isi nomor dulu bro')),
);
return;
}
final fullPhone = '${selectedCountry.code}${_normalizePhone(phone)}';
html.window.open('https://wa.me/$fullPhone', '_blank');
}
void openApiLink() {
final phone = phoneController.text.trim();
final message = messageController.text.trim();
if (phone.isEmpty || message.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Isi nomor dan pesan dulu bro')),
);
return;
}
final fullPhone = '${selectedCountry.code}${_normalizePhone(phone)}';
final url =
'https://api.whatsapp.com/send?phone=$fullPhone&text=${Uri.encodeComponent(message)}';
html.window.open(url, '_blank');
}
void clearAll() {
phoneController.clear();
messageController.clear();
setState(() {});
}
void saveFavorite() {
final phone = phoneController.text.trim();
if (phone.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Isi nomor dulu bro')),
);
return;
}
final label = favoriteLabelController.text.trim().isEmpty
? 'Favorit ${selectedCountry.flag} ${selectedCountry.name}'
: favoriteLabelController.text.trim();
final fav = FavoriteContact(
phone: _normalizePhone(phone),
countryCode: selectedCountry.code,
countryName: selectedCountry.name,
flag: selectedCountry.flag,
label: label,
);
final exists = favorites.any((f) =>
f.phone == fav.phone && f.countryCode == fav.countryCode);
if (!exists) {
favorites.insert(0, fav);
if (favorites.length > 20) {
favorites = favorites.sublist(0, 20);
}
_saveFavorites(); // Uses the safe helper
setState(() {});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Nomor favorit disimpan')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Nomor favorit sudah ada')),
);
}
}
void loadFavorite(FavoriteContact fav) {
setState(() {
selectedCountry = countryCodes.firstWhere(
(c) => c.code == fav.countryCode,
orElse: () => selectedCountry,
);
phoneController.text = fav.phone;
selectedTab = 0;
});
}
void deleteFavorite(FavoriteContact fav) {
favorites.removeWhere((f) => f.phone == fav.phone && f.countryCode == fav.countryCode);
_saveFavorites(); // Uses the safe helper
setState(() {});
}
void clearHistory() {
history.clear();
_saveHistory(); // Uses the safe helper
setState(() {});
}
Future<void> openCountryPicker() async {
final chosen = await showModalBottomSheet<CountryItem>(
context: context,
isScrollControlled: true,
showDragHandle: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (context) {
List<CountryItem> filtered = List.from(countryCodes);
return StatefulBuilder(
builder: (context, setModalState) {
final query = countrySearchController.text.trim().toLowerCase();
filtered = query.isEmpty
? countryCodes
: countryCodes.where((c) {
return c.name.toLowerCase().contains(query) ||
c.code.contains(query) ||
c.flag.contains(query);
}).toList();
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 8,
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Pilih Negara',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
TextField(
controller: countrySearchController,
onChanged: (_) => setModalState(() {}),
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
hintText: 'Cari negara atau kode',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
),
),
),
const SizedBox(height: 12),
SizedBox(
height: 420,
child: ListView.separated(
itemCount: filtered.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, index) {
final c = filtered[index];
return ListTile(
leading: CircleAvatar(
child: Text(c.flag),
),
title: Text(c.name),
subtitle: Text(c.code),
onTap: () => Navigator.pop(context, c),
);
},
),
),
],
),
);
},
);
},
);
countrySearchController.clear();
if (chosen != null) {
setState(() {
selectedCountry = chosen;
});
}
}
Widget buildDashboard() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_headerCard(),
const SizedBox(height: 16),
_composeCard(),
const SizedBox(height: 16),
_quickStats(),
],
),
);
}
Widget _headerCard() {
return Card(
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: Padding(
padding: const EdgeInsets.all(18),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Colors.blue, Colors.lightBlueAccent],
),
borderRadius: BorderRadius.circular(18),
),
child: const Icon(Icons.chat_bubble_rounded, color: Colors.white),
),
const SizedBox(width: 14),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'WhatsApp Sender Pro',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
SizedBox(height: 4),
Text('Kirim pesan, simpan favorit, dan cek riwayat dengan cepat.'),
],
),
),
],
),
),
);
}
Widget _composeCard() {
final currentPreview = '${selectedCountry.flag} ${selectedCountry.name} ${selectedCountry.code}';
return Card(
elevation: 10,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: openCountryPicker,
icon: const Icon(Icons.public),
label: Text(currentPreview, overflow: TextOverflow.ellipsis),
),
),
],
),
const SizedBox(height: 14),
TextField(
controller: phoneController,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'Nomor WhatsApp',
hintText: '812xxxxxxx',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: favoriteLabelController,
decoration: const InputDecoration(
labelText: 'Label favorit (opsional)',
hintText: 'Contoh: Temen sekolah',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: messageController,
maxLines: 4,
decoration: InputDecoration(
labelText: 'Pesan',
hintText: 'Tulis pesan yang mau dikirim...',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
tooltip: 'Toggle preview mode',
icon: Icon(useBoldMessagePreview ? Icons.visibility : Icons.visibility_off),
onPressed: () => setState(() => useBoldMessagePreview = !useBoldMessagePreview),
),
),
),
const SizedBox(height: 14),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
FilledButton.icon(
onPressed: sendWhatsApp,
icon: const Icon(Icons.send),
label: const Text('Kirim'),
),
OutlinedButton.icon(
onPressed: clearAll,
icon: const Icon(Icons.delete_outline),
label: const Text('Clear'),
),
OutlinedButton.icon(
onPressed: openChatWithoutMessage,
icon: const Icon(Icons.chat),
label: const Text('Chat kosong'),
),
OutlinedButton.icon(
onPressed: openApiLink,
icon: const Icon(Icons.link),
label: const Text('API link'),
),
FilledButton.tonalIcon(
onPressed: saveFavorite,
icon: const Icon(Icons.star_border),
label: const Text('Simpan favorit'),
),
],
),
const SizedBox(height: 10),
if (useBoldMessagePreview && messageController.text.trim().isNotEmpty)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.08),
borderRadius: BorderRadius.circular(16),
),
child: Text(
'Preview: ${messageController.text.trim()}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
),
);
}
Widget _quickStats() {
return Row(
children: [
Expanded(
child: _statCard('Favorit', favorites.length.toString(), Icons.star),
),
const SizedBox(width: 12),
Expanded(
child: _statCard('Riwayat', history.length.toString(), Icons.history),
),
],
);
}
Widget _statCard(String label, String value, IconData icon) {
return Card(
elevation: 6,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(icon, size: 30),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
Text(label),
],
),
],
),
),
);
}
Widget buildFavorites() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: Padding(
padding: const EdgeInsets.all(18),
child: Row(
children: [
const Icon(Icons.star, size: 30),
const SizedBox(width: 12),
Expanded(
child: Text(
'Nomor Favorit',
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
TextButton.icon(
onPressed: favorites.isEmpty ? null : () {
favorites.clear();
_saveFavorites(); // Uses the safe helper to save the now-empty list
setState(() {});
},
icon: const Icon(Icons.delete_outline),
label: const Text('Hapus semua'),
),
],
),
),
),
const SizedBox(height: 12),
if (favorites.isEmpty)
const Padding(
padding: EdgeInsets.all(20),
child: Center(child: Text('Belum ada nomor favorit disimpan.')),
)
else
...favorites.map((fav) {
return Card(
elevation: 4,
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: ListTile(
leading: CircleAvatar(child: Text(fav.flag)),
title: Text(fav.label),
subtitle: Text('${fav.countryName} • ${fav.countryCode}${fav.phone}'),
onTap: () => loadFavorite(fav),
trailing: IconButton(
icon: const Icon(Icons.delete_forever),
onPressed: () => deleteFavorite(fav),
),
),
);
}),
],
),
);
}
Widget buildHistory() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: Padding(
padding: const EdgeInsets.all(18),
child: Row(
children: [
const Icon(Icons.history, size: 30),
const SizedBox(width: 12),
Expanded(
child: Text(
'History Pesan',
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
TextButton.icon(
onPressed: history.isEmpty ? null : clearHistory,
icon: const Icon(Icons.delete_outline),
label: const Text('Bersihkan'),
),
],
),
),
),
const SizedBox(height: 12),
if (history.isEmpty)
const Padding(
padding: EdgeInsets.all(20),
child: Center(child: Text('Belum ada riwayat pesan.')),
)
else
...history.map((item) {
return Card(
elevation: 4,
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: ListTile(
leading: CircleAvatar(child: Text(item.flag)),
title: Text('${item.countryCode}${item.phone}'),
subtitle: Text(
'${item.countryName}\n${item.message}',
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
isThreeLine: true,
trailing: Text(
'${item.time.hour.toString().padLeft(2, '0')}:${item.time.minute.toString().padLeft(2, '0')}',
),
),
);
}),
],
),
);
}
@override
Widget build(BuildContext context) {
final pages = [buildDashboard(), buildFavorites(), buildHistory()];
return Scaffold(
appBar: AppBar(
title: const Text('WhatsApp Sender Pro'),
centerTitle: true,
actions: [
IconButton(
icon: Icon(isDarkMode ? Icons.light_mode : Icons.dark_mode),
onPressed: () => setState(() => isDarkMode = !isDarkMode),
),
],
),
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: isDarkMode
? [Colors.black, Colors.blueGrey.shade900]
: [Colors.blue.shade300, Colors.blue.shade50],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: IndexedStack(index: selectedTab, children: pages),
),
bottomNavigationBar: NavigationBar(
selectedIndex: selectedTab,
onDestinationSelected: (value) => setState(() => selectedTab = value),
destinations: const [
NavigationDestination(icon: Icon(Icons.message_outlined), selectedIcon: Icon(Icons.message), label: 'Kirim'),
NavigationDestination(icon: Icon(Icons.star_border), selectedIcon: Icon(Icons.star), label: 'Favorit'),
NavigationDestination(icon: Icon(Icons.history_outlined), selectedIcon: Icon(Icons.history), label: 'Riwayat'),
],
),
);
}
}
Klik Run
Langsung jadi web app!
🔥 Next Improvement (Kalau mau upgrade lagi)
Kalau mau lebih gila lagi, bisa ditambah:
📂 Import CSV (bulk kirim)
🤖 Auto message scheduler
📱 UI mirip WhatsApp asli
🔔 Notifikasi sukses kirim
🔗 Short link generator
🎯 Kesimpulan
Project ini cocok banget buat:
Latihan Flutter Web
Portfolio sekolah / SMK
Tools pribadi sehari-hari
Simple tapi powerful 🔥
Nama: Rizqi Resdhiana XI RPL II
Comments
Post a Comment