@@ -5,6 +5,7 @@ import 'package:asante_typing/models/units.dart';
55import 'package:asante_typing/services/custom_lessons_service.dart' ;
66import 'package:asante_typing/state/zoom_scope.dart' ;
77import 'package:asante_typing/theme/app_colors.dart' ;
8+ import 'package:asante_typing/utils/csv_two_col.dart' ;
89import 'package:asante_typing/utils/pick_text.dart' as picktext;
910import 'package:asante_typing/utils/typing_utils.dart' ;
1011import '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}
0 commit comments