Flutter Android login vault: local DB (id+base64 content), email/Google auth, dashboard search/edit/swipe-delete, dark/system theme, minSdk 29

This commit is contained in:
cania
2026-08-12 11:41:20 +02:00
commit a8791a5b10
138 changed files with 6285 additions and 0 deletions

128
lib/auth_screen.dart Normal file
View File

@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:login_vault_app/auth_service.dart';
/// First-launch screen: register with email+password, or with Google.
class AuthScreen extends StatefulWidget {
final AuthService auth;
const AuthScreen({super.key, required this.auth});
@override
State<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends State<AuthScreen> {
final _emailCtrl = TextEditingController();
final _passCtrl = TextEditingController();
bool _isLogin = true; // toggle register/login
bool _busy = false;
String? _error;
Future<void> _submitEmail() async {
setState(() => _busy = true);
final email = _emailCtrl.text.trim();
final pass = _passCtrl.text;
if (email.isEmpty || pass.isEmpty) {
setState(() => _error = 'Email & password wajib diisi');
_busy = false;
return;
}
try {
if (_isLogin) {
final stored = await widget.auth.getAppPassword();
final ok = await widget.auth.verifyPassword(pass);
if (stored == null) {
setState(() => _error = 'Belum terdaftar, silakan registrasi');
} else if (!ok) {
setState(() => _error = 'Password aplikasi salah');
} else {
// success -> pop true
if (mounted) Navigator.pop(context, true);
}
} else {
await widget.auth.registerWithEmail(email, pass);
if (mounted) Navigator.pop(context, true);
}
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _signInGoogle() async {
setState(() => _busy = true);
try {
final gs = GoogleSignIn(scopes: ['email']);
final account = await gs.signIn();
if (account?.email != null) {
await widget.auth.registerWithGoogle(account!.email);
if (mounted) Navigator.pop(context, true);
} else {
setState(() => _error = 'Google sign-in dibatalkan');
}
} catch (e) {
setState(() => _error = 'Google sign-in gagal: ${e.toString()}');
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Login Vault')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_isLogin ? 'Masuk' : 'Daftar',
style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 16),
TextField(
controller: _emailCtrl,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email', border: OutlineInputBorder()),
),
const SizedBox(height: 12),
TextField(
controller: _passCtrl,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Password aplikasi', border: OutlineInputBorder()),
),
const SizedBox(height: 8),
if (_error != null)
Text(_error!, style: const TextStyle(color: Colors.red)),
const SizedBox(height: 12),
_busy
? const CircularProgressIndicator()
: Column(
children: [
ElevatedButton(
onPressed: _submitEmail,
child: Text(_isLogin ? 'Masuk' : 'Daftar'),
),
TextButton(
onPressed: () =>
setState(() => _isLogin = !_isLogin),
child: Text(_isLogin
? 'Belum punya akun? Daftar'
: 'Sudah punya akun? Masuk'),
),
const Divider(),
OutlinedButton.icon(
onPressed: _signInGoogle,
icon: const Icon(Icons.login),
label: const Text('Lanjut dengan Google'),
),
],
),
],
),
),
);
}
}

67
lib/auth_service.dart Normal file
View File

