diff --git a/app-mobile/flutter_application/lib/model/PlatformType.dart b/app-mobile/flutter_application/lib/model/PlatformType.dart new file mode 100644 index 0000000..120fc2a --- /dev/null +++ b/app-mobile/flutter_application/lib/model/PlatformType.dart @@ -0,0 +1,18 @@ +import 'dart:io'; + +enum PlatformType { UNKNOWN, MOBILE, DESKTOP, WEB } + +PlatformType getPlatformType() { + try { + if (Platform.isAndroid || Platform.isIOS) { + return PlatformType.MOBILE; + } else if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { + return PlatformType.DESKTOP; + } + } catch (e) { + if (e.toString().contains("Unsupported operation")) { + return PlatformType.WEB; + } + } + return PlatformType.UNKNOWN; +} diff --git a/app-mobile/flutter_application/lib/model/Utils.dart b/app-mobile/flutter_application/lib/model/Utils.dart new file mode 100644 index 0000000..8f94ed0 --- /dev/null +++ b/app-mobile/flutter_application/lib/model/Utils.dart @@ -0,0 +1,11 @@ +import 'package:roquiz/persistence/QuestionRepository.dart'; + +class Utils { + static String getParsedDateTime(DateTime date) { + if (date == QuestionRepository.CUSTOM_DATE) { + return "personalizzato"; + } else { + return "${date.day < 10 ? "0" : ""}${date.day}/${date.month < 10 ? "0" : ""}${date.month}/${date.year}, ${date.hour < 10 ? "0" : ""}${date.hour}:${date.minute < 10 ? "0" : ""}${date.minute}"; + } + } +} diff --git a/app-mobile/flutter_application/lib/persistence/QuestionRepository.dart b/app-mobile/flutter_application/lib/persistence/QuestionRepository.dart index 852f2a2..375817b 100644 --- a/app-mobile/flutter_application/lib/persistence/QuestionRepository.dart +++ b/app-mobile/flutter_application/lib/persistence/QuestionRepository.dart @@ -1,12 +1,20 @@ import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:roquiz/model/PlatformType.dart'; import 'package:roquiz/model/Question.dart'; import 'package:roquiz/model/Answer.dart'; +import 'package:http/http.dart' as http; import 'dart:io'; import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; class QuestionRepository { + static final DateTime CUSTOM_DATE = DateTime.parse("1999-01-01T00:00:00Z"); + static final DateTime DEFAULT_LAST_QUESTION_UPDATE = + DateTime.parse("2023-07-14T14:35:16Z"); static const int DEFAULT_ANSWER_NUMBER = 5; + DateTime lastQuestionUpdate = DEFAULT_LAST_QUESTION_UPDATE; List questions = []; List topics = []; List qNumPerTopic = []; @@ -14,12 +22,203 @@ class QuestionRepository { String error = ""; - Future load(String filePath) async { + late String _cachedNewContent; + late DateTime _cachedNewDate; + + Future load() async { + final prefs = await SharedPreferences.getInstance(); + + lastQuestionUpdate = DateTime.parse(prefs.getString("lastQuestionUpdate") ?? + DEFAULT_LAST_QUESTION_UPDATE.toString()); + + String content = ""; + + switch (getPlatformType()) { + // Mobile + case PlatformType.MOBILE: + Directory appDocDir = await getApplicationSupportDirectory(); + + String filePath = "${appDocDir.path}/Domande.txt"; + File file = File(filePath); + + // Check if file is present + if (!file.existsSync()) { + // If not create it and load from bundle + file.create(); + + String contentFromAsset = + await rootBundle.loadString("assets/domande.txt"); + file.writeAsString(contentFromAsset); + } + + String testContent = await file.readAsString(); + + if (isValid(testContent) > 0) { + String contentFromAsset = + await rootBundle.loadString("assets/domande.txt"); + file.writeAsString(contentFromAsset); + } + content = testContent; + + break; + + // Desktop + /*case PlatformType.DESKTOP: + // TO-DO + Directory appDocDir = await getApplicationSupportDirectory(); + print("test0: ${appDocDir.path}"); + + String filePath = "${appDocDir.path}\\data\\Domande.txt"; + File file = File(filePath); + + print("test1: ${Platform.resolvedExecutable}"); + + // Check if file is present + if (!file.existsSync()) { + print("test2.1: creation..."); + // If not create it and load from bundle + await file.create(); + + String contentFromAsset = + await rootBundle.loadString("assets/domande.txt"); + file.writeAsString(contentFromAsset); + + print("test2.2: created..."); + } else { + String tmp = await file.readAsString(); + print("test3: $tmp"); + } + + break;*/ + + // Web & others + default: + content = await rootBundle.loadString("assets/domande.txt"); + } + + parse(content); + } + + Future getLatestQuestionFileDate() async { + DateTime date; + + http.Response response = await http.get(Uri.parse( + 'https://api.github.com/repos/mikyll/ROQuiz/commits?path=Domande.txt&page=1&per_page=1')); + + try { + List json = jsonDecode(response.body); + String dateString = json[0]['commit']['author']['date']; + date = DateTime.parse(dateString); + + _cachedNewDate = date; + + return date; + } catch (e) { + print("Error: $e"); + + return Future.error(e); + } + } + + // Returns true if there is a more recent questions file + Future<(bool, DateTime, int)> checkQuestionUpdates() async { + DateTime date = await getLatestQuestionFileDate(); + String content = await downloadFile(); + int qNum = isValid(content); + + _cachedNewDate = date; + + return (date.isAfter(lastQuestionUpdate), date, qNum); + } + + Future downloadFile( + [url = + "https://raw.githubusercontent.com/mikyll/ROQuiz/main/Domande.txt"]) async { + String result = ""; + + // Get file content from repo + http.Response response = await http.get(Uri.parse(url)); + + if (response.statusCode == 200) { + result = response.body; + + _cachedNewContent = result; + } + + return result; + } + + void updateQuestionsDate([DateTime? newDate]) async { + final prefs = await SharedPreferences.getInstance(); + + DateTime date; + if (newDate != null) { + date = newDate; + } else { + date = _cachedNewDate; + } + + // Update shared preferences + prefs.setString("lastQuestionUpdate", date.toString()); + + // Load date to repository + lastQuestionUpdate = date; + } + + Future updateQuestionsFile([String? newContent]) async { + String content; + + // Check if it's valid + if (newContent != null) { + content = newContent; + } else { + content = _cachedNewContent; + } + if (QuestionRepository.isValid(content) < 0) { + return false; + } + + // Update file + switch (getPlatformType()) { + // Mobile + case PlatformType.MOBILE: + Directory appDocDir = await getApplicationSupportDirectory(); + + String filePath = "${appDocDir.path}/Domande.txt"; + File file = File(filePath); + + file.writeAsString(content); + + break; + /*case PlatformType.DESKTOP: + // TO-DO + break;*/ + default: + print("TO-DO: implement"); + } + + // Load to repository + parse(content); + + return true; + } + + Future update() async { + updateQuestionsDate(); + await updateQuestionsFile(); + } + + void parse(String content) async { + questions.clear(); + topics.clear(); + qNumPerTopic.clear(); + topicsPresent = false; + error = ""; + int numPerTopic = 0, totQuest = 0; - String fileText = await rootBundle.loadString(filePath); LineSplitter ls = const LineSplitter(); - List lines = ls.convert(fileText); + List lines = ls.convert(content); // the question file can be subdivided by topics (i == line #) for (int i = 0; i < lines.length; i++) { @@ -33,7 +232,7 @@ class QuestionRepository { } // next question - if (lines[i].isNotEmpty) { + if (lines.length > i && lines[i].isNotEmpty) { // next topic if (topicsPresent && lines[i].startsWith("@")) { qNumPerTopic.add(numPerTopic); @@ -50,6 +249,10 @@ class QuestionRepository { for (int j = 0; j < DEFAULT_ANSWER_NUMBER; j++) { i++; + if (lines.length <= i) { + throw FileSystemException( + "Riga ${i + 1}: risposta ${String.fromCharCode((j + 65))} mancante"); + } List splitted = lines[i].split(". "); if (splitted.length < 2 || splitted[1].isEmpty) { throw FileSystemException( @@ -60,7 +263,7 @@ class QuestionRepository { } i++; - if (lines[i].length != 1) { + if (lines.length <= i || lines[i].length != 1) { throw FileSystemException("Riga ${i + 1}: risposta corretta assente"); } @@ -87,7 +290,6 @@ class QuestionRepository { if (topicsPresent) { qNumPerTopic.add(numPerTopic); - // test print("Tot domande: $totQuest"); print("Argomenti: "); for (int i = 0; i < qNumPerTopic.length; i++) { @@ -112,19 +314,105 @@ class QuestionRepository { return topicsPresent; } + static (int, String) isValidErrors(String content) { + LineSplitter ls = const LineSplitter(); + List lines = ls.convert(content); + bool topics = false; + int numQuestions = 0; + + // the question file can be subdivided by topics (i == line #) + for (int i = 0; i < lines.length; i++) { + // if the first line starts with '@', then the file has topics + if (i == 0 && lines[i].startsWith("@")) { + topics = true; + + continue; + } + + // next question + if (lines.length > i && lines[i].isNotEmpty) { + // next topic + if (lines[i].startsWith("@")) { + if (topics) { + i++; + } else { + return ( + -1, + "Riga ${i + 1}: divisione per argomenti non rilevata (non è presente l'argomento per le prime domande), ma ne è stato trovato uno comunque" + ); + } + } + + // answers + for (int j = 0; j < DEFAULT_ANSWER_NUMBER; j++) { + i++; + if (lines.length <= i) { + return ( + -2, + "Riga ${i + 1}: risposta ${String.fromCharCode((j + 65))} mancante" + ); + } + List splitted = lines[i].split(". "); + if (splitted.length < 2 || splitted[1].isEmpty) { + return ( + -3, + "Riga ${i + 1}: risposta ${String.fromCharCode((j + 65))} formata male" + ); + } + } + i++; + + if (lines.length <= i || lines[i].length != 1) { + return (-4, "Riga ${i + 1}: risposta corretta assente"); + } + + int asciiValue = lines[i].codeUnitAt(0); + int value = asciiValue - 65; + if (value < 0 || value > DEFAULT_ANSWER_NUMBER - 1) { + return (-5, "Riga ${i + 1}: risposta corretta non valida"); + } + + numQuestions++; + } + } + return (numQuestions, "OK"); + } + + static int isValid(String content) { + int res; + String err; + (res, err) = isValidErrors(content); + + print(err); + + return res; + } + + static bool isValidBool(String content) { + return isValid(content) > 0; + } + @override String toString() { String res = ""; for (int i = 0; i < questions.length; i++) { - res += "Q${i + 1}) " + questions[i].question + "\n"; + res += "Q${i + 1}) ${questions[i].question}\n"; for (int j = 0; j < questions[i].answers.length; j++) { - res += - Answer.values[j].toString() + ". " + questions[i].answers[j] + "\n"; + res += "${Answer.values[j]}. ${questions[i].answers[j]}\n"; } res += "\n"; } return res; } + + // Auxiliary method for testing + Future reloadFromAsset() async { + String content = await rootBundle.loadString("assets/domande.txt"); + + updateQuestionsDate(DEFAULT_LAST_QUESTION_UPDATE); + updateQuestionsFile(content); + parse(content); + } } diff --git a/app-mobile/flutter_application/lib/persistence/Settings.dart b/app-mobile/flutter_application/lib/persistence/Settings.dart index 3d95c29..f7105ea 100644 --- a/app-mobile/flutter_application/lib/persistence/Settings.dart +++ b/app-mobile/flutter_application/lib/persistence/Settings.dart @@ -3,59 +3,59 @@ import 'package:shared_preferences/shared_preferences.dart'; class Settings { static const String APP_TITLE = "ROQuiz"; static String VERSION_NUMBER = ""; + static const int DEFAULT_ANSWER_NUMBER = 5; + + static const bool DEFAULT_CHECK_QUESTIONS_UPDATE = true; + static const bool DEFAULT_CHECK_APP_UPDATE = false; static const int DEFAULT_QUESTION_NUMBER = 16; static const int DEFAULT_TIMER = 18; static const bool DEFAULT_SHUFFLE_ANSWERS = true; static const bool DEFAULT_CONFIRM_ALERTS = true; static const bool DEFAULT_DARK_THEME = false; - static const bool DEFAULT_CHECK_QUESTIONS_UPDATE = false; - static const bool DEFAULT_CHECK_APP_UPDATE = false; - - static const int DEFAULT_ANSWER_NUMBER = 5; + late bool checkQuestionsUpdate = DEFAULT_CHECK_QUESTIONS_UPDATE; + late bool checkAppUpdate = DEFAULT_CHECK_APP_UPDATE; late int questionNumber = DEFAULT_QUESTION_NUMBER; late int timer = DEFAULT_TIMER; late bool shuffleAnswers = DEFAULT_SHUFFLE_ANSWERS; late bool confirmAlerts = DEFAULT_CONFIRM_ALERTS; late bool darkTheme = DEFAULT_DARK_THEME; - late bool checkQuestionsUpdate = DEFAULT_CHECK_QUESTIONS_UPDATE; - late bool checkAppUpdate = DEFAULT_CHECK_APP_UPDATE; - void loadSettings() async { + void loadFromSharedPreferences() async { final prefs = await SharedPreferences.getInstance(); + checkQuestionsUpdate = + prefs.getBool("checkQuestionsUpdate") ?? DEFAULT_CHECK_QUESTIONS_UPDATE; + checkAppUpdate = + prefs.getBool("checkAppUpdate") ?? DEFAULT_CHECK_APP_UPDATE; questionNumber = prefs.getInt("questionNumber") ?? DEFAULT_QUESTION_NUMBER; timer = prefs.getInt("timer") ?? DEFAULT_TIMER; shuffleAnswers = prefs.getBool("shuffleAnswers") ?? DEFAULT_SHUFFLE_ANSWERS; confirmAlerts = prefs.getBool("confirmAlerts") ?? DEFAULT_CONFIRM_ALERTS; darkTheme = prefs.getBool("darkTheme") ?? DEFAULT_DARK_THEME; - checkQuestionsUpdate = - prefs.getBool("checkQuestionsUpdate") ?? DEFAULT_CHECK_QUESTIONS_UPDATE; - checkAppUpdate = - prefs.getBool("checkAppUpdate") ?? DEFAULT_CHECK_APP_UPDATE; } - void saveSettings() async { + void saveToSharedPreferences() async { final prefs = await SharedPreferences.getInstance(); + prefs.setBool("checkQuestionsUpdate", checkQuestionsUpdate); + prefs.setBool("checkAppUpdate", checkAppUpdate); prefs.setInt("questionNumber", questionNumber); prefs.setInt("timer", timer); prefs.setBool("shuffleAnswers", shuffleAnswers); prefs.setBool("confirmAlerts", confirmAlerts); prefs.setBool("darkTheme", darkTheme); - prefs.setBool("checkQuestionsUpdate", checkQuestionsUpdate); - prefs.setBool("checkAppUpdate", checkAppUpdate); } - void resetSettings() async { + void resetDefault() async { final prefs = await SharedPreferences.getInstance(); + prefs.setBool("checkQuestionsUpdate", DEFAULT_CHECK_QUESTIONS_UPDATE); + prefs.setBool("checkAppUpdate", DEFAULT_CHECK_APP_UPDATE); prefs.setInt("questionNumber", DEFAULT_QUESTION_NUMBER); prefs.setInt("timer", DEFAULT_TIMER); prefs.setBool("shuffleAnswers", DEFAULT_SHUFFLE_ANSWERS); prefs.setBool("confirmAlerts", DEFAULT_CONFIRM_ALERTS); prefs.setBool("darkTheme", DEFAULT_DARK_THEME); - prefs.setBool("checkQuestionsUpdate", DEFAULT_CHECK_QUESTIONS_UPDATE); - prefs.setBool("checkAppUpdate", DEFAULT_CHECK_APP_UPDATE); } } diff --git a/app-mobile/flutter_application/lib/views/ViewMenu.dart b/app-mobile/flutter_application/lib/views/ViewMenu.dart index faf5fdc..865d28c 100644 --- a/app-mobile/flutter_application/lib/views/ViewMenu.dart +++ b/app-mobile/flutter_application/lib/views/ViewMenu.dart @@ -1,5 +1,8 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:roquiz/model/Question.dart'; +import 'package:roquiz/model/Utils.dart'; import 'package:roquiz/persistence/QuestionRepository.dart'; import 'package:roquiz/persistence/Settings.dart'; import 'package:roquiz/views/ViewQuiz.dart'; @@ -7,10 +10,12 @@ import 'package:roquiz/views/ViewSettings.dart'; import 'package:roquiz/views/ViewTopics.dart'; import 'package:roquiz/views/ViewInfo.dart'; import 'package:roquiz/model/Themes.dart'; +import 'package:roquiz/widget/confirmation_alert.dart'; import 'package:roquiz/widget/icon_button_widget.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:http/http.dart' as http; class ViewMenu extends StatefulWidget { const ViewMenu({Key? key}) : super(key: key); @@ -30,9 +35,6 @@ class ViewMenuState extends State { final List _selectedTopics = []; int _quizPool = 0; - late int _quizQuestionNumber; - late int _timer; - void _initTopics() { setState(() { for (int i = 0; i < qRepo.topics.length; i++) { @@ -72,37 +74,80 @@ class ViewMenuState extends State { }); } - void saveSettings( - int qNum, int timer, bool shuffle, bool confirmAlerts, bool dTheme) { + void saveSettings(bool qCheck, int qNum, int timer, bool shuffle, + bool confirmAlerts, bool dTheme) { setState(() { + _settings.checkQuestionsUpdate = qCheck; _settings.questionNumber = qNum; _settings.timer = timer; _settings.shuffleAnswers = shuffle; _settings.confirmAlerts = confirmAlerts; _settings.darkTheme = dTheme; - _settings.saveSettings(); + _settings.saveToSharedPreferences(); resetTopics(); }); } + void _showConfirmationDialog(BuildContext context, String title, + String content, void Function()? onConfirm, void Function()? onCancel) { + showDialog( + context: context, + builder: (BuildContext context) { + return ConfirmationAlert( + title: title, + content: content, + buttonConfirmText: "Sì", + buttonCancelText: "No", + onConfirm: onConfirm, + onCancel: onCancel); + }); + } + + Future _launchInBrowser(String url) async { + if (!await launchUrl(Uri.parse(url), + mode: LaunchMode.externalApplication)) { + throw 'Could not launch $url'; + } + } + + Future _checkNewVersion() async { + http.Response response = await http.get(Uri.parse( + 'https://api.github.com/repos/mikyll/ROQuiz/releases/latest')); + + try { + Map json = jsonDecode(response.body); + String tag_name = json['tag_name']; + List repoVersion = tag_name.replaceAll("v", "").split("."); + + int repoMajor = int.parse(repoVersion[0]); + int repoMinor = int.parse(repoVersion[1]); + + List currentVersion = Settings.VERSION_NUMBER.split("."); + int currentMajor = int.parse(currentVersion[0]) - 1; + int currentMinor = int.parse(currentVersion[1]); + + if (currentMajor < repoMajor || + (currentMajor == repoMajor && currentMinor < repoMinor)) { + return true; + } + } catch (e) { + print("Error: $e"); + } + return false; + } + @override void initState() { super.initState(); PackageInfo.fromPlatform().then((PackageInfo packageInfo) { - //print(packageInfo.appName); - //print(packageInfo.packageName); - setState( - () { - Settings.VERSION_NUMBER = packageInfo.version; - }, - ); - //print(packageInfo.buildNumber); + setState(() => Settings.VERSION_NUMBER = packageInfo.version); }); - _settings.loadSettings(); + _settings.loadFromSharedPreferences(); + qRepo - .loadFile("assets/domande.txt") + .load() .then( (_) => { _initTopics(), @@ -116,16 +161,32 @@ class ViewMenuState extends State { .onError((error, stackTrace) => {setState(() => _qRepoLoadingError = error.toString())}); - _prefs.then((value) => (SharedPreferences prefs) { - _quizQuestionNumber = prefs.getInt("questionNumber") ?? -1; - _timer = prefs.getInt("timer") ?? -1; - }); - } - - Future _launchInBrowser(String url) async { - if (!await launchUrl(Uri.parse(url), - mode: LaunchMode.externalApplication)) { - throw 'Could not launch $url'; + if (_settings.checkQuestionsUpdate) { + qRepo.checkQuestionUpdates().then((result) { + bool newQuestionsPresent = result.$1; + DateTime date = result.$2; + int qNum = result.$3; + if (newQuestionsPresent) { + _showConfirmationDialog( + context, + "Nuove Domande", + "È stata trovata una versione più recente del file contenente le domande.\n" + "Versione attuale: ${qRepo.questions.length} domande (${Utils.getParsedDateTime(qRepo.lastQuestionUpdate)}).\n" + "Nuova versione: $qNum domande (${Utils.getParsedDateTime(date)}).\n" + "Scaricare il nuovo file?", + () { + setState(() { + qRepo + .update() + .then((_) => updateQuizPool(qRepo.questions.length)); + _quizPool = qRepo.questions.length; + }); + Navigator.pop(context); + }, + () => Navigator.pop(context), + ); + } + }); } } @@ -166,7 +227,9 @@ class ViewMenuState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 30.0), child: ElevatedButton( - onPressed: qRepo.questions.isEmpty + onPressed: qRepo.questions.isEmpty || + qRepo.questions.length < + _settings.questionNumber ? null : () { Navigator.push( @@ -230,12 +293,12 @@ class ViewMenuState extends State { Icons.format_list_numbered_rounded, ), Text( - "Domande: ${_settings.questionNumber} su $_quizPool"), + " Domande: ${_settings.questionNumber} su $_quizPool"), const SizedBox(width: 20), const Icon( Icons.timer_rounded, ), - Text("Tempo: ${_settings.timer} min"), + Text(" Tempo: ${_settings.timer} min"), ]), _qRepoLoadingError.isNotEmpty ? Padding( diff --git a/app-mobile/flutter_application/lib/views/ViewQuestions.dart b/app-mobile/flutter_application/lib/views/ViewQuestions.dart index e31749b..979e6c9 100644 --- a/app-mobile/flutter_application/lib/views/ViewQuestions.dart +++ b/app-mobile/flutter_application/lib/views/ViewQuestions.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:roquiz/widget/search_bar_widget.dart'; import 'package:flutter/material.dart'; import 'package:roquiz/model/Question.dart'; @@ -52,18 +54,9 @@ class ViewQuestionsState extends State { }); } - (bool, String) _isFirstOfTopic(int index) { - if (widget.iTopic != null) { - return (false, ""); - } - int qNum = 0; - for (int i = 0; i < widget.qRepo.qNumPerTopic.length; i++) { - if (index == qNum) { - return (true, widget.qRepo.topics[i]); - } - qNum += widget.qRepo.qNumPerTopic[i]; - } - return (false, ""); + double _getWidth() { + return PlatformDispatcher.instance.views.first.physicalSize.width / + PlatformDispatcher.instance.views.first.devicePixelRatio; } void _clearSearch() { @@ -120,36 +113,34 @@ class ViewQuestionsState extends State { }, ), actions: [ - widget.iTopic == null - ? SearchBarWidget( - color: Colors.transparent, - searchIconColor: Colors.white, - boxShadow: false, - width: 300, - textController: _textController, - onOpen: () { - //print("open"); - setState(() => _searchBarOpen = true); - }, - onSearch: (stringToSearch) { - //print("search"); - _search(stringToSearch); - if (questions.isEmpty) { - _clearSearch(); - _textController.clear(); - } - }, - onClear: () { - //print("clear"); - _clearSearch(); - _textController.clear(); - }, - onClose: () { - //print("close"); - setState(() => _searchBarOpen = false); - }, - ) - : const Text(""), + SearchBarWidget( + color: Colors.transparent, + searchIconColor: Colors.white, + boxShadow: false, + width: 300, + textController: _textController, + onOpen: () { + //print("open"); + setState(() => _searchBarOpen = true); + }, + onSearch: (stringToSearch) { + //print("search"); + _search(stringToSearch); + if (questions.isEmpty) { + _clearSearch(); + _textController.clear(); + } + }, + onClear: () { + //print("clear"); + _clearSearch(); + _textController.clear(); + }, + onClose: () { + //print("close"); + setState(() => _searchBarOpen = false); + }, + ), ], ), body: Scrollbar( diff --git a/app-mobile/flutter_application/lib/views/ViewSettings.dart b/app-mobile/flutter_application/lib/views/ViewSettings.dart index 3949920..8a15a6c 100644 --- a/app-mobile/flutter_application/lib/views/ViewSettings.dart +++ b/app-mobile/flutter_application/lib/views/ViewSettings.dart @@ -1,11 +1,16 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; +import 'package:roquiz/model/Utils.dart'; import 'package:roquiz/persistence/QuestionRepository.dart'; import 'package:roquiz/persistence/Settings.dart'; import 'package:roquiz/model/Themes.dart'; import 'package:roquiz/widget/change_theme_button_widget.dart'; import 'package:roquiz/widget/confirmation_alert.dart'; import 'package:roquiz/widget/icon_button_widget.dart'; +import 'package:file_picker/file_picker.dart'; class ViewSettings extends StatefulWidget { const ViewSettings({ @@ -17,19 +22,117 @@ class ViewSettings extends StatefulWidget { final QuestionRepository qRepo; final Settings settings; - final Function(int, int, bool, bool, bool) saveSettings; + final Function(bool, int, int, bool, bool, bool) saveSettings; @override State createState() => ViewSettingsState(); } class ViewSettingsState extends State { + bool _checkQuestionsUpdate = Settings.DEFAULT_CHECK_QUESTIONS_UPDATE; int _questionNumber = Settings.DEFAULT_QUESTION_NUMBER; int _timer = Settings.DEFAULT_TIMER; bool _shuffleAnswers = Settings.DEFAULT_SHUFFLE_ANSWERS; bool _confirmAlerts = Settings.DEFAULT_CONFIRM_ALERTS; bool _darkTheme = Settings.DEFAULT_DARK_THEME; // previous value + bool _isLoading = false; + + void _resetCheckQuestionsUpdate() { + setState( + () => _checkQuestionsUpdate = Settings.DEFAULT_CHECK_QUESTIONS_UPDATE); + } + + void _selectCheckQuestionsUpdate(bool value) { + setState(() { + _checkQuestionsUpdate = value; + }); + } + + void _loadQuestionFilePath() async { + setState(() { + _isLoading = true; + }); + FilePickerResult? result = await FilePicker.platform + .pickFiles(type: FileType.custom, allowedExtensions: ["txt"]); + + if (result != null) { + File file = File(result.files.single.path!); + + String content = await file.readAsString(); + + int res; + String err; + (res, err) = QuestionRepository.isValidErrors(content); + if (res > 0) { + widget.qRepo.updateQuestionsDate(QuestionRepository.CUSTOM_DATE); + await widget.qRepo.updateQuestionsFile(content); + } + _showConfirmationDialog( + context, + res > 0 ? "File Caricato" : "File Non Valido", + res > 0 + ? "Il file domande personalizzato è stato caricato correttamente.\n" + "Numero domande: $res" + : err, + "", + "Ok", + null, + () => Navigator.pop(context), + ); + + setState(() { + _isLoading = false; + }); + } + } + + void _checkNewQuestions() { + print("Versione corrente: ${widget.qRepo.lastQuestionUpdate}"); + + setState(() { + _isLoading = true; + }); + + widget.qRepo.checkQuestionUpdates().then((result) { + setState(() { + _isLoading = false; + }); + bool newQuestionsPresent = result.$1; + DateTime date = result.$2; + int qNum = result.$3; + if (newQuestionsPresent) { + _showConfirmationDialog( + context, + "Nuove Domande", + "È stata trovata una versione più recente del file contenente le domande.\n" + "Versione attuale: ${widget.qRepo.questions.length} domande (${Utils.getParsedDateTime(widget.qRepo.lastQuestionUpdate)}).\n" + "Nuova versione: $qNum domande (${Utils.getParsedDateTime(date)}).\n" + "Scaricare il nuovo file?", + "Sì", + "No", + () { + setState(() { + widget.qRepo.update(); + }); + Navigator.pop(context); + }, + () => Navigator.pop(context), + ); + } else { + _showConfirmationDialog( + context, + "Nessuna Nuova Domanda", + "Non sono state trovate nuove domande. Il file è aggiornato all'ultima versione (${Utils.getParsedDateTime(widget.qRepo.lastQuestionUpdate)}).", + "", + "Ok", + null, + () => Navigator.pop(context), + ); + } + }); + } + void _resetQuestionNumber() { setState(() => _questionNumber = Settings.DEFAULT_QUESTION_NUMBER); } @@ -97,6 +200,7 @@ class ViewSettingsState extends State { } void _reset(ThemeProvider themeProvider) { + _resetCheckQuestionsUpdate(); _resetQuestionNumber(); _resetTimer(); _resetShuffleAnswers(); @@ -105,7 +209,8 @@ class ViewSettingsState extends State { } bool _isDefault(ThemeProvider themeProvider) { - return _questionNumber == Settings.DEFAULT_QUESTION_NUMBER && + return _checkQuestionsUpdate == Settings.DEFAULT_CHECK_QUESTIONS_UPDATE && + _questionNumber == Settings.DEFAULT_QUESTION_NUMBER && _timer == Settings.DEFAULT_TIMER && _shuffleAnswers == Settings.DEFAULT_SHUFFLE_ANSWERS && _confirmAlerts == Settings.DEFAULT_CONFIRM_ALERTS && @@ -113,21 +218,30 @@ class ViewSettingsState extends State { } bool _isChanged(ThemeProvider themeProvider) { - return _questionNumber != widget.settings.questionNumber || + return _checkQuestionsUpdate != widget.settings.checkQuestionsUpdate || + _questionNumber != widget.settings.questionNumber || _timer != widget.settings.timer || _shuffleAnswers != widget.settings.shuffleAnswers || _confirmAlerts != widget.settings.confirmAlerts || themeProvider.isDarkMode != widget.settings.darkTheme; } - void _showConfirmationDialog(BuildContext context, String title, - String content, void Function()? onConfirm, void Function()? onCancel) { + void _showConfirmationDialog( + BuildContext context, + String title, + String content, + String confirmButton, + String cancelButton, + void Function()? onConfirm, + void Function()? onCancel) { showDialog( context: context, builder: (BuildContext context) { return ConfirmationAlert( title: title, content: content, + buttonConfirmText: confirmButton, + buttonCancelText: cancelButton, onConfirm: onConfirm, onCancel: onCancel); }); @@ -138,6 +252,7 @@ class ViewSettingsState extends State { super.initState(); setState(() { + _checkQuestionsUpdate = widget.settings.checkQuestionsUpdate; _questionNumber = widget.settings.questionNumber; _timer = widget.settings.timer; _shuffleAnswers = widget.settings.shuffleAnswers; @@ -157,8 +272,10 @@ class ViewSettingsState extends State { context, "Modifiche Non Salvate", "Uscire senza salvare?", + "Conferma", + "Annulla", () { - // Discard + // Discard settings (confirm) Navigator.pop(context); Navigator.pop(context); themeProvider.toggleTheme(_darkTheme); @@ -186,8 +303,10 @@ class ViewSettingsState extends State { context, "Modifiche Non Salvate", "Uscire senza salvare?", + "Conferma", + "Annulla", () { - // Discard (Confirm) + // Discard settings (Confirm) Navigator.pop(context); Navigator.pop(context); themeProvider.toggleTheme(_darkTheme); @@ -209,6 +328,106 @@ class ViewSettingsState extends State { child: ListView( shrinkWrap: true, children: [ + // SETTING: Questions File + Row( + children: [ + Expanded( + child: InkWell( + hoverColor: Colors.transparent, + highlightColor: Colors.transparent, + splashColor: Colors.transparent, + onDoubleTap: () { + // TO-DO: reset + }, + child: const Text("File domande: ", + style: TextStyle(fontSize: 20))), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0), + child: SizedBox( + width: 100.0, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Opacity( + opacity: 0.5, + child: IconButtonWidget( + /*onTap: + () { + print("TO-DO: Edit question file."); + },*/ + lightPalette: MyThemes.lightIconButtonPalette, + darkPalette: MyThemes.darkIconButtonPalette, + width: 40.0, + height: 40.0, + icon: Icons.edit, + iconSize: 35, + borderRadius: 5, + ), + ), + const Spacer(flex: 1), + IconButtonWidget( + onTap: _isLoading + ? null + : () { + _loadQuestionFilePath(); + }, + lightPalette: MyThemes.lightIconButtonPalette, + darkPalette: MyThemes.darkIconButtonPalette, + width: 40.0, + height: 40.0, + icon: Icons.file_open_outlined, + iconSize: 35, + borderRadius: 5, + ), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 20), + // SETTING: New Questions Check + Row( + children: [ + IconButtonWidget( + onTap: _isLoading + ? null + : () { + _checkNewQuestions(); + }, + lightPalette: MyThemes.lightIconButtonPalette, + darkPalette: MyThemes.darkIconButtonPalette, + width: 40.0, + height: 40.0, + icon: Icons.sync_rounded, + iconSize: 35, + borderRadius: 5, + ), + const SizedBox(width: 10), + Expanded( + child: InkWell( + hoverColor: Colors.transparent, + highlightColor: Colors.transparent, + splashColor: Colors.transparent, + onDoubleTap: () { + _resetCheckQuestionsUpdate(); + }, + child: const Text("Controllo nuove domande: ", + softWrap: true, style: TextStyle(fontSize: 20))), + ), + SizedBox( + width: 120.0, + child: Transform.scale( + scale: 1.5, + child: Checkbox( + value: _checkQuestionsUpdate, + onChanged: (bool? value) => + _selectCheckQuestionsUpdate(value!)), + )) + ], + ), + const SizedBox(height: 20), // SETTING: QUIZ QUESTION NUMBER Row( children: [ @@ -422,6 +641,7 @@ class ViewSettingsState extends State { child: ElevatedButton( onPressed: () { widget.saveSettings( + _checkQuestionsUpdate, _questionNumber, _timer, _shuffleAnswers, diff --git a/app-mobile/flutter_application/lib/views/ViewTopics.dart b/app-mobile/flutter_application/lib/views/ViewTopics.dart index dae0ddc..ac23c23 100644 --- a/app-mobile/flutter_application/lib/views/ViewTopics.dart +++ b/app-mobile/flutter_application/lib/views/ViewTopics.dart @@ -138,10 +138,21 @@ class ViewTopicsState extends State { mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ - TopicsInfoWidget( - text: "Domande Totali: ", - value: widget.qRepo.questions.length, - color: Colors.indigo.withOpacity(0.35), + InkWell( + child: TopicsInfoWidget( + text: "Domande Totali: ", + value: widget.qRepo.questions.length, + color: Colors.indigo.withOpacity(0.35), + ), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ViewQuestions( + title: "Lista domande", + qRepo: widget.qRepo, + ))); + }, ), TopicsInfoWidget( text: "Pool Corrente: ", diff --git a/app-mobile/flutter_application/lib/widget/confirmation_alert.dart b/app-mobile/flutter_application/lib/widget/confirmation_alert.dart index e329f94..54141dc 100644 --- a/app-mobile/flutter_application/lib/widget/confirmation_alert.dart +++ b/app-mobile/flutter_application/lib/widget/confirmation_alert.dart @@ -25,20 +25,20 @@ class ConfirmationAlert extends StatelessWidget { content: Text(content), actions: [ TextButton( + onPressed: onConfirm != null + ? () { + onConfirm!(); + } + : null, child: Text(buttonConfirmText), - onPressed: () { - if (onConfirm != null) { - onConfirm!(); - } - }, ), TextButton( + onPressed: onCancel != null + ? () { + onCancel!(); + } + : null, child: Text(buttonCancelText), - onPressed: () { - if (onCancel != null) { - onCancel!(); - } - }, ), ], ); diff --git a/app-mobile/flutter_application/lib/widget/search_bar_widget.dart b/app-mobile/flutter_application/lib/widget/search_bar_widget.dart index 4f2ae8a..573e89b 100644 --- a/app-mobile/flutter_application/lib/widget/search_bar_widget.dart +++ b/app-mobile/flutter_application/lib/widget/search_bar_widget.dart @@ -420,3 +420,217 @@ class SearchBarWidgetState extends State ); } } + +/* +child: Stack( + children: [ + ///Using Animated Positioned widget to expand and shrink the widget + AnimatedPositioned( + duration: + Duration(milliseconds: widget.animationDurationInMilli), + top: 6.0, + right: 7.0, + curve: Curves.easeOut, + child: AnimatedOpacity( + opacity: !isOpen ? 0.0 : 1.0, + duration: const Duration(milliseconds: 200), + child: Container( + padding: const EdgeInsets.all(8.0), + decoration: BoxDecoration( + /// can add custom color or the color will be white + color: widget.color, + borderRadius: BorderRadius.circular(30.0), + ), + child: AnimatedBuilder( + builder: (context, widget) { + ///Using Transform.rotate to rotate the suffix icon when it gets expanded + return Transform.rotate( + angle: _con.value * 2.0 * pi, + child: widget, + ); + }, + animation: _con, + child: GestureDetector( + onTap: () { + try { + ///trying to execute the onSuffixTap function + if (widget.onClear != null) { + widget.onClear!(); + } + + // * if field empty then the user trying to close bar + if (textFieldValue == '') { + unfocusKeyboard(); + setState(() { + isOpen = false; + + if (widget.onClose != null) { + widget.onClose!(); + } + }); + + ///reverse == close + _con.reverse(); + } + + // * why not clear textfield here? + widget.textController.clear(); + textFieldValue = ''; + + ///closeSearchOnSuffixTap will execute if it's true + if (widget.closeSearchOnSuffixTap) { + unfocusKeyboard(); + setState(() { + isOpen = false; + }); + } + } catch (e) { + ///print the error if the try block fails + print(e); + } + }, + + ///suffixIcon is of type Icon + child: widget.suffixIcon ?? + Icon( + Icons.close, + size: 20.0, + color: widget.textFieldIconColor, + ), + ), + ), + ), + ), + ), + AnimatedPositioned( + duration: + Duration(milliseconds: widget.animationDurationInMilli), + left: !isOpen ? 20.0 : 40.0, + curve: Curves.easeOut, + top: 11.0, + + ///Using Animated opacity to change the opacity of th textField while expanding + child: AnimatedOpacity( + opacity: !isOpen ? 0.0 : 1.0, + duration: const Duration(milliseconds: 200), + child: Container( + padding: const EdgeInsets.only(left: 10), + alignment: Alignment.topCenter, + width: widget.width / 1.7, + child: TextField( + ///Text Controller. you can manipulate the text inside this textField by calling this controller. + controller: widget.textController, + inputFormatters: widget.inputFormatters, + focusNode: focusNode, + cursorRadius: const Radius.circular(10.0), + cursorWidth: 2.0, + onChanged: (value) { + textFieldValue = value; + }, + onSubmitted: (value) { + widget.onSearch(value); + if (widget.onClose != null) { + widget.onClose!(); + } + unfocusKeyboard(); + setState(() { + isOpen = false; + }); + }, + onEditingComplete: () { + /// on editing complete the keyboard will be closed and the search bar will be closed + unfocusKeyboard(); + setState(() { + isOpen = false; + }); + }, + + ///style is of type TextStyle, the default is just a color black + style: widget.style ?? + TextStyle( + color: themeProvider.isDarkMode + ? widget.textFieldTextDarkColor + : widget.textFieldTextLightColor), + cursorColor: themeProvider.isDarkMode + ? widget.textFieldTextDarkColor + : widget.textFieldTextLightColor, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(5.0), + isDense: true, + floatingLabelBehavior: FloatingLabelBehavior.never, + labelText: widget.helpText, + labelStyle: const TextStyle( + color: Color(0xff5B5B5B), + fontSize: 20.0, + fontWeight: FontWeight.w400, + ), + alignLabelWithHint: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20.0), + borderSide: BorderSide.none, + ), + ), + ), + ), + ), + ), + + ///Using material widget here to get the ripple effect on the prefix icon + Material( + /// can add custom color or the color will be white + /// toggle button color based on toggle state + color: !isOpen + ? widget.color + : (themeProvider.isDarkMode + ? widget.textFieldBackgroundDarkColor + : widget.textFieldBackgroundLightColor), + borderRadius: BorderRadius.circular(30.0), + child: IconButton( + splashRadius: 19.0, + + ///if toggle is 1, which means it's open. so show the back icon, which will close it. + ///if the toggle is 0, which means it's closed, so tapping on it will expand the widget. + ///prefixIcon is of type Icon + icon: Icon( + Icons.search, + color: isOpen ? Colors.grey : widget.searchIconColor, + ), + onPressed: () { + setState( + () { + if (!isOpen) { + isOpen = true; + + if (widget.onOpen != null) { + widget.onOpen!(); + } + + ///if the autoFocus is true, the keyboard will pop open, automatically + if (widget.autoFocus) { + FocusScope.of(context).requestFocus(focusNode); + } + + // expand + _con.forward(); + } else { + isOpen = false; + + widget.onSearch(widget.textController.text); + if (widget.onClose != null) { + widget.onClose!(); + } + + ///if the autoFocus is true, the keyboard will close, automatically + if (widget.autoFocus) unfocusKeyboard(); + + // shrink + _con.reverse(); + } + }, + ); + }, + ), + ), + ], + ), +*/ \ No newline at end of file diff --git a/app-mobile/flutter_application/pubspec.lock b/app-mobile/flutter_application/pubspec.lock index 5f840fa..27a9cbc 100644 --- a/app-mobile/flutter_application/pubspec.lock +++ b/app-mobile/flutter_application/pubspec.lock @@ -73,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.4" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "21145c9c268d54b1f771d8380c195d2d6f655e0567dc1ca2f9c134c02c819e0a" + url: "https://pub.dev" + source: hosted + version: "5.3.3" flutter: dependency: "direct main" description: flutter @@ -86,6 +94,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "950e77c2bbe1692bc0874fc7fb491b96a4dc340457f4ea1641443d0a6c1ea360" + url: "https://pub.dev" + source: hosted + version: "2.0.15" flutter_test: dependency: "direct dev" description: flutter diff --git a/app-mobile/flutter_application/pubspec.yaml b/app-mobile/flutter_application/pubspec.yaml index 5066b9a..2f6e569 100644 --- a/app-mobile/flutter_application/pubspec.yaml +++ b/app-mobile/flutter_application/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.6.0 +version: 1.7.0 environment: sdk: '>=3.0.6 <4.0.0' @@ -41,6 +41,7 @@ dependencies: shared_preferences: ^2.2.0 package_info_plus: ^4.0.2 path_provider: ^2.0.15 + file_picker: ^5.3.3 dev_dependencies: flutter_test: