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:
128
lib/auth_screen.dart
Normal file
128
lib/auth_screen.dart
Normal 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
67
lib/auth_service.dart
Normal 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
19
lib/constants.dart
Normal 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
208
lib/dashboard_screen.dart
Normal 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
97
lib/database_helper.dart
Normal 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
29
lib/encryptor.dart
Normal 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
56
lib/main.dart
Normal 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
111
lib/settings_screen.dart
Normal 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
40
lib/theme_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
80
lib/vault_entry_dialogs.dart
Normal file
80
lib/vault_entry_dialogs.dart
Normal 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user