Add account UI and secure session storage

This commit is contained in:
Hamza Ayed
2026-10-03 00:54:16 +03:00
parent b0cd825838
commit f745bed0f7
14 changed files with 456 additions and 13 deletions
@@ -24,7 +24,8 @@ android {
applicationId = "com.example.flutter_app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
// flutter_secure_storage 10.x requires Android API 23 or newer.
minSdk = 23
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
@@ -0,0 +1,27 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
abstract interface class SessionTokenStore {
Future<String?> read();
Future<void> write(String token);
Future<void> delete();
}
class SecureSessionTokenStore implements SessionTokenStore {
SecureSessionTokenStore({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
static const _key = 'sovereignai.account.access_token';
final FlutterSecureStorage _storage;
@override
Future<String?> read() => _storage.read(key: _key);
@override
Future<void> write(String token) => _storage.write(key: _key, value: token);
@override
Future<void> delete() => _storage.delete(key: _key);
}
@@ -6,6 +6,7 @@ import 'package:http_parser/http_parser.dart';
import 'package:file_selector/file_selector.dart';
import 'package:uuid/uuid.dart';
import '../auth/session_token_store.dart';
import '../../features/chat/presentation/cubit/chat_state.dart';
const _uuid = Uuid();
@@ -18,13 +19,25 @@ class AgentRunResult {
}
class ApiRepository {
ApiRepository({required String baseUrl}) : _baseUrl = baseUrl;
ApiRepository({required String baseUrl, SessionTokenStore? sessionTokenStore})
: _baseUrl = baseUrl,
_sessionTokenStore = sessionTokenStore ?? SecureSessionTokenStore();
String _baseUrl;
final SessionTokenStore _sessionTokenStore;
String? _accessToken;
Future<String>? _loadingLocalSession;
Future<void>? _restoringSession;
Map<String, dynamic>? _currentUser;
bool _sessionRestored = false;
http.Client? _activeRequestClient;
String get baseUrl => _baseUrl;
Map<String, dynamic>? get currentUser => _currentUser;
String? get accountEmail {
final user = _currentUser;
if (user == null || user['mode'] != 'account') return null;
return user['email'] as String?;
}
void cancelActiveRequest() {
_activeRequestClient?.close();
@@ -37,6 +50,44 @@ class ApiRepository {
_baseUrl = value.replaceFirst(RegExp(r'/+$'), '');
_accessToken = null;
_loadingLocalSession = null;
_currentUser = null;
_sessionRestored = false;
_restoringSession = null;
}
Future<void> initializeSession() async {
if (_sessionRestored) return;
final pending = _restoringSession;
if (pending != null) return pending;
final restoring = _restoreAccountSession();
_restoringSession = restoring;
try {
await restoring;
} finally {
_restoringSession = null;
}
}
Future<void> _restoreAccountSession() async {
try {
final token = await _sessionTokenStore.read();
if (token == null || token.isEmpty) return;
final response = await http.get(
Uri.parse('$_baseUrl/v1/auth/me'),
headers: {'Authorization': 'Bearer $token'},
);
if (response.statusCode < 200 || response.statusCode >= 300) {
await _sessionTokenStore.delete();
return;
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
_accessToken = token;
_currentUser = data['user'] as Map<String, dynamic>?;
} catch (_) {
// Local single-user mode remains available if secure storage is absent.
} finally {
_sessionRestored = true;
}
}
Future<String> getModelName() async {
@@ -64,6 +115,7 @@ class ApiRepository {
}
Future<String> _localSessionToken() async {
await initializeSession();
final cached = _accessToken;
if (cached != null) return cached;
final existing = _loadingLocalSession;
@@ -86,6 +138,7 @@ class ApiRepository {
_checkStatus(response);
final data = jsonDecode(response.body) as Map<String, dynamic>;
_accessToken = data['access_token'] as String;
_currentUser = data['user'] as Map<String, dynamic>?;
return _accessToken!;
}
@@ -116,20 +169,56 @@ class ApiRepository {
);
_checkStatus(response);
final data = jsonDecode(response.body) as Map<String, dynamic>;
_accessToken = data['access_token'] as String;
final token = data['access_token'] as String;
try {
await _sessionTokenStore.write(token);
} catch (error) {
try {
await http.post(
Uri.parse('$_baseUrl/v1/auth/logout'),
headers: {'Authorization': 'Bearer $token'},
);
} catch (_) {
// The short-lived server session expires automatically if revocation fails.
}
throw Exception(
'تعذر حفظ جلسة الحساب في التخزين الآمن على هذا الجهاز: $error',
);
}
_accessToken = token;
_currentUser = data['user'] as Map<String, dynamic>?;
_sessionRestored = true;
return data;
}
Future<void> logout() async {
await initializeSession();
final token = _accessToken;
if (token == null) return;
final response = await http.post(
Uri.parse('$_baseUrl/v1/auth/logout'),
headers: {'Authorization': 'Bearer $token'},
);
_checkStatus(response);
_accessToken = null;
_loadingLocalSession = null;
Object? logoutError;
try {
final response = await http.post(
Uri.parse('$_baseUrl/v1/auth/logout'),
headers: {'Authorization': 'Bearer $token'},
);
_checkStatus(response);
} catch (error) {
logoutError = error;
} finally {
try {
await _sessionTokenStore.delete();
} finally {
_accessToken = null;
_currentUser = null;
_loadingLocalSession = null;
_sessionRestored = true;
}
}
if (logoutError != null) {
throw Exception(
'أُزيل الرمز من الجهاز، لكن تعذر تأكيد إلغاء جلسة الخادم: $logoutError',
);
}
}
void _checkStatus(http.Response response) {
@@ -423,6 +423,28 @@ class ChatCubit extends Cubit<ChatState> {
}
}
Future<void> reloadForAccountChange() async {
if (state.isSending) return;
emit(
state.copyWith(
messages: const [],
conversations: const [],
clearActiveConversation: true,
isLoadingHistory: true,
isWorkspaceMode: false,
clearSelectedWorkspacePath: true,
availableWorkspaceFiles: const [],
selectedWorkspaceFiles: const [],
clearSelectedSkill: true,
requestStatus: RequestStatus.idle,
clearAgentProgress: true,
clearPendingFileChangeProposal: true,
clearError: true,
),
);
await Future.wait([_loadHistory(), _loadAgentSkills()]);
}
Future<void> _saveCurrent(List<ChatMessage> messages) async {
if (messages.isEmpty) return;
final firstQuestion = messages.firstWhere(
@@ -11,6 +11,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../../../audio/presentation/cubit/voice_cubit.dart';
import '../../../audio/presentation/cubit/voice_state.dart';
import '../../../../core/notifications/completion_notification_service.dart';
import '../../../../core/network/api_repository.dart';
import '../cubit/chat_cubit.dart';
import '../cubit/chat_state.dart';
@@ -423,6 +424,232 @@ class _ChatPageState extends State<ChatPage> {
controller.dispose();
}
Future<void> _manageAccount() async {
final cubit = context.read<ChatCubit>();
final api = context.read<ApiRepository>();
if (cubit.state.isSending) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('انتظر حتى ينتهي الطلب قبل تبديل الحساب.'),
),
);
return;
}
if (api.accountEmail != null) {
final confirmed = await showDialog<bool>(
context: context,
builder:
(context) => Directionality(
textDirection: TextDirection.rtl,
child: AlertDialog(
title: const Text('تسجيل الخروج'),
content: Text('هل تريد تسجيل الخروج من ${api.accountEmail}؟'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('إلغاء'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('تسجيل الخروج'),
),
],
),
),
);
if (confirmed != true) return;
try {
await api.logout();
await cubit.reloadForAccountChange();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('تم تسجيل الخروج والعودة إلى الحساب المحلي.'),
),
);
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(error.toString().replaceFirst('Exception: ', '')),
),
);
}
return;
}
final emailController = TextEditingController();
final passwordController = TextEditingController();
var register = false;
var validationError = '';
final credentials = await showDialog<(bool, String, String)>(
context: context,
builder:
(dialogContext) => StatefulBuilder(
builder:
(context, setDialogState) => Directionality(
textDirection: TextDirection.rtl,
child: AlertDialog(
title: Text(register ? 'إنشاء حساب' : 'تسجيل الدخول'),
content: SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: emailController,
autofocus: true,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
decoration: const InputDecoration(
labelText: 'البريد الإلكتروني',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: passwordController,
obscureText: true,
autofillHints: [
register
? AutofillHints.newPassword
: AutofillHints.password,
],
onSubmitted: (_) {
final email = emailController.text.trim();
final password = passwordController.text;
if (email.isEmpty || password.isEmpty) {
setDialogState(
() =>
validationError =
'أدخل البريد وكلمة المرور.',
);
} else if (register && password.length < 12) {
setDialogState(
() =>
validationError =
'كلمة المرور يجب أن تكون 12 محرفًا على الأقل.',
);
} else {
Navigator.pop(dialogContext, (
register,
email,
password,
));
}
},
decoration: const InputDecoration(
labelText: 'كلمة المرور',
border: OutlineInputBorder(),
),
),
if (register) ...[
const SizedBox(height: 8),
const Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
'استخدم 12 محرفًا على الأقل.',
style: TextStyle(fontSize: 12),
),
),
],
if (validationError.isNotEmpty) ...[
const SizedBox(height: 8),
Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
validationError,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
),
],
TextButton(
onPressed:
() => setDialogState(() {
register = !register;
validationError = '';
}),
child: Text(
register
? 'لديك حساب؟ سجّل الدخول'
: 'ليس لديك حساب؟ أنشئ حسابًا',
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('إلغاء'),
),
FilledButton(
onPressed: () {
final email = emailController.text.trim();
final password = passwordController.text;
if (email.isEmpty || password.isEmpty) {
setDialogState(
() =>
validationError = 'أدخل البريد وكلمة المرور.',
);
} else if (register && password.length < 12) {
setDialogState(
() =>
validationError =
'كلمة المرور يجب أن تكون 12 محرفًا على الأقل.',
);
} else {
Navigator.pop(dialogContext, (
register,
email,
password,
));
}
},
child: Text(register ? 'إنشاء الحساب' : 'دخول'),
),
],
),
),
),
);
emailController.dispose();
passwordController.dispose();
if (credentials == null) return;
try {
if (credentials.$1) {
await api.registerAccount(
email: credentials.$2,
password: credentials.$3,
);
} else {
await api.login(email: credentials.$2, password: credentials.$3);
}
await cubit.reloadForAccountChange();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
credentials.$1
? 'تم إنشاء الحساب وتخزين الجلسة بأمان.'
: 'تم تسجيل الدخول وتخزين الجلسة بأمان.',
),
),
);
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(error.toString().replaceFirst('Exception: ', '')),
),
);
}
}
Future<void> _toggleRecording(VoiceState voice) async {
final cubit = context.read<VoiceCubit>();
if (voice.status == VoiceStatus.recording) {
@@ -1081,6 +1308,18 @@ class _ChatPageState extends State<ChatPage> {
),
),
SizedBox(width: compact ? 2 : 8),
IconButton(
onPressed: _manageAccount,
tooltip:
context.read<ApiRepository>().accountEmail == null
? 'تسجيل الدخول أو إنشاء حساب'
: 'الحساب: ${context.read<ApiRepository>().accountEmail} · تسجيل الخروج',
color:
context.read<ApiRepository>().accountEmail == null
? _ink
: _green,
icon: const Icon(Icons.account_circle_outlined),
),
IconButton(
onPressed: _settings,
tooltip: 'إعداد الاتصال',
@@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <local_notifier/local_notifier_plugin.h>
#include <record_linux/record_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
@@ -15,6 +16,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) local_notifier_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "LocalNotifierPlugin");
local_notifier_plugin_register_with_registrar(local_notifier_registrar);
@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
flutter_secure_storage_linux
local_notifier
record_linux
url_launcher_linux
@@ -7,6 +7,7 @@ import Foundation
import file_selector_macos
import flutter_local_notifications
import flutter_secure_storage_darwin
import local_notifier
import path_provider_foundation
import record_macos
@@ -16,6 +17,7 @@ import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
@@ -270,6 +270,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.0.0"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: fe638107c5f69119156ada2db5a57734385fac3f64430bd7252a00d3ead2ca4b
url: "https://pub.dev"
source: hosted
version: "10.3.4"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: caa75bd78f017422912e3a904933113b2428eb79f981ac37acd2dd2a700458ea
url: "https://pub.dev"
source: hosted
version: "3.0.3"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "64951127f001f546891c86f414972bf49ef2c4c8a4c92c65f5790cc0c9100045"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -46,6 +46,7 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
flutter_secure_storage: 10.3.4
dev_dependencies:
flutter_test:
@@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <local_notifier/local_notifier_plugin.h>
#include <record_windows/record_windows_plugin_c_api.h>
#include <share_plus/share_plus_windows_plugin_c_api.h>
@@ -15,6 +16,8 @@
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
LocalNotifierPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("LocalNotifierPlugin"));
RecordWindowsPluginCApiRegisterWithRegistrar(
@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
flutter_secure_storage_windows
local_notifier
record_windows
share_plus