diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..757c789ab --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: AppIntro diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 1190f4acb..000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,30 +0,0 @@ - - - -**AppIntro Version**: - - -**Device/Android Version**: - - -**Issue details / Repro steps / Use case background**: - - -**Your Code**: - - -**Stack trace / LogCat**: -```ruby -paste stack trace and/or log here -``` - - diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..03f2ffb83 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report for an issue or a bug with AppIntro +--- + + + +## 🐛 Describe the bug + + +## ⚠️ Current behavior + + +## ✅ Expected behavior + + +## 💣 Steps to reproduce + + +## 📷 Screenshots + + +## 📑 Your Code + + + + + + +## 📱 Tech info + - AppIntro Version: + - Device: + - Android OS Version: \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..6b1902e65 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Questions + url: https://github.com/AppIntro/AppIntro/discussions/new + about: Ask questions about AppIntro and get support for your problems. + - name: Slack Chat + url: https://kotlinlang.slack.com/archives/C019SH1RMBN + about: Join the 'appintro' channel on KotlinLang Slack to chat with other AppIntro developers. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..958d339f1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,15 @@ +--- +name: Feature request +about: Suggest an idea or a new feature for AppIntro +--- + +## ⚠️ Is your feature request related to a problem? Please describe + + +## 💡 Describe the solution you'd like + + +## 🤚 Do you want to develop this feature yourself? + +- [ ] Yes +- [ ] No diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml new file mode 100644 index 000000000..f12fd6368 --- /dev/null +++ b/.github/workflows/gradle-wrapper-validation.yml @@ -0,0 +1,18 @@ +name: Validate Gradle Wrapper +on: + push: + branches: + - '*' + pull_request: + branches: + - '*' + +jobs: + validation: + name: Validation + runs-on: ubuntu-latest + steps: + - name: Checkout latest code + uses: actions/checkout@v4 + - name: Validate Gradle Wrapper + uses: gradle/wrapper-validation-action@v3 diff --git a/.github/workflows/pre-merge.yml b/.github/workflows/pre-merge.yml new file mode 100644 index 000000000..4095ac3f9 --- /dev/null +++ b/.github/workflows/pre-merge.yml @@ -0,0 +1,106 @@ +name: Pre Merge Checks +on: + push: + branches: + - 'main' + pull_request: + branches: + - '*' + +jobs: + ktlint: + runs-on: 'ubuntu-latest' + env: + GRADLE_OPTS: -Dorg.gradle.daemon=false + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + cache: 'gradle' + + - name: Run ktlint + run: ./gradlew ktlintCheck + + detekt: + runs-on: 'ubuntu-latest' + env: + GRADLE_OPTS: -Dorg.gradle.daemon=false + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + cache: 'gradle' + + - name: Run detekt + run: ./gradlew detekt + + lint: + runs-on: 'ubuntu-latest' + env: + GRADLE_OPTS: -Dorg.gradle.daemon=false + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + cache: 'gradle' + + - name: Run lint + run: ./gradlew lint + + test: + runs-on: 'ubuntu-latest' + env: + GRADLE_OPTS: -Dorg.gradle.daemon=false + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + cache: 'gradle' + + - name: Run all the tests + run: ./gradlew test + + build-debug-apk: + runs-on: 'ubuntu-latest' + name: Build Debug APK + env: + GRADLE_OPTS: -Dorg.gradle.daemon=false + + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + cache: 'gradle' + + - name: Build the Debug APK + run: ./gradlew assembleDebug + + - uses: actions/upload-artifact@v4 + with: + name: appintro-sample-app-${{ matrix.name }}.apk + path: example/build/outputs/apk/debug/example-debug.apk diff --git a/.gitignore b/.gitignore index 7b4b2e364..3f428279e 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,8 @@ captures/ !/example/example-release-v7.apk !/example/proguard-rules.pro .idea/ + +.DS_Store + +# Kotlin +.kotlin \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..ee32fd3a6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,390 @@ +# Change Log + +## Version 7.0.0 *(YYYY-MM-DD)* +This is a new major release of AppIntro. Please note that this release contains multiple new features (see below), several bugfixes, as well as multiple breaking changes. + +### Summary of Changes +* [#1112] We migrated AppIntro from ViewPager to ViewPager2 + +### Breaking Changes +* We removed `setScrollDurationFactor` since customizing scroll duration is supported anymore on ViewPager2 +* `setCustomTransformer` accepts a `ViewPager2.PageTransformer` instead of `ViewPager.PageTransformer` + +## Version 6.3.1 *(2023-07-24)* +This release of AppIntro is identical to 6.3.0. An outdated JitPack configuration caused 6.3.0 not to build correctly. + +## Version 6.3.0 *(2023-07-23)* +This is a new minor release of AppIntro. This library comes with several new features (see below) and bugfixes. + +### Summary of Changes +* We deprecated `setScrollDurationFactor` since customizing scroll duration will not be supported anymore in upcoming releases of AppIntro based on ViewPager2 +* Target SDK is now 33 + +### Enhancements 🎁 +* [#1030] AppIntro now internally uses Gradle KTS and Version Catalog +* [#1080] Add ability to change done button background color +* [#1049] Handle onBackPressed deprecation +* [#1051] Register callback on onBackPressedDispatcher + +### Bugfixes 🐛 +* [#1002] Fix RTL bug on wrongly retained currentItem +* [#1108] Fix RTL detection +* [#1109] Fix unexpected crash when using custom layouts with wrong ids with Parallax effect + +### Dependency updates 📦 +* Kotlin to 1.9.0 +* AGP to 8.0.2 +* AppCompat to 1.6.1 +* ConstraintLayout to 2.1.4 + +## Version 6.2.0 *(2022-01-17)* + +This is a new minor release of AppIntro. This library comes with several new features (see below) and bugfixes. + +### Summary of Changes + +* We deprecated `AppIntroFragment.newInstance` in favor of `AppIntroFragment.createInstance`. This was needed in order +to support passing color resources instead of color int, to tackle scenarios such as configuration changes and dark mode [#979]. +* Target SDK is now 31 +* We exposed a couple of properties/methods on the public API that were requested by the community ([#960] and [#959]) +* We added some Java examples for our Java users [#953] + +### Enhancements 🎁 + +* [#959] Add @JvmOverloads on goToNextSlide +* [#960] Expose a protected property for slidesNumber +* [#979] Fix #926: Add color resource parameters +* [#993] Make description scrollable + +### Bugfixes 🐛 + +* [#934] Fix ProgressIndicatorController in RTL + +### Dependency updates 📦 + +* Java version to 11 +* Kotlin to 1.6.10 +* AGP to 7.0.3 +* Androidx Appcompat to 1.4.0 +* ConstraintLayout to 2.1.2 + +## Version 6.1.0 *(2021-02-03)* + +This is a new minor release of AppIntro. This library comes with several new features (see below) and several bugfixes. + +### Summary of Changes + +* Target SDK is now 30. +* Text visualization has been improved with Autosizing TextViews and URL autolinking. +* AppIntro now offers better support for tablets (`sw600dp`). +* Slide indicator has been improved with better color blending and it won't be shown if you have only one slide. +* The AppIntro sample app has been completely rewritten with more examples (for Java, SlidePolicy and more). +* 14 translations have been added or improved. + +### Enhancements 🎁 + +* [#922] Add support for Autosizing TextViews +* [#887] Added start/stop animation for `Animatable` images +* [#883] Changing Visibility of Progress Bar when count of slide is one +* [#878] Add sw600dp support +* [#876] Single slide indicator condition +* [#870] added URL autolinking in appintro_fragment_intro.xml +* [#869] Allow to change skip and next arrow button color +* [#857] Function for setting text appearance of Done and Skip buttons +* [#825] Added ability to color back arrow and done button +* [#796] Extend visibility of goToPreviousSlide to protected + +### Bugfixes 🐛 + +* [#903] Deprecate the setNextPageSwipeLock method +* [#896] Fix for crash on PagerAdapter when using SlidePolicy +* [#891] Do not show back arrow on wizard mode on first slide +* [#856] Fix blending of Indicator Colors +* [#821] Fix bug with SetSwipeLock being reset to false +* [#817] enabling backward sliding when slide policy not met +* [#808] Fix isButtonsEnabled being reset to true after onCreate +* [#802] Fix goToNextSlide default param for RTL +* [#792] Remove reference to mainView in AppIntroBaseFragment +* [#791] Clearing main layout in onDestroyView + +### Translations 🌍 + +* [#919] Romanian (RO) translation +* [#908] Added Bulgarian translation +* [#895] Azerbaijan language support +* [#880] add Ukrainian translation +* [#877] Tamil translation added +* [#874] added translation for bengali +* [#872] added translation for yoruba +* [#868] Translation for Gujarati language +* [#862] Add Japanese translation +* [#839] Complete the Chinese (Simplified) translation +* [#834] Russian localization +* [#831] Finish the French translation +* [#829] Update Persian localization +* [#812] Update strings.xml Spanish + +### Dependency updates 📦 + +* Kotlin to 1.4.21 +* AGP to 4.1.1 +* Androidx Appcompat to 1.2.0 +* Constraintlayout to 2.0.4 + +### Credits + +This release was possible thanks to the contribution of: + +[@CristianCardosoA](https://github.com/CristianCardosoA) [@Debanshu777](https://github.com/Debanshu777) [@Debanshu777](https://github.com/Debanshu777) [@Hoossayn](https://github.com/Hoossayn) [@Hoossayn](https://github.com/Hoossayn) [@JekRock](https://github.com/JekRock) [@LinX64](https://github.com/LinX64) [@NikolaGanchev](https://github.com/NikolaGanchev) [@RobertPal95](https://github.com/RobertPal95) [@WWCheng02](https://github.com/WWCheng02) [@ZakCodes](https://github.com/ZakCodes) [@beefsausage](https://github.com/beefsausage) [@ch22843](https://github.com/ch22843) [@cortinico](https://github.com/cortinico) [@ghost](https://github.com/ghost) [@idish](https://github.com/idish) [@kunal-ch](https://github.com/kunal-ch) [@manimaran96](https://github.com/manimaran96) [@paolorotolo](https://github.com/paolorotolo) [@siper](https://github.com/siper) [@sr01](https://github.com/sr01) [@tsumuchan](https://github.com/tsumuchan) + +## Version 6.0.0 *(2020-05-04)* + +This is a new major release of AppIntro. Please note that this release contains multiple new features (see below), several bugfixes, as well as multiple breaking changes. +To get a deeper overview of the breaking changes, please read the [migration document](/docs/migrating-from-5.0.md). + +### Summary of Changes + +* The library is now 100% in Kotlin! 🎉. +* Target SDK is now 29. +* The UI was completely revamped and refactored. +* You can now request permissions on AppIntro without having to lock the slide. +* The library has now 14 more translations. + +### Enhancements 🎁 + +* [#748] Refactor library package name +* [#738] Fix button state not being retained on configuration change +* [#735] Added a parallax animation setting +* [#733] Move SampleSlide to library +* [#730] New Approach to Permission & Cleanup +* [#700] Add methods to allow to change done/skip text by passing String Res ID +* [#678] Support requesting permissions without locking the Swipe +* [#666] Minor improvements on downloadable fonts support +* [#647] Complete UI Overhaul +* [#642] Replace the Layout2 background color with the proper resource +* [#626] Fixing missing Content Description (#624) + +### Bugfixes 🐛 + +* [#773] Fix bug on swipe with Permission slide +* [#770] Add missing flags for `setStatusBarColor` +* [#767] Fix setIndicatorColor crashing onCreate +* [#742] Fix Crash on orientation changes due to UninitiatedPropertyAccessException +* [#734] Move strings-vi to correct location +* [#689] AppIntroViewPager: Fix slide policy handling when sliding the view pager +* [#666] Minor improvements on downloadable fonts support +* [#653] Fix Fade Animation +* [#641] Fix overlap of the ViewPager on the bottom AppIntro bar + +### Translations 🌍 + +* [#723] Add Norwegian translation +* [#715] Add Korean translation +* [#714] Added Dutch translations +* [#712] add Vietnamese +* [#696] Add slovak translation +* [#694] Added Serbian translation +* [#693] Adding Greek Translation +* [#687] added Polish translation +* [#671] Best Pactise Builder Pattern along with Missing Arabic Word Translations. +* [#639] Update Skip Icon and add Hindi translation. +* [#637] Added PT and changed PT-BR +* [#635] Add missing German Translations +* [#629] Create indonesian translation +* [#620] added Czech (cs) translation + +### Library Internals ⚙️ + +* [#774] Move from Travis to GitHub Actions +* [#768] Refactor Transformers to use a sealed class +* [#766] Add missing @Res annotations +* [#765] Remove `I` prefix from interface names +* [#764] Remove extra LinearLayout qualifier in DotIndicatorController +* [#763] Remove dependency on kotlin-reflect +* [#762] Updating several dependencies +* [#747] Rewrite example in Kotlin and simplify code +* [#739] Fix Visibility leakage before releasing 6.0.0 +* [#744] Refactor example package name, update gradle +* [#729] Update setButtonsEnabled Deprecation note +* [#674] Fix typo in OnPageChangeListener +* [#670] Convert AppIntroBase to Kotlin +* [#634] Kotlinize the AppIntroViewPager +* [#613] Kotlinize all the Abstract Base Classes +* [#612] Kotlinize fragments +* [#611] Kotlinize the ViewPager +* [#605] Kotlinize the ScrollerCustomDuration +* [#604] Convert all the interfaces to Kotlin +* [#602] Kotlinize the 'indicator' package +* [#601] Kotlinize the PermissionWrapper +* [#600] Kotlinize the 'util' package +* [#574] Add prefix for resources (Closes: #573) + +### Infrastructure 🏗 + +* [#728] Gradle to 6.1 +* [#726] Update Dependencies +* [#724] Fix Travis failure due to Detekt +* [#698] Update Dependencies to latest versions +* [#691] Make Travis run all the Gradle tasks +* [#684] Introduce KtLint and Detekt +* [#683] MaterialDrawer to 6.1.2 +* [#681] Gradle to 5.4.1 +* [#680] Cleanup all the Sonatype/MavenCentral publishing files +* [#677] Update dependencies +* [#633] Updating Gradle to 5.1.1 +* [#631] Updating AndroidX to the latest version +* [#625] Updating Kotlin to 1.3.11 +* [#606] Bumping Kotlin to 1.3 + +### Credits + +This release was possible thanks to the contribution of: + +[@AnuthaDev](https://github.com/AnuthaDev) [@bartekpacia](https://github.com/bartekpacia) [@chihung93](https://github.com/chihung93) [@cortinico](https://github.com/cortinico) [@dragstor](https://github.com/dragstor) [@elegktara37](https://github.com/elegktara37) [@fchauveau](https://github.com/fchauveau) [@Goopher](https://github.com/Goopher) [@GuilhE](https://github.com/GuilhE) [@ivaniskandar](https://github.com/ivaniskandar) [@ivaniskandar](https://github.com/ivaniskandar) [@Kimjio](https://github.com/Kimjio) [@maxee](https://github.com/maxee) [@moxspoy](https://github.com/moxspoy) [@MTRNord](https://github.com/MTRNord) [@paolorotolo](https://github.com/paolorotolo) [@perchrh](https://github.com/perchrh) [@vzahradnik](https://github.com/vzahradnik) [@Younes-Charfaoui](https://github.com/Younes-Charfaoui) [@zpapez](https://github.com/zpapez) + +## Version 5.1.0 *(2018-10-23)* + +* Fixed issue that caused a build failure on Kotlin projects [#597]; +* Added support for Android 8.0 custom Fonts API [#590]; +* Updated Translations; +* Miscellaneous bug fixes and performance improvements; + +## Version 5.0.1 *(2018-10-16)* + +* Fixed incorrect behaviour when android:supportsRtl="true" was present in app's Manifest; +* Fixed RTL support; +* Update Translations; +* Miscellaneous bug fixes and performance improvements; + +## Version 5.0.0 *(2018-10-7)* + +* Migrate to AndroidX; +* Target SDK 28; +* Update Translations; +* Miscellaneous bug fixes and performance improvements; + +Previous release notes can be found here: [releases] + +[#574]: https://github.com/AppIntro/AppIntro/pull/574 +[#590]: https://github.com/AppIntro/AppIntro/pull/590 +[#597]: https://github.com/AppIntro/AppIntro/pull/597 +[#600]: https://github.com/AppIntro/AppIntro/pull/600 +[#601]: https://github.com/AppIntro/AppIntro/pull/601 +[#602]: https://github.com/AppIntro/AppIntro/pull/602 +[#604]: https://github.com/AppIntro/AppIntro/pull/604 +[#605]: https://github.com/AppIntro/AppIntro/pull/605 +[#606]: https://github.com/AppIntro/AppIntro/pull/606 +[#611]: https://github.com/AppIntro/AppIntro/pull/611 +[#612]: https://github.com/AppIntro/AppIntro/pull/612 +[#613]: https://github.com/AppIntro/AppIntro/pull/613 +[#620]: https://github.com/AppIntro/AppIntro/pull/620 +[#625]: https://github.com/AppIntro/AppIntro/pull/625 +[#626]: https://github.com/AppIntro/AppIntro/pull/626 +[#629]: https://github.com/AppIntro/AppIntro/pull/629 +[#631]: https://github.com/AppIntro/AppIntro/pull/631 +[#633]: https://github.com/AppIntro/AppIntro/pull/633 +[#634]: https://github.com/AppIntro/AppIntro/pull/634 +[#635]: https://github.com/AppIntro/AppIntro/pull/635 +[#637]: https://github.com/AppIntro/AppIntro/pull/637 +[#639]: https://github.com/AppIntro/AppIntro/pull/639 +[#641]: https://github.com/AppIntro/AppIntro/pull/641 +[#642]: https://github.com/AppIntro/AppIntro/pull/642 +[#647]: https://github.com/AppIntro/AppIntro/pull/647 +[#653]: https://github.com/AppIntro/AppIntro/pull/653 +[#666]: https://github.com/AppIntro/AppIntro/pull/666 +[#666]: https://github.com/AppIntro/AppIntro/pull/666 +[#670]: https://github.com/AppIntro/AppIntro/pull/670 +[#671]: https://github.com/AppIntro/AppIntro/pull/671 +[#674]: https://github.com/AppIntro/AppIntro/pull/674 +[#677]: https://github.com/AppIntro/AppIntro/pull/677 +[#678]: https://github.com/AppIntro/AppIntro/pull/678 +[#680]: https://github.com/AppIntro/AppIntro/pull/680 +[#681]: https://github.com/AppIntro/AppIntro/pull/681 +[#683]: https://github.com/AppIntro/AppIntro/pull/683 +[#684]: https://github.com/AppIntro/AppIntro/pull/684 +[#687]: https://github.com/AppIntro/AppIntro/pull/687 +[#689]: https://github.com/AppIntro/AppIntro/pull/689 +[#691]: https://github.com/AppIntro/AppIntro/pull/691 +[#693]: https://github.com/AppIntro/AppIntro/pull/693 +[#694]: https://github.com/AppIntro/AppIntro/pull/694 +[#696]: https://github.com/AppIntro/AppIntro/pull/696 +[#698]: https://github.com/AppIntro/AppIntro/pull/698 +[#700]: https://github.com/AppIntro/AppIntro/pull/700 +[#712]: https://github.com/AppIntro/AppIntro/pull/712 +[#714]: https://github.com/AppIntro/AppIntro/pull/714 +[#715]: https://github.com/AppIntro/AppIntro/pull/715 +[#723]: https://github.com/AppIntro/AppIntro/pull/723 +[#724]: https://github.com/AppIntro/AppIntro/pull/724 +[#726]: https://github.com/AppIntro/AppIntro/pull/726 +[#728]: https://github.com/AppIntro/AppIntro/pull/728 +[#729]: https://github.com/AppIntro/AppIntro/pull/729 +[#730]: https://github.com/AppIntro/AppIntro/pull/730 +[#733]: https://github.com/AppIntro/AppIntro/pull/733 +[#734]: https://github.com/AppIntro/AppIntro/pull/734 +[#735]: https://github.com/AppIntro/AppIntro/pull/735 +[#738]: https://github.com/AppIntro/AppIntro/pull/738 +[#739]: https://github.com/AppIntro/AppIntro/pull/739 +[#742]: https://github.com/AppIntro/AppIntro/pull/742 +[#744]: https://github.com/AppIntro/AppIntro/pull/744 +[#747]: https://github.com/AppIntro/AppIntro/pull/747 +[#748]: https://github.com/AppIntro/AppIntro/pull/748 +[#762]: https://github.com/AppIntro/AppIntro/pull/762 +[#763]: https://github.com/AppIntro/AppIntro/pull/763 +[#764]: https://github.com/AppIntro/AppIntro/pull/764 +[#765]: https://github.com/AppIntro/AppIntro/pull/765 +[#766]: https://github.com/AppIntro/AppIntro/pull/766 +[#767]: https://github.com/AppIntro/AppIntro/pull/767 +[#768]: https://github.com/AppIntro/AppIntro/pull/768 +[#770]: https://github.com/AppIntro/AppIntro/pull/770 +[#773]: https://github.com/AppIntro/AppIntro/pull/773 +[#774]: https://github.com/AppIntro/AppIntro/pull/774 +[#791]: https://github.com/AppIntro/AppIntro/pull/791 +[#792]: https://github.com/AppIntro/AppIntro/pull/792 +[#796]: https://github.com/AppIntro/AppIntro/pull/796 +[#802]: https://github.com/AppIntro/AppIntro/pull/802 +[#808]: https://github.com/AppIntro/AppIntro/pull/808 +[#812]: https://github.com/AppIntro/AppIntro/pull/812 +[#817]: https://github.com/AppIntro/AppIntro/pull/817 +[#821]: https://github.com/AppIntro/AppIntro/pull/821 +[#825]: https://github.com/AppIntro/AppIntro/pull/825 +[#829]: https://github.com/AppIntro/AppIntro/pull/829 +[#831]: https://github.com/AppIntro/AppIntro/pull/831 +[#834]: https://github.com/AppIntro/AppIntro/pull/834 +[#839]: https://github.com/AppIntro/AppIntro/pull/839 +[#856]: https://github.com/AppIntro/AppIntro/pull/856 +[#857]: https://github.com/AppIntro/AppIntro/pull/857 +[#862]: https://github.com/AppIntro/AppIntro/pull/862 +[#868]: https://github.com/AppIntro/AppIntro/pull/868 +[#869]: https://github.com/AppIntro/AppIntro/pull/869 +[#870]: https://github.com/AppIntro/AppIntro/pull/870 +[#872]: https://github.com/AppIntro/AppIntro/pull/872 +[#874]: https://github.com/AppIntro/AppIntro/pull/874 +[#876]: https://github.com/AppIntro/AppIntro/pull/876 +[#877]: https://github.com/AppIntro/AppIntro/pull/877 +[#878]: https://github.com/AppIntro/AppIntro/pull/878 +[#880]: https://github.com/AppIntro/AppIntro/pull/880 +[#883]: https://github.com/AppIntro/AppIntro/pull/883 +[#887]: https://github.com/AppIntro/AppIntro/pull/887 +[#891]: https://github.com/AppIntro/AppIntro/pull/891 +[#895]: https://github.com/AppIntro/AppIntro/pull/895 +[#896]: https://github.com/AppIntro/AppIntro/pull/896 +[#903]: https://github.com/AppIntro/AppIntro/pull/903 +[#908]: https://github.com/AppIntro/AppIntro/pull/908 +[#919]: https://github.com/AppIntro/AppIntro/pull/919 +[#922]: https://github.com/AppIntro/AppIntro/pull/922 +[#934]: https://github.com/AppIntro/AppIntro/pull/934 +[#953]: https://github.com/AppIntro/AppIntro/pull/953 +[#959]: https://github.com/AppIntro/AppIntro/pull/959 +[#960]: https://github.com/AppIntro/AppIntro/pull/960 +[#979]: https://github.com/AppIntro/AppIntro/pull/979 +[#993]: https://github.com/AppIntro/AppIntro/pull/993 +[#1030]: https://github.com/AppIntro/AppIntro/pull/1030 +[#1080]: https://github.com/AppIntro/AppIntro/pull/1080 +[#1049]: https://github.com/AppIntro/AppIntro/pull/1049 +[#1051]: https://github.com/AppIntro/AppIntro/pull/1051 +[#1002]: https://github.com/AppIntro/AppIntro/pull/1002 +[#1108]: https://github.com/AppIntro/AppIntro/pull/1108 +[#1109]: https://github.com/AppIntro/AppIntro/pull/1109 +[#1112]: https://github.com/AppIntro/AppIntro/pull/1112 +[releases]: https://github.com/AppIntro/AppIntro/releases?after=v5.0.0 + diff --git a/LICENSE b/LICENSE index 1178d82a2..90c4e34b3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,3 @@ - AppIntro library - - Copyright 2015 Paolo Rotolo - Copyright 2016 Maximilian Narr - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -192,3 +174,28 @@ of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2015 - 2020 AppIntro Developers + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 67be5025e..5f2c98c88 100644 --- a/README.md +++ b/README.md @@ -1,325 +1,603 @@ -[![Maven Central](https://img.shields.io/badge/maven%20central-appintro-green.svg)](http://search.maven.org/#browse%7C2137414099) -[![Android Arsenal](https://img.shields.io/badge/Android%20Arsenal-AppIntro-green.svg?style=flat)](https://android-arsenal.com/details/1/1939) -[![Android Gems](http://www.android-gems.com/badge/PaoloRotolo/AppIntro.svg?branch=master)](http://www.android-gems.com/lib/PaoloRotolo/AppIntro) - -

Sample App:

-Get it on Google Play - -# AppIntro -AppIntro is an Android Library that helps you make a **cool intro** for your app, like the ones in Google apps. - - - - -### *Watch the demo video on YouTube* -[![Intro demo video](https://img.youtube.com/vi/-KgAAbZz248/0.jpg)](https://www.youtube.com/watch?v=-KgAAbZz248) - -## Usage - -### Basic usage - -Add this to your **build.gradle**: - -```java -repositories { - mavenCentral() -} - -dependencies { - compile 'com.github.paolorotolo:appintro:4.1.0' -} -``` - -Create a new **Activity that extends AppIntro**: - -```java -public class IntroActivity extends AppIntro { - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - // Note here that we DO NOT use setContentView(); - - // Add your slide fragments here. - // AppIntro will automatically generate the dots indicator and buttons. - addSlide(firstFragment); - addSlide(secondFragment); - addSlide(thirdFragment); - addSlide(fourthFragment); - - // Instead of fragments, you can also use our default slide - // Just set a title, description, background and image. AppIntro will do the rest. - addSlide(AppIntroFragment.newInstance(title, description, image, backgroundColor)); - - // OPTIONAL METHODS - // Override bar/separator color. - setBarColor(Color.parseColor("#3F51B5")); - setSeparatorColor(Color.parseColor("#2196F3")); - - // Hide Skip/Done button. - showSkipButton(false); - setProgressButtonEnabled(false); - - // Turn vibration on and set intensity. - // NOTE: you will probably need to ask VIBRATE permission in Manifest. - setVibrate(true); - setVibrateIntensity(30); - } - - @Override - public void onSkipPressed(Fragment currentFragment) { - super.onSkipPressed(currentFragment); - // Do something when users tap on Skip button. - } - - @Override - public void onDonePressed(Fragment currentFragment) { - super.onDonePressed(currentFragment); - // Do something when users tap on Done button. - } - - @Override - public void onSlideChanged(@Nullable Fragment oldFragment, @Nullable Fragment newFragment) { - super.onSlideChanged(oldFragment, newFragment); - // Do something when the slide changes. - } -} -``` - -_Note above that we DID NOT use setContentView();_ - -Finally, declare the activity in your Manifest like so: - -``` xml - -``` - -Do not declare the intro as your main app launcher unless you want the intro to launch every time your app starts. -Refer to the [wiki](https://github.com/PaoloRotolo/AppIntro/wiki/How-to-Use#show-the-intro-once) for an example of how to launch the intro once from your main activity. - -#### Alternative layout -If you want to try an alternative layout (as seen in Google's Photo app), just extend **AppIntro2** in your Activity. That's all :) - -```java -public class IntroActivity extends AppIntro2 { - // ... -} -``` - - - -
- -#### Slides - -##### Basic slides - -AppIntro provides two simple classes, `AppIntroFragment` and `AppIntro2Fragment` which one can use to build simple slides. - -```java -@Override -protected void onCreate(@Nullable Bundle savedInstanceState) { - // ... - - addSlide(AppIntroFragment.newInstance(title, description, image, backgroundColor)); -} -``` - -##### Custom slides example - -One may also define custom slides as seen in the example project: - * Copy the class **SampleSlide** from my [example project](https://github.com/paolorotolo/AppIntro/blob/master/example/src/main/java/com/amqtech/opensource/appintroexample/util/SampleSlide.java). - * Add a new slide with `addSlide(SampleSlide.newInstance(R.layout.your_slide_here));` - -There's no need to create one class for fragment anymore. :) - -### Extended usage - -#### Animations -AppIntro comes with some pager animations. -Choose the one you like and then activate it with: - -```java -@Override -protected void onCreate(@Nullable Bundle savedInstanceState) { - // ... - - setFadeAnimation(); -} -``` - -Available animations: -```java -setFadeAnimation() -setZoomAnimation() -setFlowAnimation() -setSlideOverAnimation() -setDepthAnimation() -``` - -If you want to create nice parallax effect or your own custom animation, create your own **PageTransformer** and call: - -```java -@Override -protected void onCreate(@Nullable Bundle savedInstanceState) { - // ... - - setCustomTransformer(transformer); -} -``` - -Click [here](https://github.com/PaoloRotolo/AppIntro/blob/90a513fda9b70a5e5df35435a7f2984832727eeb/AppIntroExample/app/src/main/java/com/github/paolorotolo/appintroexample/animations/CustomAnimation.java) to see how I did it in the example app. - -#### Background color transitions - -AppIntro supports background color transitions: - - - -In order to setup the transitions, simply implement `ISlideBackgroundColorHolder`: -```java -public final class MySlide extends Fragment implements ISlideBackgroundColorHolder { - @Override - public int getDefaultBackgroundColor() { - // Return the default background color of the slide. - return Color.parseColor("#000000"); - } - - @Override - public void setBackgroundColor(@ColorInt int backgroundColor) { - // Set the background color of the view within your slide to which the transition should be applied. - if (layoutContainer != null) { - layoutContainer.setBackgroundColor(backgroundColor); - } - } -} -``` - -The API is quite low-level, therefore highly customizable. The interface contains two methods: - -- `getDefaultBackgroundColor`: Return the default background color (i.e. the background color the slide has in non-sliding state) of the slide here. -- `setBackgroundColor(int)`: This method will be called while swiping between two slides. Update the background color of the view to which the transition should be applied. -This is normally the root view of your Fragment's layout. But one may also apply the color transition to some other view only (i.e. a Button). - -#### Runtime Permissions (Android 6.0+) - - - -Android 6.0 introduced a new permissions model for developers. Now all your apps have to request permissions which can be a tedious thing to implement. - -However, AppIntro simplifies this down to one single line of code! - -```java -@Override -protected void onCreate(@Nullable Bundle savedInstanceState) { - // ... - - // Ask for CAMERA permission on the second slide - askForPermissions(new String[]{Manifest.permission.CAMERA}, 2); // OR - - // This will ask for the camera permission AND the contacts permission on the same slide. - // Ensure your slide talks about both so as not to confuse the user. - askForPermissions(new String[]{Manifest.permission.CAMERA, Manifest.permission.READ_CONTACTS}, 2); -} -``` - -**NOTE:** It is advised that you only put one permission in the String array unless you want the app to ask for multiple permissions on the same slide. - -#### Slide Policies - -If you want to restrict navigation between your slides (i.e. the user has to toggle a checkbox in order to continue), our **Slide Policy** feature might help you. - -All you have to do is implement `ISlidePolicy` in your slides: -```java -public final class MySlide extends Fragment implements ISlidePolicy { - @Override - public boolean isPolicyRespected() { - return // If user should be allowed to leave this slide - } - - @Override - public void onUserIllegallyRequestedNextPage() { - // User illegally requested next slide - } -} -``` -The interface contains two methods: - -- `isPolicyRespected`: The return value of this method defines if the user can leave this slide, i.e. navigate to another one -- `onUserIllegallyRequestedNextPage`: This method gets called if the user tries to leave the slide although `isPolicyRespected` returned false. One may show some error message here. - -## Example App -See example code [here](https://github.com/PaoloRotolo/AppIntro/tree/master/example) on GitHub. You can also see it live by downloading our example on [Google Play](https://play.google.com/store/apps/details?id=com.amqtech.opensource.appintroexample). - -## Real life examples -Do you need inspiration? A lot of apps are using AppIntro out there: - -**Planets** - - - -**Hermes - Material IRC Client** - - - - - - -## Apps using AppIntro -If you are using AppIntro in your app and would like to be listed here, please let us know by commenting in [this issue](https://github.com/PaoloRotolo/AppIntro/issues/325)! - - * [Numix Hermes](https://play.google.com/store/apps/details?id=org.numixproject.hermes) - * [Audio Reminder Pro](https://play.google.com/store/apps/details?id=com.brandon.audioreminderpro) - * [Wizr Daily Quotes](https://play.google.com/store/apps/details?id=com.wizrapp) - * [Planets](https://play.google.com/store/apps/details?id=com.andrewq.planets) - * [Weather Delta](https://play.google.com/store/apps/details?id=com.felkertech.n.weatherdelta) - * [PDF Me](https://play.google.com/store/apps/details?id=com.pdfme) - * [Circles](https://play.google.com/store/apps/details?id=com.felipejoglar.circles) - * [Task Master](https://play.google.com/store/apps/details?id=com.cr5315.taskmaster) - * [Smoothie Recipes](https://play.google.com/store/apps/details?id=com.skykonig.smoothierecipes) - * [SideBar Notes](https://play.google.com/store/apps/details?id=com.app.floating.notes) - * [just food](https://play.google.com/store/apps/details?id=scientist.jobless.foodmana) - * [AlarmSMS](https://play.google.com/store/apps/details?id=com.qhutch.alarmsms) - * [Aware](https://play.google.com/store/apps/details?id=com.bunemekyakilika.aware) - * [neutriNote](https://play.google.com/store/apps/details?id=com.appmindlab.nano) - * [Handwriting Note](https://play.google.com/store/apps/details?id=com.lyk.immersivenote) - * [Friends Roulette](https://play.google.com/store/apps/details?id=com.crioltech.roulette) - * [Karting Tools](https://play.google.com/store/apps/details?id=com.fabreax.android.kartingtools.activity) - * [ChineseDictionary (粵韻漢典離線粵語普通話發聲中文字典)](https://play.google.com/store/apps/details?id=com.jonasng.chinesedictionary) - * [Sifter](https://play.google.com/store/apps/details?id=sifter.social.network.archaeologist) - * [#-ludus 2.0](https://play.google.com/store/apps/details?id=com.fallenritemonk.ludus) - * [Snipit Text Grabber](https://play.google.com/store/apps/details?id=com.om.snipit) - * [Service Notes](https://play.google.com/store/apps/details?id=notes.service.com.servicenotes) - * [Salary Barometer](https://play.google.com/store/apps/details?id=anaware.salarybarometer) - * [Best Business Idea!](https://play.google.com/store/apps/details?id=anaware.bestidea) - * [Wi-Fi password reminder](https://play.google.com/store/apps/details?id=com.rusdelphi.wifipassword) - * [Safe Notes](https://play.google.com/store/apps/details?id=software.codeplus.safenotes) - * [Xpaper - Moto X Wallpapers](https://play.google.com/store/apps/details?id=com.dunrite.xpaper) - * [Find My Parked Car](https://play.google.com/store/apps/details?id=com.ofirmiron.findmycarandroidwear) - * [BoxPlay Music Player](https://play.google.com/store/apps/details?id=de.luckyworks.boxplay) - * [Vape Tool Pro](https://play.google.com/store/apps/details?id=com.stasbar.vapetoolpro) - * [NebelNiek Soundboard](https://play.google.com/store/apps/details?id=de.logtainment.nebelnieksoundboard) - * [sdiwi | Win your purchase!](https://play.google.com/store/apps/details?id=com.sdiwi.app) - * [Helal ve Sağlıklı Yaşam](https://play.google.com/store/apps/details?id=org.yasam.hsy.helalvesaglikliyasam) - * [HipCar - Car Rental](https://play.google.com/store/apps/details?id=com.hipcar.android) - * [Schematiskt Skolschema](https://play.google.com/store/apps/details?id=se.zinokader.schematiskt) - * [Third Eye](https://play.google.com/store/apps/details?id=com.miragestacks.thirdeye) - * [Crypton - Password Manager](https://play.google.com/store/apps/details?id=mindstorm.crypton) - * [Web Video Cast](https://play.google.com/store/apps/details?id=com.instantbits.cast.webvideo) - * [Sask. Geo-Memorial](https://play.google.com/store/apps/details?id=com.github.dstaflund.geomemorial) - * [SchoolBox](https://play.google.com/store/apps/details?id=com.deenysoft.schoolbox) - * [Fitness Challenge](https://play.google.com/store/apps/details?id=com.isidroid.fitchallenge) - * [Crunch (ICE)](https://play.google.com/store/apps/details?id=com.figsandolives.ice.free) - * [Filmy - Your Movie Guide](https://play.google.com/store/apps/details?id=tech.salroid.filmy) - * [HEBF Optimizer ▪ Root](https://play.google.com/store/apps/details?id=com.androidvip.hebf) - * [Wifi Captive Login](https://play.google.com/store/apps/details?id=com.anantharam.wificaptivelogin) - * [IIFYM](https://play.google.com/store/apps/details?id=com.javierd.iifym) - * [Ampwifi Winamp Remote](https://play.google.com/store/apps/details?id=com.blitterhead.ampwifi) - * [AaiKya: Leave Tracker](https://play.google.com/store/apps/details?id=com.ranveeraggarwal.letrack) - * [Angopapo - People around you](https://play.google.com/store/apps/details?id=com.msingapro.angopapofb) - * [Hugetwit](https://play.google.com/store/apps/details?id=com.halilibo.hugetwit) - * [Wake Me Up (Mumbai Railway)](https://play.google.com/store/apps/details?id=com.catacomblabs.wakemeup) - * [SelfMote - Wireless Remote app](https://play.google.com/store/apps/details?id=com.dmicse.selfmote.free) - * [Boo Music Player](https://play.google.com/store/apps/details?id=cdn.BooPlayer) - * [BeatPrompter](https://play.google.com/store/apps/details?id=com.stevenfrew.beatprompter) +# AppIntro + +[![](https://jitpack.io/v/AppIntro/AppIntro.svg)](https://jitpack.io/#AppIntro/appintro) [![Join the chat at https://kotlinlang.slack.com](https://img.shields.io/badge/slack-@kotlinlang/appintro-yellow.svg?logo=slack)](https://kotlinlang.slack.com/archives/C019SH1RMBN) [![Pre Merge Checks](https://github.com/AppIntro/AppIntro/workflows/Pre%20Merge%20Checks/badge.svg)](https://github.com/AppIntro/AppIntro/actions?query=workflow%3A%22Pre+Merge+Checks%22) [![Awesome Kotlin Badge](https://kotlin.link/awesome-kotlin.svg)](https://github.com/KotlinBy/awesome-kotlin) + +AppIntro is an Android Library that helps you build a **cool carousel intro** for your App. AppIntro has support for **requesting permissions** and helps you create a great onboarding experience in just a couple of minutes. + +

+ appintro icon appintro sample +

+ + * [Getting Started 👣](#getting-started-) + * [Adding a dependency](#adding-a-dependency) + * [Basic usage](#basic-usage) + * [Java users](#java-users) + * [Migrating 🚗](#migrating-) + * [Features 🧰](#features-) + * [Creating Slides 👩‍🎨](#creating-slides-) + * [AppIntroFragment](#appintrofragment) + * [AppIntroCustomLayoutFragment](#appintrocustomlayoutfragment) + * [Configure 🎨](#configure-) + * [Slide Transformer](#slide-transformer) + * [Custom Slide Transformer](#custom-slide-transformer) + * [Color Transition](#color-transition) + * [Multiple Windows Layout](#multiple-windows-layout) + * [Indicators](#indicators) + * [Vibration](#vibration) + * [Wizard Mode](#wizard-mode) + * [Immersive Mode](#immersive-mode) + * [System Back button](#system-back-button) + * [System UI (Status Bar and Navigation Bar)](#system-ui-status-bar-and-navigation-bar) + * [Bottom Bar Margin](#bottom-bar-margin) + * [Permission 🔒](#permission-) + * [Slide Policy](#slide-policy) + * [Example App 💡](#example-app-) + * [Translating 🌍](#translating-) + * [Snapshots 📦](#snapshots-) + * [Contributing 🤝](#contributing-) + * [Acknowledgments 🌸](#acknowledgments-) + * [Maintainers](#maintainers) + * [Libraries](#libraries) + * [License 📄](#license-) + * [Apps using AppIntro 📱](#apps-using-appintro-) + + +## Getting Started 👣 + +AppIntro is distributed through [JitPack](https://jitpack.io/#AppIntro/AppIntro). + +### Adding a dependency + +To use it you need to add the following gradle dependency to your `build.gradle` file of the module where you want to use AppIntro (NOT the root file). + +```groovy +repositories { + maven { url "https://jitpack.io" } +} +``` + +```groovy +dependencies { + // AndroidX Capable version + implementation 'com.github.AppIntro:AppIntro:6.3.1' + + // *** OR *** + + // Latest version compatible with the old Support Library + implementation 'com.github.AppIntro:AppIntro:4.2.3' +} +``` + +Please note that since AppIntro 5.x, the library supports [Android X](https://developer.android.com/jetpack/androidx/). If you haven't migrated yet, you probably want to use a previous version of the library that uses the **old Support Library** packages (or try [Jetifier Reverse mode](https://ncorti.com/blog/jetifier-reverse)). + +### Basic usage + +To use AppIntro, you simply have to create a new **Activity that extends AppIntro** like the following: + +```kotlin +class MyCustomAppIntro : AppIntro() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Make sure you don't call setContentView! + + // Call addSlide passing your Fragments. + // You can use AppIntroFragment to use a pre-built fragment + addSlide(AppIntroFragment.createInstance( + title = "Welcome...", + description = "This is the first slide of the example" + )) + addSlide(AppIntroFragment.createInstance( + title = "...Let's get started!", + description = "This is the last slide, I won't annoy you more :)" + )) + } + + override fun onSkipPressed(currentFragment: Fragment?) { + super.onSkipPressed(currentFragment) + // Decide what to do when the user clicks on "Skip" + finish() + } + + override fun onDonePressed(currentFragment: Fragment?) { + super.onDonePressed(currentFragment) + // Decide what to do when the user clicks on "Done" + finish() + } +} +``` + +Please note that you **must NOT call** setContentView. The `AppIntro` superclass is taking care of it for you. + +Also confirm that you're overriding `onCreate` with **a single parameter** (`Bundle`) and you're not using another override (like `onCreate(Bundle, PersistableBundle)`) instead. + +Finally, declare the activity in your Manifest like so: + +``` xml + +``` + +We suggest to don't declare `MyCustomAppIntro` as your first Activity unless you want the intro to launch every time your app starts. Ideally you should show the AppIntro activity only once to the user, and you should hide it once completed (you can use a flag in the `SharedPreferences`). + +### Java users + +You can find many examples in java language in the [examples directory](example/src/main/java/com/github/appintro/example/ui/java/JavaIntro.java) + +## Migrating 🚗 + +If you're migrating **from AppIntro v5.x to v6.x**, please expect multiple breaking changes. You can find documentation on how to update your code on this other [migration guide](/docs/migrating-from-5.0.md). + +## Features 🧰 + +Don't forget to check the [changelog](CHANGELOG.md) to have a look at all the changes in the latest version of AppIntro. + +* **API >= 14** compatible. +* 100% Kotlin Library. +* **AndroidX** Compatible. +* Support for **runtime permissions**. +* Dependent only on AndroidX AppCompat/Annotations, ConstraintLayout and Kotlin JDK. +* Full RTL support. + +## Creating Slides 👩‍🎨 + +The entry point to add a new slide is the `addSlide(fragment: Fragment)` function on the `AppIntro` class. +You can easily use it to add a new `Fragment` to the carousel. + +The library comes with several util classes to help you create your Slide with just a couple lines: + +### `AppIntroFragment` + +You can use the `AppIntroFragment` if you just want to customize title, description, image and colors. +That's the suggested approach if you want to create a quick intro: + +```kotlin +addSlide(AppIntroFragment.createInstance( + title = "The title of your slide", + description = "A description that will be shown on the bottom", + imageDrawable = R.drawable.the_central_icon, + backgroundDrawable = R.drawable.the_background_image, + titleColorRes = R.color.yellow, + descriptionColorRes = R.color.red, + backgroundColorRes = R.color.blue, + titleTypefaceFontRes = R.font.opensans_regular, + descriptionTypefaceFontRes = R.font.opensans_regular, +)) +``` + +All the parameters are optional, so you're free to customize your slide as you wish. + +If you need to programmatically create several slides you can also use the `SliderPage` class. +This class can be passed to `AppIntroFragment.createInstance(sliderPage: SliderPage)` that will create +a new slide starting from that instance. + +### `AppIntroCustomLayoutFragment` + +If you need further control on the customization of your slide, you can use the `AppIntroCustomLayoutFragment`. +This will allow you pass your custom Layout Resource file: + +```kotlin +AppIntroCustomLayoutFragment.newInstance(R.layout.intro_custom_layout1) +``` + +This allows you to achieve complex layout and include your custom logic in the Intro (see also [Slide Policy](#slide-policy)): + +

+ appintro custom-layout +

+ +## Configure 🎨 + +AppIntro offers several configuration option to help you customize your onboarding experience. + +### Slide Transformer + +AppIntro comes with a set of _Slide Transformer_ that you can use out of the box to animate your Slide transition. + +| Slide Transformers | Slide Transformers | +| :---: | :---: | +| Fade
fade | Zoom
zoom | +| Flow
flow | Slide Over
slideover | +| Depth
depth | Parallax
parallax | + +You can simply call `setTransformer()` and pass one of the subclass of the sealed class `AppIntroPageTransformerType`: + +```kotlin +setTransformer(AppIntroPageTransformerType.Fade) +setTransformer(AppIntroPageTransformerType.Zoom) +setTransformer(AppIntroPageTransformerType.Flow) +setTransformer(AppIntroPageTransformerType.SlideOver) +setTransformer(AppIntroPageTransformerType.Depth) + +// You can customize your parallax parameters in the constructors. +setTransformer(AppIntroPageTransformerType.Parallax( + titleParallaxFactor = 1.0, + imageParallaxFactor = -1.0, + descriptionParallaxFactor = 2.0 +)) +``` + +#### Custom Slide Transformer + +You can also provide your custom Slide Transformer (implementing the `ViewPager.PageTransformer` interface) with: + +```kotlin +setCustomTransformer(ViewPager.PageTransformer) +``` + +### Color Transition + +

+ appintro sample +

+ +AppIntro offers the possibility to animate the **color transition** between two slides background. +This feature is disabled by default, and you need to enable it on your AppIntro with: + +```kotlin +isColorTransitionsEnabled = true +``` + +Once you enable it, the color will be animated between slides with a gradient. +Make sure you provide a `backgroundColor` parameter in your slides. + +If you're providing custom Fragments, you can let them support the color transition by implementing +the `SlideBackgroundColorHolder` interface. + +### Multiple Windows Layout + +AppIntro is shipped with two top-level layouts that you can use. +The default layout (`AppIntro`) has textual buttons, while the alternative +layout has buttons with icons. + +To change the Window layout, you can simply change your superclass to `AppIntro2`. +The methods to add and customize the AppIntro are unchanged. + +```kotlin +class MyCustomAppIntro : AppIntro2() { + // Same code as displayed in the `Basic Usage` section of this README +} +``` + +| Page | `AppIntro` | `AppIntro2` | +| ---: | :--------: | :---------: | +| standard page | layout1-start | layout2-start | +| last page | layout1-end | layout2-end | + +### Indicators + +AppIntro supports two indicators out of the box to show the progress of the Intro experience to the user: + +* `DotIndicatorController` represented with a list of Dot (the default) +* `ProgressIndicatorController` represented with a progress bar. + +| `DotIndicator` | `ProgressIndicator` | +| ---------- | ----------- | +| ![dotted indicator](assets/dotted-indicator.gif) | ![progress indicator](assets/progress-indicator.gif) | + +Moreover, you can supply your own indicator by providing an implementation of the `IndicatorController` interface. + +You can customize the indicator with the following API on the `AppIntro` class: + +```kotlin +// Toggle Indicator Visibility +isIndicatorEnabled = true + +// Change Indicator Color +setIndicatorColor( + selectedIndicatorColor = getColor(R.color.red), + unselectedIndicatorColor = getColor(R.color.blue) +) + +// Switch from Dotted Indicator to Progress Indicator +setProgressIndicator() + +// Supply your custom `IndicatorController` implementation +indicatorController = MyCustomIndicator(/* initialize me */) +``` + +If you don't specify any customization, a `DotIndicatorController` will be shown. + +### Vibration + +AppIntro supports providing haptic _vibration_ feedback on button clicks. +Please note that you **need to specify the Vibration permission** in your app Manifest +(the library is not doing it). If you forget to specify the permission, the app will experience a crash. + +```xml + +``` + +You can enable and customize the vibration with: + +```kotlin +// Enable vibration and set duration in ms +isVibrate = true +vibrateDuration = 50L +``` + +### Wizard Mode + +

+ appintro wizard1 appintro wizard2 +

+ +AppIntro supports a _Wizard_ mode where the Skip button will be replaced with the back arrow. +This comes handy if you're presenting a Wizard to your users with a set of steps they need to do, +and they might frequently go back and forth. + +You can enable it with: + +```kotlin +isWizardMode = true +``` + +### Immersive Mode + +

+ appintro immersive1 appintro immersive2 +

+ +If you want to display your Intro with a fullscreen experience, you can enable the _Immersive_ mode. This will +hide both the _Status Bar_ and the _Navigation Bar_ and the user will have to scroll from the top of the screen to +show them again. + +This allows you to have more space for your Intro content and graphics. + +You can enable it with: + +```kotlin +setImmersiveMode() +``` + +### System Back button + +You can lock the System Back button if you don't want your user to go back from intro. +This could be useful if you need to request permission and the Intro experience is not optional. + +If this is the case, please set to true the following flag: + +```kotlin +isSystemBackButtonLocked = true +``` + +### System UI (Status Bar and Navigation Bar) + +

+ appintro system-ui +

+ +You can customize the _Status Bar_, and the _Navigation Bar_ visibility & color with the following methods: + +```kotlin +// Hide/Show the status Bar +showStatusBar(true) +// Control the status bar color +setStatusBarColor(Color.GREEN) +setStatusBarColorRes(R.color.green) + +// Control the navigation bar color +setNavBarColor(Color.RED) +setNavBarColorRes(R.color.red) +``` + +### Bottom Bar Margin + +By default, the slides use the whole size available in the screen, so it might happen that the +bottom bar overlaps the content of the slide if you have set the background color to a +non-transparent one. +If you want to make sure that the bar doesn't overlap the content, use the `setBarMargin` function +as follows: +```kotlin +setBarMargin(true) +``` + +## Permission 🔒 + +

+ appintro permissions +

+ +AppIntro simplifies the process of requesting **runtime permissions** to your user. +You can integrate one or more permission request inside a slide with the `askForPermissions` method inside your activity. + +Please note that: +* `slideNumber` is in a **One-based numbering** (it starts from 1) +* You can specify more than one permission if needed +* You can specify if the permission is required. If so, users can't proceed if he denies the permission. + +```kotlin +// Ask for required CAMERA permission on the second slide. +askForPermissions( + permissions = arrayOf(Manifest.permission.CAMERA), + slideNumber = 2, + required = true) + +// Ask for both optional ACCESS_FINE_LOCATION and WRITE_EXTERNAL_STORAGE +// permission on the third slide. +askForPermissions( + permissions = arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ), + slideNumber = 3, + required = false) +``` + +Should you need further control on the permission request, you can override those two methods on the `AppIntro` class: + +```kotlin +override fun onUserDeniedPermission(permissionName: String) { + // User pressed "Deny" on the permission dialog +} +override fun onUserDisabledPermission(permissionName: String) { + // User pressed "Deny" + "Don't ask again" on the permission dialog +} +``` + +### Slide Policy + +If you want to restrict navigation between your slides (i.e. the user has to toggle a checkbox in order to continue), +the `SlidePolicy` feature might help you. + +All you have to do is implement `SlidePolicy` in your slides. + +This interface contains the `isPolicyRespected` property and the `onUserIllegallyRequestedNextPage` method +that you must implement with your custom logic + +```kotlin +class MyFragment : Fragment(), SlidePolicy { + + // If user should be allowed to leave this slide + override val isPolicyRespected: Boolean + get() = false // Your custom logic here. + + override fun onUserIllegallyRequestedNextPage() { + // User illegally requested next slide. + // Show a toast or an informative message to the user. + } +} +``` + +You can find a full working example of `SlidePolicy` in the [example app - CustomSlidePolicyFragment.kt](example/src/main/java/com/github/appintro/example/ui/custom/fragments/CustomSlidePolicyFragment.kt) + +## Example App 💡 + +AppIntro comes with a **sample app** full of examples and use case that you can use as inspiration for your project. You can find it inside the [/example folder](https://github.com/AppIntro/AppIntro/tree/main/example). + +You can get a **debug APK** of the sample app from the **Pre Merge** GitHub Actions job as an [output artifact here](https://github.com/AppIntro/AppIntro/actions?query=workflow%3A%22Pre+Merge+Checks%22). + +

+ appintro sample app +

+ +## Translating 🌍 + +Do you want to help AppIntro becoming international 🌍? We are more than happy! +AppIntro currently supports [the following languages](appintro/src/main/res). + +To add a new translation just add a pull request with a new `strings.xml` file inside a `values-xx` folder (where `xx` is a [two-letter ISO 639-1 language code](https://en.wikipedia.org/wiki/ISO_639-1)). + +In order to provide the translation, your file needs to contain the following strings: + +```xml + + + [Translation for SKIP] + [Translation for NEXT] + [Translation for BACK] + [Translation for DONE] + [Translation for "graphics"] + +``` + +An updated version of the English version translation is [available here](appintro/src/main/res/values/strings.xml). + +If a translation in your language is already available, please check it and eventually fix it (all the strings should be listed, not just a subset). + +## Snapshots 📦 + +Development of AppIntro happens on the [main](https://github.com/AppIntro/AppIntro/tree/main) branch. You can get `SNAPSHOT` versions directly from JitPack if needed. + +```gradle +repositories { + maven { url "https://jitpack.io" } +} +``` + +```gradle +dependencies { + implementation "com.github.AppIntro:AppIntro:main-SNAPSHOT" +} +``` + +⚠️ Please note that the latest snapshot might be **unstable**. Use it at your own risk ⚠️ + +## Contributing 🤝 + +We're offering support for AppIntro on the [#appintro channel on KotlinLang Slack](https://kotlinlang.slack.com/archives/C019SH1RMBN). Come and join the conversation over there. If you don't have access to KotlinLang Slack, you can [request access here](https://surveys.jetbrains.com/s3/kotlin-slack-sign-up). + +**We're looking for contributors! Don't be shy.** 👍 Feel free to open issues/pull requests to help me improve this project. + +* When reporting a new Issue, make sure to attach **Screenshots**, **Videos** or **GIFs** of the problem you are reporting. +* When submitting a new PR, make sure tests are all green. Write new tests if necessary. + +## Acknowledgments 🌸 + +### Maintainers + +AppIntro is currently developed and maintained by the [AppIntro GitHub Org](https://github.com/AppIntro). When submitting a new PR, please ping one of: + +- [@paolorotolo](https://github.com/paolorotolo) +- [@cortinico](https://github.com/cortinico) + +### Libraries + +AppIntro is not relying on any third party library other than those from AndroidX: + +- `androidx.appcompat:appcompat` +- `androidx.annotation:annotation` +- `androidx.constraintlayout:constraintlayout` + +## License 📄 + +``` + Copyright (C) 2015-2020 AppIntro Developers + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +``` + +## Apps using AppIntro 📱 + +If you are using AppIntro in your app and would like to be listed here, please open a pull request and we will be more than happy to include you: + +
+ List of Apps using AppIntro + +* [Smoothie Recipes](https://play.google.com/store/apps/details?id=com.skykonig.smoothierecipes) +* [neutriNote](https://play.google.com/store/apps/details?id=com.appmindlab.nano) +* [Handwriting Note](https://play.google.com/store/apps/details?id=com.lyk.immersivenote) +* [ChineseDictionary (粵韻漢典離線粵語普通話發聲中文字典)](https://play.google.com/store/apps/details?id=com.jonasng.chinesedictionary) +* [Salary Barometer](https://play.google.com/store/apps/details?id=anaware.salarybarometer) +* [Best Business Idea!](https://play.google.com/store/apps/details?id=anaware.bestidea) +* [Wi-Fi password reminder](https://play.google.com/store/apps/details?id=com.rusdelphi.wifipassword) +* [Find My Parked Car](https://play.google.com/store/apps/details?id=com.ofirmiron.findmycarandroidwear) +* [Vape Tool Pro](https://play.google.com/store/apps/details?id=com.stasbar.vapetoolpro) +* [Third Eye](https://play.google.com/store/apps/details?id=com.miragestacks.thirdeye) +* [Web Video Cast](https://play.google.com/store/apps/details?id=com.instantbits.cast.webvideo) +* [Fitness Challenge](https://play.google.com/store/apps/details?id=com.isidroid.fitchallenge) +* [Filmy - Your Movie Guide](https://play.google.com/store/apps/details?id=tech.salroid.filmy) +* [HEBF Optimizer ▪ Root](https://play.google.com/store/apps/details?id=com.androidvip.hebf) +* [IIFYM](https://play.google.com/store/apps/details?id=com.javierd.iifym) +* [Ampwifi Winamp Remote](https://play.google.com/store/apps/details?id=com.blitterhead.ampwifi) +* [Hugetwit](https://play.google.com/store/apps/details?id=com.halilibo.hugetwit) +* [Boo Music Player](https://play.google.com/store/apps/details?id=cdn.BooPlayer) +* [BeatPrompter](https://play.google.com/store/apps/details?id=com.stevenfrew.beatprompter) +* [BlueWords](https://play.google.com/store/apps/details?id=com.thesrb.bluewords&referrer=utm_source%3Dappintro%26utm_medium%3Dgithub%26utm_campaign%3Dreadme) +* [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android) +* [PhotoGuard Photo Vault](https://play.google.com/store/apps/details?id=com.photovault.photoguard) +* [Ride My Park - Best Spots, Skateparks Map](https://play.google.com/store/apps/details?id=com.andrieutom.rmp) +* [Shopping list](https://play.google.com/store/apps/details?id=de.vanse.shoppinglist) and [Shopping list pro](https://play.google.com/store/apps/details?id=de.vanse.shoppinglist.pro) +* [PZPIC - Pan & Zoom Effect Video from Still Picture](https://play.google.com/store/apps/details?id=com.photo3dlab.pzpic) +* [PrezziBenzina](https://play.google.com/store/apps/details?id=org.vernazza.androidfuel) +* [LogViewer for openHAB](https://github.com/cyb3rko/logviewer-for-openhab-app) +* [Firmo con CIE](https://play.google.com/store/apps/details?id=com.cyberneid.disigoncie) +* [iC-YOURLS](https://play.google.com/store/apps/details?id=net.inscomers.yourls) +* [Noon Happen](https://play.google.com/store/apps/details?id=com.noonhappen.noonhappen) +* [Weather Forecast](https://play.google.com/store/apps/details?id=com.ehlb.weatherapp) +* [Zoned Pomodoro Timer](https://play.google.com/store/apps/details?id=com.just.five) +* [PCAPdroid Network Monitor](https://play.google.com/store/apps/details?id=com.emanuelef.remote_capture) +* [EQtive](https://play.google.com/store/apps/details?id=com.vekanto.eqtive) +* [PocketMark - MarkDown Editor](https://play.google.com/store/apps/details?id=com.ZetaDev.PocketMark) +* [AIRSOFT SPOTTER](https://play.google.com/store/apps/details?id=io.github.precisionmarks.spotter) + +
diff --git a/appintro/build.gradle.kts b/appintro/build.gradle.kts new file mode 100644 index 000000000..6910f8cf3 --- /dev/null +++ b/appintro/build.gradle.kts @@ -0,0 +1,116 @@ +plugins { + alias(libs.plugins.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.detekt) + alias(libs.plugins.ktlint) + id("maven-publish") +} + +group = "com.github.AppIntro" +version = "7.0.0-beta02" + +android { + namespace = "com.github.appintro" + compileSdk = libs.versions.compile.sdk.version.get().toInt() + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toInt() + + consumerProguardFiles("consumer-proguard-rules.pro") + vectorDrawables.useSupportLibrary = true + } + publishing { + singleVariant("release") { + withSourcesJar() + withJavadocJar() + } + } + lint { + warningsAsErrors = true + abortOnError = true + lint { + disable.addAll( + listOf( + "MissingTranslation", + "OldTargetApi", + "GradleDependency", + ), + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } +} + +dependencies { + implementation(libs.androidx.appcompat) + implementation(libs.androidx.annotation) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.viewpager2) + implementation(libs.androidx.fragment) + + testImplementation(libs.junit) + testImplementation(libs.mockito.core) +} + +ktlint { + debug.set(false) + verbose.set(true) + android.set(false) + outputToConsole.set(true) + ignoreFailures.set(false) +} + +detekt { + config.setFrom(files("./detekt-config.yml")) +} + +val pomName: String by project +val pomDescription: String by project +val pomLicenseName: String by project +val pomLicenseUrl: String by project +val pomScmConnection: String by project +val pomUrl: String by project + +publishing { + publications { + register("release") { + afterEvaluate { + from(components["release"]) + } + pom { + name.set(pomName) + description.set(pomDescription) + url.set(pomUrl) + licenses { + license { + name.set(pomLicenseName) + url.set(pomLicenseUrl) + } + } + scm { + connection.set(pomScmConnection) + developerConnection.set(pomScmConnection) + url.set(pomUrl) + } + developers { + developer { + id.set("paolorotolo") + name.set("Paolo Rotolo") + email.set("paolo@rotolo.dev") + } + developer { + id.set("cortinico") + name.set("Nicola Corti") + email.set("corti.nico@gmail.com") + } + } + } + } + } +} diff --git a/appintro/consumer-proguard-rules.pro b/appintro/consumer-proguard-rules.pro new file mode 100644 index 000000000..f81b822b3 --- /dev/null +++ b/appintro/consumer-proguard-rules.pro @@ -0,0 +1 @@ +-keep class com.github.appintro.** {*;} diff --git a/appintro/detekt-config.yml b/appintro/detekt-config.yml new file mode 100644 index 000000000..cae1c2b9e --- /dev/null +++ b/appintro/detekt-config.yml @@ -0,0 +1,786 @@ +build: + maxIssues: 0 + excludeCorrectable: false + weights: + # complexity: 2 + # LongParameterList: 1 + # style: 1 + # comments: 1 + +config: + validation: true + warningsAsErrors: false + checkExhaustiveness: false + # when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]' + excludes: '' + +processors: + active: true + exclude: + - 'DetektProgressListener' + # - 'KtFileCountProcessor' + # - 'PackageCountProcessor' + # - 'ClassCountProcessor' + # - 'FunctionCountProcessor' + # - 'PropertyCountProcessor' + # - 'ProjectComplexityProcessor' + # - 'ProjectCognitiveComplexityProcessor' + # - 'ProjectLLOCProcessor' + # - 'ProjectCLOCProcessor' + # - 'ProjectLOCProcessor' + # - 'ProjectSLOCProcessor' + # - 'LicenseHeaderLoaderExtension' + +console-reports: + active: true + exclude: + - 'ProjectStatisticsReport' + - 'ComplexityReport' + - 'NotificationReport' + - 'FindingsReport' + - 'FileBasedFindingsReport' + # - 'LiteFindingsReport' + +output-reports: + active: true + exclude: + # - 'TxtOutputReport' + # - 'XmlOutputReport' + # - 'HtmlOutputReport' + # - 'MdOutputReport' + # - 'SarifOutputReport' + +comments: + active: true + AbsentOrWrongFileLicense: + active: false + licenseTemplateFile: 'license.template' + licenseTemplateIsRegex: false + CommentOverPrivateFunction: + active: false + CommentOverPrivateProperty: + active: false + DeprecatedBlockTag: + active: false + EndOfSentenceFormat: + active: false + endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' + KDocReferencesNonPublicProperty: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + OutdatedDocumentation: + active: false + matchTypeParameters: true + matchDeclarationsOrder: true + allowParamOnConstructorProperties: false + UndocumentedPublicClass: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + searchInNestedClass: true + searchInInnerClass: true + searchInInnerObject: true + searchInInnerInterface: true + searchInProtectedClass: false + UndocumentedPublicFunction: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + searchProtectedFunction: false + UndocumentedPublicProperty: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + searchProtectedProperty: false + +complexity: + active: true + CognitiveComplexMethod: + active: false + threshold: 15 + ComplexCondition: + active: true + threshold: 4 + ComplexInterface: + active: false + threshold: 10 + includeStaticDeclarations: false + includePrivateDeclarations: false + ignoreOverloaded: false + CyclomaticComplexMethod: + active: true + threshold: 15 + ignoreSingleWhenExpression: false + ignoreSimpleWhenEntries: false + ignoreNestingFunctions: false + nestingFunctions: + - 'also' + - 'apply' + - 'forEach' + - 'isNotNull' + - 'ifNull' + - 'let' + - 'run' + - 'use' + - 'with' + LabeledExpression: + active: false + ignoredLabels: [] + LargeClass: + active: true + threshold: 600 + LongMethod: + active: true + threshold: 60 + LongParameterList: + active: true + functionThreshold: 6 + constructorThreshold: 7 + ignoreDefaultParameters: false + ignoreDataClasses: true + ignoreAnnotatedParameter: [] + MethodOverloading: + active: false + threshold: 6 + NamedArguments: + active: false + threshold: 3 + ignoreArgumentsMatchingNames: false + NestedBlockDepth: + active: true + threshold: 4 + NestedScopeFunctions: + active: false + threshold: 1 + functions: + - 'kotlin.apply' + - 'kotlin.run' + - 'kotlin.with' + - 'kotlin.let' + - 'kotlin.also' + ReplaceSafeCallChainWithRun: + active: false + StringLiteralDuplication: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + threshold: 3 + ignoreAnnotation: true + excludeStringsWithLessThan5Characters: true + ignoreStringsRegex: '$^' + TooManyFunctions: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + thresholdInFiles: 21 + thresholdInClasses: 21 + thresholdInInterfaces: 21 + thresholdInObjects: 21 + thresholdInEnums: 21 + ignoreDeprecated: false + ignorePrivate: false + ignoreOverridden: false + +coroutines: + active: true + GlobalCoroutineUsage: + active: false + InjectDispatcher: + active: true + dispatcherNames: + - 'IO' + - 'Default' + - 'Unconfined' + RedundantSuspendModifier: + active: true + SleepInsteadOfDelay: + active: true + SuspendFunSwallowedCancellation: + active: false + SuspendFunWithCoroutineScopeReceiver: + active: false + SuspendFunWithFlowReturnType: + active: true + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: '_|(ignore|expected).*' + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: true + ignoreOverridden: false + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKtFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyTryBlock: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: true + methodNames: + - 'equals' + - 'finalize' + - 'hashCode' + - 'toString' + InstanceOfCheckForException: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + NotImplementedDeclaration: + active: false + ObjectExtendsThrowable: + active: false + PrintStackTrace: + active: true + RethrowCaughtException: + active: true + ReturnFromFinally: + active: true + ignoreLabeled: false + SwallowedException: + active: true + ignoredExceptionTypes: + - 'InterruptedException' + - 'MalformedURLException' + - 'NumberFormatException' + - 'ParseException' + allowedExceptionNameRegex: '_|(ignore|expected).*' + ThrowingExceptionFromFinally: + active: true + ThrowingExceptionInMain: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + exceptions: + - 'ArrayIndexOutOfBoundsException' + - 'Exception' + - 'IllegalArgumentException' + - 'IllegalMonitorStateException' + - 'IllegalStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + ThrowingNewInstanceOfSameException: + active: true + TooGenericExceptionCaught: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + exceptionNames: + - 'ArrayIndexOutOfBoundsException' + - 'Error' + - 'Exception' + - 'IllegalMonitorStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + allowedExceptionNameRegex: '_|(ignore|expected).*' + TooGenericExceptionThrown: + active: true + exceptionNames: + - 'Error' + - 'Exception' + - 'RuntimeException' + - 'Throwable' + +naming: + active: true + BooleanPropertyNaming: + active: false + allowedPattern: '^(is|has|are)' + ClassNaming: + active: true + classPattern: '[A-Z][a-zA-Z0-9]*' + ConstructorParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + privateParameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + EnumNaming: + active: true + enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' + ForbiddenClassName: + active: false + forbiddenName: [] + FunctionMaxLength: + active: false + maximumFunctionNameLength: 30 + FunctionMinLength: + active: false + minimumFunctionNameLength: 3 + FunctionNaming: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + functionPattern: '[a-z][a-zA-Z0-9]*' + excludeClassPattern: '$^' + FunctionParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + InvalidPackageDeclaration: + active: true + rootPackage: '' + requireRootInDeclaration: false + LambdaParameterNaming: + active: false + parameterPattern: '[a-z][A-Za-z0-9]*|_' + MatchingDeclarationName: + active: true + mustBeFirst: true + MemberNameEqualsClassName: + active: true + ignoreOverridden: true + NoNameShadowing: + active: true + NonBooleanPropertyPrefixedWithIs: + active: false + ObjectPropertyNaming: + active: true + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: true + packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' + TopLevelPropertyNaming: + active: true + constantPattern: '[A-Z][_A-Z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' + VariableMaxLength: + active: false + maximumVariableNameLength: 64 + VariableMinLength: + active: false + minimumVariableNameLength: 1 + VariableNaming: + active: true + variablePattern: '[a-z][A-Za-z0-9]*' + privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + +performance: + active: true + ArrayPrimitive: + active: true + CouldBeSequence: + active: false + threshold: 3 + ForEachOnRange: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + SpreadOperator: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + UnnecessaryPartOfBinaryExpression: + active: false + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + AvoidReferentialEquality: + active: true + forbiddenTypePatterns: + - 'kotlin.String' + CastNullableToNonNullableType: + active: false + CastToNullableType: + active: false + Deprecation: + active: false + DontDowncastCollectionTypes: + active: false + DoubleMutabilityForCollection: + active: true + mutableTypes: + - 'kotlin.collections.MutableList' + - 'kotlin.collections.MutableMap' + - 'kotlin.collections.MutableSet' + - 'java.util.ArrayList' + - 'java.util.LinkedHashSet' + - 'java.util.HashSet' + - 'java.util.LinkedHashMap' + - 'java.util.HashMap' + ElseCaseInsteadOfExhaustiveWhen: + active: false + ignoredSubjectTypes: [] + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExitOutsideMain: + active: false + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: true + IgnoredReturnValue: + active: true + restrictToConfig: true + returnValueAnnotations: + - 'CheckResult' + - '*.CheckResult' + - 'CheckReturnValue' + - '*.CheckReturnValue' + ignoreReturnValueAnnotations: + - 'CanIgnoreReturnValue' + - '*.CanIgnoreReturnValue' + returnValueTypes: + - 'kotlin.sequences.Sequence' + - 'kotlinx.coroutines.flow.*Flow' + - 'java.util.stream.*Stream' + ignoreFunctionCall: [] + ImplicitDefaultLocale: + active: true + ImplicitUnitReturnType: + active: false + allowExplicitReturnType: true + InvalidRange: + active: true + IteratorHasNextCallsNextMethod: + active: true + IteratorNotThrowingNoSuchElementException: + active: true + LateinitUsage: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + ignoreOnClassesPattern: '' + MapGetWithNotNullAssertionOperator: + active: true + MissingPackageDeclaration: + active: false + excludes: ['**/*.kts'] + NullCheckOnMutableProperty: + active: false + NullableToStringCall: + active: false + PropertyUsedBeforeDeclaration: + active: false + UnconditionalJumpStatementInLoop: + active: false + UnnecessaryNotNullCheck: + active: false + UnnecessaryNotNullOperator: + active: true + UnnecessarySafeCall: + active: true + UnreachableCatchBlock: + active: true + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + UnsafeCast: + active: true + UnusedUnaryOperator: + active: true + UselessPostfixExpression: + active: true + WrongEqualsTypeParameter: + active: true + +style: + active: true + AlsoCouldBeApply: + active: false + BracesOnIfStatements: + active: false + singleLine: 'never' + multiLine: 'always' + BracesOnWhenStatements: + active: false + singleLine: 'necessary' + multiLine: 'consistent' + CanBeNonNullable: + active: false + CascadingCallWrapping: + active: false + includeElvis: true + ClassOrdering: + active: false + CollapsibleIfStatements: + active: false + DataClassContainsFunctions: + active: false + conversionFunctionPrefix: + - 'to' + allowOperators: false + DataClassShouldBeImmutable: + active: false + DestructuringDeclarationWithTooManyEntries: + active: true + maxDestructuringEntries: 3 + DoubleNegativeLambda: + active: false + negativeFunctions: + - reason: 'Use `takeIf` instead.' + value: 'takeUnless' + - reason: 'Use `all` instead.' + value: 'none' + negativeFunctionNameParts: + - 'not' + - 'non' + EqualsNullCall: + active: true + EqualsOnSignatureLine: + active: false + ExplicitCollectionElementAccessMethod: + active: false + ExplicitItLambdaParameter: + active: true + ExpressionBodySyntax: + active: false + includeLineWrapping: false + ForbiddenAnnotation: + active: false + annotations: + - reason: 'it is a java annotation. Use `Suppress` instead.' + value: 'java.lang.SuppressWarnings' + - reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.' + value: 'java.lang.Deprecated' + - reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.' + value: 'java.lang.annotation.Documented' + - reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.' + value: 'java.lang.annotation.Target' + - reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.' + value: 'java.lang.annotation.Retention' + - reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.' + value: 'java.lang.annotation.Repeatable' + - reason: 'Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265' + value: 'java.lang.annotation.Inherited' + ForbiddenComment: + active: true + comments: + - reason: 'Forbidden FIXME todo marker in comment, please fix the problem.' + value: 'FIXME:' + - reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.' + value: 'STOPSHIP:' + - reason: 'Forbidden TODO todo marker in comment, please do the changes.' + value: 'TODO:' + allowedPatterns: '' + ForbiddenImport: + active: false + imports: [] + forbiddenPatterns: '' + ForbiddenMethodCall: + active: false + methods: + - reason: 'print does not allow you to configure the output stream. Use a logger instead.' + value: 'kotlin.io.print' + - reason: 'println does not allow you to configure the output stream. Use a logger instead.' + value: 'kotlin.io.println' + ForbiddenSuppress: + active: false + rules: [] + ForbiddenVoid: + active: true + ignoreOverridden: false + ignoreUsageInGenerics: false + FunctionOnlyReturningConstant: + active: true + ignoreOverridableFunction: true + ignoreActualFunction: true + excludedFunctions: [] + LoopWithTooManyJumpStatements: + active: true + maxJumpCount: 1 + MagicNumber: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts'] + ignoreNumbers: + - '-1' + - '0' + - '1' + - '2' + ignoreHashCodeFunction: true + ignorePropertyDeclaration: false + ignoreLocalVariableDeclaration: false + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: false + ignoreNamedArgument: true + ignoreEnums: false + ignoreRanges: false + ignoreExtensionFunctions: true + MandatoryBracesLoops: + active: false + MaxChainedCallsOnSameLine: + active: false + maxChainedCalls: 5 + MaxLineLength: + active: true + maxLineLength: 120 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: false + excludeRawStrings: true + MayBeConst: + active: true + ModifierOrder: + active: true + MultilineLambdaItParameter: + active: false + MultilineRawStringIndentation: + active: false + indentSize: 4 + trimmingMethods: + - 'trimIndent' + - 'trimMargin' + NestedClassesVisibility: + active: true + NewLineAtEndOfFile: + active: true + NoTabs: + active: false + NullableBooleanCheck: + active: false + ObjectLiteralToLambda: + active: true + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false + OptionalWhenBraces: + active: false + PreferToOverPairSyntax: + active: false + ProtectedMemberInFinalClass: + active: true + RedundantExplicitType: + active: false + RedundantHigherOrderMapUsage: + active: true + RedundantVisibilityModifierRule: + active: false + ReturnCount: + active: true + max: 5 + excludedFunctions: + - 'equals' + excludeLabeled: false + excludeReturnFromLambda: true + excludeGuardClauses: false + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: true + SpacingBetweenPackageAndImports: + active: false + StringShouldBeRawString: + active: false + maxEscapedCharacterCount: 2 + ignoredCharacters: [] + ThrowsCount: + active: true + max: 2 + excludeGuardClauses: false + TrailingWhitespace: + active: false + TrimMultilineRawString: + active: false + trimmingMethods: + - 'trimIndent' + - 'trimMargin' + UnderscoresInNumericLiterals: + active: false + acceptableLength: 4 + allowNonStandardGrouping: false + UnnecessaryAbstractClass: + active: true + UnnecessaryAnnotationUseSiteTarget: + active: false + UnnecessaryApply: + active: true + UnnecessaryBackticks: + active: false + UnnecessaryBracesAroundTrailingLambda: + active: false + UnnecessaryFilter: + active: true + UnnecessaryInheritance: + active: true + UnnecessaryInnerClass: + active: false + UnnecessaryLet: + active: false + UnnecessaryParentheses: + active: false + allowForUnclearPrecedence: false + UntilInsteadOfRangeTo: + active: false + UnusedImports: + active: false + UnusedParameter: + active: true + allowedNames: 'ignored|expected' + UnusedPrivateClass: + active: true + UnusedPrivateMember: + active: true + allowedNames: '' + UnusedPrivateProperty: + active: true + allowedNames: '_|ignored|expected|serialVersionUID' + UseAnyOrNoneInsteadOfFind: + active: true + UseArrayLiteralsInAnnotations: + active: true + UseCheckNotNull: + active: true + UseCheckOrError: + active: true + UseDataClass: + active: false + allowVars: false + UseEmptyCounterpart: + active: false + UseIfEmptyOrIfBlank: + active: false + UseIfInsteadOfWhen: + active: false + ignoreWhenContainingVariableDeclaration: false + UseIsNullOrEmpty: + active: true + UseLet: + active: false + UseOrEmpty: + active: true + UseRequire: + active: true + UseRequireNotNull: + active: true + UseSumOfInsteadOfFlatMapSize: + active: false + UselessCallOnNotNull: + active: true + UtilityClassWithPublicConstructor: + active: true + VarCouldBeVal: + active: true + ignoreLateinitVar: false + WildcardImport: + active: true + excludeImports: + - 'java.util.*' diff --git a/appintro/gradle.properties b/appintro/gradle.properties new file mode 100644 index 000000000..3944b82e4 --- /dev/null +++ b/appintro/gradle.properties @@ -0,0 +1,9 @@ +pomArtifactId=appintro +pomName=AppIntro +pomDescription=Make a cool intro for your Android app. +pomUrl=git://github.com/AppIntro/AppIntro.git +pomScmUrl=https://github.com/AppIntro/AppIntro +pomScmConnection=scm:git://github.com/AppIntro/AppIntro.git +pomScmDevConnection=scm:git://github.com/AppIntro/AppIntro.git +pomLicenseName=The Apache Software License, Version 2.0 +pomLicenseUrl=http://www.apache.org/licenses/LICENSE-2.0.txt diff --git a/appintro/src/main/java/com/github/appintro/AppIntro.kt b/appintro/src/main/java/com/github/appintro/AppIntro.kt new file mode 100644 index 000000000..9b116ede5 --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/AppIntro.kt @@ -0,0 +1,247 @@ +package com.github.appintro + +import android.graphics.drawable.Drawable +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.annotation.FontRes +import androidx.annotation.StringRes +import androidx.annotation.StyleRes +import androidx.core.widget.TextViewCompat +import com.github.appintro.internal.TypefaceContainer + +abstract class AppIntro : AppIntroBase() { + override val layoutId = R.layout.appintro_intro_layout + + /** + * Override viewpager bar color + * @param color your color resource + */ + fun setBarColor( + @ColorInt color: Int, + ) { + val bottomBar = findViewById(R.id.bottom) + bottomBar.setBackgroundColor(color) + } + + /** + * Override next button arrow color + * + * @param color your color + */ + fun setNextArrowColor( + @ColorInt color: Int, + ) { + val nextButton = findViewById(R.id.next) + nextButton.setColorFilter(color) + } + + /** + * Override back button arrow color + * + * @param color your color + */ + fun setBackArrowColor( + @ColorInt color: Int, + ) { + val backButton = findViewById(R.id.back) + backButton.setColorFilter(color) + } + + /** + * Override separator color + * + * @param color your color resource + */ + fun setSeparatorColor( + @ColorInt color: Int, + ) { + val separator = findViewById(R.id.bottom_separator) + separator.setBackgroundColor(color) + } + + /** + * Override skip text + * + * @param text your text + */ + fun setSkipText(text: CharSequence?) { + val skipText = findViewById(R.id.skip) + skipText.text = text + } + + /** + * Override skip text + * + * @param skipResId your text resource Id + */ + fun setSkipText( + @StringRes skipResId: Int, + ) { + val skipText = findViewById(R.id.skip) + skipText.setText(skipResId) + } + + /** + * Override skip text typeface + * + * @param typeface the typeface to apply to Skip button + */ + fun setSkipTextTypeface( + @FontRes typeface: Int, + ) { + val view = findViewById(R.id.skip) + TypefaceContainer(null, typeface).applyTo(view) + } + + /** + * Override skip text typeface + * + * @param typeURL URL of font file located in Assets folder + */ + fun setSkipTextTypeface(typeURL: String?) { + val view = findViewById(R.id.skip) + TypefaceContainer(typeURL, 0).applyTo(view) + } + + /** + * Override done text + * + * @param text your text + */ + fun setDoneText(text: CharSequence?) { + val doneText = findViewById(R.id.done) + doneText.text = text + } + + /** + * Override done text + * + * @param doneResId your text resource Id + */ + fun setDoneText( + @StringRes doneResId: Int, + ) { + val doneText = findViewById(R.id.done) + doneText.setText(doneResId) + } + + /** + * Override done text typeface + * + * @param typeURL URL of font file located in Assets folder + */ + fun setDoneTextTypeface(typeURL: String?) { + val view = findViewById(R.id.done) + TypefaceContainer(typeURL, 0).applyTo(view) + } + + /** + * Override done text typeface + * + * @param typeface the typeface to apply to Done button + */ + fun setDoneTextTypeface( + @FontRes typeface: Int, + ) { + val view = findViewById(R.id.done) + TypefaceContainer(null, typeface).applyTo(view) + } + + /** + * Override done button text color + * + * @param colorDoneText your color resource + */ + fun setColorDoneText( + @ColorInt colorDoneText: Int, + ) { + val doneText = findViewById(R.id.done) + doneText.setTextColor(colorDoneText) + } + + /** + * Override done button text overall style + * + * @param textAppearance your text style from resource + */ + fun setDoneTextAppearance( + @StyleRes textAppearance: Int, + ) { + val doneText = findViewById(R.id.done) + TextViewCompat.setTextAppearance(doneText, textAppearance) + } + + /** + * Override skip button color + * + * @param colorSkipButton your color resource + */ + fun setColorSkipButton( + @ColorInt colorSkipButton: Int, + ) { + val skip = findViewById(R.id.skip) + skip.setTextColor(colorSkipButton) + } + + /** + * Override skip button text overall style + * + * @param textAppearance your text style from resource + */ + fun setSkipTextAppearance( + @StyleRes textAppearance: Int, + ) { + val skip = findViewById(R.id.skip) + TextViewCompat.setTextAppearance(skip, textAppearance) + } + + /** + * Override Next button + * + * @param imageNextButton your drawable resource + */ + fun setImageNextButton(imageNextButton: Drawable) { + val nextButton = findViewById(R.id.next) + nextButton.setImageDrawable(imageNextButton) + } + + /** + * Show or hide the Separator line. + * This is a static setting and Separator state is maintained across slides + * until explicitly changed. + * + * @param showSeparator Set : true to display. false to hide. + */ + fun showSeparator(showSeparator: Boolean) { + val bottomSeparator = findViewById(R.id.bottom_separator) + if (showSeparator) { + bottomSeparator.visibility = View.VISIBLE + } else { + bottomSeparator.visibility = View.INVISIBLE + } + } + + /** + * Adds a margin to the bottom of the content of the slide equal to the bottom bar. + * For situations where the bar is not transparent, and you want the content of the slide to + * be intractable. + * + * @param setBarMargin Set : true for margin. false for no margin. + */ + fun setBarMargin(setBarMargin: Boolean) { + val bottomBar = findViewById(R.id.pager_gesture_overlay) + val margin = + if (setBarMargin) { + resources.getDimension(R.dimen.appintro_bottombar_height).toInt() + } else { + 0 + } + (bottomBar.layoutParams as? ViewGroup.MarginLayoutParams) + ?.setMargins(0, 0, 0, margin) + ?.also { bottomBar.requestLayout() } + } +} diff --git a/appintro/src/main/java/com/github/appintro/AppIntro2.kt b/appintro/src/main/java/com/github/appintro/AppIntro2.kt new file mode 100644 index 000000000..93474404f --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/AppIntro2.kt @@ -0,0 +1,96 @@ +package com.github.appintro + +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.View +import android.widget.ImageButton +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.constraintlayout.widget.ConstraintLayout + +abstract class AppIntro2 : AppIntroBase() { + override val layoutId = R.layout.appintro_intro_layout2 + + @DrawableRes + var backgroundResource: Int? = null + set(value) { + field = value + if (field != null) { + field?.let { backgroundFrame.setBackgroundResource(it) } + } + } + + var backgroundDrawable: Drawable? = null + set(value) { + field = value + if (field != null) { + backgroundFrame.background = field + } + } + + private lateinit var backgroundFrame: ConstraintLayout + private lateinit var bottomBar: View + private lateinit var skipImageButton: ImageButton + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + backgroundFrame = findViewById(R.id.background) + bottomBar = findViewById(R.id.bottom) + skipImageButton = findViewById(R.id.skip) + if (isRtl) { + skipImageButton.scaleX = -1F + } + } + + /** + * Override viewpager bar color + * @param color your color resource + */ + fun setBarColor( + @ColorInt color: Int, + ) { + bottomBar.setBackgroundColor(color) + } + + /** + * Override Skip button drawable + * @param imageSkipButton your drawable resource + */ + fun setImageSkipButton(imageSkipButton: Drawable) { + skipImageButton.setImageDrawable(imageSkipButton) + } + + /** + * Override next button arrow color + * + * @param color your color + */ + fun setNextArrowColor( + @ColorInt color: Int, + ) { + val nextButton = findViewById(R.id.next) + nextButton.setColorFilter(color) + } + + /** + * Override skip button color + * + * @param colorSkipButton your color resource + */ + fun setSkipArrowColor( + @ColorInt colorSkipButton: Int, + ) { + val skip = findViewById(R.id.skip) + skip.setColorFilter(colorSkipButton) + } + + /** + * Override done button drawable resource + * + * @param imageDoneButton your drawable resource + */ + fun setImageDoneButton(imageDoneButton: Drawable) { + val done = findViewById(R.id.done) + done.setImageDrawable(imageDoneButton) + } +} diff --git a/appintro/src/main/java/com/github/appintro/AppIntroBase.kt b/appintro/src/main/java/com/github/appintro/AppIntroBase.kt new file mode 100644 index 000000000..f26134bd5 --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/AppIntroBase.kt @@ -0,0 +1,870 @@ +@file:Suppress("TooManyFunctions") + +package com.github.appintro + +import android.animation.ArgbEvaluator +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.view.KeyEvent +import android.view.View +import android.view.ViewGroup +import android.view.Window +import android.widget.ImageButton +import androidx.activity.OnBackPressedCallback +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.annotation.LayoutRes +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.appcompat.widget.TooltipCompat.setTooltipText +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat.Type.systemBars +import androidx.core.view.WindowInsetsControllerCompat +import androidx.fragment.app.Fragment +import androidx.viewpager2.widget.ViewPager2 +import com.github.appintro.indicator.DotIndicatorController +import com.github.appintro.indicator.IndicatorController +import com.github.appintro.indicator.ProgressIndicatorController +import com.github.appintro.internal.AppIntroViewPagerController +import com.github.appintro.internal.LayoutUtil +import com.github.appintro.internal.LogHelper +import com.github.appintro.internal.PermissionWrapper +import com.github.appintro.internal.VibrationHelper +import com.github.appintro.internal.viewpager.PagerAdapter +import com.github.appintro.internal.viewpager.ViewPagerTransformer + +/** + * The AppIntro Base Class. This class is the Activity that is responsible of handling + * the lifecycle and all the event callbacks for AppIntro. + */ +abstract class AppIntroBase : AppCompatActivity(), AppIntroViewPagerListener { + /** The layout ID that will be used during inflation. */ + @get:LayoutRes + protected abstract val layoutId: Int + + /** + * A subclass of [IndicatorController] to show the progress. + * If not provided will be initialized with a [DotIndicatorController] */ + protected var indicatorController: IndicatorController? = null + + /** Toggles all the buttons visibility (between visible and invisible). */ + protected var isButtonsEnabled: Boolean = true + set(value) { + field = value + updateButtonsVisibility() + } + + /** Toggles only the SKIP button visibility (allows the user to skip the Intro). */ + protected var isSkipButtonEnabled = true + set(value) { + field = value + updateButtonsVisibility() + } + + /** Toggles the Wizard mode (back button instead of skip). */ + protected var isWizardMode: Boolean = false + set(value) { + field = value + this.isSkipButtonEnabled = !value + updateButtonsVisibility() + } + + /** Toggles the [IndicatorController] visibility. */ + protected var isIndicatorEnabled = true + set(value) { + field = value + indicatorContainer.isVisible = value + } + + /** + * Toggles leaving the device's software/hardware back button. + * If set to true, the device's back button will be ignored. + * Note, that does does NOT lock swiping back through the slides. + */ + protected var isSystemBackButtonLocked = false + + /** Toggles color cross-fading between slides. + * Please note that slides should implement [SlideBackgroundColorHolder] */ + protected var isColorTransitionsEnabled = false + + /** Vibration duration in milliseconds */ + protected var vibrateDuration = DEFAULT_VIBRATE_DURATION + + /** + * Toggles vibration on button clicks If you enable it, don't forget to grant + * vibration permissions on your AndroidManifest.xml file + * */ + protected var isVibrate = false + + /** + * Read-only property with the total number of slides for this AppIntro. + */ + protected val totalSlidesNumber: Int get() = slidesNumber + + // Private Fields + + private lateinit var pagerAdapter: PagerAdapter + private lateinit var pagerController: AppIntroViewPagerController + private var slidesNumber: Int = 0 + private var savedCurrentItem: Int = 0 + private var currentlySelectedItem = -1 + private val fragments: MutableList = mutableListOf() + + private lateinit var nextButton: View + private lateinit var doneButton: View + private lateinit var skipButton: View + private lateinit var backButton: View + private lateinit var indicatorContainer: ViewGroup + + // Asks the ViewPager for the current slide number. Useful to query the [permissionsMap] + private val currentSlideNumber: Int + get() = pagerController.getCurrentSlideNumber(fragments.size) + + /** HashMap that contains the [PermissionWrapper] objects */ + private var permissionsMap = HashMap() + + private var retainIsButtonsEnabled = true + + private val argbEvaluator = ArgbEvaluator() + + internal val isRtl: Boolean + get() = LayoutUtil.isRtl(this) + + /* + PUBLIC API + =================================== */ + + /** + * Adds a new slide at the end of the Intro + * @param fragment Instance of Fragment which should be added as slide. + */ + protected fun addSlide(fragment: Fragment) { + if (isRtl) { + fragments.add(0, fragment) + pagerAdapter.notifyItemInserted(0) + } else { + fragments.add(fragment) + pagerAdapter.notifyItemInserted(fragments.size) + } + if (isWizardMode) { + pagerController.setOffscreenPageLimit(fragments.size) + } + } + + /** + * Called by the user to associate permissions with slides. + * + * @param permissions - Array of permissions + * @param slideNumber - The slide at which permission is to be asked. + * @param required - Whether the user can change this slide without granting the permissions. + */ + @JvmOverloads + protected fun askForPermissions( + permissions: Array, + slideNumber: Int, + required: Boolean = true, + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (slideNumber <= 0) { + error("Invalid Slide Number: $slideNumber") + } else { + permissionsMap[slideNumber] = PermissionWrapper(permissions, slideNumber, required) + } + } + } + + /** Moves the AppIntro to the previous slide */ + protected fun goToPreviousSlide() { + pagerController.goToPreviousSlide() + } + + /** Moves the AppIntro to the next slide */ + @JvmOverloads + protected fun goToNextSlide(isLastSlide: Boolean = pagerController.isLastSlide(fragments.size)) { + if (isLastSlide) { + onIntroFinished() + } else { + pagerController.goToNextSlide() + onNextSlide() + } + } + + /** Enable the Immersive Sticky Mode */ + protected fun setImmersiveMode() { + WindowCompat.getInsetsController(window, window.decorView).apply { + systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + hide(systemBars()) + } + } + + /** Customize the color of the Status Bar */ + protected fun setStatusBarColor( + @ColorInt color: Int, + ) { + // We set the light status bar/translucent first via the WindowInsetsControllerCompat + WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars = true + window.statusBarColor = color + } + + /** Customize the color of the Status Bar */ + protected fun setStatusBarColorRes( + @ColorRes color: Int, + ) { + setStatusBarColor(ContextCompat.getColor(this, color)) + } + + /** Customize the color of the Navigation Bar */ + protected fun setNavBarColor( + @ColorInt color: Int, + ) { + window.navigationBarColor = color + } + + /** Customize the color of the Navigation Bar */ + protected fun setNavBarColorRes( + @ColorRes color: Int, + ) { + window.navigationBarColor = ContextCompat.getColor(this, color) + } + + /** Toggle the Status Bar visibility */ + protected fun showStatusBar(show: Boolean) { + val controller = WindowCompat.getInsetsController(window, window.decorView) + if (show) { + controller.show(systemBars()) + } else { + controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT + controller.hide(systemBars()) + } + } + + /** + * @param lock Set true to disable forward swiping. False to enable. + * @deprecated setNextPageSwipeLock has been deprecated in favor of setSwipeLock or SlidePolicy + */ + @Deprecated( + "setNextPageSwipeLock has been deprecated in favor of setSwipeLock or SlidePolicy", + ReplaceWith("setSwipeLock"), + DeprecationLevel.ERROR, + ) + @Suppress("UnusedPrivateMember", "UNUSED_PARAMETER") + protected fun setNextPageSwipeLock(lock: Boolean) { + LogHelper.w( + TAG, + "Calling setNextPageSwipeLock has not effect here. Please switch to setSwipeLock or SlidePolicy", + ) + } + + /** + * Setting to disable swiping left and right on current page. This also + * hides/shows the Next and Done buttons accordingly. + * + * @param lock Set true to disable forward swiping. False to enable. + */ + protected fun setSwipeLock(lock: Boolean) { + // We retain the button state in order to be able to restore + // it properly afterwards. + if (lock) { + retainIsButtonsEnabled = this.isButtonsEnabled + this.isButtonsEnabled = true + } else { + this.isButtonsEnabled = retainIsButtonsEnabled + } + pagerController.isFullPagingEnabled = !lock + } + + /** + * Set a [ProgressIndicatorController] instead of dots (a [DotIndicatorController]. + * This is recommended for a large amount of slides. In this case there + * could not be enough space to display all dots on smaller device screens. + */ + protected fun setProgressIndicator() { + indicatorController = ProgressIndicatorController(this) + } + + /** + * Overrides color of selected and unselected indicator colors + * @param selectedIndicatorColor your selected color + * @param unselectedIndicatorColor your unselected color + */ + protected fun setIndicatorColor( + @ColorInt selectedIndicatorColor: Int, + @ColorInt unselectedIndicatorColor: Int, + ) { + indicatorController?.selectedIndicatorColor = selectedIndicatorColor + indicatorController?.unselectedIndicatorColor = unselectedIndicatorColor + } + + /* + TRANSFORMERS + =================================== */ + + /** Allows to specify one of the [AppIntroPageTransformerType] for the ViewPager */ + protected fun setTransformer(appIntroTransformer: AppIntroPageTransformerType) { + pagerController.setAppIntroPageTransformer(appIntroTransformer) + } + + /** Overrides viewpager transformer with you custom [ViewPagerTransformer] */ + protected fun setCustomTransformer(transformer: ViewPager2.PageTransformer?) { + pagerController.setPageTransformer(transformer) + } + + /* + CALLBACKS + =================================== */ + + /** + * Called by [AppIntroViewPager] when the user swipes forward on a slide which contains permissions. + * [.setSwipeLock] is called to prevent user from swiping while permission is requested. + * [.checkAndRequestPermissions] is called to request permissions from the user. + */ + override fun onUserRequestedPermissionsDialog() { + LogHelper.d(TAG, "Requesting Permissions on $currentSlideNumber") + requestPermissions() + } + + /** + * Called when the user checks "Don't ask again" in the permission dialog + * or when the permission is disabled by the device policy. + * + * @param permissionName - Name of the permission disabled. + */ + protected open fun onUserDisabledPermission(permissionName: String) {} + + /** + * Called when the user denies a permission to the app. + * This method is called both when the permission was required (so we + * block the user) and when it was not (so the user can actually proceed). + * + * @param permissionName - Name of the permission denied. + */ + protected open fun onUserDeniedPermission(permissionName: String) {} + + /** + * Called after a new slide has been selected + * @param position Position of the new selected slide + */ + protected open fun onPageSelected(position: Int) {} + + /** Called when the user clicked the done button */ + protected open fun onDonePressed(currentFragment: Fragment?) {} + + /** Called when the user clicked the next button */ + protected open fun onNextPressed(currentFragment: Fragment?) {} + + /** Called when the user clicked the skip button */ + protected open fun onSkipPressed(currentFragment: Fragment?) {} + + /** Called when the user request to go to the next slide either + * via keyboard (Enter, etc.) or via button */ + protected open fun onNextSlide() {} + + /** Called when the AppIntro reached the end. */ + protected open fun onIntroFinished() {} + + /** + * Called when the selected fragment changed. + * @param oldFragment Instance of the fragment which was displayed before. + * This might be null if the the intro has just started. + * @param newFragment Instance of the fragment which is displayed now. + * This might be null if the intro has finished + */ + protected open fun onSlideChanged( + oldFragment: Fragment?, + newFragment: Fragment?, + ) {} + + /* + LIFECYCLE + =================================== */ + + @Suppress("DEPRECATION") + override fun onCreate(savedInstanceState: Bundle?) { + supportRequestWindowFeature(Window.FEATURE_NO_TITLE) + AppCompatDelegate.setCompatVectorFromResourcesEnabled(true) + super.onCreate(savedInstanceState) + + // Add back handler + addBackHandler() + + // We default the indicator controller to the Dotted one. + indicatorController = DotIndicatorController(this) + + // We default to don't show the Status Bar. User can override this. + showStatusBar(false) + + setContentView(layoutId) + + indicatorContainer = findViewById(R.id.indicator_container) + ?: error("Missing Indicator Container: R.id.indicator_container") + nextButton = findViewById(R.id.next) ?: error("Missing Next button: R.id.next") + doneButton = findViewById(R.id.done) ?: error("Missing Done button: R.id.done") + skipButton = findViewById(R.id.skip) ?: error("Missing Skip button: R.id.skip") + backButton = findViewById(R.id.back) ?: error("Missing Back button: R.id.back") + + setTooltipText(nextButton, getString(R.string.app_intro_next_button)) + if (skipButton is ImageButton) { + setTooltipText(skipButton, getString(R.string.app_intro_skip_button)) + } + if (doneButton is ImageButton) { + setTooltipText(doneButton, getString(R.string.app_intro_done_button)) + } + if (backButton is ImageButton) { + setTooltipText(backButton, getString(R.string.app_intro_back_button)) + } + + if (isRtl) { + nextButton.scaleX = -1f + backButton.scaleX = -1f + } + + pagerAdapter = PagerAdapter(this@AppIntroBase, fragments) + pagerController = + AppIntroViewPagerController( + viewPager = findViewById(R.id.view_pager), + viewPagerGestureOverlay = findViewById(R.id.pager_gesture_overlay), + ) + + doneButton.setOnClickListener(NextSlideOnClickListener(isLastSlide = true)) + nextButton.setOnClickListener(NextSlideOnClickListener(isLastSlide = false)) + backButton.setOnClickListener { pagerController.goToPreviousSlide() } + skipButton.setOnClickListener { + dispatchVibration() + onSkipPressed(getPagerItem(pagerController.getCurrentItem())) + } + + pagerController.setAdapter(this.pagerAdapter) + pagerController.registerOnPageChangeCallback(OnPageChangeCallback()) + pagerController.onNextPageRequestedListener = this + } + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + + slidesNumber = fragments.size + initializeIndicator() + + // Makes sure we correctly retain the `isButtonsEnabled` just after onCreate + retainIsButtonsEnabled = isButtonsEnabled + + // Required for triggering onPageSelected and onSlideChanged for the first page. + if (isRtl) { + pagerController.setCurrentViewPagerItem(fragments.size - savedCurrentItem) + } else { + pagerController.setCurrentViewPagerItem(savedCurrentItem) + } + + pagerController.post { + if (pagerController.getCurrentItem() < pagerAdapter.itemCount) { + dispatchSlideChangedCallbacks( + null, + getPagerItem(pagerController.getCurrentItem()), + ) + } else { + // Close the intro if there are no slides to show + finish() + } + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.apply { + putInt(ARG_BUNDLE_SLIDES_NUMBER, slidesNumber) + putBoolean(ARG_BUNDLE_RETAIN_IS_BUTTONS_ENABLED, retainIsButtonsEnabled) + putBoolean(ARG_BUNDLE_IS_BUTTONS_ENABLED, isButtonsEnabled) + putBoolean(ARG_BUNDLE_IS_SKIP_BUTTON_ENABLED, isSkipButtonEnabled) + putBoolean(ARG_BUNDLE_IS_INDICATOR_ENABLED, isIndicatorEnabled) + + // We can't use pager.currentItem as we need the current item that is RTL-invariant. + val currentItem = + if (isRtl) { + fragments.size - pagerController.getCurrentItem() + } else { + pagerController.getCurrentItem() + } + putInt(ARG_BUNDLE_CURRENT_ITEM, currentItem) + putBoolean(ARG_BUNDLE_IS_FULL_PAGING_ENABLED, pagerController.isFullPagingEnabled) + + putSerializable(ARG_BUNDLE_PERMISSION_MAP, permissionsMap) + + putBoolean(ARG_BUNDLE_COLOR_TRANSITIONS_ENABLED, isColorTransitionsEnabled) + } + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + with(savedInstanceState) { + slidesNumber = getInt(ARG_BUNDLE_SLIDES_NUMBER) + retainIsButtonsEnabled = getBoolean(ARG_BUNDLE_RETAIN_IS_BUTTONS_ENABLED) + isButtonsEnabled = getBoolean(ARG_BUNDLE_IS_BUTTONS_ENABLED) + isSkipButtonEnabled = getBoolean(ARG_BUNDLE_IS_SKIP_BUTTON_ENABLED) + isIndicatorEnabled = getBoolean(ARG_BUNDLE_IS_INDICATOR_ENABLED) + + savedCurrentItem = getInt(ARG_BUNDLE_CURRENT_ITEM) + pagerController.isFullPagingEnabled = getBoolean(ARG_BUNDLE_IS_FULL_PAGING_ENABLED) + + @Suppress("UNCHECKED_CAST", "DEPRECATION") + permissionsMap = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ( + getSerializable(ARG_BUNDLE_PERMISSION_MAP, HashMap::class.java) + as HashMap? + ) ?: hashMapOf() + } else { + (getSerializable(ARG_BUNDLE_PERMISSION_MAP) as HashMap?) + ?: hashMapOf() + } + + isColorTransitionsEnabled = getBoolean(ARG_BUNDLE_COLOR_TRANSITIONS_ENABLED) + } + } + + private fun initializeIndicator() { + indicatorContainer.addView(indicatorController?.newInstance(this)) + indicatorController?.initialize(slidesNumber) + indicatorController?.selectPosition(currentlySelectedItem) + } + + override fun onKeyDown( + code: Int, + event: KeyEvent, + ): Boolean { + // Handle the navigation with 'Enter' or Dpad events. + if (code == KeyEvent.KEYCODE_ENTER || + code == KeyEvent.KEYCODE_BUTTON_A || + code == KeyEvent.KEYCODE_DPAD_CENTER + ) { + val isLastSlide = pagerController.isLastSlide(fragments.size) + goToNextSlide(isLastSlide) + if (isLastSlide) { + // We emulate the onDonePressed here to keep backward compatibility + // with the previous API (users expect an onDonePressed to kill the Activity). + // Ideally we should get rid of this extra callback in one of the future release. + onDonePressed(getPagerItem(pagerController.getCurrentItem())) + } + return false + } + return super.onKeyDown(code, event) + } + + /* + BACK HANDLER + =================================== */ + + private fun addBackHandler() { + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + // Do nothing if go back lock is enabled or slide has custom policy. + if (isSystemBackButtonLocked) { + return + } + if (pagerController.isFirstSlide(fragments.size)) { + finish() + } else { + pagerController.goToPreviousSlide() + } + } + }, + ) + } + + /* + BUTTONS VISIBILITY FLAGS + =================================== */ + + private fun updateButtonsVisibility() { + if (isButtonsEnabled) { + val isLastSlide = pagerController.isLastSlide(fragments.size) + val isFirstSlide = pagerController.isFirstSlide(fragments.size) + nextButton.isVisible = !isLastSlide + doneButton.isVisible = isLastSlide + skipButton.isVisible = isSkipButtonEnabled && !isLastSlide + backButton.isVisible = isWizardMode && !isFirstSlide + } else { + nextButton.isVisible = false + doneButton.isVisible = false + backButton.isVisible = false + skipButton.isVisible = false + } + } + + /* + SLIDE POLICY + =================================== */ + + /** + * Called before a slide change happens. By returning false, one can + * disallow the slide change. + * + * @return true, if the slide change should be allowed, else false + */ + override fun onCanRequestNextPage(): Boolean { + val currentFragment = getPagerItem(pagerController.getCurrentItem()) + + // Check if the current fragment implements SlidePolicy, else a change is always allowed. + return if (currentFragment is SlidePolicy && !currentFragment.isPolicyRespected) { + LogHelper.d(TAG, "Slide policy not respected, denying change request.") + false + } else { + LogHelper.d(TAG, "Change request will be allowed.") + true + } + } + + override fun onIllegallyRequestedNextPage() { + val currentFragment = getPagerItem(pagerController.getCurrentItem()) + if (currentFragment is SlidePolicy) { + if (!currentFragment.isPolicyRespected) { + currentFragment.onUserIllegallyRequestedNextPage() + } + } + } + + /* + PERMISSION + =================================== */ + + private fun shouldRequestPermission() = permissionsMap.containsKey(currentSlideNumber) + + // Request one or more permission to the Android SDK. + private fun requestPermissions() { + setSwipeLock(true) + val permissionToRequest = permissionsMap[currentSlideNumber] + permissionToRequest?.let { + ActivityCompat.requestPermissions( + this, + it.permissions, + PERMISSIONS_REQUEST_ALL_PERMISSIONS, + ) + } + } + + /** + * Handles the Permission Result from Android SDK. + */ + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + setSwipeLock(false) + + if (requestCode != PERMISSIONS_REQUEST_ALL_PERMISSIONS) { + return + } + + val deniedPermissions = + grantResults + .mapIndexed { index, result -> (permissions[index] to result) } + .filter { (_, result) -> result == PackageManager.PERMISSION_DENIED } + .map { (permission, _) -> permission } + + // Check if all permissions are granted. + if (deniedPermissions.isEmpty()) { + // If the permission request was a success, we remove + // the permission from the permissionsMap. + permissionsMap.remove(currentSlideNumber) + goToNextSlide() + } else { + // At least one or all of the permissions have been denied. + deniedPermissions.forEach(::handleDeniedPermission) + // Let's force a recenter of the current slide. + pagerController.reCenterCurrentSlide() + } + } + + /** + * Function to supports the event dispatching and the handling of the + * various "Permission Denied" scenarios. + */ + private fun handleDeniedPermission(permission: String) { + if (ActivityCompat.shouldShowRequestPermissionRationale(this, permission)) { + // Permission is denied for the first time (never ask again box is not checked). + // Ask again explaining the usage of the permission (Show an AlertDialog or Snackbar) + onUserDeniedPermission(permission) + } else { + // Permission is disabled (never ask again is checked) + // Ask the user to go to settings to enable permission. + onUserDisabledPermission(permission) + } + + // If the permission was not required, we can remove it from the App and let the user proceed. + permissionsMap[currentSlideNumber]?.let { requestedPermission -> + if (!requestedPermission.required) { + permissionsMap.remove(requestedPermission.position) + goToNextSlide() + } + } + } + + private fun dispatchVibration() { + if (isVibrate) { + VibrationHelper.vibrate(this, vibrateDuration) + } + } + + /** + * Getter used to notify [AppIntroViewPager] if the currently selected slide + * has permissions attached to it. + */ + private val isPermissionSlide: Boolean + get() = pagerController.getCurrentSlideNumber(fragments.size) in permissionsMap + + /** Takes care of calling all the necessary callbacks on Slide Changing. */ + private fun dispatchSlideChangedCallbacks( + oldFragment: Fragment?, + newFragment: Fragment?, + ) { + if (oldFragment is SlideSelectionListener) { + oldFragment.onSlideDeselected() + } + if (newFragment is SlideSelectionListener) { + newFragment.onSlideSelected() + } + onSlideChanged(oldFragment, newFragment) + } + + /** Performs color interpolation between two slides.. */ + private fun performColorTransition( + currentSlide: Fragment?, + nextSlide: Fragment?, + positionOffset: Float, + ) { + if (nextSlide == null) return + + if (currentSlide is SlideBackgroundColorHolder && + nextSlide is SlideBackgroundColorHolder + ) { + // Check if both fragments are attached to an activity, + // otherwise getDefaultBackgroundColor may fail. + if (currentSlide.isAdded && nextSlide.isAdded) { + val newColor = + argbEvaluator.evaluate( + positionOffset, + getSlideColor(currentSlide), + getSlideColor(nextSlide), + ) as Int + currentSlide.setBackgroundColor(newColor) + nextSlide.setBackgroundColor(newColor) + } + } else { + error("Color transitions are only available if all slides implement SlideBackgroundColorHolder.") + } + } + + @ColorInt + @Suppress("DEPRECATION") + private fun getSlideColor(slide: SlideBackgroundColorHolder): Int { + if (slide.defaultBackgroundColorRes != 0) { + return ContextCompat.getColor(this, slide.defaultBackgroundColorRes) + } + + return slide.defaultBackgroundColor + } + + private fun getPagerItem(position: Int): Fragment? = pagerAdapter.getItem(position, supportFragmentManager) + + /** + * Onclick listener for the Next/Done button. + * @param isLastSlide True if you're using this for the DONE button. + */ + private inner class NextSlideOnClickListener(var isLastSlide: Boolean) : View.OnClickListener { + override fun onClick(view: View) { + dispatchVibration() + // Check if changing to the next slide is allowed + if (!onCanRequestNextPage()) { + onIllegallyRequestedNextPage() + return + } + if (shouldRequestPermission()) { + requestPermissions() + return + } + + // We can successfully change slide, let's do it. + val currentFragment = getPagerItem(pagerController.getCurrentItem()) + if (isLastSlide) { + onDonePressed(currentFragment) + } else { + onNextPressed(currentFragment) + } + goToNextSlide(isLastSlide) + } + } + + /** + * [OnPageChangeCallback] used to handle all the callbacks coming from the ViewPager. + * Moreover, if [isColorTransitionsEnabled] a color interpolation will happen in the [onPageScrolled] + */ + internal inner class OnPageChangeCallback : ViewPager2.OnPageChangeCallback() { + override fun onPageScrolled( + position: Int, + positionOffset: Float, + positionOffsetPixels: Int, + ) { + if (isColorTransitionsEnabled && position < pagerAdapter.itemCount - 1) { + val currentSlide = getPagerItem(position) + val nextSlide = getPagerItem(position + 1) + performColorTransition(currentSlide, nextSlide, positionOffset) + } + } + + override fun onPageSelected(position: Int) { + if (slidesNumber >= 1) { + indicatorController?.selectPosition(position) + } + updateButtonsVisibility() + + pagerController.isPermissionSlide = this@AppIntroBase.isPermissionSlide + + // Firing all the necessary Callbacks + this@AppIntroBase.onPageSelected(position) + if (slidesNumber > 0) { + if (currentlySelectedItem == -1) { + dispatchSlideChangedCallbacks( + null, + getPagerItem(position), + ) + } else { + dispatchSlideChangedCallbacks( + getPagerItem(currentlySelectedItem), + getPagerItem(pagerController.getCurrentItem()), + ) + } + } + currentlySelectedItem = position + } + } + + private companion object { + private val TAG = LogHelper.makeLogTag(AppIntroBase::class.java) + + private const val DEFAULT_VIBRATE_DURATION = 20L + private const val PERMISSIONS_REQUEST_ALL_PERMISSIONS = 1 + + private const val ARG_BUNDLE_COLOR_TRANSITIONS_ENABLED = "colorTransitionEnabled" + private const val ARG_BUNDLE_CURRENT_ITEM = "currentItem" + private const val ARG_BUNDLE_IS_BUTTONS_ENABLED = "isButtonsEnabled" + private const val ARG_BUNDLE_IS_FULL_PAGING_ENABLED = "isFullPagingEnabled" + private const val ARG_BUNDLE_IS_INDICATOR_ENABLED = "isIndicatorEnabled" + private const val ARG_BUNDLE_IS_SKIP_BUTTON_ENABLED = "isSkipButtonsEnabled" + private const val ARG_BUNDLE_PERMISSION_MAP = "permissionMap" + private const val ARG_BUNDLE_RETAIN_IS_BUTTONS_ENABLED = "retainIsButtonsEnabled" + private const val ARG_BUNDLE_SLIDES_NUMBER = "slidesNumber" + } +} + +/** Extension property to toggle visibility/invisibility of a view */ +private var View.isVisible: Boolean + get() = this.visibility == View.VISIBLE + set(value) { + this.visibility = if (value) View.VISIBLE else View.INVISIBLE + } diff --git a/appintro/src/main/java/com/github/appintro/AppIntroBaseFragment.kt b/appintro/src/main/java/com/github/appintro/AppIntroBaseFragment.kt new file mode 100644 index 000000000..1996274fe --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/AppIntroBaseFragment.kt @@ -0,0 +1,186 @@ +package com.github.appintro + +import android.graphics.drawable.Animatable +import android.os.Bundle +import android.text.method.ScrollingMovementMethod +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.annotation.LayoutRes +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import com.github.appintro.internal.LogHelper +import com.github.appintro.internal.TypefaceContainer + +abstract class AppIntroBaseFragment : Fragment(), SlideSelectionListener, SlideBackgroundColorHolder { + private val viewModel: AppIntroFragmentViewModel by viewModels() + + private val logTAG = LogHelper.makeLogTag(AppIntroBaseFragment::class.java) + + @get:LayoutRes + protected abstract val layoutId: Int + + @ColorInt + @Deprecated( + "`defaultBackgroundColor` has been deprecated to support configuration changes", + ReplaceWith("defaultBackgroundColorRes"), + ) + final override var defaultBackgroundColor: Int = 0 + private set + + @ColorRes + final override var defaultBackgroundColorRes: Int = 0 + private set + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + @Suppress("DEPRECATION") + defaultBackgroundColor = viewModel.defaultBackgroundColor ?: 0 + defaultBackgroundColorRes = viewModel.defaultBackgroundColorRes ?: 0 + + val args = arguments + if (args != null && args.size() != 0) { + viewModel.drawable = args.getInt(ARG_DRAWABLE) + viewModel.title = args.getCharSequence(ARG_TITLE) + viewModel.description = args.getCharSequence(ARG_DESC) + + if (args.containsKey(ARG_BG_DRAWABLE)) { + viewModel.bgDrawable = args.getInt(ARG_BG_DRAWABLE) + } + + viewModel.titleTypefaceUrl = args.getString(ARG_TITLE_TYPEFACE_URL) + viewModel.titleTypefaceRes = args.getInt(ARG_TITLE_TYPEFACE_RES) + + viewModel.descTypefaceUrl = args.getString(ARG_DESC_TYPEFACE_URL) + viewModel.descTypefaceRes = args.getInt(ARG_DESC_TYPEFACE_RES) + + @Suppress("DEPRECATION") + viewModel.defaultBackgroundColor = args.getInt(ARG_BG_COLOR) + viewModel.defaultBackgroundColorRes = args.getInt(ARG_BG_COLOR_RES) + + if (args.containsKey(ARG_TITLE_COLOR)) { + viewModel.titleColor = args.getInt(ARG_TITLE_COLOR) + } + + if (args.containsKey(ARG_TITLE_COLOR_RES)) { + viewModel.titleColorRes = args.getInt(ARG_TITLE_COLOR_RES) + } + + if (args.containsKey(ARG_DESC_COLOR)) { + viewModel.descColor = args.getInt(ARG_DESC_COLOR) + } + + if (args.containsKey(ARG_DESC_COLOR_RES)) { + viewModel.descColorRes = args.getInt(ARG_DESC_COLOR_RES) + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + val view = inflater.inflate(layoutId, container, false) + val titleText = view.findViewById(R.id.title) + val descriptionText = view.findViewById(R.id.description) + val slideImage = view.findViewById(R.id.image) + val mainLayout = view.findViewById(R.id.main) + + titleText.text = viewModel.title + descriptionText.text = viewModel.description + + val titleColorRes = viewModel.titleColorRes + val titleColor = viewModel.titleColor + + if (titleColorRes != null) { + titleText.setTextColor(ContextCompat.getColor(requireContext(), titleColorRes)) + } else if (titleColor != null) { // Fallback to deprecated static color + titleText.setTextColor(titleColor) + } + + val descColorRes = viewModel.descColorRes + val descColor = viewModel.descColor + + if (descColorRes != null) { + descriptionText.setTextColor(ContextCompat.getColor(requireContext(), descColorRes)) + } else if (descColor != null) { // Fallback to deprecated static color + descriptionText.setTextColor(descColor) + } + + TypefaceContainer( + typeFaceUrl = viewModel.titleTypefaceUrl, + typeFaceResource = viewModel.titleTypefaceRes ?: 0, + ).applyTo(titleText) + + TypefaceContainer( + typeFaceUrl = viewModel.descTypefaceUrl, + typeFaceResource = viewModel.descTypefaceRes ?: 0, + ).applyTo(descriptionText) + + viewModel.drawable?.let { + slideImage.setImageResource(it) + } + + val bgDrawable = viewModel.bgDrawable + + when { + bgDrawable != null -> { + mainLayout?.setBackgroundResource(bgDrawable) + } + defaultBackgroundColorRes != 0 -> { + mainLayout?.setBackgroundColor(ContextCompat.getColor(requireContext(), defaultBackgroundColorRes)) + } + else -> { + @Suppress("DEPRECATION") + mainLayout?.setBackgroundColor(defaultBackgroundColor) + } + } + + titleText.movementMethod = ScrollingMovementMethod() + descriptionText.movementMethod = ScrollingMovementMethod() + + return view + } + + override fun onResume() { + super.onResume() + + view?.findViewById(R.id.image).let { + if (it is Animatable) { + it.start() + } + } + } + + override fun onPause() { + super.onPause() + + view?.findViewById(R.id.image).let { + if (it is Animatable) { + it.start() + } + } + } + + override fun onSlideDeselected() { + LogHelper.d(logTAG, "Slide ${viewModel.title} has been deselected.") + } + + override fun onSlideSelected() { + LogHelper.d(logTAG, "Slide ${viewModel.title} has been selected.") + } + + override fun setBackgroundColor( + @ColorInt backgroundColor: Int, + ) { + view?.findViewById(R.id.main)?.setBackgroundColor(backgroundColor) + } +} diff --git a/appintro/src/main/java/com/github/appintro/AppIntroCustomLayoutFragment.kt b/appintro/src/main/java/com/github/appintro/AppIntroCustomLayoutFragment.kt new file mode 100644 index 000000000..75a82b3e7 --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/AppIntroCustomLayoutFragment.kt @@ -0,0 +1,42 @@ +package com.github.appintro + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment + +/** + * Util class to be used when creating a slide with a custom layout. + * [AppIntroCustomLayoutFragment.newInstance] passing the Layout ID of your custom layout. + * + * You can then use this Slide with the [AppIntroBase.addSlide] methods to add this slide + * to your AppIntro. + */ +class AppIntroCustomLayoutFragment : Fragment() { + private var layoutResId = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + layoutResId = arguments?.getInt(ARG_LAYOUT_RES_ID) ?: 0 + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? = inflater.inflate(layoutResId, container, false) + + companion object { + private const val ARG_LAYOUT_RES_ID = "layoutResId" + + @JvmStatic + fun newInstance(layoutResId: Int): AppIntroCustomLayoutFragment { + val customSlide = AppIntroCustomLayoutFragment() + val args = Bundle() + args.putInt(ARG_LAYOUT_RES_ID, layoutResId) + customSlide.arguments = args + return customSlide + } + } +} diff --git a/appintro/src/main/java/com/github/appintro/AppIntroFragment.kt b/appintro/src/main/java/com/github/appintro/AppIntroFragment.kt new file mode 100644 index 000000000..3bfcf7c18 --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/AppIntroFragment.kt @@ -0,0 +1,148 @@ +package com.github.appintro + +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.FontRes +import com.github.appintro.model.SliderPage + +@Suppress("LongParameterList") +class AppIntroFragment : AppIntroBaseFragment() { + override val layoutId: Int get() = R.layout.appintro_fragment_intro + + companion object { + /** + * Generates a new instance for [AppIntroFragment] + * + * @param title CharSequence which will be the slide title + * @param description CharSequence which will be the slide description + * @param imageDrawable @DrawableRes (Integer) the image that will be + * displayed, obtained from Resources + * @param backgroundColor @ColorInt (Integer) custom background color + * @param titleColor @ColorInt (Integer) custom title color + * @param descriptionColor @ColorInt (Integer) custom description color + * @param titleTypefaceFontRes @FontRes (Integer) custom title typeface obtained + * from Resources + * @param descriptionTypefaceFontRes @FontRes (Integer) custom description typeface obtained + * from Resources + * @param backgroundDrawable @DrawableRes (Integer) custom background drawable + * + * @return An [AppIntroFragment] created instance + */ + @JvmOverloads + @JvmStatic + @Deprecated( + "`newInstance` is deprecated to support color resources instead of color int " + + "for configuration changes and dark theme support", + ReplaceWith( + "createInstance(title, description, imageDrawable, backgroundColor, " + + "titleColor, descriptionColor, titleTypefaceFontRes, descriptionTypefaceFontRes, " + + "backgroundDrawable)", + ), + ) + fun newInstance( + title: CharSequence? = null, + description: CharSequence? = null, + @DrawableRes imageDrawable: Int? = null, + @ColorInt backgroundColor: Int? = null, + @ColorInt titleColor: Int? = null, + @ColorInt descriptionColor: Int? = null, + @FontRes titleTypefaceFontRes: Int? = null, + @FontRes descriptionTypefaceFontRes: Int? = null, + @DrawableRes backgroundDrawable: Int? = null, + ): AppIntroFragment { + return createInstance( + SliderPage( + title = title, + description = description, + imageDrawable = imageDrawable, + backgroundColor = backgroundColor, + titleColor = titleColor, + descriptionColor = descriptionColor, + titleTypefaceFontRes = titleTypefaceFontRes, + descriptionTypefaceFontRes = descriptionTypefaceFontRes, + backgroundDrawable = backgroundDrawable, + ), + ) + } + + /** + * Generates a new instance for [AppIntroFragment] + * + * @param title CharSequence which will be the slide title + * @param description CharSequence which will be the slide description + * @param imageDrawable @DrawableRes (Integer) the image that will be + * displayed, obtained from Resources + * @param backgroundColorRes @ColorRes (Integer) custom background color + * @param titleColorRes @ColorRes (Integer) custom title color + * @param descriptionColorRes @ColorRes (Integer) custom description color + * @param titleTypefaceFontRes @FontRes (Integer) custom title typeface obtained + * from Resources + * @param descriptionTypefaceFontRes @FontRes (Integer) custom description typeface obtained + * from Resources + * @param backgroundDrawable @DrawableRes (Integer) custom background drawable + * + * @return An [AppIntroFragment] created instance + */ + @JvmOverloads + @JvmStatic + fun createInstance( + title: CharSequence? = null, + description: CharSequence? = null, + @DrawableRes imageDrawable: Int? = null, + @ColorRes backgroundColorRes: Int? = null, + @ColorRes titleColorRes: Int? = null, + @ColorRes descriptionColorRes: Int? = null, + @FontRes titleTypefaceFontRes: Int? = null, + @FontRes descriptionTypefaceFontRes: Int? = null, + @DrawableRes backgroundDrawable: Int? = null, + ): AppIntroFragment { + return createInstance( + SliderPage( + title = title, + description = description, + imageDrawable = imageDrawable, + backgroundColorRes = backgroundColorRes, + titleColorRes = titleColorRes, + descriptionColorRes = descriptionColorRes, + titleTypefaceFontRes = titleTypefaceFontRes, + descriptionTypefaceFontRes = descriptionTypefaceFontRes, + backgroundDrawable = backgroundDrawable, + ), + ) + } + + /** + * Generates an [AppIntroFragment] from a given [SliderPage] + * + * @param sliderPage the [SliderPage] object which contains all attributes for + * the current slide + * + * @return An [AppIntroFragment] created instance + */ + @JvmStatic + @Deprecated( + "`newInstance` is deprecated to support color resources instead of color int " + + "for configuration changes and dark theme support", + ReplaceWith( + "createInstance(sliderPage)", + ), + ) + fun newInstance(sliderPage: SliderPage) = createInstance(sliderPage) + + /** + * Generates an [AppIntroFragment] from a given [SliderPage] + * + * @param sliderPage the [SliderPage] object which contains all attributes for + * the current slide + * + * @return An [AppIntroFragment] created instance + */ + @JvmStatic + fun createInstance(sliderPage: SliderPage): AppIntroFragment { + val slide = AppIntroFragment() + slide.arguments = sliderPage.toBundle() + return slide + } + } +} diff --git a/appintro/src/main/java/com/github/appintro/AppIntroFragmentViewModel.kt b/appintro/src/main/java/com/github/appintro/AppIntroFragmentViewModel.kt new file mode 100644 index 000000000..b89b89510 --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/AppIntroFragmentViewModel.kt @@ -0,0 +1,56 @@ +package com.github.appintro + +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.annotation.FontRes +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.github.appintro.internal.delegate + +internal const val ARG_TITLE = "title" +internal const val ARG_TITLE_TYPEFACE_URL = "title_typeface" +internal const val ARG_TITLE_TYPEFACE_RES = "title_typeface_res" +internal const val ARG_DESC = "desc" +internal const val ARG_DESC_TYPEFACE_URL = "desc_typeface" +internal const val ARG_DESC_TYPEFACE_RES = "desc_typeface_res" +internal const val ARG_DRAWABLE = "drawable" +internal const val ARG_BG_COLOR = "bg_color" +internal const val ARG_BG_COLOR_RES = "bg_color_res" +internal const val ARG_TITLE_COLOR = "title_color" +internal const val ARG_TITLE_COLOR_RES = "title_color_res" +internal const val ARG_DESC_COLOR = "desc_color" +internal const val ARG_DESC_COLOR_RES = "desc_color_res" +internal const val ARG_BG_DRAWABLE = "bg_drawable" + +internal class AppIntroFragmentViewModel(state: SavedStateHandle) : ViewModel() { + internal var title by state.delegate(ARG_TITLE) + internal var description by state.delegate(ARG_DESC) + internal var drawable by state.delegate(ARG_DRAWABLE) + internal var bgDrawable by state.delegate(ARG_BG_DRAWABLE) + + @get:ColorInt + internal var titleColor by state.delegate(ARG_TITLE_COLOR) + + @get:ColorRes + internal var titleColorRes by state.delegate(ARG_TITLE_COLOR_RES) + + @get:ColorInt + internal var descColor by state.delegate(ARG_DESC_COLOR) + + @get:ColorRes + internal var descColorRes by state.delegate(ARG_DESC_COLOR_RES) + + @get:ColorRes + internal var defaultBackgroundColorRes by state.delegate(ARG_BG_COLOR_RES) + + @get:ColorInt + internal var defaultBackgroundColor by state.delegate(ARG_BG_COLOR) + + @get:FontRes + internal var titleTypefaceRes by state.delegate(ARG_TITLE_TYPEFACE_RES) + internal var titleTypefaceUrl by state.delegate(ARG_TITLE_TYPEFACE_URL) + + @get:FontRes + internal var descTypefaceRes by state.delegate(ARG_DESC_TYPEFACE_RES) + internal var descTypefaceUrl by state.delegate(ARG_DESC_TYPEFACE_URL) +} diff --git a/appintro/src/main/java/com/github/appintro/AppIntroPageTransformerType.kt b/appintro/src/main/java/com/github/appintro/AppIntroPageTransformerType.kt new file mode 100644 index 000000000..150a268e2 --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/AppIntroPageTransformerType.kt @@ -0,0 +1,42 @@ +package com.github.appintro + +import androidx.annotation.IdRes + +/** + * Sealed class to represent all the possible Page Transformers + * offered by AppIntro. + */ +sealed class AppIntroPageTransformerType { + /** Sets the animation of the intro to a flow animation */ + object Flow : AppIntroPageTransformerType() + + /** Sets the animation of the intro to a depth animation */ + object Depth : AppIntroPageTransformerType() + + /** Sets the animation of the intro to a zoom animation */ + object Zoom : AppIntroPageTransformerType() + + /** Sets the animation of the intro to a slide over animation */ + object SlideOver : AppIntroPageTransformerType() + + /** Sets the animation of the intro to a fade animation */ + object Fade : AppIntroPageTransformerType() + + /** + * Sets the animation of the intro to a parallax animation + * @property titleParallaxFactor Parallax factor of title + * @property imageParallaxFactor Parallax factor of image + * @property descriptionParallaxFactor Parallax factor of description + * @property titleViewId The ID to use for the title view to animate + * @property imageViewId The ID to use for the image view to animate + * @property descriptionViewId The ID to use for the description view to animate + */ + class Parallax( + val titleParallaxFactor: Double = 1.0, + val imageParallaxFactor: Double = -1.0, + val descriptionParallaxFactor: Double = 2.0, + @IdRes val titleViewId: Int = R.id.title, + @IdRes val imageViewId: Int = R.id.image, + @IdRes val descriptionViewId: Int = R.id.description, + ) : AppIntroPageTransformerType() +} diff --git a/appintro/src/main/java/com/github/appintro/AppIntroViewPagerListener.kt b/appintro/src/main/java/com/github/appintro/AppIntroViewPagerListener.kt new file mode 100644 index 000000000..5e4160857 --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/AppIntroViewPagerListener.kt @@ -0,0 +1,20 @@ +package com.github.appintro + +/** + * Register an instance of AppIntroViewPagerListener. + * Before the user swipes to the next page, this listener will be called and + * can interrupt swiping by returning false to [onCanRequestNextPage] + * + * [onIllegallyRequestedNextPage] will be called if the user tries to swipe and was not allowed + * to do so (useful for showing a toast or something similar). + * + * [onUserRequestedPermissionsDialog] will be called when the user swipes forward on a slide + * that contains permissions. + */ +interface AppIntroViewPagerListener { + fun onCanRequestNextPage(): Boolean + + fun onIllegallyRequestedNextPage() + + fun onUserRequestedPermissionsDialog() +} diff --git a/appintro/src/main/java/com/github/appintro/SlideBackgroundColorHolder.kt b/appintro/src/main/java/com/github/appintro/SlideBackgroundColorHolder.kt new file mode 100644 index 000000000..d41aeda6a --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/SlideBackgroundColorHolder.kt @@ -0,0 +1,36 @@ +package com.github.appintro + +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes + +interface SlideBackgroundColorHolder { + /** + * Returns the default background color of the slide + * + * @return The default background color of the slide + */ + @get:ColorInt + @Deprecated( + "`defaultBackgroundColor` has been deprecated to support configuration changes", + ReplaceWith("defaultBackgroundColorRes"), + ) + val defaultBackgroundColor: Int + + /** + * Returns the default background color of the slide + * + * @return The default background color of the slide + */ + @get:ColorRes + val defaultBackgroundColorRes: Int + + /** + * Sets the actual background color of the slide. This does not affect the default background color. + * This method should change the background color of the slide's root layout element (e.g. LinearLayout). + * + * @param backgroundColor New actual background color. + */ + fun setBackgroundColor( + @ColorInt backgroundColor: Int, + ) +} diff --git a/library/src/main/java/com/github/paolorotolo/appintro/ISlidePolicy.java b/appintro/src/main/java/com/github/appintro/SlidePolicy.kt similarity index 58% rename from library/src/main/java/com/github/paolorotolo/appintro/ISlidePolicy.java rename to appintro/src/main/java/com/github/appintro/SlidePolicy.kt index 25dcc4d6a..02fb1b643 100644 --- a/library/src/main/java/com/github/paolorotolo/appintro/ISlidePolicy.java +++ b/appintro/src/main/java/com/github/appintro/SlidePolicy.kt @@ -1,17 +1,16 @@ -package com.github.paolorotolo.appintro; - -public interface ISlidePolicy { +package com.github.appintro +interface SlidePolicy { /** * Whether the user has fulfilled the slides policy and should be allowed to navigate through the intro further. - * If false is returned, {@link #onUserIllegallyRequestedNextPage()} will be called. + * If false is returned, [.onUserIllegallyRequestedNextPage] will be called. * * @return True if the user should be allowed to leave the slide, else false. */ - boolean isPolicyRespected(); + val isPolicyRespected: Boolean /** * Called if a user tries to go to the next slide while into navigation has been locked. */ - void onUserIllegallyRequestedNextPage(); + fun onUserIllegallyRequestedNextPage() } diff --git a/library/src/main/java/com/github/paolorotolo/appintro/ISlideSelectionListener.java b/appintro/src/main/java/com/github/appintro/SlideSelectionListener.kt similarity index 61% rename from library/src/main/java/com/github/paolorotolo/appintro/ISlideSelectionListener.java rename to appintro/src/main/java/com/github/appintro/SlideSelectionListener.kt index 40834109c..066a38b15 100644 --- a/library/src/main/java/com/github/paolorotolo/appintro/ISlideSelectionListener.java +++ b/appintro/src/main/java/com/github/appintro/SlideSelectionListener.kt @@ -1,14 +1,14 @@ -package com.github.paolorotolo.appintro; +package com.github.appintro -public interface ISlideSelectionListener { +interface SlideSelectionListener { /** * Called when this slide becomes selected */ - void onSlideSelected(); + fun onSlideSelected() /** * Called when this slide gets deselected. * Please note, that this method won't be called if the user exits the intro in any way. */ - void onSlideDeselected(); + fun onSlideDeselected() } diff --git a/appintro/src/main/java/com/github/appintro/indicator/DotIndicatorController.kt b/appintro/src/main/java/com/github/appintro/indicator/DotIndicatorController.kt new file mode 100644 index 000000000..57c815363 --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/indicator/DotIndicatorController.kt @@ -0,0 +1,78 @@ +package com.github.appintro.indicator + +import android.content.Context +import android.view.Gravity +import android.view.Gravity.CENTER +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat +import com.github.appintro.R + +/** + * An [IndicatorController] that shows a list of dots and highlight the selected dot. + * Use this when the number of page you're dealing with is not too high. + */ +class DotIndicatorController(context: Context) : IndicatorController, LinearLayout(context) { + override var selectedIndicatorColor = ContextCompat.getColor(context, R.color.appintro_default_selected_color) + set(value) { + field = value + selectPosition(currentPosition) + } + + override var unselectedIndicatorColor = ContextCompat.getColor(context, R.color.appintro_default_unselected_color) + set(value) { + field = value + selectPosition(currentPosition) + } + + private var currentPosition = 0 + private var slideCount = 0 + + override fun newInstance(context: Context): View { + val newLayoutParams = + LayoutParams( + LayoutParams.WRAP_CONTENT, + LayoutParams.MATCH_PARENT, + ) + newLayoutParams.gravity = Gravity.CENTER_VERTICAL + layoutParams = newLayoutParams + orientation = HORIZONTAL + gravity = CENTER + return this + } + + override fun initialize(slideCount: Int) { + this.slideCount = slideCount + for (i in 0 until slideCount) { + val dot = ImageView(this.context) + dot.setImageDrawable( + ContextCompat.getDrawable(this.context, R.drawable.ic_appintro_indicator), + ) + val params = + LayoutParams( + LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT, + ) + if (slideCount == 1) { + dot.visibility = View.INVISIBLE + } + this.addView(dot, params) + } + selectPosition(0) + } + + override fun selectPosition(index: Int) { + currentPosition = index + for (i in 0 until slideCount) { + val tint = + if (i == index) { + selectedIndicatorColor + } else { + unselectedIndicatorColor + } + (getChildAt(i) as ImageView).let { DrawableCompat.setTint(it.drawable, tint) } + } + } +} diff --git a/appintro/src/main/java/com/github/appintro/indicator/IndicatorController.kt b/appintro/src/main/java/com/github/appintro/indicator/IndicatorController.kt new file mode 100644 index 000000000..a5d1006d3 --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/indicator/IndicatorController.kt @@ -0,0 +1,48 @@ +package com.github.appintro.indicator + +import android.content.Context +import android.view.View +import androidx.annotation.ColorInt + +/** + * A controller that is used to provide custom indicator implementations and to control + * their behaviour. This is used for [AppIntro.setCustomIndicator] and + * [AppIntro2.setCustomIndicator] + */ +interface IndicatorController { + @get:ColorInt + var selectedIndicatorColor: Int + + @get:ColorInt + var unselectedIndicatorColor: Int + + /** + * Create a new instance of the view to be inserted in the AppIntro layout. + * This method is only called once for each creation of the activity. + * + * [IndicatorController.initialize] is called after this. + * + * @param context A context to be used for the view instantiation + * @return An instance of the indicator view + */ + fun newInstance(context: Context): View + + /** + * Initialize the indicator view with the requested amount of elements. + * As with [IndicatorController.newInstance], this method is only called once for each + * creation of the activity as well. + * + * [IndicatorController.newInstance] is called before this. + * + * @param slideCount The amount of slides present in the AppIntro + */ + fun initialize(slideCount: Int) + + /** + * Select the position for the new page that became selected. + * This method is called every time the selected page changed. + * + * @param index The index of the page that became selected + */ + fun selectPosition(index: Int) +} diff --git a/appintro/src/main/java/com/github/appintro/indicator/ProgressIndicatorController.kt b/appintro/src/main/java/com/github/appintro/indicator/ProgressIndicatorController.kt new file mode 100644 index 000000000..62676f343 --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/indicator/ProgressIndicatorController.kt @@ -0,0 +1,69 @@ +package com.github.appintro.indicator + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.ProgressBar +import androidx.core.graphics.BlendModeColorFilterCompat +import androidx.core.graphics.BlendModeCompat +import com.github.appintro.internal.LayoutUtil + +internal const val DEFAULT_COLOR = 1 + +/** + * An [IndicatorController] that shows a [ProgressBar] for express the number of + * page that have been completed. + * Use this when the number of page is higher and the [DotIndicatorController] + * would not fit in the screen. + */ +class ProgressIndicatorController + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = android.R.attr.progressBarStyleHorizontal, + ) : IndicatorController, ProgressBar(context, attrs, defStyleAttr) { + override var selectedIndicatorColor = DEFAULT_COLOR + set(value) { + field = value + progressDrawable.colorFilter = + BlendModeColorFilterCompat.createBlendModeColorFilterCompat( + value, + BlendModeCompat.SRC_ATOP, + ) + } + + override var unselectedIndicatorColor = DEFAULT_COLOR + set(value) { + field = value + progressDrawable.colorFilter = + BlendModeColorFilterCompat.createBlendModeColorFilterCompat( + value, + BlendModeCompat.SRC_ATOP, + ) + } + + override fun newInstance(context: Context) = this + + override fun initialize(slideCount: Int) { + this.max = slideCount + if (isRtl) { + this.scaleX = -1F + } + if (slideCount == 1) { + this.visibility = View.INVISIBLE + } + selectPosition(0) + } + + override fun selectPosition(index: Int) { + this.progress = + if (isRtl) { + max - index + } else { + index + 1 + } + } + + private val isRtl: Boolean get() = LayoutUtil.isRtl(this.context) + } diff --git a/appintro/src/main/java/com/github/appintro/internal/AppIntroViewPagerController.kt b/appintro/src/main/java/com/github/appintro/internal/AppIntroViewPagerController.kt new file mode 100644 index 000000000..a19892b4b --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/internal/AppIntroViewPagerController.kt @@ -0,0 +1,302 @@ +package com.github.appintro.internal + +import android.gesture.GestureOverlayView +import android.gesture.GestureOverlayView.OnGestureListener +import android.view.MotionEvent +import androidx.viewpager2.widget.ViewPager2 +import com.github.appintro.AppIntroBase +import com.github.appintro.AppIntroPageTransformerType +import com.github.appintro.AppIntroViewPagerListener +import com.github.appintro.internal.viewpager.PagerAdapter +import com.github.appintro.internal.viewpager.ViewPagerTransformer +import kotlin.math.max + +/** + * Class that controls the [ViewPager2] of AppIntro. + * This is responsible of handling of paging, managing touch and dispatching events. + * + * @property isFullPagingEnabled Enable or disable swiping at all. + * @property isPermissionSlide If the current slide has permissions. + * @property onNextPageRequestedListener Listener for Next Page events. + */ +internal class AppIntroViewPagerController( + private val viewPager: ViewPager2, + private val viewPagerGestureOverlay: GestureOverlayView, +) { + var isFullPagingEnabled = true + var isPermissionSlide = false + var onNextPageRequestedListener: AppIntroViewPagerListener? = null + + private var currentTouchDownX: Float = 0.toFloat() + private var currentTouchDownY: Float = 0.toFloat() + private var illegallyRequestedNextPageLastCalled: Long = 0 + private var pageChangeCallback: AppIntroBase.OnPageChangeCallback? = null + + init { + addPagerTouchInterceptor() + } + + fun goToNextSlide() { + with(viewPager) { + // avoid IllegalStateException when changing item while fake dragging + endFakeDrag() + setCurrentViewPagerItem( + position = if (!LayoutUtil.isRtl(context)) currentItem + 1 else currentItem - 1, + smoothScrool = true, + ) + } + } + + fun goToPreviousSlide() { + with(viewPager) { + // avoid IllegalStateException when changing item while fake dragging + endFakeDrag() + setCurrentViewPagerItem( + position = if (!LayoutUtil.isRtl(context)) currentItem - 1 else currentItem + 1, + smoothScrool = true, + ) + } + } + + fun isFirstSlide(size: Int): Boolean { + with(viewPager) { + return if (LayoutUtil.isRtl(context)) (currentItem - size + 1 == 0) else (currentItem == 0) + } + } + + fun isLastSlide(size: Int): Boolean { + with(viewPager) { + return if (LayoutUtil.isRtl(context)) (currentItem == 0) else (currentItem - size + 1 == 0) + } + } + + fun getCurrentSlideNumber(size: Int): Int { + with(viewPager) { + return if (LayoutUtil.isRtl(context)) (size - currentItem) else (currentItem + 1) + } + } + + /** + * Override is required to trigger [AppIntroBase.OnPageChangeCallback.onPageSelected] for the first page. + * This is needed to correctly handle progress button display after rotation on a locked first page. + */ + fun setCurrentViewPagerItem( + position: Int, + smoothScrool: Boolean = false, + ) { + with(viewPager) { + endFakeDrag() + + val oldItem = currentItem + viewPager.setCurrentItem(position, smoothScrool) + + // When you pass set current item to 0, + // The pageChangeListener won't be called so we call it on our own + if (oldItem == 0 && position == 0) { + pageChangeCallback?.onPageSelected(0) + } + } + } + + /** + * Checks for illegal sliding attempts. + * Every time the user presses the screen, the respective coordinates are stored. + * Once the user swipes/stops pressing, the new coordinates are checked against the stored ones. + * Therefore [userIllegallyRequestNextPage] is called. If this call detects an illegal swipe, + * the respective listener [onNextPageRequestedListener] gets called. + */ + private fun canPerformTouchEvent(event: MotionEvent?): Boolean { + // If paging is disabled we should ignore any viewpager touch + // (also, not display any error message) + if (!isFullPagingEnabled || event == null) { + return false + } + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + currentTouchDownX = event.x + currentTouchDownY = event.y + } + else -> { + if (event.action == MotionEvent.ACTION_UP) { + viewPager.performClick() + } + val canRequestNextPage = onNextPageRequestedListener?.onCanRequestNextPage() ?: true + + // If user can't request the page, we shortcircuit the ACTION_MOVE logic here. + // We need to return false if we detect that the user swipes forward, + // and also call onIllegallyRequestedNextPage if the threshold was too high + // (so the user can be informed). + if (!canRequestNextPage && isSwipeForward(currentTouchDownX, event.x)) { + if (userIllegallyRequestNextPage()) { + onNextPageRequestedListener?.onIllegallyRequestedNextPage() + } + return false + } + + // If the slide contains permissions, check for forward swipe. + if (isPermissionSlide && isSwipeForward(currentTouchDownX, event.x)) { + onNextPageRequestedListener?.onUserRequestedPermissionsDialog() + } + } + } + + return isFullPagingEnabled + } + + private var lastTouchValue: Float = 0f + + /** + * Simulate touch events on the ViewPager2 using fakeDrag, since isUserInputEnabled = false + * We need this to eventually block user touches in forward if policy is not respected + */ + private fun handleOnTouchEvent(event: MotionEvent?): Boolean { + if (!canPerformTouchEvent(event)) { + return false + } + + // allow the slider to "work" left and right. + when (event?.action) { + MotionEvent.ACTION_DOWN -> { + lastTouchValue = event.x + if (!viewPager.isFakeDragging) { + viewPager.beginFakeDrag() + } + } + + MotionEvent.ACTION_MOVE -> { + val value = event.x + val delta = value - lastTouchValue + + viewPager.fakeDragBy(delta) + lastTouchValue = value + return true + } + + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { + viewPager.endFakeDrag() + } + } + return true + } + + /** + * Util function to check if the user swiped forward. + * The direction of forward is different in RTL mode. + */ + private fun isSwipeForward( + oldX: Float, + newX: Float, + ): Boolean { + with(viewPager) { + return (if (LayoutUtil.isRtl(context)) (newX > oldX) else (oldX > newX)) + } + } + + /** + * Util function to throttle illegallyRequestedNext to max one request per second. + */ + private fun userIllegallyRequestNextPage(): Boolean { + if (System.currentTimeMillis() - illegallyRequestedNextPageLastCalled >= + ON_ILLEGALLY_REQUESTED_NEXT_PAGE_MAX_INTERVAL + ) { + illegallyRequestedNextPageLastCalled = System.currentTimeMillis() + return true + } + + return false + } + + /** + * Disables ViewPager swipes and handles manually touch events. + * This is because we may want to discard some swipes in a particular direction + * if policy doesn't allow that. + * + * Touch events are then forwarded (if policy allows that) to pager + * using fakeDrag feature of ViewPager2. + */ + private fun addPagerTouchInterceptor() { + // disable ViewPager swipe + // touch events will be forwarded to gesture overlay to check policies + viewPager.isUserInputEnabled = false + + viewPagerGestureOverlay.addOnGestureListener( + object : OnGestureListener { + override fun onGestureStarted( + overlayView: GestureOverlayView?, + event: MotionEvent?, + ) { + handleOnTouchEvent(event) + } + + override fun onGesture( + overlayView: GestureOverlayView?, + event: MotionEvent?, + ) { + handleOnTouchEvent(event) + } + + override fun onGestureEnded( + overlayView: GestureOverlayView?, + event: MotionEvent?, + ) { + handleOnTouchEvent(event) + } + + override fun onGestureCancelled( + overlayView: GestureOverlayView?, + event: MotionEvent?, + ) { + handleOnTouchEvent(event) + } + }, + ) + } + + internal fun reCenterCurrentSlide() { + // Hacky way to force a recenter of the ViewPager to the current slide. + // We perform a page back and forward to recenter the ViewPager at the current position. + // This is needed as we're interrupting the user Swipe due to Permissions. + // If the user denies a permission, we want to recenter the slide. + with(viewPager) { + val item = currentItem + setCurrentViewPagerItem(max(item - 1, 0), false) + setCurrentViewPagerItem(item, false) + } + } + + internal fun registerOnPageChangeCallback(callback: AppIntroBase.OnPageChangeCallback) { + viewPager.registerOnPageChangeCallback(callback) + this.pageChangeCallback = callback + } + + fun setAppIntroPageTransformer(appIntroTransformer: AppIntroPageTransformerType) { + with(viewPager) { + setPageTransformer(ViewPagerTransformer(appIntroTransformer)) + } + } + + fun setPageTransformer(pageTransformer: ViewPager2.PageTransformer?) { + with(viewPager) { + setPageTransformer(pageTransformer) + } + } + + fun setOffscreenPageLimit(offscreenPageLimit: Int) { + viewPager.offscreenPageLimit = offscreenPageLimit + } + + fun setAdapter(pagerAdapter: PagerAdapter) { + viewPager.adapter = pagerAdapter + } + + fun post(function: () -> Unit) { + viewPager.post(function) + } + + fun getCurrentItem() = viewPager.currentItem + + private companion object { + private const val ON_ILLEGALLY_REQUESTED_NEXT_PAGE_MAX_INTERVAL = 1000 + } +} diff --git a/appintro/src/main/java/com/github/appintro/internal/CustomFontCache.kt b/appintro/src/main/java/com/github/appintro/internal/CustomFontCache.kt new file mode 100644 index 000000000..2f553741d --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/internal/CustomFontCache.kt @@ -0,0 +1,35 @@ +package com.github.appintro.internal + +import android.content.Context +import android.graphics.Typeface +import androidx.core.content.res.ResourcesCompat + +/** + * Custom Font Cache Implementation. + * Prevent(s) memory leaks due to Typeface objects + */ +internal object CustomFontCache { + private val TAG = LogHelper.makeLogTag(CustomFontCache::class) + private val cache = hashMapOf() + + fun getFont( + ctx: Context, + path: String?, + fontCallback: ResourcesCompat.FontCallback, + ) { + if (path.isNullOrEmpty()) { + LogHelper.w(TAG, "Empty typeface path provided!") + return + } + + cache[path]?.let { + // Cache hit! Return the typeface. + fontCallback.onFontRetrieved(it) + } ?: run { + // Cache miss! Create the typeface and store it. + val newTypeface = Typeface.createFromAsset(ctx.assets, path) + cache[path] = newTypeface + fontCallback.onFontRetrieved(newTypeface) + } + } +} diff --git a/appintro/src/main/java/com/github/appintro/internal/LayoutUtil.kt b/appintro/src/main/java/com/github/appintro/internal/LayoutUtil.kt new file mode 100644 index 000000000..d9d66cc31 --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/internal/LayoutUtil.kt @@ -0,0 +1,14 @@ +package com.github.appintro.internal + +import android.content.Context +import android.view.View + +/** + * Util object for interacting with Layouts + */ +internal object LayoutUtil { + @JvmStatic + fun isRtl(ctx: Context): Boolean { + return ctx.resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL + } +} diff --git a/appintro/src/main/java/com/github/appintro/internal/LogHelper.kt b/appintro/src/main/java/com/github/appintro/internal/LogHelper.kt new file mode 100644 index 000000000..0e8bb7031 --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/internal/LogHelper.kt @@ -0,0 +1,89 @@ +package com.github.appintro.internal + +import android.util.Log +import kotlin.reflect.KClass + +private const val LOG_PREFIX = "Log: " +private const val LOG_PREFIX_LENGTH = LOG_PREFIX.length +private const val MAX_LOG_TAG_LENGTH = 23 + +/** + * Helper object to interact with the Android [Log] class. + */ +internal object LogHelper { + /** + * Creates a tag for the logs from a [Class] + * Don't use this when obfuscating class names! + */ + @JvmStatic + fun makeLogTag(cls: Class<*>) = + LOG_PREFIX + + cutTagLength(cls.simpleName, MAX_LOG_TAG_LENGTH - LOG_PREFIX_LENGTH) + + /** + * Creates a tag for the logs from a [KClass] + * Don't use this when obfuscating class names! + */ + fun makeLogTag(cls: KClass<*>) = + LOG_PREFIX + + cutTagLength(cls.simpleName ?: "", MAX_LOG_TAG_LENGTH - LOG_PREFIX_LENGTH) + + private fun cutTagLength( + tag: String, + length: Int, + ): String { + return if (tag.length > length) { + tag.substring(0, length - 1) + } else { + tag + } + } + + @JvmStatic + fun d( + tag: String, + message: String, + ) = Log.d(tag, message) + + @JvmStatic + fun v( + tag: String, + message: String, + ) = Log.v(tag, message) + + @JvmStatic + fun i( + tag: String, + message: String, + ) = Log.i(tag, message) + + @JvmOverloads + @JvmStatic + fun w( + tag: String, + message: String, + throwable: Throwable? = null, + ) { + Log.w(tag, message, throwable) + } + + @JvmOverloads + @JvmStatic + fun e( + tag: String, + message: String, + throwable: Throwable? = null, + ) { + Log.e(tag, message, throwable) + } + + @JvmOverloads + @JvmStatic + fun wtf( + tag: String, + message: String, + throwable: Throwable? = null, + ) { + Log.wtf(tag, message, throwable) + } +} diff --git a/appintro/src/main/java/com/github/appintro/internal/PermissionWrapper.kt b/appintro/src/main/java/com/github/appintro/internal/PermissionWrapper.kt new file mode 100644 index 000000000..3d6e4a0b7 --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/internal/PermissionWrapper.kt @@ -0,0 +1,39 @@ +package com.github.appintro.internal + +import java.io.Serializable + +/** + * A data class that represents a set of permissions that should be requested to the user. + * @property permissions An Array of Permissions from the Android Framework + * @property position The position in the AppIntro pager when to request those permissions. + * @property required Whether the permission being requested needs to be granted to move forward. + */ +internal data class PermissionWrapper( + var permissions: Array, + var position: Int, + var required: Boolean = true, +) : Serializable { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PermissionWrapper + + if (!permissions.contentEquals(other.permissions)) return false + if (position != other.position) return false + if (required != other.required) return false + + return true + } + + override fun hashCode(): Int { + var result = permissions.contentHashCode() + result = 31 * result + position + result = 31 * result + required.hashCode() + return result + } + + companion object { + private const val serialVersionUID: Long = 1L + } +} diff --git a/appintro/src/main/java/com/github/appintro/internal/SavedStateHelper.kt b/appintro/src/main/java/com/github/appintro/internal/SavedStateHelper.kt new file mode 100644 index 000000000..83ee9e41e --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/internal/SavedStateHelper.kt @@ -0,0 +1,25 @@ +package com.github.appintro.internal + +import androidx.lifecycle.SavedStateHandle +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +inline fun SavedStateHandle.delegate(key: String? = null): ReadWriteProperty = + object : ReadWriteProperty { + override fun getValue( + thisRef: Any, + property: KProperty<*>, + ): T? { + val stateKey = key ?: property.name + return this@delegate[stateKey] + } + + override fun setValue( + thisRef: Any, + property: KProperty<*>, + value: T?, + ) { + val stateKey = key ?: property.name + this@delegate[stateKey] = value + } + } diff --git a/appintro/src/main/java/com/github/appintro/internal/TypefaceContainer.kt b/appintro/src/main/java/com/github/appintro/internal/TypefaceContainer.kt new file mode 100644 index 000000000..c5079cf43 --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/internal/TypefaceContainer.kt @@ -0,0 +1,52 @@ +package com.github.appintro.internal + +import android.graphics.Typeface +import android.widget.TextView +import androidx.annotation.FontRes +import androidx.core.content.res.ResourcesCompat + +/** + * Class for containing the Typefaces that can be used with AppIntro. + * Provide either a URL or a Resource. If you provide both, the URL will be ignored. + * + * @property typeFaceUrl A [String] which is an URL of a font found at in the /assets folder + * @property typeFaceResource An [Int] which is a @FontRes + */ +internal data class TypefaceContainer( + var typeFaceUrl: String? = null, + @FontRes var typeFaceResource: Int = 0, +) { + /** + * Applies typeface to a given TextView object. + * If there is no typeface (either URL or resource) set, this method is a no-op. + * + * @param textView The [TextView] where the Typeface will be applied + */ + fun applyTo(textView: TextView?) { + if (textView == null || textView.context == null) { + return + } + if (typeFaceUrl == null && typeFaceResource == 0) { + return + } + + // Callback to font retrieval + val callback = + object : ResourcesCompat.FontCallback() { + override fun onFontRetrievalFailed(reason: Int) { + // Don't be panic, just do nothing. + } + + override fun onFontRetrieved(typeface: Typeface) { + textView.typeface = typeface + } + } + + // We give priority to the FontRes here. + if (typeFaceResource != 0) { + ResourcesCompat.getFont(textView.context, typeFaceResource, callback, null) + } else { + CustomFontCache.getFont(textView.context, typeFaceUrl, callback) + } + } +} diff --git a/appintro/src/main/java/com/github/appintro/internal/VibrationHelper.kt b/appintro/src/main/java/com/github/appintro/internal/VibrationHelper.kt new file mode 100644 index 000000000..18ae80bea --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/internal/VibrationHelper.kt @@ -0,0 +1,43 @@ +package com.github.appintro.internal + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat.getSystemService + +object VibrationHelper { + private var vibrator: Vibrator? = null + + private fun initializeVibrator(context: Context) { + vibrator = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = + context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + vibratorManager.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(AppCompatActivity.VIBRATOR_SERVICE) as Vibrator + } + } + + // You must grant vibration permissions on your AndroidManifest.xml file + @SuppressLint("MissingPermission") + fun vibrate( + context: Context, + vibrateDuration: Long, + ) { + if (vibrator == null) { + initializeVibrator(context) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator?.vibrate(VibrationEffect.createOneShot(vibrateDuration, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + @Suppress("DEPRECATION") + vibrator?.vibrate(vibrateDuration) + } + } +} diff --git a/appintro/src/main/java/com/github/appintro/internal/viewpager/PagerAdapter.kt b/appintro/src/main/java/com/github/appintro/internal/viewpager/PagerAdapter.kt new file mode 100644 index 000000000..26b02fc32 --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/internal/viewpager/PagerAdapter.kt @@ -0,0 +1,24 @@ +package com.github.appintro.internal.viewpager + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import androidx.viewpager2.adapter.FragmentStateAdapter + +internal class PagerAdapter( + fragmentActivity: FragmentActivity, + private val fragments: MutableList, +) : FragmentStateAdapter(fragmentActivity) { + override fun getItemCount() = this.fragments.size + + override fun createFragment(position: Int): Fragment { + return fragments[position] + } + + fun getItem( + position: Int, + fragmentManager: FragmentManager, + ): Fragment? { + return fragmentManager.findFragmentByTag("f$position") + } +} diff --git a/appintro/src/main/java/com/github/appintro/internal/viewpager/ViewPagerTransformer.kt b/appintro/src/main/java/com/github/appintro/internal/viewpager/ViewPagerTransformer.kt new file mode 100644 index 000000000..26a2105d3 --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/internal/viewpager/ViewPagerTransformer.kt @@ -0,0 +1,185 @@ +package com.github.appintro.internal.viewpager + +import android.view.View +import androidx.viewpager2.widget.ViewPager2 +import com.github.appintro.AppIntroPageTransformerType +import com.github.appintro.internal.LogHelper +import kotlin.math.abs +import kotlin.math.max + +private const val MIN_SCALE_DEPTH = 0.75f +private const val MIN_SCALE_ZOOM = 0.85f +private const val MIN_ALPHA_ZOOM = 0.5f +private const val SCALE_FACTOR_SLIDE = 0.85f +private const val MIN_ALPHA_SLIDE = 0.35f + +private const val FLOW_ROTATION_ANGLE = -30f + +internal class ViewPagerTransformer( + private val transformType: AppIntroPageTransformerType, +) : ViewPager2.PageTransformer { + private var titlePF: Double = 0.0 + private var imagePF: Double = 0.0 + private var descriptionPF: Double = 0.0 + + override fun transformPage( + page: View, + position: Float, + ) { + when (transformType) { + AppIntroPageTransformerType.Flow -> { + page.rotationY = position * FLOW_ROTATION_ANGLE + } + AppIntroPageTransformerType.SlideOver -> transformSlideOver(position, page) + AppIntroPageTransformerType.Depth -> transformDepth(position, page) + AppIntroPageTransformerType.Zoom -> transformZoom(position, page) + AppIntroPageTransformerType.Fade -> transformFade(position, page) + is AppIntroPageTransformerType.Parallax -> { + titlePF = transformType.titleParallaxFactor + imagePF = transformType.imageParallaxFactor + descriptionPF = transformType.descriptionParallaxFactor + transformParallax( + position, + page, + transformType.titleViewId, + transformType.imageViewId, + transformType.descriptionViewId, + ) + } + } + } + + private fun transformParallax( + position: Float, + page: View, + titleViewId: Int, + imageViewId: Int, + descriptionViewId: Int, + ) { + if (position > -1 && position < 1) { + try { + applyParallax(page, position, titleViewId, titlePF, "title") + applyParallax(page, position, imageViewId, imagePF, "image") + applyParallax(page, position, descriptionViewId, descriptionPF, "description") + } catch (e: IllegalStateException) { + LogHelper.e(TAG, "Failed to apply parallax effect", e) + } + } + } + + private fun applyParallax( + page: View, + position: Float, + viewId: Int, + parallaxFactor: Double, + logLabel: String, + ) { + page.findViewById(viewId)?.let { + it.translationX = computeParallax(page, position, parallaxFactor) + } ?: { + LogHelper.e( + TAG, + "Could not parallax animate view '$logLabel' as " + + "the provided view ID can't be found in the layout", + ) + } + } + + private fun computeParallax( + page: View, + position: Float, + parallaxFactor: Double, + ): Float { + return (-position * (page.width / parallaxFactor)).toFloat() + } + + private fun transformFade( + position: Float, + page: View, + ) { + if (position <= -1.0f || position >= 1.0f) { + page.translationX = page.width.toFloat() + page.alpha = 0.0f + page.isClickable = false + } else if (position == 0.0f) { + page.translationX = 0.0f + page.alpha = 1.0f + page.isClickable = true + } else { + // position is between -1.0F & 0.0F OR 0.0F & 1.0F + page.translationX = page.width * -position + page.alpha = 1.0f - abs(position) + } + } + + private fun transformZoom( + position: Float, + page: View, + ) { + if (position >= -1 && position <= 1) { + page.scaleXY = max(MIN_SCALE_ZOOM, 1 - abs(position)) + page.alpha = MIN_ALPHA_ZOOM + (page.scaleXY - MIN_SCALE_ZOOM) / + (1 - MIN_SCALE_ZOOM) * (1 - MIN_ALPHA_ZOOM) + val vMargin = page.height * (1 - page.scaleXY) / 2 + val hMargin = page.width * (1 - page.scaleXY) / 2 + if (position < 0) { + page.translationX = hMargin - vMargin / 2 + } else { + page.translationX = -hMargin + vMargin / 2 + } + } else { + page.transformDefaults() + } + } + + private fun transformDepth( + position: Float, + page: View, + ) { + if (position > 0 && position < 1) { + // moving to the right + page.alpha = 1 - position + page.scaleXY = MIN_SCALE_DEPTH + (1 - MIN_SCALE_DEPTH) * (1 - abs(position)) + page.translationX = page.width * -position + } else { + page.transformDefaults() + } + } + + private fun transformSlideOver( + position: Float, + page: View, + ) { + if (position < 0 && position > -1) { + // this is the page to the left + page.scaleXY = abs(abs(position) - 1) * (1.0f - SCALE_FACTOR_SLIDE) + SCALE_FACTOR_SLIDE + page.alpha = max(MIN_ALPHA_SLIDE, 1 - abs(position)) + val pageWidth = page.width + val translateValue = position * -pageWidth + if (translateValue > -pageWidth) { + page.translationX = translateValue + } else { + page.translationX = 0f + } + } else { + page.transformDefaults() + } + } + + companion object { + private val TAG = LogHelper.makeLogTag(ViewPagerTransformer::class) + } +} + +private fun View.transformDefaults() { + this.alpha = 1f + this.scaleXY = 1f + this.translationX = 0f +} + +private var View.scaleXY: Float + get() = this.scaleX + set(value) { + this.scaleX = value + this.scaleY = value + } diff --git a/appintro/src/main/java/com/github/appintro/model/SliderPage.kt b/appintro/src/main/java/com/github/appintro/model/SliderPage.kt new file mode 100644 index 000000000..802d731e2 --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/model/SliderPage.kt @@ -0,0 +1,89 @@ +package com.github.appintro.model + +import android.os.Bundle +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.FontRes +import com.github.appintro.ARG_BG_COLOR +import com.github.appintro.ARG_BG_COLOR_RES +import com.github.appintro.ARG_BG_DRAWABLE +import com.github.appintro.ARG_DESC +import com.github.appintro.ARG_DESC_COLOR +import com.github.appintro.ARG_DESC_COLOR_RES +import com.github.appintro.ARG_DESC_TYPEFACE_RES +import com.github.appintro.ARG_DESC_TYPEFACE_URL +import com.github.appintro.ARG_DRAWABLE +import com.github.appintro.ARG_TITLE +import com.github.appintro.ARG_TITLE_COLOR +import com.github.appintro.ARG_TITLE_COLOR_RES +import com.github.appintro.ARG_TITLE_TYPEFACE_RES +import com.github.appintro.ARG_TITLE_TYPEFACE_URL + +/** + * Slide Page Model. + * + * This data class represent a single page that can be visualized with AppIntro. + */ +data class SliderPage + @JvmOverloads + constructor( + var title: CharSequence? = null, + var description: CharSequence? = null, + @DrawableRes var imageDrawable: Int? = null, + @ColorInt + @Deprecated( + "`backgroundColor` has been deprecated to support configuration changes", + ReplaceWith("backgroundColorRes"), + ) + var backgroundColor: Int? = null, + @ColorInt + @Deprecated( + "`titleColor` has been deprecated to support configuration changes", + ReplaceWith("titleColorRes"), + ) + var titleColor: Int? = null, + @ColorInt + @Deprecated( + "`descriptionColor` has been deprecated to support configuration changes", + ReplaceWith("descriptionColorRes"), + ) + var descriptionColor: Int? = null, + @ColorRes var backgroundColorRes: Int? = null, + @ColorRes var titleColorRes: Int? = null, + @ColorRes var descriptionColorRes: Int? = null, + @FontRes var titleTypefaceFontRes: Int? = null, + @FontRes var descriptionTypefaceFontRes: Int? = null, + var titleTypeface: String? = null, + var descriptionTypeface: String? = null, + @DrawableRes var backgroundDrawable: Int? = null, + ) { + val titleString: CharSequence? get() = title + val descriptionString: CharSequence? get() = description + + /** + * Util method to convert a [SliderPage] into an Android [Bundle]. + * This method will be used to pass the [SliderPage] to [AppIntroBaseFragment] implementations. + */ + @Suppress("DEPRECATION") + fun toBundle(): Bundle { + val newBundle = Bundle() + newBundle.putCharSequence(ARG_TITLE, this.titleString) + newBundle.putString(ARG_TITLE_TYPEFACE_URL, this.titleTypeface) + newBundle.putCharSequence(ARG_DESC, this.descriptionString) + newBundle.putString(ARG_DESC_TYPEFACE_URL, this.descriptionTypeface) + + this.titleTypefaceFontRes?.let { newBundle.putInt(ARG_TITLE_TYPEFACE_RES, it) } + this.titleColor?.let { newBundle.putInt(ARG_TITLE_COLOR, it) } + this.titleColorRes?.let { newBundle.putInt(ARG_TITLE_COLOR_RES, it) } + this.descriptionTypefaceFontRes?.let { newBundle.putInt(ARG_DESC_TYPEFACE_RES, it) } + this.descriptionColor?.let { newBundle.putInt(ARG_DESC_COLOR, it) } + this.descriptionColorRes?.let { newBundle.putInt(ARG_DESC_COLOR_RES, it) } + this.imageDrawable?.let { newBundle.putInt(ARG_DRAWABLE, it) } + this.backgroundColor?.let { newBundle.putInt(ARG_BG_COLOR, it) } + this.backgroundColorRes?.let { newBundle.putInt(ARG_BG_COLOR_RES, it) } + this.backgroundDrawable?.let { newBundle.putInt(ARG_BG_DRAWABLE, it) } + + return newBundle + } + } diff --git a/appintro/src/main/java/com/github/appintro/model/SliderPagerBuilder.kt b/appintro/src/main/java/com/github/appintro/model/SliderPagerBuilder.kt new file mode 100644 index 000000000..380b270f7 --- /dev/null +++ b/appintro/src/main/java/com/github/appintro/model/SliderPagerBuilder.kt @@ -0,0 +1,171 @@ +package com.github.appintro.model + +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.FontRes + +/** + * A builder to help creating [SliderPage] classes. + * Please use this class only in Java context. From Kotlin just create + * a [SliderPage] directly. + */ +class SliderPagerBuilder { + private var title: CharSequence? = null + + private var description: CharSequence? = null + + @DrawableRes + private var imageDrawable: Int? = null + + @ColorInt + private var backgroundColor: Int? = null + + @ColorRes + private var backgroundColorRes: Int? = null + + @ColorInt + private var titleColor: Int? = null + + @ColorRes + private var titleColorRes: Int? = null + + @ColorInt + private var descriptionColor: Int? = null + + @ColorRes + private var descriptionColorRes: Int? = null + + @FontRes + private var titleTypefaceFontRes: Int? = null + + @FontRes + private var descriptionTypefaceFontRes: Int? = null + + private var titleTypeface: String? = null + + private var descriptionTypeface: String? = null + + @DrawableRes + private var backgroundDrawable: Int? = null + + fun title(title: CharSequence): SliderPagerBuilder { + this.title = title + return this + } + + fun description(description: CharSequence): SliderPagerBuilder { + this.description = description + return this + } + + fun imageDrawable( + @DrawableRes imageDrawable: Int, + ): SliderPagerBuilder { + this.imageDrawable = imageDrawable + return this + } + + @Deprecated( + "`backgroundColor(...)` has been deprecated to support configuration changes", + ReplaceWith("backgroundColorRes(backgroundColor)"), + ) + fun backgroundColor( + @ColorInt backgroundColor: Int, + ): SliderPagerBuilder { + this.backgroundColor = backgroundColor + return this + } + + fun backgroundColorRes( + @ColorRes backgroundColorRes: Int, + ): SliderPagerBuilder { + this.backgroundColorRes = backgroundColorRes + return this + } + + @Deprecated( + "`titleColor(...)` has been deprecated to support configuration changes", + ReplaceWith("titleColorRes(titleColor)"), + ) + fun titleColor( + @ColorInt titleColor: Int, + ): SliderPagerBuilder { + this.titleColor = titleColor + return this + } + + fun titleColorRes( + @ColorRes titleColorRes: Int, + ): SliderPagerBuilder { + this.titleColorRes = titleColorRes + return this + } + + @Deprecated( + "`descriptionColor(...)` has been deprecated to support configuration changes", + ReplaceWith("descriptionColorRes(descriptionColor)"), + ) + fun descriptionColor( + @ColorInt descriptionColor: Int, + ): SliderPagerBuilder { + this.descriptionColor = descriptionColor + return this + } + + fun descriptionColorRes( + @ColorRes descriptionColorRes: Int, + ): SliderPagerBuilder { + this.descriptionColorRes = descriptionColorRes + return this + } + + fun titleTypefaceFontRes( + @FontRes titleTypefaceFontRes: Int, + ): SliderPagerBuilder { + this.titleTypefaceFontRes = titleTypefaceFontRes + return this + } + + fun descriptionTypefaceFontRes( + @FontRes descriptionTypefaceFontRes: Int, + ): SliderPagerBuilder { + this.descriptionTypefaceFontRes = descriptionTypefaceFontRes + return this + } + + fun titleTypeface(titleTypeface: String): SliderPagerBuilder { + this.titleTypeface = titleTypeface + return this + } + + fun descriptionTypeface(descriptionTypeface: String): SliderPagerBuilder { + this.descriptionTypeface = descriptionTypeface + return this + } + + fun backgroundDrawable( + @DrawableRes backgroundDrawable: Int, + ): SliderPagerBuilder { + this.backgroundDrawable = backgroundDrawable + return this + } + + fun build() = + SliderPage( + title = this.title, + description = this.description, + imageDrawable = this.imageDrawable, + backgroundColor = this.backgroundColor, + backgroundColorRes = this.backgroundColorRes, + titleColor = this.titleColor, + titleColorRes = this.titleColorRes, + descriptionColor = this.descriptionColor, + descriptionColorRes = this.descriptionColorRes, + titleTypefaceFontRes = this.titleTypefaceFontRes, + descriptionTypeface = this.descriptionTypeface, + titleTypeface = this.titleTypeface, + descriptionTypefaceFontRes = this.descriptionTypefaceFontRes, + backgroundDrawable = this.backgroundDrawable, + ) +} diff --git a/appintro/src/main/res/drawable/ic_appintro_arrow.xml b/appintro/src/main/res/drawable/ic_appintro_arrow.xml new file mode 100644 index 000000000..337f707be --- /dev/null +++ b/appintro/src/main/res/drawable/ic_appintro_arrow.xml @@ -0,0 +1,10 @@ + + + diff --git a/appintro/src/main/res/drawable/ic_appintro_done.xml b/appintro/src/main/res/drawable/ic_appintro_done.xml new file mode 100644 index 000000000..1f1f5b61f --- /dev/null +++ b/appintro/src/main/res/drawable/ic_appintro_done.xml @@ -0,0 +1,10 @@ + + + diff --git a/appintro/src/main/res/drawable/ic_appintro_fab_background.xml b/appintro/src/main/res/drawable/ic_appintro_fab_background.xml new file mode 100644 index 000000000..1b2eb728c --- /dev/null +++ b/appintro/src/main/res/drawable/ic_appintro_fab_background.xml @@ -0,0 +1,7 @@ + + + + diff --git a/appintro/src/main/res/drawable/ic_appintro_fab_done.xml b/appintro/src/main/res/drawable/ic_appintro_fab_done.xml new file mode 100644 index 000000000..4c04a9384 --- /dev/null +++ b/appintro/src/main/res/drawable/ic_appintro_fab_done.xml @@ -0,0 +1,9 @@ + + + + diff --git a/appintro/src/main/res/drawable/ic_appintro_fab_next.xml b/appintro/src/main/res/drawable/ic_appintro_fab_next.xml new file mode 100644 index 000000000..d0ee3cf79 --- /dev/null +++ b/appintro/src/main/res/drawable/ic_appintro_fab_next.xml @@ -0,0 +1,9 @@ + + + + diff --git a/appintro/src/main/res/drawable/ic_appintro_fab_selected.xml b/appintro/src/main/res/drawable/ic_appintro_fab_selected.xml new file mode 100644 index 000000000..99eff07e5 --- /dev/null +++ b/appintro/src/main/res/drawable/ic_appintro_fab_selected.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/appintro/src/main/res/drawable/ic_appintro_fab_skip.xml b/appintro/src/main/res/drawable/ic_appintro_fab_skip.xml new file mode 100644 index 000000000..db1286595 --- /dev/null +++ b/appintro/src/main/res/drawable/ic_appintro_fab_skip.xml @@ -0,0 +1,9 @@ + + + + diff --git a/appintro/src/main/res/drawable/ic_appintro_indicator.xml b/appintro/src/main/res/drawable/ic_appintro_indicator.xml new file mode 100644 index 000000000..30f193f34 --- /dev/null +++ b/appintro/src/main/res/drawable/ic_appintro_indicator.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/appintro/src/main/res/drawable/ic_appintro_next.xml b/appintro/src/main/res/drawable/ic_appintro_next.xml new file mode 100644 index 000000000..dcadec217 --- /dev/null +++ b/appintro/src/main/res/drawable/ic_appintro_next.xml @@ -0,0 +1,10 @@ + + + diff --git a/appintro/src/main/res/drawable/ic_appintro_ripple.xml b/appintro/src/main/res/drawable/ic_appintro_ripple.xml new file mode 100644 index 000000000..a194c18a0 --- /dev/null +++ b/appintro/src/main/res/drawable/ic_appintro_ripple.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/appintro/src/main/res/drawable/ic_appintro_skip.xml b/appintro/src/main/res/drawable/ic_appintro_skip.xml new file mode 100644 index 000000000..157ae482f --- /dev/null +++ b/appintro/src/main/res/drawable/ic_appintro_skip.xml @@ -0,0 +1,10 @@ + + + diff --git a/appintro/src/main/res/layout-land/appintro_fragment_intro.xml b/appintro/src/main/res/layout-land/appintro_fragment_intro.xml new file mode 100644 index 000000000..d1346da6e --- /dev/null +++ b/appintro/src/main/res/layout-land/appintro_fragment_intro.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + diff --git a/appintro/src/main/res/layout/appintro_fragment_intro.xml b/appintro/src/main/res/layout/appintro_fragment_intro.xml new file mode 100644 index 000000000..de8ca7f76 --- /dev/null +++ b/appintro/src/main/res/layout/appintro_fragment_intro.xml @@ -0,0 +1,51 @@ + + + + + + + + + + diff --git a/appintro/src/main/res/layout/appintro_intro_layout.xml b/appintro/src/main/res/layout/appintro_intro_layout.xml new file mode 100644 index 000000000..ef28eaf88 --- /dev/null +++ b/appintro/src/main/res/layout/appintro_intro_layout.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + +