diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index bb57294..5fda8f9 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -1,9 +1,13 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; import 'strings.dart'; import 'main_scaffold.dart'; +// providers +import 'provider/quote_provider.dart'; + // pages import 'pages/list.dart'; import 'pages/add.dart'; @@ -12,7 +16,12 @@ import 'pages/leaderboard.dart'; import 'pages/settings.dart'; void main() { - runApp(HlaskovnikFrontendApp()); + runApp( + ChangeNotifierProvider( + create: (_) => QuoteProvider(), + child: HlaskovnikFrontendApp(), + ), + ); } class HlaskovnikFrontendApp extends StatelessWidget { @@ -39,6 +48,21 @@ class HlaskovnikFrontendApp extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp.router( title: Strings.appName, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.red, + brightness: Brightness.light, + ), + useMaterial3: true, + ), + darkTheme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.red, + brightness: Brightness.dark, + ), + useMaterial3: true, + ), + themeMode: ThemeMode.system, routerConfig: _router, ); } diff --git a/frontend/lib/model/quote.dart b/frontend/lib/model/quote.dart index b5aadf7..f50c252 100644 --- a/frontend/lib/model/quote.dart +++ b/frontend/lib/model/quote.dart @@ -1,13 +1,21 @@ // quote model class class Quote{ - final int index; + final int id; final String author; final String body; const Quote({ - required this.index, + required this.id, required this.author, required this.body, }); + + factory Quote.fromJson(Map json) { + return Quote( + id: json['id'], + author: json['author'], + body: json['body'], + ); + } } \ No newline at end of file diff --git a/frontend/lib/pages/add.dart b/frontend/lib/pages/add.dart index 84e3853..cf6266a 100644 --- a/frontend/lib/pages/add.dart +++ b/frontend/lib/pages/add.dart @@ -1,10 +1,87 @@ import 'package:flutter/material.dart'; +import 'package:frontend/provider/quote_provider.dart'; +import 'package:frontend/model/quote.dart'; + +import 'package:frontend/strings.dart'; class AddPage extends StatelessWidget { const AddPage({super.key}); @override Widget build(BuildContext context) { - return const Center(child: Text('add page!')); + final bodyController = TextEditingController(text: ''); + final authorController = TextEditingController(text: ''); + final provider = QuoteProvider(); + return Card( + margin: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + Strings.createQuote, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + + const SizedBox(height: 12), + + TextField( + autofocus: true, + controller: bodyController, + decoration: const InputDecoration( + labelText: Strings.quote, + ), + ), + + const SizedBox(height: 8), + + TextField( + controller: authorController, + decoration: const InputDecoration( + labelText: Strings.author, + ), + ), + + const SizedBox(height: 16), + + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () { + bodyController.clear(); + authorController.clear(); + }, + child: const Text(Strings.cancel), + ), + + const SizedBox(width: 8), + + ElevatedButton( + onPressed: () { + final quote = Quote( + id: 0, + body: bodyController.text, + author: authorController.text, + ); + + provider.createQuote(quote); + + bodyController.clear(); + authorController.clear(); + }, + child: const Text(Strings.save), + ), + ], + ), + ], + ), + ), + ); } } \ No newline at end of file diff --git a/frontend/lib/pages/list.dart b/frontend/lib/pages/list.dart index 3c7c344..ef59e22 100644 --- a/frontend/lib/pages/list.dart +++ b/frontend/lib/pages/list.dart @@ -1,10 +1,153 @@ import 'package:flutter/material.dart'; +import 'package:frontend/provider/quote_provider.dart'; +import 'package:frontend/model/quote.dart'; +import 'package:frontend/strings.dart'; +import 'package:provider/provider.dart'; + class ListPage extends StatelessWidget { const ListPage({super.key}); @override Widget build(BuildContext context) { - return Center(child: const Text('list page!')); + final provider = context.watch(); + return RefreshIndicator( + onRefresh: () async { + await provider.readQuotes(); + }, + child: _bodyBuilder(provider), + ); + } + + Widget _bodyBuilder(QuoteProvider provider) { + if (provider.loading) { + return Center(child: CircularProgressIndicator()); + } + + if (provider.error != null) { + return Center(child: Text(provider.error!)); + } + + return + ListView.builder( + itemCount: provider.quotes.length, + itemBuilder: (context, index) { + final quote = provider.quotes[index]; + + return + ListTile( + title: Text(quote.body), + subtitle: Text("– ${quote.author}"), + trailing: PopupMenuButton( + icon: const Icon(Icons.more_vert), + + onSelected: (value) { + if (value == 'update') { + // open a dialog window that allows for mods + _showUpdateDialog(context, provider, quote); + } else if (value == 'delete') { + // open a confirmation dialog window for deletion + _showDeleteDialog(context, provider, quote); + } + }, + + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'update', + child: Row( + children: [ + Icon(Icons.edit), + SizedBox(width: 10), + Text(Strings.updateQuote), + ], + ), + ), + + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete), + SizedBox(width: 10), + Text(Strings.deleteQuote), + ], + ), + ), + ], + ) + ); + }, + ); + } + + void _showUpdateDialog(BuildContext context, QuoteProvider provider, Quote quote) { + final bodyController = TextEditingController(text: quote.body); + final authorController = TextEditingController(text: quote.author); + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(Strings.updateQuote), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + autofocus: true, + controller: bodyController, + ), + TextField( + controller: authorController, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text(Strings.cancel), + ), + ElevatedButton( + onPressed: () { + final updatedQuote = Quote( + id: quote.id, + body: bodyController.text, + author: authorController.text, + ); + Navigator.pop(context); + provider.updateQuote(updatedQuote); + }, + child: const Text(Strings.save), + ), + ], + ); + }, + ); + } + + void _showDeleteDialog(BuildContext context, QuoteProvider provider, Quote quote) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('${Strings.deleteQuote}?'), + content: const Text(Strings.noUndo), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text(Strings.cancel), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + ), + onPressed: () { + Navigator.pop(context); + provider.deleteQuote(quote); + }, + child: const Text(Strings.delete), + ), + ], + ); + }, + ); } } \ No newline at end of file diff --git a/frontend/lib/provider/quote_provider.dart b/frontend/lib/provider/quote_provider.dart new file mode 100644 index 0000000..342844d --- /dev/null +++ b/frontend/lib/provider/quote_provider.dart @@ -0,0 +1,69 @@ +// gets all quotes from the database and returns them in a list + +import 'package:flutter/material.dart'; + +import 'package:frontend/model/quote.dart'; +import 'package:frontend/repository/quote_repository.dart'; + +class QuoteProvider extends ChangeNotifier { + List _quotes = []; + bool _loading = false; + String? _error; + + List get quotes => _quotes; + bool get loading => _loading; + String? get error => _error; + + final QuoteRepository repo = QuoteRepository(); + + QuoteProvider() { + readQuotes(); // auto-load on creation + } + + Future readQuotes() async { + _loading = true; + _error = null; + notifyListeners(); + + try { + _quotes = await repo.readQuotes(); + } catch (e) { + _error = e.toString(); + } + + _loading = false; + notifyListeners(); + } + + Future createQuote(Quote quote) async { + final newQuote = await repo.createQuote(quote); + _quotes.add(newQuote); + notifyListeners(); + } + + Future updateQuote(Quote quote) async { + try { + _loading = true; + notifyListeners(); + + final updated = await repo.updateQuote(quote); + + final index = _quotes.indexWhere((q) => q.id == quote.id); + if (index != -1) { + final newList = List.from(_quotes); + newList[index] = updated; + _quotes = newList; + } + } finally { + _loading = false; + notifyListeners(); + } + } + + Future deleteQuote(Quote quote) async { + await repo.deleteQuote(quote); + + _quotes.removeWhere((q) => q.id == quote.id); + notifyListeners(); + } +} \ No newline at end of file diff --git a/frontend/lib/repository/quote_repository.dart b/frontend/lib/repository/quote_repository.dart index e062da7..4440277 100644 --- a/frontend/lib/repository/quote_repository.dart +++ b/frontend/lib/repository/quote_repository.dart @@ -1,7 +1,76 @@ // talks to the DB itself -// TODO: mock for now! +// TODO: implement the real one + +import 'dart:convert'; +import 'package:http/http.dart' as http; + +import 'package:frontend/model/quote.dart'; class QuoteRepository { - final String _dbBaseUrl = 'https://hlaskovnik-reimagined-latest.onrender.com'; - + static final String _apiBaseUrl = 'https://hlaskovnik-reimagined-latest.onrender.com'; + static final String _apiKey = "2ogD5kc4sw3JVZoouQSV0w3LGRjSj5IjVFnAnk7IRCIK0ktONCeoOYinbNLSIHuU"; + static final String _quotesEndpoint = '/quotes'; + + Future> readQuotes() async { + final url = Uri.parse(_apiBaseUrl+_quotesEndpoint); + final headers = { + 'Authorization' : 'Bearer $_apiKey', + 'Content-Type': 'application/json', + }; + final response = await http.get(url, headers: headers); + if (response.statusCode == 200) { + final List data = jsonDecode(response.body); + return data.map((json) => Quote.fromJson(json)).toList(); + } else { + throw Exception('failed to read quotes from database'); + } + } + + Future createQuote(Quote quote) async { + final url = Uri.parse(_apiBaseUrl+_quotesEndpoint); + final body = jsonEncode({'author' : quote.author, 'body' : quote.body}); + final headers = { + 'Authorization' : 'Bearer $_apiKey', + 'Content-Type': 'application/json', + }; + final response = await http.post(url, body: body, headers: headers); + if (response.statusCode == 200 || response.statusCode == 201) { + return Quote.fromJson(jsonDecode(response.body)); + } else { + throw Exception('failed to add quote to database'); + } + } + + Future updateQuote(Quote quote) async { + final url = Uri.parse('$_apiBaseUrl$_quotesEndpoint/${quote.id}'); + final body = jsonEncode({'body' : quote.body, 'author' : quote.author}); + final headers = { + 'Authorization' : 'Bearer $_apiKey', + 'Content-Type': 'application/json', + }; + final response = await http.put(url, body: body, headers: headers); + + if (response.statusCode == 200) { + return Quote.fromJson(jsonDecode(response.body)); + } + + if (response.statusCode == 204) { + return quote; + } + + throw Exception('failed to update quote in database'); + } + + Future deleteQuote(Quote quote) async { + final url = Uri.parse('$_apiBaseUrl$_quotesEndpoint/${quote.id}'); + final headers = { + 'Authorization' : 'Bearer $_apiKey', + 'Content-Type': 'application/json', + }; + final response = await http.delete(url, headers: headers); + if (response.statusCode == 200 || response.statusCode == 204) { + return; + } + throw Exception('failed to delete quote from database'); + } } \ No newline at end of file diff --git a/frontend/lib/repository/quote_repository_mock.dart b/frontend/lib/repository/quote_repository_mock.dart new file mode 100644 index 0000000..0b5ad98 --- /dev/null +++ b/frontend/lib/repository/quote_repository_mock.dart @@ -0,0 +1,16 @@ +// talks to the DB itself +// TODO: mock for now! + +import 'package:frontend/model/quote.dart'; + +class QuoteRepository { + //final String _dbBaseUrl = 'https://hlaskovnik-reimagined-latest.onrender.com'; + + Future> getQuotes() async { + return [ + Quote(id: 1, author: 'Adam', body: 'kratom je vule bozi'), + Quote(id: 2, author: 'Vojta', body: 'kalach s kecupem'), + Quote(id: 3, author: 'Anton', body: 'have you ever noticed how long cows are'), + ]; + } +} \ No newline at end of file diff --git a/frontend/lib/service/get_quotes.dart b/frontend/lib/service/get_quotes.dart deleted file mode 100644 index c2c5f61..0000000 --- a/frontend/lib/service/get_quotes.dart +++ /dev/null @@ -1 +0,0 @@ -// gets all quotes from the database and returns them in a list \ No newline at end of file diff --git a/frontend/lib/strings.dart b/frontend/lib/strings.dart index ed63582..abf1ed8 100644 --- a/frontend/lib/strings.dart +++ b/frontend/lib/strings.dart @@ -1,10 +1,23 @@ class Strings { static const String appName = "Nový Hláškovník"; - static const String apiKey = "2ogD5kc4sw3JVZoouQSV0w3LGRjSj5IjVFnAnk7IRCIK0ktONCeoOYinbNLSIHuU"; static const String list = "Seznam"; static const String add = "Přidat"; static const String flashcards = "Flashkarty"; static const String leaderboard = "Žebříčky"; static const String settings = "Nastavení"; + + static const String createQuote = "Přidat hlášku"; + static const String updateQuote = "Upravit hlášku"; + static const String deleteQuote = "Smazat hlášku"; + + static const String update = "Upravit"; + static const String cancel = "Zrušit"; + static const String delete = "Smazat"; + static const String save = "Uložit"; + + static const String quote = "Hláška"; + static const String author = "Autor"; + + static const String noUndo = "Tuto akci nelze odčinit."; } \ No newline at end of file diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index d11c78d..8c4f5b3 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -88,6 +88,22 @@ packages: url: "https://pub.dev" source: hosted version: "17.1.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" leak_tracker: dependency: transitive description: @@ -229,6 +245,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.10" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: @@ -245,6 +269,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" sdks: dart: ">=3.11.3 <4.0.0" flutter: ">=3.35.0" diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 132d0c8..924c57f 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -1,5 +1,5 @@ name: frontend -description: "A new Flutter project." +description: "A frontend for the Hlaskovnik Reimagined project." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev @@ -36,6 +36,7 @@ dependencies: cupertino_icons: ^1.0.8 provider: go_router: + http: ^1.2.0 dev_dependencies: flutter_test: