diff --git a/frontend/android/app/build.gradle.kts b/frontend/android/app/build.gradle.kts index 909eb6a..445199c 100644 --- a/frontend/android/app/build.gradle.kts +++ b/frontend/android/app/build.gradle.kts @@ -20,12 +20,11 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "app.odweta.novehlasky" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion - targetSdk = flutter.targetSdkVersion + targetSdk = 33 versionCode = flutter.versionCode versionName = flutter.versionName } diff --git a/frontend/android/app/src/main/AndroidManifest.xml b/frontend/android/app/src/main/AndroidManifest.xml index 0fd500e..0aef1e2 100644 --- a/frontend/android/app/src/main/AndroidManifest.xml +++ b/frontend/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,8 @@ - + + + + QuoteProvider() ), ChangeNotifierProvider( - create: (_) => ThemeProvider() + create: (_) => ThemeProvider(isDark: isDark) ) ], child: HlaskovnikFrontendApp() diff --git a/frontend/lib/pages/flashcards.dart b/frontend/lib/pages/flashcards.dart index 703df0c..11354f2 100644 --- a/frontend/lib/pages/flashcards.dart +++ b/frontend/lib/pages/flashcards.dart @@ -1,9 +1,6 @@ import 'package:flutter/material.dart'; - import 'package:frontend/provider/quote_provider.dart'; - import 'package:provider/provider.dart'; - import 'package:frontend/values/strings.dart'; import 'package:frontend/values/constants.dart'; @@ -12,10 +9,10 @@ class FlashcardsPage extends StatelessWidget { @override Widget build(BuildContext context) { - //return const Center(child: Text('flashcards! flip flip')); final provider = Provider.of(context); - if (provider.randomQuoteText == "dummy" || - provider.randomQuoteText == "quote text") { provider.nextRandomQuote(); } // initialize the random quote variable + + final screenWidth = MediaQuery.of(context).size.width; + return Card( margin: const EdgeInsets.all(Constants.edgeInset), child: Padding( @@ -23,43 +20,65 @@ class FlashcardsPage extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, children: [ + const Spacer(), + Padding( - padding: const EdgeInsets.all(Constants.edgeInset*2), - child: Text(provider.randomQuoteText, + padding: const EdgeInsets.all(Constants.edgeInset), + child: Text( + provider.randomQuoteText, + textAlign: TextAlign.center, style: TextStyle( - fontSize: 35, + fontSize: screenWidth * 0.06, // responsive text + fontWeight: FontWeight.w500, ), ), ), - Column( - mainAxisAlignment: MainAxisAlignment.end, + + const Spacer(), + + // Buttons side by side + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - Padding( - padding: const EdgeInsets.all(Constants.edgeInset), - child: TextButton.icon( - onPressed: provider.flipRandomQuote, - icon: Icon(Icons.repeat, - size: 35, - ), - label: Text(Strings.flipFlashcard, - style: TextStyle( - fontSize: 35, + Expanded( + child: Padding( + padding: const EdgeInsets.all(Constants.edgeInset), + child: TextButton.icon( + onPressed: provider.flipRandomQuote, + icon: const Icon( + Icons.repeat, + size: 28, + ), + label: Text( + Strings.flipFlashcard, + style: TextStyle( + fontSize: screenWidth * 0.045, + ), + ), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), ), ), ), ), - Padding( - padding: const EdgeInsets.all(Constants.edgeInset), - child: TextButton.icon( - onPressed: provider.nextRandomQuote, - icon: Icon(Icons.arrow_right_alt, - size: 35, - ), - label: Text(Strings.nextFlashcard, - style: TextStyle( - fontSize: 35, + Expanded( + child: Padding( + padding: const EdgeInsets.all(Constants.edgeInset), + child: TextButton.icon( + onPressed: provider.nextRandomQuote, + icon: const Icon( + Icons.arrow_right_alt, + size: 28, + ), + label: Text( + Strings.nextFlashcard, + style: TextStyle( + fontSize: screenWidth * 0.045, + ), + ), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), ), ), ), diff --git a/frontend/lib/pages/leaderboard.dart b/frontend/lib/pages/leaderboard.dart index 96b2cdf..4c882a8 100644 --- a/frontend/lib/pages/leaderboard.dart +++ b/frontend/lib/pages/leaderboard.dart @@ -3,24 +3,42 @@ import 'package:frontend/provider/quote_provider.dart'; import 'package:frontend/values/constants.dart'; import 'package:provider/provider.dart'; -class LeaderboardPage extends StatelessWidget { +class LeaderboardPage extends StatefulWidget { const LeaderboardPage({super.key}); + @override + State createState() => _LeaderboardPageState(); +} + +class _LeaderboardPageState extends State { + @override + void initState() { + super.initState(); + + // reset filter once, only when page is created + WidgetsBinding.instance.addPostFrameCallback((_) { + final provider = context.read(); + provider.resetLeaderboardFilter(); + }); + } + @override Widget build(BuildContext context) { final provider = context.watch(); + return Center( child: Column( children: [ Expanded( child: ListView.builder( padding: EdgeInsets.all(Constants.edgeInset), - itemCount: provider.filteredQuotes.length, + itemCount: provider.sortedAuthorCounts.length, itemBuilder: (context, index) { return Card( color: provider.getLeaderboardEntryBgColor(context, index), child: ListTile( + onTap: () { provider.showListPageFilteredByAuthor(context, index); }, contentPadding: EdgeInsets.symmetric( vertical: 0, horizontal: Constants.edgeInset, diff --git a/frontend/lib/pages/list.dart b/frontend/lib/pages/list.dart index 8a9ba4c..4698b40 100644 --- a/frontend/lib/pages/list.dart +++ b/frontend/lib/pages/list.dart @@ -8,9 +8,14 @@ import 'package:provider/provider.dart'; import 'package:frontend/values/constants.dart'; -class ListPage extends StatelessWidget { +class ListPage extends StatefulWidget { const ListPage({super.key}); + @override + State createState() => _ListPageState(); +} + +class _ListPageState extends State { @override Widget build(BuildContext context) { final provider = context.watch(); @@ -40,9 +45,7 @@ class ListPage extends StatelessWidget { Center( child: Padding( padding: const EdgeInsets.only( - left: Constants.edgeInset, - right: Constants.edgeInset, - top: Constants.edgeInset + top: Constants.edgeInset*2 ), child: Text( '${Strings.allQuotesCount} – $quoteCount', @@ -56,7 +59,7 @@ class ListPage extends StatelessWidget { filterBar(context, provider, themeProvider), Expanded( child: ListView.builder( - padding: EdgeInsets.all(Constants.edgeInset), + padding: EdgeInsets.all(Constants.edgeInset/2), itemCount: provider.filteredQuotes.length, itemBuilder: (context, index) { final quote = provider.filteredQuotes[index]; @@ -114,7 +117,7 @@ class ListPage extends StatelessWidget { ], ); } - + void _showUpdateDialog(BuildContext context, QuoteProvider provider, Quote quote) { final bodyController = TextEditingController(text: quote.body); final authorController = TextEditingController(text: quote.author); @@ -275,12 +278,15 @@ class ListPage extends StatelessWidget { ListTile( title: Text(Strings.exportSelectedQuotes), trailing: Icon(Icons.import_export), - onTap: () { - provider.exportQuotesToFile(); + onTap: () async { Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( + final scaffoldMessenger = ScaffoldMessenger.of(context); + final success = await provider.exportWithSAF(); + // Make sure the context is still valid + if (!mounted) return; + scaffoldMessenger.showSnackBar( SnackBar( - content: Text(Strings.fileExportedSuccessfully), + content: Text(success ? Strings.fileExportedSuccessfully + provider.exportDir : "export [x]"), duration: const Duration(seconds: 3), ), ); @@ -288,21 +294,22 @@ class ListPage extends StatelessWidget { ), ListTile( title: Text(Strings.userPrefersDarkMode), - onTap: () { themeProvider.setDarkMode(!themeProvider.isDark); }, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.only(right: Constants.edgeInset), - child: Switch( - onChanged: (value) { - themeProvider.setDarkMode(value); - }, - value: themeProvider.isDark + trailing: Consumer( + builder: (context, themeProvider, _) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(right: Constants.edgeInset), + child: Switch( + onChanged: (value) { + themeProvider.setDarkMode(value); + }, + value: themeProvider.isDark, + ), ), - ), - Icon(Icons.dark_mode), - ], + Icon(Icons.dark_mode), + ], + ), ), ), ], diff --git a/frontend/lib/provider/quote_provider.dart b/frontend/lib/provider/quote_provider.dart index f9c35b8..660752d 100644 --- a/frontend/lib/provider/quote_provider.dart +++ b/frontend/lib/provider/quote_provider.dart @@ -1,6 +1,14 @@ // gets all quotes from the database and returns them in a list +import 'package:file_saver/file_saver.dart'; + import 'package:path_provider/path_provider.dart'; +import 'package:flutter/foundation.dart'; // for kIsWeb + +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:go_router/go_router.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:frontend/model/quote.dart'; @@ -64,12 +72,10 @@ class QuoteProvider extends ChangeNotifier { filterController.text = ''; } - Quote randomQuote = Quote( - id: 0, - body: "quote text", - author: "dummy" - ); + late Quote randomQuote; bool randomQuoteDeterminator = true; + + String exportDir = ""; String get randomQuoteText { if (randomQuoteDeterminator) { return randomQuote.body; @@ -84,6 +90,7 @@ class QuoteProvider extends ChangeNotifier { } void nextRandomQuote() { + if (filteredQuotes.isEmpty) return; final random = Random(); randomQuote = filteredQuotes[random.nextInt(filteredQuotes.length)]; notifyListeners(); @@ -111,17 +118,20 @@ class QuoteProvider extends ChangeNotifier { Future readQuotes() async { _loading = true; - _error = null; notifyListeners(); try { _quotes = await repo.readQuotes(); + populateAuthors(); + + if (_quotes.isNotEmpty) { + nextRandomQuote(); + } + } catch (e) { _error = e.toString(); } - populateAuthors(); - _loading = false; notifyListeners(); } @@ -169,30 +179,28 @@ class QuoteProvider extends ChangeNotifier { notifyListeners(); } + Future exportWithSAF() async { + try { + final datetime = DateTime.now().toUtc().toString() + .replaceAll(" ", "T") + .replaceAll(":", "-") + .replaceAll(RegExp(r"\..*"), ""); + final filename = 'hlasky_$datetime.txt'; + final content = filteredQuotes.map((q) => "${q.body} – ${q.author}").join('\n') + '\n'; + final bytes = Uint8List.fromList(utf8.encode(content)); - Future _getSaveDirectory() async { - if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { - final path = Platform.isWindows - ? Platform.environment['USERPROFILE'] - : Platform.environment['HOME']; + // Show folder picker + final folderPath = await FilePicker.platform.getDirectoryPath(); + if (folderPath == null) return false; // user cancelled + exportDir = folderPath; - return Directory(path!); + final file = File('$folderPath/$filename'); + await file.writeAsBytes(bytes); + + return true; // export successful + } catch (e) { + return false; // something went wrong } - - // Mobile fallback - return await getApplicationDocumentsDirectory(); - } - - Future exportQuotesToFile() async { - final dir = await _getSaveDirectory(); - - final datetime = DateTime.now().toUtc().toString() - .replaceAll(" ", "T") - .replaceAll(":", "-") - .replaceAll(RegExp(r"\..*"), ""); - - final file = File('${dir.path}/hlasky_$datetime.txt'); - await file.writeAsString("${filteredQuotes.join("\n")}\n"); } Color getLeaderboardEntryBgColor(BuildContext context, int index) { @@ -218,4 +226,18 @@ class QuoteProvider extends ChangeNotifier { return Theme.of(context).textTheme.bodyMedium!.color!; } } + + void showListPageFilteredByAuthor(BuildContext context, int index) { + filterController.text = sortedAuthorCounts[index].key; + filterBy = 0; // filter by author + // navigate to the main page + GoRouter.of(context).go('/'); + Future.microtask(() => notifyListeners()); + } + + void resetLeaderboardFilter() { + filterController.text = ''; + filterBy = 0; + notifyListeners(); + } } \ No newline at end of file diff --git a/frontend/lib/provider/theme_provider.dart b/frontend/lib/provider/theme_provider.dart index 25e8560..15284f7 100644 --- a/frontend/lib/provider/theme_provider.dart +++ b/frontend/lib/provider/theme_provider.dart @@ -13,22 +13,16 @@ Future loadDarkMode() async { } class ThemeProvider extends ChangeNotifier { - bool _isDark = false; + bool _isDark; bool get isDark => _isDark; - ThemeProvider() { - _load(); // load saved value on startup - } + ThemeProvider({required bool isDark}) : _isDark = isDark; - void _load() async { - _isDark = await loadDarkMode(); - notifyListeners(); - } - - void setDarkMode(bool value) async { + void setDarkMode(bool value) { + if (_isDark == value) return; // avoid unnecessary rebuilds _isDark = value; notifyListeners(); - await saveDarkMode(value); + saveDarkMode(value); } } \ No newline at end of file diff --git a/frontend/lib/values/strings.dart b/frontend/lib/values/strings.dart index c7d8193..ce92e43 100644 --- a/frontend/lib/values/strings.dart +++ b/frontend/lib/values/strings.dart @@ -21,9 +21,9 @@ class Strings { static const String flipFlashcard = "Otočit flashkartu"; static const String nextFlashcard = "Další flashkarta"; - +// //static const String importQuotesFromFile = "Importovat hlášky ze souboru"; - static const String fileExportedSuccessfully = "Soubor byl úspěšně vyexportován."; + static const String fileExportedSuccessfully = "Soubor byl úspěšně vyexportován do: "; static const String noUndo = "Tuto akci nelze odčinit."; static const String filterQuery = "Filtrovací požadavek"; static const String allQuotesCount = "Celkový počet hlášek"; diff --git a/frontend/linux/flutter/generated_plugin_registrant.cc b/frontend/linux/flutter/generated_plugin_registrant.cc index e71a16d..05397a8 100644 --- a/frontend/linux/flutter/generated_plugin_registrant.cc +++ b/frontend/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_saver_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSaverPlugin"); + file_saver_plugin_register_with_registrar(file_saver_registrar); } diff --git a/frontend/linux/flutter/generated_plugins.cmake b/frontend/linux/flutter/generated_plugins.cmake index 2e1de87..9336355 100644 --- a/frontend/linux/flutter/generated_plugins.cmake +++ b/frontend/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_saver ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/frontend/macos/Flutter/GeneratedPluginRegistrant.swift b/frontend/macos/Flutter/GeneratedPluginRegistrant.swift index 724bb2a..93f820f 100644 --- a/frontend/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/frontend/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,8 +5,12 @@ import FlutterMacOS import Foundation +import file_picker +import file_saver import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index ad637dc..7c4ffb7 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -49,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -65,6 +81,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" + dio: + dependency: transitive + description: + name: dio + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.dev" + source: hosted + version: "5.9.2" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.dev" + source: hosted + version: "2.1.2" fake_async: dependency: transitive description: @@ -89,6 +129,22 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343" + url: "https://pub.dev" + source: hosted + version: "10.3.10" + file_saver: + dependency: "direct main" + description: + name: file_saver + sha256: "9d93db09bd4da9e43238f9dd485360fc51a5c138eea5ef5f407ec56e58079ac0" + url: "https://pub.dev" + source: hosted + version: "0.3.1" flutter: dependency: "direct main" description: flutter @@ -102,6 +158,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" flutter_test: dependency: "direct dev" description: flutter @@ -216,6 +280,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" native_toolchain_c: dependency: transitive description: @@ -296,6 +368,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -469,6 +549,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: @@ -477,6 +565,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" yaml: dependency: transitive description: diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index ece9de9..39dc833 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -39,6 +39,8 @@ dependencies: http: ^1.2.0 path_provider: shared_preferences: ^2.1.1 + file_picker: ^10.3.10 + file_saver: ^0.3.1 dev_dependencies: flutter_test: diff --git a/frontend/windows/flutter/generated_plugin_registrant.cc b/frontend/windows/flutter/generated_plugin_registrant.cc index 8b6d468..8cce40f 100644 --- a/frontend/windows/flutter/generated_plugin_registrant.cc +++ b/frontend/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSaverPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSaverPlugin")); } diff --git a/frontend/windows/flutter/generated_plugins.cmake b/frontend/windows/flutter/generated_plugins.cmake index b93c4c3..cd9db6c 100644 --- a/frontend/windows/flutter/generated_plugins.cmake +++ b/frontend/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_saver ) list(APPEND FLUTTER_FFI_PLUGIN_LIST