feat(learn): one assistant field across the app, kept from tab to tab (ADR 0021, D69)
Each screen's button kept its own open state and its own draft, so the question half written on the Library was gone on Home. ComposerCubit, provided beside ConversationDockCubit, now holds whether the field is open and the draft (text, files attached, parts chosen) for every button. Switching tabs keeps it as it is (AppShell says so before goBranch); going to another screen within a tab, or over the tabs, keeps it open only while something is written or attached. Only the button of the screen at the router's location unfolds, so no tab kept alive behind holds a second field. Whats-New: The question you are writing stays open as you switch tabs. Whats-New-De: Die Frage, die du schreibst, bleibt offen, wenn du den Tab wechselst. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
b4b27a1d0c
commit
f45ed98d52
6 changed files with 314 additions and 25 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
|
@ -12,6 +14,7 @@ import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/l10n/app_languages.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/theme/learn_theme.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/bloc/composer_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/bloc/conversation_dock_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/settings_own_assistants.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/feedback/bloc/inbox_count_bloc.dart';
|
||||
|
|
@ -48,9 +51,28 @@ class _PersonAppState() extends State<PersonApp> {
|
|||
initialLocation: widget.initialLocation,
|
||||
);
|
||||
|
||||
/// The field asking an assistant, one across every screen (ADR 0021,
|
||||
/// D69), told where the router goes.
|
||||
final _composer = ComposerCubit();
|
||||
|
||||
void _located() {
|
||||
final matches = _router.routerDelegate.currentConfiguration;
|
||||
if (matches.isNotEmpty) {
|
||||
_composer.located(matches.uri.path);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_router.routerDelegate.addListener(_located);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_router.routerDelegate.removeListener(_located);
|
||||
_router.dispose();
|
||||
unawaited(_composer.close());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -119,6 +141,8 @@ class _PersonAppState() extends State<PersonApp> {
|
|||
// A conversation folded away into the assistant's
|
||||
// button, for every button to open again.
|
||||
BlocProvider(create: (context) => ConversationDockCubit()),
|
||||
// The field asking it, the same on every screen (D69).
|
||||
BlocProvider.value(value: _composer),
|
||||
// The Pomodoro timer goes on across every screen.
|
||||
BlocProvider(
|
||||
create: (context) => PomodoroBloc(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/app/learn_frame.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/bloc/composer_cubit.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -50,10 +52,17 @@ class const AppShell({
|
|||
// keeps reading on light and dark rails alike.
|
||||
icon: learnIcon,
|
||||
selectedIndex: navigationShell.currentIndex,
|
||||
onSelected: (index) => navigationShell.goBranch(
|
||||
index,
|
||||
initialLocation: index == navigationShell.currentIndex,
|
||||
),
|
||||
onSelected: (index) {
|
||||
// Another tab keeps the assistant's field as it is (D69); the
|
||||
// same tab again goes back to its first screen, as navigating.
|
||||
if (index != navigationShell.currentIndex) {
|
||||
context.read<ComposerCubit>().switchingTab();
|
||||
}
|
||||
navigationShell.goBranch(
|
||||
index,
|
||||
initialLocation: index == navigationShell.currentIndex,
|
||||
);
|
||||
},
|
||||
hidesNavigationBar: hidesNavigationBar,
|
||||
destinations: [
|
||||
for (final destination in destinations)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
|
||||
|
||||
/// The field asking an assistant, one for every screen of the person's app
|
||||
/// (ADR 0021, D69): whether it is unfolded, and the question being
|
||||
/// written, so that it stays as it is from one tab to another.
|
||||
final class const ComposerState({
|
||||
/// Whether the field is unfolded from its button.
|
||||
final bool open = false,
|
||||
|
||||
/// The question written so far.
|
||||
final String text = '',
|
||||
|
||||
/// The files attached, in the order attached (D67).
|
||||
final List<AssistantPart> attached = const [],
|
||||
|
||||
/// The parts chosen to go along, of whichever topic they are; a field
|
||||
/// sends only those its own topic offers.
|
||||
final Set<AssistantPart> chosen = const {},
|
||||
|
||||
/// The router's location, for the button of the screen showing it to be
|
||||
/// the one unfolded; null until known.
|
||||
final String? location,
|
||||
}) with Equatable {
|
||||
/// Whether anything was written or attached, which navigating keeps.
|
||||
bool get hasDraft => text.trim().isNotEmpty || attached.isNotEmpty;
|
||||
|
||||
ComposerState copyWith({
|
||||
bool? open,
|
||||
String? text,
|
||||
List<AssistantPart>? attached,
|
||||
Set<AssistantPart>? chosen,
|
||||
String? location,
|
||||
}) => ComposerState(
|
||||
open: open ?? this.open,
|
||||
text: text ?? this.text,
|
||||
attached: attached ?? this.attached,
|
||||
chosen: chosen ?? this.chosen,
|
||||
location: location ?? this.location,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [open, text, attached, chosen, location];
|
||||
}
|
||||
|
||||
/// The field's state across the person's app (D69, the developer's review
|
||||
/// of 4 Nov 2026): switching tabs keeps it as it is, open on the Library
|
||||
/// and open on Home; going to another screen within a tab, or to one over
|
||||
/// the tabs, keeps it open only while something is written or attached,
|
||||
/// and folds it otherwise. Above the router, as `ConversationDockCubit`.
|
||||
class ComposerCubit() extends Cubit<ComposerState> {
|
||||
this : super(const ComposerState());
|
||||
|
||||
/// Whether the next change of location is a switch of tabs.
|
||||
var _switchingTab = false;
|
||||
|
||||
/// Unfolds the field.
|
||||
void unfold() => emit(state.copyWith(open: true));
|
||||
|
||||
/// Folds the field, keeping what was written.
|
||||
void fold() => emit(state.copyWith(open: false));
|
||||
|
||||
/// [text] is written.
|
||||
void write(String text) {
|
||||
if (text != state.text) {
|
||||
emit(state.copyWith(text: text));
|
||||
}
|
||||
}
|
||||
|
||||
/// [file] is attached.
|
||||
void attach(AssistantPart file) =>
|
||||
emit(state.copyWith(attached: [...state.attached, file]));
|
||||
|
||||
/// [file] is taken off again.
|
||||
void detach(AssistantPart file) => emit(
|
||||
state.copyWith(attached: [...state.attached]..remove(file)),
|
||||
);
|
||||
|
||||
/// [part] is chosen to go along, or no more.
|
||||
void toggle(AssistantPart part) => emit(
|
||||
state.copyWith(
|
||||
chosen: state.chosen.contains(part)
|
||||
? ({...state.chosen}..remove(part))
|
||||
: {...state.chosen, part},
|
||||
),
|
||||
);
|
||||
|
||||
/// Sent: the field folds, empty again.
|
||||
void sent() => emit(ComposerState(location: state.location));
|
||||
|
||||
/// The person picks another tab, which keeps the field as it is.
|
||||
void switchingTab() => _switchingTab = true;
|
||||
|
||||
/// The router went to [location]: from one tab to another the field
|
||||
/// stays as it is; anywhere else it folds, unless something is written
|
||||
/// or attached.
|
||||
void located(String location) {
|
||||
if (location == state.location) {
|
||||
return;
|
||||
}
|
||||
final keep = _switchingTab || state.location == null || state.hasDraft;
|
||||
_switchingTab = false;
|
||||
emit(state.copyWith(location: location, open: keep ? null : false));
|
||||
}
|
||||
}
|
||||
|
|
@ -2,12 +2,14 @@ import 'dart:async';
|
|||
|
||||
import 'package:flutter/foundation.dart' show setEquals;
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/widget/stored_file_image.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/bloc/composer_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/bloc/conversation_dock_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
|
||||
|
|
@ -122,13 +124,35 @@ class const AssistantField({
|
|||
}
|
||||
|
||||
class _AssistantFieldState() extends State<AssistantField> {
|
||||
final _question = TextEditingController();
|
||||
/// The question, the parts chosen and the files attached, one draft for
|
||||
/// every screen (D69).
|
||||
late final ComposerCubit _composer = context.read<ComposerCubit>();
|
||||
|
||||
late final TextEditingController _question;
|
||||
|
||||
/// The text this field last wrote into the draft.
|
||||
late String _written;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_written = _composer.state.text;
|
||||
_question = TextEditingController(text: _written)
|
||||
..addListener(() {
|
||||
// Only what is written goes into the draft: a field losing the
|
||||
// focus moves its cursor, not its text.
|
||||
if (_question.text != _written) {
|
||||
_written = _question.text;
|
||||
_composer.write(_written);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The parts chosen to go along.
|
||||
final _chosen = <AssistantPart>{};
|
||||
Set<AssistantPart> get _chosen => _composer.state.chosen;
|
||||
|
||||
/// The files attached, in the order attached (D67).
|
||||
final _attached = <AssistantPart>[];
|
||||
List<AssistantPart> get _attached => _composer.state.attached;
|
||||
|
||||
/// Who answers, as picked in the field's menu; null for the one that
|
||||
/// answered last.
|
||||
|
|
@ -217,9 +241,8 @@ class _AssistantFieldState() extends State<AssistantField> {
|
|||
|
||||
void _clear() {
|
||||
_question.clear();
|
||||
_composer.sent();
|
||||
setState(() {
|
||||
_chosen.clear();
|
||||
_attached.clear();
|
||||
_reach = AssistantReach.ticked;
|
||||
_includePrivate = false;
|
||||
});
|
||||
|
|
@ -285,9 +308,7 @@ class _AssistantFieldState() extends State<AssistantField> {
|
|||
chosen: _chosen,
|
||||
onReach: (reach) => setState(() => _reach = reach),
|
||||
onIncludePrivate: (on) => setState(() => _includePrivate = on),
|
||||
onToggle: (part) => setState(
|
||||
() => _chosen.contains(part) ? _chosen.remove(part) : _chosen.add(part),
|
||||
),
|
||||
onToggle: _composer.toggle,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -295,6 +316,10 @@ class _AssistantFieldState() extends State<AssistantField> {
|
|||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
// What is chosen and attached, as the draft holds it.
|
||||
context.select<ComposerCubit, (Set<AssistantPart>, List<AssistantPart>)>(
|
||||
(composer) => (composer.state.chosen, composer.state.attached),
|
||||
);
|
||||
final answerers = answerersOf(context);
|
||||
final answerer = currentAnswerer(
|
||||
answerers,
|
||||
|
|
@ -355,7 +380,7 @@ class _AssistantFieldState() extends State<AssistantField> {
|
|||
AssistantAnswerer(:final choice) => AttachButton(
|
||||
images: choice.images,
|
||||
assistant: choice.name,
|
||||
onAttached: (part) => setState(() => _attached.add(part)),
|
||||
onAttached: _composer.attach,
|
||||
rows: [contextRow],
|
||||
),
|
||||
// A search takes no files, only the parts chosen.
|
||||
|
|
@ -467,7 +492,7 @@ class _AssistantFieldState() extends State<AssistantField> {
|
|||
if (attached.isNotEmpty)
|
||||
AttachedFiles(
|
||||
files: attached,
|
||||
onRemove: (file) => setState(() => _attached.remove(file)),
|
||||
onRemove: _composer.detach,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 8, 0),
|
||||
|
|
@ -911,20 +936,48 @@ class const AssistantButton({
|
|||
}
|
||||
|
||||
class _AssistantButtonState() extends State<AssistantButton> {
|
||||
var _expanded = false;
|
||||
/// Whether [AssistantButton.onUnfolded] was last told it unfolded.
|
||||
var _toldUnfolded = false;
|
||||
|
||||
void _fold() => _unfold(false);
|
||||
void _fold() => context.read<ComposerCubit>().fold();
|
||||
|
||||
void _unfold(bool expanded) {
|
||||
setState(() => _expanded = expanded);
|
||||
widget.onUnfolded?.call(expanded);
|
||||
void _unfold() => context.read<ComposerCubit>().unfold();
|
||||
|
||||
/// Whether this button's screen is the one showing, so that it is the
|
||||
/// one unfolded while the field is open (D69): its route's location the
|
||||
/// router's, else, before the router said where it is, its tab showing.
|
||||
bool _showing(String? location) {
|
||||
if (location == null) {
|
||||
return TabVisibility.of(context);
|
||||
}
|
||||
return GoRouterState.of(context).matchedLocation == location;
|
||||
}
|
||||
|
||||
/// Tells [AssistantButton.onUnfolded] once [unfolded] changed, after the
|
||||
/// frame, as it may change with the router or another screen's button.
|
||||
void _tell(bool unfolded) {
|
||||
if (unfolded == _toldUnfolded) {
|
||||
return;
|
||||
}
|
||||
_toldUnfolded = unfolded;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
widget.onUnfolded?.call(unfolded);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final beside = widget.beside;
|
||||
final end = widget.end;
|
||||
if (!assistantOn(context)) {
|
||||
final (open, location) = context.select<ComposerCubit, (bool, String?)>(
|
||||
(composer) => (composer.state.open, composer.state.location),
|
||||
);
|
||||
final on = assistantOn(context);
|
||||
final expanded = on && open && _showing(location);
|
||||
_tell(expanded);
|
||||
if (!on) {
|
||||
return beside ?? end ?? const SizedBox.shrink();
|
||||
}
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
|
@ -950,7 +1003,7 @@ class _AssistantButtonState() extends State<AssistantButton> {
|
|||
final width =
|
||||
widget.expandedWidth ??
|
||||
(constraints.maxWidth.isFinite ? constraints.maxWidth - 32 : 360);
|
||||
return _expanded
|
||||
return expanded
|
||||
? SizedBox(
|
||||
width: width,
|
||||
child: AssistantField(
|
||||
|
|
@ -1003,7 +1056,7 @@ class _AssistantButtonState() extends State<AssistantButton> {
|
|||
child: InkWell(
|
||||
onTap: docked
|
||||
? () => unawaited(reopenConversation(context))
|
||||
: () => _unfold(true),
|
||||
: _unfold,
|
||||
child: SizedBox.square(
|
||||
dimension: size - 2 * _ringWidth,
|
||||
child: Icon(
|
||||
|
|
@ -1029,7 +1082,7 @@ class _AssistantButtonState() extends State<AssistantButton> {
|
|||
// Unfolded, the field alone, as wide as the screen less its margins
|
||||
// (the developer's review of 3 Nov 2026: it fell short on the
|
||||
// right); folded, the two buttons at the screen's margins.
|
||||
return _expanded
|
||||
return expanded
|
||||
? button
|
||||
: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
|
|
@ -1039,7 +1092,7 @@ class _AssistantButtonState() extends State<AssistantButton> {
|
|||
),
|
||||
);
|
||||
}
|
||||
if (beside == null || _expanded) {
|
||||
if (beside == null || expanded) {
|
||||
return button;
|
||||
}
|
||||
return Padding(
|
||||
|
|
|
|||
|
|
@ -683,4 +683,90 @@ void main() {
|
|||
);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
group('one field across the app (D69)', () {
|
||||
/// The field's text, where one shows.
|
||||
String? written(WidgetTester tester) =>
|
||||
find
|
||||
.descendant(
|
||||
of: find.byType(AssistantField),
|
||||
matching: find.byType(TextField),
|
||||
)
|
||||
.evaluate()
|
||||
.isEmpty
|
||||
? null
|
||||
: tester
|
||||
.widget<TextField>(
|
||||
find.descendant(
|
||||
of: find.byType(AssistantField),
|
||||
matching: find.byType(TextField),
|
||||
),
|
||||
)
|
||||
.controller!
|
||||
.text;
|
||||
|
||||
Future<void> tab(WidgetTester tester, String label) async {
|
||||
await tester.tap(find.text(label).last);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('switching tabs keeps it as it is: open with its question '
|
||||
'on the Library, open with it on Home; folded, folded', (
|
||||
tester,
|
||||
) async {
|
||||
await showApp(tester, location: RoutePaths.library);
|
||||
await unfold(tester);
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: find.byType(AssistantField),
|
||||
matching: find.byType(TextField),
|
||||
),
|
||||
'kanji',
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tab(tester, 'Home');
|
||||
expect(written(tester), 'kanji');
|
||||
await tester.tap(find.byTooltip('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(AssistantField), findsNothing);
|
||||
|
||||
await tab(tester, 'Library');
|
||||
expect(find.byType(AssistantField), findsNothing, reason: 'folded');
|
||||
await unfold(tester);
|
||||
expect(written(tester), 'kanji', reason: 'folding keeps the question');
|
||||
});
|
||||
|
||||
testWidgets('going to another screen in a tab keeps it open only while '
|
||||
'something is written', (tester) async {
|
||||
await showApp(tester, location: RoutePaths.library);
|
||||
await unfold(tester);
|
||||
await tester.tap(find.text('Japanese'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(AssistantField), findsNothing, reason: 'empty');
|
||||
|
||||
await unfold(tester);
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: find.byType(AssistantField),
|
||||
matching: find.byType(TextField),
|
||||
),
|
||||
'kanji',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle();
|
||||
expect(written(tester), 'kanji', reason: 'on the Library again');
|
||||
expect(find.byType(AssistantField), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('once sent, it folds empty everywhere', (tester) async {
|
||||
await showApp(tester, location: RoutePaths.library);
|
||||
await send(tester, 'kanji');
|
||||
await tab(tester, 'Home');
|
||||
expect(find.byType(AssistantField), findsNothing);
|
||||
await unfold(tester);
|
||||
expect(written(tester), '');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue