feat(profile): the profile saves as it is written, the handle chosen in a dialog that checks it
Changing the profile meant a Save button at the foot of the form, which the floating navigation bar half covered on a phone. A field now saves when it is left or Enter is pressed in it, and a link when it is removed; "Saving…" and "Saved" show beside the title. The form keeps what is being written as saves come back, where it used to start afresh and lose focus after each one. The handle is chosen in a dialog that asks the instance while it is typed whether it is free (`ProfileEndpoint.checkHandle`, which tells nothing `byHandle` doesn't), and choosing the first makes the profile with what was written. Saves go one at a time, the latest of those waiting next, and a handle chosen holds for every save after it, so a field's save can't take it back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
b46dfe22b1
commit
959014b4a1
1 changed files with 1062 additions and 152 deletions
|
|
@ -1,8 +1,11 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/chevron_field.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/bloc/public_profile_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -44,43 +47,124 @@ void main() {
|
|||
|
||||
Finder field(String label) => find.widgetWithText(TextField, label);
|
||||
|
||||
testWidgets('a person with an identity writes a public profile and saves '
|
||||
'it, then sees the address others find them by', (tester) async {
|
||||
final profiles = FakeProfileGateway();
|
||||
/// The dialog's field for the handle.
|
||||
Finder handleInDialog() => find.descendant(
|
||||
of: find.byType(AlertDialog),
|
||||
matching: find.byType(TextField),
|
||||
);
|
||||
|
||||
/// Types [handle] into the handle's dialog and waits for the instance to
|
||||
/// say whether it is free.
|
||||
Future<void> typeHandle(WidgetTester tester, String handle) async {
|
||||
await tester.enterText(handleInDialog(), handle);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('a person with an identity writes first, then chooses a '
|
||||
'handle in a dialog saying whether it is free; that makes the profile '
|
||||
'with what was written', (tester) async {
|
||||
final profiles = FakeProfileGateway(taken: {'Tizian'});
|
||||
await showProfile(tester, profiles);
|
||||
|
||||
expect(find.text('Public profile'), findsOneWidget);
|
||||
expect(find.text('Save'), findsNothing);
|
||||
await tester.enterText(field('Display name'), 'Tizian');
|
||||
await tester.enterText(field('Handle'), 'raisondetredev');
|
||||
await tester.enterText(field('Bio'), 'Learning Japanese.');
|
||||
await tester.tap(find.text('Add link'));
|
||||
await tester.tap(find.text('Display name'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'https://…'),
|
||||
'https://halkyon.app',
|
||||
// Nothing to save without a handle.
|
||||
expect(profiles.saves, isEmpty);
|
||||
|
||||
await tester.tap(find.text('Choose a handle'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Choose your handle'), findsOneWidget);
|
||||
await typeHandle(tester, 'tizian');
|
||||
expect(find.text('Someone else has this handle.'), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Choose'))
|
||||
.onPressed,
|
||||
isNull,
|
||||
);
|
||||
await tester.tap(find.text('Save'));
|
||||
await typeHandle(tester, 'raisondetredev');
|
||||
expect(find.text('@raisondetredev is free'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Choose'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Profile saved.'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Others find you as @raisondetredev@halkyon.eu'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(profiles.profile!.links, ['https://halkyon.app']);
|
||||
expect(find.text('@raisondetredev'), findsOneWidget);
|
||||
expect(profiles.profile!.displayName, 'Tizian');
|
||||
expect(profiles.profile!.bio, 'Learning Japanese.');
|
||||
expect(find.text('Saved'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a handle someone else has is refused, said under it', (
|
||||
testWidgets('a profile saves a field when it is left or Enter is pressed, '
|
||||
'only what changed, without losing what is being written', (
|
||||
tester,
|
||||
) async {
|
||||
await showProfile(tester, FakeProfileGateway(taken: {'Tizian'}));
|
||||
final profiles = FakeProfileGateway(
|
||||
profile: const PublicProfile(
|
||||
handle: 'raisondetredev',
|
||||
address: 'raisondetredev@halkyon.eu',
|
||||
displayName: 'Tizian',
|
||||
),
|
||||
);
|
||||
await showProfile(tester, profiles);
|
||||
|
||||
await tester.enterText(field('Handle'), 'tizian');
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.enterText(field('Display name'), 'Tizian K.');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
expect(profiles.saves.single.displayName, 'Tizian K.');
|
||||
|
||||
await tester.enterText(field('Bio'), 'Learning Japanese.');
|
||||
// Into the next field: the bio is left, and saves.
|
||||
await tester.tap(find.text('Add link'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(profiles.saves.last.bio, 'Learning Japanese.');
|
||||
expect(profiles.saves, hasLength(2));
|
||||
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'https://…'),
|
||||
'https://halkyon.app',
|
||||
);
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
expect(profiles.profile!.links, ['https://halkyon.app']);
|
||||
// What was written stays as it was written.
|
||||
expect(find.text('Tizian K.'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Remove link'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(profiles.profile!.links, isEmpty);
|
||||
expect(profiles.saves, hasLength(4));
|
||||
});
|
||||
|
||||
testWidgets('the handle changes through the dialog, which says one is '
|
||||
'yours', (tester) async {
|
||||
final profiles = FakeProfileGateway(
|
||||
profile: const PublicProfile(
|
||||
handle: 'raisondetredev',
|
||||
address: 'raisondetredev@halkyon.eu',
|
||||
displayName: 'Tizian',
|
||||
),
|
||||
);
|
||||
await showProfile(tester, profiles);
|
||||
|
||||
await tester.tap(find.text('@raisondetredev'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Change your handle'), findsOneWidget);
|
||||
expect(find.text('This is your handle'), findsOneWidget);
|
||||
await typeHandle(tester, 'tizian_k');
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Change'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Someone else has this handle.'), findsOneWidget);
|
||||
expect(profiles.profile!.handle, 'tizian_k');
|
||||
expect(profiles.profile!.displayName, 'Tizian');
|
||||
expect(find.text('@tizian_k'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('once its person has published, the handle is fixed and the '
|
||||
|
|
@ -101,8 +185,8 @@ void main() {
|
|||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester.widget<TextField>(field('Handle')).readOnly,
|
||||
isTrue,
|
||||
tester.widget<ChevronField>(find.byType(ChevronField)).onTap,
|
||||
isNull,
|
||||
);
|
||||
expect(find.text('Remove profile'), findsNothing);
|
||||
});
|
||||
|
|
@ -145,7 +229,7 @@ void main() {
|
|||
|
||||
expect(profiles.avatars.single, [1, 2, 3]);
|
||||
expect(files.asked.single, contains('jpg'));
|
||||
expect(find.text('Profile saved.'), findsOneWidget);
|
||||
expect(find.text('Saved'), findsOneWidget);
|
||||
await tester.tap(find.text('Remove picture'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(profiles.profile!.avatarUrl, isNull);
|
||||
|
|
@ -157,7 +241,7 @@ void main() {
|
|||
) async {
|
||||
await showProfile(tester, FakeProfileGateway());
|
||||
|
||||
expect(find.text('Save your profile to add a picture.'), findsOneWidget);
|
||||
expect(find.text('Choose your handle to add a picture.'), findsOneWidget);
|
||||
expect(find.text('Choose picture'), findsNothing);
|
||||
});
|
||||
|
||||
|
|
@ -184,7 +268,48 @@ void main() {
|
|||
profiles.fail = null;
|
||||
await tester.tap(find.text('Retry'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(field('Handle'), findsOneWidget);
|
||||
expect(find.byType(ChevronField), findsOneWidget);
|
||||
});
|
||||
|
||||
test('a save asked for while another is on its way follows it, the '
|
||||
'latest of those waiting; a handle chosen holds for the saves after '
|
||||
'it', () async {
|
||||
final gateway = _HeldProfileGateway(
|
||||
profile: const PublicProfile(
|
||||
handle: 'raisondetredev',
|
||||
address: 'raisondetredev@halkyon.eu',
|
||||
),
|
||||
);
|
||||
final bloc = PublicProfileBloc(ProfileRepository(gateway))
|
||||
..add(const PublicProfileEventStarted());
|
||||
addTearDown(bloc.close);
|
||||
await bloc.stream.firstWhere((state) => state is PublicProfileStateReady);
|
||||
|
||||
PublicProfileEventSaved saved(String name, {String? handle}) =>
|
||||
PublicProfileEventSaved(
|
||||
displayName: name,
|
||||
bio: '',
|
||||
links: const [],
|
||||
handle: handle,
|
||||
);
|
||||
bloc
|
||||
..add(saved('A'))
|
||||
..add(saved('A', handle: 'tizian'))
|
||||
..add(saved('B'));
|
||||
await pumpEventQueue();
|
||||
gateway.release();
|
||||
await pumpEventQueue();
|
||||
gateway.release();
|
||||
await pumpEventQueue();
|
||||
|
||||
// The second gave way to the third, which kept its handle.
|
||||
expect(
|
||||
[for (final s in gateway.saves) (s.handle, s.displayName)],
|
||||
[
|
||||
('raisondetredev', 'A'),
|
||||
('tizian', 'B'),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
|
|
@ -203,3 +328,29 @@ void main() {
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Saves only once [release] lets each through.
|
||||
class _HeldProfileGateway({super.profile}) extends FakeProfileGateway {
|
||||
final _held = <Completer<void>>[];
|
||||
|
||||
/// Lets the save waiting longest through.
|
||||
void release() => _held.removeAt(0).complete();
|
||||
|
||||
@override
|
||||
Future<PublicProfile> save({
|
||||
required String handle,
|
||||
required String displayName,
|
||||
required String bio,
|
||||
required List<String> links,
|
||||
}) async {
|
||||
final held = Completer<void>();
|
||||
_held.add(held);
|
||||
await held.future;
|
||||
return await super.save(
|
||||
handle: handle,
|
||||
displayName: displayName,
|
||||
bio: bio,
|
||||
links: links,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue