import 'dart:collection'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:lemmy_api_client/v3.dart'; import 'package:shared_preferences/shared_preferences.dart'; part 'accounts_store.g.dart'; /// Store that manages all accounts @JsonSerializable() class AccountsStore extends ChangeNotifier { static const prefsKey = 'v4:AccountsStore'; static final _prefs = SharedPreferences.getInstance(); /// Map containing user data (jwt token, userId) of specific accounts. /// If a token is in this map, the user is considered logged in /// for that account. /// `accounts['instanceHost']['username']` @protected @JsonKey(defaultValue: {'lemmy.ml': {}}) late Map> accounts; /// default account for a given instance /// map where keys are instanceHosts and values are usernames @protected @JsonKey(defaultValue: {}) late Map defaultAccounts; /// default account for the app /// It is in a form of `username@instanceHost` @protected String? defaultAccount; static Future load() async { final prefs = await _prefs; return _$AccountsStoreFromJson( jsonDecode(prefs.getString(prefsKey) ?? '{}') as Map, ); } Future save() async { final prefs = await _prefs; await prefs.setString(prefsKey, jsonEncode(_$AccountsStoreToJson(this))); } /// automatically sets default accounts Future _assignDefaultAccounts() async { // remove dangling defaults defaultAccounts.entries .map((dft) { final instance = dft.key; final username = dft.value; // if instance or username doesn't exist, remove if (!instances.contains(instance) || !usernamesFor(instance).contains(username)) { return instance; } }) .toList() .forEach(defaultAccounts.remove); if (defaultAccount != null) { final instance = defaultAccount!.split('@')[1]; final username = defaultAccount!.split('@')[0]; // if instance or username doesn't exist, remove if (!instances.contains(instance) || !usernamesFor(instance).contains(username)) { defaultAccount = null; } } // set local defaults for (final instanceHost in instances) { // if this instance is not in defaults if (!defaultAccounts.containsKey(instanceHost)) { // select first account in this instance, if any if (!isAnonymousFor(instanceHost)) { await setDefaultAccountFor( instanceHost, usernamesFor(instanceHost).first); } } } // set global default if (defaultAccount == null) { // select first account of first instance for (final instanceHost in instances) { // select first account in this instance, if any if (!isAnonymousFor(instanceHost)) { await setDefaultAccount( instanceHost, usernamesFor(instanceHost).first); } } } } String? get defaultUsername => defaultAccount?.split('@')[0]; String? get defaultInstanceHost => defaultAccount?.split('@')[1]; UserData? get defaultUserData { if (defaultAccount == null) { return null; } final userTag = defaultAccount!.split('@'); return accounts[userTag[1]]?[userTag[0]]; } String? defaultUsernameFor(String instanceHost) { if (isAnonymousFor(instanceHost)) { return null; } return defaultAccounts[instanceHost]; } UserData? defaultUserDataFor(String instanceHost) { if (isAnonymousFor(instanceHost)) { return null; } return accounts[instanceHost]?[defaultAccounts[instanceHost]]; } UserData? userDataFor(String instanceHost, String username) { if (!usernamesFor(instanceHost).contains(username)) { return null; } return accounts[instanceHost]?[username]; } /// sets globally default account Future setDefaultAccount(String instanceHost, String username) { defaultAccount = '$username@$instanceHost'; notifyListeners(); return save(); } /// sets default account for given instance Future setDefaultAccountFor(String instanceHost, String username) { defaultAccounts[instanceHost] = username; notifyListeners(); return save(); } /// An instance is considered anonymous if it was not /// added or there are no accounts assigned to it. bool isAnonymousFor(String instanceHost) { if (!instances.contains(instanceHost)) { return true; } return accounts[instanceHost]!.isEmpty; } /// `true` if no added instance has an account assigned to it bool get hasNoAccount => loggedInInstances.isEmpty; Iterable get instances => accounts.keys; Iterable get loggedInInstances => instances.where((e) => !isAnonymousFor(e)); /// Usernames that are assigned to a given instance Iterable usernamesFor(String instanceHost) => accounts[instanceHost]?.keys ?? const Iterable.empty(); /// adds a new account /// if it's the first account ever the account is /// set as default for the app /// if it's the first account for an instance the account is /// set as default for that instance Future addAccount( String instanceHost, String usernameOrEmail, String password, ) async { if (!instances.contains(instanceHost)) { throw Exception('No such instance was added'); } final lemmy = LemmyApiV3(instanceHost); final response = await lemmy.run(Login( usernameOrEmail: usernameOrEmail, password: password, )); final jwt = response.jwt; if (jwt == null) { if (response.verifyEmailSent) { throw const VerifyEmailException(); } else if (response.registrationCreated) { throw const RegistrationApplicationSentException(); } throw Exception('Unknown error'); // should never happen } final userData = await lemmy .run(GetSite(auth: jwt.raw)) .then((value) => value.myUser!.localUserView.person); accounts[instanceHost]![userData.name] = UserData( jwt: jwt, userId: userData.id, ); await _assignDefaultAccounts(); notifyListeners(); return save(); } /// adds a new instance with no accounts associated with it. /// Additionally makes a test `GET /site` request to check if the instance exists. /// Check is skipped when [assumeValid] is `true` Future addInstance( String instanceHost, { bool assumeValid = false, }) async { if (instances.contains(instanceHost)) { throw Exception('This instance has already been added'); } if (!assumeValid) { try { await LemmyApiV3(instanceHost).run(const GetSite()); } catch (_) { throw Exception('This instance seems to not exist'); } } accounts[instanceHost] = HashMap(); await _assignDefaultAccounts(); notifyListeners(); return save(); } /// This also removes all accounts assigned to this instance Future removeInstance(String instanceHost) async { accounts.remove(instanceHost); await _assignDefaultAccounts(); notifyListeners(); return save(); } Future removeAccount(String instanceHost, String username) async { if (!accounts.containsKey(instanceHost)) { throw Exception("instance doesn't exist"); } accounts[instanceHost]!.remove(username); await _assignDefaultAccounts(); notifyListeners(); return save(); } } /// Stores data associated with a logged in user @JsonSerializable() class UserData { final Jwt jwt; final int userId; const UserData({ required this.jwt, required this.userId, }); factory UserData.fromJson(Map json) => _$UserDataFromJson(json); Map toJson() => _$UserDataToJson(this); } //if (data.verify_email_sent) { // toast(i18n.t("verify_email_sent")); // } // if (data.registration_created) { // toast(i18n.t("registration_application_sent")); // } class VerifyEmailException implements Exception { final message = 'verify_email_sent'; const VerifyEmailException(); } class RegistrationApplicationSentException implements Exception { final message = 'registration_application_sent'; const RegistrationApplicationSentException(); }