From ab33ec62b3ef435bd571be8dfee44733b2c89fa0 Mon Sep 17 00:00:00 2001 From: Ryan Cartwright Date: Thu, 22 Aug 2024 15:45:25 +1000 Subject: [PATCH 1/4] flutter word generator --- v1/flutter/.gitignore | 43 +++ v1/flutter/.metadata | 45 +++ v1/flutter/README.md | 16 + v1/flutter/analysis_options.yaml | 28 ++ v1/flutter/lib/cors.dart | 23 ++ v1/flutter/lib/favourite.dart | 13 + v1/flutter/lib/main.dart | 29 ++ v1/flutter/lib/pages/favourites.dart | 38 +++ v1/flutter/lib/pages/generator.dart | 147 +++++++++ v1/flutter/lib/pages/home.dart | 107 +++++++ v1/flutter/lib/providers/favourites.dart | 78 +++++ v1/flutter/lib/providers/word.dart | 25 ++ v1/flutter/lib/services/main.dart | 65 ++++ v1/flutter/nitric-spec.json | 61 ++++ v1/flutter/nitric.dev.yaml | 68 +++++ v1/flutter/nitric.yaml | 9 + v1/flutter/pubspec.lock | 373 +++++++++++++++++++++++ v1/flutter/pubspec.yaml | 92 ++++++ 18 files changed, 1260 insertions(+) create mode 100644 v1/flutter/.gitignore create mode 100644 v1/flutter/.metadata create mode 100644 v1/flutter/README.md create mode 100644 v1/flutter/analysis_options.yaml create mode 100644 v1/flutter/lib/cors.dart create mode 100644 v1/flutter/lib/favourite.dart create mode 100644 v1/flutter/lib/main.dart create mode 100644 v1/flutter/lib/pages/favourites.dart create mode 100644 v1/flutter/lib/pages/generator.dart create mode 100644 v1/flutter/lib/pages/home.dart create mode 100644 v1/flutter/lib/providers/favourites.dart create mode 100644 v1/flutter/lib/providers/word.dart create mode 100644 v1/flutter/lib/services/main.dart create mode 100644 v1/flutter/nitric-spec.json create mode 100755 v1/flutter/nitric.dev.yaml create mode 100644 v1/flutter/nitric.yaml create mode 100644 v1/flutter/pubspec.lock create mode 100644 v1/flutter/pubspec.yaml diff --git a/v1/flutter/.gitignore b/v1/flutter/.gitignore new file mode 100644 index 0000000..29a3a50 --- /dev/null +++ b/v1/flutter/.gitignore @@ -0,0 +1,43 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/v1/flutter/.metadata b/v1/flutter/.metadata new file mode 100644 index 0000000..9d32c61 --- /dev/null +++ b/v1/flutter/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "b0850beeb25f6d5b10426284f506557f66181b36" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: b0850beeb25f6d5b10426284f506557f66181b36 + base_revision: b0850beeb25f6d5b10426284f506557f66181b36 + - platform: android + create_revision: b0850beeb25f6d5b10426284f506557f66181b36 + base_revision: b0850beeb25f6d5b10426284f506557f66181b36 + - platform: ios + create_revision: b0850beeb25f6d5b10426284f506557f66181b36 + base_revision: b0850beeb25f6d5b10426284f506557f66181b36 + - platform: linux + create_revision: b0850beeb25f6d5b10426284f506557f66181b36 + base_revision: b0850beeb25f6d5b10426284f506557f66181b36 + - platform: macos + create_revision: b0850beeb25f6d5b10426284f506557f66181b36 + base_revision: b0850beeb25f6d5b10426284f506557f66181b36 + - platform: web + create_revision: b0850beeb25f6d5b10426284f506557f66181b36 + base_revision: b0850beeb25f6d5b10426284f506557f66181b36 + - platform: windows + create_revision: b0850beeb25f6d5b10426284f506557f66181b36 + base_revision: b0850beeb25f6d5b10426284f506557f66181b36 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/v1/flutter/README.md b/v1/flutter/README.md new file mode 100644 index 0000000..7d9dc9d --- /dev/null +++ b/v1/flutter/README.md @@ -0,0 +1,16 @@ +# word_generator + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/v1/flutter/analysis_options.yaml b/v1/flutter/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/v1/flutter/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/v1/flutter/lib/cors.dart b/v1/flutter/lib/cors.dart new file mode 100644 index 0000000..638744b --- /dev/null +++ b/v1/flutter/lib/cors.dart @@ -0,0 +1,23 @@ +import 'package:nitric_sdk/nitric.dart'; + +/// Handle Preflight Options requests by returning status 200 to the requests +Future optionsHandler(HttpContext ctx) async { + ctx.res.headers["Content-Type"] = ["text/html; charset=ascii"]; + ctx.res.body = "OK"; + + return ctx.next(); +} + +/// Add CORS headers to responses +Future addCors(HttpContext ctx) async { + ctx.res.headers["Access-Control-Allow-Origin"] = ["*"]; + ctx.res.headers["Access-Control-Allow-Headers"] = [ + "Origin, X-Requested-With, Content-Type, Accept, Authorization", + ]; + ctx.res.headers["Access-Control-Allow-Methods"] = [ + "GET, PUT, POST, PATCH, OPTIONS, DELETE", + ]; + ctx.res.headers["Access-Control-Max-Age"] = ["7200"]; + + return ctx.next(); +} diff --git a/v1/flutter/lib/favourite.dart b/v1/flutter/lib/favourite.dart new file mode 100644 index 0000000..2772ea3 --- /dev/null +++ b/v1/flutter/lib/favourite.dart @@ -0,0 +1,13 @@ +class Favourite { + /// The name of the favourite + String name; + + Favourite(this.name); + + /// Convert a json decodable map to a Favourite object + Favourite.fromJson(Map json) : name = json['name']; + + /// Convert a Favourite object to a json encodable + static Map toJson(Favourite favourite) => + {'name': favourite.name}; +} diff --git a/v1/flutter/lib/main.dart b/v1/flutter/lib/main.dart new file mode 100644 index 0000000..842a13d --- /dev/null +++ b/v1/flutter/lib/main.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:word_generator/pages/home.dart'; +import 'package:word_generator/providers/favourites.dart'; +import 'package:word_generator/providers/word.dart'; + +void main() => runApp(Application()); + +class Application extends StatelessWidget { + const Application({super.key}); + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (context) => FavouritesProvider()), + ChangeNotifierProvider(create: (context) => WordProvider()), + ], + child: MaterialApp( + title: 'Word Generator App', + theme: ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), + ), + home: HomePage(), + ), + ); + } +} diff --git a/v1/flutter/lib/pages/favourites.dart b/v1/flutter/lib/pages/favourites.dart new file mode 100644 index 0000000..03cf0a3 --- /dev/null +++ b/v1/flutter/lib/pages/favourites.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:word_generator/providers/favourites.dart'; + +class FavouritesPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + var favourites = context.watch(); + + // If the favourites list is still loading then show a spinning circle. + if (favourites.isLoading) { + return Center( + child: SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator(color: Colors.blue), + )); + } + + // Otherwise return a list of all the favourites + return ListView( + children: [ + Padding( + padding: const EdgeInsets.all(20), + // Display how many favourites there are + child: Text('You have ' + '${favourites.favourites.length} favourites:'), + ), + // Create a list tile for every favourite in the list of favourites + for (var favourite in favourites.favourites) + ListTile( + leading: Icon(Icons.favorite), // <- A heart icon + title: Text(favourite.name), + ), + ], + ); + } +} diff --git a/v1/flutter/lib/pages/generator.dart b/v1/flutter/lib/pages/generator.dart new file mode 100644 index 0000000..1cc4153 --- /dev/null +++ b/v1/flutter/lib/pages/generator.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:word_generator/providers/favourites.dart'; +import 'package:word_generator/providers/word.dart'; + +class GeneratorPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final style = theme.textTheme.displayMedium!.copyWith( + color: theme.colorScheme.onPrimary, + ); + + final favourites = context.watch(); + final words = context.watch(); + + IconData icon = Icons.favorite_border; + + if (favourites.hasFavourite(words.current)) { + icon = Icons.favorite; + } + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + // <- allows the list to extend to the top of the page + flex: 3, + child: HistoryListView(), // <- Add the history list view here + ), + SizedBox(height: 10), + Card( + color: theme.colorScheme.primary, + child: Padding( + padding: const EdgeInsets.all(20), + child: AnimatedSize( + duration: Duration(milliseconds: 200), + child: MergeSemantics( + child: Wrap( + children: [ + Text( + words.current.first, + style: style.copyWith(fontWeight: FontWeight.w200), + ), + Text( + words.current.second, + style: style.copyWith(fontWeight: FontWeight.bold), + ) + ], + ), + ), + ), + ), + ), + SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + ElevatedButton.icon( + onPressed: () { + favourites.toggleFavourite(words.current); + }, + icon: Icon(icon), + label: Text('Like'), + ), + SizedBox(width: 10), + ElevatedButton( + onPressed: () { + words.getNext(); + }, + child: Text('Next'), + ), + ], + ), + Spacer(flex: 2), + ], + ), + ); + } +} + +class HistoryListView extends StatefulWidget { + const HistoryListView({super.key}); + + @override + State createState() => _HistoryListViewState(); +} + +class _HistoryListViewState extends State { + final _key = GlobalKey(); + + // Create a mask which will make a fade out appearance by making a linear gradient of transparent -> opaque. + static const Gradient _maskingGradient = LinearGradient( + colors: [Colors.transparent, Colors.black], + stops: [0.0, 0.5], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ); + + @override + Widget build(BuildContext context) { + final favourites = context.watch(); + final words = context.watch(); + + // Set the key of the animated list to the WordProvider GlobalKey so it can be manipulated from there + // Not recommended for a production app as it can slow performance... + // Read more here: https://api.flutter.dev/flutter/widgets/GlobalKey-class.html + words.historyListKey = _key; + + return ShaderMask( + shaderCallback: (bounds) => _maskingGradient.createShader(bounds), + // This blend mode takes the opacity of the shader (i.e. our gradient) + // and applies it to the destination (i.e. our animated list). + blendMode: BlendMode.dstIn, + child: AnimatedList( + key: _key, + // Reverse the list so the latest is on the bottom + reverse: true, + padding: EdgeInsets.only(top: 100), + initialItemCount: words.history.length, + // Build each item in the list, will be run initially and when a new word pair is added. + itemBuilder: (context, index, animation) { + final pair = words.history[index]; + return SizeTransition( + sizeFactor: animation, + child: Center( + child: TextButton.icon( + onPressed: () { + favourites.toggleFavourite(pair); + }, + // If the word pair was favourited, show a heart next to it + icon: favourites.hasFavourite(pair) + ? Icon(Icons.favorite, size: 12) + : SizedBox(), + label: Text( + pair.asLowerCase, + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/v1/flutter/lib/pages/home.dart b/v1/flutter/lib/pages/home.dart new file mode 100644 index 0000000..2715958 --- /dev/null +++ b/v1/flutter/lib/pages/home.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:word_generator/providers/favourites.dart'; + +import 'favourites.dart'; +import 'generator.dart'; + +class HomePage extends StatefulWidget { + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + var selectedIndex = 0; + + @override + void initState() { + super.initState(); + context.read().fetchData(); + } + + @override + Widget build(BuildContext context) { + var colorScheme = Theme.of(context).colorScheme; + + Widget page; + switch (selectedIndex) { + case 0: + page = GeneratorPage(); + case 1: + page = FavouritesPage(); + default: + throw UnimplementedError('no widget for $selectedIndex'); + } + + // The container for the current page, with its background color + // and subtle switching animation. + var mainArea = ColoredBox( + color: colorScheme.surfaceContainerHighest, + child: AnimatedSwitcher( + duration: Duration(milliseconds: 200), + child: page, + ), + ); + + return Scaffold( + body: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 450) { + return Column( + children: [ + Expanded(child: mainArea), + SafeArea( + child: BottomNavigationBar( + items: [ + BottomNavigationBarItem( + icon: Icon(Icons.home), + label: 'Home', + ), + BottomNavigationBarItem( + icon: Icon(Icons.favorite), + label: 'Favorites', + ), + ], + currentIndex: selectedIndex, + onTap: (value) { + setState(() { + selectedIndex = value; + }); + }, + ), + ) + ], + ); + } else { + return Row( + children: [ + SafeArea( + child: NavigationRail( + extended: constraints.maxWidth >= 600, + destinations: [ + NavigationRailDestination( + icon: Icon(Icons.home), + label: Text('Home'), + ), + NavigationRailDestination( + icon: Icon(Icons.favorite), + label: Text('Favorites'), + ), + ], + selectedIndex: selectedIndex, + onDestinationSelected: (value) { + setState(() { + selectedIndex = value; + }); + }, + ), + ), + Expanded(child: mainArea), + ], + ); + } + }, + ), + ); + } +} diff --git a/v1/flutter/lib/providers/favourites.dart b/v1/flutter/lib/providers/favourites.dart new file mode 100644 index 0000000..274ebe6 --- /dev/null +++ b/v1/flutter/lib/providers/favourites.dart @@ -0,0 +1,78 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:english_words/english_words.dart'; +import 'package:word_generator/favourite.dart'; + +class FavouritesProvider extends ChangeNotifier { + final baseApiUrl = "http://localhost:4001"; + + List _favourites = []; + bool _isLoading = false; + + /// Get a list of active favourites + List get favourites => _favourites; + + /// Check whether the data is loading or not + bool get isLoading => _isLoading; + + /// Updates the list of favourites whilst returning a Future with the list of favourites. + /// Sets isLoading to true when the favourites have been fetched + Future> fetchData() async { + _isLoading = true; + notifyListeners(); + + final response = await http.get(Uri.parse("$baseApiUrl/favourites")); + + if (response.statusCode == 200) { + // Decode the json data into an iterable list of unknown objects + Iterable rawFavourites = jsonDecode(response.body); + + // Map over the iterable, converting it to a list of Favourite objects + _favourites = List.from( + rawFavourites.map((model) => Favourite.fromJson(model))); + } else { + throw Exception('Failed to load data'); + } + + _isLoading = false; + notifyListeners(); + + return _favourites; + } + + bool hasFavourite(WordPair pair) { + if (isLoading) { + return false; + } + + return _favourites.any((f) => f.name == pair.asLowerCase); + } + + /// Toggles whether a favourite being liked or unliked. + Future toggleFavourite(WordPair pair) async { + // Convert the word pair into a json encoded + final encodedFavourites = + jsonEncode(Favourite.toJson(Favourite(pair.asLowerCase))); + + // Makes a post request to the toggle favourite route. + final response = await http.post(Uri.parse("$baseApiUrl/favourite"), + body: encodedFavourites); + + // If the response doesn't respond with OK, throw an error + if (response.statusCode != 200) { + throw Exception("Failed to add favourite: ${response.body}"); + } + + // If it was successfully removed update favourites + if (hasFavourite(pair)) { + // Remove the favourite for + _favourites.removeWhere((f) => f.name == pair.asLowerCase); + } else { + _favourites.add(Favourite(pair.asLowerCase)); + } + + notifyListeners(); + } +} diff --git a/v1/flutter/lib/providers/word.dart b/v1/flutter/lib/providers/word.dart new file mode 100644 index 0000000..e542235 --- /dev/null +++ b/v1/flutter/lib/providers/word.dart @@ -0,0 +1,25 @@ +import 'package:english_words/english_words.dart'; +import 'package:flutter/material.dart'; + +class WordProvider extends ChangeNotifier { + // The current word pair + var current = WordPair.random(); + // A list of all generated word pairs + var history = []; + + // A key that is used to get a reference to the history list state + GlobalKey? historyListKey; + + // Generate a new word pair and notify the listeners + void getNext() { + // Add the current pair to the start of the history list + history.insert(0, current); + + // Adds space to the start of the animated list and triggers an animation to start + var animatedList = historyListKey?.currentState as AnimatedListState?; + animatedList?.insertItem(0); + + current = WordPair.random(); + notifyListeners(); + } +} diff --git a/v1/flutter/lib/services/main.dart b/v1/flutter/lib/services/main.dart new file mode 100644 index 0000000..b8c3f85 --- /dev/null +++ b/v1/flutter/lib/services/main.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; + +import 'package:nitric_sdk/nitric.dart'; +import 'package:word_generator/cors.dart'; +import 'package:word_generator/favourite.dart'; + +void main() { + final api = Nitric.api("main", opts: ApiOptions(middlewares: [addCors])); + + final favouritesKV = Nitric.kv("favourites").allow([ + KeyValueStorePermission.get, + KeyValueStorePermission.set, + KeyValueStorePermission.delete + ]); + + api.options("/favourites", optionsHandler); + api.options("/favourite", optionsHandler); + + api.get("/favourites", (ctx) async { + // Get a list of all the keys in the store + var keyStream = await favouritesKV.keys(); + + // Convert the keys to a list of favourites + var favourites = await keyStream.asyncMap((k) async { + final favourite = await favouritesKV.get(k); + + return favourite; + }).toList(); + + // Return the body as a list of favourites + ctx.res.body = jsonEncode(favourites); + + return ctx; + }); + + api.post("/favourite", (ctx) async { + final req = ctx.req.json(); + + // convert the request json to a Favourite object + final favourite = Favourite.fromJson(req); + + // search for the key, filtering by the name of the favourite + final stream = await favouritesKV.keys(prefix: favourite.name); + + // checks if the favourite exists in the list of keys + final exists = await stream.any((f) => f == favourite.name); + + // if it exists delete and return + if (exists) { + await favouritesKV.delete(favourite.name); + + return ctx; + } + + // if it doesn't exist, create it + try { + await favouritesKV.set(favourite.name, Favourite.toJson(favourite)); + } catch (e) { + ctx.res.status = 500; + ctx.res.body = "could not set ${favourite.name}"; + } + + return ctx; + }); +} diff --git a/v1/flutter/nitric-spec.json b/v1/flutter/nitric-spec.json new file mode 100644 index 0000000..42da14a --- /dev/null +++ b/v1/flutter/nitric-spec.json @@ -0,0 +1,61 @@ +{ + "resources": [ + { + "id": { + "name": "main" + }, + "api": { + "openapi": "{\"components\":{},\"info\":{\"title\":\"main\",\"version\":\"v1\"},\"openapi\":\"3.0.1\",\"paths\":{\"/favourite\":{\"options\":{\"operationId\":\"favouriteoptions\",\"responses\":{\"default\":{\"description\":\"\"}},\"security\":[],\"x-nitric-target\":{\"name\":\"backend_lib-services-main\",\"type\":\"function\"}},\"post\":{\"operationId\":\"favouritepost\",\"responses\":{\"default\":{\"description\":\"\"}},\"security\":[],\"x-nitric-target\":{\"name\":\"backend_lib-services-main\",\"type\":\"function\"}}},\"/favourites\":{\"get\":{\"operationId\":\"favouritesget\",\"responses\":{\"default\":{\"description\":\"\"}},\"security\":[],\"x-nitric-target\":{\"name\":\"backend_lib-services-main\",\"type\":\"function\"}},\"options\":{\"operationId\":\"favouritesoptions\",\"responses\":{\"default\":{\"description\":\"\"}},\"security\":[],\"x-nitric-target\":{\"name\":\"backend_lib-services-main\",\"type\":\"function\"}}}}}" + } + }, + { + "id": { + "type": "KeyValueStore", + "name": "favourites" + }, + "keyValueStore": {} + }, + { + "id": { + "type": "Policy", + "name": "4e19479ef94af74f894d83df7315494c" + }, + "policy": { + "principals": [ + { + "id": { + "type": "Service", + "name": "backend_lib-services-main" + } + } + ], + "actions": [ + "KeyValueStoreRead", + "KeyValueStoreWrite", + "KeyValueStoreDelete" + ], + "resources": [ + { + "id": { + "type": "KeyValueStore", + "name": "favourites" + } + } + ] + } + }, + { + "id": { + "type": "Service", + "name": "backend_lib-services-main" + }, + "service": { + "image": { + "uri": "backend_lib-services-main" + }, + "workers": 1, + "type": "default" + } + } + ] +} \ No newline at end of file diff --git a/v1/flutter/nitric.dev.yaml b/v1/flutter/nitric.dev.yaml new file mode 100755 index 0000000..cbae95e --- /dev/null +++ b/v1/flutter/nitric.dev.yaml @@ -0,0 +1,68 @@ +# The nitric provider to use +provider: nitric/aws@1.11.1 +# The target aws region to deploy to +# See available regions: +# https://docs.aws.amazon.com/general/latest/gr/lambda-service.html +region: us-east-1 +# Optional Configuration Below + +# The timezone that deployed schedules will run with +# Format is in tz identifiers: +# https://en.wikipedia.org/wiki/List_of_tz_database_time_zones +# schedule-timezone: Australia/Sydney # Available since v0.27.0 + +# Import existing AWS Resources +# Currently only secrets are supported +# Available since v0.28.0 +# import: +# # A name ARN map of secrets, where the name matches the nitric name of the secret you would like to import +# secrets: # Available since v0.28.0 +# # In typescript this would import the provided secret reference for a secret declared as +# # const mySecret = secret('my-secret'); +# my-secret: arn:... + +# # Apply configuration to nitric APIs +# apis: +# # The nitric name of the API to configure +# my-api: +# # Array of domains to apply to the API +# # The domain or parent domain must have a hosted zone already in Route53 +# domains: +# - api.example.com + +# # Configure your deployed functions/services +# config: +# # How functions without a type will be deployed +# default: +# # configure a sample rate for telemetry (between 0 and 1) e.g. 0.5 is 50% +# telemetry: 0 +# # configure functions to deploy to AWS lambda +# lambda: # Available since v0.26.0 +# # set 128MB of RAM +# # See lambda configuration docs here: +# # https://docs.aws.amazon.com/lambda/latest/dg/configuration-function-common.html#configuration-memory-console +# memory: 128 +# # set a timeout of 15 seconds +# # See lambda timeout values here: +# # https://docs.aws.amazon.com/lambda/latest/dg/configuration-function-common.html#configuration-timeout-console +# timeout: 15 +# # set a provisioned concurrency value +# # For info on provisioned concurrency for AWS Lambda see: +# # https://docs.aws.amazon.com/lambda/latest/dg/configuration-concurrency.html +# provisioned-concurrency: 0 +# # Configure VPCs that the lambda can access +# vpc: +# # Array of existing security group ids to apply +# security-group-ids: +# - sg-xxx +# # Array of existing subnet ids to apply +# subnet-ids: +# - subnet-xxx +# # Additional deployment types +# # You can target these types by setting a `type` in your project configuration +# big-service: +# telemetry: 0 +# lambda: +# memory: 1024 +# timeout: 60 +# provisioned-concurrency: 1 diff --git a/v1/flutter/nitric.yaml b/v1/flutter/nitric.yaml new file mode 100644 index 0000000..64ea54e --- /dev/null +++ b/v1/flutter/nitric.yaml @@ -0,0 +1,9 @@ +name: backend +services: + - match: lib/services/*.dart + runtime: flutter + start: dart run --observe $SERVICE_PATH +runtimes: + flutter: + dockerfile: ./docker/flutter.dockerfile + args: {} diff --git a/v1/flutter/pubspec.lock b/v1/flutter/pubspec.lock new file mode 100644 index 0000000..33b8cf8 --- /dev/null +++ b/v1/flutter/pubspec.lock @@ -0,0 +1,373 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + archive: + dependency: transitive + description: + name: archive + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + url: "https://pub.dev" + source: hosted + version: "3.6.1" + args: + dependency: transitive + description: + name: args + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27 + url: "https://pub.dev" + source: hosted + version: "3.0.5" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + english_words: + dependency: "direct main" + description: + name: english_words + sha256: "6a7ef6473a97bd8571b6b641d006a6e58a7c67e65fb6f3d6d1151cb46b0e983c" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5be191523702ba8d7a01ca97c17fca096822ccf246b0a9f11923a6ded06199b6" + url: "https://pub.dev" + source: hosted + version: "0.3.1+4" + googleapis_auth: + dependency: transitive + description: + name: googleapis_auth + sha256: befd71383a955535060acde8792e7efc11d2fccd03dd1d3ec434e85b68775938 + url: "https://pub.dev" + source: hosted + version: "1.6.0" + grpc: + dependency: transitive + description: + name: grpc + sha256: e93ee3bce45c134bf44e9728119102358c7cd69de7832d9a874e2e74eb8cab40 + url: "https://pub.dev" + source: hosted + version: "3.2.4" + http: + dependency: "direct main" + description: + name: http + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + http2: + dependency: transitive + description: + name: http2 + sha256: "9ced024a160b77aba8fb8674e38f70875e321d319e6f303ec18e87bd5a4b0c1d" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + url: "https://pub.dev" + source: hosted + version: "10.0.4" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + url: "https://pub.dev" + source: hosted + version: "3.0.3" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + url: "https://pub.dev" + source: hosted + version: "0.8.0" + meta: + dependency: transitive + description: + name: meta + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + url: "https://pub.dev" + source: hosted + version: "1.12.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + nitric_sdk: + dependency: "direct main" + description: + name: nitric_sdk + sha256: "18eb075706a18bd51c87dc7b3f065cb3a20998ad3b87f304eded159ff7546ce4" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + path: + dependency: transitive + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + provider: + dependency: "direct main" + description: + name: provider + sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + url: "https://pub.dev" + source: hosted + version: "6.1.2" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" + url: "https://pub.dev" + source: hosted + version: "3.1.0+1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + uuid: + dependency: transitive + description: + name: uuid + sha256: "83d37c7ad7aaf9aa8e275490669535c8080377cfa7a7004c24dfac53afffaa90" + url: "https://pub.dev" + source: hosted + version: "4.4.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + url: "https://pub.dev" + source: hosted + version: "14.2.1" + web: + dependency: transitive + description: + name: web + sha256: d43c1d6b787bf0afad444700ae7f4db8827f701bc61c255ac8d328c6f4d52062 + url: "https://pub.dev" + source: hosted + version: "1.0.0" +sdks: + dart: ">=3.4.4 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/v1/flutter/pubspec.yaml b/v1/flutter/pubspec.yaml new file mode 100644 index 0000000..6ba01b3 --- /dev/null +++ b/v1/flutter/pubspec.yaml @@ -0,0 +1,92 @@ +name: word_generator +description: "A new Flutter 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 + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# 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.0.0+1 + +environment: + sdk: ">=3.4.4 <4.0.0" + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.6 + english_words: ^4.0.0 + http: ^1.2.2 + provider: ^6.1.2 + nitric_sdk: ^1.2.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^3.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages From 1d1b48b916f66aaed0a17d4674d86a499fec21ef Mon Sep 17 00:00:00 2001 From: Ryan Cartwright Date: Thu, 22 Aug 2024 15:46:39 +1000 Subject: [PATCH 2/4] add guide to readme --- v1/flutter/README.md | 1129 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 1119 insertions(+), 10 deletions(-) diff --git a/v1/flutter/README.md b/v1/flutter/README.md index 7d9dc9d..7891502 100644 --- a/v1/flutter/README.md +++ b/v1/flutter/README.md @@ -1,16 +1,1125 @@ -# word_generator +# Building a Flutter Application with Nitric -A new Flutter project. +In this guide we'll go over how to create a basic Flutter application using the Nitric framework as the backend. Dart does not have native support on AWS, GCP, or Azure, so by using the Nitric framework you can use your skills in Dart to create an API and interact with cloud services in an intuitive way. -## Getting Started +The application will have a Flutter frontend that will generate wordpairs that can be added to a list of favourites. The backend will be a Nitric API with a key value store that can store favourited wordpairs. This application will be simple, but requires that you know the basics of Flutter and Nitric. -This project is a starting point for a Flutter application. +## Getting started -A few resources to get you started if this is your first Flutter project: +To get started make sure you have the following prerequisites installed: -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) +- [Dart](https://dart.dev/get-dart) +- [Flutter](https://docs.flutter.dev/get-started/install) +- The [Nitric CLI](/getting-started/installation) +- An [AWS](https://aws.amazon.com), [Google Cloud](https://cloud.google.com) or [Azure](https://azure.microsoft.com) account (_your choice_) -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +Start by making sure your environment is set up correctly with Flutter for web development. + +```txt +flutter doctor +Doctor summary (to see all details, run flutter doctor -v): +[✓] Flutter (Channel stable, 3.24.0, on macOS darwin-arm64, locale en) +[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.1) +[✓] Xcode - develop for iOS and macOS (Xcode 15) +[✓] Chrome - develop for the web +[✓] Android Studio (version 2024.1) +[✓] VS Code (version 1.92) +[✓] Connected device (4 available) +[✓] HTTP Host Availability + +• No issues found! +``` + +We can then scaffold the project using the following command: + +```bash +flutter create word_generator +``` + +Then open your project in your favourite editor: + +```bash +code word_generator +``` + +## Backend + +Let's start by building out the backend. This will be an API with a route dedicated to getting a list of all the favourites and a route to toggle a favourite on or off. These favourites will be stored in a [key value store](/keyvalue). To create a Nitric project add the `nitric.yaml` to the Flutter template project. + +```yaml {{ label: "nitric.yaml" }} +name: word_generator +services: + - match: lib/services/*.dart + start: dart run $SERVICE_PATH +``` + +This points the project to the services that we will create in the `lib/services` directory. We'll create that directory and a file to start building our services. + +```bash +mkdir lib/services +touch lib/services/main.dart +``` + +You will also need to add Nitric to your `pubspec.yaml`. + +```yaml {{ label: "pubspec.yaml" }} +dependencies: + flutter: + sdk: flutter + + nitric_sdk: + git: + url: https://github.com/nitrictech/dart-sdk.git + ref: main +``` + +### Building the API + +Define the API and the key value store in the `main.dart` service file. This will create an API named `main` and a kv store named `favourites` and give the function permissions to get, set, and delete documents. The `favourites` store will contain keys with the name of the favourite and then a value with the favourites object. + +```dart {{ label: "lib/services/main.dart" }} +import 'package:nitric_sdk/nitric.dart'; + +void main() { + final api = Nitric.api("main"); + + final favouritesKV = Nitric.kv("favourites").allow([ + KeyValueStorePermission.get, + KeyValueStorePermission.set, + KeyValueStorePermission.delete + ]); +} +``` + +We will define a favourites class to convert our JSON requests to Favourite objects and then back into JSON. Conversion to a `Map json) : name = json['name']; + + /// Convert a Favourite object to a json encodable + static Map toJson(Favourite favourite) => + {'name': favourite.name}; +} +``` + +For the API we will define two routes, one GET method for `/favourites` and one POST method on `/favourite`. Let's start by defining the GET `/favourites` route. Make sure you import `dart:convert` to get access to the `jsonEncode` method for converting the documents to favourites. + +```dart {{ label: "lib/services/main.dart" }} +import 'dart:convert'; + +... + +api.get("/favourites", (ctx) async { + // Get a list of all the keys in the store + var keyStream = await favouritesKV.keys(); + + // Convert the keys to a list of favourites + var favourites = await keyStream.asyncMap((k) async { + final favourite = await favouritesKV.get(k); + + return favourite; + }).toList(); + + // Return the body as a list of favourites + ctx.res.body = jsonEncode(favourites); + + return ctx; +}); +``` + +We can then define the route for adding favourites. This will toggle a favourite on or off depending on whether the key exists in the key value store. Make sure you import the `Favourite` class from `package:word_generator/favourite.dart` + +```dart {{ label: "lib/services/main.dart" }} +import 'package:word_generator/favourite.dart'; + +... + +api.post("/favourite", (ctx) async { + final req = ctx.req.json(); + + // convert the request json to a Favourite object + final favourite = Favourite.fromJson(req); + + // search for the key, filtering by the name of the favourite + final stream = await favouritesKV.keys(prefix: favourite.name); + + // checks if the favourite exists in the list of keys + final exists = await stream.any((f) => f == favourite.name); + + // if it exists delete and return + if (exists) { + await favouritesKV.delete(favourite.name); + + return ctx; + } + + // if it doesn't exist, create it + try { + await favouritesKV.set(favourite.name, Favourite.toJson(favourite)); + } catch (e) { + ctx.res.status = 500; + ctx.res.body = "could not set ${favourite.name}"; + } + + return ctx; +}); +``` + +### Cross-Origin Resource Sharing + +When we are making requests to our backend from our frontend, we will run into issues with Cross-Origin Resource Sharing (CORS) errors. We can handle this by adding CORS headers to our responses and adding OPTIONS methods to respond to preflight requests from the frontend. If you want to learn more about CORS, you can read [here](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS). Create a file called `lib/cors.dart` which is where we will define the middleware and options handler. + +```dart {{ label: "lib/cors.dart" }}cors. +import 'package:nitric_sdk/nitric.dart'; + +/// Handle Preflight Options requests by returning status 200 to the requests +Future optionsHandler(HttpContext ctx) async { + ctx.res.headers["Content-Type"] = ["text/html; charset=ascii"]; + ctx.res.body = "OK"; + + return ctx.next(); +} + +/// Add CORS headers to responses +Future addCors(HttpContext ctx) async { + ctx.res.headers["Access-Control-Allow-Origin"] = ["*"]; + ctx.res.headers["Access-Control-Allow-Headers"] = [ + "Origin, X-Requested-With, Content-Type, Accept, Authorization", + ]; + ctx.res.headers["Access-Control-Allow-Methods"] = [ + "GET, PUT, POST, PATCH, OPTIONS, DELETE", + ]; + ctx.res.headers["Access-Control-Max-Age"] = ["7200"]; + + return ctx.next(); +} +``` + +We can then add the options routes and add the CORS middleware to the API. When we add a middleware at the API level it will run on every request to any route on that API. + +```dart {{ label: "lib/services/main.dart" }} +import 'package:flutter_blog/cors.dart'; + +... + +final api = Nitric.api("main", opts: ApiOptions(middlewares: [addCors])); + +... + +api.options("/favourites", optionsHandler); +api.options("/favourite", optionsHandler); +``` + +### Test + +You can start your backend for testing using the following command. + +```bash +nitric start +``` + +You can test the routes using the dashboard or cURL commands in your terminal. + +```bash +> curl http://localhost:4001/favourites +[] + +> curl -X POST -d '{"name": "testpair"}' http://localhost:4001/favourite +> curl http://localhost:4001/favourites +[{"name": "testpair"}] +``` + +## Frontend + +We can now start on the frontend. The application will contain two pages which can be navigated between by using the + +The first will show the current generated word along with a history of all previously generated words. It will have a button to like the word and a button to generate the next word. + +![main flutter page](/docs/images/guides/flutter/main_page_final.png) + +The second page will show the list of favourites if there are any, otherwise it will display that there are no word pairs currently favourited. + +![favourites flutter page](/docs/images/guides/flutter/favourites_page_final.png) + +### Providers + +Before creating these pages, we'll first create the data providers as these are required for the pages to function. These will be split into a provider for word generation and a provider for favourites gathering. These will both be `ChangeNotifiers` to allow for dynamic updates to the pages. + +Let's start with the word provider. For this you'll need to add the `english_words` dependency to generate new words. + +```bash +flutter pub add english_words +``` + +We can then build the `WordProvider`. + +```dart {{ label: "lib/providers/word.dart" }} +import 'package:english_words/english_words.dart'; +import 'package:flutter/material.dart'; + +class WordProvider extends ChangeNotifier { + // The current word pair + var current = WordPair.random(); +} +``` + +We'll then define a function for getting a new word pair and notifying the listeners. + +```dart {{ label: "lib/providers/word.dart" }} +// Generate a new word pair and notify the listeners +void getNext() { + current = WordPair.random(); + notifyListeners(); +} +``` + +We can then build the `FavouritesProvider`. This will use the Nitric API to get a list of favourites and also toggle if a favourite is liked or not. To start, we'll define our `FavouritesProvider` and add the attributes for setting the list of favourites and whether the list is loading. + +```dart {{ label: "lib/providers/favourites.dart" }} +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:word_generator/favourite.dart'; +import 'package:http/http.dart' as http; + +class FavouritesProvider extends ChangeNotifier { + final baseApiUrl = "http://localhost:4001"; + + List _favourites = []; + bool _isLoading = false; + + /// Get a list of active favourites + List get favourites => _favourites; + + /// Check whether the data is loading or not + bool get isLoading => _isLoading; +} +``` + +We'll then add a method for getting a list of favourites and notifying the listeneres. For this we require the `http` package to make requests to our API. + +```bash +flutter pub add http +``` + +```dart {{ label: "lib/providers/favourites.dart" }} +/// Updates the list of favourites whilst returning a Future with the list of favourites. +/// Sets isLoading to true when the favourites have been fetched +Future> fetchData() async { + _isLoading = true; + notifyListeners(); + + final response = await http.get(Uri.parse("$baseApiUrl/favourites")); + + if (response.statusCode == 200) { + // Decode the json data into an iterable list of unknown objects + Iterable rawFavourites = jsonDecode(response.body); + + // Map over the iterable, converting it to a list of Favourite objects + _favourites = + List.from(rawFavourites.map((model) => Favourite.fromJson(model))); + } else { + throw Exception('Failed to load data'); + } + + _isLoading = false; + notifyListeners(); + + return _favourites; +} +``` + +We can then make a function for listeners to check if a word pair has been favourited. This requires the `english_words` package for importing the `WordPair` typing. + +```dart {{ label: "lib/providers/favourites.dart" }} +/// Add english words import +import 'package:english_words/english_words.dart'; + +... + +/// Checks if the word pair exists in the list of favourites +bool hasFavourite(WordPair pair) { + if (isLoading) { + return false; + } + + return _favourites.any((f) => f.name == pair.asLowerCase); +} +``` + +Finally, we'll define a function for toggling a word pair as being liked or unliked. + +```dart {{ label: "lib/providers/favourites.dart" }} +/// Toggles whether a favourite being liked or unliked. +Future toggleFavourite(WordPair pair) async { +// Convert the word pair into a json encoded +final encodedFavourites = jsonEncode(Favourite.toJson(Favourite(pair.asLowerCase))); + + // Makes a post request to the toggle favourite route. + final response = await http.post(Uri.parse("$baseApiUrl/favourite"), body: encodedFavourites); + + // If the response doesn't respond with OK, throw an error + if (response.statusCode != 200) { + throw Exception("Failed to add favourite: ${response.body}"); + } + + // If it was successfully removed update favourites + if (hasFavourite(pair)) { + // Remove the favourite for + _favourites.removeWhere((f) => f.name == pair.asLowerCase); + } else { + _favourites.add(Favourite(pair.asLowerCase)); + } + + notifyListeners(); +} +``` + +### Generator Page + +We can now build our generator page, the central functionality of our application. Add the provider package to be able to respond to change notifier events. + +```bash +flutter pub add provider +``` + +You can then create the generator page with the following stateless widget. This will display a card with the generated word, along with a button for liking the word pair or generating the next pair. + +```dart {{ label: "lib/pages/generator.dart" }} +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:word_generator/providers/favourites.dart'; +import 'package:word_generator/providers/word.dart'; + +class GeneratorPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + // Get a reference to the current color scheme + final theme = Theme.of(context); + + final style = theme.textTheme.displayMedium!.copyWith( + color: theme.colorScheme.onPrimary, + ); + + // Start listening to both the favourites and the word providers + final favourites = context.watch(); + final words = context.watch(); + + IconData icon = Icons.favorite_border; + + if (favourites.hasFavourite(words.current)) { + icon = Icons.favorite; + } + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Card to display the word pair generated + Card( + color: theme.colorScheme.primary, + child: Padding( + padding: const EdgeInsets.all(20), + // Smooth animate the box changing size + child: AnimatedSize( + duration: Duration(milliseconds: 200), + child: MergeSemantics( + child: Wrap( + children: [ + Text( + words.current.first, + style: style.copyWith(fontWeight: FontWeight.w200), + ), + Text( + words.current.second, + style: style.copyWith(fontWeight: FontWeight.bold), + ) + ], + ), + ), + ), + ), + ), + SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Button to like the current word pair + ElevatedButton.icon( + onPressed: () { + favourites.toggleFavourite(words.current); + }, + icon: Icon(icon), + label: Text('Like'), + ), + SizedBox(width: 10), + // Button to generate the next word pair + ElevatedButton( + onPressed: () { + words.getNext(); + }, + child: Text('Next'), + ), + ], + ), + ], + ), + ); + } +} +``` + +To test this generation we can build the application entrypoint to run our paplication. In this application we use a `MultiProvider` to supply the child pages with the ability to listen to the `FavouritesProvider` and the `WordProvider`. + +```dart {{ label: "lib/main.dart" }} +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:word_generator/pages/generator.dart'; +import 'package:word_generator/providers/favourites.dart'; +import 'package:word_generator/providers/word.dart'; + +// Start the application +void main() => runApp(Application()); + +class Application extends StatelessWidget { + const Application({super.key}); + + @override + Widget build(BuildContext context) { + return MultiProvider( + // Allow the child pages to reference the data providers + providers: [ + ChangeNotifierProvider(create: (context) => FavouritesProvider()), + ChangeNotifierProvider(create: (context) => WordProvider()), + ], + child: MaterialApp( + title: 'Word Generator App', + theme: ThemeData( + useMaterial3: true, + // Set the default colour for the application. + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), + ), + // Set the home page to the generator page + home: GeneratorPage(), + ), + ); + } +} +``` + +You can test the generator page by starting the API and running the flutter app. Use the following commands (in separate terminals): + +```bash +nitric start + +flutter run -d chrome +``` + +This page should currently look like so: + +![initial generator page](/docs/images/guides/flutter/main_page_1.png) + +#### Optional: History Animation + +We can add a list of previously generated word pairs to make our generator page more interesting. This will be a trailing list which will slowly get more transparent as it goes off the page. We'll start by updating our `WordProvider` with history. + +```dart {{ label: "lib/providers/word.dart" }} +import 'package:english_words/english_words.dart'; +import 'package:flutter/material.dart'; + +class WordProvider extends ChangeNotifier { + // The current word pair + var current = WordPair.random(); + // A list of all generated word pairs + var history = []; + + // A key that is used to get a reference to the history list state + GlobalKey? historyListKey; + + // Generate a new word pair and notify the listeners + void getNext() { + // Add the current pair to the start of the history list + history.insert(0, current); + + // Adds space to the start of the animated list and triggers an animation to start + var animatedList = historyListKey?.currentState as AnimatedListState?; + animatedList?.insertItem(0); + + current = WordPair.random(); + notifyListeners(); + } +} +``` + +These new features in the word pair will be used by a new widget called `HistoryListView` that will be used by the `GeneratorPage`. You can add this to the bottom of the generator page. + +```dart {{ label: "lib/pages/generator.dart" }} +class HistoryListView extends StatefulWidget { + const HistoryListView({super.key}); + + @override + State createState() => _HistoryListViewState(); +} + +class _HistoryListViewState extends State { + final _key = GlobalKey(); + + // Create a linear gradient mask from transparent to opaque. + static const Gradient _maskingGradient = LinearGradient( + colors: [Colors.transparent, Colors.black], + stops: [0.0, 0.5], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ); + + @override + Widget build(BuildContext context) { + final favourites = context.watch(); + final words = context.watch(); + + // Set the key of the animated list to the WordProvider GlobalKey so it can be manipulated from there + // Not recommended for a production app as it can slow performance... + // Read more here: https://api.flutter.dev/flutter/widgets/GlobalKey-class.html + words.historyListKey = _key; + + return ShaderMask( + shaderCallback: (bounds) => _maskingGradient.createShader(bounds), + // This blend mode takes the opacity of the shader (i.e. our gradient) + // and applies it to the destination (i.e. our animated list). + blendMode: BlendMode.dstIn, + child: AnimatedList( + key: _key, + // Reverse the list so the latest is on the bottom + reverse: true, + padding: EdgeInsets.only(top: 100), + initialItemCount: words.history.length, + // Build each item in the list, will be run initially and when a new word pair is added. + itemBuilder: (context, index, animation) { + final pair = words.history[index]; + return SizeTransition( + sizeFactor: animation, + child: Center( + child: TextButton.icon( + onPressed: () { + favourites.toggleFavourite(pair); + }, + // If the word pair was favourited, show a heart next to it + icon: favourites.hasFavourite(pair) + ? Icon(Icons.favorite, size: 12) + : SizedBox(), + label: Text( + pair.asLowerCase, + ), + ), + ), + ); + }, + ), + ); + } +} +``` + +With that built you can add it to the `GeneratorPage`. + +```dart {{ label: "lib/pages/generator.dart" }} +return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( // <- allows the list to extend to the top of the page + flex: 3, + child: HistoryListView(), // <- Add the history list view here + ), + SizedBox(height: 10), + Card( + ... + ), + ... + Spacer(flex: 2), // <- Stops the Card being pushed to the bottom of the page + ] + ) +); +``` + +If you reload the flutter app it should now display your history when you click through the words. + +![generator page with history](/docs/images/guides/flutter/main_page_2.png) + +### Favourites Page + +The favourites page will simply list all the favourites and the number that have been favourited: + +```dart {{ label: "lib/pages/favourites.dart" }} +import 'package:flutter/material.dart'; +import 'package:word_generator/providers/favourites.dart'; +import 'package:provider/provider.dart'; + +class FavouritesPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + var favourites = context.watch(); + + // If the favourites list is still loading then show a spinning circle. + if (favourites.isLoading) { + return Center( + child: SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator(color: Colors.blue), + )); + } + + // Otherwise return a list of all the favourites + return ListView( + children: [ + Padding( + padding: const EdgeInsets.all(20), + // Display how many favourites there are + child: Text('You have ' + '${favourites.favourites.length} favourites:'), + ), + // Create a list tile for every favourite in the list of favourites + for (var favourite in favourites.favourites) + ListTile( + leading: Icon(Icons.favorite), // <- A heart icon + title: Text(favourite.name), + ), + ], + ); + } +} +``` + +You might notice at no point is the favourites list actually being fetched, so the `FavouritesProvider` will always contain an empty favourites list. This will be handled in the next section where we build the navigation. + +### Navigation + +To finish, we'll add the navigation page. This will wrap the `GeneratorPage` and the `FavouritesPage` and allow a user to switch between them through a nav bar. This will be responsive, with a desktop having it appear on the side and a mobile appearing at the bottom. It will be a `StatefulWidget` so it can maintain the page that is being viewed. In the `initState` we will fetch the favourites data. + +Start by scaffolding the main page `StatefulWidget` and `State`. + +```dart {{ label: "lib/pages/home.dart" }} +import 'package:flutter/material.dart'; +import 'package:word_generator/providers/favourites.dart'; +import 'package:provider/provider.dart'; + +import 'favourites.dart'; +import 'generator.dart'; + +class HomePage extends StatefulWidget { + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + // The page that the user is currently viewing: Generator (0) or Favourites (1) + var selectedIndex = 0; + + @override + void initState() { + super.initState(); + // Fetch + context.read().fetchData(); + } +} +``` + +We then want to fill out the `build` function so it returns the current page. This will be wrapped in a `ColoredBox` that has a consistent background colour between all pages. + +```dart {{ label: "lib/pages/home.dart" }} +@override +Widget build(BuildContext context) { + Widget page; + switch (selectedIndex) { + case 0: + page = GeneratorPage(); + case 1: + page = FavouritesPage(); + default: + throw UnimplementedError('no widget for $selectedIndex'); + } + + var colorScheme = Theme.of(context).colorScheme; + + // The container for the current page, with its background color + // and subtle switching animation. + var mainArea = ColoredBox( + color: colorScheme.surfaceContainerHighest, + child: AnimatedSwitcher( + duration: Duration(milliseconds: 200), + child: page, + ), + ); + + return mainArea; +} +``` + +You can now set the application's entrypoint page to be the `HomePage` now in `lib/main.dart` + +```dart {{ label: "lib/main.dart" }} +import 'package:word_generator/pages/home.dart'; // <-- Add import + +... + +class Application extends StatelessWidget { + const Application({super.key}); + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (context) => FavouritesProvider()), + ChangeNotifierProvider(create: (context) => WordProvider()), + ], + child: MaterialApp( + title: 'Word Generator App', + theme: ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), + ), + home: HomePage(), // <-- Change here + ), + ); + } +} +``` + +For now, you can test both pages by swapping the `selectedIndex` manually. We'll then want to build out a navigation bar for desktop and for mobile. For this we will use a `LayoutBuilder` to check if the screen width is less than 450px. + +```dart {{ label: "lib/pages/home.dart" }} +Widget build(BuildContext context) { + ... + + return Scaffold( + body: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 450) { + // return mobile navigation + } else { + // return desktop navigation + } + } + ) + ) +} +``` + +Starting with the mobile navigation: + +```dart {{ label: "lib/pages/home.dart" }} +return Column( + children: [ + Expanded(child: mainArea), + SafeArea( + child: BottomNavigationBar( + items: [ + BottomNavigationBarItem( + icon: Icon(Icons.home), + label: 'Home', + ), + BottomNavigationBarItem( + icon: Icon(Icons.favorite), + label: 'Favorites', + ), + ], + currentIndex: selectedIndex, + onTap: (value) { + setState(() { + selectedIndex = value; + }); + }, + ), + ) + ], +); +``` + +And then finally the desktop navigation: + +```dart {{ label: "lib/pages/home.dart" }} +return Row( + children: [ + SafeArea( + child: NavigationRail( + // Display only icons if screen width is less than 600px + extended: constraints.maxWidth >= 600, + destinations: [ + NavigationRailDestination( + icon: Icon(Icons.home), + label: Text('Home'), + ), + NavigationRailDestination( + icon: Icon(Icons.favorite), + label: Text('Favourites'), + ), + ], + selectedIndex: selectedIndex, + onDestinationSelected: (value) { + setState(() { + selectedIndex = value; + }); + }, + ), + ), + Expanded(child: mainArea), + ], +); +``` + +Altogether, the page code should look like this: + +```dart {{ label: "lib/pages/home.dart" }} +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:word_generator/providers/favourites.dart'; + +import 'favourites.dart'; +import 'generator.dart'; + +class HomePage extends StatefulWidget { + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + var selectedIndex = 0; + + @override + void initState() { + super.initState(); + context.read().fetchData(); + } + + @override + Widget build(BuildContext context) { + var colorScheme = Theme.of(context).colorScheme; + + Widget page; + switch (selectedIndex) { + case 0: + page = GeneratorPage(); + case 1: + page = FavouritesPage(); + default: + throw UnimplementedError('no widget for $selectedIndex'); + } + + // The container for the current page, with its background color + // and subtle switching animation. + var mainArea = ColoredBox( + color: colorScheme.surfaceContainerHighest, + child: AnimatedSwitcher( + duration: Duration(milliseconds: 200), + child: page, + ), + ); + + return Scaffold( + body: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 450) { + return Column( + children: [ + Expanded(child: mainArea), + SafeArea( + child: BottomNavigationBar( + items: [ + BottomNavigationBarItem( + icon: Icon(Icons.home), + label: 'Home', + ), + BottomNavigationBarItem( + icon: Icon(Icons.favorite), + label: 'Favorites', + ), + ], + currentIndex: selectedIndex, + onTap: (value) { + setState(() { + selectedIndex = value; + }); + }, + ), + ) + ], + ); + } else { + return Row( + children: [ + SafeArea( + child: NavigationRail( + extended: constraints.maxWidth >= 600, + destinations: [ + NavigationRailDestination( + icon: Icon(Icons.home), + label: Text('Home'), + ), + NavigationRailDestination( + icon: Icon(Icons.favorite), + label: Text('Favorites'), + ), + ], + selectedIndex: selectedIndex, + onDestinationSelected: (value) { + setState(() { + selectedIndex = value; + }); + }, + ), + ), + Expanded(child: mainArea), + ], + ); + } + }, + ), + ); + } +} +``` + +## Deployment + +At this point, you can deploy the application to any supported cloud provider. Start by setting up your credentials and any configuration for the cloud you prefer: + +- [AWS](/reference/providers/aws) +- [Azure](/reference/providers/azure) +- [Google Cloud](/reference/providers/gcp) + +Next, we'll need to create a `stack`. Stacks represent deployed instances of an application, including the target provider and other details such as the deployment region. You'll usually define separate stacks for each environment such as development, testing and production. For now, let's start by creating a `dev` stack for AWS. + +```bash +nitric stack new dev aws +``` + +You'll then need to edit the `nitric.dev.yaml` file to add a region. + +```yaml {{ label: "nitric.dev.yaml" }} +provider: nitric/aws@1.11.1 +region: us-east-1 +``` + +### Dockerfile + +Because we've mixed Flutter and Dart dependencies, we need to use a [custom container](/docs/reference/custom-containers) that fetches our dependencies using Flutter. You can point to a custom container in your `nitric.yaml`: + + + If you have a separate Dart backend that doesn't share dependencies with your + Flutter application, this step is unnecessary. + + +```yaml {{ label: "nitric.yaml" }} +name: word_generator +services: + - match: lib/services/*.dart + runtime: flutter # <-- Specifies the runtime to use + start: dart run --observe $SERVICE_PATH +runtimes: + flutter: + dockerfile: ./docker/flutter.dockerfile # <-- Specifies where to find the Dockerfile + args: {} +``` + +Create the Dockerfile at the same path as your runtime specifies. This Dockerfile is fairly straightforward, taking its + +```dockerfile {{ label: "docker/flutter.dockerfile" }} +FROM dart:stable AS build + +# The Nitric CLI will provide the HANDLER arg with the location of our service +ARG HANDLER +WORKDIR /app + +ENV DEBIAN_FRONTEND=noninteractive + +# download Flutter SDK from Flutter Github repo +RUN git clone https://github.com/flutter/flutter.git /usr/local/flutter + +ENV DEBIAN_FRONTEND=dialog + +# Set flutter environment path +ENV PATH="/usr/local/flutter/bin:/usr/local/flutter/bin/cache/dart-sdk/bin:${PATH}" + +# Run flutter doctor +RUN flutter doctor + +# Resolve app dependencies. +COPY pubspec.* ./ +RUN flutter pub get + +# Ensure the ./bin folder exists +RUN mkdir -p ./bin + +# Copy app source code and AOT compile it. +COPY . . +# Ensure packages are still up-to-date if anything has changed +RUN flutter pub get --offline +# Compile the dart service into an exe +RUN dart compile exe ./${HANDLER} -o bin/main + +# Start from scratch and copy in the necessary runtime files +FROM alpine + +COPY --from=build /runtime/ / +COPY --from=build /app/bin/main /app/bin/ + +ENTRYPOINT ["/app/bin/main"] +``` + +We can also add a `.dockerignore` to optimise our image further: + +```txt {{ label: "docker/flutter.dockerignore" }} +build +test + +.nitric +.idea +.dart_tool +.git +docker + +android +ios +linux +macos +web +windows +``` + +### AWS + + + Cloud deployments incur costs and while most of these resource are available + with free tier pricing you should consider the costs of the deployment. + + +Now that the application has been configured for deployment, let's try deploying it with the `up` command. + +```bash +nitric up + +API Endpoints: +────────────── +main: https://xxxxxxxx.execute-api.us-east-1.amazonaws.com +``` + +Once we have our API, we can update our flutter app to use the new endpoint. Go into the `FavouritesProvider` and set the `baseApiUrl` to your AWS endpoint. + +```dart {{ label: "lib/providers/favourites.dart" }} +class FavouritesProvider extends ChangeNotifier { + final baseApiUrl = "https://xxxxxxxx.execute-api.us-east-1.amazonaws.com"; +``` + +When you're done testing your application you can tear it down from the cloud, use the `down` command: + +```bash +nitric down +``` From c8e8e00aaf316162c8979dd4a5dc1863a0f881a9 Mon Sep 17 00:00:00 2001 From: Ryan Cartwright Date: Thu, 26 Sep 2024 00:20:19 +1000 Subject: [PATCH 3/4] update readme and add to main readme --- README.md | 7 +- v1/flutter/README.md | 1139 ++---------------------------------------- 2 files changed, 43 insertions(+), 1103 deletions(-) diff --git a/README.md b/README.md index 4a44095..2d6bacf 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,10 @@ When utilizing the `nitric new` command to initiate a new project, the available ### Dart -| Name | Description | Features | -| ---------------------------------- | ---------------- | -------- | -| [dart-starter](./v1/dart-starter/) | REST API Starter | APIs | +| Name | Description | Features | +| ---------------------------------- | ----------------------------------------------- | ---------------------- | +| [dart-starter](./v1/dart-starter/) | REST API Starter | APIs | +| [flutter](./v1/flutter/) | Basic Flutter Application with a Nitric backend | APIs, Key Value Stores | ### Go diff --git a/v1/flutter/README.md b/v1/flutter/README.md index 7891502..775117a 100644 --- a/v1/flutter/README.md +++ b/v1/flutter/README.md @@ -1,1125 +1,64 @@ -# Building a Flutter Application with Nitric +

