diff --git a/mobile/apps/subturtle/lib/features/me/me_screen.dart b/mobile/apps/subturtle/lib/features/me/me_screen.dart index 2aad7da0..2fa51209 100644 --- a/mobile/apps/subturtle/lib/features/me/me_screen.dart +++ b/mobile/apps/subturtle/lib/features/me/me_screen.dart @@ -473,7 +473,7 @@ class _LanguagePickerState extends State<_LanguagePicker> { class _LanguageGroups extends ConsumerWidget { const _LanguageGroups(); - static const _perDay = [3, 5, 8, 10, 15]; + static const _perDay = [3, 5, 8, 10, 15, 20, 30]; @override Widget build(BuildContext context, WidgetRef ref) { @@ -567,7 +567,8 @@ class _LanguageGroups extends ConsumerWidget { children: [ Text('How many new $name phrases come from the Pool each day. This setting is for $name only.', style: StType.bodyMedium.copyWith(color: ctx.st.textMuted)), const SizedBox(height: 12), - for (final n in _perDay) ...[ + // The current value is always a choice, even one set on the web. + for (final n in {..._perDay, ?current}.toList()..sort()) ...[ StLanguageOption(code: '$n', title: Fmt.plural(n, 'phrase'), selected: n == current, onTap: () => Navigator.pop(ctx, n)), const SizedBox(height: 8), ], diff --git a/mobile/apps/subturtle/test/fake_languages.dart b/mobile/apps/subturtle/test/fake_languages.dart index 33f378c1..574a4805 100644 --- a/mobile/apps/subturtle/test/fake_languages.dart +++ b/mobile/apps/subturtle/test/fake_languages.dart @@ -146,7 +146,8 @@ class FakeLanguageServer { } else { final level = (a['startLevel'] as String?) ?? 'A1'; langs[code] = _Lang(code, level, (a['startLevelSource'] as String?) ?? 'default') - ..pack = _hasPack(code) ? 'offered' : 'none'; + ..pack = _hasPack(code) ? 'offered' : 'none' + ..goals = [...?(a['goals'] as List?)?.cast()]; } pending = {...pending}..remove(code); active = code; @@ -175,6 +176,7 @@ class FakeLanguageServer { final l = langs[a['language']]!; if (a['explanationLanguage'] != null) l.explanationLanguage = a['explanationLanguage'] as String; if (a['newPhrasesPerDay'] != null) l.newPerDay = a['newPhrasesPerDay'] as int; + if (a['goals'] != null) l.goals = (a['goals'] as List).cast(); return _wrapped(); }); on('set-translation-language', (a) { @@ -284,7 +286,8 @@ class _Lang { String status = 'active'; String pack = 'none'; String? explanationLanguage; - int newPerDay = 10; + int newPerDay = 20; + List goals = const []; final heard = {}; int heardToday = 0; @@ -297,6 +300,11 @@ class _Lang { 'phraseCount': phrases, 'explanationLanguage': explanationLanguage ?? l1, 'starterPack': {'state': pack, 'heard': heard.length, 'total': pack == 'none' ? 0 : 4}, + // Per-language settings, as get-language-state returns them (#134). + 'newPhrasesPerDay': newPerDay, + 'poolChunkSize': 10, + 'poolAgeCutoffDays': 14, + 'goals': goals, if (status == 'removed') 'purgeAt': '2026-11-15T00:00:00.000Z', }; } diff --git a/mobile/apps/subturtle/test/languages_test.dart b/mobile/apps/subturtle/test/languages_test.dart index a7d1f520..2a97ef66 100644 --- a/mobile/apps/subturtle/test/languages_test.dart +++ b/mobile/apps/subturtle/test/languages_test.dart @@ -370,6 +370,16 @@ void main() { expect(upd['explanationLanguage'], 'tr'); await tester.scrollUntilVisible(find.text('New phrases a day'), 100); + // The current value from get-language-state (server default 20). + expect(find.descendant(of: find.ancestor(of: find.text('New phrases a day'), matching: find.byType(Row)).first, matching: find.text('20')), findsOneWidget); + await tester.tap(find.text('New phrases a day')); + await tester.settle(); + expect(find.bySemanticsLabel(RegExp('^20 phrases.*active')), findsOneWidget, reason: 'the current value is ticked'); + await tester.tap(find.text('5 phrases')); + await tester.settle(); + expect(langs.langs['en']!.newPerDay, 5); + expect(find.descendant(of: find.ancestor(of: find.text('New phrases a day'), matching: find.byType(Row)).first, matching: find.text('5')), findsOneWidget); + // Again, to keep the original assertions below working. await tester.tap(find.text('New phrases a day')); await tester.settle(); await tester.tap(find.text('5 phrases')); diff --git a/mobile/packages/subturtle_api/lib/src/models/language.dart b/mobile/packages/subturtle_api/lib/src/models/language.dart index 43f482b7..0844ecef 100644 --- a/mobile/packages/subturtle_api/lib/src/models/language.dart +++ b/mobile/packages/subturtle_api/lib/src/models/language.dart @@ -271,6 +271,9 @@ class LanguageSummary { this.starterPack = const StarterPackSummary(), this.purgeAt, this.newPhrasesPerDay, + this.poolChunkSize, + this.poolAgeCutoffDays, + this.goals = const [], }); final String code; @@ -293,9 +296,17 @@ class LanguageSummary { /// When a removed language is deleted for good. final DateTime? purgeAt; - /// New phrases a day from the Pool, when the server sends it. + /// New phrases a day from the Pool (server default 20). Null on servers + /// that do not send it. final int? newPhrasesPerDay; + /// Pool settings of this language. + final int? poolChunkSize; + final int? poolAgeCutoffDays; + + /// Why the learner studies it (`watch`, `work`, `travel`, `curious`). + final List goals; + bool get isActive => status == 'active'; bool get isPaused => status == 'paused'; bool get isRemoved => status == 'removed'; @@ -314,6 +325,9 @@ class LanguageSummary { starterPack: StarterPackSummary.fromJson(j['starterPack']), purgeAt: date(j['purgeAt']), newPhrasesPerDay: intOrNull(j['newPhrasesPerDay'] ?? asJson(j['settings'])['newPhrasesPerDay']), + poolChunkSize: intOrNull(j['poolChunkSize'] ?? asJson(j['settings'])['poolChunkSize']), + poolAgeCutoffDays: intOrNull(j['poolAgeCutoffDays'] ?? asJson(j['settings'])['poolAgeCutoffDays']), + goals: listOf(j['goals'], (e) => str(e)), ); } } diff --git a/mobile/packages/subturtle_api/test/language_test.dart b/mobile/packages/subturtle_api/test/language_test.dart index a65a2d76..d364e34b 100644 --- a/mobile/packages/subturtle_api/test/language_test.dart +++ b/mobile/packages/subturtle_api/test/language_test.dart @@ -9,7 +9,7 @@ Map stateJson({String? active = 'de', String plan = 'reader', i 'learningLanguage': active, 'languages': [ {'code': 'en', 'status': 'active', 'startLevel': 'B1', 'startLevelSource': 'test', 'dueCount': 12, 'phraseCount': 142, 'explanationLanguage': 'fa', 'starterPack': {'state': 'none', 'heard': 0, 'total': 0}}, - {'code': 'de', 'status': 'active', 'startLevel': 'A1', 'startLevelSource': 'default', 'dueCount': 7, 'phraseCount': 38, 'explanationLanguage': 'fa', 'starterPack': {'state': 'in_progress', 'heard': 12, 'total': 150}}, + {'code': 'de', 'status': 'active', 'startLevel': 'A1', 'startLevelSource': 'default', 'dueCount': 7, 'phraseCount': 38, 'explanationLanguage': 'fa', 'starterPack': {'state': 'in_progress', 'heard': 12, 'total': 150}, 'newPhrasesPerDay': 20, 'poolChunkSize': 10, 'poolAgeCutoffDays': 14, 'goals': ['watch']}, {'code': 'it', 'status': 'paused', 'startLevel': 'A1', 'startLevelSource': 'default', 'dueCount': 0, 'phraseCount': 3, 'explanationLanguage': 'fa', 'starterPack': {'state': 'none', 'heard': 0, 'total': 0}}, {'code': 'fr', 'status': 'removed', 'startLevel': 'A1', 'startLevelSource': 'default', 'dueCount': 0, 'phraseCount': 5, 'explanationLanguage': 'fa', 'purgeAt': '2026-11-15T00:00:00.000Z', 'starterPack': {'state': 'none', 'heard': 0, 'total': 0}}, {'code': 'sv', 'status': 'purging', 'startLevel': 'A1', 'startLevelSource': 'default', 'dueCount': 0, 'phraseCount': 0}, @@ -36,6 +36,9 @@ void main() { expect(s.paused.single.code, 'it'); expect(s.removed.single.purgeAt, isNotNull); expect(s.summaryOf('de')!.starterPack.heard, 12); + final de = s.summaryOf('de')!; + expect([de.newPhrasesPerDay, de.poolChunkSize, de.poolAgeCutoffDays], [20, 10, 14]); + expect(de.goals, ['watch']); expect(s.summaryOf('en')!.fromTest, isTrue); expect(s.used, 2); expect(s.atLimit, isFalse); diff --git a/mobile/packages/subturtle_api/test/live_server_test.dart b/mobile/packages/subturtle_api/test/live_server_test.dart index 75b1e6b9..c9199e55 100644 --- a/mobile/packages/subturtle_api/test/live_server_test.dart +++ b/mobile/packages/subturtle_api/test/live_server_test.dart @@ -62,6 +62,11 @@ void main() { s = await api.languages.updateSettings('de', explanationLanguage: 'fa'); expect(s.summaryOf('de')!.explanationLanguage, 'fa'); + // Per-language settings come back with the state (server #134). + expect(s.summaryOf('de')!.newPhrasesPerDay, isNotNull); + s = await api.languages.updateSettings('de', newPhrasesPerDay: 8); + expect(s.summaryOf('de')!.newPhrasesPerDay, 8); + expect(s.summaryOf('de')!.goals, ['watch']); expect(await api.languages.setInterest('pt', on: true), isTrue);