@@ -0,0 +1,67 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:login_vault_app/constants.dart';
/// Handles app-level authentication and persistence of the local identity.
///
/// On first launch the user registers (email+password OR Google). The chosen
/// email is stored locally as the key ([AppConstants.secureKeyEmail]) and the
/// app password (used to lock the dashboard) is stored in secure storage.
class AuthService {
final FlutterSecureStorage _storage = const FlutterSecureStorage();
/// Returns the stored email, or null if not registered yet.
Future<String?> getEmail() async =>
await _storage.read(key: AppConstants.secureKeyEmail);
Future<String?> getAppPassword() async =>
await _storage.read(key: AppConstants.secureKeyAppPassword);
/// True if a user identity already exists locally.
Future<bool> isRegistered() async => (await getEmail()) != null;
/// Register with email + app password.
Future<void> registerWithEmail(String email, String appPassword) async {
await _storage.write(key: AppConstants.secureKeyEmail, value: email);
await _storage.write(
key: AppConstants.secureKeyAppPassword, value: appPassword);
}
/// Register with Google (email supplied by Google sign-in).
Future<void> registerWithGoogle(String email) async {
await _storage.write(key: AppConstants.secureKeyEmail, value: email);
// App password defaults to a marker; user can change it later in settings.
final existing = await getAppPassword();
if (existing == null) {
await _storage.write(
key: AppConstants.secureKeyAppPassword, value: '__google__');
}
}
/// Verify the entered app password against the stored one.
Future<bool> verifyPassword(String password) async {
final stored = await getAppPassword();
if (stored == null) return false;
if (stored == '__google__') {
// Google users have no password by default; let any entry pass OR force set.
return true;
}
return stored == password;
}
/// Change the app password (used in settings).
Future<void> changePassword(String newPassword) async {
await _storage.write(
key: AppConstants.secureKeyAppPassword, value: newPassword);
}
Future<void> logout() async {
// Keep email (identity) but we treat logout as returning to auth screen
// with the email still known. To fully reset, call [reset].
}
/// Wipe local identity (for completeness / debugging).
Future<void> reset() async {
await _storage.delete(key: AppConstants.secureKeyEmail);
await _storage.delete(key: AppConstants.secureKeyAppPassword);
}
}

19
lib/constants.dart Normal file
View File

@@ -0,0 +1,19 @@
/// App-wide constants.
///
/// SECURITY NOTE: The "encryption" below is a lightweight obfuscation
/// (XOR + base64) keyed by a constant. It is NOT cryptographically secure
/// and exists only to satisfy the requirement "content disimpan dengan
/// encrypt base64 (key sebagai constant)". For real protection, swap
/// [encryptionKey] for a key derived from the user's app password + a
/// platform secure store (e.g. flutter_secure_storage + AES).
class AppConstants {
/// Constant encryption key used for the XOR+base64 obfuscation.
static const String encryptionKey = 'OMAH_CITRA_LOGIN_VAULT_2026_XOR_KEY';
/// SQLite database file name.
static const String dbName = 'login_vault.db';
/// Application package-ish identifiers for secure storage keys.
static const String secureKeyEmail = 'vault_email';
static const String secureKeyAppPassword = 'vault_app_password';
}

208
lib/dashboard_screen.dart Normal file
View File

@@ -0,0 +1,208 @@
import 'package:flutter/material.dart';
import 'package:login_vault_app/auth_service.dart';
import 'package:login_vault_app/database_helper.dart';
import 'package:login_vault_app/settings_screen.dart';
import 'package:login_vault_app/theme_provider.dart';
import 'package:login_vault_app/vault_entry_dialogs.dart';
/// Main dashboard: search ids, list entries, expand content, edit, swipe delete.
class DashboardScreen extends StatefulWidget {
final AuthService auth;
final ThemeProvider theme;
final String email;
const DashboardScreen(
{super.key,
required this.auth,
required this.theme,
required this.email});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
final _db = DatabaseHelper();
final _searchCtrl = TextEditingController();
List<VaultEntry> _entries = [];
bool _loading = true;
@override
void initState() {
super.initState();
_searchCtrl.addListener(_onSearchChanged);
_refresh();
}
@override
void dispose() {
_searchCtrl.removeListener(_onSearchChanged);
_searchCtrl.dispose();
super.dispose();
}
void _onSearchChanged() => _refresh();
Future<void> _refresh() async {
setState(() => _loading = true);
final list = await _db.search(_searchCtrl.text);
if (mounted) {
setState(() {
_entries = list;
_loading = false;
});
}
}
Future<void> _deleteEntry(VaultEntry e) async {
final confirmed = await showConfirmDialog(
context,
title: 'Hapus entri?',
body: 'Yakin ingin menghapus "${e.id}"? Tindakan ini tidak dapat dibatalkan.',
);
if (confirmed == true) {
await _db.delete(e.id);
_refresh();
}
}
Future<void> _editEntry(VaultEntry e) async {
final result = await showEntryDialog(
context,
title: 'Edit konten',
id: e.id,
initialContent: e.content,
lockedId: true,
);
if (result != null) {
await _db.update(e.id, result.content);
_refresh();
}
}
Future<void> _addEntry() async {
final result = await showEntryDialog(
context,
title: 'Tambah entri baru',
id: '',
initialContent: '',
lockedId: false,
);
if (result != null && result.id.isNotEmpty) {
try {
await _db.insert(VaultEntry(id: result.id, content: result.content));
_refresh();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gagal: ID "${result.id}" sudah ada')),
);
}
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Login Vault'),
actions: [
IconButton(
icon: const Icon(Icons.settings),
tooltip: 'Pengaturan',
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => SettingsScreen(
auth: widget.auth, theme: widget.theme),
),
).then((_) => _refresh()),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _addEntry,
tooltip: 'Tambah entri',
child: const Icon(Icons.add),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: TextField(
controller: _searchCtrl,
decoration: InputDecoration(
labelText: 'Cari ID...',
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
suffixIcon: _searchCtrl.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchCtrl.clear();
_refresh();
},
)
: null,
),
),
),
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: _entries.isEmpty
? const Center(child: Text('Tidak ada entri'))
: ListView.builder(
itemCount: _entries.length,
itemBuilder: (ctx, i) {
final e = _entries[i];
return Dismissible(
key: Key(e.id),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding:
const EdgeInsets.symmetric(horizontal: 20),
child: const Icon(Icons.delete, color: Colors.white),
),
confirmDismiss: (_) async {
// Use our own dialog for nicer copy.
final c = await showConfirmDialog(
context,
title: 'Hapus entri?',
body:
'Yakin hapus "${e.id}"?',
);
if (c == true) await _deleteEntry(e);
return false; // we handle deletion ourselves
},
child: Card(
margin: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
child: ExpansionTile(
title: Text(e.id,
style: const TextStyle(
fontWeight: FontWeight.bold)),
trailing: IconButton(
icon: const Icon(Icons.edit),
tooltip: 'Edit',
onPressed: () => _editEntry(e),
),
children: [
Padding(
padding: const EdgeInsets.all(16),
child: SelectableText(e.content),
),
],
),
),
);
},
),
),
],
),
);
}
}

97
lib/database_helper.dart Normal file
View File

@@ -0,0 +1,97 @@
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:login_vault_app/constants.dart';
import 'package:login_vault_app/encryptor.dart';
/// A single saved login/private entry.
class VaultEntry {
final String id; // unique string
final String content; // decrypted plaintext
VaultEntry({required this.id, required this.content});
/// Build from a DB row (content is stored encrypted).
factory VaultEntry.fromDbRow(Map<String, dynamic> row) => VaultEntry(
id: row['id'] as String,
content: Encryptor.decrypt(row['content'] as String),
);
/// Build a DB row map (content encrypted).
Map<String, dynamic> toDbRow() => {
'id': id,
'content': Encryptor.encrypt(content),
};
}
class DatabaseHelper {
static final DatabaseHelper _instance = DatabaseHelper._internal();
factory DatabaseHelper() => _instance;
DatabaseHelper._internal();
Database? _db;
Future<Database> get db async {
_db ??= await _initDb();
return _db!;
}
Future<Database> _initDb() async {
final path = join(await getDatabasesPath(), AppConstants.dbName);
return openDatabase(
path,
version: 1,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE vault (
id TEXT PRIMARY KEY,
content TEXT NOT NULL
)
''');
},
);
}
/// Insert a new entry (throws if [id] already exists -> uniqueness).
Future<void> insert(VaultEntry entry) async {
final d = await db;
await d.insert('vault', entry.toDbRow(),
conflictAlgorithm: ConflictAlgorithm.fail);
}
/// Update content for an existing [id].
Future<void> update(String id, String newContent) async {
final d = await db;
await d.update(
'vault',
{'content': Encryptor.encrypt(newContent)},
where: 'id = ?',
whereArgs: [id],
);
}
/// Delete by [id].
Future<void> delete(String id) async {
final d = await db;
await d.delete('vault', where: 'id = ?', whereArgs: [id]);
}
/// Get all entries (used for full list / search).
Future<List<VaultEntry>> getAll() async {
final d = await db;
final rows = await d.query('vault', orderBy: 'id COLLATE NOCASE ASC');
return rows.map(VaultEntry.fromDbRow).toList();
}
/// Search ids containing [query] (case-insensitive). Returns matching entries.
Future<List<VaultEntry>> search(String query) async {
if (query.trim().isEmpty) return getAll();
final d = await db;
final rows = await d.query(
'vault',
where: 'id LIKE ?',
whereArgs: ['%${query.trim()}%'],
orderBy: 'id COLLATE NOCASE ASC',
);
return rows.map(VaultEntry.fromDbRow).toList();
}
}

29
lib/encryptor.dart Normal file
View File

@@ -0,0 +1,29 @@
import 'dart:convert';
import 'package:login_vault_app/constants.dart';
/// XOR cipher keyed by [AppConstants.encryptionKey], then base64-encoded.
///
/// Operates on raw bytes so any Unicode content round-trips correctly.
class Encryptor {
static List<int> _xorBytes(List<int> input, List<int> key) {
final out = List<int>.filled(input.length, 0);
for (var i = 0; i < input.length; i++) {
out[i] = input[i] ^ key[i % key.length];
}
return out;
}
/// Encrypt [plain] -> base64 string using the constant key.
static String encrypt(String plain) {
final keyBytes = utf8.encode(AppConstants.encryptionKey);
final xored = _xorBytes(utf8.encode(plain), keyBytes);
return base64.encode(xored);
}
/// Decrypt a base64 [cipher] produced by [encrypt].
static String decrypt(String cipher) {
final keyBytes = utf8.encode(AppConstants.encryptionKey);
final xored = _xorBytes(base64.decode(cipher), keyBytes);
return utf8.decode(xored);
}
}

56
lib/main.dart Normal file
View File

@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:login_vault_app/auth_service.dart';
import 'package:login_vault_app/theme_provider.dart';
import 'package:login_vault_app/auth_screen.dart';
import 'package:login_vault_app/dashboard_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final auth = AuthService();
final theme = ThemeProvider();
final registered = await auth.isRegistered();
final email = await auth.getEmail() ?? '';
runApp(
MultiProvider(
providers: [
Provider<AuthService>.value(value: auth),
ChangeNotifierProvider<ThemeProvider>.value(value: theme),
],
child: MyApp(registered: registered, email: email),
),
);
}
class MyApp extends StatelessWidget {
final bool registered;
final String email;
const MyApp({super.key, required this.registered, required this.email});
@override
Widget build(BuildContext context) {
final theme = Provider.of<ThemeProvider>(context);
return MaterialApp(
title: 'Login Vault',
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorSchemeSeed: Colors.indigo,
),
darkTheme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorSchemeSeed: Colors.indigo,
),
themeMode: theme.mode,
home: registered
? DashboardScreen(
auth: context.read<AuthService>(),
theme: theme,
email: email,
)
: AuthScreen(auth: context.read<AuthService>()),
debugShowCheckedModeBanner: false,
);
}
}

111
lib/settings_screen.dart Normal file
View File

@@ -0,0 +1,111 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:login_vault_app/auth_service.dart';
import 'package:login_vault_app/theme_provider.dart';
/// Settings: change app password, switch theme (dark/system), and Exit.
class SettingsScreen extends StatefulWidget {
final AuthService auth;
final ThemeProvider theme;
const SettingsScreen({super.key, required this.auth, required this.theme});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
final _oldCtrl = TextEditingController();
final _newCtrl = TextEditingController();
String? _msg;
Future<void> _changePassword() async {
final oldP = _oldCtrl.text;
final newP = _newCtrl.text;
if (newP.isEmpty) {
setState(() => _msg = 'Password baru wajib diisi');
return;
}
final stored = await widget.auth.getAppPassword();
if (stored != null && stored != '__google__' && stored != oldP) {
setState(() => _msg = 'Password lama salah');
return;
}
await widget.auth.changePassword(newP);
setState(() => _msg = 'Password aplikasi berhasil diubah');
_oldCtrl.clear();
_newCtrl.clear();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Pengaturan')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
const Text('Tema',
style: TextStyle(fontWeight: FontWeight.bold)),
ListTile(
title: const Text('Gelap (Dark)'),
leading: const Icon(Icons.dark_mode),
trailing: Radio<ThemeMode>(
value: ThemeMode.dark,
groupValue: widget.theme.mode,
onChanged: (m) => widget.theme.setMode(m!),
),
onTap: () => widget.theme.setMode(ThemeMode.dark),
),
ListTile(
title: const Text('Sistem (System)'),
leading: const Icon(Icons.brightness_auto),
trailing: Radio<ThemeMode>(
value: ThemeMode.system,
groupValue: widget.theme.mode,
onChanged: (m) => widget.theme.setMode(m!),
),
onTap: () => widget.theme.setMode(ThemeMode.system),
),
const Divider(),
const Text('Ubah Password Aplikasi',
style: TextStyle(fontWeight: FontWeight.bold)),
TextField(
controller: _oldCtrl,
obscureText: true,
decoration: const InputDecoration(labelText: 'Password lama'),
),
TextField(
controller: _newCtrl,
obscureText: true,
decoration: const InputDecoration(labelText: 'Password baru'),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _changePassword,
child: const Text('Simpan Password'),
),
if (_msg != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(_msg!,
style: const TextStyle(color: Colors.green)),
),
const Divider(),
ListTile(
leading: const Icon(Icons.exit_to_app),
title: const Text('Keluar (Exit)'),
onTap: () {
// On Android, pop the whole stack then ask the system to close.
Navigator.of(context).popUntil((r) => r.isFirst);
if (Platform.isAndroid) {
SystemNavigator.pop();
} else {
exit(0);
}
},
),
],
),
);
}
}

40
lib/theme_provider.dart Normal file
View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Persists and exposes the app theme mode (system | dark).
class ThemeProvider extends ChangeNotifier {
static const _key = 'theme_mode';
ThemeMode _mode = ThemeMode.system;
ThemeProvider() {
_load();
}
ThemeMode get mode => _mode;
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
final v = prefs.getString(_key);
if (v == 'dark') {
_mode = ThemeMode.dark;
} else if (v == 'light') {
_mode = ThemeMode.light;
} else {
_mode = ThemeMode.system;
}
notifyListeners();
}
Future<void> setMode(ThemeMode mode) async {
_mode = mode;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_key,
mode == ThemeMode.dark
? 'dark'
: mode == ThemeMode.light
? 'light'
: 'system');
notifyListeners();
}
}

View File

@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
/// Result of [showEntryDialog]: an entry id + content.
class EntryInput {
final String id;
final String content;
EntryInput(this.id, this.content);
}
/// Dialog to add or edit an entry.
/// When [lockedId] is true, the id field is disabled (editing existing entry).
Future<EntryInput?> showEntryDialog(
BuildContext context, {
required String title,
required String id,
required String initialContent,
required bool lockedId,
}) {
final idCtrl = TextEditingController(text: id);
final contentCtrl = TextEditingController(text: initialContent);
return showDialog<EntryInput>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(title),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: idCtrl,
enabled: !lockedId,
decoration: const InputDecoration(
labelText: 'ID (unik)', border: OutlineInputBorder()),
),
const SizedBox(height: 12),
TextField(
controller: contentCtrl,
maxLines: 5,
decoration: const InputDecoration(
labelText: 'Konten', border: OutlineInputBorder()),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Batal')),
ElevatedButton(
onPressed: () => Navigator.pop(
ctx, EntryInput(idCtrl.text.trim(), contentCtrl.text)),
child: const Text('Simpan'),
),
],
),
);
}
/// Confirmation dialog; returns true if user confirms.
Future<bool?> showConfirmDialog(
BuildContext context, {
required String title,
required String body,
}) {
return showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(title),
content: Text(body),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Batal')),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Hapus', style: TextStyle(color: Colors.white)),
),
],
),
);
}