Skip to content

Commit

Permalink
Migrate from Hive to shared_preferences + flutter_secure_storage (#61)
Browse files Browse the repository at this point in the history
* Change settings storage from Hive to shared prefs

* Use secure storage instead of Hive to store token

* Remove Hive from dependencies

* Wrap prefs and secure storage instances with providers

* Upgrade secure storage package (somehow used v4 before)

* Fix settings test

* Fix API service alive behavior & fix tests

* Fix profile tests

* Implement mocked fetch profile

* Add missing `equals`

* Separate token provider & fix profile unit test

* Fix typo

* Move keepAlive call
  • Loading branch information
dhafinrayhan authored May 29, 2024
1 parent ca2fe00 commit 5e92cfb
Show file tree
Hide file tree
Showing 34 changed files with 402 additions and 213 deletions.
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# DummyMart

An example Flutter project that uses [Riverpod], [go_router], [Hive], [flutter_hooks], and [Freezed].
An example Flutter project that uses [Riverpod], [go_router], [flutter_hooks], and [Freezed].

> Check out the experimental use of macros [here](https://github.com/dhafinrayhan/dummymart/tree/macros).
Expand Down Expand Up @@ -65,7 +65,6 @@ Or you can use any user credentials from here: https://dummyjson.com/users
[riverpod]: https://pub.dev/packages/riverpod
[flutter_hooks]: https://pub.dev/packages/flutter_hooks
[freezed]: https://pub.dev/packages/freezed
[hive]: https://pub.dev/packages/hive
[go_router]: https://pub.dev/packages/go_router
[build_runner]: https://pub.dev/packages/build_runner
[DummyJSON]: https://dummyjson.com/
Expand Down
2 changes: 1 addition & 1 deletion android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ android {
applicationId "dev.dhafin.dummymart"
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
minSdk = flutter.minSdkVersion
minSdk = Math.max(18, flutter.minSdkVersion)
targetSdk = flutter.targetSdkVersion
versionCode = flutterVersionCode.toInteger()
versionName = flutterVersionName
Expand Down
27 changes: 15 additions & 12 deletions lib/features/auth/providers/auth_state.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../../../services/api/api_service.dart';
import '../../../services/storage/secure_storage.dart';
import '../models/auth_state.dart';
import '../models/login.dart';

Expand All @@ -13,39 +13,42 @@ part 'auth_state.g.dart';
/// to the storage through the [login] and [logout] methods.
@riverpod
class CurrentAuthState extends _$CurrentAuthState {
final _tokenBox = Hive.box<String>('token');

@override
AuthState build() {
final token = _tokenBox.get('current');
final secureStorage = ref.watch(secureStorageProvider).requireValue;
final token = secureStorage.get('token');
return token != null ? AuthState.authenticated : AuthState.unauthenticated;
}

/// Attempts to log in with [data] and saves the token and profile info to storage.
/// Will invalidate the state if success.
Future<void> login(Login data) async {
final secureStorage = ref.read(secureStorageProvider).requireValue;
final token = await ref.read(apiServiceProvider).login(data);

// Save the new [token] and [profile] to Hive box.
_tokenBox.put('current', token);
// Save the new [token] and [profile] to secure storage.
secureStorage.set('token', token);

ref
// Invalidate the state so the auth state will be updated to authenticated.
..invalidateSelf()
// Invalidate the API service so that it will use the new token.
..invalidate(apiServiceProvider);
// Invalidate the token provider so the API service will use the new token.
..invalidate(tokenProvider);
}

/// Logs out, deletes the saved token and profile info from storage, and invalidates
/// the state.
void logout() {
// Delete the current [token] and [profile] from Hive box.
_tokenBox.delete('current');
final secureStorage = ref.read(secureStorageProvider).requireValue;

// Delete the current [token] and [profile] from secure storage.
secureStorage.remove('token');

ref
// Invalidate the state so the auth state will be updated to unauthenticated.
..invalidateSelf()
// Invalidate the API service so that it will no longer use the previous token.
..invalidate(apiServiceProvider);
// Invalidate the token provider so the API service will no longer use the
// previous token.
..invalidate(tokenProvider);
}
}
19 changes: 11 additions & 8 deletions lib/features/settings/providers/settings.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../../../services/storage/prefs.dart';

part 'settings.g.dart';

/// The current theme mode of the app.
Expand All @@ -10,12 +11,12 @@ part 'settings.g.dart';
/// and defaults to [ThemeMode.system] if the theme mode has not been set before.
@riverpod
class CurrentThemeMode extends _$CurrentThemeMode {
late final _box = Hive.box<String>('settings');

@override
ThemeMode build() {
// Load the saved theme mode setting from Hive box.
final themeModeName = _box.get('themeMode');
final prefs = ref.watch(prefsProvider).requireValue;

// Load the saved theme mode setting from shared preferences.
final themeModeName = prefs.getString('themeMode');

// Return [ThemeMode] based on the saved setting, or [ThemeMode.system]
// if there's no saved setting yet.
Expand All @@ -26,9 +27,11 @@ class CurrentThemeMode extends _$CurrentThemeMode {
}

void set(ThemeMode themeMode) {
state = themeMode;
final prefs = ref.read(prefsProvider).requireValue;

// Save the new theme mode to shared preferences.
prefs.setString('themeMode', themeMode.name);

// Save the new theme mode to Hive box.
_box.put('themeMode', themeMode.name);
ref.invalidateSelf();
}
}
70 changes: 50 additions & 20 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,47 +2,77 @@ import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'features/settings/providers/settings.dart';
import 'services/router.dart';
import 'services/storage/prefs.dart';
import 'services/storage/secure_storage.dart';
import 'utils/methods.dart';
import 'utils/provider_observer.dart';

Future<void> main() async {
// Some packages, like Hive (through its `initFlutter` method), call this
// internally. This could make the illusion that we don't need to call it,
// when some other packages actually need this to be called, but no error
// occurred because of those internal calls from the packages that call it. So
// it's always a good idea to call this ourself to prevent undesired or
// unpredictable behavior if in the future packages are being added/removed.
WidgetsFlutterBinding.ensureInitialized();
WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized();

HttpOverrides.global = _HttpOverrides();

// Initialize Hive.
await Future(() async {
await Hive.initFlutter();
// We preserve the native splash screen, which will then removed once the main
// app is inserted to the widget tree.
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);

// Open boxes.
await [
Hive.openBox<String>('token'),
Hive.openBox<String>('settings'),
].wait;
});
HttpOverrides.global = _HttpOverrides();

runApp(ProviderScope(
observers: [AppProviderObserver()],
child: const DummyMartApp(),
));
}

class DummyMartApp extends HookConsumerWidget {
class DummyMartApp extends StatelessWidget {
const DummyMartApp({super.key});

@override
Widget build(BuildContext context) {
return const _EagerInitialization(
child: _MainApp(),
);
}
}

class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;

@override
Widget build(BuildContext context, WidgetRef ref) {
final values = [
ref.watch(prefsProvider),
ref.watch(secureStorageProvider),
];

if (values.every((value) => value.hasValue)) {
return child;
}

return const SizedBox();
}
}

class _MainApp extends StatefulHookConsumerWidget {
const _MainApp();

@override
ConsumerState<_MainApp> createState() => _MainAppState();
}

class _MainAppState extends ConsumerState<_MainApp> {
@override
void initState() {
super.initState();
FlutterNativeSplash.remove();
}

@override
Widget build(BuildContext context) {
final router = ref.watch(routerProvider);
final themeMode = ref.watch(currentThemeModeProvider);

Expand Down
28 changes: 20 additions & 8 deletions lib/services/api/api_service.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../storage/secure_storage.dart';
import 'api_client.dart';
import 'mock/mocked_api_client.dart';

Expand All @@ -13,15 +13,27 @@ part 'api_service.g.dart';
/// data provider (provider that fetches data) to refetch when the
/// authentication state changes.
///
/// The provider is kept alive to follow dio's recommendation to use the same
/// client instance for the entire app. Technically, this would still work
/// without keepAlive set to true.
@Riverpod(keepAlive: true)
/// The API client is kept alive to follow dio's recommendation to use the same
/// client instance for the entire app.
@riverpod
ApiClient apiService(ApiServiceRef ref) {
final token = Hive.box<String>('token').get('current');
final token = ref.watch(tokenProvider);

final ApiClient client;

const mock = bool.fromEnvironment('MOCK_API', defaultValue: false);
if (mock) return MockedApiClient();
client = switch (mock) {
true =>
token != null ? MockedApiClient.withToken(token) : MockedApiClient(),
false => token != null ? ApiClient.withToken(token) : ApiClient(),
};
ref.keepAlive();

return client;
}

return token != null ? ApiClient.withToken(token) : ApiClient();
@riverpod
String? token(TokenRef ref) {
final secureStorage = ref.watch(secureStorageProvider).requireValue;
return secureStorage.get('token');
}
27 changes: 24 additions & 3 deletions lib/services/api/mock/mocked_api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,15 @@ class MockedApiClient implements ApiClient {
final List<Map<String, Object?>> _usersRaw =
_MockedApiClientRepository.getUsersRaw();

String? _token;

MockedApiClient({Duration? delay})
: _delay = delay ?? const Duration(milliseconds: 500);

MockedApiClient.withToken(String token, {Duration? delay})
: _delay = delay ?? const Duration(milliseconds: 500),
_token = token;

@override
Future<String> login(Login data) async {
await Future.delayed(_delay);
Expand All @@ -27,7 +33,8 @@ class MockedApiClient implements ApiClient {
user['username'] == data.username &&
user['password'] == data.password);
final profile = Profile.fromJson(profileRaw);
final token = 'fakeTokenForUser${profile.id}';
final token = 'fakeTokenForUser=${profile.username}';
_token = token;
return token;
} on StateError {
final requestOptions = ApiClientRequestOptions();
Expand All @@ -42,8 +49,22 @@ class MockedApiClient implements ApiClient {
}

@override
Future<Profile> fetchProfile() {
throw UnimplementedError();
Future<Profile> fetchProfile() async {
await Future.delayed(_delay);
if (_token == null) {
final requestOptions = ApiClientRequestOptions();
throw ApiClientException(
requestOptions: requestOptions,
response: ApiClientResponse(
requestOptions: requestOptions,
data: {'message': 'Authentication Problem'},
),
);
}
final username = _token!.substring(17);
final profileRaw =
_usersRaw.singleWhere((user) => user['username'] == username);
return Profile.fromJson(profileRaw);
}

@override
Expand Down
8 changes: 8 additions & 0 deletions lib/services/storage/prefs.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';

part 'prefs.g.dart';

@riverpod
Future<SharedPreferences> prefs(PrefsRef ref) =>
SharedPreferences.getInstance();
41 changes: 41 additions & 0 deletions lib/services/storage/secure_storage.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'secure_storage.g.dart';

@riverpod
Future<SecureStorage> secureStorage(SecureStorageRef ref) =>
SecureStorage.getInstance(keys: {'token'});

class SecureStorage {
SecureStorage._(this._flutterSecureStorage, this._cache);

late final FlutterSecureStorage _flutterSecureStorage;

late final Map<String, String> _cache;

static Future<SecureStorage> getInstance({required Set<String> keys}) async {
const flutterSecureStorage = FlutterSecureStorage();
final cache = <String, String>{};
await keys
.map((key) => flutterSecureStorage.read(key: key).then((value) {
if (value != null) {
cache[key] = value;
}
}))
.wait;
return SecureStorage._(flutterSecureStorage, cache);
}

String? get(String key) => _cache[key];

Future<void> set(String key, String value) {
_cache[key] = value;
return _flutterSecureStorage.write(key: key, value: value);
}

Future<void> remove(String key) {
_cache.remove(key);
return _flutterSecureStorage.delete(key: key);
}
}
4 changes: 4 additions & 0 deletions linux/flutter/generated_plugin_registrant.cc
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

#include "generated_plugin_registrant.h"

#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>

void fl_register_plugins(FlPluginRegistry* registry) {
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);
}
1 change: 1 addition & 0 deletions linux/flutter/generated_plugins.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#

list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux
)

list(APPEND FLUTTER_FFI_PLUGIN_LIST
Expand Down
Loading

0 comments on commit 5e92cfb

Please sign in to comment.