Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor login page to "pop" #255

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions lib/app/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ part "app_router.gr.dart";

/// The router for the application.
@AutoRouterConfig(replaceInRouteName: "Page,Route")
class AppRouter extends _$AppRouter {
class AppRouter extends _$AppRouter implements AutoRouteGuard {
/// Create a new instance of [AppRouter].
AppRouter({required this.ref});

Expand All @@ -28,20 +28,31 @@ class AppRouter extends _$AppRouter {
transitionsBuilder: TransitionsBuilders.slideLeftWithFade,
);

@override
Future<void> onNavigation(
NavigationResolver resolver,
StackRouter router,
) async {
final authState = ref.read(userProvider).valueOrNull?.isLoggedIn;

if ((authState ?? false) || (resolver.route.name == AuthRoute.name)) {
resolver.next(); // continue navigation
} else {
// else we navigate to the Login page so we get authenticated

// tip: use resolver.redirect to have the redirected route
// automatically removed from the stack when the resolver is completed
await resolver.redirect(const AuthRoute()).then(
(didLogin) => resolver.next((didLogin ?? false) as bool),
);
}
}

@override
List<AutoRoute> get routes => [
AutoRoute(
page: WrapperRoute.page,
path: "/",
guards: [
AutoRouteGuard.redirect(
(resolver) {
final authState = ref.read(userProvider).valueOrNull;

return (authState != null) ? null : const AuthRoute();
},
),
],
children: [
AutoRoute(
page: PirateCoinsRoute.page,
Expand Down
29 changes: 26 additions & 3 deletions lib/features/auth/application/auth_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ part "auth_service.g.dart";
base class PirateAuthService extends _$PirateAuthService {
@override
FutureOr<PirateAuthModel> build() async {
return _createSession(anonymous: true);
return _fetchSession();
}

/// Authenticate the current user.
Expand All @@ -27,13 +27,25 @@ base class PirateAuthService extends _$PirateAuthService {

Future<PirateAuthModel> _createSession({bool anonymous = false}) async {
final auth = ref.read(authProvider);
final account = await auth.authenticate(anonymous: anonymous);
await auth.authenticate(anonymous: anonymous);
final account = await auth.getData();

return PirateAuthModel(
user: account,
);
}

Future<PirateAuthModel> _fetchSession() async {
final auth = ref.read(authProvider);
try {
final account = await auth.getData();

return PirateAuthModel(user: account);
} catch (e) {
return const PirateAuthModel(user: null);
}
}

/// Create a new anonymous session for the user.
Future<void> anonymous() async {
state = await AsyncValue.guard(() => _createSession(anonymous: true));
Expand All @@ -45,9 +57,20 @@ base class PirateAuthService extends _$PirateAuthService {
/// Use [pirateAuthServiceProvider] for more granular output.
@Riverpod(keepAlive: true)
Future<PirateUserEntity> user(UserRef ref) async => await ref.watch(
pirateAuthServiceProvider.selectAsync((value) => value.user),
pirateAuthServiceProvider.selectAsync((value) {
return value.user ?? fakeUser;
}),
);

/// A fake user, for use when all else fails.
final fakeUser = PirateUserEntity(
name: redactedName,
email: redactedEmail,
accountType: AccountType.student,
avatar: redactedAvatar,
isLoggedIn: false,
);

/// Get the current user's name.
///
/// Named as such to prevent a naming conflict with riverpod.
Expand Down
137 changes: 68 additions & 69 deletions lib/features/auth/data/auth_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,86 +20,93 @@ part "auth_repository.g.dart";
/// A repository for authentication.
abstract interface class AuthRepository {
/// Authenticate the user.
Future<PirateUserEntity> authenticate({required bool anonymous});
Future<void> authenticate({required bool anonymous});

/// Get data about the current user.
Future<PirateUserEntity> getData();
}

/// The default implementation of [AuthRepository].
base class _AppwriteAuthRepository implements AuthRepository {
/// Create a new instance of [_AppwriteAuthRepository].
const _AppwriteAuthRepository(
Account account,
Device platform,
AvatarRepository avatar,
) : _account = account,
_platform = platform,
_avatarRepo = avatar;
const _AppwriteAuthRepository(this.account, this.platform, this.avatarRepo);

/// The Appwrite [Account].
final Account _account;
final Account account;

/// The [currentPlatform].
final Device _platform;
final Device platform;

/// The current user's avatar.
final AvatarRepository _avatarRepo;
final AvatarRepository avatarRepo;

/// Get a user from Appwrite.
Future<User> getUser() => account.get();

@override
Future<PirateUserEntity> authenticate({bool anonymous = false}) async {
User account;
Future<PirateUserEntity> getData() async {
final user = await getUser();

return getUserData(user);
}

Future<PirateUserEntity> getUserData(User user) async {
final accountType = AccountType.fromEmail(user.email);
final avatar = await avatarRepo.getAvatar();

return PirateUserEntity(
name: user.name,
email: user.email,
accountType: accountType,
avatar: avatar,
isLoggedIn: true,
);
}

@override
Future<void> authenticate({bool anonymous = false}) async {
try {
account = await _account.get();
await getUser();
} catch (e, s) {
log.warning("Failed to fetch session.", e, s);
if (anonymous) {
try {
await _account.createAnonymousSession();
} catch (e, s) {
log.warning("Failed to create anonymous session.", e, s);
}
} else {
try {
// Go to the Google account login page.
switch (_platform) {
// Both Android and iOS need the same behavior, so it reuses it.
case Device.android || Device.ios:
await _account.createOAuth2Session(
provider: "google",
);

// TODO(lishaduck): The web needs different behavior than that of linux/mac/windows/fuchsia.
case Device.web ||
Device.linux ||
Device.macos ||
Device.windows ||
Device.other:
await _account.createOAuth2Session(
provider: "google",
success: "${Uri.base.origin}/auth.html",
failure: "${Uri.base}",
);
}
} catch (e, s) {
log.warning("Failed to create OAuth2 session.", e, s);
}
}
await _createAccount(anonymous);

account = await _account.get();
await getUser();
}
}

try {
final accountType = AccountType.fromEmail(account.email);
final avatar = await _avatarRepo.getAvatar();

return PirateUserEntity(
name: account.name,
email: account.email,
accountType: accountType,
avatar: avatar,
);
} catch (e) {
log.warning("Failed to fetch user data.", e);

return fakeUser;
Future<void> _createAccount(bool anonymous) async {
if (anonymous) {
try {
await account.createAnonymousSession();
} catch (e, s) {
log.warning("Failed to create anonymous session.", e, s);
}
} else {
try {
// Go to the Google account login page.
switch (platform) {
// Both Android and iOS need the same behavior, so it reuses it.
case Device.android || Device.ios:
await account.createOAuth2Session(
provider: "google",
);

// TODO(lishaduck): The web needs different behavior than that of linux/mac/windows/fuchsia.
case Device.web ||
Device.linux ||
Device.macos ||
Device.windows ||
Device.other:
await account.createOAuth2Session(
provider: "google",
success: "${Uri.base.origin}/auth.html",
failure: "${Uri.base}",
);
}
} catch (e, s) {
log.warning("Failed to create OAuth2 session.", e, s);
}
}
}
}
Expand All @@ -113,14 +120,6 @@ const redactedName = "Anonymous";
/// The avatar used in case things go wrong.
final redactedAvatar = Uint8List(1);

/// A fake user, for use when all else fails.
final fakeUser = PirateUserEntity(
name: redactedName,
email: redactedEmail,
accountType: AccountType.student,
avatar: redactedAvatar,
);

/// Get the authentication data provider.
@Riverpod(keepAlive: true)
AuthRepository auth(AuthRef ref) {
Expand Down
2 changes: 1 addition & 1 deletion lib/features/auth/domain/pirate_auth_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ sealed class PirateAuthModel with _$PirateAuthModel implements Model {
/// Create a new, immutable instance of [PirateAuthModel].
const factory PirateAuthModel({
/// The user.
required PirateUserEntity user,
required PirateUserEntity? user,
}) = _PirateAuthModel;

/// Convert a JSON [Map] into a new, immutable instance of [PirateAuthModel].
Expand Down
3 changes: 3 additions & 0 deletions lib/features/auth/domain/pirate_user_entity.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ sealed class PirateUserEntity with _$PirateUserEntity implements Model {

/// The user's avatar.
@Uint8ListConverter() required Uint8List avatar,

/// If the user has logged in through Appwrite.
required bool isLoggedIn,
}) = _PirateUser;

const PirateUserEntity._();
Expand Down
17 changes: 14 additions & 3 deletions lib/features/auth/presentation/auth_page/auth_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import "../../../../l10n/l10n.dart";
import "../../application/auth_service.dart";

/// The page located at `/login/`.
@RoutePage()
@RoutePage<bool>()
class AuthPage extends ConsumerWidget {
/// Create a new instance of [AuthPage].
const AuthPage({super.key});
Expand All @@ -38,7 +38,7 @@ class AuthPage extends ConsumerWidget {
.authenticate();

if (context.mounted) {
await context.router.push(const DashboardRoute());
await context.go();
}
},
icon: const Icon(Icons.g_mobiledata),
Expand All @@ -52,7 +52,7 @@ class AuthPage extends ConsumerWidget {
.anonymous();

if (context.mounted) {
await context.router.push(const DashboardRoute());
await context.go();
}
},
icon: const Icon(Icons.person),
Expand All @@ -67,3 +67,14 @@ class AuthPage extends ConsumerWidget {
);
}
}

extension _Go on BuildContext {
Future<void> go() async {
// if we were redirected here, go back to the right page.
await router.pop<bool>(true);
if (mounted) {
// otherwise, go to the dashboard.
await router.push(const DashboardRoute());
}
}
}
1 change: 0 additions & 1 deletion test/app/app_test.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// ignore_for_file: scoped_providers_should_specify_dependencies, prefer_const_constructors, prefer_const_literals_to_create_immutables
import "package:appwrite/appwrite.dart";
import "package:flutter/material.dart";
import "package:flutter_test/flutter_test.dart";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:pirate_code/app/app.dart";
import "package:pirate_code/app/app_router.dart";
import "package:pirate_code/features/auth/application/auth_service.dart";
import "package:pirate_code/features/auth/data/auth_repository.dart";
import "package:pirate_code/features/dashboard/presentation/wrapper_page/wrapper_page.dart";
import "package:pirate_code/l10n/l10n.dart";
import "package:pirate_code/utils/design.dart";
Expand Down