diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..33826d0 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "pub" + directory: "/" + schedule: + interval: "daily" \ No newline at end of file diff --git a/.github/workflows/melos-component-ci.yml b/.github/workflows/melos-component-ci.yml new file mode 100644 index 0000000..d96d605 --- /dev/null +++ b/.github/workflows/melos-component-ci.yml @@ -0,0 +1,12 @@ +name: Iconica Standard Melos CI Workflow +# Workflow Caller version: 1.0.0 + +on: + pull_request: + workflow_dispatch: + +jobs: + call-global-iconica-workflow: + uses: Iconica-Development/.github/.github/workflows/melos-ci.yml@master + secrets: inherit + permissions: write-all diff --git a/.gitignore b/.gitignore index 173504c..05936c3 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,13 @@ build/ .metadata + +pubspec.lock + +pubspec_overrides.yaml + +example/ios example/web +example/android +example/linux +example/macos diff --git a/CHANGELOG.md b/CHANGELOG.md index 15ef64a..25fcb3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ -## 1.0.0 +## 2.0.0 -* Update introduction_widget and introduction_service +* Initial release of working flutter_introduction mono project. ## 0.0.1 -* Initial release. +* Initial release of combined flutter_introduction melos project diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3ad674e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,194 @@ +# Contributing +First off, thanks for taking the time to contribute! ❤️ + +All types of contributions are encouraged and valued. +See the [Table of Contents](#table-of-contents) for different ways to help and details about how we handle them. +Please make sure to read the relevant section before making your contribution. +It will make it a lot easier for us maintainers and smooth out the experience for all involved. +Iconica looks forward to your contributions. 🎉 + +## Table of contents + - [Code of conduct](#code-of-conduct) + - [I Have a Question](#i-have-a-question) + - [I Want To Contribute](#i-want-to-contribute) + - [Reporting Bugs](#reporting-bugs) + - [Contributing code](#contributing-code) + +## Code of conduct + +### Legal notice +When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. +All accepted pull requests and other additions to this project will be considered intellectual property of Iconica. + +All repositories should be kept clean of jokes, easter eggs and other unnecessary additions. + +## I have a question + +If you want to ask a question, we assume that you have read the available documentation found within the code. +Before you ask a question, it is best to search for existing issues that might help you. +In case you have found a suitable issue but still need clarification, you can ask your question +It is also advisable to search the internet for answers first. + +If you then still feel the need to ask a question and need clarification, we recommend the following: + +- Open an issue. +- Provide as much context as you can about what you're running into. + +We will then take care of the issue as soon as possible. + +## I want to contribute + +### Reporting bugs + + +**Before submitting a bug report** + +A good bug report shouldn't leave others needing to chase you up for more information. +Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. +Please complete the following steps in advance to help us fix any potential bug as fast as possible. + +- Make sure that you are using the latest version. +- Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (If you are looking for support, you might want to check [this section](#i-have-a-question)). +- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error. +- Also make sure to search the internet (including Stack Overflow) to see if users outside of Iconica have discussed the issue. +- Collect information about the bug: +- Stack trace (Traceback) +- OS, Platform and Version (Windows, Linux, macOS, x86, ARM) +- Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant. +- Time and date of occurance +- Describe the expected result and actual result +- Can you reliably reproduce the issue? And can you also reproduce it with older versions? Describe all steps that lead to the bug. + +Once it's filed: + +- The project team will label the issue accordingly. +- A team member will try to reproduce the issue with your provided steps. + If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for additional information. +- If the team is able to reproduce the issue, it will be moved into the backlog, as well as marked with a priority, and the issue will be left to be [implemented by someone](#contributing-code). + +### Contributing code + +When you start working on your contribution, make sure you are aware of the relevant documentation and the functionality of the component you are working on. + +When writing code, follow the style guidelines set by Dart: [Effective Dart](https://Dart.dev/guides/language/effective-Dart). This contains most information you will need to write clean Dart code that is well documented. + +**Documentation** + +As Effective Dart indicates, documenting your public methods with Dart doc comments is recommended. +Aside from Effective Dart, we require specific information in the documentation of a method: + +At the very least, your documentation should first name what the code does, then followed below by requirements for calling the method, the result of the method. +Any references to internal variables or other methods should be done through [var] to indicate a reference. + +If the method or class is complex enough (determined by the reviewers) an example is required. +If unsure, add an example in the docs using code blocks. + +For classes and methods, document the individual parameters with their implications. + +> Tip: Remember that the shortest documentation can be written by having good descriptive names in the first place. + +An example: +```Dart +library iconica_utilities.bidirectional_sorter; + +part 'sorter.Dart'; +part 'enum.Dart'; + +/// Generic sort method, allow sorting of list with primitives or complex types. +/// Uses [SortDirection] to determine the direction, either Ascending or Descending, +/// Gets called on [List] toSort of type [T] which cannot be shorter than 2. +/// Optionally for complex types a [Comparable] [Function] can be given to compare complex types. +/// ``` +/// List objects = []; +/// for (int i = 0; i < 10; i++) { +/// objects.add(TestObject(name: "name", id: i)); +/// } +/// +/// sort( +/// SortDirection.descending, objects, (object) => object.id); +/// +/// ``` +/// In the above example a list of TestObjects is created, and then sorted in descending order. +/// If the implementation of TestObject is as following: +/// ``` +/// class TestObject { +/// final String name; +/// final int id; +/// +/// TestObject({required this.name, required this.id}); +/// } +/// ``` +/// And the list is logged to the console, the following will appear: +/// ``` +/// [name9, name8, name7, name6, name5, name4, name3, name2, name1, name0] +/// ``` + +void sort( + /// Determines the sorting direction, can be either Ascending or Descending + SortDirection sortDirection, + + /// Incoming list, which gets sorted + List toSort, [ + + /// Optional comparable, which is only necessary for complex types + SortFieldGetter? sortValueCallback, +]) { + if (toSort.length < 2) return; + assert( + toSort.whereType().isNotEmpty || sortValueCallback != null); + BidirectionalSorter( + sortInstructions: >[ + SortInstruction( + sortValueCallback ?? (t) => t as Comparable, sortDirection), + ], + ).sort(toSort); +} + +/// same functionality as [sort] but with the added functionality +/// of sorting multiple values +void sortMulti( + /// Incoming list, which gets sorted + List toSort, + + /// list of comparables to sort multiple values at once, + /// priority based on index + List> sortValueCallbacks, +) { + if (toSort.length < 2) return; + assert(sortValueCallbacks.isNotEmpty); + BidirectionalSorter( + sortInstructions: sortValueCallbacks, + ).sort(toSort); +} + +``` + + + +**Tests** + +For each public method that was created, excluding widgets, which contains any form of logic (e.g. Calculations, predicates or major side-effects) tests are required. + +A set of tests is written for each method, covering at least each path within the method. For example: + +```Dart +void foo() { + try { + var bar = doSomething(); + if (bar) { + doSomethingElse(); + } else { + doSomethingCool(); + } + } catch (_) { + displayError(); + } +} +``` +The method above should result in 3 tests: + +1. A test for the path leading to displayError by the cause of an exception +2. A test for if bar is true, resulting in doSomethingElse() +3. A test for if bar is false, resulting in the doSomethingCool() method being called. + +This means that we require 100% coverage of each method you test. \ No newline at end of file diff --git a/LICENSE b/LICENSE index fe891f8..1070e69 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2022 Iconica, All rights reserved. +Copyright (c) 2023 Iconica, All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/README.md b/README.md index 02fe8ec..2a71b0e 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,39 @@ - +Monorepo for the Flutter introduction package. Including the following packages: +- Flutter Introduction + Main package for Flutter Introduction including an example. -TODO: Put a short description of the package here that helps potential users -know whether this package might be useful for them. +- Flutter Introduction Firebase + Package to provide content from firebase. -## Features +- Flutter Introduction Interface + Interface regarding data for the Introduction widget, like whether to show the introduction or not. -TODO: List what your package can do. Maybe include images, gifs, or videos. +- Flutter Introduction Service + Service to handle actions done in the Introduction widget. -## Getting started +- Flutter Introduction Shared Preferences + Implementation of the interface with the use of shared preferences. -TODO: List prerequisites and provide or point to information on how to -start using the package. +- Flutter Introduction Widget + The actual widget showing the Introduction widget. -## Usage +## How to use +The simple way to use this package is by using the flutter_introduction package. An example is included if needed. -TODO: Include short and useful examples for package users. Add longer examples -to `/example` folder. +If needed a custom implementation can be made on the interface if the shared preferences doesn't suffice. -```dart -const like = 'sample'; -``` +## Issues -## Additional information +Please file any issues, bugs or feature request as an issue on our [GitHub](https://github.com/Iconica-Development/flutter_introduction/pulls) page. Commercial support is available if you need help with integration with your app or services. You can contact us at [support@iconica.nl](mailto:support@iconica.nl). -TODO: Tell users more about the package: where to find more information, how to -contribute to the package, how to file issues, what response they can expect -from the package authors, and more. +## Want to contribute + +If you would like to contribute to the plugin (e.g. by improving the documentation, solving a bug or adding a cool new feature), please carefully review our [contribution guide](../CONTRIBUTING.md) and send us your [pull request](https://github.com/Iconica-Development/flutter_introduction/pulls). + +## Author + +This `flutter_introduction` for Flutter is developed by [Iconica](https://iconica.nl). You can contact us at \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml deleted file mode 100644 index a5744c1..0000000 --- a/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -include: package:flutter_lints/flutter.yaml - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/example/lib/main.dart b/example/lib/main.dart deleted file mode 100644 index 01f3a6c..0000000 --- a/example/lib/main.dart +++ /dev/null @@ -1,100 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause - -import 'package:flutter/material.dart'; -import 'package:flutter_introduction/flutter_introduction.dart'; -import 'package:flutter_introduction_shared_preferences/flutter_introduction_shared_preferences.dart'; - -void main() { - runApp(const MyApp()); -} - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - primarySwatch: Colors.blue, - ), - home: const MyHomePage(), - ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key}); - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - IntroductionService service = - IntroductionService(SharedPreferencesIntroductionDataProvider()); - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Introduction( - options: IntroductionOptions( - pages: [ - IntroductionPage( - title: const Text('First page'), - text: const Text('Wow a page'), - graphic: const FlutterLogo(), - ), - IntroductionPage( - title: const Text('Second page'), - text: const Text('Another page'), - graphic: const FlutterLogo(), - ), - IntroductionPage( - title: const Text('Third page'), - text: const Text('The final page of this app'), - graphic: const FlutterLogo(), - ), - ], - introductionTranslations: const IntroductionTranslations( - skipButton: 'Skip it!', - nextButton: 'Previous', - previousButton: 'Next', - finishButton: 'To the app!', - ), - tapEnabled: true, - displayMode: IntroductionDisplayMode.multiPageHorizontal, - buttonMode: IntroductionScreenButtonMode.text, - indicatorMode: IndicatorMode.dash, - skippable: true, - buttonBuilder: (context, onPressed, child) => - ElevatedButton(onPressed: onPressed, child: child), - ), - service: service, - navigateTo: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) { - return const Home(); - }, - ), - ); - }, - child: const Home(), - ), - ); - } -} - -class Home extends StatelessWidget { - const Home({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return Container(); - } -} diff --git a/example/pubspec.lock b/example/pubspec.lock deleted file mode 100644 index b3b9d7c..0000000 --- a/example/pubspec.lock +++ /dev/null @@ -1,542 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a - url: "https://pub.dev" - source: hosted - version: "61.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562 - url: "https://pub.dev" - source: hosted - version: "5.13.0" - args: - dependency: transitive - description: - name: args - sha256: b003c3098049a51720352d219b0bb5f219b60fbfb68e7a4748139a06a5676515 - url: "https://pub.dev" - source: hosted - version: "2.3.1" - 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" - build: - dependency: transitive - description: - name: build - sha256: "3fbda25365741f8251b39f3917fb3c8e286a96fd068a5a242e11c2012d495777" - url: "https://pub.dev" - source: hosted - version: "2.3.1" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: d7a9cd57c215bdf8d502772447aa6b52a8ab3f956d25d5fdea6ef1df2d2dad60 - url: "https://pub.dev" - source: hosted - version: "8.4.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" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "02ce3596b459c666530f045ad6f96209474e8fee6e4855940a3cee65fb872ec5" - url: "https://pub.dev" - source: hosted - version: "4.3.0" - collection: - dependency: transitive - description: - name: collection - sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c" - url: "https://pub.dev" - source: hosted - version: "1.17.1" - convert: - dependency: transitive - description: - name: convert - sha256: "196284f26f69444b7f5c50692b55ec25da86d9e500451dc09333bf2e3ad69259" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - crypto: - dependency: transitive - description: - name: crypto - sha256: aa274aa7774f8964e4f4f38cc994db7b6158dd36e9187aaceaddc994b35c6c67 - url: "https://pub.dev" - source: hosted - version: "3.0.2" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: e35129dc44c9118cee2a5603506d823bab99c68393879edb440e0090d07586be - url: "https://pub.dev" - source: hosted - version: "1.0.5" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: "7a03456c3490394c8e7665890333e91ae8a49be43542b616e414449ac358acd4" - url: "https://pub.dev" - source: hosted - version: "2.2.4" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" - url: "https://pub.dev" - source: hosted - version: "1.3.1" - ffi: - dependency: transitive - description: - name: ffi - sha256: ed5337a5660c506388a9f012be0288fb38b49020ce2b45fe1f8b8323fe429f99 - url: "https://pub.dev" - source: hosted - version: "2.0.2" - file: - dependency: transitive - description: - name: file - sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" - url: "https://pub.dev" - source: hosted - version: "6.1.4" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: "04be3e934c52e082558cc9ee21f42f5c1cd7a1262f4c63cd0357c08d5bba81ec" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_data_interface: - dependency: transitive - description: - path: "." - ref: "1.0.0" - resolved-ref: "500ed1d08095b33387ae3aa4ed1a2ad4d2fb2ac3" - url: "https://github.com/Iconica-Development/flutter_data_interface.git" - source: git - version: "1.0.0" - flutter_introduction: - dependency: "direct main" - description: - path: ".." - relative: true - source: path - version: "1.0.0" - flutter_introduction_interface: - dependency: transitive - description: - path: "." - ref: "1.0.0" - resolved-ref: "2bb986c60a4ce7370a46c5db4cc3bc82a7f96884" - url: "https://github.com/Iconica-Development/flutter_introduction_interface.git" - source: git - version: "1.0.0" - flutter_introduction_service: - dependency: transitive - description: - path: "." - ref: "1.0.0" - resolved-ref: d8af4b73f1c951dd5fb72d24b07d854ee64a7ee1 - url: "https://github.com/Iconica-Development/flutter_introduction_service.git" - source: git - version: "1.0.0" - flutter_introduction_shared_preferences: - dependency: "direct main" - description: - path: "." - ref: "1.0.0" - resolved-ref: fd976b68e0b44bc6fec7d6570f1e410a98ae3d61 - url: "https://github.com/Iconica-Development/flutter_introduction_shared_preferences.git" - source: git - version: "1.0.0" - flutter_introduction_widget: - dependency: transitive - description: - path: "." - ref: "3.0.0" - resolved-ref: ae72ec10ea33eea5afbe62913992bf5215b4ad78 - url: "https://github.com/Iconica-Development/flutter_introduction_widget.git" - source: git - version: "3.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: aeb0b80a8b3709709c9cc496cdc027c5b3216796bc0af0ce1007eaf24464fd4c - url: "https://pub.dev" - source: hosted - version: "2.0.1" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - glob: - dependency: transitive - description: - name: glob - sha256: c51b4fdfee4d281f49b8c957f1add91b815473597f76bcf07377987f66a55729 - url: "https://pub.dev" - source: hosted - version: "2.1.0" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" - lints: - dependency: transitive - description: - name: lints - sha256: "5cfd6509652ff5e7fe149b6df4859e687fca9048437857cb2e65c8d780f396e3" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - logging: - dependency: transitive - description: - name: logging - sha256: c0bbfe94d46aedf9b8b3e695cf3bd48c8e14b35e3b2c639e0aa7755d589ba946 - url: "https://pub.dev" - source: hosted - version: "1.1.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: "6501fbd55da300384b768785b83e5ce66991266cec21af89ab9ae7f5ce1c4cbb" - url: "https://pub.dev" - source: hosted - version: "0.12.15" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: d92141dc6fe1dad30722f9aa826c7fbc896d021d792f80678280601aff8cf724 - url: "https://pub.dev" - source: hosted - version: "0.2.0" - meta: - dependency: transitive - description: - name: meta - sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - mockito: - dependency: transitive - description: - name: mockito - sha256: "8b46d7eb40abdda92d62edd01546051f0c27365e65608c284de336dccfef88cc" - url: "https://pub.dev" - source: hosted - version: "5.4.1" - package_config: - dependency: transitive - description: - name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - path: - dependency: transitive - description: - name: path - sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" - url: "https://pub.dev" - source: hosted - version: "1.8.3" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: ffbb8cc9ed2c9ec0e4b7a541e56fd79b138e8f47d2fb86815f15358a349b3b57 - url: "https://pub.dev" - source: hosted - version: "2.1.11" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "57585299a729335f1298b43245842678cb9f43a6310351b18fb577d6e33165ec" - url: "https://pub.dev" - source: hosted - version: "2.0.6" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: d3f80b32e83ec208ac95253e0cd4d298e104fbc63cb29c5c69edaed43b0c69d6 - url: "https://pub.dev" - source: hosted - version: "2.1.6" - platform: - dependency: transitive - description: - name: platform - sha256: "4a451831508d7d6ca779f7ac6e212b4023dd5a7d08a27a63da33756410e32b76" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "6a2128648c854906c53fa8e33986fc0247a1116122f9534dd20e3ab9e16a32bc" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - process: - dependency: transitive - description: - name: process - sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09" - url: "https://pub.dev" - source: hosted - version: "4.2.4" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "816c1a640e952d213ddd223b3e7aafae08cd9f8e1f6864eed304cc13b0272b07" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - shared_preferences: - dependency: transitive - description: - name: shared_preferences - sha256: "16d3fb6b3692ad244a695c0183fca18cf81fd4b821664394a781de42386bf022" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - shared_preferences_android: - dependency: transitive - description: - name: shared_preferences_android - sha256: "6478c6bbbecfe9aced34c483171e90d7c078f5883558b30ec3163cf18402c749" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - shared_preferences_foundation: - dependency: transitive - description: - name: shared_preferences_foundation - sha256: e014107bb79d6d3297196f4f2d0db54b5d1f85b8ea8ff63b8e8b391a02700feb - url: "https://pub.dev" - source: hosted - version: "2.2.2" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - sha256: "9d387433ca65717bbf1be88f4d5bb18f10508917a8fa2fb02e0fd0d7479a9afa" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - sha256: fb5cf25c0235df2d0640ac1b1174f6466bd311f621574997ac59018a6664548d - url: "https://pub.dev" - source: hosted - version: "2.2.0" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: "74083203a8eae241e0de4a0d597dbedab3b8fef5563f33cf3c12d7e93c655ca5" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: "5e588e2efef56916a3b229c3bfe81e6a525665a454519ca51dbcc4236a274173" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: "85f8c7d6425dff95475db618404732f034c87fe23efe05478cea50520a2517a3" - url: "https://pub.dev" - source: hosted - version: "1.2.5" - source_span: - dependency: transitive - description: - name: source_span - sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250 - url: "https://pub.dev" - source: hosted - version: "1.9.1" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 - url: "https://pub.dev" - source: hosted - version: "1.11.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - 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: eb6ac1540b26de412b3403a163d919ba86f6a973fe6cc50ae3541b80092fdcfb - url: "https://pub.dev" - source: hosted - version: "0.5.1" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: "26f87ade979c47a150c9eaab93ccd2bebe70a27dc0b4b29517f2904f04eb11a5" - url: "https://pub.dev" - source: hosted - version: "1.3.1" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - watcher: - dependency: transitive - description: - name: watcher - sha256: e42dfcc48f67618344da967b10f62de57e04bae01d9d3af4c2596f3712a88c99 - url: "https://pub.dev" - source: hosted - version: "1.0.1" - win32: - dependency: transitive - description: - name: win32 - sha256: "5a751eddf9db89b3e5f9d50c20ab8612296e4e8db69009788d6c8b060a84191c" - url: "https://pub.dev" - source: hosted - version: "4.1.4" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: ee1505df1426458f7f60aac270645098d318a8b4766d85fde75f76f2e21807d1 - url: "https://pub.dev" - source: hosted - version: "1.0.0" - yaml: - dependency: transitive - description: - name: yaml - sha256: "23812a9b125b48d4007117254bca50abb6c712352927eece9e155207b1db2370" - url: "https://pub.dev" - source: hosted - version: "3.1.1" -sdks: - dart: ">=3.0.0-0 <4.0.0" - flutter: ">=3.3.0" diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart deleted file mode 100644 index ccc4ce3..0000000 --- a/example/test/widget_test.dart +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause - -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:example/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} diff --git a/flutter_introduction_widget.gif b/flutter_introduction_widget.gif new file mode 100644 index 0000000..fc07838 Binary files /dev/null and b/flutter_introduction_widget.gif differ diff --git a/melos.yaml b/melos.yaml new file mode 100644 index 0000000..2280b71 --- /dev/null +++ b/melos.yaml @@ -0,0 +1,39 @@ +name: flutter_introduction + +packages: + - packages/** + +command: + version: + branch: master + +scripts: + lint:all: + run: dart run melos run analyze && dart run melos run format-check + description: Run all static analysis checks. + + get: + run: | + melos exec -c 1 -- "flutter pub get" + melos exec --scope="*example*" -c 1 -- "flutter pub get" + + upgrade: + run: melos exec -c 1 -- "flutter pub upgrade" + + create: + # run create in the example folder of flutter_introduction, flutter_introduction_firebase + run: melos exec --scope="*example*" -c 1 -- "flutter create ." + + analyze: + run: | + dart run melos exec -c 1 -- \ + flutter analyze --fatal-infos + description: Run `flutter analyze` for all packages. + + format: + run: dart run melos exec dart format . + description: Run `dart format` for all packages. + + format-check: + run: dart run melos exec dart format . --set-exit-if-changed + description: Run `dart format` checks for all packages. diff --git a/packages/flutter_introduction/.gitignore b/packages/flutter_introduction/.gitignore new file mode 100644 index 0000000..bffd2c4 --- /dev/null +++ b/packages/flutter_introduction/.gitignore @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: 2022 Iconica +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# 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 +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +# /pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ +.flutter-plugins-dependencies +.flutter-plugins +.metadata + +pubspec.lock + +pubspec_overrides.yaml diff --git a/packages/flutter_introduction/analysis_options.yaml b/packages/flutter_introduction/analysis_options.yaml new file mode 100644 index 0000000..8ea00ce --- /dev/null +++ b/packages/flutter_introduction/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_iconica_analysis/analysis_options.yaml + +# Possible to overwrite the rules from the package + +analyzer: + exclude: + +linter: + rules: diff --git a/example/.gitignore b/packages/flutter_introduction/example/.gitignore similarity index 100% rename from example/.gitignore rename to packages/flutter_introduction/example/.gitignore diff --git a/example/README.md b/packages/flutter_introduction/example/README.md similarity index 100% rename from example/README.md rename to packages/flutter_introduction/example/README.md diff --git a/packages/flutter_introduction/example/analysis_options.yaml b/packages/flutter_introduction/example/analysis_options.yaml new file mode 100644 index 0000000..8ea00ce --- /dev/null +++ b/packages/flutter_introduction/example/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_iconica_analysis/analysis_options.yaml + +# Possible to overwrite the rules from the package + +analyzer: + exclude: + +linter: + rules: diff --git a/packages/flutter_introduction/example/lib/main.dart b/packages/flutter_introduction/example/lib/main.dart new file mode 100644 index 0000000..8eef932 --- /dev/null +++ b/packages/flutter_introduction/example/lib/main.dart @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; +import 'package:flutter_introduction/flutter_introduction.dart'; +import 'package:flutter_introduction_shared_preferences/flutter_introduction_shared_preferences.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) => MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: const MyHomePage(), + ); +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key}); + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + IntroductionService service = + IntroductionService(SharedPreferencesIntroductionDataProvider()); + + @override + Widget build(BuildContext context) => Scaffold( + body: Introduction( + options: IntroductionOptions( + pages: [ + IntroductionPage( + title: const Text('First page'), + text: const Text('Wow a page'), + graphic: const FlutterLogo(), + ), + IntroductionPage( + title: const Text('Second page'), + text: const Text('Another page'), + graphic: const FlutterLogo(), + ), + IntroductionPage( + title: const Text('Third page'), + text: const Text('The final page of this app'), + graphic: const FlutterLogo(), + ), + ], + introductionTranslations: const IntroductionTranslations( + skipButton: 'Skip it!', + nextButton: 'Next', + previousButton: 'Previous', + finishButton: 'To the app!', + ), + tapEnabled: true, + displayMode: IntroductionDisplayMode.multiPageHorizontal, + buttonMode: IntroductionScreenButtonMode.text, + indicatorMode: IndicatorMode.dash, + skippable: true, + buttonBuilder: (context, onPressed, child, type) => + ElevatedButton(onPressed: onPressed, child: child), + ), + service: service, + navigateTo: () async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const Home(), + ), + ); + }, + child: const Home(), + ), + ); +} + +class Home extends StatelessWidget { + const Home({ + super.key, + }); + + @override + Widget build(BuildContext context) => Container(); +} diff --git a/example/pubspec.yaml b/packages/flutter_introduction/example/pubspec.yaml similarity index 69% rename from example/pubspec.yaml rename to packages/flutter_introduction/example/pubspec.yaml index 1de9587..78642e3 100644 --- a/example/pubspec.yaml +++ b/packages/flutter_introduction/example/pubspec.yaml @@ -17,14 +17,19 @@ dependencies: flutter_introduction: path: ../ flutter_introduction_shared_preferences: - git: - url: https://github.com/Iconica-Development/flutter_introduction_shared_preferences.git - ref: 1.0.0 + git: + url: https://github.com/Iconica-Development/flutter_introduction + ref: 2.0.0 + path: packages/flutter_introduction_shared_preferences dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^2.0.0 + flutter_iconica_analysis: + git: + url: https://github.com/Iconica-Development/flutter_iconica_analysis + ref: 6.0.0 flutter: uses-material-design: true diff --git a/lib/flutter_introduction.dart b/packages/flutter_introduction/lib/flutter_introduction.dart similarity index 57% rename from lib/flutter_introduction.dart rename to packages/flutter_introduction/lib/flutter_introduction.dart index aa9411e..fc7965c 100644 --- a/lib/flutter_introduction.dart +++ b/packages/flutter_introduction/lib/flutter_introduction.dart @@ -2,14 +2,12 @@ // // SPDX-License-Identifier: BSD-3-Clause -library flutter_introduction; - import 'package:flutter/material.dart'; import 'package:flutter_introduction_service/flutter_introduction_service.dart'; import 'package:flutter_introduction_widget/flutter_introduction_widget.dart'; -export 'package:flutter_introduction_widget/flutter_introduction_widget.dart'; export 'package:flutter_introduction_service/flutter_introduction_service.dart'; +export 'package:flutter_introduction_widget/flutter_introduction_widget.dart'; class Introduction extends StatefulWidget { const Introduction({ @@ -21,7 +19,7 @@ class Introduction extends StatefulWidget { super.key, }); - final Function navigateTo; + final VoidCallback navigateTo; final IntroductionService? service; final IntroductionOptions options; final ScrollPhysics? physics; @@ -45,30 +43,29 @@ class _IntroductionState extends State { } @override - Widget build(BuildContext context) { - return FutureBuilder( - future: _service.shouldShow(), - builder: (context, snapshot) { - if (snapshot.data == null || snapshot.data!) { - return IntroductionScreen( - options: widget.options, - onComplete: () async { - _service.onComplete(); + Widget build(BuildContext context) => FutureBuilder( + // ignore: discarded_futures + future: _service.shouldShow(), + builder: (context, snapshot) { + if (snapshot.data == null || snapshot.data!) { + return IntroductionScreen( + options: widget.options, + onComplete: () async { + await _service.onComplete(); + widget.navigateTo(); + }, + physics: widget.physics, + onSkip: () async { + await _service.onComplete(); + widget.navigateTo(); + }, + ); + } else { + WidgetsBinding.instance.addPostFrameCallback((_) { widget.navigateTo(); - }, - physics: widget.physics, - onSkip: () async { - _service.onComplete(); - widget.navigateTo(); - }, - ); - } else { - WidgetsBinding.instance.addPostFrameCallback((_) { - widget.navigateTo(); - }); - return widget.child ?? const CircularProgressIndicator(); - } - }, - ); - } + }); + return widget.child ?? const CircularProgressIndicator(); + } + }, + ); } diff --git a/packages/flutter_introduction/pubspec.yaml b/packages/flutter_introduction/pubspec.yaml new file mode 100644 index 0000000..91939be --- /dev/null +++ b/packages/flutter_introduction/pubspec.yaml @@ -0,0 +1,33 @@ +name: flutter_introduction +description: Combined Package of Flutter Introduction Widget and Flutter Introduction Service +version: 2.0.0 +publish_to: none + +environment: + sdk: ">=2.18.0 <3.0.0" + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + flutter_introduction_widget: + git: + url: https://github.com/Iconica-Development/flutter_introduction + ref: 2.0.0 + path: packages/flutter_introduction_widget + flutter_introduction_service: + git: + url: https://github.com/Iconica-Development/flutter_introduction + ref: 2.0.0 + path: packages/flutter_introduction_service + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + flutter_iconica_analysis: + git: + url: https://github.com/Iconica-Development/flutter_iconica_analysis + ref: 6.0.0 + +flutter: \ No newline at end of file diff --git a/packages/flutter_introduction_firebase/.gitignore b/packages/flutter_introduction_firebase/.gitignore new file mode 100644 index 0000000..bffd2c4 --- /dev/null +++ b/packages/flutter_introduction_firebase/.gitignore @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: 2022 Iconica +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# 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 +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +# /pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ +.flutter-plugins-dependencies +.flutter-plugins +.metadata + +pubspec.lock + +pubspec_overrides.yaml diff --git a/packages/flutter_introduction_firebase/README.md b/packages/flutter_introduction_firebase/README.md new file mode 100644 index 0000000..e69de29 diff --git a/packages/flutter_introduction_firebase/analysis_options.yaml b/packages/flutter_introduction_firebase/analysis_options.yaml new file mode 100644 index 0000000..8ea00ce --- /dev/null +++ b/packages/flutter_introduction_firebase/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_iconica_analysis/analysis_options.yaml + +# Possible to overwrite the rules from the package + +analyzer: + exclude: + +linter: + rules: diff --git a/packages/flutter_introduction_firebase/lib/flutter_introduction_firebase.dart b/packages/flutter_introduction_firebase/lib/flutter_introduction_firebase.dart new file mode 100644 index 0000000..5c17400 --- /dev/null +++ b/packages/flutter_introduction_firebase/lib/flutter_introduction_firebase.dart @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +/// +library flutter_introduction_firebase; + +export 'src/firebase_service.dart'; +export 'src/introduction_widget.dart'; diff --git a/packages/flutter_introduction_firebase/lib/src/firebase_service.dart b/packages/flutter_introduction_firebase/lib/src/firebase_service.dart new file mode 100644 index 0000000..0e6f51e --- /dev/null +++ b/packages/flutter_introduction_firebase/lib/src/firebase_service.dart @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_introduction_firebase/src/introduction_page.dart'; + +const _introductionDocumentRef = 'introduction/introduction'; + +class FirebaseIntroductionService { + FirebaseIntroductionService({ + DocumentReference>? documentRef, + }) : _documentRef = documentRef ?? + FirebaseFirestore.instance.doc(_introductionDocumentRef); + + final DocumentReference> _documentRef; + List _pages = []; + + Future> getIntroductionPages() async { + if (_pages.isNotEmpty) return _pages; + var pagesDocuments = + await _documentRef.collection('pages').orderBy('order').get(); + return _pages = pagesDocuments.docs.map((document) { + var data = document.data(); + // convert Map to Map + var title = data['title'] != null + ? (data['title'] as Map).cast() + : {}; + var content = data['content'] != null + ? (data['content'] as Map).cast() + : {}; + return IntroductionPageData( + title: title, + content: content, + contentImage: data['image'] as String?, + backgroundImage: data['background_image'] as String?, + // the color is stored as a hex string + backgroundColor: data['background_color'] != null + ? Color(int.parse(data['background_color'] as String, radix: 16)) + : null, + ); + }).toList(); + } + + Future introductionIsDisabled() async { + var document = await _documentRef.get(); + return document.data()!['disabled'] as bool? ?? false; + } + + Future shouldAlwaysShowIntroduction() async { + var document = await _documentRef.get(); + return document.data()!['always_show'] as bool? ?? false; + } + + Future loadIntroductionPages( + BuildContext context, + ) async { + for (var page in _pages) { + if (context.mounted && page.backgroundImage != null) { + await precacheImage( + CachedNetworkImageProvider(page.backgroundImage!), + context, + ); + } + if (context.mounted && page.contentImage != null) { + await precacheImage( + CachedNetworkImageProvider(page.contentImage!), + context, + ); + } + } + } +} diff --git a/packages/flutter_introduction_firebase/lib/src/introduction_page.dart b/packages/flutter_introduction_firebase/lib/src/introduction_page.dart new file mode 100644 index 0000000..618e1ef --- /dev/null +++ b/packages/flutter_introduction_firebase/lib/src/introduction_page.dart @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; + +@immutable +class IntroductionPageData { + const IntroductionPageData({ + required this.title, + required this.content, + this.contentImage, + this.backgroundImage, + this.backgroundColor, + }); + + /// The title of the introduction page in different languages + final Map title; + + /// The content of the introduction page in different languages + final Map content; + + /// The imageUrl of the graphic on the introduction page + final String? contentImage; + + /// The imageUrl of the background image of the introduction page + final String? backgroundImage; + + /// Optional background color of the introduction page + /// (defaults to transparent) + final Color? backgroundColor; +} diff --git a/packages/flutter_introduction_firebase/lib/src/introduction_widget.dart b/packages/flutter_introduction_firebase/lib/src/introduction_widget.dart new file mode 100644 index 0000000..94905ff --- /dev/null +++ b/packages/flutter_introduction_firebase/lib/src/introduction_widget.dart @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +// ignore_for_file: discarded_futures + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_introduction_firebase/flutter_introduction_firebase.dart'; +import 'package:flutter_introduction_service/flutter_introduction_service.dart'; + +export 'package:flutter_introduction_firebase/src/introduction_page.dart'; +export 'package:flutter_introduction_widget/flutter_introduction_widget.dart'; + +class IntroductionFirebase extends StatefulWidget { + const IntroductionFirebase({ + required this.options, + required this.onComplete, + this.decoration, + this.layoutStyle, + this.titleBuilder, + this.contentBuilder, + this.imageBuilder, + this.onSkip, + this.firebaseService, + this.introductionService, + this.physics, + this.child, + this.languageCodeOverride, + super.key, + }); + + /// The options used to build the introduction screen + final IntroductionOptions options; + + /// The service used to determine if the introduction screen should be shown + final IntroductionService? introductionService; + + /// The service used to get the introduction pages + final FirebaseIntroductionService? firebaseService; + + /// A function called when the introductionSceen changes + final VoidCallback onComplete; + + /// A function called when the introductionScreen is skipped + final VoidCallback? onSkip; + + /// How the single child scroll view should respond to scrolling + final ScrollPhysics? physics; + + /// The widget to show when the introduction screen is loading + final Widget? child; + + /// The decoration of an introduction page if it doesn't have + /// a backgroundImage or backgroundColor + final BoxDecoration? decoration; + + /// The layout style of all the introduction pages + final IntroductionLayoutStyle? layoutStyle; + + /// The builder used to build the title of the introduction page + final Widget Function(String)? titleBuilder; + + /// The builder used to build the content of the introduction page + final Widget Function(String)? contentBuilder; + + /// The builder used to build the image of the introduction page + final Widget Function(String)? imageBuilder; + + /// Use this to override the language code that is in the context + /// used for showing the introduction in a different language + final String? languageCodeOverride; + + @override + State createState() => _IntroductionState(); +} + +class _IntroductionState extends State { + late IntroductionService _service; + late FirebaseIntroductionService _firebaseService; + + @override + void initState() { + super.initState(); + if (widget.introductionService == null) { + _service = IntroductionService(); + } else { + _service = widget.introductionService!; + } + if (widget.firebaseService == null) { + _firebaseService = FirebaseIntroductionService(); + } else { + _firebaseService = widget.firebaseService!; + } + } + + @override + Widget build(BuildContext context) { + Future shouldShow() async => + !await _firebaseService.introductionIsDisabled() && + (await _service.shouldShow() || + await _firebaseService.shouldAlwaysShowIntroduction()); + var languageCode = widget.languageCodeOverride ?? + Localizations.localeOf(context).languageCode; + + return FutureBuilder( + future: shouldShow(), + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data != null && snapshot.data!) { + return FutureBuilder( + future: _firebaseService.getIntroductionPages(), + builder: (context, snapshot) { + if (snapshot.hasData && + snapshot.data != null && + snapshot.data is List) { + return IntroductionScreen( + options: widget.options.copyWith( + pages: snapshot.data?.map( + (e) { + var title = e.title.isEmpty + ? '' + : e.title.containsKey(languageCode) + ? e.title[languageCode]! + : e.title.values.first; + var content = e.content.isEmpty + ? '' + : e.content.containsKey(languageCode) + ? e.content[languageCode]! + : e.content.values.first; + return IntroductionPage( + title: + widget.titleBuilder?.call(title) ?? Text(title), + graphic: e.contentImage != null && + e.contentImage!.isNotEmpty + ? widget.imageBuilder?.call(e.contentImage!) ?? + CachedNetworkImage(imageUrl: e.contentImage!) + : null, + text: widget.contentBuilder?.call(content) ?? + Text(content), + decoration: widget.decoration?.copyWith( + color: e.backgroundColor, + image: e.backgroundImage != null && + e.backgroundImage!.isNotEmpty + ? DecorationImage( + image: CachedNetworkImageProvider( + e.backgroundImage!, + ), + fit: BoxFit.cover, + ) + : null, + ), + layoutStyle: widget.layoutStyle, + ); + }, + ).toList(), + ), + onComplete: () async { + await _service.onComplete(); + widget.onComplete(); + }, + physics: widget.physics, + onSkip: () async { + await _service.onSkip(); + widget.onComplete(); + }, + ); + } else { + return widget.child ?? const CircularProgressIndicator(); + } + }, + ); + } else { + if (snapshot.hasData && snapshot.data != null && !snapshot.data!) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + await _service.onComplete(); + widget.onComplete(); + }); + } + return widget.child ?? const CircularProgressIndicator(); + } + }, + ); + } +} diff --git a/packages/flutter_introduction_firebase/pubspec.yaml b/packages/flutter_introduction_firebase/pubspec.yaml new file mode 100644 index 0000000..fc69ef3 --- /dev/null +++ b/packages/flutter_introduction_firebase/pubspec.yaml @@ -0,0 +1,34 @@ +name: flutter_introduction_firebase +description: Flutter Introduction Page that uses firebase for the pages and some settings +version: 2.0.0 +publish_to: none + +environment: + sdk: ">=3.1.5 <4.0.0" + +dependencies: + flutter: + sdk: flutter + cloud_firestore: ^4.12.2 + cached_network_image: ^3.3.0 + + flutter_introduction_widget: + git: + url: https://github.com/Iconica-Development/flutter_introduction + ref: 2.0.0 + path: packages/flutter_introduction_widget + flutter_introduction_service: + git: + url: https://github.com/Iconica-Development/flutter_introduction + ref: 2.0.0 + path: packages/flutter_introduction_service + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_iconica_analysis: + git: + url: https://github.com/Iconica-Development/flutter_iconica_analysis + ref: 6.0.0 + +flutter: \ No newline at end of file diff --git a/packages/flutter_introduction_firebase/test/flutter_introduction_firebase_test.dart b/packages/flutter_introduction_firebase/test/flutter_introduction_firebase_test.dart new file mode 100644 index 0000000..b6f647b --- /dev/null +++ b/packages/flutter_introduction_firebase/test/flutter_introduction_firebase_test.dart @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('test', () { + expect(true, true); + }); +} diff --git a/packages/flutter_introduction_interface/.gitignore b/packages/flutter_introduction_interface/.gitignore new file mode 100644 index 0000000..35b14c4 --- /dev/null +++ b/packages/flutter_introduction_interface/.gitignore @@ -0,0 +1,32 @@ +# 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 +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ + +.metadata diff --git a/packages/flutter_introduction_interface/analysis_options.yaml b/packages/flutter_introduction_interface/analysis_options.yaml new file mode 100644 index 0000000..8ea00ce --- /dev/null +++ b/packages/flutter_introduction_interface/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_iconica_analysis/analysis_options.yaml + +# Possible to overwrite the rules from the package + +analyzer: + exclude: + +linter: + rules: diff --git a/packages/flutter_introduction_interface/lib/flutter_introduction_interface.dart b/packages/flutter_introduction_interface/lib/flutter_introduction_interface.dart new file mode 100644 index 0000000..aeb5392 --- /dev/null +++ b/packages/flutter_introduction_interface/lib/flutter_introduction_interface.dart @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +export './src/introduction_interface.dart'; +export './src/local_introduction.dart'; diff --git a/packages/flutter_introduction_interface/lib/src/introduction_interface.dart b/packages/flutter_introduction_interface/lib/src/introduction_interface.dart new file mode 100644 index 0000000..6fa3020 --- /dev/null +++ b/packages/flutter_introduction_interface/lib/src/introduction_interface.dart @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter_data_interface/flutter_data_interface.dart'; +import 'package:flutter_introduction_interface/src/local_introduction.dart'; + +abstract class IntroductionInterface extends DataInterface { + IntroductionInterface() : super(token: _token); + + static final Object _token = Object(); + + static IntroductionInterface _instance = LocalIntroductionDataProvider(); + + static IntroductionInterface get instance => _instance; + + static set instance(IntroductionInterface instance) { + DataInterface.verify(instance, _token); + _instance = instance; + } + + Future setCompleted({bool value = true}); + + Future shouldShow(); +} diff --git a/packages/flutter_introduction_interface/lib/src/local_introduction.dart b/packages/flutter_introduction_interface/lib/src/local_introduction.dart new file mode 100644 index 0000000..ce8fb6e --- /dev/null +++ b/packages/flutter_introduction_interface/lib/src/local_introduction.dart @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter_introduction_interface/src/introduction_interface.dart'; + +class LocalIntroductionDataProvider extends IntroductionInterface { + LocalIntroductionDataProvider(); + + bool hasViewed = false; + + @override + Future setCompleted({bool value = true}) async { + hasViewed = value; + } + + @override + Future shouldShow() async => hasViewed; +} diff --git a/packages/flutter_introduction_interface/pubspec.yaml b/packages/flutter_introduction_interface/pubspec.yaml new file mode 100644 index 0000000..771c5f7 --- /dev/null +++ b/packages/flutter_introduction_interface/pubspec.yaml @@ -0,0 +1,27 @@ +name: flutter_introduction_interface +description: A new Flutter package project. +version: 2.0.0 +publish_to: none + +environment: + sdk: '>=2.18.0 <3.0.0' + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + flutter_data_interface: + git: + url: https://github.com/Iconica-Development/flutter_data_interface.git + ref: 1.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + flutter_iconica_analysis: + git: + url: https://github.com/Iconica-Development/flutter_iconica_analysis + ref: 6.0.0 + +flutter: diff --git a/packages/flutter_introduction_service/.gitignore b/packages/flutter_introduction_service/.gitignore new file mode 100644 index 0000000..35b14c4 --- /dev/null +++ b/packages/flutter_introduction_service/.gitignore @@ -0,0 +1,32 @@ +# 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 +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ + +.metadata diff --git a/packages/flutter_introduction_service/analysis_options.yaml b/packages/flutter_introduction_service/analysis_options.yaml new file mode 100644 index 0000000..8ea00ce --- /dev/null +++ b/packages/flutter_introduction_service/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_iconica_analysis/analysis_options.yaml + +# Possible to overwrite the rules from the package + +analyzer: + exclude: + +linter: + rules: diff --git a/packages/flutter_introduction_service/lib/flutter_introduction_service.dart b/packages/flutter_introduction_service/lib/flutter_introduction_service.dart new file mode 100644 index 0000000..323eb42 --- /dev/null +++ b/packages/flutter_introduction_service/lib/flutter_introduction_service.dart @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +export 'package:flutter_introduction_interface/flutter_introduction_interface.dart'; + +export './src/introduction_service.dart'; diff --git a/packages/flutter_introduction_service/lib/src/introduction_service.dart b/packages/flutter_introduction_service/lib/src/introduction_service.dart new file mode 100644 index 0000000..1bfe862 --- /dev/null +++ b/packages/flutter_introduction_service/lib/src/introduction_service.dart @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter_introduction_interface/flutter_introduction_interface.dart'; + +class IntroductionService { + IntroductionService([IntroductionInterface? dataProvider]) + : _dataProvider = dataProvider ?? LocalIntroductionDataProvider(); + + late final IntroductionInterface _dataProvider; + + Future onSkip() => _dataProvider.setCompleted(value: true); + + Future onComplete() => _dataProvider.setCompleted(value: true); + + Future shouldShow() => _dataProvider.shouldShow(); +} diff --git a/packages/flutter_introduction_service/pubspec.yaml b/packages/flutter_introduction_service/pubspec.yaml new file mode 100644 index 0000000..8043c0c --- /dev/null +++ b/packages/flutter_introduction_service/pubspec.yaml @@ -0,0 +1,28 @@ +name: flutter_introduction_service +description: A new Flutter package project. +version: 2.0.0 +publish_to: none + +environment: + sdk: '>=2.18.0 <3.0.0' + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + flutter_introduction_interface: + git: + url: https://github.com/Iconica-Development/flutter_introduction + ref: 2.0.0 + path: packages/flutter_introduction_interface + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + flutter_iconica_analysis: + git: + url: https://github.com/Iconica-Development/flutter_iconica_analysis + ref: 6.0.0 + +flutter: diff --git a/packages/flutter_introduction_shared_preferences/.gitignore b/packages/flutter_introduction_shared_preferences/.gitignore new file mode 100644 index 0000000..7a69d18 --- /dev/null +++ b/packages/flutter_introduction_shared_preferences/.gitignore @@ -0,0 +1,34 @@ +# 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 +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ + +.metadata +.flutter-plugins +.flutter-plugins-dependencies diff --git a/packages/flutter_introduction_shared_preferences/analysis_options.yaml b/packages/flutter_introduction_shared_preferences/analysis_options.yaml new file mode 100644 index 0000000..8ea00ce --- /dev/null +++ b/packages/flutter_introduction_shared_preferences/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_iconica_analysis/analysis_options.yaml + +# Possible to overwrite the rules from the package + +analyzer: + exclude: + +linter: + rules: diff --git a/packages/flutter_introduction_shared_preferences/lib/flutter_introduction_shared_preferences.dart b/packages/flutter_introduction_shared_preferences/lib/flutter_introduction_shared_preferences.dart new file mode 100644 index 0000000..afeab4d --- /dev/null +++ b/packages/flutter_introduction_shared_preferences/lib/flutter_introduction_shared_preferences.dart @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter_introduction_interface/flutter_introduction_interface.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class SharedPreferencesIntroductionDataProvider extends IntroductionInterface { + SharedPreferencesIntroductionDataProvider(); + + SharedPreferences? _prefs; + String key = '_completedIntroduction'; + + Future _writeKeyValue(String key, bool value) async { + await _prefs!.setBool(key, value); + } + + Future _init() async { + _prefs ??= await SharedPreferences.getInstance(); + } + + @override + Future setCompleted({bool value = true}) async { + await _init(); + await _writeKeyValue(key, value); + } + + @override + Future shouldShow() async { + await _init(); + return !(_prefs!.getBool(key) ?? false); + } +} diff --git a/packages/flutter_introduction_shared_preferences/pubspec.yaml b/packages/flutter_introduction_shared_preferences/pubspec.yaml new file mode 100644 index 0000000..4d643c5 --- /dev/null +++ b/packages/flutter_introduction_shared_preferences/pubspec.yaml @@ -0,0 +1,29 @@ +name: flutter_introduction_shared_preferences +description: A new Flutter package project. +version: 2.0.0 +publish_to: none + +environment: + sdk: '>=2.18.0 <3.0.0' + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + flutter_introduction_interface: + git: + url: https://github.com/Iconica-Development/flutter_introduction + ref: 2.0.0 + path: packages/flutter_introduction_interface + shared_preferences: any + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + flutter_iconica_analysis: + git: + url: https://github.com/Iconica-Development/flutter_iconica_analysis + ref: 6.0.0 + +flutter: diff --git a/packages/flutter_introduction_widget/.gitignore b/packages/flutter_introduction_widget/.gitignore new file mode 100644 index 0000000..e174818 --- /dev/null +++ b/packages/flutter_introduction_widget/.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 +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ + +example/android/ +example/ios/ +example/macos/ +example/web/ +example/windows/ +example/linux/ +example/.metadata +example/pubspec.lock + +.flutter-plugins +.flutter-plugins-dependencies +.metadata \ No newline at end of file diff --git a/packages/flutter_introduction_widget/README.md b/packages/flutter_introduction_widget/README.md new file mode 100644 index 0000000..ea9cf49 --- /dev/null +++ b/packages/flutter_introduction_widget/README.md @@ -0,0 +1,65 @@ +[![pub package](https://img.shields.io/pub/v/flutter_introduction_widget.svg)](https://github.com/Iconica-Development) [![Build status](https://img.shields.io/github/workflow/status/Iconica-Development/flutter_introduction_widget/CI)](https://github.com/Iconica-Development/flutter_introduction_widget/actions/new) [![style: effective dart](https://img.shields.io/badge/style-effective_dart-40c4ff.svg)](https://github.com/tenhobi/effective_dart) + +# Introduction Widget +Flutter Introduction Widget for showing a list of introduction pages on a single scrollable page or horizontal pageview. + +![Introduction GIF](../../flutter_introduction_widget.gif) + +## Setup + +To use this package, add `flutter_introduction_widget` as a dependency in your pubspec.yaml file. + +## How to use + +Simple way to use the introduction widget: +```dart +IntroductionScreen( + options: IntroductionOptions( + pages: [ + IntroductionPage( + title: const Text('First page'), + text: const Text('Wow a page'), + graphic: const FlutterLogo(), + ), + IntroductionPage( + title: const Text('Second page'), + text: const Text('Another page'), + graphic: const FlutterLogo(), + ), + IntroductionPage( + title: const Text('Third page'), + text: const Text('The final page of this app'), + graphic: const FlutterLogo(), + backgroundImage: const AssetImage( + 'assets/flutter_introduction_background.jpeg'), + ), + ], + introductionTranslations: const IntroductionTranslations( + skipButton: 'Skip it!', + nextButton: 'Next', + previousButton: 'Previous', + finishButton: 'Finish', + ), + buttonMode: IntroductionScreenButtonMode.text, + buttonBuilder: (context, onPressed, child) => + ElevatedButton(onPressed: onPressed, child: child), + ), + onComplete: () { + debugPrint('We completed the cycle'); + }, +), +``` + +See the [Example Code](example/lib/main.dart) for an example on how to use this package. + +## Issues + +Please file any issues, bugs or feature request as an issue on our [GitHub](https://github.com/Iconica-Development/flutter_introduction_widget) page. Commercial support is available if you need help with integration with your app or services. You can contact us at [support@iconica.nl](mailto:support@iconica.nl). + +## Want to contribute + +If you would like to contribute to the plugin (e.g. by improving the documentation, solving a bug or adding a cool new feature), please carefully review our [contribution guide](./CONTRIBUTING.md) and send us your [pull request](https://github.com/Iconica-Development/flutter_introduction_widget/pulls). + +## Author + +This `flutter_introduction_widget` for Flutter is developed by [Iconica](https://iconica.nl). You can contact us at \ No newline at end of file diff --git a/packages/flutter_introduction_widget/analysis_options.yaml b/packages/flutter_introduction_widget/analysis_options.yaml new file mode 100644 index 0000000..8ea00ce --- /dev/null +++ b/packages/flutter_introduction_widget/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_iconica_analysis/analysis_options.yaml + +# Possible to overwrite the rules from the package + +analyzer: + exclude: + +linter: + rules: diff --git a/packages/flutter_introduction_widget/example/.gitignore b/packages/flutter_introduction_widget/example/.gitignore new file mode 100644 index 0000000..24476c5 --- /dev/null +++ b/packages/flutter_introduction_widget/example/.gitignore @@ -0,0 +1,44 @@ +# 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 +.packages +.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/packages/flutter_introduction_widget/example/README.md b/packages/flutter_introduction_widget/example/README.md new file mode 100644 index 0000000..e69de29 diff --git a/example/analysis_options.yaml b/packages/flutter_introduction_widget/example/analysis_options.yaml similarity index 100% rename from example/analysis_options.yaml rename to packages/flutter_introduction_widget/example/analysis_options.yaml diff --git a/packages/flutter_introduction_widget/example/assets/flutter_introduction_background.jpeg b/packages/flutter_introduction_widget/example/assets/flutter_introduction_background.jpeg new file mode 100644 index 0000000..ffa9d55 Binary files /dev/null and b/packages/flutter_introduction_widget/example/assets/flutter_introduction_background.jpeg differ diff --git a/packages/flutter_introduction_widget/example/lib/main.dart b/packages/flutter_introduction_widget/example/lib/main.dart new file mode 100644 index 0000000..035ce15 --- /dev/null +++ b/packages/flutter_introduction_widget/example/lib/main.dart @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; +import 'package:flutter_introduction_widget/flutter_introduction_widget.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + // This widget is the root of your application. + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + // This is the theme of your application. + // + // Try running your application with "flutter run". You'll see the + // application has a blue toolbar. Then, without quitting the app, try + // changing the primarySwatch below to Colors.green and then invoke + // "hot reload" (press "r" in the console where you ran "flutter run", + // or simply save your changes to "hot reload" in a Flutter IDE). + // Notice that the counter didn't reset back to zero; the application + // is not restarted. + primarySwatch: Colors.blue, + ), + home: IntroductionScreen( + options: IntroductionOptions( + pages: [ + IntroductionPage( + title: const Text('Basic Page'), + text: const Text( + 'A page with some text and a widget in the middle.', + ), + graphic: const FlutterLogo(size: 100), + ), + IntroductionPage( + title: const Text('Layout Shift'), + text: const Text( + 'You can change the layout of a page to mix things up.', + ), + graphic: const FlutterLogo(size: 100), + layoutStyle: IntroductionLayoutStyle.imageTop, + ), + IntroductionPage( + title: const Text( + 'Decoration', + style: TextStyle( + color: Colors.white, + ), + ), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topRight, + end: Alignment.bottomLeft, + colors: [ + Colors.yellow, + Colors.red, + Colors.indigo, + Colors.teal, + ], + ), + ), + text: const Text( + 'Add a Decoration to make a custom background, like a LinearGradient', + style: TextStyle( + color: Colors.white, + ), + ), + graphic: const FlutterLogo( + size: 100, + ), + ), + IntroductionPage( + title: const Text( + 'Background Image', + ), + text: const Text( + 'Add a Decoration with a DecorationImage, to add an background image', + ), + decoration: const BoxDecoration( + image: DecorationImage( + fit: BoxFit.cover, + image: AssetImage( + 'assets/flutter_introduction_background.jpeg', + ), + ), + ), + ), + ], + introductionTranslations: const IntroductionTranslations( + skipButton: 'Skip it!', + nextButton: 'Next', + previousButton: 'Previous', + finishButton: 'Finish', + ), + tapEnabled: true, + displayMode: IntroductionDisplayMode.multiPageHorizontal, + buttonMode: IntroductionScreenButtonMode.text, + indicatorMode: IndicatorMode.dash, + skippable: true, + buttonBuilder: (context, onPressed, child, buttonType) => + ElevatedButton(onPressed: onPressed, child: child), + ), + onComplete: () { + debugPrint('We completed the cycle'); + }, + ), + ); + } +} diff --git a/packages/flutter_introduction_widget/example/pubspec.yaml b/packages/flutter_introduction_widget/example/pubspec.yaml new file mode 100644 index 0000000..861f95e --- /dev/null +++ b/packages/flutter_introduction_widget/example/pubspec.yaml @@ -0,0 +1,60 @@ +name: example_widget +description: A new Flutter project. + +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +version: 1.0.0+1 + +environment: + sdk: '>=2.18.1 <3.0.0' + +dependencies: + flutter: + sdk: flutter + flutter_introduction_widget: + path: ../ + +dev_dependencies: + flutter_test: + sdk: flutter + + flutter_lints: ^2.0.0 + + +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: + - assets/ + # - 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 diff --git a/packages/flutter_introduction_widget/lib/flutter_introduction_widget.dart b/packages/flutter_introduction_widget/lib/flutter_introduction_widget.dart new file mode 100644 index 0000000..8a4e2cd --- /dev/null +++ b/packages/flutter_introduction_widget/lib/flutter_introduction_widget.dart @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +export 'src/config/introduction.dart'; +export 'src/introduction.dart'; diff --git a/packages/flutter_introduction_widget/lib/src/config/introduction.dart b/packages/flutter_introduction_widget/lib/src/config/introduction.dart new file mode 100644 index 0000000..e7b0323 --- /dev/null +++ b/packages/flutter_introduction_widget/lib/src/config/introduction.dart @@ -0,0 +1,246 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; + +enum IntroductionScreenMode { showNever, showAlways, showOnce } + +enum IntroductionScreenButtonMode { text, icon, disabled, singleFinish } + +enum IntroductionLayoutStyle { + imageCenter, + imageTop, + imageBottom, +} + +enum IndicatorMode { dot, dash, custom } + +enum IntroductionDisplayMode { + singleScrollablePageVertical, + multiPageHorizontal +} + +enum IntroductionControlMode { + previousNextButton, + singleButton, +} + +enum IntroductionButtonType { + next, + previous, + finish, + skip, +} + +class IntroductionPage { + /// Creates an introduction page with data used in the introduction screen for + /// each page. + /// + /// The values for [title] and [text] are optional in this, but will use a + /// default translation key when built. + /// + /// The [background] is fully optional and if not provided will show the + /// [ThemeData.colorScheme.background] as default. + IntroductionPage({ + this.title, + this.text, + this.graphic, + this.decoration, + this.layoutStyle, + }); + final Widget? title; + final Widget? text; + final Widget? graphic; + final BoxDecoration? decoration; + final IntroductionLayoutStyle? layoutStyle; +} + +class IntroductionOptions { + const IntroductionOptions({ + this.introductionTranslations = const IntroductionTranslations(), + this.indicatorMode = IndicatorMode.dash, + this.indicatorBuilder, + this.layoutStyle = IntroductionLayoutStyle.imageCenter, + this.pages = const [], + this.buttonMode = IntroductionScreenButtonMode.disabled, + this.tapEnabled = false, + this.mode = IntroductionScreenMode.showNever, + this.textAlign = TextAlign.center, + this.displayMode = IntroductionDisplayMode.multiPageHorizontal, + this.skippable = false, + this.buttonBuilder, + this.controlMode = IntroductionControlMode.previousNextButton, + }) : assert( + !(identical(indicatorMode, IndicatorMode.custom) && + indicatorBuilder == null), + 'When indicator mode is set to custom, ' + 'make sure to define indicatorBuilder', + ); + + /// Determine when the introduction screens needs to be shown. + /// + /// [IntroductionScreenMode.showNever] To disable introduction screens. + /// + /// [IntroductionScreenMode.showAlways] To always show the introduction + /// screens on startup. + /// + /// [IntroductionScreenMode.showOnce] To only show the introduction screens + /// once on startup. + final IntroductionScreenMode mode; + + /// List of introduction pages to set the text, icons or images for the + /// introduction screens. + final List pages; + + /// Determines whether the user can tap the screen to go to the next + /// introduction screen. + final bool tapEnabled; + + /// Determines what kind of buttons are used to navigate to the next + /// introduction screen. + /// Introduction screens can always be navigated by swiping (or tapping if + /// [tapEnabled] is enabled). + /// + /// [IntroductionScreenButtonMode.text] Use text buttons (text can be set by + /// setting the translation key or using the default appshell translations). + /// + /// [IntroductionScreenButtonMode.icon] Use icon buttons (icons can be + /// changed by providing a icon library) + /// + /// [IntroductionScreenButtonMode.disabled] Disable buttons. + final IntroductionScreenButtonMode buttonMode; + + /// Determines the position of the provided images or icons that are set + /// using [pages]. + /// Every introduction page provided with a image or icon will use the same + /// layout setting. + /// + /// [IntroductionLayoutStyle.imageCenter] Image/icon will be at the center of the introduction page. + /// + /// [IntroductionLayoutStyle.imageTop] Image/icon will be at the top of the introduction page. + /// + /// [IntroductionLayoutStyle.imageBottom] Image/icon will be at the bottom of the introduction page. + final IntroductionLayoutStyle layoutStyle; + + /// Determines the style of the page indicator shown at the bottom on the + /// introduction pages. + /// + /// [IndicatorMode.dot] Shows a dot for each page. + /// + /// [IndicatorMode.dash] Shows a dash for each page. + /// + /// [IndicatorMode.custom] calls indicatorBuilder for the indicator + final IndicatorMode indicatorMode; + + /// Builder that is called when [indicatorMode] is set + /// to [IndicatorMode.custom] + final Widget Function( + BuildContext, + PageController, + int, + int, + )? indicatorBuilder; + + /// Determines whether the user can skip the introduction pages using a button + /// in the header + final bool skippable; + + /// Determines whether the introduction screens should be shown in a single + final TextAlign textAlign; + + /// [IntroductionDisplayMode.multiPageHorizontal] Configured introduction + /// pages will be shown on seperate screens and can be navigated using using + /// buttons (if enabled) or swiping. + /// + /// !Unimplemented! [IntroductionDisplayMode.singleScrollablePageVertical] + /// All configured introduction pages will be shown on a single scrollable + /// page. + /// + final IntroductionDisplayMode displayMode; + + /// When [IntroductionDisplayMode.multiPageHorizontal] is selected multiple + /// controlMode can be selected. + /// + /// [IntroductionControlMode.previousNextButton] shows two buttons at the + /// bottom of the screen to return or proceed. The skip button is placed at + /// the top left of the screen. + /// + /// [IntroductionControlMode.singleButton] contains one button at the bottom + /// of the screen to proceed. Underneath is clickable text to skip if the + /// current page is the first page. If the current page is any different it + /// return to the previous screen. + /// + final IntroductionControlMode controlMode; + + /// A builder that can be used to replace the default text buttons when + /// [IntroductionScreenButtonMode.text] is provided to [buttonMode] + final Widget Function( + BuildContext, + VoidCallback, + Widget, + IntroductionButtonType, + )? buttonBuilder; + + /// The translations for all buttons on the introductionpages + /// + /// See [IntroductionTranslations] for more information + /// The following buttons have a translation: + /// - Skip + /// - Next + /// - Previous + /// - Finish + final IntroductionTranslations introductionTranslations; + + IntroductionOptions copyWith({ + IntroductionScreenMode? mode, + List? pages, + bool? tapEnabled, + IntroductionScreenButtonMode? buttonMode, + IntroductionLayoutStyle? layoutStyle, + IndicatorMode? indicatorMode, + Widget Function( + BuildContext, + PageController, + int, + int, + )? indicatorBuilder, + bool? skippable, + TextAlign? textAlign, + IntroductionDisplayMode? displayMode, + IntroductionControlMode? controlMode, + Widget Function(BuildContext, VoidCallback, Widget, IntroductionButtonType)? + buttonBuilder, + IntroductionTranslations? introductionTranslations, + }) => + IntroductionOptions( + mode: mode ?? this.mode, + pages: pages ?? this.pages, + tapEnabled: tapEnabled ?? this.tapEnabled, + buttonMode: buttonMode ?? this.buttonMode, + layoutStyle: layoutStyle ?? this.layoutStyle, + indicatorMode: indicatorMode ?? this.indicatorMode, + indicatorBuilder: indicatorBuilder ?? this.indicatorBuilder, + skippable: skippable ?? this.skippable, + textAlign: textAlign ?? this.textAlign, + displayMode: displayMode ?? this.displayMode, + controlMode: controlMode ?? this.controlMode, + buttonBuilder: buttonBuilder ?? this.buttonBuilder, + introductionTranslations: + introductionTranslations ?? this.introductionTranslations, + ); +} + +/// +class IntroductionTranslations { + const IntroductionTranslations({ + this.skipButton = 'skip', + this.nextButton = 'next', + this.previousButton = 'previous', + this.finishButton = 'finish', + }); + final String skipButton; + final String nextButton; + final String previousButton; + final String finishButton; +} diff --git a/packages/flutter_introduction_widget/lib/src/introduction.dart b/packages/flutter_introduction_widget/lib/src/introduction.dart new file mode 100644 index 0000000..f594365 --- /dev/null +++ b/packages/flutter_introduction_widget/lib/src/introduction.dart @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; +import 'package:flutter_introduction_widget/src/config/introduction.dart'; +import 'package:flutter_introduction_widget/src/types/page_introduction.dart'; +import 'package:flutter_introduction_widget/src/types/single_introduction.dart'; + +const kAnimationDuration = Duration(milliseconds: 300); + +class IntroductionScreen extends StatelessWidget { + const IntroductionScreen({ + required this.options, + required this.onComplete, + super.key, + this.physics, + this.onNext, + this.onPrevious, + this.onSkip, + }); + + /// The options used to build the introduction screen + final IntroductionOptions options; + + /// A function called when the introductionSceen changes + final VoidCallback onComplete; + + /// A function called when the introductionScreen is skipped + final VoidCallback? onSkip; + final ScrollPhysics? physics; + + /// A function called when the introductionScreen moved to the next page + /// where the page provided is the page where the user currently moved to + final void Function(IntroductionPage)? onNext; + + /// A function called when the introductionScreen moved to the previous page + /// where the page provided is the page where the user currently moved to + final void Function(IntroductionPage)? onPrevious; + + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: Colors.transparent, + body: Builder( + builder: (context) { + switch (options.displayMode) { + case IntroductionDisplayMode.multiPageHorizontal: + return MultiPageIntroductionScreen( + onComplete: onComplete, + physics: physics, + onSkip: onSkip, + onPrevious: onPrevious, + onNext: onNext, + options: options, + ); + case IntroductionDisplayMode.singleScrollablePageVertical: + return SingleIntroductionPage( + options: options, + ); + } + }, + ), + ); +} diff --git a/packages/flutter_introduction_widget/lib/src/types/page_introduction.dart b/packages/flutter_introduction_widget/lib/src/types/page_introduction.dart new file mode 100644 index 0000000..d16228d --- /dev/null +++ b/packages/flutter_introduction_widget/lib/src/types/page_introduction.dart @@ -0,0 +1,583 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_introduction_widget/src/config/introduction.dart'; +import 'package:flutter_introduction_widget/src/introduction.dart'; +import 'package:flutter_introduction_widget/src/widgets/background.dart'; +import 'package:flutter_introduction_widget/src/widgets/indicator.dart'; +import 'package:flutter_introduction_widget/src/widgets/page_content.dart'; + +class MultiPageIntroductionScreen extends StatefulWidget { + const MultiPageIntroductionScreen({ + required this.options, + required this.onComplete, + this.physics, + this.onNext, + this.onPrevious, + this.onSkip, + super.key, + }); + + final VoidCallback onComplete; + final VoidCallback? onSkip; + final void Function(IntroductionPage)? onNext; + final void Function(IntroductionPage)? onPrevious; + final ScrollPhysics? physics; + + final IntroductionOptions options; + + @override + State createState() => + _MultiPageIntroductionScreenState(); +} + +class _MultiPageIntroductionScreenState + extends State { + final PageController _controller = PageController(); + final ValueNotifier _currentPage = ValueNotifier(0); + + @override + void initState() { + super.initState(); + _controller.addListener(_handleScroll); + } + + void _handleScroll() { + _currentPage.value = _controller.page?.round() ?? 0; + } + + @override + void dispose() { + _controller.removeListener(_handleScroll); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + var pages = widget.options.pages; + var translations = widget.options.introductionTranslations; + return Stack( + children: [ + NotificationListener( + onNotification: (notification) { + if (notification is OverscrollNotification) { + if (notification.overscroll > 8) { + widget.onComplete.call(); + } + } + // add bouncing scroll physics support + if (notification is ScrollUpdateNotification) { + var offset = notification.metrics.pixels; + if (offset > notification.metrics.maxScrollExtent + 8) { + widget.onComplete.call(); + } + } + return false; + }, + child: PageView( + controller: _controller, + physics: widget.physics, + children: List.generate( + pages.length, + (index) => ExplainerPage( + onTap: () { + widget.onNext?.call(pages[_currentPage.value]); + }, + controller: _controller, + page: pages[index], + options: widget.options, + index: index, + ), + ), + ), + ), + SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (widget.options.controlMode == + IntroductionControlMode.previousNextButton) ...[ + Padding( + padding: const EdgeInsets.only(right: 16.0), + child: SizedBox( + height: 64, + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) => Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (widget.options.skippable && !_isLast(pages)) ...[ + TextButton( + onPressed: widget.onComplete, + child: Text(translations.skipButton), + ), + ], + ], + ), + ), + ), + ), + ] else ...[ + const SizedBox.shrink(), + ], + Align( + alignment: Alignment.bottomCenter, + child: Column( + children: [ + AnimatedBuilder( + animation: _currentPage, + builder: (context, _) => Indicator( + indicatorBuilder: widget.options.indicatorBuilder, + mode: widget.options.indicatorMode, + controller: _controller, + count: pages.length, + index: _currentPage.value, + ), + ), + Padding( + padding: const EdgeInsets.all(32), + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + if (widget.options.controlMode == + IntroductionControlMode.singleButton) { + return IntroductionOneButton( + controller: _controller, + next: _isNext(pages), + previous: _isPrevious, + last: _isLast(pages), + options: widget.options, + onFinish: widget.onComplete, + onNext: () { + widget.onNext?.call(pages[_currentPage.value]); + }, + onPrevious: () { + widget.onNext?.call(pages[_currentPage.value]); + }, + ); + } + + return IntroductionTwoButtons( + controller: _controller, + next: _isNext(pages), + previous: _isPrevious, + last: _isLast(pages), + options: widget.options, + onFinish: widget.onComplete, + onNext: () { + widget.onNext?.call(pages[_currentPage.value]); + }, + onPrevious: () { + widget.onNext?.call(pages[_currentPage.value]); + }, + ); + }, + ), + ), + ], + ), + ), + ], + ), + ), + AnimatedBuilder( + animation: _controller, + builder: (context, _) => IntroductionIconButtons( + controller: _controller, + next: _isNext(pages), + previous: _isPrevious, + last: _isLast(pages), + options: widget.options, + onFinish: widget.onComplete, + onNext: () { + widget.onNext?.call(pages[_currentPage.value]); + }, + onPrevious: () { + widget.onNext?.call(pages[_currentPage.value]); + }, + ), + ), + ], + ); + } + + bool _isLast(List pages) => + _currentPage.value == pages.length - 1; + + bool get _isPrevious => _currentPage.value > 0; + + bool _isNext(List pages) => + _currentPage.value < pages.length - 1; +} + +class ExplainerPage extends StatelessWidget { + const ExplainerPage({ + required this.page, + required this.options, + required this.index, + required this.controller, + this.onTap, + super.key, + }); + + final IntroductionPage page; + final IntroductionOptions options; + final PageController controller; + final int index; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + return Background( + background: page.decoration, + child: SafeArea( + child: Column( + children: [ + const SizedBox( + height: 64, + ), + Expanded( + child: IntroductionPageContent( + onTap: () { + if (options.tapEnabled) { + onTap?.call(); + } + }, + title: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: DefaultTextStyle( + style: theme.textTheme.displayMedium!, + child: page.title ?? Text('introduction.$index.title'), + ), + ), + text: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: DefaultTextStyle( + style: theme.textTheme.bodyLarge!, + child: page.text ?? Text('introduction.$index.description'), + ), + ), + graphic: Expanded( + child: Padding( + padding: const EdgeInsets.all(32), + child: page.graphic, + ), + ), + layoutStyle: page.layoutStyle ?? options.layoutStyle, + ), + ), + const SizedBox( + height: 144, + ), + ], + ), + ), + ); + } +} + +class IntroductionTwoButtons extends StatelessWidget { + const IntroductionTwoButtons({ + required this.options, + required this.controller, + required this.next, + required this.last, + required this.previous, + required this.onFinish, + required this.onNext, + required this.onPrevious, + super.key, + }); + + final IntroductionOptions options; + final PageController controller; + final VoidCallback? onFinish; + final VoidCallback? onNext; + final VoidCallback? onPrevious; + + final bool previous; + final bool next; + final bool last; + + Future _previous() async { + await controller.previousPage( + duration: kAnimationDuration, + curve: Curves.easeInOut, + ); + onPrevious?.call(); + } + + @override + Widget build(BuildContext context) { + var translations = options.introductionTranslations; + var showFinishButton = + options.buttonMode == IntroductionScreenButtonMode.singleFinish; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (options.buttonMode == IntroductionScreenButtonMode.text) ...[ + if (previous) ...[ + options.buttonBuilder?.call( + context, + _previous, + Text(translations.previousButton), + IntroductionButtonType.previous, + ) ?? + TextButton( + onPressed: _previous, + child: Text(translations.previousButton), + ), + ] else + const SizedBox.shrink(), + if (next) ...[ + options.buttonBuilder?.call( + context, + _next, + Text(translations.nextButton), + IntroductionButtonType.next, + ) ?? + TextButton( + onPressed: _next, + child: Text(translations.nextButton), + ), + ] else if (last) ...[ + options.buttonBuilder?.call( + context, + () { + onFinish?.call(); + }, + Text(translations.finishButton), + IntroductionButtonType.finish, + ) ?? + TextButton( + onPressed: () { + onFinish?.call(); + }, + child: Text(translations.finishButton), + ), + ] else ...[ + const SizedBox.shrink(), + ], + ] else if (showFinishButton) ...[ + const SizedBox.shrink(), + Expanded( + child: Visibility( + visible: last, + maintainSize: true, + maintainAnimation: true, + maintainState: true, + maintainInteractivity: false, + child: Align( + child: options.buttonBuilder?.call( + context, + () { + onFinish?.call(); + }, + Text(translations.finishButton), + IntroductionButtonType.finish, + ) ?? + ElevatedButton( + onPressed: () { + onFinish?.call(); + }, + child: Text(translations.finishButton), + ), + ), + ), + ), + const SizedBox.shrink(), + ], + ], + ); + } + + Future _next() async { + await controller.nextPage( + duration: kAnimationDuration, + curve: Curves.easeInOut, + ); + onNext?.call(); + } +} + +class IntroductionOneButton extends StatelessWidget { + const IntroductionOneButton({ + required this.options, + required this.controller, + required this.next, + required this.last, + required this.previous, + required this.onFinish, + required this.onNext, + required this.onPrevious, + super.key, + }); + + final IntroductionOptions options; + final PageController controller; + final VoidCallback? onFinish; + final VoidCallback? onNext; + final VoidCallback? onPrevious; + + final bool previous; + final bool next; + final bool last; + + Future _previous() async { + await controller.previousPage( + duration: kAnimationDuration, + curve: Curves.easeInOut, + ); + onPrevious?.call(); + } + + @override + Widget build(BuildContext context) { + var translations = options.introductionTranslations; + + return Column( + children: [ + if (options.buttonMode == IntroductionScreenButtonMode.text) ...[ + if (last) ...[ + options.buttonBuilder?.call( + context, + () { + onFinish?.call(); + }, + Text(translations.finishButton), + IntroductionButtonType.finish, + ) ?? + TextButton( + onPressed: () { + onFinish?.call(); + }, + child: Text(translations.finishButton), + ), + ] else ...[ + options.buttonBuilder?.call( + context, + _next, + Text(translations.nextButton), + IntroductionButtonType.next, + ) ?? + TextButton( + onPressed: _next, + child: Text(translations.nextButton), + ), + ], + if (previous) ...[ + options.buttonBuilder?.call( + context, + _previous, + Text(translations.previousButton), + IntroductionButtonType.previous, + ) ?? + TextButton( + onPressed: _previous, + child: Text(translations.previousButton), + ), + ] else ...[ + options.buttonBuilder?.call( + context, + () { + onFinish?.call(); + }, + Text(translations.finishButton), + IntroductionButtonType.skip, + ) ?? + TextButton( + onPressed: () { + onFinish?.call(); + }, + child: Text(translations.finishButton), + ), + ], + ], + ], + ); + } + + Future _next() async { + await controller.nextPage( + duration: kAnimationDuration, + curve: Curves.easeInOut, + ); + onNext?.call(); + } +} + +class IntroductionIconButtons extends StatelessWidget { + const IntroductionIconButtons({ + required this.options, + required this.controller, + required this.next, + required this.last, + required this.previous, + required this.onFinish, + required this.onNext, + required this.onPrevious, + super.key, + }); + + final IntroductionOptions options; + final PageController controller; + final VoidCallback? onFinish; + final VoidCallback? onNext; + final VoidCallback? onPrevious; + + final bool previous; + final bool next; + final bool last; + + Future _previous() async { + await controller.previousPage( + duration: kAnimationDuration, + curve: Curves.easeInOut, + ); + onPrevious?.call(); + } + + @override + Widget build(BuildContext context) => Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (options.buttonMode == IntroductionScreenButtonMode.icon) ...[ + if (previous) ...[ + IconButton( + iconSize: 70, + onPressed: _previous, + icon: const Icon(Icons.chevron_left), + ), + ] else + const SizedBox.shrink(), + IconButton( + iconSize: 70, + onPressed: () { + if (next) { + unawaited(_next()); + } else { + onFinish?.call(); + } + }, + icon: const Icon(Icons.chevron_right), + ), + ], + ], + ), + ); + + Future _next() async { + await controller.nextPage( + duration: kAnimationDuration, + curve: Curves.easeInOut, + ); + onNext?.call(); + } +} diff --git a/packages/flutter_introduction_widget/lib/src/types/single_introduction.dart b/packages/flutter_introduction_widget/lib/src/types/single_introduction.dart new file mode 100644 index 0000000..86fa514 --- /dev/null +++ b/packages/flutter_introduction_widget/lib/src/types/single_introduction.dart @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/cupertino.dart'; + +import 'package:flutter_introduction_widget/src/config/introduction.dart'; + +class SingleIntroductionPage extends StatelessWidget { + const SingleIntroductionPage({ + required this.options, + super.key, + }); + + final IntroductionOptions options; + + @override + Widget build(BuildContext context) { + throw UnimplementedError(); + } +} diff --git a/packages/flutter_introduction_widget/lib/src/widgets/background.dart b/packages/flutter_introduction_widget/lib/src/widgets/background.dart new file mode 100644 index 0000000..86c05e4 --- /dev/null +++ b/packages/flutter_introduction_widget/lib/src/widgets/background.dart @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; + +class Background extends StatelessWidget { + const Background({ + required this.child, + this.background, + super.key, + }); + + final BoxDecoration? background; + final Widget child; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var background = this.background ?? + BoxDecoration( + color: theme.colorScheme.background, + ); + var size = MediaQuery.of(context).size; + return Container( + width: size.width, + height: size.height, + decoration: background, + child: child, + ); + } +} diff --git a/packages/flutter_introduction_widget/lib/src/widgets/indicator.dart b/packages/flutter_introduction_widget/lib/src/widgets/indicator.dart new file mode 100644 index 0000000..cb247e9 --- /dev/null +++ b/packages/flutter_introduction_widget/lib/src/widgets/indicator.dart @@ -0,0 +1,211 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_introduction_widget/src/config/introduction.dart'; +import 'package:flutter_introduction_widget/src/introduction.dart'; + +class Indicator extends StatelessWidget { + const Indicator({ + required this.mode, + required this.controller, + required this.count, + required this.index, + required this.indicatorBuilder, + super.key, + }) : assert( + !(mode == IndicatorMode.custom && indicatorBuilder == null), + 'When a custom indicator is used the indicatorBuilder ' + 'must be provided', + ); + + final IndicatorMode mode; + final PageController controller; + final Widget Function( + BuildContext, + PageController, + int, + int, + )? indicatorBuilder; + final int index; + final int count; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + switch (mode) { + case IndicatorMode.custom: + return indicatorBuilder!.call(context, controller, index, count); + case IndicatorMode.dot: + return DotsIndicator( + controller: controller, + color: theme.colorScheme.primary, + dotcolor: theme.colorScheme.secondary, + itemCount: count, + onPageSelected: (int page) { + unawaited( + controller.animateToPage( + page, + duration: kAnimationDuration, + curve: Curves.easeInOut, + ), + ); + }, + ); + case IndicatorMode.dash: + return DashIndicator( + controller: controller, + selectedColor: theme.colorScheme.primary, + itemCount: count, + onPageSelected: (int page) { + unawaited( + controller.animateToPage( + page, + duration: kAnimationDuration, + curve: Curves.easeInOut, + ), + ); + }, + ); + } + } +} + +class DashIndicator extends AnimatedWidget { + const DashIndicator({ + required this.controller, + required this.selectedColor, + required this.itemCount, + required this.onPageSelected, + this.color = Colors.white, + super.key, + }) : super(listenable: controller); + final PageController controller; + final Color color; + final Color selectedColor; + final int itemCount; + final Function(int) onPageSelected; + + int _getPage() { + try { + return controller.page?.round() ?? 0; + } on Exception catch (_) { + return 0; + } + } + + @override + Widget build(BuildContext context) { + var index = _getPage(); + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (int i = 0; i < itemCount; i++) ...[ + buildDash(i, selected: index == i), + ], + ], + ); + } + + Widget buildDash(int index, {required bool selected}) => SizedBox( + width: 20, + child: Center( + child: Material( + color: selected ? color : color.withAlpha(125), + type: MaterialType.card, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2.5), + ), + width: 16, + height: 5, + child: InkWell( + onTap: () => onPageSelected.call(index), + ), + ), + ), + ), + ); +} + +/// An indicator showing the currently selected page of a PageController +class DotsIndicator extends AnimatedWidget { + const DotsIndicator({ + required this.controller, + this.color = Colors.white, + this.dotcolor = Colors.green, + this.itemCount, + this.onPageSelected, + super.key, + }) : super( + listenable: controller, + ); + + /// The PageController that this DotsIndicator is representing. + final Color? dotcolor; + final PageController controller; + + /// The number of items managed by the PageController + final int? itemCount; + + /// Called when a dot is tapped + final ValueChanged? onPageSelected; + + /// The color of the dots. + /// + /// Defaults to `Colors.white`. + final Color color; + + // The base size of the dots + static const double _kDotSize = 4.0; + + // The increase in the size of the selected dot + static const double _kMaxZoom = 2.0; + + // The distance between the center of each dot + static const double _kDotSpacing = 12.0; + + Widget _buildDot(int index) { + var selectedness = Curves.easeOut.transform( + max( + 0.0, + 1.0 - + ((controller.page ?? controller.initialPage).round() - index).abs(), + ), + ); + var zoom = 1.0 + (_kMaxZoom - 1.0) * selectedness; + + return SizedBox( + width: _kDotSpacing, + child: Center( + child: Material( + color: (((controller.page ?? controller.initialPage).round()) == index + ? color + : color.withAlpha(125)), + type: MaterialType.circle, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(width: 2, color: dotcolor!), + ), + width: _kDotSize * 2 * zoom, + height: _kDotSize * 2 * zoom, + child: InkWell( + onTap: () => onPageSelected!.call(index), + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(itemCount!, _buildDot), + ); +} diff --git a/packages/flutter_introduction_widget/lib/src/widgets/page_content.dart b/packages/flutter_introduction_widget/lib/src/widgets/page_content.dart new file mode 100644 index 0000000..0146807 --- /dev/null +++ b/packages/flutter_introduction_widget/lib/src/widgets/page_content.dart @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; + +import 'package:flutter_introduction_widget/src/config/introduction.dart'; + +class IntroductionPageContent extends StatelessWidget { + const IntroductionPageContent({ + required this.title, + required this.text, + required this.graphic, + required this.layoutStyle, + required this.onTap, + super.key, + }); + + final Widget? title; + final Widget? text; + final Widget? graphic; + final IntroductionLayoutStyle layoutStyle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => GestureDetector( + onTap: onTap, + child: Column( + children: [ + if (graphic != null && + layoutStyle == IntroductionLayoutStyle.imageTop) + graphic!, + if (title != null) title!, + if (graphic != null && + layoutStyle == IntroductionLayoutStyle.imageCenter) + graphic!, + if (text != null) text!, + if (graphic != null && + layoutStyle == IntroductionLayoutStyle.imageBottom) + graphic!, + ], + ), + ); +} diff --git a/packages/flutter_introduction_widget/pubspec.yaml b/packages/flutter_introduction_widget/pubspec.yaml new file mode 100644 index 0000000..2898b38 --- /dev/null +++ b/packages/flutter_introduction_widget/pubspec.yaml @@ -0,0 +1,23 @@ +name: flutter_introduction_widget +description: Flutter Introduction Widget for showing a list of introduction pages on a single scrollable page or horizontal pageview +version: 2.0.0 +homepage: https://github.com/Iconica-Development/flutter_introduction_widget + +environment: + sdk: ">=2.18.0 <3.0.0" + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + flutter_iconica_analysis: + git: + url: https://github.com/Iconica-Development/flutter_iconica_analysis + ref: 6.0.0 + +flutter: diff --git a/packages/flutter_introduction_widget/test/flutter_introduction_test.dart b/packages/flutter_introduction_widget/test/flutter_introduction_test.dart new file mode 100644 index 0000000..c62bf12 --- /dev/null +++ b/packages/flutter_introduction_widget/test/flutter_introduction_test.dart @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2022 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('test', () { + expect(true, true); + }); +} diff --git a/pubspec.yaml b/pubspec.yaml index 050270d..fcb35c8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,27 +1,7 @@ -name: flutter_introduction -description: Combined Package of Flutter Introduction Widget and Flutter Introduction Service -version: 1.0.0 -publish_to: none +name: flutter_introduction_workspace +version: 2.0.0 environment: - sdk: ">=2.18.0 <3.0.0" - flutter: ">=1.17.0" - -dependencies: - flutter: - sdk: flutter - flutter_introduction_widget: - git: - url: https://github.com/Iconica-Development/flutter_introduction_widget.git - ref: 3.0.0 - flutter_introduction_service: - git: - url: https://github.com/Iconica-Development/flutter_introduction_service.git - ref: 1.0.0 - + sdk: '>=3.1.0 <4.0.0' dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.0 - -flutter: + melos: ^3.0.1