Skip to content

Commit 57aaea9

Browse files
committed
Further improvement of custom lessons feature
1 parent 6c0fc62 commit 57aaea9

File tree

4 files changed

+302
-71
lines changed

4 files changed

+302
-71
lines changed

lib/screens/tutor_page.dart

Lines changed: 187 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'package:asante_typing/models/units.dart';
55
import 'package:asante_typing/services/custom_lessons_service.dart';
66
import 'package:asante_typing/state/zoom_scope.dart';
77
import 'package:asante_typing/theme/app_colors.dart';
8+
import 'package:asante_typing/utils/csv_two_col.dart';
89
import 'package:asante_typing/utils/pick_text.dart' as picktext;
910
import 'package:asante_typing/utils/typing_utils.dart';
1011
import 'package:asante_typing/widgets/custom_lessons_panel.dart';
@@ -338,6 +339,7 @@ class _TutorPageState extends State<TutorPage> {
338339
// Subunit chips or custom lessons panel
339340
if (selectedLesson.title == 'Custom lessons')
340341
CustomLessonsPanel(
342+
onResetAll: _confirmResetAll,
341343
lessons: _customLessons,
342344
selectedTitle: _selectedSubunit,
343345
accent: accent,
@@ -597,10 +599,12 @@ class _TutorPageState extends State<TutorPage> {
597599
builder: (ctx) {
598600
return StatefulBuilder(
599601
builder: (dialogCtx, setStateDialog) {
602+
final size = MediaQuery.of(ctx).size;
600603
return AlertDialog(
601604
title: Text(isEdit ? 'Edit lesson' : 'Add lesson'),
602605
content: SizedBox(
603-
width: 400,
606+
width: size.width * 0.80,
607+
height: size.height * 0.80,
604608
child: SingleChildScrollView(
605609
child: Column(
606610
mainAxisSize: MainAxisSize.min,
@@ -723,75 +727,206 @@ class _TutorPageState extends State<TutorPage> {
723727
});
724728
}
725729

730+
void _confirmResetAll() {
731+
if (_customLessons.isEmpty) return;
732+
733+
showDialog<bool>(
734+
context: context,
735+
builder: (ctx) {
736+
return AlertDialog(
737+
title: const Text('Delete ALL custom lessons?'),
738+
content: Text(
739+
'This will permanently remove ${_customLessons.length} custom '
740+
'lesson(s) and leave the list empty. Are you sure?'
741+
),
742+
actions: [
743+
TextButton(
744+
onPressed: () => Navigator.of(ctx).pop(false),
745+
child: const Text('Cancel'),
746+
),
747+
TextButton(
748+
onPressed: () => Navigator.of(ctx).pop(true),
749+
style: TextButton.styleFrom(foregroundColor: Colors.red),
750+
child: const Text('Delete all'),
751+
),
752+
],
753+
);
754+
},
755+
).then((yes) {
756+
if (yes != true) return;
757+
setState(() {
758+
_customLessons.clear();
759+
_updateCustomUnit();
760+
_saveCustomLessons();
761+
762+
_selectedSubunit = null;
763+
if (_customUnitIndex != null) {
764+
_lastSubunitPerUnit.remove(_customUnitIndex);
765+
}
766+
767+
// reset typing session
768+
_controller.clear();
769+
_startTime = null;
770+
_ticker?.cancel();
771+
_ticker = null;
772+
_errors = 0;
773+
_finished = false;
774+
_sessionCompleted = false;
775+
});
776+
});
777+
}
778+
726779
/// Shows a dialog allowing the user to paste or upload multiple passages
727780
/// separated by newlines. Each line will become a new lesson. Default
728781
/// titles will be generated as "Paragraph 01", "Paragraph 02", etc.
729782
void _showBulkUploadDialog() {
730783
final textController = TextEditingController();
784+
List<MapEntry<String, String>>? csvRows; // null => not a valid 2-col CSV
731785

732-
Future<void> pickFile() async {
733-
final result = await picktext.pickTextFile();
734-
if (result != null) {
735-
textController.text = result;
736-
}
737-
}
738-
739-
740-
741-
showDialog<bool>(
786+
showDialog<List<Map<String, String>>?>(
742787
context: context,
743788
barrierDismissible: false,
744789
builder: (ctx) {
790+
final size = MediaQuery.of(ctx).size;
791+
745792
return StatefulBuilder(
746793
builder: (dialogCtx, setStateDialog) {
794+
// Local helper so we can re-parse on text change or file load
795+
void reparse() {
796+
csvRows = TwoColCsv.tryParse(textController.text);
797+
setStateDialog(() {}); // refresh banner
798+
}
799+
800+
Future<void> pickFile() async {
801+
final result = await picktext.pickTextFile();
802+
if (result != null) {
803+
textController.text = result; // programmatic update
804+
reparse(); // ensure detection runs
805+
}
806+
}
807+
747808
return AlertDialog(
809+
// NEW: occupy ~80% of the viewport
810+
insetPadding: EdgeInsets.symmetric(
811+
horizontal: size.width * 0.10,
812+
vertical: size.height * 0.10,
813+
),
748814
title: const Text('Bulk upload lessons'),
749815
content: SizedBox(
750-
width: 400,
751-
child: SingleChildScrollView(
752-
child: Column(
753-
mainAxisSize: MainAxisSize.min,
754-
children: [
755-
TextField(
816+
width: size.width * 0.80,
817+
height: size.height * 0.80,
818+
child: Column(
819+
children: [
820+
// Banner: green when CSV detected, amber tip otherwise
821+
Container(
822+
width: double.infinity,
823+
margin: const EdgeInsets.only(bottom: 8),
824+
padding: const EdgeInsets.all(10),
825+
decoration: BoxDecoration(
826+
color: (csvRows != null)
827+
? Colors.green.withValues(alpha: 0.12)
828+
: Colors.amber.withValues(alpha: 0.10),
829+
borderRadius: BorderRadius.circular(6),
830+
border: Border.all(
831+
color: (csvRows != null)
832+
? Colors.green.withValues(alpha: 0.40)
833+
: Colors.amber.withValues(alpha: 0.35),
834+
),
835+
),
836+
child: Text(
837+
(csvRows != null)
838+
? 'Detected a two-column CSV: "Title","Passage". Each row will be imported as a lesson with its title.'
839+
: 'Tip: Paste or upload text. You can also use a two-column CSV for "Title","Passage". Each line becomes its own lesson. Avoid newlines inside cells.',
840+
style: TextStyle(
841+
color: (csvRows != null) ? Colors.green : Colors.black87,
842+
fontWeight: (csvRows != null) ? FontWeight.w600 : FontWeight.normal,
843+
),
844+
),
845+
),
846+
847+
// Big editor that grows with dialog
848+
Expanded(
849+
child: TextField(
756850
controller: textController,
851+
onChanged: (_) => reparse(), // detect CSV on paste/type
757852
decoration: const InputDecoration(
758-
labelText: 'Enter passages (one per line)',
853+
labelText: 'TIP: Paste or upload your lessons text here. You can also upload a CSV with two columns for "Title","Passage". No need for headers. Note: Each new line becomes a new lesson in the file, thus avoid newlines within paragraphs.',
759854
alignLabelWithHint: true,
855+
border: OutlineInputBorder(),
760856
),
761-
minLines: 6,
857+
expands: true,
762858
maxLines: null,
763859
),
764-
const SizedBox(height: 8),
765-
Row(
766-
children: [
767-
ElevatedButton.icon(
768-
onPressed: pickFile,
769-
icon: const Icon(Icons.folder_open),
770-
label: const Text('Choose file'),
771-
),
772-
const SizedBox(width: 12),
773-
Expanded(
774-
child: Text(
775-
'Upload a .txt file or paste passages above.',
776-
style: Theme.of(context).textTheme.bodySmall,
777-
),
860+
),
861+
862+
const SizedBox(height: 8),
863+
864+
Row(
865+
children: [
866+
ElevatedButton.icon(
867+
onPressed: pickFile,
868+
icon: const Icon(Icons.folder_open),
869+
label: const Text('Choose file'),
870+
),
871+
const SizedBox(width: 12),
872+
const Expanded(
873+
child: Text(
874+
'Upload a .txt or a two-column CSV. Each row becomes a lesson.',
875+
style: TextStyle(fontSize: 12.5),
778876
),
779-
],
780-
),
781-
],
782-
),
877+
),
878+
],
879+
),
880+
],
783881
),
784882
),
785883
actions: [
786884
TextButton(
787-
onPressed: () => Navigator.of(dialogCtx).pop(false),
885+
onPressed: () => Navigator.of(dialogCtx).pop(),
788886
child: const Text('Cancel'),
789887
),
790888
ElevatedButton(
791889
onPressed: () {
792-
// Do not allow upload if no content provided.
793-
if (textController.text.trim().isEmpty) return;
794-
Navigator.of(dialogCtx).pop(true);
890+
final raw = textController.text.trim();
891+
if (raw.isEmpty) return;
892+
893+
// If CSV detected, build lessons from CSV rows
894+
final parsed = csvRows ?? TwoColCsv.tryParse(raw);
895+
if (parsed != null && parsed.isNotEmpty) {
896+
// Optional header skip: Title,Passage
897+
final rows = List<MapEntry<String, String>>.from(parsed);
898+
if (rows.first.key.toLowerCase() == 'title' &&
899+
rows.first.value.toLowerCase() == 'passage') {
900+
rows.removeAt(0);
901+
}
902+
903+
final items = <Map<String, String>>[
904+
for (final r in rows)
905+
{
906+
'title': (r.key.trim().isEmpty) ? 'Untitled' : r.key.trim(),
907+
'content': r.value,
908+
}
909+
];
910+
Navigator.of(dialogCtx).pop(items);
911+
return;
912+
}
913+
914+
// Fallback: one line => one lesson (keep your old behavior)
915+
final lines = raw
916+
.split(RegExp(r'[\r\n]+'))
917+
.map((e) => e.trim())
918+
.where((e) => e.isNotEmpty)
919+
.toList();
920+
921+
var indexStart = _customLessons.length + 1;
922+
final items = <Map<String, String>>[];
923+
for (final line in lines) {
924+
final label = 'Paragraph ${indexStart.toString().padLeft(2, '0')}';
925+
items.add({'title': label, 'content': line});
926+
indexStart++;
927+
}
928+
if (items.isEmpty) return;
929+
Navigator.of(dialogCtx).pop(items);
795930
},
796931
child: const Text('Upload'),
797932
),
@@ -800,30 +935,22 @@ class _TutorPageState extends State<TutorPage> {
800935
},
801936
);
802937
},
803-
).then((confirm) {
804-
if (confirm != true) return;
805-
final lines = textController.text.split(RegExp('[\r\n]+'));
806-
// Determine starting number for generated titles. Use count of existing lessons + 1.
807-
var indexStart = _customLessons.length + 1;
808-
final newLessons = <Map<String, String>>[];
809-
for (final line in lines) {
810-
final trimmed = line.trim();
811-
if (trimmed.isEmpty) continue;
812-
final label = 'Paragraph ${indexStart.toString().padLeft(2, '0')}';
813-
newLessons.add({'title': label, 'content': trimmed});
814-
indexStart++;
815-
}
816-
if (newLessons.isEmpty) return;
938+
).then((result) {
939+
// result is List<Map<String,String>>? (null => cancelled)
940+
if (result == null || result.isEmpty) return;
941+
817942
setState(() {
818-
_customLessons.addAll(newLessons);
943+
_customLessons.addAll(result);
819944
_updateCustomUnit();
820945
_saveCustomLessons();
821-
// Select the first newly added lesson if nothing is selected.
822-
_selectedSubunit ??= newLessons.first['title'];
946+
947+
// Select first of the new ones if nothing selected
948+
_selectedSubunit ??= result.first['title'];
823949
if (_customUnitIndex != null && _selectedSubunit != null) {
824950
_lastSubunitPerUnit[_customUnitIndex!] = _selectedSubunit!;
825951
}
826-
// Reset typing session for new selection.
952+
953+
// Reset typing session
827954
_controller.clear();
828955
_startTime = null;
829956
_ticker?.cancel();
@@ -835,4 +962,5 @@ class _TutorPageState extends State<TutorPage> {
835962
});
836963
}
837964

965+
838966
}

lib/utils/csv_two_col.dart

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// NEW: simple two-column CSV detector & parser (supports quotes, no newlines in cells)
2+
import 'dart:convert';
3+
4+
class TwoColCsv {
5+
/// Returns null when not a valid 2-col CSV, otherwise a list of (title, passage).
6+
static List<MapEntry<String, String>>? tryParse(String raw) {
7+
if (raw.trim().isEmpty) return null;
8+
9+
final lines = const LineSplitter().convert(raw.trim());
10+
if (lines.isEmpty) return null;
11+
12+
final rows = <MapEntry<String, String>>[];
13+
var ok = 0;
14+
var total = 0;
15+
16+
for (final line in lines) {
17+
final parsed = _parseTwoCells(line);
18+
if (parsed == null) {
19+
total++;
20+
continue;
21+
}
22+
total++;
23+
ok++;
24+
rows.add(parsed);
25+
}
26+
27+
// Heuristic: at least 3 valid lines and >=80% lines are valid
28+
if (rows.length >= 3 && ok / total >= 0.8) {
29+
return rows;
30+
}
31+
return null;
32+
}
33+
34+
// Very small CSV line parser for exactly 2 cells, supports quotes and commas.
35+
// Disallows newlines inside cells (your UX hint says “avoid newline characters”).
36+
static MapEntry<String, String>? _parseTwoCells(String line) {
37+
// Match: "a","b" or a,b or "a",b or a,"b"
38+
final re = RegExp(
39+
r'^\s*(?:"([^"]*)"|([^",]*))\s*,\s*(?:"([^"]*)"|([^",]*))\s*$'
40+
);
41+
final m = re.firstMatch(line);
42+
if (m == null) return null;
43+
44+
final cell1 = m.group(1) ?? m.group(2) ?? '';
45+
final cell2 = m.group(3) ?? m.group(4) ?? '';
46+
return MapEntry(cell1, cell2);
47+
}
48+
}

lib/utils/pick_text.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import 'package:file_picker/file_picker.dart';
99
Future<String?> pickTextFile() async {
1010
final result = await FilePicker.platform.pickFiles(
1111
type: FileType.custom,
12-
allowedExtensions: const ['txt'],
12+
allowedExtensions: const ['txt','csv'],
1313
withData: true, // ensures bytes on Web; often on desktop/mobile too
1414
withReadStream: true, // desktop/mobile stream fallback when bytes are null
1515
);

0 commit comments

Comments
 (0)