+ + Nitric Logo + +

-In this guide we'll go over how to create a basic Flutter application using the Nitric framework as the backend. Dart does not have native support on AWS, GCP, or Azure, so by using the Nitric framework you can use your skills in Dart to create an API and interact with cloud services in an intuitive way. +

+ A fast & fun way to build portable cloud-native applications +

-The application will have a Flutter frontend that will generate wordpairs that can be added to a list of favourites. The backend will be a Nitric API with a key value store that can store favourited wordpairs. This application will be simple, but requires that you know the basics of Flutter and Nitric. +

+ GitHub release (latest SemVer) + + Twitter Follow + + Discord +

-## Getting started +## Project Description -To get started make sure you have the following prerequisites installed: +A basic Flutter application that uses Nitric as the backend service for the API and key value collections. -- [Dart](https://dart.dev/get-dart) -- [Flutter](https://docs.flutter.dev/get-started/install) -- The [Nitric CLI](/getting-started/installation) -- An [AWS](https://aws.amazon.com), [Google Cloud](https://cloud.google.com) or [Azure](https://azure.microsoft.com) account (_your choice_) +## Running this project -Start by making sure your environment is set up correctly with Flutter for web development. +To run this project you'll need the [Nitric CLI](https://nitric.io/docs/installation) installed, then you can use the CLI commands to run, build or deploy the project. -```txt -flutter doctor -Doctor summary (to see all details, run flutter doctor -v): -[✓] Flutter (Channel stable, 3.24.0, on macOS darwin-arm64, locale en) -[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.1) -[✓] Xcode - develop for iOS and macOS (Xcode 15) -[✓] Chrome - develop for the web -[✓] Android Studio (version 2024.1) -[✓] VS Code (version 1.92) -[✓] Connected device (4 available) -[✓] HTTP Host Availability - -• No issues found! -``` - -We can then scaffold the project using the following command: - -```bash -flutter create word_generator -``` - -Then open your project in your favourite editor: - -```bash -code word_generator -``` - -## Backend - -Let's start by building out the backend. This will be an API with a route dedicated to getting a list of all the favourites and a route to toggle a favourite on or off. These favourites will be stored in a [key value store](/keyvalue). To create a Nitric project add the `nitric.yaml` to the Flutter template project. - -```yaml {{ label: "nitric.yaml" }} -name: word_generator -services: - - match: lib/services/*.dart - start: dart run $SERVICE_PATH -``` - -This points the project to the services that we will create in the `lib/services` directory. We'll create that directory and a file to start building our services. - -```bash -mkdir lib/services -touch lib/services/main.dart -``` - -You will also need to add Nitric to your `pubspec.yaml`. - -```yaml {{ label: "pubspec.yaml" }} -dependencies: - flutter: - sdk: flutter - - nitric_sdk: - git: - url: https://github.com/nitrictech/dart-sdk.git - ref: main -``` - -### Building the API - -Define the API and the key value store in the `main.dart` service file. This will create an API named `main` and a kv store named `favourites` and give the function permissions to get, set, and delete documents. The `favourites` store will contain keys with the name of the favourite and then a value with the favourites object. - -```dart {{ label: "lib/services/main.dart" }} -import 'package:nitric_sdk/nitric.dart'; - -void main() { - final api = Nitric.api("main"); - - final favouritesKV = Nitric.kv("favourites").allow([ - KeyValueStorePermission.get, - KeyValueStorePermission.set, - KeyValueStorePermission.delete - ]); -} -``` - -We will define a favourites class to convert our JSON requests to Favourite objects and then back into JSON. Conversion to a `Map json) : name = json['name']; - - /// Convert a Favourite object to a json encodable - static Map toJson(Favourite favourite) => - {'name': favourite.name}; -} -``` - -For the API we will define two routes, one GET method for `/favourites` and one POST method on `/favourite`. Let's start by defining the GET `/favourites` route. Make sure you import `dart:convert` to get access to the `jsonEncode` method for converting the documents to favourites. - -```dart {{ label: "lib/services/main.dart" }} -import 'dart:convert'; - -... - -api.get("/favourites", (ctx) async { - // Get a list of all the keys in the store - var keyStream = await favouritesKV.keys(); - - // Convert the keys to a list of favourites - var favourites = await keyStream.asyncMap((k) async { - final favourite = await favouritesKV.get(k); - - return favourite; - }).toList(); - - // Return the body as a list of favourites - ctx.res.body = jsonEncode(favourites); - - return ctx; -}); -``` - -We can then define the route for adding favourites. This will toggle a favourite on or off depending on whether the key exists in the key value store. Make sure you import the `Favourite` class from `package:word_generator/favourite.dart` - -```dart {{ label: "lib/services/main.dart" }} -import 'package:word_generator/favourite.dart'; - -... - -api.post("/favourite", (ctx) async { - final req = ctx.req.json(); - - // convert the request json to a Favourite object - final favourite = Favourite.fromJson(req); - - // search for the key, filtering by the name of the favourite - final stream = await favouritesKV.keys(prefix: favourite.name); - - // checks if the favourite exists in the list of keys - final exists = await stream.any((f) => f == favourite.name); - - // if it exists delete and return - if (exists) { - await favouritesKV.delete(favourite.name); - - return ctx; - } - - // if it doesn't exist, create it - try { - await favouritesKV.set(favourite.name, Favourite.toJson(favourite)); - } catch (e) { - ctx.res.status = 500; - ctx.res.body = "could not set ${favourite.name}"; - } - - return ctx; -}); -``` - -### Cross-Origin Resource Sharing - -When we are making requests to our backend from our frontend, we will run into issues with Cross-Origin Resource Sharing (CORS) errors. We can handle this by adding CORS headers to our responses and adding OPTIONS methods to respond to preflight requests from the frontend. If you want to learn more about CORS, you can read [here](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS). Create a file called `lib/cors.dart` which is where we will define the middleware and options handler. - -```dart {{ label: "lib/cors.dart" }}cors. -import 'package:nitric_sdk/nitric.dart'; - -/// Handle Preflight Options requests by returning status 200 to the requests -Future optionsHandler(HttpContext ctx) async { - ctx.res.headers["Content-Type"] = ["text/html; charset=ascii"]; - ctx.res.body = "OK"; - - return ctx.next(); -} - -/// Add CORS headers to responses -Future addCors(HttpContext ctx) async { - ctx.res.headers["Access-Control-Allow-Origin"] = ["*"]; - ctx.res.headers["Access-Control-Allow-Headers"] = [ - "Origin, X-Requested-With, Content-Type, Accept, Authorization", - ]; - ctx.res.headers["Access-Control-Allow-Methods"] = [ - "GET, PUT, POST, PATCH, OPTIONS, DELETE", - ]; - ctx.res.headers["Access-Control-Max-Age"] = ["7200"]; - - return ctx.next(); -} -``` - -We can then add the options routes and add the CORS middleware to the API. When we add a middleware at the API level it will run on every request to any route on that API. - -```dart {{ label: "lib/services/main.dart" }} -import 'package:flutter_blog/cors.dart'; - -... - -final api = Nitric.api("main", opts: ApiOptions(middlewares: [addCors])); - -... - -api.options("/favourites", optionsHandler); -api.options("/favourite", optionsHandler); -``` - -### Test - -You can start your backend for testing using the following command. - -```bash -nitric start -``` - -You can test the routes using the dashboard or cURL commands in your terminal. - -```bash -> curl http://localhost:4001/favourites -[] - -> curl -X POST -d '{"name": "testpair"}' http://localhost:4001/favourite -> curl http://localhost:4001/favourites -[{"name": "testpair"}] -``` - -## Frontend - -We can now start on the frontend. The application will contain two pages which can be navigated between by using the - -The first will show the current generated word along with a history of all previously generated words. It will have a button to like the word and a button to generate the next word. - -![main flutter page](/docs/images/guides/flutter/main_page_final.png) - -The second page will show the list of favourites if there are any, otherwise it will display that there are no word pairs currently favourited. - -![favourites flutter page](/docs/images/guides/flutter/favourites_page_final.png) - -### Providers - -Before creating these pages, we'll first create the data providers as these are required for the pages to function. These will be split into a provider for word generation and a provider for favourites gathering. These will both be `ChangeNotifiers` to allow for dynamic updates to the pages. - -Let's start with the word provider. For this you'll need to add the `english_words` dependency to generate new words. - -```bash -flutter pub add english_words -``` - -We can then build the `WordProvider`. - -```dart {{ label: "lib/providers/word.dart" }} -import 'package:english_words/english_words.dart'; -import 'package:flutter/material.dart'; - -class WordProvider extends ChangeNotifier { - // The current word pair - var current = WordPair.random(); -} -``` - -We'll then define a function for getting a new word pair and notifying the listeners. - -```dart {{ label: "lib/providers/word.dart" }} -// Generate a new word pair and notify the listeners -void getNext() { - current = WordPair.random(); - notifyListeners(); -} -``` - -We can then build the `FavouritesProvider`. This will use the Nitric API to get a list of favourites and also toggle if a favourite is liked or not. To start, we'll define our `FavouritesProvider` and add the attributes for setting the list of favourites and whether the list is loading. - -```dart {{ label: "lib/providers/favourites.dart" }} -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:word_generator/favourite.dart'; -import 'package:http/http.dart' as http; - -class FavouritesProvider extends ChangeNotifier { - final baseApiUrl = "http://localhost:4001"; - - List _favourites = []; - bool _isLoading = false; - - /// Get a list of active favourites - List get favourites => _favourites; - - /// Check whether the data is loading or not - bool get isLoading => _isLoading; -} -``` - -We'll then add a method for getting a list of favourites and notifying the listeneres. For this we require the `http` package to make requests to our API. - -```bash -flutter pub add http -``` - -```dart {{ label: "lib/providers/favourites.dart" }} -/// Updates the list of favourites whilst returning a Future with the list of favourites. -/// Sets isLoading to true when the favourites have been fetched -Future> fetchData() async { - _isLoading = true; - notifyListeners(); - - final response = await http.get(Uri.parse("$baseApiUrl/favourites")); - - if (response.statusCode == 200) { - // Decode the json data into an iterable list of unknown objects - Iterable rawFavourites = jsonDecode(response.body); - - // Map over the iterable, converting it to a list of Favourite objects - _favourites = - List.from(rawFavourites.map((model) => Favourite.fromJson(model))); - } else { - throw Exception('Failed to load data'); - } - - _isLoading = false; - notifyListeners(); - - return _favourites; -} -``` - -We can then make a function for listeners to check if a word pair has been favourited. This requires the `english_words` package for importing the `WordPair` typing. - -```dart {{ label: "lib/providers/favourites.dart" }} -/// Add english words import -import 'package:english_words/english_words.dart'; - -... - -/// Checks if the word pair exists in the list of favourites -bool hasFavourite(WordPair pair) { - if (isLoading) { - return false; - } - - return _favourites.any((f) => f.name == pair.asLowerCase); -} -``` - -Finally, we'll define a function for toggling a word pair as being liked or unliked. - -```dart {{ label: "lib/providers/favourites.dart" }} -/// Toggles whether a favourite being liked or unliked. -Future toggleFavourite(WordPair pair) async { -// Convert the word pair into a json encoded -final encodedFavourites = jsonEncode(Favourite.toJson(Favourite(pair.asLowerCase))); - - // Makes a post request to the toggle favourite route. - final response = await http.post(Uri.parse("$baseApiUrl/favourite"), body: encodedFavourites); - - // If the response doesn't respond with OK, throw an error - if (response.statusCode != 200) { - throw Exception("Failed to add favourite: ${response.body}"); - } - - // If it was successfully removed update favourites - if (hasFavourite(pair)) { - // Remove the favourite for - _favourites.removeWhere((f) => f.name == pair.asLowerCase); - } else { - _favourites.add(Favourite(pair.asLowerCase)); - } - - notifyListeners(); -} -``` - -### Generator Page - -We can now build our generator page, the central functionality of our application. Add the provider package to be able to respond to change notifier events. +You'll also want to make sure the project's required dependencies have been installed. ```bash -flutter pub add provider -``` - -You can then create the generator page with the following stateless widget. This will display a card with the generated word, along with a button for liking the word pair or generating the next pair. - -```dart {{ label: "lib/pages/generator.dart" }} -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:word_generator/providers/favourites.dart'; -import 'package:word_generator/providers/word.dart'; - -class GeneratorPage extends StatelessWidget { - @override - Widget build(BuildContext context) { - // Get a reference to the current color scheme - final theme = Theme.of(context); - - final style = theme.textTheme.displayMedium!.copyWith( - color: theme.colorScheme.onPrimary, - ); - - // Start listening to both the favourites and the word providers - final favourites = context.watch(); - final words = context.watch(); - - IconData icon = Icons.favorite_border; +# install dependencies +dart pub get - if (favourites.hasFavourite(words.current)) { - icon = Icons.favorite; - } - - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Card to display the word pair generated - Card( - color: theme.colorScheme.primary, - child: Padding( - padding: const EdgeInsets.all(20), - // Smooth animate the box changing size - child: AnimatedSize( - duration: Duration(milliseconds: 200), - child: MergeSemantics( - child: Wrap( - children: [ - Text( - words.current.first, - style: style.copyWith(fontWeight: FontWeight.w200), - ), - Text( - words.current.second, - style: style.copyWith(fontWeight: FontWeight.bold), - ) - ], - ), - ), - ), - ), - ), - SizedBox(height: 10), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Button to like the current word pair - ElevatedButton.icon( - onPressed: () { - favourites.toggleFavourite(words.current); - }, - icon: Icon(icon), - label: Text('Like'), - ), - SizedBox(width: 10), - // Button to generate the next word pair - ElevatedButton( - onPressed: () { - words.getNext(); - }, - child: Text('Next'), - ), - ], - ), - ], - ), - ); - } -} -``` - -To test this generation we can build the application entrypoint to run our paplication. In this application we use a `MultiProvider` to supply the child pages with the ability to listen to the `FavouritesProvider` and the `WordProvider`. - -```dart {{ label: "lib/main.dart" }} -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:word_generator/pages/generator.dart'; -import 'package:word_generator/providers/favourites.dart'; -import 'package:word_generator/providers/word.dart'; - -// Start the application -void main() => runApp(Application()); - -class Application extends StatelessWidget { - const Application({super.key}); - - @override - Widget build(BuildContext context) { - return MultiProvider( - // Allow the child pages to reference the data providers - providers: [ - ChangeNotifierProvider(create: (context) => FavouritesProvider()), - ChangeNotifierProvider(create: (context) => WordProvider()), - ], - child: MaterialApp( - title: 'Word Generator App', - theme: ThemeData( - useMaterial3: true, - // Set the default colour for the application. - colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), - ), - // Set the home page to the generator page - home: GeneratorPage(), - ), - ); - } -} -``` - -You can test the generator page by starting the API and running the flutter app. Use the following commands (in separate terminals): - -```bash +# run locally nitric start - -flutter run -d chrome -``` - -This page should currently look like so: - -![initial generator page](/docs/images/guides/flutter/main_page_1.png) - -#### Optional: History Animation - -We can add a list of previously generated word pairs to make our generator page more interesting. This will be a trailing list which will slowly get more transparent as it goes off the page. We'll start by updating our `WordProvider` with history. - -```dart {{ label: "lib/providers/word.dart" }} -import 'package:english_words/english_words.dart'; -import 'package:flutter/material.dart'; - -class WordProvider extends ChangeNotifier { - // The current word pair - var current = WordPair.random(); - // A list of all generated word pairs - var history = []; - - // A key that is used to get a reference to the history list state - GlobalKey? historyListKey; - - // Generate a new word pair and notify the listeners - void getNext() { - // Add the current pair to the start of the history list - history.insert(0, current); - - // Adds space to the start of the animated list and triggers an animation to start - var animatedList = historyListKey?.currentState as AnimatedListState?; - animatedList?.insertItem(0); - - current = WordPair.random(); - notifyListeners(); - } -} -``` - -These new features in the word pair will be used by a new widget called `HistoryListView` that will be used by the `GeneratorPage`. You can add this to the bottom of the generator page. - -```dart {{ label: "lib/pages/generator.dart" }} -class HistoryListView extends StatefulWidget { - const HistoryListView({super.key}); - - @override - State createState() => _HistoryListViewState(); -} - -class _HistoryListViewState extends State { - final _key = GlobalKey(); - - // Create a linear gradient mask from transparent to opaque. - static const Gradient _maskingGradient = LinearGradient( - colors: [Colors.transparent, Colors.black], - stops: [0.0, 0.5], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ); - - @override - Widget build(BuildContext context) { - final favourites = context.watch(); - final words = context.watch(); - - // Set the key of the animated list to the WordProvider GlobalKey so it can be manipulated from there - // Not recommended for a production app as it can slow performance... - // Read more here: https://api.flutter.dev/flutter/widgets/GlobalKey-class.html - words.historyListKey = _key; - - return ShaderMask( - shaderCallback: (bounds) => _maskingGradient.createShader(bounds), - // This blend mode takes the opacity of the shader (i.e. our gradient) - // and applies it to the destination (i.e. our animated list). - blendMode: BlendMode.dstIn, - child: AnimatedList( - key: _key, - // Reverse the list so the latest is on the bottom - reverse: true, - padding: EdgeInsets.only(top: 100), - initialItemCount: words.history.length, - // Build each item in the list, will be run initially and when a new word pair is added. - itemBuilder: (context, index, animation) { - final pair = words.history[index]; - return SizeTransition( - sizeFactor: animation, - child: Center( - child: TextButton.icon( - onPressed: () { - favourites.toggleFavourite(pair); - }, - // If the word pair was favourited, show a heart next to it - icon: favourites.hasFavourite(pair) - ? Icon(Icons.favorite, size: 12) - : SizedBox(), - label: Text( - pair.asLowerCase, - ), - ), - ), - ); - }, - ), - ); - } -} -``` - -With that built you can add it to the `GeneratorPage`. - -```dart {{ label: "lib/pages/generator.dart" }} -return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( // <- allows the list to extend to the top of the page - flex: 3, - child: HistoryListView(), // <- Add the history list view here - ), - SizedBox(height: 10), - Card( - ... - ), - ... - Spacer(flex: 2), // <- Stops the Card being pushed to the bottom of the page - ] - ) -); -``` - -If you reload the flutter app it should now display your history when you click through the words. - -![generator page with history](/docs/images/guides/flutter/main_page_2.png) - -### Favourites Page - -The favourites page will simply list all the favourites and the number that have been favourited: - -```dart {{ label: "lib/pages/favourites.dart" }} -import 'package:flutter/material.dart'; -import 'package:word_generator/providers/favourites.dart'; -import 'package:provider/provider.dart'; - -class FavouritesPage extends StatelessWidget { - @override - Widget build(BuildContext context) { - var favourites = context.watch(); - - // If the favourites list is still loading then show a spinning circle. - if (favourites.isLoading) { - return Center( - child: SizedBox( - width: 40, - height: 40, - child: CircularProgressIndicator(color: Colors.blue), - )); - } - - // Otherwise return a list of all the favourites - return ListView( - children: [ - Padding( - padding: const EdgeInsets.all(20), - // Display how many favourites there are - child: Text('You have ' - '${favourites.favourites.length} favourites:'), - ), - // Create a list tile for every favourite in the list of favourites - for (var favourite in favourites.favourites) - ListTile( - leading: Icon(Icons.favorite), // <- A heart icon - title: Text(favourite.name), - ), - ], - ); - } -} -``` - -You might notice at no point is the favourites list actually being fetched, so the `FavouritesProvider` will always contain an empty favourites list. This will be handled in the next section where we build the navigation. - -### Navigation - -To finish, we'll add the navigation page. This will wrap the `GeneratorPage` and the `FavouritesPage` and allow a user to switch between them through a nav bar. This will be responsive, with a desktop having it appear on the side and a mobile appearing at the bottom. It will be a `StatefulWidget` so it can maintain the page that is being viewed. In the `initState` we will fetch the favourites data. - -Start by scaffolding the main page `StatefulWidget` and `State`. - -```dart {{ label: "lib/pages/home.dart" }} -import 'package:flutter/material.dart'; -import 'package:word_generator/providers/favourites.dart'; -import 'package:provider/provider.dart'; - -import 'favourites.dart'; -import 'generator.dart'; - -class HomePage extends StatefulWidget { - @override - State createState() => _HomePageState(); -} - -class _HomePageState extends State { - // The page that the user is currently viewing: Generator (0) or Favourites (1) - var selectedIndex = 0; - - @override - void initState() { - super.initState(); - // Fetch - context.read().fetchData(); - } -} -``` - -We then want to fill out the `build` function so it returns the current page. This will be wrapped in a `ColoredBox` that has a consistent background colour between all pages. - -```dart {{ label: "lib/pages/home.dart" }} -@override -Widget build(BuildContext context) { - Widget page; - switch (selectedIndex) { - case 0: - page = GeneratorPage(); - case 1: - page = FavouritesPage(); - default: - throw UnimplementedError('no widget for $selectedIndex'); - } - - var colorScheme = Theme.of(context).colorScheme; - - // The container for the current page, with its background color - // and subtle switching animation. - var mainArea = ColoredBox( - color: colorScheme.surfaceContainerHighest, - child: AnimatedSwitcher( - duration: Duration(milliseconds: 200), - child: page, - ), - ); - - return mainArea; -} -``` - -You can now set the application's entrypoint page to be the `HomePage` now in `lib/main.dart` - -```dart {{ label: "lib/main.dart" }} -import 'package:word_generator/pages/home.dart'; // <-- Add import - -... - -class Application extends StatelessWidget { - const Application({super.key}); - - @override - Widget build(BuildContext context) { - return MultiProvider( - providers: [ - ChangeNotifierProvider(create: (context) => FavouritesProvider()), - ChangeNotifierProvider(create: (context) => WordProvider()), - ], - child: MaterialApp( - title: 'Word Generator App', - theme: ThemeData( - useMaterial3: true, - colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), - ), - home: HomePage(), // <-- Change here - ), - ); - } -} -``` - -For now, you can test both pages by swapping the `selectedIndex` manually. We'll then want to build out a navigation bar for desktop and for mobile. For this we will use a `LayoutBuilder` to check if the screen width is less than 450px. - -```dart {{ label: "lib/pages/home.dart" }} -Widget build(BuildContext context) { - ... - - return Scaffold( - body: LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth < 450) { - // return mobile navigation - } else { - // return desktop navigation - } - } - ) - ) -} -``` - -Starting with the mobile navigation: - -```dart {{ label: "lib/pages/home.dart" }} -return Column( - children: [ - Expanded(child: mainArea), - SafeArea( - child: BottomNavigationBar( - items: [ - BottomNavigationBarItem( - icon: Icon(Icons.home), - label: 'Home', - ), - BottomNavigationBarItem( - icon: Icon(Icons.favorite), - label: 'Favorites', - ), - ], - currentIndex: selectedIndex, - onTap: (value) { - setState(() { - selectedIndex = value; - }); - }, - ), - ) - ], -); -``` - -And then finally the desktop navigation: - -```dart {{ label: "lib/pages/home.dart" }} -return Row( - children: [ - SafeArea( - child: NavigationRail( - // Display only icons if screen width is less than 600px - extended: constraints.maxWidth >= 600, - destinations: [ - NavigationRailDestination( - icon: Icon(Icons.home), - label: Text('Home'), - ), - NavigationRailDestination( - icon: Icon(Icons.favorite), - label: Text('Favourites'), - ), - ], - selectedIndex: selectedIndex, - onDestinationSelected: (value) { - setState(() { - selectedIndex = value; - }); - }, - ), - ), - Expanded(child: mainArea), - ], -); -``` - -Altogether, the page code should look like this: - -```dart {{ label: "lib/pages/home.dart" }} -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:word_generator/providers/favourites.dart'; - -import 'favourites.dart'; -import 'generator.dart'; - -class HomePage extends StatefulWidget { - @override - State createState() => _HomePageState(); -} - -class _HomePageState extends State { - var selectedIndex = 0; - - @override - void initState() { - super.initState(); - context.read().fetchData(); - } - - @override - Widget build(BuildContext context) { - var colorScheme = Theme.of(context).colorScheme; - - Widget page; - switch (selectedIndex) { - case 0: - page = GeneratorPage(); - case 1: - page = FavouritesPage(); - default: - throw UnimplementedError('no widget for $selectedIndex'); - } - - // The container for the current page, with its background color - // and subtle switching animation. - var mainArea = ColoredBox( - color: colorScheme.surfaceContainerHighest, - child: AnimatedSwitcher( - duration: Duration(milliseconds: 200), - child: page, - ), - ); - - return Scaffold( - body: LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth < 450) { - return Column( - children: [ - Expanded(child: mainArea), - SafeArea( - child: BottomNavigationBar( - items: [ - BottomNavigationBarItem( - icon: Icon(Icons.home), - label: 'Home', - ), - BottomNavigationBarItem( - icon: Icon(Icons.favorite), - label: 'Favorites', - ), - ], - currentIndex: selectedIndex, - onTap: (value) { - setState(() { - selectedIndex = value; - }); - }, - ), - ) - ], - ); - } else { - return Row( - children: [ - SafeArea( - child: NavigationRail( - extended: constraints.maxWidth >= 600, - destinations: [ - NavigationRailDestination( - icon: Icon(Icons.home), - label: Text('Home'), - ), - NavigationRailDestination( - icon: Icon(Icons.favorite), - label: Text('Favorites'), - ), - ], - selectedIndex: selectedIndex, - onDestinationSelected: (value) { - setState(() { - selectedIndex = value; - }); - }, - ), - ), - Expanded(child: mainArea), - ], - ); - } - }, - ), - ); - } -} -``` - -## Deployment - -At this point, you can deploy the application to any supported cloud provider. Start by setting up your credentials and any configuration for the cloud you prefer: - -- [AWS](/reference/providers/aws) -- [Azure](/reference/providers/azure) -- [Google Cloud](/reference/providers/gcp) - -Next, we'll need to create a `stack`. Stacks represent deployed instances of an application, including the target provider and other details such as the deployment region. You'll usually define separate stacks for each environment such as development, testing and production. For now, let's start by creating a `dev` stack for AWS. - -```bash -nitric stack new dev aws ``` -You'll then need to edit the `nitric.dev.yaml` file to add a region. +## About Nitric -```yaml {{ label: "nitric.dev.yaml" }} -provider: nitric/aws@1.11.1 -region: us-east-1 -``` +[Nitric](https://nitric.io) is a framework for rapid development of cloud-native and serverless applications. Define your apps in terms of the resources they need, then write the code for serverless function based APIs, event subscribers and scheduled jobs. -### Dockerfile - -Because we've mixed Flutter and Dart dependencies, we need to use a [custom container](/docs/reference/custom-containers) that fetches our dependencies using Flutter. You can point to a custom container in your `nitric.yaml`: - - - If you have a separate Dart backend that doesn't share dependencies with your - Flutter application, this step is unnecessary. - - -```yaml {{ label: "nitric.yaml" }} -name: word_generator -services: - - match: lib/services/*.dart - runtime: flutter # <-- Specifies the runtime to use - start: dart run --observe $SERVICE_PATH -runtimes: - flutter: - dockerfile: ./docker/flutter.dockerfile # <-- Specifies where to find the Dockerfile - args: {} -``` +Apps built with Nitric can be deployed to AWS, Azure or Google Cloud all from the same code base so you can focus on your products, not your cloud provider. -Create the Dockerfile at the same path as your runtime specifies. This Dockerfile is fairly straightforward, taking its +Nitric makes it easy to: -```dockerfile {{ label: "docker/flutter.dockerfile" }} -FROM dart:stable AS build +- Create smart serverless functions and APIs +- Build reliable distributed apps that use events and/or queues +- Securely store and retrieve secrets +- Read and write files from buckets -# The Nitric CLI will provide the HANDLER arg with the location of our service -ARG HANDLER -WORKDIR /app +## Documentation -ENV DEBIAN_FRONTEND=noninteractive +The full documentation is available at [nitric.io/docs](https://nitric.io/docs). -# download Flutter SDK from Flutter Github repo -RUN git clone https://github.com/flutter/flutter.git /usr/local/flutter +We're completely open-source and encourage [code contributions](https://nitric.io/docs/contributions). -ENV DEBIAN_FRONTEND=dialog +## Get in touch -# Set flutter environment path -ENV PATH="/usr/local/flutter/bin:/usr/local/flutter/bin/cache/dart-sdk/bin:${PATH}" - -# Run flutter doctor -RUN flutter doctor - -# Resolve app dependencies. -COPY pubspec.* ./ -RUN flutter pub get - -# Ensure the ./bin folder exists -RUN mkdir -p ./bin - -# Copy app source code and AOT compile it. -COPY . . -# Ensure packages are still up-to-date if anything has changed -RUN flutter pub get --offline -# Compile the dart service into an exe -RUN dart compile exe ./${HANDLER} -o bin/main - -# Start from scratch and copy in the necessary runtime files -FROM alpine - -COPY --from=build /runtime/ / -COPY --from=build /app/bin/main /app/bin/ - -ENTRYPOINT ["/app/bin/main"] -``` +- Jump into our [Discord server](https://nitric.io/chat) -We can also add a `.dockerignore` to optimise our image further: +- Ask questions in [GitHub discussions](https://github.com/nitrictech/nitric/discussions) -```txt {{ label: "docker/flutter.dockerignore" }} -build -test +- Find us on [Twitter](https://twitter.com/nitric_io) -.nitric -.idea -.dart_tool -.git -docker - -android -ios -linux -macos -web -windows -``` - -### AWS - - - Cloud deployments incur costs and while most of these resource are available - with free tier pricing you should consider the costs of the deployment. - - -Now that the application has been configured for deployment, let's try deploying it with the `up` command. - -```bash -nitric up - -API Endpoints: -────────────── -main: https://xxxxxxxx.execute-api.us-east-1.amazonaws.com -``` - -Once we have our API, we can update our flutter app to use the new endpoint. Go into the `FavouritesProvider` and set the `baseApiUrl` to your AWS endpoint. - -```dart {{ label: "lib/providers/favourites.dart" }} -class FavouritesProvider extends ChangeNotifier { - final baseApiUrl = "https://xxxxxxxx.execute-api.us-east-1.amazonaws.com"; -``` - -When you're done testing your application you can tear it down from the cloud, use the `down` command: - -```bash -nitric down -``` +- Send us an [email](mailto:maintainers@nitric.io) From ed5392827b3bc85135d4a6626aee328116de069a Mon Sep 17 00:00:00 2001 From: Ryan Cartwright Date: Wed, 9 Oct 2024 15:52:29 +1100 Subject: [PATCH 4/4] update nitric version --- v1/flutter/docker/flutter.dockerfile | 40 +++ v1/flutter/docker/flutter.dockerignore | 13 + v1/flutter/lib/favorite.dart | 13 + v1/flutter/lib/favourite.dart | 13 - v1/flutter/lib/main.dart | 7 +- v1/flutter/lib/pages/favorites.dart | 40 +++ v1/flutter/lib/pages/favourites.dart | 38 --- v1/flutter/lib/pages/generator.dart | 38 +-- v1/flutter/lib/pages/home.dart | 19 +- v1/flutter/lib/providers/favorites.dart | 78 +++++ v1/flutter/lib/providers/favourites.dart | 78 ----- v1/flutter/lib/services/main.dart | 55 ++-- v1/flutter/nitric.yaml | 2 +- v1/flutter/pubspec.lock | 373 ----------------------- v1/flutter/pubspec.yaml | 2 +- 15 files changed, 240 insertions(+), 569 deletions(-) create mode 100644 v1/flutter/docker/flutter.dockerfile create mode 100644 v1/flutter/docker/flutter.dockerignore create mode 100644 v1/flutter/lib/favorite.dart delete mode 100644 v1/flutter/lib/favourite.dart create mode 100644 v1/flutter/lib/pages/favorites.dart delete mode 100644 v1/flutter/lib/pages/favourites.dart create mode 100644 v1/flutter/lib/providers/favorites.dart delete mode 100644 v1/flutter/lib/providers/favourites.dart delete mode 100644 v1/flutter/pubspec.lock diff --git a/v1/flutter/docker/flutter.dockerfile b/v1/flutter/docker/flutter.dockerfile new file mode 100644 index 0000000..fafdfae --- /dev/null +++ b/v1/flutter/docker/flutter.dockerfile @@ -0,0 +1,40 @@ +FROM dart:stable AS build + +# The Nitric CLI will provide the HANDLER arg with the location of our service +ARG HANDLER +WORKDIR /app + +ENV DEBIAN_FRONTEND=noninteractive + +# download Flutter SDK from Flutter Github repo +RUN git clone https://github.com/flutter/flutter.git /usr/local/flutter + +ENV DEBIAN_FRONTEND=dialog + +# Set flutter environment path +ENV PATH="/usr/local/flutter/bin:/usr/local/flutter/bin/cache/dart-sdk/bin:${PATH}" + +# Run flutter doctor +RUN flutter doctor + +# Resolve app dependencies. +COPY pubspec.* ./ +RUN flutter pub get + +# Ensure the ./bin folder exists +RUN mkdir -p ./bin + +# Copy app source code and AOT compile it. +COPY . . +# Ensure packages are still up-to-date if anything has changed +RUN flutter pub get --offline +# Compile the dart service into an exe +RUN dart compile exe ./${HANDLER} -o bin/main + +# Start from scratch and copy in the necessary runtime files +FROM alpine + +COPY --from=build /runtime/ / +COPY --from=build /app/bin/main /app/bin/ + +ENTRYPOINT ["/app/bin/main"] \ No newline at end of file diff --git a/v1/flutter/docker/flutter.dockerignore b/v1/flutter/docker/flutter.dockerignore new file mode 100644 index 0000000..6f67a3d --- /dev/null +++ b/v1/flutter/docker/flutter.dockerignore @@ -0,0 +1,13 @@ +build +test +.nitric +.idea +.dart_tool +.git +docker +android +ios +linux +macos +web +windows \ No newline at end of file diff --git a/v1/flutter/lib/favorite.dart b/v1/flutter/lib/favorite.dart new file mode 100644 index 0000000..62470f7 --- /dev/null +++ b/v1/flutter/lib/favorite.dart @@ -0,0 +1,13 @@ +class Favorite { + /// The name of the favorite + String name; + + Favorite(this.name); + + /// Convert a json decodable map to a Favorite object + Favorite.fromJson(Map json) : name = json['name']; + + /// Convert a Favorite object to a json encodable + static Map toJson(Favorite favorite) => + {'name': favorite.name}; +} diff --git a/v1/flutter/lib/favourite.dart b/v1/flutter/lib/favourite.dart deleted file mode 100644 index 2772ea3..0000000 --- a/v1/flutter/lib/favourite.dart +++ /dev/null @@ -1,13 +0,0 @@ -class Favourite { - /// The name of the favourite - String name; - - Favourite(this.name); - - /// Convert a json decodable map to a Favourite object - Favourite.fromJson(Map json) : name = json['name']; - - /// Convert a Favourite object to a json encodable - static Map toJson(Favourite favourite) => - {'name': favourite.name}; -} diff --git a/v1/flutter/lib/main.dart b/v1/flutter/lib/main.dart index 842a13d..51a456e 100644 --- a/v1/flutter/lib/main.dart +++ b/v1/flutter/lib/main.dart @@ -1,19 +1,18 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:word_generator/pages/home.dart'; -import 'package:word_generator/providers/favourites.dart'; +import 'package:word_generator/providers/favorites.dart'; import 'package:word_generator/providers/word.dart'; void main() => runApp(Application()); class Application extends StatelessWidget { const Application({super.key}); - @override Widget build(BuildContext context) { return MultiProvider( providers: [ - ChangeNotifierProvider(create: (context) => FavouritesProvider()), + ChangeNotifierProvider(create: (context) => FavoritesProvider()), ChangeNotifierProvider(create: (context) => WordProvider()), ], child: MaterialApp( @@ -22,7 +21,7 @@ class Application extends StatelessWidget { useMaterial3: true, colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), ), - home: HomePage(), + home: HomePage(), // <-- Change here ), ); } diff --git a/v1/flutter/lib/pages/favorites.dart b/v1/flutter/lib/pages/favorites.dart new file mode 100644 index 0000000..80e599c --- /dev/null +++ b/v1/flutter/lib/pages/favorites.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:word_generator/providers/favorites.dart'; + +class FavoritesPage extends StatelessWidget { + const FavoritesPage({super.key}); + + @override + Widget build(BuildContext context) { + var favorites = context.watch(); + + // If the favorites list is still loading then show a spinning circle. + if (favorites.isLoading) { + return const Center( + child: SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator(color: Colors.blue), + )); + } + + // Otherwise return a list of all the favorites + return ListView( + children: [ + Padding( + padding: const EdgeInsets.all(20), + // Display how many favorites there are + child: Text('You have ' + '${favorites.favorites.length} favorites:'), + ), + // Create a list tile for every favorite in the list of favorites + for (var favorite in favorites.favorites) + ListTile( + leading: Icon(Icons.favorite), // <- A heart icon + title: Text(favorite.name), + ), + ], + ); + } +} diff --git a/v1/flutter/lib/pages/favourites.dart b/v1/flutter/lib/pages/favourites.dart deleted file mode 100644 index 03cf0a3..0000000 --- a/v1/flutter/lib/pages/favourites.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:word_generator/providers/favourites.dart'; - -class FavouritesPage extends StatelessWidget { - @override - Widget build(BuildContext context) { - var favourites = context.watch(); - - // If the favourites list is still loading then show a spinning circle. - if (favourites.isLoading) { - return Center( - child: SizedBox( - width: 40, - height: 40, - child: CircularProgressIndicator(color: Colors.blue), - )); - } - - // Otherwise return a list of all the favourites - return ListView( - children: [ - Padding( - padding: const EdgeInsets.all(20), - // Display how many favourites there are - child: Text('You have ' - '${favourites.favourites.length} favourites:'), - ), - // Create a list tile for every favourite in the list of favourites - for (var favourite in favourites.favourites) - ListTile( - leading: Icon(Icons.favorite), // <- A heart icon - title: Text(favourite.name), - ), - ], - ); - } -} diff --git a/v1/flutter/lib/pages/generator.dart b/v1/flutter/lib/pages/generator.dart index 1cc4153..3b48167 100644 --- a/v1/flutter/lib/pages/generator.dart +++ b/v1/flutter/lib/pages/generator.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:word_generator/providers/favourites.dart'; +import 'package:word_generator/providers/favorites.dart'; import 'package:word_generator/providers/word.dart'; class GeneratorPage extends StatelessWidget { @@ -12,12 +12,12 @@ class GeneratorPage extends StatelessWidget { color: theme.colorScheme.onPrimary, ); - final favourites = context.watch(); + final favorites = context.watch(); final words = context.watch(); IconData icon = Icons.favorite_border; - if (favourites.hasFavourite(words.current)) { + if (favorites.hasFavorite(words.current)) { icon = Icons.favorite; } @@ -25,18 +25,18 @@ class GeneratorPage extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Expanded( + const Expanded( // <- allows the list to extend to the top of the page flex: 3, child: HistoryListView(), // <- Add the history list view here ), - SizedBox(height: 10), + const SizedBox(height: 10), Card( color: theme.colorScheme.primary, child: Padding( padding: const EdgeInsets.all(20), child: AnimatedSize( - duration: Duration(milliseconds: 200), + duration: const Duration(milliseconds: 200), child: MergeSemantics( child: Wrap( children: [ @@ -54,27 +54,27 @@ class GeneratorPage extends StatelessWidget { ), ), ), - SizedBox(height: 10), + const SizedBox(height: 10), Row( mainAxisSize: MainAxisSize.min, children: [ ElevatedButton.icon( onPressed: () { - favourites.toggleFavourite(words.current); + favorites.toggleFavorite(words.current); }, icon: Icon(icon), - label: Text('Like'), + label: const Text('Like'), ), - SizedBox(width: 10), + const SizedBox(width: 10), ElevatedButton( onPressed: () { words.getNext(); }, - child: Text('Next'), + child: const Text('Next'), ), ], ), - Spacer(flex: 2), + const Spacer(flex: 2), ], ), ); @@ -101,7 +101,7 @@ class _HistoryListViewState extends State { @override Widget build(BuildContext context) { - final favourites = context.watch(); + final favorites = context.watch(); final words = context.watch(); // Set the key of the animated list to the WordProvider GlobalKey so it can be manipulated from there @@ -118,7 +118,7 @@ class _HistoryListViewState extends State { key: _key, // Reverse the list so the latest is on the bottom reverse: true, - padding: EdgeInsets.only(top: 100), + padding: const EdgeInsets.only(top: 100), initialItemCount: words.history.length, // Build each item in the list, will be run initially and when a new word pair is added. itemBuilder: (context, index, animation) { @@ -128,12 +128,12 @@ class _HistoryListViewState extends State { child: Center( child: TextButton.icon( onPressed: () { - favourites.toggleFavourite(pair); + favorites.toggleFavorite(pair); }, - // If the word pair was favourited, show a heart next to it - icon: favourites.hasFavourite(pair) - ? Icon(Icons.favorite, size: 12) - : SizedBox(), + // If the word pair was favorited, show a heart next to it + icon: favorites.hasFavorite(pair) + ? const Icon(Icons.favorite, size: 12) + : const SizedBox(), label: Text( pair.asLowerCase, ), diff --git a/v1/flutter/lib/pages/home.dart b/v1/flutter/lib/pages/home.dart index 2715958..ca44b81 100644 --- a/v1/flutter/lib/pages/home.dart +++ b/v1/flutter/lib/pages/home.dart @@ -1,48 +1,45 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:word_generator/providers/favourites.dart'; - -import 'favourites.dart'; +import 'package:word_generator/providers/favorites.dart'; +import 'favorites.dart'; import 'generator.dart'; class HomePage extends StatefulWidget { + const HomePage({super.key}); + @override State createState() => _HomePageState(); } class _HomePageState extends State { var selectedIndex = 0; - @override void initState() { super.initState(); - context.read().fetchData(); + context.read().fetchData(); } @override Widget build(BuildContext context) { var colorScheme = Theme.of(context).colorScheme; - Widget page; switch (selectedIndex) { case 0: page = GeneratorPage(); case 1: - page = FavouritesPage(); + page = FavoritesPage(); default: throw UnimplementedError('no widget for $selectedIndex'); } - // The container for the current page, with its background color // and subtle switching animation. var mainArea = ColoredBox( color: colorScheme.surfaceContainerHighest, child: AnimatedSwitcher( - duration: Duration(milliseconds: 200), + duration: const Duration(milliseconds: 200), child: page, ), ); - return Scaffold( body: LayoutBuilder( builder: (context, constraints) { @@ -52,7 +49,7 @@ class _HomePageState extends State { Expanded(child: mainArea), SafeArea( child: BottomNavigationBar( - items: [ + items: const [ BottomNavigationBarItem( icon: Icon(Icons.home), label: 'Home', diff --git a/v1/flutter/lib/providers/favorites.dart b/v1/flutter/lib/providers/favorites.dart new file mode 100644 index 0000000..aa6ec2c --- /dev/null +++ b/v1/flutter/lib/providers/favorites.dart @@ -0,0 +1,78 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:english_words/english_words.dart'; +import 'package:word_generator/favorite.dart'; + +class FavoritesProvider extends ChangeNotifier { + final baseApiUrl = "http://localhost:4001"; + + List _favorites = []; + bool _isLoading = false; + + /// Get a list of active favorite + List get favorites => _favorites; + + /// Check whether the data is loading or not + bool get isLoading => _isLoading; + + /// Updates the list of favorites whilst returning a Future with the list of favorites. + /// Sets isLoading to true when the favorites have been fetched + Future> fetchData() async { + _isLoading = true; + notifyListeners(); + + final response = await http.get(Uri.parse("$baseApiUrl/favorites")); + + if (response.statusCode == 200) { + // Decode the json data into an iterable list of unknown objects + Iterable rawFavorites = jsonDecode(response.body); + + // Map over the iterable, converting it to a list of Favorite objects + _favorites = List.from( + rawFavorites.map((model) => Favorite.fromJson(model))); + } else { + throw Exception('Failed to load data'); + } + + _isLoading = false; + notifyListeners(); + + return _favorites; + } + + bool hasFavorite(WordPair pair) { + if (isLoading) { + return false; + } + + return _favorites.any((f) => f.name == pair.asLowerCase); + } + + /// Toggles whether a favorite being liked or unliked. + Future toggleFavorite(WordPair pair) async { + // Convert the word pair into a json encoded + final encodedFavorites = + jsonEncode(Favorite.toJson(Favorite(pair.asLowerCase))); + + // Makes a post request to the toggle favorite route. + final response = await http.post(Uri.parse("$baseApiUrl/favorite"), + body: encodedFavorites); + + // If the response doesn't respond with OK, throw an error + if (response.statusCode != 200) { + throw Exception("Failed to add favorite: ${response.body}"); + } + + // If it was successfully removed update favorites + if (hasFavorite(pair)) { + // Remove the favorite for + _favorites.removeWhere((f) => f.name == pair.asLowerCase); + } else { + _favorites.add(Favorite(pair.asLowerCase)); + } + + notifyListeners(); + } +} diff --git a/v1/flutter/lib/providers/favourites.dart b/v1/flutter/lib/providers/favourites.dart deleted file mode 100644 index 274ebe6..0000000 --- a/v1/flutter/lib/providers/favourites.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; -import 'package:english_words/english_words.dart'; -import 'package:word_generator/favourite.dart'; - -class FavouritesProvider extends ChangeNotifier { - final baseApiUrl = "http://localhost:4001"; - - List _favourites = []; - bool _isLoading = false; - - /// Get a list of active favourites - List get favourites => _favourites; - - /// Check whether the data is loading or not - bool get isLoading => _isLoading; - - /// Updates the list of favourites whilst returning a Future with the list of favourites. - /// Sets isLoading to true when the favourites have been fetched - Future> fetchData() async { - _isLoading = true; - notifyListeners(); - - final response = await http.get(Uri.parse("$baseApiUrl/favourites")); - - if (response.statusCode == 200) { - // Decode the json data into an iterable list of unknown objects - Iterable rawFavourites = jsonDecode(response.body); - - // Map over the iterable, converting it to a list of Favourite objects - _favourites = List.from( - rawFavourites.map((model) => Favourite.fromJson(model))); - } else { - throw Exception('Failed to load data'); - } - - _isLoading = false; - notifyListeners(); - - return _favourites; - } - - bool hasFavourite(WordPair pair) { - if (isLoading) { - return false; - } - - return _favourites.any((f) => f.name == pair.asLowerCase); - } - - /// Toggles whether a favourite being liked or unliked. - Future toggleFavourite(WordPair pair) async { - // Convert the word pair into a json encoded - final encodedFavourites = - jsonEncode(Favourite.toJson(Favourite(pair.asLowerCase))); - - // Makes a post request to the toggle favourite route. - final response = await http.post(Uri.parse("$baseApiUrl/favourite"), - body: encodedFavourites); - - // If the response doesn't respond with OK, throw an error - if (response.statusCode != 200) { - throw Exception("Failed to add favourite: ${response.body}"); - } - - // If it was successfully removed update favourites - if (hasFavourite(pair)) { - // Remove the favourite for - _favourites.removeWhere((f) => f.name == pair.asLowerCase); - } else { - _favourites.add(Favourite(pair.asLowerCase)); - } - - notifyListeners(); - } -} diff --git a/v1/flutter/lib/services/main.dart b/v1/flutter/lib/services/main.dart index b8c3f85..8af4b29 100644 --- a/v1/flutter/lib/services/main.dart +++ b/v1/flutter/lib/services/main.dart @@ -1,64 +1,57 @@ import 'dart:convert'; +import 'package:word_generator/cors.dart'; +import 'package:word_generator/favorite.dart'; import 'package:nitric_sdk/nitric.dart'; -import 'package:word_generator/cors.dart'; -import 'package:word_generator/favourite.dart'; -void main() { +void main() async { final api = Nitric.api("main", opts: ApiOptions(middlewares: [addCors])); - final favouritesKV = Nitric.kv("favourites").allow([ + final favoritesKV = Nitric.kv("favorites").allow([ KeyValueStorePermission.get, KeyValueStorePermission.set, KeyValueStorePermission.delete ]); - api.options("/favourites", optionsHandler); - api.options("/favourite", optionsHandler); - - api.get("/favourites", (ctx) async { - // Get a list of all the keys in the store - var keyStream = await favouritesKV.keys(); + api.options("/favorites", optionsHandler); + api.get("/favorites", (ctx) async { + var keys = await favoritesKV.keys(); - // Convert the keys to a list of favourites - var favourites = await keyStream.asyncMap((k) async { - final favourite = await favouritesKV.get(k); + var favorites = await keys.asyncMap((k) async { + final favorite = await favoritesKV.get(k); - return favourite; + return favorite; }).toList(); - // Return the body as a list of favourites - ctx.res.body = jsonEncode(favourites); + ctx.res.body = jsonEncode(favorites); return ctx; }); - api.post("/favourite", (ctx) async { + api.options("/favorite", optionsHandler); + api.post("/favorite", (ctx) async { final req = ctx.req.json(); - // convert the request json to a Favourite object - final favourite = Favourite.fromJson(req); + final favorite = Favorite.fromJson(req); - // search for the key, filtering by the name of the favourite - final stream = await favouritesKV.keys(prefix: favourite.name); + var exists = false; - // checks if the favourite exists in the list of keys - final exists = await stream.any((f) => f == favourite.name); + final keys = await favoritesKV.keys(prefix: favorite.name); + + await for (final key in keys) { + if (key == favorite.name) { + exists = true; + } + } // if it exists delete and return if (exists) { - await favouritesKV.delete(favourite.name); + await favoritesKV.delete(favorite.name); return ctx; } - // if it doesn't exist, create it - try { - await favouritesKV.set(favourite.name, Favourite.toJson(favourite)); - } catch (e) { - ctx.res.status = 500; - ctx.res.body = "could not set ${favourite.name}"; - } + await favoritesKV.set(favorite.name, Favorite.toJson(favorite)); return ctx; }); diff --git a/v1/flutter/nitric.yaml b/v1/flutter/nitric.yaml index 64ea54e..c3f15fc 100644 --- a/v1/flutter/nitric.yaml +++ b/v1/flutter/nitric.yaml @@ -2,7 +2,7 @@ name: backend services: - match: lib/services/*.dart runtime: flutter - start: dart run --observe $SERVICE_PATH + start: dart run $SERVICE_PATH runtimes: flutter: dockerfile: ./docker/flutter.dockerfile diff --git a/v1/flutter/pubspec.lock b/v1/flutter/pubspec.lock deleted file mode 100644 index 33b8cf8..0000000 --- a/v1/flutter/pubspec.lock +++ /dev/null @@ -1,373 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - archive: - dependency: transitive - description: - name: archive - sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d - url: "https://pub.dev" - source: hosted - version: "3.6.1" - args: - dependency: transitive - description: - name: args - sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" - url: "https://pub.dev" - source: hosted - version: "2.5.0" - async: - dependency: transitive - description: - name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" - url: "https://pub.dev" - source: hosted - version: "2.11.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - characters: - dependency: transitive - description: - name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" - url: "https://pub.dev" - source: hosted - version: "1.3.0" - clock: - dependency: transitive - description: - name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf - url: "https://pub.dev" - source: hosted - version: "1.1.1" - collection: - dependency: transitive - description: - name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a - url: "https://pub.dev" - source: hosted - version: "1.18.0" - crypto: - dependency: transitive - description: - name: crypto - sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27 - url: "https://pub.dev" - source: hosted - version: "3.0.5" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" - english_words: - dependency: "direct main" - description: - name: english_words - sha256: "6a7ef6473a97bd8571b6b641d006a6e58a7c67e65fb6f3d6d1151cb46b0e983c" - url: "https://pub.dev" - source: hosted - version: "4.0.0" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" - url: "https://pub.dev" - source: hosted - version: "1.3.1" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - google_identity_services_web: - dependency: transitive - description: - name: google_identity_services_web - sha256: "5be191523702ba8d7a01ca97c17fca096822ccf246b0a9f11923a6ded06199b6" - url: "https://pub.dev" - source: hosted - version: "0.3.1+4" - googleapis_auth: - dependency: transitive - description: - name: googleapis_auth - sha256: befd71383a955535060acde8792e7efc11d2fccd03dd1d3ec434e85b68775938 - url: "https://pub.dev" - source: hosted - version: "1.6.0" - grpc: - dependency: transitive - description: - name: grpc - sha256: e93ee3bce45c134bf44e9728119102358c7cd69de7832d9a874e2e74eb8cab40 - url: "https://pub.dev" - source: hosted - version: "3.2.4" - http: - dependency: "direct main" - description: - name: http - sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 - url: "https://pub.dev" - source: hosted - version: "1.2.2" - http2: - dependency: transitive - description: - name: http2 - sha256: "9ced024a160b77aba8fb8674e38f70875e321d319e6f303ec18e87bd5a4b0c1d" - url: "https://pub.dev" - source: hosted - version: "2.3.0" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" - url: "https://pub.dev" - source: hosted - version: "4.0.2" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" - url: "https://pub.dev" - source: hosted - version: "10.0.4" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" - url: "https://pub.dev" - source: hosted - version: "3.0.3" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" - url: "https://pub.dev" - source: hosted - version: "3.0.1" - lints: - dependency: transitive - description: - name: lints - sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 - url: "https://pub.dev" - source: hosted - version: "3.0.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb - url: "https://pub.dev" - source: hosted - version: "0.12.16+1" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" - url: "https://pub.dev" - source: hosted - version: "0.8.0" - meta: - dependency: transitive - description: - name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" - url: "https://pub.dev" - source: hosted - version: "1.12.0" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - nitric_sdk: - dependency: "direct main" - description: - name: nitric_sdk - sha256: "18eb075706a18bd51c87dc7b3f065cb3a20998ad3b87f304eded159ff7546ce4" - url: "https://pub.dev" - source: hosted - version: "1.4.0" - path: - dependency: transitive - description: - name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" - url: "https://pub.dev" - source: hosted - version: "1.9.0" - protobuf: - dependency: transitive - description: - name: protobuf - sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - provider: - dependency: "direct main" - description: - name: provider - sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c - url: "https://pub.dev" - source: hosted - version: "6.1.2" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_span: - dependency: transitive - description: - name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" - url: "https://pub.dev" - source: hosted - version: "1.10.0" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.dev" - source: hosted - version: "7.0.0" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" - url: "https://pub.dev" - source: hosted - version: "1.11.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 - url: "https://pub.dev" - source: hosted - version: "2.1.2" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - synchronized: - dependency: transitive - description: - name: synchronized - sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" - url: "https://pub.dev" - source: hosted - version: "3.1.0+1" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 - url: "https://pub.dev" - source: hosted - version: "1.2.1" - test_api: - dependency: transitive - description: - name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" - url: "https://pub.dev" - source: hosted - version: "0.7.0" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c - url: "https://pub.dev" - source: hosted - version: "1.3.2" - uuid: - dependency: transitive - description: - name: uuid - sha256: "83d37c7ad7aaf9aa8e275490669535c8080377cfa7a7004c24dfac53afffaa90" - url: "https://pub.dev" - source: hosted - version: "4.4.2" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" - url: "https://pub.dev" - source: hosted - version: "14.2.1" - web: - dependency: transitive - description: - name: web - sha256: d43c1d6b787bf0afad444700ae7f4db8827f701bc61c255ac8d328c6f4d52062 - url: "https://pub.dev" - source: hosted - version: "1.0.0" -sdks: - dart: ">=3.4.4 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" diff --git a/v1/flutter/pubspec.yaml b/v1/flutter/pubspec.yaml index 6ba01b3..7fde40a 100644 --- a/v1/flutter/pubspec.yaml +++ b/v1/flutter/pubspec.yaml @@ -37,7 +37,7 @@ dependencies: english_words: ^4.0.0 http: ^1.2.2 provider: ^6.1.2 - nitric_sdk: ^1.2.0 + nitric_sdk: ^1.4.0 dev_dependencies: flutter_test: