feat(app): sign in anonymously on first launch
The app's first patterns. Dependencies (lib/src/core/dependencies) is the composition root, built at startup and handed down by DependenciesScope; it replaces the scaffold's global client and restores a stored session before the first frame, so a restart never creates a second anonymous account. AuthGateway with ServerpodAuthGateway is the boundary to the server's authentication, AuthRepository signs in anonymously when nobody is signed in, and SessionBloc (flutter_bloc) keeps a person signed in, also after signing out or a revoked session. SessionGate shows a spinner while signing in, offers to try again when that fails, and builds the app anew for every person. AccountButton explains that progress is saved on this device and asks before signing out of an anonymous account, which can't be restored. AppBlocObserver logs all blocs. Tests: the repository, the bloc's sequences (a stored session is kept, a failed sign-in is retried, signing out starts anew), and the widgets, including that cancelling the sign-out keeps the account. test/support holds the fake gateway and testDependencies(). AGENTS.md describes the composition root and the test factory. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
df99def5bc
commit
d6b4c46b57
24 changed files with 790 additions and 43 deletions
|
|
@ -71,7 +71,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
- **Four layers per app feature**, dependencies pointing strictly downward: `widget/` (UI; reads bloc state, sends events) → `bloc/` (UI state; orchestrates repositories) → `data/` (repositories and gateways) → `model/` (immutable domain types; pure Dart, no Flutter, no I/O). A widget never calls a gateway; a model never imports a bloc.
|
||||
- **Generated protocol classes are not domain models.** They carry the envelope (`payload` as bytes); `model/` holds the decoded content. Mapping between them happens in `data/`.
|
||||
- **Gateway / repository.** A gateway is a thin boundary to *one* backend (e.g. the Serverpod client's endpoints) behind an `abstract interface class`. A repository is what features use: it composes gateways and holds the logic. Blocs depend on repositories, never on gateways or the raw client. Why: gateways are the seams tests fake, and adding the offline layer later becomes a new local gateway, not a rewrite of every feature.
|
||||
- **Manual dependency injection.** One `Dependencies` object is the composition root, built at startup and exposed through an `InheritedWidget` scope. No service locator, no DI framework, no globals (the scaffold's global `client` moves into it). Why: every dependency is a typed, greppable field, and nothing is resolved by magic.
|
||||
- **Manual dependency injection.** One `Dependencies` object (`lib/src/core/dependencies/`) is the composition root, built at startup (`Dependencies.create()`) and exposed through the `DependenciesScope` InheritedWidget. It hands features repositories only. No service locator, no DI framework, no globals (the scaffold's global `client` moved into it). Why: every dependency is a typed, greppable field, and nothing is resolved by magic.
|
||||
- **One graph per context.** The dependency graph belongs to one instance and one signed-in person; when either changes, a `KeyedSubtree` disposes the whole subtree and rebuilds it. Why: nothing from one person's session (blocs, streams, caches) can leak into another's.
|
||||
- **State: `flutter_bloc`, uniformly.** Blocs fold repository streams into immutable states with value equality, are provided as close to their consumer as possible, and log through one global `BlocObserver`. Why: the data layer is stream-shaped, event → state machines test without widgets, and one pattern everywhere is itself the point.
|
||||
- **Value equality comes from `equatable`**, mixed in (`with Equatable`), for models and states alike; `leancode_lint`'s `missing_equatable_props` catches a field left out of `props`.
|
||||
|
|
@ -85,7 +85,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
|
||||
- **By layer**: models with plain unit tests; repositories against fake gateways; blocs by asserting the emitted state sequence; widgets pumped in isolation, presentation widgets fed with ready-made data.
|
||||
- **Server endpoints are tested for real** against the embedded Postgres (`withServerpod`, `dart test` in the server package), including the denial tests.
|
||||
- **Test dependencies come from a test factory** (e.g. `Dependencies.test(...)` filling in fakes by default), so a new dependency changes one place. Don't make dependencies nullable to spare tests: that moves null checks into production code.
|
||||
- **Test dependencies come from a test factory**, `testDependencies(...)` in `test/support/test_dependencies.dart`, filling in fakes by default (a factory in `lib/` couldn't reach the fakes in `test/`), so a new dependency changes one place. Don't make dependencies nullable to spare tests: that moves null checks into production code.
|
||||
- Shared fakes live in `test/support/`; fakes pre-stub their streams so an incidental subscription doesn't fail the test.
|
||||
- **Editor in widget tests**: host `AppFlowyEditor` in a scrollable body with a fixed-height slot (an over-constrained `Expanded` hangs the test), and `pump(const Duration(seconds: 1))` after editor changes to flush its timers. Test the document ↔ JSON round-trip as plain unit tests.
|
||||
- **Layout-sensitive widget tests** set `tester.view.physicalSize` explicitly, so a responsive branch isn't missed for the wrong reason.
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart';
|
||||
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
|
||||
import 'package:serverpod_flutter/serverpod_flutter.dart';
|
||||
|
||||
// When you are running the app on a physical device, you need to set the
|
||||
// server URL to the IP address of your computer. You can find the IP
|
||||
// address by running `ipconfig` on Windows or `ifconfig` on Mac/Linux.
|
||||
//
|
||||
// You can set the variable when running or building your app like this:
|
||||
// E.g. `flutter run --dart-define=SERVER_URL=https://api.example.com/`.
|
||||
//
|
||||
// Otherwise, the server URL is fetched from the assets/config.json file or
|
||||
// defaults to http://$localhost:8080/ if not found.
|
||||
final serverUrl = getServerUrl();
|
||||
|
||||
/// Sets up a global client object that can be used to talk to the server from
|
||||
/// anywhere in our app. The client is generated from your server code
|
||||
/// and is set up to connect to a Serverpod running on a local server on
|
||||
/// the default port. You will need to modify this to connect to staging or
|
||||
/// production servers.
|
||||
/// In a larger app, you may want to use the dependency injection of your choice
|
||||
/// instead of using a global client object. This is just a simple example.
|
||||
late final Client client;
|
||||
|
||||
Future<void> initializeClient() async {
|
||||
client = Client(await serverUrl)
|
||||
..connectivityMonitor = FlutterConnectivityMonitor()
|
||||
..authSessionManager = FlutterAuthSessionManager();
|
||||
unawaited(client.auth.initialize());
|
||||
}
|
||||
|
|
@ -1,11 +1,21 @@
|
|||
import 'package:halkyon_learn_flutter/client.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/bloc/app_bloc_observer.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/widget/session_gate.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/editor_playground/widget/editor_playground_screen.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() async {
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await initializeClient();
|
||||
runApp(const HalkyonLearnApp());
|
||||
Bloc.observer = const AppBlocObserver();
|
||||
final dependencies = await Dependencies.create();
|
||||
runApp(
|
||||
DependenciesScope(
|
||||
dependencies: dependencies,
|
||||
child: const HalkyonLearnApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a theme for the given [brightness].
|
||||
|
|
@ -31,7 +41,7 @@ class const HalkyonLearnApp({super.key}) extends StatelessWidget {
|
|||
// bridge is deprecated on purpose, as a reminder: remove it once they have moved.
|
||||
// ignore: deprecated_member_use
|
||||
builder: (context, child) => MaterialUiCompatibilityBridge(child: child!),
|
||||
home: const EditorPlaygroundScreen(),
|
||||
home: const SessionGate(child: EditorPlaygroundScreen()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
/// The one observer of all blocs: logs every state change and error, so bloc
|
||||
/// behaviour can be followed without adding logging to each bloc.
|
||||
class const AppBlocObserver() extends BlocObserver {
|
||||
@override
|
||||
void onChange(BlocBase<dynamic> bloc, Change<dynamic> change) {
|
||||
super.onChange(bloc, change);
|
||||
log(
|
||||
'${change.currentState} → ${change.nextState}',
|
||||
name: '${bloc.runtimeType}',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(BlocBase<dynamic> bloc, Object error, StackTrace stackTrace) {
|
||||
log(
|
||||
'Error',
|
||||
name: '${bloc.runtimeType}',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
super.onError(bloc, error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import 'package:halkyon_learn_client/halkyon_learn_client.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/serverpod_auth_gateway.dart';
|
||||
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
|
||||
import 'package:serverpod_flutter/serverpod_flutter.dart';
|
||||
|
||||
/// The app's composition root: everything the features depend on, built once
|
||||
/// at startup and handed down through `DependenciesScope`.
|
||||
///
|
||||
/// Features get repositories only, never gateways or the Serverpod client.
|
||||
/// Tests build it with fakes (`test/support/test_dependencies.dart`).
|
||||
class Dependencies({
|
||||
/// Who is signed in, and signing in and out.
|
||||
required final AuthRepository auth,
|
||||
final Future<void> Function()? _onDispose,
|
||||
}) {
|
||||
/// Builds the production graph around one Serverpod client.
|
||||
///
|
||||
/// The server URL comes from `--dart-define=SERVER_URL=…` (or
|
||||
/// `assets/config.json`); the client holds no secrets.
|
||||
static Future<Dependencies> create() async {
|
||||
final client = Client(await getServerUrl())
|
||||
..connectivityMonitor = FlutterConnectivityMonitor()
|
||||
..authSessionManager = FlutterAuthSessionManager();
|
||||
// Restores a stored session before anything asks who is signed in;
|
||||
// otherwise the first launch logic would sign in a second anonymous person.
|
||||
await client.auth.initialize();
|
||||
|
||||
final authGateway = ServerpodAuthGateway(client);
|
||||
return Dependencies(
|
||||
auth: AuthRepository(authGateway),
|
||||
onDispose: () async {
|
||||
await authGateway.dispose();
|
||||
client.close();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Releases what [create] opened.
|
||||
Future<void> dispose() async {
|
||||
await _onDispose?.call();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
|
||||
|
||||
/// Hands [dependencies] down the widget tree.
|
||||
class const DependenciesScope({
|
||||
/// The app's dependencies.
|
||||
required final Dependencies dependencies,
|
||||
required super.child,
|
||||
super.key,
|
||||
}) extends InheritedWidget {
|
||||
/// The dependencies above [context].
|
||||
///
|
||||
/// Doesn't register [context] for rebuilds: the dependencies of one app run
|
||||
/// never change.
|
||||
static Dependencies of(BuildContext context) {
|
||||
final scope = context.getInheritedWidgetOfExactType<DependenciesScope>();
|
||||
assert(scope != null, 'No DependenciesScope above this widget.');
|
||||
return scope!.dependencies;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(DependenciesScope oldWidget) =>
|
||||
dependencies != oldWidget.dependencies;
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_event.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_state.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
|
||||
/// Keeps a person signed in: follows who is signed in, signs in anonymously
|
||||
/// whenever nobody is, and signs out on request.
|
||||
class SessionBloc(final AuthRepository _auth)
|
||||
extends Bloc<SessionEvent, SessionState> {
|
||||
this : super(const SessionLoading()) {
|
||||
on<SessionStarted>(_onStarted);
|
||||
on<SessionSignInRequested>(_onSignInRequested);
|
||||
on<SessionSignOutRequested>(_onSignOutRequested);
|
||||
}
|
||||
|
||||
Future<void> _onStarted(
|
||||
SessionStarted event,
|
||||
Emitter<SessionState> emit,
|
||||
) async {
|
||||
add(const SessionSignInRequested());
|
||||
await emit.forEach<AuthStatus>(
|
||||
_auth.statusChanges,
|
||||
onData: (status) {
|
||||
if (status is SignedOut) {
|
||||
// Signed out, here or by the server (a revoked session): start anew.
|
||||
add(const SessionSignInRequested());
|
||||
}
|
||||
return _stateOf(status);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onSignInRequested(
|
||||
SessionSignInRequested event,
|
||||
Emitter<SessionState> emit,
|
||||
) async {
|
||||
// A session stored on the device wins: never a second anonymous account.
|
||||
if (_auth.status case final SignedIn person) {
|
||||
emit(SessionActive(person));
|
||||
return;
|
||||
}
|
||||
emit(const SessionLoading());
|
||||
try {
|
||||
await _auth.ensureSignedIn();
|
||||
emit(_stateOf(_auth.status));
|
||||
} on Object catch (error, st) {
|
||||
addError(error, st);
|
||||
emit(const SessionFailed());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSignOutRequested(
|
||||
SessionSignOutRequested event,
|
||||
Emitter<SessionState> emit,
|
||||
) async {
|
||||
emit(const SessionLoading());
|
||||
try {
|
||||
// The status change to signed out then starts a new anonymous session.
|
||||
await _auth.signOut();
|
||||
} on Object catch (error, st) {
|
||||
addError(error, st);
|
||||
emit(_stateOf(_auth.status));
|
||||
}
|
||||
}
|
||||
|
||||
SessionState _stateOf(AuthStatus status) => switch (status) {
|
||||
SignedIn() => SessionActive(status),
|
||||
SignedOut() => const SessionLoading(),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// What happens to the session.
|
||||
sealed class const SessionEvent() with Equatable {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// The app starts: follow who is signed in, and sign in anonymously if nobody
|
||||
/// is.
|
||||
final class const SessionStarted() extends SessionEvent;
|
||||
|
||||
/// Sign in (again): after a failed attempt, or once nobody is signed in.
|
||||
final class const SessionSignInRequested() extends SessionEvent;
|
||||
|
||||
/// The person confirmed that they want to sign out.
|
||||
final class const SessionSignOutRequested() extends SessionEvent;
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
|
||||
/// Whether the app has a signed-in person to work for.
|
||||
sealed class const SessionState() with Equatable;
|
||||
|
||||
/// Signing in, or finding out who is signed in.
|
||||
final class const SessionLoading() extends SessionState {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// Signing in failed, for example without a connection; the app offers to try
|
||||
/// again.
|
||||
final class const SessionFailed() extends SessionState {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// A person is signed in.
|
||||
final class const SessionActive(
|
||||
/// Who is signed in.
|
||||
final SignedIn person,
|
||||
) extends SessionState {
|
||||
@override
|
||||
List<Object?> get props => [person];
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
|
||||
/// The boundary to the server's authentication: who is signed in, and signing
|
||||
/// in and out. Tests replace it with a fake.
|
||||
abstract interface class AuthGateway() {
|
||||
/// Who is signed in right now.
|
||||
AuthStatus get status;
|
||||
|
||||
/// Every later change of [status].
|
||||
Stream<AuthStatus> get statusChanges;
|
||||
|
||||
/// Creates an anonymous account on the server and signs in with it.
|
||||
Future<void> signInAnonymously();
|
||||
|
||||
/// Signs out on this device.
|
||||
Future<void> signOut();
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
|
||||
/// Who is signed in, and signing in and out, as the features use it.
|
||||
class AuthRepository(final AuthGateway _gateway) {
|
||||
/// Who is signed in right now.
|
||||
AuthStatus get status => _gateway.status;
|
||||
|
||||
/// Every later change of [status].
|
||||
Stream<AuthStatus> get statusChanges => _gateway.statusChanges;
|
||||
|
||||
/// Signs in anonymously unless someone is signed in already, so the app
|
||||
/// works from the first launch without any sign-up.
|
||||
Future<void> ensureSignedIn() async {
|
||||
if (_gateway.status is SignedOut) {
|
||||
await _gateway.signInAnonymously();
|
||||
}
|
||||
}
|
||||
|
||||
/// Signs out on this device. An anonymous person can never sign back in, so
|
||||
/// the UI warns before calling this.
|
||||
Future<void> signOut() => _gateway.signOut();
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
|
||||
|
||||
/// [AuthGateway] on top of the Serverpod client and its session manager,
|
||||
/// which keeps the session on the device (secure storage).
|
||||
class ServerpodAuthGateway(final Client _client) implements AuthGateway {
|
||||
this {
|
||||
_client.auth.authInfoListenable.addListener(_onAuthInfoChanged);
|
||||
}
|
||||
|
||||
final _changes = StreamController<AuthStatus>.broadcast();
|
||||
|
||||
/// The scope a person gets once they have an identity (step 6).
|
||||
static const _identifiedScope = 'identified';
|
||||
|
||||
@override
|
||||
AuthStatus get status => switch (_client.auth.authInfo) {
|
||||
null => const SignedOut(),
|
||||
final authInfo => SignedIn(
|
||||
authUserId: authInfo.authUserId.toString(),
|
||||
isIdentified: authInfo.scopeNames.contains(_identifiedScope),
|
||||
),
|
||||
};
|
||||
|
||||
@override
|
||||
Stream<AuthStatus> get statusChanges => _changes.stream;
|
||||
|
||||
@override
|
||||
Future<void> signInAnonymously() async {
|
||||
final authSuccess = await _client.anonymousIdp.login();
|
||||
await _client.auth.updateSignedInUser(authSuccess);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
await _client.auth.signOutDevice();
|
||||
}
|
||||
|
||||
/// Stops listening; the owner calls it when the app shuts down.
|
||||
Future<void> dispose() async {
|
||||
_client.auth.authInfoListenable.removeListener(_onAuthInfoChanged);
|
||||
await _changes.close();
|
||||
}
|
||||
|
||||
void _onAuthInfoChanged() => _changes.add(status);
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Who is signed in on this device.
|
||||
sealed class const AuthStatus() with Equatable;
|
||||
|
||||
/// Nobody is signed in.
|
||||
final class const SignedOut() extends AuthStatus {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// A person is signed in.
|
||||
final class const SignedIn({
|
||||
/// The person, i.e. the server's `AuthUser` id.
|
||||
required final String authUserId,
|
||||
|
||||
/// Whether the person has an identity, such as GitHub. Anonymous people have
|
||||
/// none: their progress lives on this device, and signing out loses it.
|
||||
required final bool isIdentified,
|
||||
}) extends AuthStatus {
|
||||
@override
|
||||
List<Object?> get props => [authUserId, isIdentified];
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_event.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_state.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Opens the account dialog: what the current account means, and signing out.
|
||||
class const AccountButton({super.key}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => IconButton(
|
||||
icon: const Icon(Icons.account_circle_outlined),
|
||||
tooltip: 'Account',
|
||||
onPressed: () {
|
||||
// The dialog's route sits above the SessionBloc's provider, so the bloc
|
||||
// is handed over explicitly.
|
||||
final bloc = context.read<SessionBloc>();
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) =>
|
||||
BlocProvider.value(value: bloc, child: const _AccountDialog()),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class const _AccountDialog() extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = context.watch<SessionBloc>().state;
|
||||
final isIdentified = switch (state) {
|
||||
SessionActive(:final person) => person.isIdentified,
|
||||
_ => false,
|
||||
};
|
||||
return AlertDialog(
|
||||
title: const Text('Account'),
|
||||
content: Text(
|
||||
isIdentified
|
||||
? 'Your progress is kept with your account.'
|
||||
: 'Progress is saved on this device. Sign in to keep it.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
if (isIdentified || await _confirmSignOut(context)) {
|
||||
if (context.mounted) {
|
||||
context.read<SessionBloc>().add(
|
||||
const SessionSignOutRequested(),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Sign out'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks before signing out of an anonymous account, which can't be restored.
|
||||
Future<bool> _confirmSignOut(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Sign out?'),
|
||||
content: const Text(
|
||||
"This account can't be restored after signing out: its progress on "
|
||||
'this device is lost.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Sign out'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return confirmed ?? false;
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_event.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_state.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Shows [child] once a person is signed in, signing in anonymously first if
|
||||
/// nobody is.
|
||||
///
|
||||
/// [child] is built anew for every person (keyed by the person), so nothing of
|
||||
/// one person's session, such as blocs, streams or caches, reaches another's.
|
||||
class const SessionGate({
|
||||
/// What a signed-in person sees.
|
||||
required final Widget child,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => BlocProvider(
|
||||
create: (context) =>
|
||||
SessionBloc(DependenciesScope.of(context).auth)
|
||||
..add(const SessionStarted()),
|
||||
child: BlocBuilder<SessionBloc, SessionState>(
|
||||
builder: (context, state) => switch (state) {
|
||||
SessionLoading() => const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
SessionFailed() => const _SignInFailed(),
|
||||
SessionActive(:final person) => KeyedSubtree(
|
||||
key: ValueKey(person.authUserId),
|
||||
child: child,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class const _SignInFailed() extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text("Couldn't reach the server."),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () =>
|
||||
context.read<SessionBloc>().add(const SessionSignInRequested()),
|
||||
child: const Text('Try again'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import 'dart:convert';
|
|||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/widget/account_button.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A placeholder until the first real content screen (step 7): edits a
|
||||
|
|
@ -29,7 +30,10 @@ class _EditorPlaygroundScreenState() extends State<EditorPlaygroundScreen> {
|
|||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Halkyon Learn')),
|
||||
appBar: AppBar(
|
||||
title: const Text('Halkyon Learn'),
|
||||
actions: const [AccountButton()],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ dependencies:
|
|||
equatable: ^3.0.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_bloc: ^9.1.1
|
||||
halkyon_learn_client:
|
||||
path: ../halkyon_learn_client
|
||||
material_ui: ^1.5.0
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_event.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_state.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
|
||||
import '../../../support/fake_auth_gateway.dart';
|
||||
|
||||
void main() {
|
||||
late FakeAuthGateway gateway;
|
||||
late SessionBloc bloc;
|
||||
|
||||
SessionActive anonymous(int number) => SessionActive(
|
||||
SignedIn(authUserId: 'anonymous-$number', isIdentified: false),
|
||||
);
|
||||
|
||||
void start({AuthStatus status = const SignedOut()}) {
|
||||
gateway = FakeAuthGateway(status: status);
|
||||
bloc = SessionBloc(AuthRepository(gateway));
|
||||
}
|
||||
|
||||
tearDown(() => bloc.close());
|
||||
|
||||
test('signs in anonymously when nobody is signed in', () async {
|
||||
start();
|
||||
|
||||
bloc.add(const SessionStarted());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([const SessionLoading(), anonymous(1)]),
|
||||
);
|
||||
expect(gateway.signIns, 1);
|
||||
});
|
||||
|
||||
test('keeps a stored session instead of creating a second account', () async {
|
||||
const stored = SignedIn(authUserId: 'stored', isIdentified: false);
|
||||
start(status: stored);
|
||||
|
||||
bloc.add(const SessionStarted());
|
||||
|
||||
await expectLater(bloc.stream, emits(const SessionActive(stored)));
|
||||
expect(gateway.signIns, 0);
|
||||
});
|
||||
|
||||
test('reports a failed sign-in and signs in when asked again', () async {
|
||||
start();
|
||||
gateway.signInError = Exception('No connection');
|
||||
|
||||
bloc.add(const SessionStarted());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([const SessionLoading(), const SessionFailed()]),
|
||||
);
|
||||
|
||||
gateway.signInError = null;
|
||||
bloc.add(const SessionSignInRequested());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([const SessionLoading(), anonymous(2)]),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'starts anew with another anonymous account after signing out',
|
||||
() async {
|
||||
const stored = SignedIn(authUserId: 'stored', isIdentified: false);
|
||||
start(status: stored);
|
||||
bloc.add(const SessionStarted());
|
||||
await expectLater(bloc.stream, emits(const SessionActive(stored)));
|
||||
|
||||
bloc.add(const SessionSignOutRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([const SessionLoading(), anonymous(1)]),
|
||||
);
|
||||
expect(gateway.signOuts, 1);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
|
||||
import '../../../support/fake_auth_gateway.dart';
|
||||
|
||||
void main() {
|
||||
group('AuthRepository.ensureSignedIn', () {
|
||||
test('signs in anonymously when nobody is signed in', () async {
|
||||
final gateway = FakeAuthGateway();
|
||||
|
||||
await AuthRepository(gateway).ensureSignedIn();
|
||||
|
||||
expect(gateway.signIns, 1);
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'anonymous-1', isIdentified: false),
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps the person already signed in', () async {
|
||||
const stored = SignedIn(authUserId: 'stored', isIdentified: false);
|
||||
final gateway = FakeAuthGateway(status: stored);
|
||||
|
||||
await AuthRepository(gateway).ensureSignedIn();
|
||||
|
||||
expect(gateway.signIns, 0);
|
||||
expect(gateway.status, stored);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/widget/account_button.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/widget/session_gate.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../../support/fake_auth_gateway.dart';
|
||||
import '../../../support/test_dependencies.dart';
|
||||
|
||||
void main() {
|
||||
late FakeAuthGateway gateway;
|
||||
|
||||
Future<void> showApp(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
DependenciesScope(
|
||||
dependencies: testDependencies(authGateway: gateway),
|
||||
child: MaterialApp(
|
||||
home: SessionGate(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(actions: const [AccountButton()]),
|
||||
body: const Text('Home'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
setUp(() => gateway = FakeAuthGateway());
|
||||
|
||||
testWidgets('shows the app once signed in anonymously', (tester) async {
|
||||
await showApp(tester);
|
||||
|
||||
expect(find.text('Home'), findsOneWidget);
|
||||
expect(gateway.signIns, 1);
|
||||
});
|
||||
|
||||
testWidgets('offers to try again when signing in fails', (tester) async {
|
||||
gateway.signInError = Exception('No connection');
|
||||
await showApp(tester);
|
||||
expect(find.text("Couldn't reach the server."), findsOneWidget);
|
||||
|
||||
gateway.signInError = null;
|
||||
await tester.tap(find.text('Try again'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Home'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('explains that progress lives on this device', (tester) async {
|
||||
await showApp(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Account'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('Progress is saved on this device. Sign in to keep it.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'signing out of an anonymous account asks first; Cancel keeps it',
|
||||
(tester) async {
|
||||
await showApp(tester);
|
||||
await tester.tap(find.byTooltip('Account'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Sign out'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Sign out?'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(gateway.signOuts, 0);
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'anonymous-1', isIdentified: false),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('confirmed sign-out starts anew with another account', (
|
||||
tester,
|
||||
) async {
|
||||
await showApp(tester);
|
||||
await tester.tap(find.byTooltip('Account'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Sign out'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Sign out').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(gateway.signOuts, 1);
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'anonymous-2', isIdentified: false),
|
||||
);
|
||||
expect(find.text('Home'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue