commit 8fea656229d3db8e5a4acc9a7deaee4d072119e8 Author: minhhieu2312 Date: Thu Feb 26 10:39:42 2026 +0700 Commit đầu diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5057035 --- /dev/null +++ b/.gitignore @@ -0,0 +1,140 @@ +# Do not remove or rename entries in this file, only add new ones +# See https://github.com/flutter/flutter/issues/128635 for more context. + +# Miscellaneous +*.class +*.lock +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# Visual Studio Code related +.classpath +.project +.settings/ +.vscode/* + +# Flutter repo-specific +/bin/cache/ +/bin/internal/bootstrap.bat +/bin/internal/bootstrap.sh +/bin/mingit/ +/dev/benchmarks/mega_gallery/ +/dev/bots/.recipe_deps +/dev/bots/android_tools/ +/dev/devicelab/ABresults*.json +/dev/docs/doc/ +/dev/docs/api_docs.zip +/dev/docs/flutter.docs.zip +/dev/docs/lib/ +/dev/docs/pubspec.yaml +/dev/integration_tests/**/xcuserdata +/dev/integration_tests/**/Pods +/packages/flutter/coverage/ +version +analysis_benchmark.json + +# packages file containing multi-root paths +.packages.generated + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +**/generated_plugin_registrant.dart +.packages +.pub-preload-cache/ +.pub-cache/ +.pub/ +build/ +flutter_*.png +linked_*.ds +unlinked.ds +unlinked_spec.ds + +# Android related +**/android/**/gradle-wrapper.jar +.gradle/ +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java +**/android/key.properties +*.jks + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/.last_build_id +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# macOS +**/Flutter/ephemeral/ +**/Pods/ +**/macos/Flutter/GeneratedPluginRegistrant.swift +**/macos/Flutter/ephemeral +**/xcuserdata/ + +# Windows +**/windows/flutter/ephemeral/ +**/windows/flutter/generated_plugin_registrant.cc +**/windows/flutter/generated_plugin_registrant.h +**/windows/flutter/generated_plugins.cmake + +# Linux +**/linux/flutter/ephemeral/ +**/linux/flutter/generated_plugin_registrant.cc +**/linux/flutter/generated_plugin_registrant.h +**/linux/flutter/generated_plugins.cmake + +# Coverage +coverage/ + +# Symbols +app.*.symbols + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +!/dev/ci/**/Gemfile.lock +!.vscode/settings.json \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..be98e7f --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# baseproject + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook) + +For help getting started with Flutter, view our +[online documentation](https://flutter.dev/docs), which offers tutorials, +samples, guidance on mobile development, and a full API reference. + +## Cấu hình Environment NDK (Windows) + +- Khi build báo lỗi không tìm thấy **Android NDK Clang**, ngoài `local.properties` có thể thêm biến môi trường: + - `ANDROID_NDK_HOME=C:\Users\hieudm\AppData\Local\Android\Sdk\ndk\26.1.10909125` + - (Tuỳ chọn) `ANDROID_NDK_ROOT=C:\Users\hieudm\AppData\Local\Android\Sdk\ndk\26.1.10909125` + - Đảm bảo thư mục trên tồn tại và trùng với version NDK đã cài trong Android Studio. + +- Sau khi thêm biến môi trường, **đóng và mở lại** terminal/IDE rồi chạy: + ```bash + flutter doctor -v + flutter run + ``` + +## Cấu hình môi trường Android + +- **Java / JDK** + - Cài Android Studio mới nhất (kèm JDK 21/17). + - Dùng cùng JDK mà `flutter doctor -v` đang báo (không cần cấu hình thêm nếu đã OK). + +- **Android SDK & NDK** + - Mở **Android Studio** → `Settings` → `Android SDK` → tab **SDK Tools`. + - Bật **Android SDK Command-line Tools**, **CMake**, **NDK (Side by side)**. + - Bật **Show Package Details**, chọn NDK **26.1.10909125**. + +- **`android/local.properties`** + - File cần có dạng: + ```properties + sdk.dir=C:\\Users\\hieudm\\AppData\\Local\\Android\\Sdk + ndk.dir=C:\\Users\\hieudm\\AppData\\Local\\Android\\Sdk\\ndk\\26.1.10909125 + flutter.sdk=C:\\flutter + flutter.buildMode=debug + flutter.versionName=1.0.0 + flutter.versionCode=1 + ``` + - Thay `C:\\Users\\hieudm` nếu user Windows khác. + +- **`android/app/build.gradle` (Android)** + - Bên trong block `android { ... }` cần có: + ```groovy + android { + namespace "com.example.baseproject" + compileSdkVersion 36 + ndkVersion "26.1.10909125" + ... + } + ``` + +- **Lệnh tiện ích** + - Tạo splash: + ```bash + flutter pub run flutter_native_splash:create + ``` + - Chạy build runner: + ```bash + flutter pub run build_runner build --delete-conflicting-outputs + ``` + - Tạo app icon: + ```bash + flutter pub run flutter_launcher_icons:main + ``` + +## Ghi chú iOS + +- Khi gặp lỗi CocoaPods có thể thử: + ```bash + pod deintegrate + pod cache clean --all + pod install + ``` \ No newline at end of file diff --git a/alice/CHANGELOG.md b/alice/CHANGELOG.md new file mode 100644 index 0000000..fc83124 --- /dev/null +++ b/alice/CHANGELOG.md @@ -0,0 +1,189 @@ +## 0.2.4 +* Updated dependencies + +## 0.2.3 +* Updated dependencies + +## 0.2.2 +* Updated dependencies +* Changed default sort filter of create time from ascending to descending. This will show latest HTTP calls on top of the list. + +## 0.2.1 +* Added directionality support (by Abdol Hussain Mozaffari https://github.com/mozaffari) +* Updated dependencies (by https://github.com/Nyan274) + +## 0.2.0 +* Migrate to null safety (by https://github.com/ARIFCSE10) +* Updated Dio interceptor +* Updated dependencies + +## 0.1.12 +* Fixed query parameter issue not handled properly (by https://github.com/shreyas18jan). +* Removed shake dependency and added sensors dependency. Shake will be detected with sensors. +* Updated other dependencies. +* Added maxCallsCount which handles max number of calls stored in memory. +* Refactored notification text. +* Added sorting in inspector UI. +* Added additional chopper request error handling. + +## 0.1.11 +* Updated dependencies +* Lint fixes + +## 0.1.10 +* Lint update +* General refactor +* Dart format + +## 0.1.9 +* Lint update + +## 0.1.8 +* Lint update + +## 0.1.7 +* Updated dependencies + +## 0.1.6 +* Updated dependencies +* Removed unused android/ios native code +* Migrated example to v2 android + +## 0.1.5 +* Changed video_player and Chewie to Better Player. Better Player will be used to display videos. + +## 0.1.4 +* Updated texts in call details to be selectable +* Fixed general bugs +* Fixed video not disposed properly + +## 0.1.3 +* Updated documentation + +## 0.1.2 +* Updated dependencies +* Added documentation +* General refactor + +## 0.1.1 +* Removed sound in ios notification +* Upgraded local notification library + +## 0.1.0 +* Promoted to 0.1.0 +* Added Android/iOS dummy classes for pubdev score fix + +## 0.0.33 +* Fixed share issue + +## 0.0.32 +* Code style refactor + +## 0.0.31 +* Fixed file save path of iOS +* Fixed Stream request body + +## 0.0.30 +* Added better duration and bytes formatting + +## 0.0.29 +* Added possibility to add generic http call +* Refactored rendering of invalid body in application/json response + +## 0.0.28 +* Fixed rendering body responses of unknown content-type + +## 0.0.27 +* UI polishing +* File & email content polishing + +## 0.0.26 +* Added search support in calls screen +* Disabled notifications sound (by https://github.com/itsJoKr Josip Krnjic) + +## 0.0.25 +* Added notificationIcon parameter +* Added better notification handling +* Refactored codebase +* Added setNavigatorKey method +* Added FormData support for Dio requests + +## 0.0.24 +* Updated dependencies +* Prepare for 1.0.0 version of sensors and package_info. ([dart_lsc](https://github.com/amirh/dart_lsc)) + +## 0.0.23 +* Updated to dart 2.6.0 +* Added AliceHttpExtensions, AliceHttpClientExtensions + +## 0.0.22 +* Updated dependencies +* Refactored response page. If response is image or video, Alice will show it in response page. Large +body outputs will be not shown by default. There is a "Show body" button to show large output. + +## 0.0.21 +* Added Chopper support +* Added AndroidX support + +## 0.0.20 +* Updated dependencies + +## 0.0.19 +* Updated dependencies + +## 0.0.18 +* Added share option in call details. Share allows user to share curl of the request. (by: Praveenkumar Ramasamy https://github.com/pravinarr) + +## 0.0.17 +* Added shake option to open inspector from everywhere (by https://github.com/MattisBrizard MattisBrizard) +* Fixed double-encoding of request body if request body is a minified json (by https://github.com/knaeckeKami knaeckeKami) +* Added dark theme (idea by: https://github.com/Agondev Agondev) + +## 0.0.16 +* Fixed server text overflow + +## 0.0.15 +* Updated dependencies + +## 0.0.14 +* Fixed Dio API breaking change + +## 0.0.13 +* Updated dependencies +* Notification won't init when showNotification is off + +## 0.0.12 +* Updated flutter local notification dependency version +* Refactor + +## 0.0.11 +* Fixed iOS version issues (fixed by https://github.com/britannio Britannio Jarrett) + +## 0.0.10 +* Added stats feature +* Added save feature +* Added secured/not secured connection indicator in call list item +* Query parameters feature (Dio only) +* Fix for Uint8List SDK breaking change +* Updated dependencies +* Refactored code + +## 0.0.6 +* Fixed http/http package requests + +## 0.0.5 +* Updated dependencies +* Navigator key can be provided now from application (instead of using Alice's navigator key) + +## 0.0.4 +* Updated Kotlin version + +## 0.0.3 +* Removed gif from package + +## 0.0.2 +* Bug fixes + +## 0.0.1 + +* Initial release diff --git a/alice/LICENSE b/alice/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/alice/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + 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 [yyyy] [name of copyright owner] + + 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/alice/README.md b/alice/README.md new file mode 100644 index 0000000..bb53e96 --- /dev/null +++ b/alice/README.md @@ -0,0 +1,252 @@ +

+ +

+ +# Alice + +[![pub package](https://img.shields.io/pub/v/alice.svg)](https://pub.dartlang.org/packages/alice) +[![pub package](https://img.shields.io/github/license/jhomlala/alice.svg?style=flat)](https://github.com/jhomlala/alice) +[![pub package](https://img.shields.io/badge/platform-flutter-blue.svg)](https://github.com/jhomlala/alice) + +Alice is an HTTP Inspector tool for Flutter which helps debugging http requests. It catches and stores http requests and responses, which can be viewed via simple UI. It is inspired from [Chuck](https://github.com/jgilfelt/chuck) and [Chucker](https://github.com/ChuckerTeam/chucker). + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + +
+ +**Supported Dart http client plugins:** + +- Dio +- HttpClient from dart:io package +- Http from http/http package +- Chopper +- Generic HTTP client + +**Features:** +✔️ Detailed logs for each HTTP calls (HTTP Request, HTTP Response) +✔️ Inspector UI for viewing HTTP calls +✔️ Save HTTP calls to file +✔️ Statistics +✔️ Notification on HTTP call +✔️ Support for top used HTTP clients in Dart +✔️ Error handling +✔️ Shake to open inspector +✔️ HTTP calls search + +## Install + +1. Add this to your **pubspec.yaml** file: + +```yaml +dependencies: + alice: ^0.2.4 +``` + +2. Install it + +```bash +$ flutter packages get +``` + +3. Import it + +```dart +import 'package:alice/alice.dart'; +``` + +## Usage +### Alice configuration +1. Create Alice instance: + +```dart +Alice alice = Alice(); +``` + +2. Add navigator key to your application: + +```dart +MaterialApp( navigatorKey: alice.getNavigatorKey(), home: ...) +``` + +You need to add this navigator key in order to show inspector UI. +You can use also your navigator key in Alice: + +```dart +Alice alice = Alice(showNotification: true, navigatorKey: yourNavigatorKeyHere); +``` + +If you need to pass navigatorKey lazily, you can use: +```dart +alice.setNavigatorKey(yourNavigatorKeyHere); +``` +This is minimal configuration required to run Alice. Can set optional settings in Alice constructor, which are presented below. If you don't want to change anything, you can move to Http clients configuration. + +### Additional settings + +You can set `showNotification` in Alice constructor to show notification. Clicking on this notification will open inspector. +```dart +Alice alice = Alice(..., showNotification: true); +``` + +You can set `showInspectorOnShake` in Alice constructor to open inspector by shaking your device (default disabled): + +```dart +Alice alice = Alice(..., showInspectorOnShake: true); +``` + +If you want to use dark mode just add `darkTheme` flag: + +```dart +Alice alice = Alice(..., darkTheme: true); +``` + +If you want to pass another notification icon, you can use `notificationIcon` parameter. Default value is @mipmap/ic_launcher. +```dart +Alice alice = Alice(..., notificationIcon: "myNotificationIconResourceName"); +``` + +If you want to limit max numbers of HTTP calls saved in memory, you may use `maxCallsCount` parameter. + +```dart +Alice alice = Alice(..., maxCallsCount: 1000)); +``` + + +If you want to change the Directionality of Alice, you can use the `directionality` parameter. If the parameter is set to null, the Directionality of the app will be used. +```dart +Alice alice = Alice(..., directionality: TextDirection.ltr); +``` +### HTTP Client configuration +If you're using Dio, you just need to add interceptor. + +```dart +Dio dio = Dio(); +dio.interceptors.add(alice.getDioInterceptor()); +``` + + +If you're using HttpClient from dart:io package: + +```dart +httpClient + .getUrl(Uri.parse("https://jsonplaceholder.typicode.com/posts")) + .then((request) async { + alice.onHttpClientRequest(request); + var httpResponse = await request.close(); + var responseBody = await httpResponse.transform(utf8.decoder).join(); + alice.onHttpClientResponse(httpResponse, request, body: responseBody); + }); +``` + +If you're using http from http/http package: + +```dart +http.get('https://jsonplaceholder.typicode.com/posts').then((response) { + alice.onHttpResponse(response); +}); +``` + +If you're using Chopper. you need to add interceptor: + +```dart +chopper = ChopperClient( + interceptors: alice.getChopperInterceptor(), +); +``` + +If you have other HTTP client you can use generic http call interface: +```dart +AliceHttpCall aliceHttpCall = AliceHttpCall(id); +alice.addHttpCall(aliceHttpCall); +``` + +## Show inspector manually + +You may need that if you won't use shake or notification: + +```dart +alice.showInspector(); +``` + +## Saving calls + +Alice supports saving logs to your mobile device storage. In order to make save feature works, you need to add in your Android application manifest: + +```xml + +``` + +## Extensions +You can use extensions to shorten your http and http client code. This is optional, but may improve your codebase. +Example: +1. Import: +```dart +import 'package:alice/core/alice_http_client_extensions.dart'; +import 'package:alice/core/alice_http_extensions.dart'; +``` + +2. Use extensions: +```dart +http + .post('https://jsonplaceholder.typicode.com/posts', body: body) + .interceptWithAlice(alice, body: body); +``` + +```dart +httpClient + .postUrl(Uri.parse("https://jsonplaceholder.typicode.com/posts")) + .interceptWithAlice(alice, body: body, headers: Map()); +``` + + +## Example +See complete example here: https://github.com/jhomlala/alice/blob/master/example/lib/main.dart +To run project, you need to call this command in your terminal: +```bash +flutter pub run build_runner build --delete-conflicting-outputs +``` +You need to run this command to build Chopper generated classes. You should run this command only once, +you don't need to run this command each time before running project (unless you modify something in Chopper endpoints). +

+ +

diff --git a/alice/analysis_options.yaml b/alice/analysis_options.yaml new file mode 100644 index 0000000..c855bf7 --- /dev/null +++ b/alice/analysis_options.yaml @@ -0,0 +1,21 @@ +#include: package:lint/analysis_options.yaml + +analyzer: + strong-mode: + implicit-dynamic: false + exclude: + - "**/*.chopper.dart" + +linter: + rules: + close_sinks: true + sort_constructors_first: false + avoid_classes_with_only_static_members: false + avoid_void_async: false + avoid_positional_boolean_parameters: false + avoid_function_literals_in_foreach_calls: false + prefer_constructors_over_static_methods: false + sort_unnamed_constructors_first: false + sized_box_for_whitespace: false + invalid_dependency: false + sort_pub_dependencies: false \ No newline at end of file diff --git a/alice/lib/alice.dart b/alice/lib/alice.dart new file mode 100644 index 0000000..7ab539d --- /dev/null +++ b/alice/lib/alice.dart @@ -0,0 +1,113 @@ +import 'dart:io'; +import 'package:alice/core/alice_chopper_response_interceptor.dart'; +import 'package:alice/core/alice_http_adapter.dart'; +import 'package:alice/model/alice_http_call.dart'; + +import 'package:chopper/chopper.dart'; +import 'package:http/http.dart' as http; +import 'package:alice/core/alice_core.dart'; +import 'package:alice/core/alice_dio_interceptor.dart'; +import 'package:alice/core/alice_http_client_adapter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +class Alice { + /// Should user be notified with notification if there's new request catched + /// by Alice + final bool showNotification; + + /// Should inspector be opened on device shake (works only with physical + /// with sensors) + final bool showInspectorOnShake; + + /// Should inspector use dark theme + final bool darkTheme; + + /// Icon url for notification + final String notificationIcon; + + ///Max number of calls that are stored in memory. When count is reached, FIFO + ///method queue will be used to remove elements. + final int maxCallsCount; + + ///Directionality of app. Directionality of the app will be used if set to null. + final TextDirection? directionality; + + GlobalKey? _navigatorKey; + late AliceCore _aliceCore; + late AliceHttpClientAdapter _httpClientAdapter; + late AliceHttpAdapter _httpAdapter; + + /// Creates alice instance. + Alice({ + GlobalKey? navigatorKey, + this.showNotification = true, + this.showInspectorOnShake = false, + this.darkTheme = false, + this.notificationIcon = "@mipmap/ic_launcher", + this.maxCallsCount = 1000, + this.directionality, + }) { + _navigatorKey = navigatorKey ?? GlobalKey(); + _aliceCore = AliceCore( + _navigatorKey, + showNotification: showNotification, + showInspectorOnShake: showInspectorOnShake, + darkTheme: darkTheme, + notificationIcon: notificationIcon, + maxCallsCount: maxCallsCount, + directionality: directionality, + ); + _httpClientAdapter = AliceHttpClientAdapter(_aliceCore); + _httpAdapter = AliceHttpAdapter(_aliceCore); + } + + /// Set custom navigation key. This will help if there's route library. + void setNavigatorKey(GlobalKey navigatorKey) { + _navigatorKey = navigatorKey; + _aliceCore.navigatorKey = navigatorKey; + } + + /// Get currently used navigation key + GlobalKey? getNavigatorKey() { + return _navigatorKey; + } + + /// Get Dio interceptor which should be applied to Dio instance. + AliceDioInterceptor getDioInterceptor() { + return AliceDioInterceptor(_aliceCore); + } + + /// Handle request from HttpClient + void onHttpClientRequest(HttpClientRequest request, {dynamic body}) { + _httpClientAdapter.onRequest(request, body: body); + } + + /// Handle response from HttpClient + void onHttpClientResponse(HttpClientResponse response, HttpClientRequest request, {dynamic body}) { + _httpClientAdapter.onResponse(response, request, body: body); + } + + /// Handle both request and response from http package + void onHttpResponse(http.Response response, {dynamic body}) { + _httpAdapter.onResponse(response, body: body); + } + + /// Opens Http calls inspector. This will navigate user to the new fullscreen + /// page where all listened http calls can be viewed. + void showInspector() { + _aliceCore.navigateToCallListScreen(); + } + + /// Get chopper interceptor. This should be added to Chopper instance. + // List getChopperInterceptor() { + // return [AliceChopperInterceptor(_aliceCore)]; + // } + + /// Handle generic http call. Can be used to any http client. + void addHttpCall(AliceHttpCall aliceHttpCall) { + assert(aliceHttpCall.request != null, "Http call request can't be null"); + assert(aliceHttpCall.response != null, "Http call response can't be null"); + _aliceCore.addCall(aliceHttpCall); + } +} diff --git a/alice/lib/core/alice_chopper_response_interceptor.dart b/alice/lib/core/alice_chopper_response_interceptor.dart new file mode 100644 index 0000000..447d8cc --- /dev/null +++ b/alice/lib/core/alice_chopper_response_interceptor.dart @@ -0,0 +1,123 @@ +// import 'dart:async'; +// import 'dart:convert'; + +// import 'package:alice/core/alice_utils.dart'; +// import 'package:alice/model/alice_http_call.dart'; +// import 'package:alice/model/alice_http_request.dart'; +// import 'package:alice/model/alice_http_response.dart'; +// import 'package:chopper/chopper.dart' as chopper; +// import 'package:http/http.dart'; +// import 'alice_core.dart'; + +// class AliceChopperInterceptor extends chopper.ResponseInterceptor with chopper.RequestInterceptor { +// /// AliceCore instance +// final AliceCore aliceCore; + +// /// Creates instance of chopper interceptor +// AliceChopperInterceptor(this.aliceCore); + +// /// Creates hashcode based on request +// int getRequestHashCode(BaseRequest baseRequest) { +// int hashCodeSum = 0; +// hashCodeSum += baseRequest.url.hashCode; +// hashCodeSum += baseRequest.method.hashCode; +// if (baseRequest.headers.isNotEmpty) { +// baseRequest.headers.forEach((key, value) { +// hashCodeSum += key.hashCode; +// hashCodeSum += value.hashCode; +// }); +// } +// if (baseRequest.contentLength != null) { +// hashCodeSum += baseRequest.contentLength.hashCode; +// } + +// return hashCodeSum.hashCode; +// } + +// /// Handles chopper request and creates alice http call +// @override +// FutureOr onRequest(chopper.Request request) async { +// try { +// final baseRequest = await request.toBaseRequest(); +// final AliceHttpCall call = AliceHttpCall(getRequestHashCode(baseRequest)); +// String endpoint = ""; +// String server = ""; +// if (request.baseUrl.isEmpty) { +// final List split = request.url.split("/"); +// if (split.length > 2) { +// server = split[1] + split[2]; +// } +// if (split.length > 4) { +// endpoint = "/"; +// for (int splitIndex = 3; splitIndex < split.length; splitIndex++) { +// // ignore: use_string_buffers +// endpoint += "${split[splitIndex]}/"; +// } +// endpoint = endpoint.substring(0, endpoint.length - 1); +// } +// } else { +// endpoint = request.url; +// server = request.baseUrl; +// } + +// call.method = request.method; +// call.endpoint = endpoint; +// call.server = server; +// call.client = "Chopper"; +// if (request.baseUrl.contains("https") || request.url.contains("https")) { +// call.secure = true; +// } + +// final AliceHttpRequest aliceHttpRequest = AliceHttpRequest(); + +// if (request.body == null) { +// aliceHttpRequest.size = 0; +// aliceHttpRequest.body = ""; +// } else { +// aliceHttpRequest.size = utf8.encode(request.body as String).length; +// aliceHttpRequest.body = request.body; +// } +// aliceHttpRequest.time = DateTime.now(); +// aliceHttpRequest.headers = request.headers; + +// String? contentType = "unknown"; +// if (request.headers.containsKey("Content-Type")) { +// contentType = request.headers["Content-Type"]; +// } +// aliceHttpRequest.contentType = contentType; +// aliceHttpRequest.queryParameters = request.parameters; + +// call.request = aliceHttpRequest; +// call.response = AliceHttpResponse(); + +// aliceCore.addCall(call); +// } catch (exception) { +// AliceUtils.log(exception.toString()); +// } +// return request; +// } + +// /// Handles chopper response and adds data to existing alice http call +// @override +// FutureOr onResponse(chopper.Response response) { +// final httpResponse = AliceHttpResponse(); +// httpResponse.status = response.statusCode; +// if (response.body == null) { +// httpResponse.body = ""; +// httpResponse.size = 0; +// } else { +// httpResponse.body = response.body; +// httpResponse.size = utf8.encode(response.body.toString()).length; +// } + +// httpResponse.time = DateTime.now(); +// final Map headers = {}; +// response.headers.forEach((header, values) { +// headers[header] = values.toString(); +// }); +// httpResponse.headers = headers; + +// aliceCore.addResponse(httpResponse, getRequestHashCode(response.base.request!)); +// return response; +// } +// } diff --git a/alice/lib/core/alice_core.dart b/alice/lib/core/alice_core.dart new file mode 100644 index 0000000..abacc2c --- /dev/null +++ b/alice/lib/core/alice_core.dart @@ -0,0 +1,264 @@ +import 'dart:async'; + +import 'package:alice/core/alice_utils.dart'; +import 'package:alice/helper/alice_save_helper.dart'; +import 'package:alice/model/alice_http_error.dart'; +import 'package:alice/model/alice_http_call.dart'; +import 'package:alice/model/alice_http_response.dart'; +import 'package:alice/ui/page/alice_calls_list_screen.dart'; +import 'package:alice/utils/shake_detector.dart'; +import 'package:collection/collection.dart' show IterableExtension; +import 'package:flutter/material.dart'; +// import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:rxdart/rxdart.dart'; + +class AliceCore { + /// Should user be notified with notification if there's new request catched + /// by Alice + final bool showNotification; + + /// Should inspector be opened on device shake (works only with physical + /// with sensors) + final bool showInspectorOnShake; + + /// Should inspector use dark theme + final bool darkTheme; + + /// Rx subject which contains all intercepted http calls + final BehaviorSubject> callsSubject = BehaviorSubject.seeded([]); + + /// Icon url for notification + final String notificationIcon; + + ///Max number of calls that are stored in memory. When count is reached, FIFO + ///method queue will be used to remove elements. + final int maxCallsCount; + + ///Directionality of app. If null then directionality of context will be used. + final TextDirection? directionality; + + // late FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin; + GlobalKey? navigatorKey; + Brightness _brightness = Brightness.light; + bool _isInspectorOpened = false; + ShakeDetector? _shakeDetector; + StreamSubscription? _callsSubscription; + String? _notificationMessage; + String? _notificationMessageShown; + bool _notificationProcessing = false; + + /// Creates alice core instance + AliceCore( + this.navigatorKey, { + required this.showNotification, + required this.showInspectorOnShake, + required this.darkTheme, + required this.notificationIcon, + required this.maxCallsCount, + this.directionality, + }) { + if (showNotification) { + _initializeNotificationsPlugin(); + _callsSubscription = callsSubject.listen((_) => _onCallsChanged()); + } + if (showInspectorOnShake) { + _shakeDetector = ShakeDetector.autoStart( + onPhoneShake: () { + navigateToCallListScreen(); + }, + shakeThresholdGravity: 5, + ); + } + _brightness = darkTheme ? Brightness.dark : Brightness.light; + } + + /// Dispose subjects and subscriptions + void dispose() { + callsSubject.close(); + _shakeDetector?.stopListening(); + _callsSubscription?.cancel(); + } + + /// Get currently used brightness + Brightness get brightness => _brightness; + + void _initializeNotificationsPlugin() { + // _flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + // final initializationSettingsAndroid = AndroidInitializationSettings(notificationIcon); + // const initializationSettingsIOS = IOSInitializationSettings(); + // final initializationSettings = + // InitializationSettings(android: initializationSettingsAndroid, iOS: initializationSettingsIOS); + // _flutterLocalNotificationsPlugin.initialize(initializationSettings, onSelectNotification: _onSelectedNotification); + } + + void _onCallsChanged() async { + if (callsSubject.value.isNotEmpty) { + _notificationMessage = _getNotificationMessage(); + if (_notificationMessage != _notificationMessageShown && !_notificationProcessing) { + // await _showLocalNotification(); + _onCallsChanged(); + } + } + } + + Future _onSelectedNotification(String? payload) async { + assert(payload != null, "payload can't be null"); + navigateToCallListScreen(); + return; + } + + /// Opens Http calls inspector. This will navigate user to the new fullscreen + /// page where all listened http calls can be viewed. + void navigateToCallListScreen() { + final context = getContext(); + if (context == null) { + AliceUtils.log("Cant start Alice HTTP Inspector. Please add NavigatorKey to your application"); + return; + } + if (!_isInspectorOpened) { + _isInspectorOpened = true; + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AliceCallsListScreen(this), + ), + ).then((onValue) => _isInspectorOpened = false); + } + } + + /// Get context from navigator key. Used to open inspector route. + BuildContext? getContext() => navigatorKey?.currentState?.overlay?.context; + + String _getNotificationMessage() { + final List calls = callsSubject.value; + final int successCalls = calls + .where((call) => call.response != null && call.response!.status! >= 200 && call.response!.status! < 300) + .toList() + .length; + + final int redirectCalls = calls + .where((call) => call.response != null && call.response!.status! >= 300 && call.response!.status! < 400) + .toList() + .length; + + final int errorCalls = calls + .where((call) => call.response != null && call.response!.status! >= 400 && call.response!.status! < 600) + .toList() + .length; + + final int loadingCalls = calls.where((call) => call.loading).toList().length; + + final StringBuffer notificationsMessage = StringBuffer(); + if (loadingCalls > 0) { + notificationsMessage.write("Loading: $loadingCalls"); + notificationsMessage.write(" | "); + } + if (successCalls > 0) { + notificationsMessage.write("Success: $successCalls"); + notificationsMessage.write(" | "); + } + if (redirectCalls > 0) { + notificationsMessage.write("Redirect: $redirectCalls"); + notificationsMessage.write(" | "); + } + if (errorCalls > 0) { + notificationsMessage.write("Error: $errorCalls"); + } + String notificationMessageString = notificationsMessage.toString(); + if (notificationMessageString.endsWith(" | ")) { + notificationMessageString = notificationMessageString.substring(0, notificationMessageString.length - 3); + } + + return notificationMessageString; + } + + // Future _showLocalNotification() async { + // _notificationProcessing = true; + // const channelId = "Alice"; + // const channelName = "Alice"; + // const channelDescription = "Alice"; + // final androidPlatformChannelSpecifics = AndroidNotificationDetails( + // channelId, channelName, channelDescription, + // enableVibration: false, + // playSound: false, + // largeIcon: DrawableResourceAndroidBitmap(notificationIcon)); + // const iOSPlatformChannelSpecifics = + // IOSNotificationDetails(presentSound: false); + // final platformChannelSpecifics = NotificationDetails( + // android: androidPlatformChannelSpecifics, + // iOS: iOSPlatformChannelSpecifics); + // final String? message = _notificationMessage; + // await _flutterLocalNotificationsPlugin.show( + // 0, + // "Alice (total: ${callsSubject.value.length} requests)", + // message, + // platformChannelSpecifics, + // payload: ""); + // _notificationMessageShown = message; + // _notificationProcessing = false; + // return; + // } + + /// Add alice http call to calls subject + void addCall(AliceHttpCall call) { + final callsCount = callsSubject.value.length; + if (callsCount >= maxCallsCount) { + final originalCalls = callsSubject.value; + final calls = List.from(originalCalls); + calls.sort((call1, call2) => call1.createdTime.compareTo(call2.createdTime)); + final indexToReplace = originalCalls.indexOf(calls.first); + originalCalls[indexToReplace] = call; + + callsSubject.add(originalCalls); + } else { + callsSubject.add([...callsSubject.value, call]); + } + } + + /// Add error to existing alice http call + void addError(AliceHttpError error, int requestId) { + final AliceHttpCall? selectedCall = _selectCall(requestId); + + if (selectedCall == null) { + AliceUtils.log("Selected call is null"); + return; + } + + selectedCall.error = error; + callsSubject.add([...callsSubject.value]); + } + + /// Add response to existing alice http call + void addResponse(AliceHttpResponse response, int requestId) { + final AliceHttpCall? selectedCall = _selectCall(requestId); + + if (selectedCall == null) { + AliceUtils.log("Selected call is null"); + return; + } + selectedCall.loading = false; + selectedCall.response = response; + selectedCall.duration = response.time.millisecondsSinceEpoch - selectedCall.request!.time.millisecondsSinceEpoch; + + callsSubject.add([...callsSubject.value]); + } + + /// Add alice http call to calls subject + void addHttpCall(AliceHttpCall aliceHttpCall) { + assert(aliceHttpCall.request != null, "Http call request can't be null"); + assert(aliceHttpCall.response != null, "Http call response can't be null"); + callsSubject.add([...callsSubject.value, aliceHttpCall]); + } + + /// Remove all calls from calls subject + void removeCalls() { + callsSubject.add([]); + } + + AliceHttpCall? _selectCall(int requestId) => callsSubject.value.firstWhereOrNull((call) => call.id == requestId); + + /// Save all calls to file + void saveHttpRequests(BuildContext context) { + AliceSaveHelper.saveCalls(context, callsSubject.value, _brightness); + } +} diff --git a/alice/lib/core/alice_dio_interceptor.dart b/alice/lib/core/alice_dio_interceptor.dart new file mode 100644 index 0000000..e1fda9b --- /dev/null +++ b/alice/lib/core/alice_dio_interceptor.dart @@ -0,0 +1,144 @@ +import 'dart:convert'; + +import 'package:alice/core/alice_core.dart'; +import 'package:alice/model/alice_form_data_file.dart'; +import 'package:alice/model/alice_from_data_field.dart'; +import 'package:alice/model/alice_http_call.dart'; +import 'package:alice/model/alice_http_error.dart'; +import 'package:alice/model/alice_http_request.dart'; +import 'package:alice/model/alice_http_response.dart'; +import 'package:dio/dio.dart'; + +class AliceDioInterceptor extends InterceptorsWrapper { + /// AliceCore instance + final AliceCore aliceCore; + + /// Creates dio interceptor + AliceDioInterceptor(this.aliceCore); + + /// Handles dio request and creates alice http call based on it + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + final AliceHttpCall call = AliceHttpCall(options.hashCode); + + final Uri uri = options.uri; + call.method = options.method; + var path = options.uri.path; + if (path.isEmpty) { + path = "/"; + } + call.endpoint = path; + call.server = uri.host; + call.client = "Dio"; + call.uri = options.uri.toString(); + + if (uri.scheme == "https") { + call.secure = true; + } + + final AliceHttpRequest request = AliceHttpRequest(); + + final dynamic data = options.data; + if (data == null) { + request.size = 0; + request.body = ""; + } else { + if (data is FormData) { + request.body += "Form data"; + + if (data.fields.isNotEmpty == true) { + final List fields = []; + data.fields.forEach((entry) { + fields.add(AliceFormDataField(entry.key, entry.value)); + }); + request.formDataFields = fields; + } + if (data.files.isNotEmpty == true) { + final List files = []; + data.files.forEach((entry) { + files.add(AliceFormDataFile(entry.value.filename, + entry.value.contentType.toString(), entry.value.length)); + }); + + request.formDataFiles = files; + } + } else { + request.size = utf8.encode(data.toString()).length; + request.body = data; + } + } + + request.time = DateTime.now(); + request.headers = options.headers; + request.contentType = options.contentType.toString(); + request.queryParameters = options.queryParameters; + + call.request = request; + call.response = AliceHttpResponse(); + + aliceCore.addCall(call); + handler.next(options); + } + + /// Handles dio response and adds data to alice http call + @override + void onResponse(Response response, ResponseInterceptorHandler handler) { + final httpResponse = AliceHttpResponse(); + httpResponse.status = response.statusCode; + + if (response.data == null) { + httpResponse.body = ""; + httpResponse.size = 0; + } else { + httpResponse.body = response.data; + httpResponse.size = utf8.encode(response.data.toString()).length; + } + + httpResponse.time = DateTime.now(); + final Map headers = {}; + response.headers.forEach((header, values) { + headers[header] = values.toString(); + }); + httpResponse.headers = headers; + + aliceCore.addResponse(httpResponse, response.requestOptions.hashCode); + handler.next(response); + } + + /// Handles error and adds data to alice http call + @override + void onError(DioError error, ErrorInterceptorHandler handler) { + final httpError = AliceHttpError(); + httpError.error = error.toString(); + if (error is Error) { + final basicError = error as Error; + httpError.stackTrace = basicError.stackTrace; + } + + aliceCore.addError(httpError, error.requestOptions.hashCode); + final httpResponse = AliceHttpResponse(); + httpResponse.time = DateTime.now(); + if (error.response == null) { + httpResponse.status = -1; + aliceCore.addResponse(httpResponse, error.requestOptions.hashCode); + } else { + httpResponse.status = error.response!.statusCode; + + if (error.response!.data == null) { + httpResponse.body = ""; + httpResponse.size = 0; + } else { + httpResponse.body = error.response!.data; + httpResponse.size = utf8.encode(error.response!.data.toString()).length; + } + final Map headers = {}; + error.response!.headers.forEach((header, values) { + headers[header] = values.toString(); + }); + httpResponse.headers = headers; + aliceCore.addResponse( + httpResponse, error.response!.requestOptions.hashCode); + } + handler.next(error); + } +} diff --git a/alice/lib/core/alice_http_adapter.dart b/alice/lib/core/alice_http_adapter.dart new file mode 100644 index 0000000..9f508be --- /dev/null +++ b/alice/lib/core/alice_http_adapter.dart @@ -0,0 +1,89 @@ +import 'dart:convert'; + +import 'package:alice/core/alice_core.dart'; +import 'package:alice/model/alice_http_call.dart'; +import 'package:alice/model/alice_http_request.dart'; +import 'package:alice/model/alice_http_response.dart'; +import 'package:http/http.dart' as http; + +class AliceHttpAdapter { + /// AliceCore instance + final AliceCore aliceCore; + + /// Creates alice http adapter + AliceHttpAdapter(this.aliceCore); + + /// Handles http response. It creates both request and response from http call + void onResponse(http.Response response, {dynamic body}) { + if (response.request == null) { + return; + } + final request = response.request!; + + final AliceHttpCall call = AliceHttpCall(response.request.hashCode); + call.loading = true; + call.client = "HttpClient (http package)"; + call.uri = request.url.toString(); + call.method = request.method; + var path = request.url.path; + if (path.isEmpty) { + path = "/"; + } + call.endpoint = path; + + call.server = request.url.host; + if (request.url.scheme == "https") { + call.secure = true; + } + + final AliceHttpRequest httpRequest = AliceHttpRequest(); + + if (response.request is http.Request) { + // we are guaranteed` the existence of body and headers + if (body != null) { + httpRequest.body = body; + } + // ignore: cast_nullable_to_non_nullable + httpRequest.body = body ?? (response.request as http.Request).body ?? ""; + httpRequest.size = utf8.encode(httpRequest.body.toString()).length; + httpRequest.headers = + Map.from(response.request!.headers); + } else if (body == null) { + httpRequest.size = 0; + httpRequest.body = ""; + } else { + httpRequest.size = utf8.encode(body.toString()).length; + httpRequest.body = body; + } + + httpRequest.time = DateTime.now(); + + String? contentType = "unknown"; + if (httpRequest.headers.containsKey("Content-Type")) { + contentType = httpRequest.headers["Content-Type"] as String?; + } + + httpRequest.contentType = contentType; + + httpRequest.queryParameters = response.request!.url.queryParameters; + + final AliceHttpResponse httpResponse = AliceHttpResponse(); + httpResponse.status = response.statusCode; + httpResponse.body = response.body; + + httpResponse.size = utf8.encode(response.body.toString()).length; + httpResponse.time = DateTime.now(); + final Map responseHeaders = {}; + response.headers.forEach((header, values) { + responseHeaders[header] = values.toString(); + }); + httpResponse.headers = responseHeaders; + + call.request = httpRequest; + call.response = httpResponse; + + call.loading = false; + call.duration = 0; + aliceCore.addCall(call); + } +} diff --git a/alice/lib/core/alice_http_client_adapter.dart b/alice/lib/core/alice_http_client_adapter.dart new file mode 100644 index 0000000..eddbb92 --- /dev/null +++ b/alice/lib/core/alice_http_client_adapter.dart @@ -0,0 +1,84 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:alice/core/alice_core.dart'; +import 'package:alice/model/alice_http_call.dart'; +import 'package:alice/model/alice_http_request.dart'; +import 'package:alice/model/alice_http_response.dart'; + +class AliceHttpClientAdapter { + /// AliceCore instance + final AliceCore aliceCore; + + /// Creates alice http client adapter + AliceHttpClientAdapter(this.aliceCore); + + /// Handles httpClientRequest and creates http alice call from it + void onRequest(HttpClientRequest request, {dynamic body}) { + final AliceHttpCall call = AliceHttpCall(request.hashCode); + call.loading = true; + call.client = "HttpClient (io package)"; + call.method = request.method; + call.uri = request.uri.toString(); + + var path = request.uri.path; + if (path.isEmpty) { + path = "/"; + } + + call.endpoint = path; + call.server = request.uri.host; + if (request.uri.scheme == "https") { + call.secure = true; + } + final AliceHttpRequest httpRequest = AliceHttpRequest(); + if (body == null) { + httpRequest.size = 0; + httpRequest.body = ""; + } else { + httpRequest.size = utf8.encode(body.toString()).length; + httpRequest.body = body; + } + httpRequest.time = DateTime.now(); + final Map headers = {}; + + httpRequest.headers.forEach((header, dynamic value) { + headers[header] = value; + }); + + httpRequest.headers = headers; + String? contentType = "unknown"; + if (headers.containsKey("Content-Type")) { + contentType = headers["Content-Type"] as String?; + } + + httpRequest.contentType = contentType; + httpRequest.cookies = request.cookies; + + call.request = httpRequest; + call.response = AliceHttpResponse(); + aliceCore.addCall(call); + } + + /// Handles httpClientRequest and adds response to http alice call + void onResponse(HttpClientResponse response, HttpClientRequest request, + {dynamic body}) async { + final AliceHttpResponse httpResponse = AliceHttpResponse(); + httpResponse.status = response.statusCode; + + if (body != null) { + httpResponse.body = body; + httpResponse.size = utf8.encode(body.toString()).length; + } else { + httpResponse.body = ""; + httpResponse.size = 0; + } + httpResponse.time = DateTime.now(); + final Map headers = {}; + response.headers.forEach((header, values) { + headers[header] = values.toString(); + }); + httpResponse.headers = headers; + aliceCore.addResponse(httpResponse, request.hashCode); + } +} diff --git a/alice/lib/core/alice_http_client_extensions.dart b/alice/lib/core/alice_http_client_extensions.dart new file mode 100644 index 0000000..5f91bfd --- /dev/null +++ b/alice/lib/core/alice_http_client_extensions.dart @@ -0,0 +1,29 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:alice/alice.dart'; + +extension AliceHttpClientExtensions on Future { + /// Intercept http client with alice. This extension method provides additional + /// helpful method to intercept httpClientResponse. + Future interceptWithAlice(Alice alice, + {dynamic body, Map? headers}) async { + final HttpClientRequest request = await this; + if (body != null) { + request.write(body); + } + if (headers != null) { + headers.forEach( + (String key, dynamic value) { + request.headers.add(key, value as Object); + }, + ); + } + alice.onHttpClientRequest(request, body: body); + final httpResponse = await request.close(); + final responseBody = await utf8.decoder.bind(httpResponse).join(); + alice.onHttpClientResponse(httpResponse, request, body: responseBody); + return httpResponse; + } +} diff --git a/alice/lib/core/alice_http_extensions.dart b/alice/lib/core/alice_http_extensions.dart new file mode 100644 index 0000000..0f1d850 --- /dev/null +++ b/alice/lib/core/alice_http_extensions.dart @@ -0,0 +1,12 @@ +import 'package:alice/alice.dart'; +import 'package:http/http.dart'; + +extension AliceHttpExtensions on Future { + /// Intercept http request with alice. This extension method provides additional + /// helpful method to intercept https' response. + Future interceptWithAlice(Alice alice, {dynamic body}) async { + final Response response = await this; + alice.onHttpResponse(response, body: body); + return response; + } +} diff --git a/alice/lib/core/alice_utils.dart b/alice/lib/core/alice_utils.dart new file mode 100644 index 0000000..b691acc --- /dev/null +++ b/alice/lib/core/alice_utils.dart @@ -0,0 +1,11 @@ +import 'package:flutter/foundation.dart'; + +///Utils used across multiple classes in app. +class AliceUtils { + static void log(String logMessage) { + if (!kReleaseMode) { + // ignore: avoid_print + print(logMessage); + } + } +} diff --git a/alice/lib/helper/alice_alert_helper.dart b/alice/lib/helper/alice_alert_helper.dart new file mode 100644 index 0000000..a293d82 --- /dev/null +++ b/alice/lib/helper/alice_alert_helper.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +class AliceAlertHelper { + ///Helper method used to open alarm with given title and description. + static void showAlert( + BuildContext context, + String title, + String description, { + String firstButtonTitle = "Accept", + String? secondButtonTitle, + Function? firstButtonAction, + Function? secondButtonAction, + Brightness? brightness, + }) { + final List actions = []; + actions.add( + TextButton( + onPressed: () { + if (firstButtonAction != null) { + firstButtonAction(); + } + Navigator.of(context).pop(); + }, + child: Text(firstButtonTitle), + ), + ); + if (secondButtonTitle != null) { + actions.add( + TextButton( + onPressed: () { + if (secondButtonAction != null) { + secondButtonAction(); + } + Navigator.of(context).pop(); + }, + child: Text(secondButtonTitle), + ), + ); + } + showDialog( + context: context, + builder: (BuildContext buildContext) { + return Theme( + data: ThemeData( + brightness: brightness ?? Brightness.light, + ), + child: AlertDialog( + title: Text(title), + content: Text(description), + actions: actions, + ), + ); + }, + ); + } +} diff --git a/alice/lib/helper/alice_conversion_helper.dart b/alice/lib/helper/alice_conversion_helper.dart new file mode 100644 index 0000000..e16e2b0 --- /dev/null +++ b/alice/lib/helper/alice_conversion_helper.dart @@ -0,0 +1,41 @@ +class AliceConversionHelper { + static const int _kilobyteAsByte = 1000; + static const int _megabyteAsByte = 1000000; + static const int _secondAsMillisecond = 1000; + static const int _minuteAsMillisecond = 60000; + + /// Format bytes text + static String formatBytes(int bytes) { + if (bytes < 0) { + return "-1 B"; + } + if (bytes <= _kilobyteAsByte) { + return "$bytes B"; + } + if (bytes <= _megabyteAsByte) { + return "${_formatDouble(bytes / _kilobyteAsByte)} kB"; + } + + return "${_formatDouble(bytes / _megabyteAsByte)} MB"; + } + + static String _formatDouble(double value) => value.toStringAsFixed(2); + + /// Format time in milliseconds + static String formatTime(int timeInMillis) { + if (timeInMillis < 0) { + return "-1 ms"; + } + if (timeInMillis <= _secondAsMillisecond) { + return "$timeInMillis ms"; + } + if (timeInMillis <= _minuteAsMillisecond) { + return "${_formatDouble(timeInMillis / _secondAsMillisecond)} s"; + } + + final Duration duration = Duration(milliseconds: timeInMillis); + + return "${duration.inMinutes} min ${duration.inSeconds.remainder(60)} s " + "${duration.inMilliseconds.remainder(1000)} ms"; + } +} diff --git a/alice/lib/helper/alice_save_helper.dart b/alice/lib/helper/alice_save_helper.dart new file mode 100644 index 0000000..e9aeed7 --- /dev/null +++ b/alice/lib/helper/alice_save_helper.dart @@ -0,0 +1,149 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:alice/core/alice_utils.dart'; +import 'package:alice/helper/alice_alert_helper.dart'; +import 'package:alice/helper/alice_conversion_helper.dart'; +import 'package:alice/model/alice_http_call.dart'; +import 'package:alice/utils/alice_parser.dart'; +import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; + +class AliceSaveHelper { + static const JsonEncoder _encoder = JsonEncoder.withIndent(' '); + + /// Top level method used to save calls to file + static void saveCalls(BuildContext context, List calls, Brightness brightness) { + _checkPermissions(context, calls, brightness); + } + + static void _checkPermissions(BuildContext context, List calls, Brightness brightness) async { + final status = await Permission.storage.status; + if (status.isGranted) { + _saveToFile(context, calls, brightness); + } else { + final status = await Permission.storage.request(); + + if (status.isGranted) { + _saveToFile(context, calls, brightness); + } else { + AliceAlertHelper.showAlert(context, "Permission error", "Permission not granted. Couldn't save logs.", + brightness: brightness); + } + } + } + + static Future _saveToFile(BuildContext context, List calls, Brightness brightness) async { + try { + if (calls.isEmpty) { + AliceAlertHelper.showAlert(context, "Error", "There are no logs to save", brightness: brightness); + return ""; + } + final bool isAndroid = Platform.isAndroid; + + final Directory externalDir = + await (isAndroid ? getExternalStorageDirectory() as FutureOr : getApplicationDocumentsDirectory()); + final String fileName = "alice_log_${DateTime.now().millisecondsSinceEpoch}.txt"; + final File file = File("${externalDir.path}/$fileName"); + file.createSync(); + final IOSink sink = file.openWrite(mode: FileMode.append); + sink.write(await _buildAliceLog()); + calls.forEach((AliceHttpCall call) { + sink.write(_buildCallLog(call)); + }); + await sink.flush(); + await sink.close(); + AliceAlertHelper.showAlert(context, "Success", "Successfully saved logs in ${file.path}", + secondButtonTitle: isAndroid ? "View file" : null, + //secondButtonAction: () => isAndroid ? OpenFile.open(file.path) : null, + brightness: brightness); + return file.path; + } catch (exception) { + AliceAlertHelper.showAlert(context, "Error", "Failed to save http calls to file", brightness: brightness); + AliceUtils.log(exception.toString()); + } + + return ""; + } + + static Future _buildAliceLog() async { + final StringBuffer stringBuffer = StringBuffer(); + final packageInfo = await PackageInfo.fromPlatform(); + stringBuffer.write("Alice - HTTP Inspector\n"); + stringBuffer.write("App name: ${packageInfo.appName}\n"); + stringBuffer.write("Package: ${packageInfo.packageName}\n"); + stringBuffer.write("Version: ${packageInfo.version}\n"); + stringBuffer.write("Build number: ${packageInfo.buildNumber}\n"); + stringBuffer.write("Generated: ${DateTime.now().toIso8601String()}\n"); + stringBuffer.write("\n"); + return stringBuffer.toString(); + } + + static String _buildCallLog(AliceHttpCall call) { + final StringBuffer stringBuffer = StringBuffer(); + stringBuffer.write("===========================================\n"); + stringBuffer.write("Id: ${call.id}\n"); + stringBuffer.write("============================================\n"); + stringBuffer.write("--------------------------------------------\n"); + stringBuffer.write("General data\n"); + stringBuffer.write("--------------------------------------------\n"); + stringBuffer.write("Server: ${call.server} \n"); + stringBuffer.write("Method: ${call.method} \n"); + stringBuffer.write("Endpoint: ${call.endpoint} \n"); + stringBuffer.write("Client: ${call.client} \n"); + stringBuffer.write("Duration ${AliceConversionHelper.formatTime(call.duration)}\n"); + stringBuffer.write("Secured connection: ${call.secure}\n"); + stringBuffer.write("Completed: ${!call.loading} \n"); + stringBuffer.write("--------------------------------------------\n"); + stringBuffer.write("Request\n"); + stringBuffer.write("--------------------------------------------\n"); + stringBuffer.write("Request time: ${call.request!.time}\n"); + stringBuffer.write("Request content type: ${call.request!.contentType}\n"); + stringBuffer.write("Request cookies: ${_encoder.convert(call.request!.cookies)}\n"); + stringBuffer.write("Request headers: ${_encoder.convert(call.request!.headers)}\n"); + if (call.request!.queryParameters.isNotEmpty) { + stringBuffer.write("Request query params: ${_encoder.convert(call.request!.queryParameters)}\n"); + } + stringBuffer.write("Request size: ${AliceConversionHelper.formatBytes(call.request!.size)}\n"); + stringBuffer.write( + "Request body: ${AliceParser.formatBody(call.request!.body, AliceParser.getContentType(call.request!.headers))}\n"); + stringBuffer.write("--------------------------------------------\n"); + stringBuffer.write("Response\n"); + stringBuffer.write("--------------------------------------------\n"); + stringBuffer.write("Response time: ${call.response!.time}\n"); + stringBuffer.write("Response status: ${call.response!.status}\n"); + stringBuffer.write("Response size: ${AliceConversionHelper.formatBytes(call.response!.size)}\n"); + stringBuffer.write("Response headers: ${_encoder.convert(call.response!.headers)}\n"); + stringBuffer.write( + "Response body: ${AliceParser.formatBody(call.response!.body, AliceParser.getContentType(call.response!.headers))}\n"); + if (call.error != null) { + stringBuffer.write("--------------------------------------------\n"); + stringBuffer.write("Error\n"); + stringBuffer.write("--------------------------------------------\n"); + stringBuffer.write("Error: ${call.error!.error}\n"); + if (call.error!.stackTrace != null) { + stringBuffer.write("Error stacktrace: ${call.error!.stackTrace}\n"); + } + } + stringBuffer.write("--------------------------------------------\n"); + stringBuffer.write("Curl\n"); + stringBuffer.write("--------------------------------------------\n"); + stringBuffer.write(call.getCurlCommand()); + stringBuffer.write("\n"); + stringBuffer.write("==============================================\n"); + stringBuffer.write("\n"); + + return stringBuffer.toString(); + } + + static Future buildCallLog(AliceHttpCall call) async { + try { + return await _buildAliceLog() + _buildCallLog(call); + } catch (exception) { + + return "Failed to generate call log"; + } + } +} diff --git a/alice/lib/model/alice_form_data_file.dart b/alice/lib/model/alice_form_data_file.dart new file mode 100644 index 0000000..dbfb43b --- /dev/null +++ b/alice/lib/model/alice_form_data_file.dart @@ -0,0 +1,7 @@ +class AliceFormDataFile { + final String? fileName; + final String contentType; + final int length; + + AliceFormDataFile(this.fileName, this.contentType, this.length); +} diff --git a/alice/lib/model/alice_from_data_field.dart b/alice/lib/model/alice_from_data_field.dart new file mode 100644 index 0000000..1fff177 --- /dev/null +++ b/alice/lib/model/alice_from_data_field.dart @@ -0,0 +1,6 @@ +class AliceFormDataField { + final String name; + final String value; + + AliceFormDataField(this.name, this.value); +} diff --git a/alice/lib/model/alice_http_call.dart b/alice/lib/model/alice_http_call.dart new file mode 100644 index 0000000..f290fd7 --- /dev/null +++ b/alice/lib/model/alice_http_call.dart @@ -0,0 +1,83 @@ +import 'package:alice/model/alice_http_error.dart'; +import 'package:alice/model/alice_http_request.dart'; +import 'package:alice/model/alice_http_response.dart'; +import 'dart:convert'; + +class AliceHttpCall { + final int id; + late DateTime createdTime; + String client = ""; + bool loading = true; + bool secure = false; + String method = ""; + String endpoint = ""; + String server = ""; + String uri = ""; + int duration = 0; + + AliceHttpRequest? request; + AliceHttpResponse? response; + AliceHttpError? error; + + AliceHttpCall(this.id) { + loading = true; + createdTime = DateTime.now(); + } + + void setResponse(AliceHttpResponse response) { + this.response = response; + loading = false; + } + + String getCurlCommand() { + var compressed = false; + var curlCmd = "curl"; + curlCmd += " -X $method"; + final headers = request!.headers; + headers.forEach((key, dynamic value) { + if ("Accept-Encoding" == key && "gzip" == value) { + compressed = true; + } + curlCmd += " -H '$key: $value'"; + }); + + String requestBody = request!.body.toString(); + if (requestBody != '' && requestBody.trim() != "{}") { + try { + requestBody = json.encode(request!.body); + } catch (e) { + requestBody = request!.body.toString(); + } + + curlCmd += " --data \'${requestBody.replaceAll("\n", "\\n")}'"; + } + + final queryParamMap = request!.queryParameters; + int paramCount = queryParamMap.keys.length; + var queryParams = ""; + if (paramCount > 0) { + queryParams += "?"; + queryParamMap.forEach((key, dynamic value) { + queryParams += '$key=$value'; + paramCount -= 1; + if (paramCount > 0) { + queryParams += "&"; + } + }); + } + + // If server already has http(s) don't add it again + if (server.contains("http://") || server.contains("https://")) { + // ignore: join_return_with_assignment + curlCmd += "${compressed ? " --compressed " : " "}${"'$server$endpoint$queryParams'"}"; + } else { + // ignore: join_return_with_assignment + print(server); + print(endpoint); + curlCmd += + "${compressed ? " --compressed " : " "}'$uri'"; //{"'${secure ? 'https://' : 'http://'}$server$endpoint$queryParams'"} + } + + return curlCmd; + } +} diff --git a/alice/lib/model/alice_http_error.dart b/alice/lib/model/alice_http_error.dart new file mode 100644 index 0000000..de6972f --- /dev/null +++ b/alice/lib/model/alice_http_error.dart @@ -0,0 +1,4 @@ +class AliceHttpError { + dynamic error; + StackTrace? stackTrace; +} diff --git a/alice/lib/model/alice_http_request.dart b/alice/lib/model/alice_http_request.dart new file mode 100644 index 0000000..49715a5 --- /dev/null +++ b/alice/lib/model/alice_http_request.dart @@ -0,0 +1,16 @@ +import 'dart:io'; + +import 'package:alice/model/alice_form_data_file.dart'; +import 'package:alice/model/alice_from_data_field.dart'; + +class AliceHttpRequest { + int size = 0; + DateTime time = DateTime.now(); + Map headers = {}; + dynamic body = ""; + String? contentType = ""; + List cookies = []; + Map queryParameters = {}; + List? formDataFiles; + List? formDataFields; +} diff --git a/alice/lib/model/alice_http_response.dart b/alice/lib/model/alice_http_response.dart new file mode 100644 index 0000000..62da96e --- /dev/null +++ b/alice/lib/model/alice_http_response.dart @@ -0,0 +1,7 @@ +class AliceHttpResponse { + int? status = 0; + int size = 0; + DateTime time = DateTime.now(); + dynamic body; + Map? headers; +} diff --git a/alice/lib/model/alice_menu_item.dart b/alice/lib/model/alice_menu_item.dart new file mode 100644 index 0000000..6558d2d --- /dev/null +++ b/alice/lib/model/alice_menu_item.dart @@ -0,0 +1,8 @@ +import 'package:flutter/material.dart'; + +class AliceMenuItem { + final String title; + final IconData iconData; + + AliceMenuItem(this.title, this.iconData); +} diff --git a/alice/lib/model/alice_sort_option.dart b/alice/lib/model/alice_sort_option.dart new file mode 100644 index 0000000..3c1c009 --- /dev/null +++ b/alice/lib/model/alice_sort_option.dart @@ -0,0 +1,25 @@ +///Available sort options in inspector UI. +enum AliceSortOption { + time, + responseTime, + responseCode, + responseSize, + endpoint, +} + +extension AliceSortOptionsExtension on AliceSortOption { + String get name { + switch (this) { + case AliceSortOption.time: + return "Create time (default)"; + case AliceSortOption.responseTime: + return "Response time"; + case AliceSortOption.responseCode: + return "Response code"; + case AliceSortOption.responseSize: + return "Response size"; + case AliceSortOption.endpoint: + return "Endpoint"; + } + } +} diff --git a/alice/lib/ui/page/alice_call_details_screen.dart b/alice/lib/ui/page/alice_call_details_screen.dart new file mode 100644 index 0000000..000c2dd --- /dev/null +++ b/alice/lib/ui/page/alice_call_details_screen.dart @@ -0,0 +1,135 @@ +import 'package:alice/core/alice_core.dart'; +import 'package:alice/helper/alice_save_helper.dart'; +import 'package:alice/model/alice_http_call.dart'; +import 'package:alice/utils/alice_constants.dart'; +import 'package:alice/ui/widget/alice_call_error_widget.dart'; +import 'package:alice/ui/widget/alice_call_overview_widget.dart'; +import 'package:alice/ui/widget/alice_call_request_widget.dart'; +import 'package:alice/ui/widget/alice_call_response_widget.dart'; +import 'package:collection/collection.dart' show IterableExtension; +import 'package:flutter/material.dart'; +import 'package:share_plus/share_plus.dart'; + +class AliceCallDetailsScreen extends StatefulWidget { + final AliceHttpCall call; + final AliceCore core; + + const AliceCallDetailsScreen(this.call, this.core); + + @override + _AliceCallDetailsScreenState createState() => _AliceCallDetailsScreenState(); +} + +class _AliceCallDetailsScreenState extends State with SingleTickerProviderStateMixin { + AliceHttpCall get call => widget.call; + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Directionality( + textDirection: widget.core.directionality ?? Directionality.of(context), + child: Theme( + data: ThemeData( + brightness: widget.core.brightness, + ), + child: StreamBuilder>( + stream: widget.core.callsSubject, + initialData: [widget.call], + builder: (context, callsSnapshot) { + if (callsSnapshot.hasData) { + final AliceHttpCall? call = + callsSnapshot.data!.firstWhereOrNull((snapshotCall) => snapshotCall.id == widget.call.id); + if (call != null) { + return _buildMainWidget(); + } else { + return _buildErrorWidget(); + } + } else { + return _buildErrorWidget(); + } + }, + ), + ), + ); + } + + Widget _buildMainWidget() { + return DefaultTabController( + length: 4, + child: Scaffold( + floatingActionButton: Column(mainAxisAlignment: MainAxisAlignment.end, children: [ + FloatingActionButton( + backgroundColor: AliceConstants.lightRed, + heroTag: 'curl_key', + key: const Key('curl_key'), + onPressed: () async { + Share.share(await _getCurl(), subject: 'Request Details'); + }, + child: Text("Curl"), + ), + SizedBox( + height: 10, + ), + FloatingActionButton( + heroTag: 'share_key', + backgroundColor: AliceConstants.lightRed, + key: const Key('share_key'), + onPressed: () async { + Share.share(await _getSharableResponseString(), subject: 'Request Details'); + }, + child: const Icon(Icons.share), + ), + ]), + appBar: AppBar( + bottom: TabBar( + indicatorColor: AliceConstants.lightRed, + tabs: _getTabBars(), + ), + title: const Text('Alice - HTTP Call Details'), + ), + body: TabBarView( + children: _getTabBarViewList(), + ), + ), + ); + } + + Widget _buildErrorWidget() { + return const Center(child: Text("Failed to load data")); + } + + Future _getSharableResponseString() async { + return AliceSaveHelper.buildCallLog(widget.call); + } + + Future _getCurl() async { + return widget.call.getCurlCommand(); + } + + List _getTabBars() { + final List widgets = []; + widgets.add(const Tab(icon: Icon(Icons.info_outline), text: "Overview")); + widgets.add(const Tab(icon: Icon(Icons.arrow_upward), text: "Request")); + widgets.add(const Tab(icon: Icon(Icons.arrow_downward), text: "Response")); + widgets.add( + const Tab( + icon: Icon(Icons.warning), + text: "Error", + ), + ); + return widgets; + } + + List _getTabBarViewList() { + final List widgets = []; + widgets.add(AliceCallOverviewWidget(widget.call)); + widgets.add(AliceCallRequestWidget(widget.call)); + widgets.add(AliceCallResponseWidget(widget.call)); + widgets.add(AliceCallErrorWidget(widget.call)); + return widgets; + } +} diff --git a/alice/lib/ui/page/alice_calls_list_screen.dart b/alice/lib/ui/page/alice_calls_list_screen.dart new file mode 100644 index 0000000..34c771e --- /dev/null +++ b/alice/lib/ui/page/alice_calls_list_screen.dart @@ -0,0 +1,352 @@ +import 'package:alice/model/alice_menu_item.dart'; +import 'package:alice/helper/alice_alert_helper.dart'; +import 'package:alice/model/alice_sort_option.dart'; +import 'package:alice/ui/page/alice_call_details_screen.dart'; +import 'package:alice/core/alice_core.dart'; +import 'package:alice/model/alice_http_call.dart'; +import 'package:alice/utils/alice_constants.dart'; +import 'package:alice/ui/widget/alice_call_list_item_widget.dart'; +import 'package:flutter/material.dart'; + +import 'alice_stats_screen.dart'; + +class AliceCallsListScreen extends StatefulWidget { + final AliceCore _aliceCore; + + const AliceCallsListScreen(this._aliceCore); + + @override + _AliceCallsListScreenState createState() => _AliceCallsListScreenState(); +} + +class _AliceCallsListScreenState extends State { + AliceCore get aliceCore => widget._aliceCore; + bool _searchEnabled = false; + final TextEditingController _queryTextEditingController = TextEditingController(); + final List _menuItems = []; + AliceSortOption? _sortOption = AliceSortOption.time; + bool _sortAscending = false; + + _AliceCallsListScreenState() { + _menuItems.add(AliceMenuItem("Sort", Icons.sort)); + _menuItems.add(AliceMenuItem("Delete", Icons.delete)); + _menuItems.add(AliceMenuItem("Stats", Icons.insert_chart)); + _menuItems.add(AliceMenuItem("Save", Icons.save)); + } + + @override + Widget build(BuildContext context) { + return Directionality( + textDirection: widget._aliceCore.directionality ?? Directionality.of(context), + child: Theme( + data: ThemeData( + brightness: widget._aliceCore.brightness, + ), + child: Scaffold( + appBar: AppBar( + title: _searchEnabled ? _buildSearchField() : _buildTitleWidget(), + actions: [ + _buildSearchButton(), + _buildMenuButton(), + ], + ), + body: _buildCallsListWrapper(), + ), + ), + ); + } + + @override + void dispose() { + super.dispose(); + _queryTextEditingController.dispose(); + } + + Widget _buildSearchButton() { + return IconButton( + icon: const Icon(Icons.search), + onPressed: _onSearchClicked, + ); + } + + void _onSearchClicked() { + setState(() { + _searchEnabled = !_searchEnabled; + if (!_searchEnabled) { + _queryTextEditingController.text = ""; + } + }); + } + + Widget _buildMenuButton() { + return PopupMenuButton( + onSelected: (AliceMenuItem item) => _onMenuItemSelected(item), + itemBuilder: (BuildContext context) { + return _menuItems.map((AliceMenuItem item) { + return PopupMenuItem( + value: item, + child: Row(children: [ + Icon( + item.iconData, + color: AliceConstants.lightRed, + ), + const Padding( + padding: EdgeInsets.only(left: 10), + ), + Text(item.title) + ]), + ); + }).toList(); + }, + ); + } + + Widget _buildTitleWidget() { + return const Text("Alice - Inspector"); + } + + Widget _buildSearchField() { + return TextField( + controller: _queryTextEditingController, + autofocus: true, + decoration: InputDecoration( + hintText: "Search http request...", + hintStyle: TextStyle(fontSize: 16.0, color: AliceConstants.grey), + border: InputBorder.none, + ), + style: const TextStyle(fontSize: 16.0), + onChanged: _updateSearchQuery, + ); + } + + void _onMenuItemSelected(AliceMenuItem menuItem) { + if (menuItem.title == "Sort") { + _showSortDialog(); + } + if (menuItem.title == "Delete") { + _showRemoveDialog(); + } + if (menuItem.title == "Stats") { + _showStatsScreen(); + } + if (menuItem.title == "Save") { + _saveToFile(); + } + } + + Widget _buildCallsListWrapper() { + return StreamBuilder>( + stream: aliceCore.callsSubject, + builder: (context, snapshot) { + List calls = snapshot.data ?? []; + final String query = _queryTextEditingController.text.trim(); + if (query.isNotEmpty) { + calls = calls.where((call) => call.endpoint.toLowerCase().contains(query.toLowerCase())).toList(); + } + if (calls.isNotEmpty) { + return _buildCallsListWidget(calls); + } else { + return _buildEmptyWidget(); + } + }, + ); + } + + Widget _buildEmptyWidget() { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 32), + child: Center( + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon( + Icons.error_outline, + color: AliceConstants.orange, + ), + const SizedBox(height: 6), + const Text( + "There are no calls to show", + style: TextStyle(fontSize: 18), + ), + const SizedBox(height: 12), + Column(crossAxisAlignment: CrossAxisAlignment.start, children: const [ + Text( + "• Check if you send any http request", + style: TextStyle(fontSize: 12), + textAlign: TextAlign.center, + ), + Text( + "• Check your Alice configuration", + style: TextStyle(fontSize: 12), + textAlign: TextAlign.center, + ), + Text( + "• Check search filters", + style: TextStyle(fontSize: 12), + textAlign: TextAlign.center, + ) + ]) + ]), + ), + ); + } + + Widget _buildCallsListWidget(List calls) { + final List callsSorted = List.of(calls); + switch (_sortOption) { + case AliceSortOption.time: + if (_sortAscending) { + callsSorted.sort((call1, call2) => call1.createdTime.compareTo(call2.createdTime)); + } else { + callsSorted.sort((call1, call2) => call2.createdTime.compareTo(call1.createdTime)); + } + break; + case AliceSortOption.responseTime: + if (_sortAscending) { + callsSorted.sort(); + callsSorted.sort((call1, call2) => call1.response?.time.compareTo(call2.response!.time) ?? -1); + } else { + callsSorted.sort((call1, call2) => call2.response?.time.compareTo(call1.response!.time) ?? -1); + } + break; + case AliceSortOption.responseCode: + if (_sortAscending) { + callsSorted.sort((call1, call2) => call1.response?.status?.compareTo(call2.response!.status!) ?? -1); + } else { + callsSorted.sort((call1, call2) => call2.response?.status?.compareTo(call1.response!.status!) ?? -1); + } + break; + case AliceSortOption.responseSize: + if (_sortAscending) { + callsSorted.sort((call1, call2) => call1.response?.size.compareTo(call2.response!.size) ?? -1); + } else { + callsSorted.sort((call1, call2) => call2.response?.size.compareTo(call1.response!.size) ?? -1); + } + break; + case AliceSortOption.endpoint: + if (_sortAscending) { + callsSorted.sort((call1, call2) => call1.endpoint.compareTo(call2.endpoint)); + } else { + callsSorted.sort((call1, call2) => call2.endpoint.compareTo(call1.endpoint)); + } + break; + default: + break; + } + + return ListView.builder( + itemCount: callsSorted.length, + itemBuilder: (context, index) { + return AliceCallListItemWidget(callsSorted[index], _onListItemClicked); + }, + ); + } + + void _onListItemClicked(AliceHttpCall call) { + Navigator.push( + widget._aliceCore.getContext()!, + MaterialPageRoute( + builder: (context) => AliceCallDetailsScreen(call, widget._aliceCore), + ), + ); + } + + void _showRemoveDialog() { + AliceAlertHelper.showAlert( + context, + "Delete calls", + "Do you want to delete http calls?", + firstButtonTitle: "No", + firstButtonAction: () => {}, + secondButtonTitle: "Yes", + secondButtonAction: () => _removeCalls(), + ); + } + + void _removeCalls() { + aliceCore.removeCalls(); + } + + void _showStatsScreen() { + Navigator.push( + aliceCore.getContext()!, + MaterialPageRoute( + builder: (context) => AliceStatsScreen(widget._aliceCore), + ), + ); + } + + void _saveToFile() async { + aliceCore.saveHttpRequests(context); + } + + void _updateSearchQuery(String query) { + setState(() {}); + } + + void _showSortDialog() { + showDialog( + context: context, + builder: (BuildContext buildContext) { + return Theme( + data: ThemeData( + brightness: Brightness.light, + ), + child: AlertDialog( + title: const Text("Select filter"), + content: StatefulBuilder(builder: (context, setState) { + return Wrap( + children: [ + ...AliceSortOption.values + .map((AliceSortOption sortOption) => RadioListTile( + title: Text(sortOption.name), + value: sortOption, + groupValue: _sortOption, + onChanged: (AliceSortOption? value) { + setState(() { + _sortOption = value; + }); + }, + )) + .toList(), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text("Descending"), + Switch( + value: _sortAscending, + onChanged: (value) { + setState(() { + _sortAscending = value; + }); + }, + activeTrackColor: Colors.grey, + activeColor: Colors.white), + const Text("Ascending") + ], + ) + ], + ); + }), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text("Cancel")), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + sortCalls(); + }, + child: const Text("Use filter"), + ), + ], + ), + ); + }, + ); + } + + void sortCalls() { + setState(() {}); + } +} diff --git a/alice/lib/ui/page/alice_stats_screen.dart b/alice/lib/ui/page/alice_stats_screen.dart new file mode 100644 index 0000000..32c93b2 --- /dev/null +++ b/alice/lib/ui/page/alice_stats_screen.dart @@ -0,0 +1,168 @@ +import 'package:alice/core/alice_core.dart'; +import 'package:alice/helper/alice_conversion_helper.dart'; +import 'package:alice/model/alice_http_call.dart'; +import 'package:alice/utils/alice_constants.dart'; +import 'package:flutter/material.dart'; + +class AliceStatsScreen extends StatelessWidget { + final AliceCore aliceCore; + + const AliceStatsScreen(this.aliceCore); + + @override + Widget build(BuildContext context) { + return Directionality( + textDirection: aliceCore.directionality ?? Directionality.of(context), + child: Theme( + data: ThemeData( + brightness: aliceCore.brightness, + ), + child: Scaffold( + appBar: AppBar( + title: const Text("Alice - HTTP Inspector - Stats"), + ), + body: Container( + padding: const EdgeInsets.all(8), + child: ListView( + children: _buildMainListWidgets(), + ), + ), + ), + ), + ); + } + + List _buildMainListWidgets() { + return [ + _getRow("Total requests:", "${_getTotalRequests()}"), + _getRow("Pending requests:", "${_getPendingRequests()}"), + _getRow("Success requests:", "${_getSuccessRequests()}"), + _getRow("Redirection requests:", "${_getRedirectionRequests()}"), + _getRow("Error requests:", "${_getErrorRequests()}"), + _getRow("Bytes send:", AliceConversionHelper.formatBytes(_getBytesSent())), + _getRow("Bytes received:", AliceConversionHelper.formatBytes(_getBytesReceived())), + _getRow("Average request time:", AliceConversionHelper.formatTime(_getAverageRequestTime())), + _getRow("Max request time:", AliceConversionHelper.formatTime(_getMaxRequestTime())), + _getRow("Min request time:", AliceConversionHelper.formatTime(_getMinRequestTime())), + _getRow("Get requests:", "${_getRequests("GET")} "), + _getRow("Post requests:", "${_getRequests("POST")} "), + _getRow("Delete requests:", "${_getRequests("DELETE")} "), + _getRow("Put requests:", "${_getRequests("PUT")} "), + _getRow("Patch requests:", "${_getRequests("PATCH")} "), + _getRow("Secured requests:", "${_getSecuredRequests()}"), + _getRow("Unsecured requests:", "${_getUnsecuredRequests()}"), + ]; + } + + Widget _getRow(String label, String value) { + return Row( + children: [ + Text( + label, + style: _getLabelTextStyle(), + ), + const Padding( + padding: EdgeInsets.only(left: 10), + ), + Text( + value, + style: _getValueTextStyle(), + ) + ], + ); + } + + TextStyle _getLabelTextStyle() { + return const TextStyle(fontSize: 16); + } + + TextStyle _getValueTextStyle() { + return const TextStyle(fontSize: 16, fontWeight: FontWeight.bold); + } + + int _getTotalRequests() { + return calls.length; + } + + int _getSuccessRequests() => calls + .where((call) => call.response != null && call.response!.status! >= 200 && call.response!.status! < 300) + .toList() + .length; + + int _getRedirectionRequests() => calls + .where((call) => call.response != null && call.response!.status! >= 300 && call.response!.status! < 400) + .toList() + .length; + + int _getErrorRequests() => calls + .where((call) => call.response != null && call.response!.status! >= 400 && call.response!.status! < 600) + .toList() + .length; + + int _getPendingRequests() => calls.where((call) => call.loading).toList().length; + + int _getBytesSent() { + int bytes = 0; + calls.forEach((AliceHttpCall call) { + bytes += call.request!.size; + }); + return bytes; + } + + int _getBytesReceived() { + int bytes = 0; + calls.forEach((AliceHttpCall call) { + if (call.response != null) { + bytes += call.response!.size; + } + }); + return bytes; + } + + int _getAverageRequestTime() { + int requestTimeSum = 0; + int requestsWithDurationCount = 0; + calls.forEach((AliceHttpCall call) { + if (call.duration != 0) { + requestTimeSum = call.duration; + requestsWithDurationCount++; + } + }); + if (requestTimeSum == 0) { + return 0; + } + return requestTimeSum ~/ requestsWithDurationCount; + } + + int _getMaxRequestTime() { + int maxRequestTime = 0; + calls.forEach((AliceHttpCall call) { + if (call.duration > maxRequestTime) { + maxRequestTime = call.duration; + } + }); + return maxRequestTime; + } + + int _getMinRequestTime() { + int minRequestTime = 10000000; + if (calls.isEmpty) { + minRequestTime = 0; + } else { + calls.forEach((AliceHttpCall call) { + if (call.duration != 0 && call.duration < minRequestTime) { + minRequestTime = call.duration; + } + }); + } + return minRequestTime; + } + + int _getRequests(String requestType) => calls.where((call) => call.method == requestType).toList().length; + + int _getSecuredRequests() => calls.where((call) => call.secure).toList().length; + + int _getUnsecuredRequests() => calls.where((call) => !call.secure).toList().length; + + List get calls => aliceCore.callsSubject.value; +} diff --git a/alice/lib/ui/widget/alice_base_call_details_widget.dart b/alice/lib/ui/widget/alice_base_call_details_widget.dart new file mode 100644 index 0000000..5272cec --- /dev/null +++ b/alice/lib/ui/widget/alice_base_call_details_widget.dart @@ -0,0 +1,42 @@ +import 'dart:convert'; + +import 'package:alice/helper/alice_conversion_helper.dart'; +import 'package:alice/utils/alice_parser.dart'; +import 'package:flutter/material.dart'; + +abstract class AliceBaseCallDetailsWidgetState + extends State { + final JsonEncoder encoder = const JsonEncoder.withIndent(' '); + + Widget getListRow(String name, String value) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText(name, + style: const TextStyle(fontWeight: FontWeight.bold)), + const Padding( + padding: EdgeInsets.only(left: 5), + ), + Flexible( + child: SelectableText( + value, + ), + ), + const Padding( + padding: EdgeInsets.only(bottom: 18), + ) + ], + ); + } + + String formatBytes(int bytes) => AliceConversionHelper.formatBytes(bytes); + + String formatDuration(int duration) => + AliceConversionHelper.formatTime(duration); + + String formatBody(dynamic body, String? contentType) => + AliceParser.formatBody(body, contentType); + + String? getContentType(Map? headers) => + AliceParser.getContentType(headers); +} diff --git a/alice/lib/ui/widget/alice_call_error_widget.dart b/alice/lib/ui/widget/alice_call_error_widget.dart new file mode 100644 index 0000000..4137f9a --- /dev/null +++ b/alice/lib/ui/widget/alice_call_error_widget.dart @@ -0,0 +1,39 @@ +import 'package:alice/model/alice_http_call.dart'; +import 'package:alice/ui/widget/alice_base_call_details_widget.dart'; +import 'package:flutter/material.dart'; + +class AliceCallErrorWidget extends StatefulWidget { + final AliceHttpCall call; + + const AliceCallErrorWidget(this.call); + + @override + State createState() { + return _AliceCallErrorWidgetState(); + } +} + +class _AliceCallErrorWidgetState + extends AliceBaseCallDetailsWidgetState { + AliceHttpCall get _call => widget.call; + + @override + Widget build(BuildContext context) { + if (_call.error != null) { + final List rows = []; + final dynamic error = _call.error!.error; + var errorText = "Error is empty"; + if (error != null) { + errorText = error.toString(); + } + rows.add(getListRow("Error:", errorText)); + + return Container( + padding: const EdgeInsets.all(6), + child: ListView(children: rows), + ); + } else { + return const Center(child: Text("Nothing to display here")); + } + } +} diff --git a/alice/lib/ui/widget/alice_call_list_item_widget.dart b/alice/lib/ui/widget/alice_call_list_item_widget.dart new file mode 100644 index 0000000..dce2f68 --- /dev/null +++ b/alice/lib/ui/widget/alice_call_list_item_widget.dart @@ -0,0 +1,209 @@ +import 'package:alice/helper/alice_conversion_helper.dart'; +import 'package:alice/model/alice_http_call.dart'; +import 'package:alice/model/alice_http_response.dart'; +import 'package:alice/utils/alice_constants.dart'; +import 'package:flutter/material.dart'; + +class AliceCallListItemWidget extends StatelessWidget { + final AliceHttpCall call; + final Function itemClickAction; + + const AliceCallListItemWidget(this.call, this.itemClickAction); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => itemClickAction(call), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildMethodAndEndpointRow(context), + const SizedBox(height: 4), + _buildServerRow(), + const SizedBox(height: 4), + _buildStatsRow() + ], + ), + ), + _buildResponseColumn(context) + ], + ), + ), + _buildDivider() + ], + ), + ); + } + + Widget _buildMethodAndEndpointRow(BuildContext context) { + final Color? textColor = _getEndpointTextColor(context); + return Row(children: [ + Text( + call.method, + style: TextStyle(fontSize: 16, color: textColor), + ), + const Padding( + padding: EdgeInsets.only(left: 10), + ), + Flexible( + // ignore: avoid_unnecessary_containers + child: Container( + child: Text( + call.endpoint, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16, + color: textColor, + ), + ), + ), + ) + ]); + } + + Widget _buildServerRow() { + return Row(children: [ + _getSecuredConnectionIcon(call.secure), + Expanded( + child: Text( + call.server, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: const TextStyle( + fontSize: 14, + ), + ), + ), + ]); + } + + Widget _buildStatsRow() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible(child: Text(_formatTime(call.request!.time), style: const TextStyle(fontSize: 12))), + Flexible(child: Text(AliceConversionHelper.formatTime(call.duration), style: const TextStyle(fontSize: 12))), + Flexible( + child: Text( + "${AliceConversionHelper.formatBytes(call.request!.size)} / " + "${AliceConversionHelper.formatBytes(call.response!.size)}", + style: const TextStyle(fontSize: 12), + ), + ) + ], + ); + } + + Widget _buildDivider() { + return Container(height: 1, color: AliceConstants.grey); + } + + String _formatTime(DateTime time) { + return "${formatTimeUnit(time.hour)}:" + "${formatTimeUnit(time.minute)}:" + "${formatTimeUnit(time.second)}:" + "${formatTimeUnit(time.millisecond)}"; + } + + String formatTimeUnit(int timeUnit) { + return (timeUnit < 10) ? "0$timeUnit" : "$timeUnit"; + } + + Widget _buildResponseColumn(BuildContext context) { + final List widgets = []; + if (call.loading) { + widgets.add( + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(AliceConstants.lightRed), + ), + ), + ); + widgets.add( + const SizedBox( + height: 4, + ), + ); + } + widgets.add( + Text( + _getStatus(call.response!), + style: TextStyle( + fontSize: 16, + color: _getStatusTextColor(context), + ), + ), + ); + return Container( + width: 50, + child: Column( + children: widgets, + ), + ); + } + + Color? _getStatusTextColor(BuildContext context) { + final int? status = call.response!.status; + if (status == -1) { + return AliceConstants.red; + } else if (status! < 200) { + return Theme.of(context).textTheme.bodyLarge?.color; + } else if (status >= 200 && status < 300) { + return AliceConstants.green; + } else if (status >= 300 && status < 400) { + return AliceConstants.orange; + } else if (status >= 400 && status < 600) { + return AliceConstants.red; + } else { + return Theme.of(context).textTheme.bodyLarge?.color; + } + } + + Color? _getEndpointTextColor(BuildContext context) { + if (call.loading) { + return AliceConstants.grey; + } else { + return _getStatusTextColor(context); + } + } + + String _getStatus(AliceHttpResponse response) { + if (response.status == -1) { + return "ERR"; + } else if (response.status == 0) { + return "???"; + } else { + return "${response.status}"; + } + } + + Widget _getSecuredConnectionIcon(bool secure) { + IconData iconData; + Color iconColor; + if (secure) { + iconData = Icons.lock_outline; + iconColor = AliceConstants.green; + } else { + iconData = Icons.lock_open; + iconColor = AliceConstants.red; + } + return Padding( + padding: const EdgeInsets.only(right: 3), + child: Icon( + iconData, + color: iconColor, + size: 12, + ), + ); + } +} diff --git a/alice/lib/ui/widget/alice_call_overview_widget.dart b/alice/lib/ui/widget/alice_call_overview_widget.dart new file mode 100644 index 0000000..846c4ba --- /dev/null +++ b/alice/lib/ui/widget/alice_call_overview_widget.dart @@ -0,0 +1,38 @@ +import 'package:alice/model/alice_http_call.dart'; +import 'package:alice/ui/widget/alice_base_call_details_widget.dart'; +import 'package:flutter/material.dart'; + +class AliceCallOverviewWidget extends StatefulWidget { + final AliceHttpCall call; + + const AliceCallOverviewWidget(this.call); + + @override + State createState() { + return _AliceCallOverviewWidget(); + } +} + +class _AliceCallOverviewWidget + extends AliceBaseCallDetailsWidgetState { + AliceHttpCall get _call => widget.call; + + @override + Widget build(BuildContext context) { + final List rows = []; + rows.add(getListRow("Method: ", _call.method)); + rows.add(getListRow("Server: ", _call.server)); + rows.add(getListRow("Endpoint: ", _call.endpoint)); + rows.add(getListRow("Started:", _call.request!.time.toString())); + rows.add(getListRow("Finished:", _call.response!.time.toString())); + rows.add(getListRow("Duration:", formatDuration(_call.duration))); + rows.add(getListRow("Bytes sent:", formatBytes(_call.request!.size))); + rows.add(getListRow("Bytes received:", formatBytes(_call.response!.size))); + rows.add(getListRow("Client:", _call.client)); + rows.add(getListRow("Secure:", _call.secure.toString())); + return Container( + padding: const EdgeInsets.all(6), + child: ListView(children: rows), + ); + } +} diff --git a/alice/lib/ui/widget/alice_call_request_widget.dart b/alice/lib/ui/widget/alice_call_request_widget.dart new file mode 100644 index 0000000..e242471 --- /dev/null +++ b/alice/lib/ui/widget/alice_call_request_widget.dart @@ -0,0 +1,78 @@ +import 'package:alice/model/alice_http_call.dart'; +import 'package:alice/ui/widget/alice_base_call_details_widget.dart'; +import 'package:flutter/material.dart'; + +class AliceCallRequestWidget extends StatefulWidget { + final AliceHttpCall call; + + const AliceCallRequestWidget(this.call); + + @override + State createState() { + return _AliceCallRequestWidget(); + } +} + +class _AliceCallRequestWidget + extends AliceBaseCallDetailsWidgetState { + AliceHttpCall get _call => widget.call; + + @override + Widget build(BuildContext context) { + final List rows = []; + rows.add(getListRow("Started:", _call.request!.time.toString())); + rows.add(getListRow("Bytes sent:", formatBytes(_call.request!.size))); + rows.add( + getListRow("Content type:", getContentType(_call.request!.headers)!)); + + final dynamic body = _call.request!.body; + var bodyContent = "Body is empty"; + if (body != null) { + bodyContent = formatBody(body, getContentType(_call.request!.headers)); + } + rows.add(getListRow("Body:", bodyContent)); + final formDataFields = _call.request!.formDataFields; + if (formDataFields?.isNotEmpty == true) { + rows.add(getListRow("Form data fields: ", "")); + formDataFields!.forEach( + (field) { + rows.add(getListRow(" • ${field.name}:", field.value)); + }, + ); + } + final formDataFiles = _call.request!.formDataFiles; + if (formDataFiles?.isNotEmpty == true) { + rows.add(getListRow("Form data files: ", "")); + formDataFiles!.forEach( + (field) { + rows.add(getListRow(" • ${field.fileName}:", + "${field.contentType} / ${field.length} B")); + }, + ); + } + + final headers = _call.request!.headers; + var headersContent = "Headers are empty"; + if (headers.isNotEmpty) { + headersContent = ""; + } + rows.add(getListRow("Headers: ", headersContent)); + _call.request!.headers.forEach((header, dynamic value) { + rows.add(getListRow(" • $header:", value.toString())); + }); + final queryParameters = _call.request!.queryParameters; + var queryParametersContent = "Query parameters are empty"; + if (queryParameters.isNotEmpty) { + queryParametersContent = ""; + } + rows.add(getListRow("Query Parameters: ", queryParametersContent)); + _call.request!.queryParameters.forEach((query, dynamic value) { + rows.add(getListRow(" • $query:", value.toString())); + }); + + return Container( + padding: const EdgeInsets.all(6), + child: ListView(children: rows), + ); + } +} diff --git a/alice/lib/ui/widget/alice_call_response_widget.dart b/alice/lib/ui/widget/alice_call_response_widget.dart new file mode 100644 index 0000000..f04a33f --- /dev/null +++ b/alice/lib/ui/widget/alice_call_response_widget.dart @@ -0,0 +1,264 @@ +import 'package:alice/model/alice_http_call.dart'; +import 'package:alice/utils/alice_constants.dart'; +import 'package:alice/ui/widget/alice_base_call_details_widget.dart'; +import 'package:flutter/material.dart'; + +class AliceCallResponseWidget extends StatefulWidget { + final AliceHttpCall call; + + const AliceCallResponseWidget(this.call); + + @override + State createState() { + return _AliceCallResponseWidgetState(); + } +} + +class _AliceCallResponseWidgetState extends AliceBaseCallDetailsWidgetState { + static const _imageContentType = "image"; + static const _videoContentType = "video"; + static const _jsonContentType = "json"; + static const _xmlContentType = "xml"; + static const _textContentType = "text"; + + static const _kLargeOutputSize = 100000; + bool _showLargeBody = false; + bool _showUnsupportedBody = false; + + AliceHttpCall get _call => widget.call; + + @override + Widget build(BuildContext context) { + final List rows = []; + if (!_call.loading) { + rows.addAll(_buildGeneralDataRows()); + rows.addAll(_buildHeadersRows()); + rows.addAll(_buildBodyRows()); + + return Container( + padding: const EdgeInsets.all(6), + child: ListView(children: rows), + ); + } else { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [CircularProgressIndicator(), Text("Awaiting response...")], + ), + ); + } + } + + @override + void dispose() { + super.dispose(); + } + + List _buildGeneralDataRows() { + final List rows = []; + rows.add(getListRow("Received:", _call.response!.time.toString())); + rows.add(getListRow("Bytes received:", formatBytes(_call.response!.size))); + + final status = _call.response!.status; + var statusText = "$status"; + if (status == -1) { + statusText = "Error"; + } + + rows.add(getListRow("Status:", statusText)); + return rows; + } + + List _buildHeadersRows() { + final List rows = []; + final headers = _call.response!.headers; + var headersContent = "Headers are empty"; + if (headers != null && headers.isNotEmpty) { + headersContent = ""; + } + rows.add(getListRow("Headers: ", headersContent)); + if (_call.response!.headers != null) { + _call.response!.headers!.forEach((header, value) { + rows.add(getListRow(" • $header:", value.toString())); + }); + } + return rows; + } + + List _buildBodyRows() { + final List rows = []; + if (_isImageResponse()) { + rows.addAll(_buildImageBodyRows()); + } else if (_isVideoResponse()) { + rows.addAll(_buildVideoBodyRows()); + } else if (_isTextResponse()) { + if (_isLargeResponseBody()) { + rows.addAll(_buildLargeBodyTextRows()); + } else { + rows.addAll(_buildTextBodyRows()); + } + } else { + rows.addAll(_buildUnknownBodyRows()); + } + + return rows; + } + + List _buildImageBodyRows() { + final List rows = []; + rows.add( + Column( + children: [ + Row( + children: const [ + Text( + "Body: Image", + style: TextStyle(fontWeight: FontWeight.bold), + ) + ], + ), + const SizedBox(height: 8), + Image.network( + _call.uri, + fit: BoxFit.fill, + headers: _buildRequestHeaders(), + loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) { + if (loadingProgress == null) return child; + return Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! + : null, + ), + ); + }, + ), + const SizedBox(height: 8), + ], + ), + ); + return rows; + } + + List _buildLargeBodyTextRows() { + final List rows = []; + if (_showLargeBody) { + return _buildTextBodyRows(); + } else { + rows.add(getListRow("Body:", "Too large to show (${_call.response!.body.toString().length} Bytes)")); + rows.add(const SizedBox(height: 8)); + rows.add( + ElevatedButton( + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all(AliceConstants.lightRed), + ), + onPressed: () { + setState(() { + _showLargeBody = true; + }); + }, + child: const Text("Show body"), + ), + ); + rows.add(const SizedBox(height: 8)); + rows.add(const Text("Warning! It will take some time to render output.")); + } + return rows; + } + + List _buildTextBodyRows() { + final List rows = []; + final headers = _call.response!.headers; + final bodyContent = formatBody(_call.response!.body, getContentType(headers)); + rows.add(getListRow("Body:", bodyContent)); + return rows; + } + + List _buildVideoBodyRows() { + final List rows = []; + rows.add( + Row( + children: const [ + Text( + "Body: Video", + style: TextStyle(fontWeight: FontWeight.bold), + ) + ], + ), + ); + rows.add(const SizedBox(height: 8)); + + rows.add(const SizedBox(height: 8)); + return rows; + } + + List _buildUnknownBodyRows() { + final List rows = []; + final headers = _call.response!.headers; + final contentType = getContentType(headers) ?? ""; + + if (_showUnsupportedBody) { + final bodyContent = formatBody(_call.response!.body, getContentType(headers)); + rows.add(getListRow("Body:", bodyContent)); + } else { + rows.add(getListRow( + "Body:", + "Unsupported body. Alice can render video/image/text body. " + "Response has Content-Type: $contentType which can't be handled. " + "If you're feeling lucky you can try button below to try render body" + " as text, but it may fail.")); + rows.add( + ElevatedButton( + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all(AliceConstants.lightRed), + ), + onPressed: () { + setState(() { + _showUnsupportedBody = true; + }); + }, + child: const Text("Show unsupported body"), + ), + ); + } + return rows; + } + + Map _buildRequestHeaders() { + final Map requestHeaders = {}; + if (_call.request?.headers != null) { + requestHeaders.addAll( + _call.request!.headers.map( + (String key, dynamic value) { + return MapEntry(key, value.toString()); + }, + ), + ); + } + return requestHeaders; + } + + bool _isImageResponse() { + return _getContentTypeOfResponse()!.toLowerCase().contains(_imageContentType); + } + + bool _isVideoResponse() { + return _getContentTypeOfResponse()!.toLowerCase().contains(_videoContentType); + } + + bool _isTextResponse() { + final String responseContentTypeLowerCase = _getContentTypeOfResponse()!.toLowerCase(); + + return responseContentTypeLowerCase.contains(_jsonContentType) || + responseContentTypeLowerCase.contains(_xmlContentType) || + responseContentTypeLowerCase.contains(_textContentType); + } + + String? _getContentTypeOfResponse() { + return getContentType(_call.response!.headers); + } + + bool _isLargeResponseBody() { + return _call.response!.body != null && _call.response!.body.toString().length > _kLargeOutputSize; + } +} diff --git a/alice/lib/utils/alice_constants.dart b/alice/lib/utils/alice_constants.dart new file mode 100644 index 0000000..33ee223 --- /dev/null +++ b/alice/lib/utils/alice_constants.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; + +class AliceConstants { + static Color red = const Color(0xffff3f34); + static Color lightRed = const Color(0xffff5e57); + static Color green = const Color(0xff05c46b); + static Color grey = const Color(0xff808e9b); + static Color orange = const Color(0xffffa801); +} diff --git a/alice/lib/utils/alice_parser.dart b/alice/lib/utils/alice_parser.dart new file mode 100644 index 0000000..fe518ca --- /dev/null +++ b/alice/lib/utils/alice_parser.dart @@ -0,0 +1,78 @@ +import 'dart:convert'; + +class AliceParser { + static const String _emptyBody = "Body is empty"; + static const String _unknownContentType = "Unknown"; + static const String _jsonContentTypeSmall = "content-type"; + static const String _jsonContentTypeBig = "Content-Type"; + static const String _stream = "Stream"; + static const String _applicationJson = "application/json"; + static const String _parseFailedText = "Failed to parse "; + static const JsonEncoder encoder = JsonEncoder.withIndent(' '); + + static String _parseJson(dynamic json) { + try { + return encoder.convert(json); + } catch (exception) { + return json.toString(); + } + } + + static dynamic _decodeJson(dynamic body) { + try { + return json.decode(body as String); + } catch (exception) { + return body; + } + } + + static String formatBody(dynamic body, String? contentType) { + try { + if (body == null) { + return _emptyBody; + } + + var bodyContent = _emptyBody; + + if (contentType == null || + !contentType.toLowerCase().contains(_applicationJson)) { + final bodyTemp = body.toString(); + + if (bodyTemp.isNotEmpty) { + bodyContent = bodyTemp; + } + } else { + if (body is String && body.contains("\n")) { + bodyContent = body; + } else { + if (body is String) { + if (body.isNotEmpty) { + //body is minified json, so decode it to a map and let the encoder pretty print this map + bodyContent = _parseJson(_decodeJson(body)); + } + } else if (body is Stream) { + bodyContent = _stream; + } else { + bodyContent = _parseJson(body); + } + } + } + + return bodyContent; + } catch (exception) { + return _parseFailedText + body.toString(); + } + } + + static String? getContentType(Map? headers) { + if (headers != null) { + if (headers.containsKey(_jsonContentTypeSmall)) { + return headers[_jsonContentTypeSmall] as String?; + } + if (headers.containsKey(_jsonContentTypeBig)) { + return headers[_jsonContentTypeBig] as String?; + } + } + return _unknownContentType; + } +} diff --git a/alice/lib/utils/shake_detector.dart b/alice/lib/utils/shake_detector.dart new file mode 100644 index 0000000..2237433 --- /dev/null +++ b/alice/lib/utils/shake_detector.dart @@ -0,0 +1,90 @@ +///Code from https://github.com/deven98/shake +///Seems to be not maintained for almost 2 years... (01.03.2021). +import 'dart:async'; +import 'dart:math'; +import 'package:sensors_plus/sensors_plus.dart'; + +/// Callback for phone shakes +typedef PhoneShakeCallback = Null Function(); + +/// ShakeDetector class for phone shake functionality +class ShakeDetector { + /// User callback for phone shake + final PhoneShakeCallback? onPhoneShake; + + /// Shake detection threshold + final double shakeThresholdGravity; + + /// Minimum time between shake + final int shakeSlopTimeMS; + + /// Time before shake count resets + final int shakeCountResetTime; + + int mShakeTimestamp = DateTime.now().millisecondsSinceEpoch; + int mShakeCount = 0; + + /// StreamSubscription for Accelerometer events + StreamSubscription? streamSubscription; + + /// This constructor waits until [startListening] is called + ShakeDetector.waitForStart( + {this.onPhoneShake, + this.shakeThresholdGravity = 2.7, + this.shakeSlopTimeMS = 500, + this.shakeCountResetTime = 3000}); + + /// This constructor automatically calls [startListening] and starts detection and callbacks.\ + ShakeDetector.autoStart( + {this.onPhoneShake, + this.shakeThresholdGravity = 2.7, + this.shakeSlopTimeMS = 500, + this.shakeCountResetTime = 3000}) { + startListening(); + } + + /// Starts listening to accelerometer events + void startListening() { + streamSubscription = accelerometerEvents.listen((AccelerometerEvent event) { + final double x = event.x; + final double y = event.y; + final double z = event.z; + + final double gX = x / 9.80665; + final double gY = y / 9.80665; + final double gZ = z / 9.80665; + + // gForce will be close to 1 when there is no movement. + final double gForce = sqrt(gX * gX + gY * gY + gZ * gZ); + + if (gForce > shakeThresholdGravity) { + final now = DateTime.now().millisecondsSinceEpoch; + // ignore shake events too close to each other (500ms) + if (mShakeTimestamp + shakeSlopTimeMS > now) { + return; + } + + // reset the shake count after 3 seconds of no shakes + if (mShakeTimestamp + shakeCountResetTime < now) { + mShakeCount = 0; + } + + mShakeTimestamp = now; + mShakeCount++; + + onPhoneShake!(); + } + }); + } + + /// Stops listening to accelerometer events + void stopListening() { + if (streamSubscription != null) { + streamSubscription!.cancel(); + } + } + + void dispose() { + streamSubscription?.cancel(); + } +} diff --git a/alice/media/1.png b/alice/media/1.png new file mode 100644 index 0000000..72a26de Binary files /dev/null and b/alice/media/1.png differ diff --git a/alice/media/10.png b/alice/media/10.png new file mode 100644 index 0000000..c53e5a9 Binary files /dev/null and b/alice/media/10.png differ diff --git a/alice/media/11.png b/alice/media/11.png new file mode 100644 index 0000000..041dd30 Binary files /dev/null and b/alice/media/11.png differ diff --git a/alice/media/12.png b/alice/media/12.png new file mode 100644 index 0000000..5622fca Binary files /dev/null and b/alice/media/12.png differ diff --git a/alice/media/13.png b/alice/media/13.png new file mode 100644 index 0000000..ec3b7eb Binary files /dev/null and b/alice/media/13.png differ diff --git a/alice/media/2.png b/alice/media/2.png new file mode 100644 index 0000000..e91ba27 Binary files /dev/null and b/alice/media/2.png differ diff --git a/alice/media/3.png b/alice/media/3.png new file mode 100644 index 0000000..4c41453 Binary files /dev/null and b/alice/media/3.png differ diff --git a/alice/media/4.png b/alice/media/4.png new file mode 100644 index 0000000..8d596d2 Binary files /dev/null and b/alice/media/4.png differ diff --git a/alice/media/5.png b/alice/media/5.png new file mode 100644 index 0000000..72720a5 Binary files /dev/null and b/alice/media/5.png differ diff --git a/alice/media/6.png b/alice/media/6.png new file mode 100644 index 0000000..221ead7 Binary files /dev/null and b/alice/media/6.png differ diff --git a/alice/media/7.png b/alice/media/7.png new file mode 100644 index 0000000..2a6d272 Binary files /dev/null and b/alice/media/7.png differ diff --git a/alice/media/8.png b/alice/media/8.png new file mode 100644 index 0000000..3e9690d Binary files /dev/null and b/alice/media/8.png differ diff --git a/alice/media/9.png b/alice/media/9.png new file mode 100644 index 0000000..9bb1556 Binary files /dev/null and b/alice/media/9.png differ diff --git a/alice/media/logo.png b/alice/media/logo.png new file mode 100644 index 0000000..3f83587 Binary files /dev/null and b/alice/media/logo.png differ diff --git a/alice/pubspec.yaml b/alice/pubspec.yaml new file mode 100644 index 0000000..afe83bb --- /dev/null +++ b/alice/pubspec.yaml @@ -0,0 +1,29 @@ +name: alice +description: Alice is an HTTP Inspector tool which helps debugging http requests. It catches and stores http requests and responses, which can be viewed via simple UI. +version: 0.2.4 +author: Jakub Homlala +homepage: https://github.com/jhomlala/alice + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=1.12.0" + +dependencies: + flutter: + sdk: flutter + http: ^1.2.2 + dio: ^4.0.0 +# flutter_local_notifications: ^9.9.1 # Disabled to avoid build conflicts + rxdart: ^0.27.1 + + path_provider: ^2.1.5 + permission_handler: ^12.0.0+1 + package_info_plus: 8.3.0 + # open_file: ^3.2.1 + sensors_plus: ^6.1.1 + share_plus: 11.0.0 + chopper: ^8.1.0 + collection: ^1.19.1 + +dev_dependencies: + lint: ^1.5.3 diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..61b6c4d --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,29 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at + # https://dart-lang.github.io/linter/lints/index.html. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..6f56801 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..1e76f31 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,69 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + +def kotlin_version = rootProject.ext.has("kotlin_version") ? rootProject.ext.get("kotlin_version") : "2.1.20" + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +android { + namespace "com.example.baseproject" + compileSdkVersion 36 + ndkVersion "26.1.10909125" + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "com.example.baseproject" + minSdkVersion flutter.minSdkVersion + targetSdkVersion 36 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..138c923 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..eca6c24 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/buskingplay/MainActivity.kt b/android/app/src/main/kotlin/com/example/buskingplay/MainActivity.kt new file mode 100644 index 0000000..9aa4a33 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/buskingplay/MainActivity.kt @@ -0,0 +1,6 @@ +package com.example.baseproject + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() { +} diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..3db14bb --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..d460d1e --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..138c923 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..bc157bd --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..f5404a9 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx4096M +android.useAndroidX=true +android.enableJetifier=true +org.gradle.java.home=C:\\Program Files\\Java\\jdk-17 \ No newline at end of file diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..9162f10 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jun 23 08:50:38 CEST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..ad3470b --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.9.1" apply false + id "org.jetbrains.kotlin.android" version "2.1.20" apply false +} + +include ":app" diff --git a/build.yaml b/build.yaml new file mode 100644 index 0000000..5d6ece8 --- /dev/null +++ b/build.yaml @@ -0,0 +1,12 @@ +targets: + $default: + builders: + injectable_generator:injectable_builder: + options: + auto_register: true + # auto registers any class with a name matches the given pattern + class_name_pattern: + "Service$|Repository$|Bloc$|UseCases$" + # auto registers any class inside a file with a + # name matches the given pattern + #file_name_pattern: "_service$|_repository$|_bloc$" \ No newline at end of file diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..8d4492f --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 9.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..9411102 --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,41 @@ +# Uncomment this line to define a global platform for your project +platform :ios, '10.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..f73af29 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,552 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + FE15EF26263AF0E3589C177A /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C769CC47EF6EE6238651A6B9 /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A9D1A183C6BE536EA5C6A7E7 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + B6F78449E779735190CB8C28 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + C769CC47EF6EE6238651A6B9 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + CB8A2F19EE92F5C4FB2CFA52 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FE15EF26263AF0E3589C177A /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1139888A422B5187C08F9150 /* Pods */ = { + isa = PBXGroup; + children = ( + CB8A2F19EE92F5C4FB2CFA52 /* Pods-Runner.debug.xcconfig */, + B6F78449E779735190CB8C28 /* Pods-Runner.release.xcconfig */, + A9D1A183C6BE536EA5C6A7E7 /* Pods-Runner.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + 14C5FD2F79A8233FC0DD05E3 /* Frameworks */ = { + isa = PBXGroup; + children = ( + C769CC47EF6EE6238651A6B9 /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 1139888A422B5187C08F9150 /* Pods */, + 14C5FD2F79A8233FC0DD05E3 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + B87CEC732CA2BE826259FF34 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 0B41A6C2C6540ED4CFDA2C6A /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1300; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 0B41A6C2C6540ED4CFDA2C6A /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + B87CEC732CA2BE826259FF34 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = P3472XX8FH; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.baseproject; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = P3472XX8FH; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.baseproject; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = P3472XX8FH; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.baseproject; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c87d15a --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..70693e4 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import UIKit +import Flutter + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..28c6bf0 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..2ccbfd9 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..f091b6b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cde121 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..d0ef06e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..dcdc230 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..2ccbfd9 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..c8f9ed8 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..a6d6b86 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..a6d6b86 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..75b2d16 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..c4df70d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..6a84f41 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..d0e1f58 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..52c5600 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,47 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + baseproject + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + baseproject + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/lib/assets/images.dart b/lib/assets/images.dart new file mode 100644 index 0000000..3634dca --- /dev/null +++ b/lib/assets/images.dart @@ -0,0 +1,5 @@ +class Images { + static const String url_image = 'lib/assets/images/'; + + static const String imageDefault = url_image + 'image_default.png'; +} diff --git a/lib/assets/images/image_default.png b/lib/assets/images/image_default.png new file mode 100644 index 0000000..6143abb Binary files /dev/null and b/lib/assets/images/image_default.png differ diff --git a/lib/core/common/bloc/base_cubit.dart b/lib/core/common/bloc/base_cubit.dart new file mode 100644 index 0000000..743df9d --- /dev/null +++ b/lib/core/common/bloc/base_cubit.dart @@ -0,0 +1,97 @@ +// ignore_for_file: prefer_mixin + +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Là một cách triển khai khác của bloc +/// Thay vì add event như bloc thì cubit chỉ cần emit một state mới +/// Các hàm trong BaseCubit xử lí tương tự như BaseBloc +/// Example +/// +/// class TestBloc extends BaseCubit> { +/// TestBloc() : super(InitState('')); +/// +/// +/// @override +/// Future init() async{ +/// // call khi khởi tạo bloc +/// refresh(); +/// } +/// +/// @override +/// Future refresh()async { +/// emit(LoadingState()); +/// await getData(); +/// await Future.delayed(Duration(seconds: 5)); +/// await loadMore(); +/// } +/// +/// @override +/// Future> getData()async { +/// logger.d('getData'); +/// return [5,6,7,8]; +/// } +/// +/// @override +/// Future> loadMore() async{ +/// logger.d('loadMore'); +/// emit(LoadedState(data: [1,2,3,4])); +/// return [1,2,3,4]; +/// } +/// +/// +/// @override +/// void dispose() { +/// // call khi kill widget +/// } +/// +/// } +/// +abstract class BaseCubit extends Cubit with WidgetsBindingObserver{ + BaseCubit(BaseStateBloc initialState) : super(initialState); + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + print("resumed"); + } + if (state == AppLifecycleState.paused) { + print("paused"); + } + if (state == AppLifecycleState.inactive) { + print("inactive"); + } + } + + + + @protected + Future getData() { + // TODO: implement getData + throw UnimplementedError(); + } + + @protected + Future loadMore() { + // TODO: implement loadMore + throw UnimplementedError(); + } + + @protected + Future refresh() { + // TODO: implement refresh + throw UnimplementedError(); + } + + @protected + void dispose() { + // TODO: implement dispose + } + + @protected + void init() { + // TODO: implement init + } +} + + diff --git a/lib/core/common/bloc/base_state.dart b/lib/core/common/bloc/base_state.dart new file mode 100644 index 0000000..f9532b7 --- /dev/null +++ b/lib/core/common/bloc/base_state.dart @@ -0,0 +1,21 @@ + +abstract class BaseStateBloc{ + final T model; + BaseStateBloc(this.model); +} + +class InitState extends BaseStateBloc { + InitState(T model) : super(model); +} + +class LoadingState extends BaseStateBloc { + LoadingState(T model) : super(model); +} + +class LoadedState extends BaseStateBloc { + LoadedState(T model) : super(model); +} + +class ErrorState extends BaseStateBloc { + ErrorState(T model) : super(model); +} diff --git a/lib/core/common/bloc/base_stateful.dart b/lib/core/common/bloc/base_stateful.dart new file mode 100644 index 0000000..258fe3a --- /dev/null +++ b/lib/core/common/bloc/base_stateful.dart @@ -0,0 +1,37 @@ +import 'package:baseproject/core/common/bloc/base_cubit.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; + +abstract class BaseStatefulCubit, S> extends State { + late C cubit; + void initCubit(); + @override + void initState() { + initCubit(); + cubit = GetIt.I(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return buildWidgets(context); + } + + Widget buildWidgets(BuildContext context) { + return BlocProvider( + create: (_) => cubit, + child: BlocConsumer( + builder: builder, + listener: listener, + ), + ); + } + + /// render view + Widget builder(BuildContext context, S state); + + /// lắng nghe sự thay đổi của state + void listener(BuildContext context, S state); +} diff --git a/lib/core/common/bloc/bloc_index.dart b/lib/core/common/bloc/bloc_index.dart new file mode 100644 index 0000000..a3b5b42 --- /dev/null +++ b/lib/core/common/bloc/bloc_index.dart @@ -0,0 +1,4 @@ +export 'package:flutter_bloc/flutter_bloc.dart'; +export 'base_cubit.dart'; +export 'base_state.dart'; +export 'base_stateful.dart'; \ No newline at end of file diff --git a/lib/core/common/common_module.dart b/lib/core/common/common_module.dart new file mode 100644 index 0000000..6a87aa2 --- /dev/null +++ b/lib/core/common/common_module.dart @@ -0,0 +1,20 @@ +import 'package:baseproject/core/common/custom_interceptor.dart'; +import 'package:baseproject/core/components/alice.dart'; +import 'package:baseproject/features/presentation/app/view/app.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:injectable/injectable.dart'; + +@module +abstract class RegisterCommonModule { + @lazySingleton + Dio get dio => Dio() + ..interceptors.addAll(kDebugMode + ? [CustomInterceptor(), CustomAlice.setAndGetAlice(navigatorKey).getDioInterceptor()] + : [CustomInterceptor()]) + ..options = BaseOptions( + receiveTimeout: 10000, + connectTimeout: 10000, + sendTimeout: 10000, + ); +} diff --git a/lib/core/common/custom_interceptor.dart b/lib/core/common/custom_interceptor.dart new file mode 100644 index 0000000..e9b42b5 --- /dev/null +++ b/lib/core/common/custom_interceptor.dart @@ -0,0 +1,96 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:get_it/get_it.dart'; + +class CustomInterceptor extends InterceptorsWrapper { + int retryCount = 0; + @override + // ignore: avoid_void_async + void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { + // final String token = LocalStoreManager.getString(UserSettings.tokenUser); + // if (token.isNotEmpty) options.headers["Authorization"] = "Bearer $token"; + + final String method = options.method.toLowerCase(); + if (method == 'get' || method == 'put') { + // xử lý double request đá .0 + // options.queryParameters.forEach((String key, dynamic value) { + // if (value != null && value is double && Utils.getDecimal(value) == 0) { + // options.queryParameters[key] = value.toInt(); + // } + // }); + + } + + return super.onRequest(options, handler); + } + + @override + // ignore: always_specify_types + onResponse(Response response, ResponseInterceptorHandler handler) { + if (response.data.runtimeType.toString().toLowerCase() == "string") { + response.data = json.decode(response.data); + } + try { + if (response.data["result"] != null) { + response.data = response.data["result"]; + } + } catch (_) {} + + if (retryCount > 0) { + retryCount = 0; + } + return super.onResponse(response, handler); + } + + @override + onError(DioError err, ErrorInterceptorHandler handler) async { + if (retryCount >= 3) { + return; + } + if (err.response?.statusCode == 403 || err.response?.statusCode == 401) { + retryCount++; + final Dio dio = GetIt.I(); + dio.lock(); + dio.interceptors.requestLock.lock(); + dio.interceptors.responseLock.lock(); + + //Refresh token + // final CoreUserRepository sessionRepository = GetIt.I(); + // final Token? token = await sessionRepository.refreshToken( + // clientId: UserSettings.oidcClientId, refreshToken: LocalStoreManager.getString(UserSettings.refreshToken)); + // if (token == null) { + // // final AuthenticateApp authenticateApp = GetIt.I(); + // // await authenticateApp.authenticate(UserSettings.oidcClientId, ["profile", "email", "offline_access"]); + // await Navigator.pushNamedAndRemoveUntil( + // navigatorKey!.currentState!.context, vhs3LoginUser, (Route route) => false); + // } else { + // dio.unlock(); + // dio.interceptors.requestLock.unlock(); + // dio.interceptors.responseLock.unlock(); + // options.headers = { + // "Content-type": "application/json", + // "Authorization": "Bearer ${LocalStoreManager.getString(UserSettings.tokenUser)}" + // }; + // await dio.fetch(options); + // } + } + + final dynamic errorData = err.response?.data; + //&& err.response?.statusCode == 400 + if (errorData != null && errorData["responseException"] != null) { + final dynamic temp = errorData["responseException"]["exceptionMessage"]; + try { + if (temp != null && temp["error"] != null) { + err.response?.data = temp["error"]; + } else { + err.response?.data = temp; + } + } catch (e) { + err.response?.data = temp; + } + } + + return super.onError(err, handler); + } +} diff --git a/lib/core/common/injection.config.dart b/lib/core/common/injection.config.dart new file mode 100644 index 0000000..85704b0 --- /dev/null +++ b/lib/core/common/injection.config.dart @@ -0,0 +1,31 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ************************************************************************** +// InjectableConfigGenerator +// ************************************************************************** + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:dio/dio.dart' as _i3; +import 'package:get_it/get_it.dart' as _i1; +import 'package:injectable/injectable.dart' as _i2; + +import 'common_module.dart' as _i4; // ignore_for_file: unnecessary_lambdas + +// ignore_for_file: lines_longer_than_80_chars +/// initializes the registration of provided dependencies inside of [GetIt] +_i1.GetIt $initGetIt( + _i1.GetIt get, { + String? environment, + _i2.EnvironmentFilter? environmentFilter, +}) { + final gh = _i2.GetItHelper( + get, + environment, + environmentFilter, + ); + final registerCommonModule = _$RegisterCommonModule(); + gh.lazySingleton<_i3.Dio>(() => registerCommonModule.dio); + return get; +} + +class _$RegisterCommonModule extends _i4.RegisterCommonModule {} diff --git a/lib/core/common/injection.dart b/lib/core/common/injection.dart new file mode 100644 index 0000000..5bffe07 --- /dev/null +++ b/lib/core/common/injection.dart @@ -0,0 +1,10 @@ +import 'package:get_it/get_it.dart'; +import 'package:injectable/injectable.dart'; +import 'injection.config.dart'; + +final GetIt getItSuper = GetIt.instance; + +@injectableInit +void configureInjection() { + $initGetIt(getItSuper); +} diff --git a/lib/core/common/loading.dart b/lib/core/common/loading.dart new file mode 100644 index 0000000..3b265af --- /dev/null +++ b/lib/core/common/loading.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; + +class Loading { + static void configLoading() { + EasyLoading.instance + ..displayDuration = const Duration(milliseconds: 4000) + ..loadingStyle = EasyLoadingStyle.custom + ..indicatorSize = 45.0 + ..radius = 550.0 + ..progressColor = Colors.yellow + ..backgroundColor = Colors.transparent + ..indicatorColor = Colors.yellow + ..textColor = Colors.yellow + ..maskColor = Colors.white.withOpacity(0.85) + ..boxShadow = [] + ..contentPadding = const EdgeInsets.symmetric(vertical: 20.0, horizontal: 20.0) + ..userInteractions = false + ..dismissOnTap = false + ..animationStyle = EasyLoadingAnimationStyle.scale; + + //..customAnimation = CustomAnimation(); + } +} + +void showLoading({bool isDismissOnTap = false}) { + EasyLoading.show( + maskType: EasyLoadingMaskType.custom, + dismissOnTap: isDismissOnTap, + ); +} + +void hideLoading() { + EasyLoading.dismiss(); +} diff --git a/lib/core/common/message.dart b/lib/core/common/message.dart new file mode 100644 index 0000000..f5f67c5 --- /dev/null +++ b/lib/core/common/message.dart @@ -0,0 +1,189 @@ +import 'dart:io'; + +import 'package:baseproject/core/constants/constant_string.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:animations/animations.dart'; + +void showSuccessMessage(String message, {Color? backgroundColor, Color? textColor, double? time}) { + Fluttertoast.showToast( + msg: message, + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.SNACKBAR, + timeInSecForIosWeb: 2, + backgroundColor: backgroundColor ?? Colors.green, + textColor: textColor ?? Colors.white, + fontSize: 16.0); +} + +void showMessage(String message, {Color? backgroundColor, Color? textColor, double? time}) { + Fluttertoast.showToast( + msg: message, + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.SNACKBAR, + timeInSecForIosWeb: 2, + backgroundColor: backgroundColor ?? const Color(0xff161C2C), + textColor: textColor ?? Colors.white, + fontSize: 16.0); +} + +void showErrorMessage(String message, {Color? backgroundColor, Color? textColor, double? time}) { + Fluttertoast.showToast( + msg: message, + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.SNACKBAR, + timeInSecForIosWeb: 2, + backgroundColor: backgroundColor ?? const Color(0xffC11C3A), + textColor: textColor ?? Colors.white, + fontSize: 16.0); +} + +Future showConfirmDialog( + BuildContext context, + String content, { + String? title, + String? cancelText, + String? submitText, + Function? onCancel, + Function? onSubmit, +}) { + return showModal( + context: context, + configuration: const FadeScaleTransitionConfiguration(transitionDuration: Duration(milliseconds: 400)), + builder: (_) { + if (Platform.isIOS) { + return _alertConfirm( + context, + content, + title: title, + cancelText: cancelText, + submitText: submitText, + onCancel: onCancel, + onSubmit: onSubmit, + ); + } + return AlertDialog( + title: Text(title ?? ConstantString.appName), + elevation: 8, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), + content: Text(content), + actions: [ + TextButton( + child: Text( + cancelText ?? 'Huỷ', + style: const TextStyle(color: Colors.red), + ), + onPressed: () { + Navigator.of(context).pop(); + if (onCancel != null) { + onCancel(); + } + }), + TextButton( + child: Text(submitText ?? 'Đồng ý'), + onPressed: () { + Navigator.of(context).pop(); + if (onSubmit != null) { + onSubmit(); + } + }, + ) + ], + ); + }); +} + +Future showAlertDialog( + BuildContext context, { + Function? onOk, + Function? onCancel, + String? cancelText, + String? okText, + String? alertTitle, + String? content, +}) { + // set up the buttons + final Widget cancelButton = TextButton( + child: Text(cancelText ?? "Huỷ"), + onPressed: () { + Navigator.of(context).pop(); + }, + ); + final Widget continueButton = TextButton( + child: Text(okText ?? "Tiếp tục"), + onPressed: () { + if (onOk != null) onOk(); + Navigator.of(context).pop(); + }, + ); + + // set up the AlertDialog + final AlertDialog alert = AlertDialog( + title: Text(alertTitle ?? "Xác nhận"), + content: Text(content ?? "Bạn chắc chắn muốn xóa?"), + actions: [ + cancelButton, + continueButton, + ], + ); + // + // // show the dialog + // showDialog( + // context: context, + // builder: (BuildContext context) { + // return alert; + // }, + // ); + return showModal( + context: context, + configuration: const FadeScaleTransitionConfiguration(transitionDuration: Duration(milliseconds: 500)), + builder: (_) { + if (Platform.isIOS) { + return _alertConfirm( + context, + content, + title: alertTitle, + cancelText: cancelText, + submitText: okText, + onCancel: onCancel, + onSubmit: onOk, + ); + } + return alert; + }); +} + +CupertinoAlertDialog _alertConfirm( + BuildContext context, + String? content, { + String? title, + String? cancelText, + String? submitText, + Function? onCancel, + Function? onSubmit, +}) { + return CupertinoAlertDialog( + title: Text(title ?? ""), + content: Text(content ?? ''), + actions: [ + CupertinoDialogAction( + child: Text(cancelText ?? "Huỷ"), + onPressed: () { + Navigator.of(context).pop(); + if (onCancel != null) { + onCancel(); + } + }), + CupertinoDialogAction( + child: Text(submitText ?? "Đồng ý"), + onPressed: () { + Navigator.of(context).pop(); + if (onSubmit != null) { + onSubmit(); + } + }, + ) + ], + ); +} diff --git a/lib/core/common/shared_preferences/local_storage_manager.dart b/lib/core/common/shared_preferences/local_storage_manager.dart new file mode 100644 index 0000000..e5d1d84 --- /dev/null +++ b/lib/core/common/shared_preferences/local_storage_manager.dart @@ -0,0 +1,67 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +class LocalStoreManager { + static SharedPreferences? _prefs; + // call this method from iniState() function of mainApp(). + static Future init() async { + _prefs = await SharedPreferences.getInstance(); + return true; + } + + //sets + static Future setBool( + String key, + bool value, + ) async => + await _prefs!.setBool(key, value); + + static Future setDouble(String key, double value) async => await _prefs!.setDouble(key, value); + + static Future setInt(String key, int value) async => await _prefs!.setInt(key, value); + + static Future setString(String key, String value) async => await _prefs!.setString(key, value); + + static Future setObject(String key, Map object) async { + try { + final String value = jsonEncode(object); + await _prefs!.setString(key, value); + return true; + } catch (ex) { + return false; + } + } + + static Future setStringList(String key, List value) async => await _prefs!.setStringList(key, value); + + //gets + static bool getBool(String key) => _prefs?.getBool(key) ?? false; + + static double getDouble(String key) => _prefs?.getDouble(key) ?? 0; + + static int getInt(String key) => _prefs?.getInt(key) ?? 0; + + static String getString(String key) => _prefs?.getString(key) ?? ""; + + static List getStringList(String key) => _prefs!.getStringList(key) ?? []; + + static Map? getObject(String key) { + try { + final String value = getString(key); + return jsonDecode(value); + } catch (ex) { + return null; + } + } + + //deletes.. + static Future remove(String key) async => await _prefs!.remove(key); + + static Future clear() async => await _prefs!.clear(); + + static bool checkKey(String key) { + final bool checkValue = _prefs!.containsKey(key); + return checkValue; + } +} diff --git a/lib/core/common/utils.dart b/lib/core/common/utils.dart new file mode 100644 index 0000000..00db2fc --- /dev/null +++ b/lib/core/common/utils.dart @@ -0,0 +1,416 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:baseproject/core/common/message.dart'; +import 'package:baseproject/core/language/app_localizations.dart'; +import 'package:baseproject/features/presentation/app/view/app.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_datetime_picker_plus/flutter_datetime_picker_plus.dart'; +import 'package:intl/intl.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:path/path.dart' as p; + +class Utils { + static int dateDiffToSeconds(DateTime date1, DateTime date2) { + return date1.difference(date2).inSeconds; + } + + static int dateDiffToDays(DateTime date1, DateTime date2) { + return date1.difference(date2).inDays; + } + + static int dateDiffToYears(DateTime date1, DateTime date2) { + return date1.year - date2.year; + } + + static Map convertObjectToMap(Object object) { + final Map temp = jsonDecode(jsonEncode(object)) as Map; + // print(temp); + return temp; + } + + static int getCharacterCode(String character) { + final List codeUnits = character.codeUnits; + int temp = 65; + if (codeUnits.isNotEmpty) temp = codeUnits[0]; + return temp; + } + + static String thumbnailImage(String url, {double? width, double? height, String mode = 'crop'}) { + // if (url.startsWith("http")) return url; + // if (url.contains(mode)) return url; + // // ignore: parameter_assignments + // if (width == double.infinity || width == null) width = 0; + + // // ignore: parameter_assignments + // if (height == double.infinity || height == null) height = 0; + + // if (width == 0 && height == 0) return url; + + // final List? storageServer = GetIt.I().getAppSettings.storageServers; + // if (storageServer != null) { + // final Iterable allStorage = storageServer.map((StorageServer e) => e.publicDomain); + // for (String item in allStorage) { + // if (url.toLowerCase().contains(item.toLowerCase())) { + // if (item[item.length - 1] != '/') item = item + "/"; + // // ignore: parameter_assignments + // url = url.replaceAll(item.toLowerCase(), + // '$item${(width != null && width > 0) ? width.toInt() * 2 : 0}x${(height != null && height > 0) ? height.toInt() * 2 : 0}/${mode}/'); + // } + // } + // } + + return url; + } + + static T enumFromString(Iterable values, String value) { + // ignore: always_specify_types + return values.firstWhere((type) => type.toString().split(".").last == value, orElse: () => values.first); + } + + LocaleType getLocaleType() { + return Utils.enumFromString( + LocaleType.values, AppLocalizations.of(navigatorKey!.currentState!.context)!.locale.languageCode); + } + + Future onShowDatePicker( + {required BuildContext context, + DateTime? currentValue, + DateTime? firstDate, + DateTime? maxDate, + bool isShowAfter = true}) { + final DateTime _currentValue = currentValue != null && currentValue.year > 0 ? currentValue : DateTime.now(); + final DateTime _minDate = !isShowAfter ? DateTime.now() : firstDate ?? DateTime(1900); + if (Platform.isIOS) { + return DatePicker.showDatePicker( + context, + minTime: _minDate, + maxTime: maxDate ?? DateTime(2100), + currentTime: _currentValue, + locale: getLocaleType(), + ); + } + + return showDatePicker( + context: context, + initialDate: _currentValue, + firstDate: _minDate, + lastDate: maxDate ?? DateTime(2100), + currentDate: currentValue, + ); + } + + Future onShowTimePicker(BuildContext context, DateTime? currentValue, {bool isShowSecond = false}) async { + final DateTime? _currentValue = (currentValue?.hour ?? 0) > 0 ? currentValue : DateTime.now(); + if (Platform.isIOS) { + final DateTime? result = await DatePicker.showTimePicker( + context, + showSecondsColumn: isShowSecond, + currentTime: _currentValue, + locale: getLocaleType(), + ); + + if (result == null) return null; + return TimeOfDay.fromDateTime(result); + } + + final TimeOfDay? timePickerResult = await showTimePicker( + context: context, + initialTime: currentValue != null ? TimeOfDay.fromDateTime(currentValue) : TimeOfDay.fromDateTime(DateTime.now()), + ); + return timePickerResult ?? (currentValue != null ? TimeOfDay.fromDateTime(currentValue) : null); + } + + static T? getPropertyValueByName(Object? object, String propertyName) { + if (object == null) return null; + try { + final Map temp = convertObjectToMap(object); + return temp[propertyName] as T; + } catch (ex) { + return null; + } + } + + static String getExtension(String path) { + return p.extension(path).replaceAll(".", ""); + } + + static String getExtensionWithDot(String path) { + return p.extension(path); + } + + static String getFileName(String path) { + return p.basename(path); + } + + static String getFileNameWithoutExtension(String path) { + return p.basenameWithoutExtension(path); + } + + static String getFileNameFromTime(String extension) { + return DateTime.now().millisecondsSinceEpoch.toString() + "." + extension; + } + + static T? getRouterObject(Object? arguments, {int index = -1}) { + if (arguments == null) return null; + if (index >= 0) { + final List args = arguments as List; + if (index < args.length) { + return (args[index]) != null ? args[index] as T : null; + } else { + return null; + } + } + + return arguments as T; + } + + static String? getRouterObjectString(Object? arguments, {int index = -1}) { + if (arguments == null) return null; + if (index >= 0) { + final List args = arguments as List; + if (index < args.length) { + return (args[index]) != null ? args[index] as String : null; + } else { + return null; + } + } + + return arguments as String?; + } + + static Map base64ToMap(String b64) { + return json.decode(base64Decode(b64)); + } + + static String base64Decode(String b64) { + final String foo = b64.split('.')[0]; + final List res = base64.decode(base64.normalize(foo)); + return utf8.decode(res); + } + + Future openChooseFiles({ + FileType type = FileType.any, + List? allowedExtensions = const ["doc", "pdf", "mp4", "mp3"], + Function(FilePickerStatus)? onFileLoading, + bool allowCompression = true, + bool allowMultiple = false, + bool withData = false, + bool withReadStream = false, + }) async { + // final AppSettings _appSettings = GetIt.I(); + final FilePickerResult? result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: allowedExtensions, + allowMultiple: allowMultiple, + allowCompression: allowCompression, + onFileLoading: onFileLoading, + withData: withData, + withReadStream: withReadStream); + if (result != null) { + double _fileSize = 0; + for (final PlatformFile filePath in result.files) { + _fileSize += _getSizeFile(filePath.path ?? ''); + } + if (_fileSize < 15) { + //_appSettings.configs.maximumFileSizeUpload.first.document + return result; + } else { + showErrorMessage(AppLocalizations.of(navigatorKey!.currentState!.context)! + .translate('Tệp tin vượt quá dung lượng cho phép: ${15} mb')); + return null; + } + } else { + return null; + } + } + + double _getSizeFile(String filePath) { + final File _file = File(filePath); + final int sizeInBytes = _file.lengthSync(); + return sizeInBytes / (1024 * 1024); + } + + static Future launchPhone({String value = ""}) async { + String url = 'tel:$value'; + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl( + uri, + ); + } else { + showErrorMessage("Không có ứng dụng phù hợp"); + } + } + + static Future launchEmail({String value = ""}) async { + String url = 'mailto:$value'; + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl( + uri, + ); + } else { + showErrorMessage("Không có ứng dụng phù hợp"); + } + } + + static DateTime getDate(DateTime time) { + return DateTime(time.year, time.month, time.day); + } + + static DateTime getToDate(DateTime time) { + return DateTime(time.year, time.month, time.day).add(const Duration(days: 1)).add(const Duration(seconds: -1)); + } + + static DateTime addMonth(DateTime date) { + var year = date.year + ((date.month + 1) ~/ 12); + var month = (date.month + 1) % 12; + if (month == 0) month = 12; + var day = date.day; + + // Adjust day if the result is an invalid date, e.g., adding a month to January 31st + if (day > 28) { + day = min(day, DateTime(year, month + 1, 0).day); + } + + return DateTime(year, month, day, date.hour, date.minute, date.second, date.millisecond, date.microsecond); + } + + static DateTime? getDateTimeFromUTC(DateTime? time) { + if (time == null) return null; + if (!time.isUtc) return time; + return time.toLocal(); + } + + static int getTimeSpanFromTime(String time) { + try { + final now = DateTime.now(); + final temps = time.split(":").map((e) => e.trim()).toList(); + return DateTime( + now.year, + now.month, + now.day, + int.parse(temps[0]), + int.parse(temps[1]), + ).millisecondsSinceEpoch; + } catch (e) { + return 0; + } + } + + static DateTime? getDateFromText(String stringTime, {String stringFormat = "MM/dd/yyyy hh:mm:ss"}) { + try { + DateFormat format = DateFormat(stringFormat); + DateTime dateTime = format.parse(stringTime); + return dateTime; + } catch (e) { + return null; + } + } + + static String fileSize(double size) { + double d1 = size / 1024.0; + double d2 = d1 / 1024.0; + double d3 = d2 / 1024.0; + if (size < 1024.0) { + return size.toString() + " bytes"; + } + if (d1 < 1024.0) { + return d1.round().toString() + " KB"; + } + if (d2 < 1024.0) { + return d2.round().toString() + " MB"; + } + return d3.round().toString() + " GB"; + } + + static Future openLaunch(String link, + {mode = LaunchMode.externalApplication, String appStoreLink = "", bool isOpenAppWebView = false}) async { + if (isOpenAppWebView) { + } else { + final uri = Uri.parse(link); + if (await canLaunchUrl(uri)) { + await launchUrl( + uri, + mode: mode, + ); + } else { + if (appStoreLink.isNotEmpty) { + await launchUrl(Uri.parse(appStoreLink), mode: mode); + } + //throw 'Could not launch $link'; + } + } + } + + // static Future _launchURL(Uri link) async { + // try { + // await ct.launchUrl( + // link, + // customTabsOptions: ct.CustomTabsOptions( + // // colorSchemes: ct.CustomTabsColorSchemes.defaults( + // // toolbarColor: theme.colorScheme.surface, + // // ), + // shareState: ct.CustomTabsShareState.on, + // urlBarHidingEnabled: true, + // showTitle: true, + // closeButton: ct.CustomTabsCloseButton( + // icon: 'ic_action_close', + // ), + // ), + // // safariVCOptions: SafariViewControllerOptions( + // // preferredBarTintColor: theme.colorScheme.surface, + // // preferredControlTintColor: theme.colorScheme.onSurface, + // // barCollapsingEnabled: true, + // // dismissButtonStyle: SafariViewControllerDismissButtonStyle.close, + // // ), + // ); + // } catch (e) { + // // If the URL launch fails, an exception will be thrown. (For example, if no browser app is installed on the Android device.) + // debugPrint(e.toString()); + // } + // } + + static String getFormatTime(int number) { + String textTimeFormat = ""; + if (number > 3600) { + final int hours = (number ~/ 3600).round(); + final int minutes = ((number % 3600) / 60).round(); + final int seconds = ((number % 3600) % 60).round(); + textTimeFormat = hours.toString() + + ' giờ ' + + (minutes != 0 ? minutes.toString() + ' phút ' : '') + + (seconds != 0 ? seconds.toString() + ' giây' : ''); + } else { + if (number >= 60) { + final int minutes = (number ~/ 60).round(); + final int seconds = number % 60; + textTimeFormat = minutes.toString() + ' phút ' + (seconds != 0 ? seconds.toString() + ' giây' : ''); + } else { + textTimeFormat = number.toString() + ' giây'; + } + } + return textTimeFormat; + } + + static paginate(List array, int pageSize, int pageNumber) { + return array.sublist( + (pageNumber - 1) * pageSize, (pageNumber * pageSize) > array.length ? array.length : pageNumber * pageSize); + } + + // static Future checkVersionOff() async { + // // if (kDebugMode) return false; + // final configs = getItSuper().appSettings.configs; + // if (configs.schoolVersionAppOffRegister == null) return false; + + // final List listVersionEnable = + // configs.schoolVersionAppOffRegister!.map((VersionModel e) => e.version).toList(); + // final String currentVersion = await PackageInfo.fromPlatform().then((PackageInfo value) => value.version); + // return listVersionEnable.firstWhere((String element) => element == currentVersion, orElse: () => '').isNotEmpty && + // Platform.isIOS; + // } +} diff --git a/lib/core/common/validators.dart b/lib/core/common/validators.dart new file mode 100644 index 0000000..31debe7 --- /dev/null +++ b/lib/core/common/validators.dart @@ -0,0 +1,223 @@ +RegExp _email = RegExp( + r"^((([a-z]|\d|[!#\$%&'*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+(\.([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+)*)|((\x22)((((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(([\x01-\x08\x0b\x0c\x0e-\x1f\x7f]|\x21|[\x23-\x5b]|[\x5d-\x7e]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(\\([\x01-\x09\x0b\x0c\x0d-\x7f]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))))*(((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(\x22)))@((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))$"); + +RegExp _phoneNumber = RegExp(r'^(84|0[3|5|7|8|9])+([0-9]{8})\b$'); + +RegExp _ipv4Maybe = RegExp(r'^(\d?\d?\d)\.(\d?\d?\d)\.(\d?\d?\d)\.(\d?\d?\d)$'); +RegExp _ipv6 = RegExp(r'^::|^::1|^([a-fA-F0-9]{1,4}::?){1,7}([a-fA-F0-9]{1,4})$'); + +RegExp _creditCard = RegExp( + r'^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\d{3})\d{11})$'); + +/// check if the string [str] is an email +bool isEmail(String str) { + return _email.hasMatch(str.toLowerCase()); +} + +bool isPhoneNumber(String str) { + return _phoneNumber.hasMatch(str.toLowerCase()); +} + +T? shift(List l) { + if (l.isNotEmpty) { + // ignore: always_specify_types + final first = l.first; + l.removeAt(0); + return first; + } + return null; +} + +/// check if the string [str] is a URL +/// +/// * [protocols] sets the list of allowed protocols +/// * [requireTld] sets if TLD is required +/// * [requireProtocol] is a `bool` that sets if protocol is required for validation +/// * [allowUnderscore] sets if underscores are allowed +/// * [hostWhitelist] sets the list of allowed hosts +/// * [hostBlacklist] sets the list of disallowed hosts +bool isURL(String? str, + {List protocols = const ['http', 'https', 'ftp'], + bool requireTld = true, + bool requireProtocol = false, + bool allowUnderscore = false, + List hostWhitelist = const [], + List hostBlacklist = const []}) { + if (str == null || str.isEmpty || str.length > 2083 || str.startsWith('mailto:')) { + return false; + } + int port; + String? protocol, auth, user; + String host, hostname, portStr, path, query, hash; + + // check protocol + var split = str.split('://'); + if (split.length > 1) { + protocol = shift(split); + if (!protocols.contains(protocol)) { + return false; + } + } else if (requireProtocol == true) { + return false; + } + str = split.join('://'); + + // check hash + split = str.split('#'); + str = shift(split); + hash = split.join('#'); + if (hash.isNotEmpty && RegExp(r'\s').hasMatch(hash)) { + return false; + } + + // check query params + split = str!.split('?'); + str = shift(split); + query = split.join('?'); + if (query.isNotEmpty && RegExp(r'\s').hasMatch(query)) { + return false; + } + + // check path + split = str!.split('/'); + str = shift(split); + path = split.join('/'); + if (path.isNotEmpty && RegExp(r'\s').hasMatch(path)) { + return false; + } + + // check auth type urls + split = str!.split('@'); + if (split.length > 1) { + auth = shift(split); + if (auth?.contains(':') ?? false) { + user = shift(auth!.split(':'))!; + if (!RegExp(r'^\S+$').hasMatch(user)) { + return false; + } + if (!RegExp(r'^\S*$').hasMatch(user)) { + return false; + } + } + } + + // check hostname + hostname = split.join('@'); + split = hostname.split(':'); + host = shift(split)!; + if (split.isNotEmpty) { + portStr = split.join(':'); + try { + port = int.parse(portStr, radix: 10); + } catch (e) { + return false; + } + if (!RegExp(r'^[0-9]+$').hasMatch(portStr) || port <= 0 || port > 65535) { + return false; + } + } + + if (!isIP(host, null) && + !isFQDN(host, requireTld: requireTld, allowUnderscores: allowUnderscore) && + host != 'localhost') { + return false; + } + + if (hostWhitelist.isNotEmpty && !hostWhitelist.contains(host)) { + return false; + } + + if (hostBlacklist.isNotEmpty && hostBlacklist.contains(host)) { + return false; + } + + return true; +} + +/// check if the string [str] is IP [version] 4 or 6 +/// +/// * [version] is a String or an `int`. +bool isIP(String? str, int? version) { + if (version == null) { + return isIP(str, 4) || isIP(str, 6); + } else if (version == 4) { + if (!_ipv4Maybe.hasMatch(str!)) { + return false; + } + var parts = str.split('.'); + parts.sort((a, b) => int.parse(a) - int.parse(b)); + return int.parse(parts[3]) <= 255; + } + return version == 6 && _ipv6.hasMatch(str!); +} + +/// check if the string [str] is a fully qualified domain name (e.g. domain.com). +/// +/// * [requireTld] sets if TLD is required +/// * [allowUnderscore] sets if underscores are allowed +bool isFQDN(String str, {bool requireTld = true, bool allowUnderscores = false}) { + var parts = str.split('.'); + if (requireTld) { + var tld = parts.removeLast(); + if (parts.isEmpty || !RegExp(r'^[a-z]{2,}$').hasMatch(tld)) { + return false; + } + } + + for (var part in parts) { + if (allowUnderscores) { + if (part.contains('__')) { + return false; + } + } + if (!RegExp(r'^[a-z\\u00a1-\\uffff0-9-]+$').hasMatch(part)) { + return false; + } + if (part[0] == '-' || part[part.length - 1] == '-' || part.contains('---')) { + return false; + } + } + return true; +} + +/// check if the string is a credit card +bool isCreditCard(String str) { + var sanitized = str.replaceAll(RegExp(r'[^0-9]+'), ''); + if (!_creditCard.hasMatch(sanitized)) { + return false; + } + + // Luhn algorithm + var sum = 0; + String digit; + var shouldDouble = false; + + for (var i = sanitized.length - 1; i >= 0; i--) { + digit = sanitized.substring(i, (i + 1)); + var tmpNum = int.parse(digit); + + if (shouldDouble == true) { + tmpNum *= 2; + if (tmpNum >= 10) { + sum += ((tmpNum % 10) + 1); + } else { + sum += tmpNum; + } + } else { + sum += tmpNum; + } + shouldDouble = !shouldDouble; + } + + return (sum % 10 == 0); +} + +/// check if the string is a date +bool isDate(String str) { + try { + DateTime.parse(str); + return true; + } catch (e) { + return false; + } +} diff --git a/lib/core/components/alice.dart b/lib/core/components/alice.dart new file mode 100644 index 0000000..50dd20d --- /dev/null +++ b/lib/core/components/alice.dart @@ -0,0 +1,37 @@ +import 'package:alice/alice.dart'; +import 'package:flutter/material.dart'; + +class CustomAlice { + static final Alice _aliceCore = Alice(showNotification: false, showInspectorOnShake: true); + + static Alice setAndGetAlice(GlobalKey? navigatorKey) { + _aliceCore.setNavigatorKey(navigatorKey!); + return _aliceCore; + } + + static void showScreen() { + _aliceCore.showInspector(); + } + + static void showAliceOverlay(BuildContext context) async { + final OverlayState? overlayState = Overlay.of(context); + final OverlayEntry overlayEntry = OverlayEntry( + builder: (BuildContext context) => Positioned( + bottom: 0.0, + right: 0.0, + child: GestureDetector( + onTap: () { + _aliceCore.showInspector(); + }, + child: const CircleAvatar( + radius: 10.0, + backgroundColor: Colors.blue, + child: Text("A"), + ), + ), + )); + overlayState!.insert(overlayEntry); + await Future.delayed(const Duration(days: 365)); + overlayEntry.remove(); + } +} diff --git a/lib/core/components/checkbox/custom_checkbox.dart b/lib/core/components/checkbox/custom_checkbox.dart new file mode 100644 index 0000000..ef9336d --- /dev/null +++ b/lib/core/components/checkbox/custom_checkbox.dart @@ -0,0 +1,281 @@ +import 'package:baseproject/core/components/form/form_builder_field.dart'; +import 'package:baseproject/core/theme/text_style.dart'; +import 'package:flutter/material.dart'; + +// ignore: must_be_immutable +class CustomCheckbox extends FormBuilderField { + final String? title; + + final TextStyle? titleTextStyle; + + final EdgeInsets? margin; + + final double scale; + + final bool isAlone; + + /// The primary content of the CheckboxListTile. + /// + /// Typically a [Text] widget. + final Widget? titleWidget; + + /// Additional content displayed below the title. + /// + /// Typically a [Text] widget. + //final Widget? subtitle; + + /// A widget to display on the opposite side of the tile from the checkbox. + /// + /// Typically an [Icon] widget. + final Widget? secondary; + + /// The color to use when this checkbox is checked. + /// + /// Defaults to accent color of the current [Theme]. + final Color? activeColor; + + /// The color to use for the check icon when this checkbox is checked. + /// + /// Defaults to Color(0xFFFFFFFF). + final Color? checkColor; + + /// Where to place the control relative to its label. + final ListTileControlAffinity controlAffinity; + + /// Defines insets surrounding the tile's contents. + /// + /// This value will surround the [Checkbox], [titleWidget], [subtitle], and [secondary] + /// widgets in [CheckboxListTile]. + /// + /// When the value is null, the `contentPadding` is `EdgeInsets.symmetric(horizontal: 16.0)`. + //final EdgeInsets contentPadding; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// If true the checkbox's [value] can be true, false, or null. + /// + /// Checkbox displays a dash when its value is null. + /// + /// When a tri-state checkbox ([tristate] is true) is tapped, its [onChanged] + /// callback will be applied to true if the current value is false, to null if + /// value is true, and to false if value is null (i.e. it cycles through false + /// => true => null => false when tapped). + /// + /// If tristate is false (the default), [value] must not be null. + final bool tristate; + + /// Whether to render icons and text in the [activeColor]. + /// + /// No effort is made to automatically coordinate the [selected] state and the + /// [value] state. To have the list tile appear selected when the checkbox is + /// checked, pass the same value to both. + /// + /// Normally, this property is left to its default value, false. + //final bool selected; + + final MainAxisAlignment mainAxisAlignment; + + final bool isCheckboxOnly; + + BorderSide? side; + + /// Creates a single Checkbox field + CustomCheckbox({ + //From Super + Key? key, + String name = "Checkbox", + FormFieldValidator? validator, + bool? initialValue, + InputDecoration decoration = const InputDecoration( + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + contentPadding: EdgeInsets.symmetric(horizontal: 0, vertical: 0), + isDense: true, + ), + ValueChanged? onChanged, + ValueTransformer? valueTransformer, + bool enabled = true, + FormFieldSetter? onSaved, + AutovalidateMode autovalidateMode = AutovalidateMode.disabled, + VoidCallback? onReset, + FocusNode? focusNode, + this.titleWidget, + this.activeColor, + this.checkColor, + //this.subtitle, + this.secondary, + this.controlAffinity = ListTileControlAffinity.leading, + //this.contentPadding = EdgeInsets.zero, + this.autofocus = false, + this.tristate = false, + // this.selected = false, + this.title, + this.titleTextStyle, + this.margin, + this.scale = 1.2, + this.isAlone = true, + this.side, + this.mainAxisAlignment = MainAxisAlignment.start, + this.isCheckboxOnly = false, + }) : super( + key: key, + initialValue: initialValue, + name: !isAlone ? name : "", + validator: validator, + valueTransformer: valueTransformer, + onChanged: onChanged, + autovalidateMode: autovalidateMode, + onSaved: onSaved, + enabled: enabled, + onReset: onReset, + decoration: decoration, + focusNode: focusNode, + builder: (FormFieldState field) { + final _BNDCheckBoxState state = field as _BNDCheckBoxState; + return !isCheckboxOnly + ? (!isAlone + ? InputDecorator( + decoration: state.decoration(), + child: state.buildCheckbox(), + ) + : Container(child: state.buildCheckbox())) + : state.buildCheckboxOnly(); + }, + ); + + @override + _BNDCheckBoxState createState() => _BNDCheckBoxState(); +} + +class _BNDCheckBoxState extends FormBuilderFieldState { + Widget? get titleWidget => widget.titleWidget; + String? get title => widget.title; + + @override + void didUpdateWidget(covariant CustomCheckbox oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initialValue != widget.initialValue) { + try { + didChange(widget.initialValue, runOnChange: false); + } catch (e) {} + } + } + + Widget? getTitleWidget() { + if (widget.titleWidget != null) { + return titleWidget; + } else if (title != null && title!.isNotEmpty) { + return Text( + title!, + style: widget.titleTextStyle ?? textStyleBodyDefault, + ); + } + return null; + } + + Widget buildCheckbox() { + return Row(mainAxisAlignment: widget.mainAxisAlignment, children: [ + buildCheckboxOnly(), + Expanded( + child: GestureDetector( + onTap: () { + if (widget.enabled) didChange(!(value ?? false)); + }, + child: getTitleWidget() ?? const SizedBox()), + ), + ]); + } + + Widget buildCheckboxOnly() { + return Transform.scale( + scale: widget.scale, + child: Container( + width: 23, + height: 23, + margin: widget.margin ?? const EdgeInsets.only(right: 8), + child: Checkbox( + value: value, + onChanged: widget.enabled + ? (bool? val) { + //state.requestFocus(); + didChange(val); + } + : null, + checkColor: widget.checkColor, + activeColor: widget.activeColor, + autofocus: widget.autofocus, + tristate: widget.tristate, + side: widget.side, + ), + ), + ); + } +} + + + +// class BndCheckboxWidget extends StatefulWidget { +// final List listData; + +// const BndCheckboxWidget({Key? key, required this.listData}) : super(key: key); + +// @override +// State createState() => _BndCheckboxWidgetState(); +// } + +// class _BndCheckboxWidgetState extends State { +// @override +// Widget build(BuildContext context) { +// return Column( +// mainAxisSize: MainAxisSize.min, +// children: [ +// for (CheckBoxSelectedModel dataModel in widget.listData) +// _CustomCheckBox( +// context, +// data: dataModel, +// onChange: () { +// setState(() { +// dataModel.isSelected = !dataModel.isSelected; +// }); +// }, +// ) +// ], +// ); +// } + +// Widget _CustomCheckBox(BuildContext context, +// {required CheckBoxSelectedModel data, VoidCallback? onChange}) { +// return GestureDetector( +// onTap: onChange, +// child: Container( +// margin: const EdgeInsets.only(bottom: 20), +// color: Colors.transparent, +// child: Row( +// mainAxisSize: MainAxisSize.max, +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// Container( +// width: 22, +// height: 22, +// child: Images.svgAssets(data.isSelected +// ? Images.icCheckboxSelected +// : Images.icCheckbox), +// ), +// const SizedBox( +// width: 12, +// ), +// Expanded( +// child: Text(data.title, +// style: textStyleBodyMedium.copyWith( +// fontWeight: FontWeight.w400, color: CustomColor.textDark)), +// ) +// ], +// ), +// ), +// ); +// } +// } diff --git a/lib/core/components/constants_widget.dart b/lib/core/components/constants_widget.dart new file mode 100644 index 0000000..40814bf --- /dev/null +++ b/lib/core/components/constants_widget.dart @@ -0,0 +1,380 @@ +import 'package:baseproject/core/components/image/custom_image.dart'; +import 'package:baseproject/core/language/app_localizations.dart'; +import 'package:baseproject/core/theme/custom_color.dart'; +import 'package:baseproject/core/theme/size.dart'; +import 'package:baseproject/core/theme/text_style.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class ConstantWidget { + ConstantWidget._internal(); + + /// Height + /// + /// SizedBox has [height] = 2 + static Widget heightSpace2 = const SizedBox(height: 2); + + /// SizedBox has [height] = 4 + static Widget heightSpace4 = const SizedBox(height: 4); + + /// SizedBox has [height] = 6 + static Widget heightSpace6 = const SizedBox(height: 6); + + /// SizedBox has [height] = 8 + static Widget heightSpace8 = const SizedBox(height: 8); + + /// SizedBox has [height] = 10 + static Widget heightSpace10 = const SizedBox(height: 10); + + /// SizedBox has [height] = 12 + static Widget heightSpace12 = const SizedBox(height: 12); + + /// SizedBox has [height] = 14 + static Widget heightSpace14 = const SizedBox(height: 14); + + /// SizedBox has [height] = 16 + static Widget heightSpace16 = const SizedBox(height: 16); + + /// SizedBox has [height] = 18 + static Widget heightSpace18 = const SizedBox(height: 18); + + /// SizedBox has [height] = 20 + static Widget heightSpace20 = const SizedBox(height: 20); + + /// SizedBox has [height] = 22 + static Widget heightSpace22 = const SizedBox(height: 22); + + /// SizedBox has [height] = 24 + static Widget heightSpace24 = const SizedBox(height: 24); + + /// SizedBox has [height] = 26 + static Widget heightSpace26 = const SizedBox(height: 26); + + /// SizedBox has [height] = 28 + static Widget heightSpace28 = const SizedBox(height: 28); + + /// SizedBox has [height] = 30 + static Widget heightSpace30 = const SizedBox(height: 30); + + /// SizedBox has [height] = 32 + static Widget heightSpace32 = const SizedBox(height: 32); + + /// SizedBox has [height] = 36 + static Widget heightSpace36 = const SizedBox(height: 36); + + /// SizedBox has [height] = 40 + static Widget heightSpace40 = const SizedBox(height: 40); + + /// SizedBox has [height] = 60 + static Widget heightSpace60 = const SizedBox(height: 60); + + /// SizedBox has [height] = 80 + static Widget heightSpace80 = const SizedBox(height: 80); + + /// SizedBox has [height] = 90 + static Widget heightSpace90 = const SizedBox(height: 90); + + /// SizedBox has [height] = 100 + static Widget heightSpace100 = const SizedBox(height: 100); + + /// Width + /// + /// SizedBox has [height] = 2 + static Widget widthSpace2 = const SizedBox(width: 2); + + /// SizedBox has [height] = 4 + static Widget widthSpace4 = const SizedBox(width: 4); + + /// SizedBox has [height] = 6 + static Widget widthSpace6 = const SizedBox(width: 6); + + /// SizedBox has [height] = 8 + static Widget widthSpace8 = const SizedBox(width: 8); + + /// SizedBox has [height] = 10 + static Widget widthSpace10 = const SizedBox(width: 10); + + /// SizedBox has [height] = 12 + static Widget widthSpace12 = const SizedBox(width: 12); + + /// SizedBox has [height] = 14 + static Widget widthSpace14 = const SizedBox(width: 14); + + /// SizedBox has [height] = 16 + static Widget widthSpace16 = const SizedBox(width: 16); + + /// SizedBox has [height] = 18 + static Widget widthSpace18 = const SizedBox(width: 18); + + /// SizedBox has [height] = 20 + static Widget widthSpace20 = const SizedBox(width: 20); + + /// SizedBox has [height] = 22 + static Widget widthSpace22 = const SizedBox(width: 22); + + /// SizedBox has [height] = 24 + static Widget widthSpace24 = const SizedBox(width: 24); + + /// SizedBox has [height] = 26 + static Widget widthSpace26 = const SizedBox(width: 26); + + /// SizedBox has [height] = 28 + static Widget widthSpace28 = const SizedBox(width: 28); + + /// SizedBox has [height] = 30 + static Widget widthSpace30 = const SizedBox(width: 30); + + /// SizedBox has [height] = 32 + static Widget widthSpace32 = const SizedBox(width: 32); + + /// SizedBox has [height] = 36 + static Widget widthSpace36 = const SizedBox(width: 36); + + /// SizedBox has [height] = 40 + static Widget widthSpace40 = const SizedBox(width: 40); + + /// SizedBox has [height] = 60 + static Widget widthSpace60 = const SizedBox(width: 60); + + /// SizedBox has [height] = 80 + static Widget widthSpace80 = const SizedBox(width: 80); + + /// SizedBox has [height] = 100 + static Widget widthSpace100 = const SizedBox(width: 100); + + static Widget dividerDefault = Divider(color: CustomColor.dividerDefaultColor, thickness: 1, height: 1); + + static Widget appbarTitleWidget(String text) => Align( + alignment: Alignment.center, + child: Text( + text, + style: textAppBarDefault, + ), + ); + + static Widget appBarBackButtonDefault(BuildContext context, {Function()? onBack}) => IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () { + if (onBack != null) { + onBack(); + } else { + Navigator.of(context).pop(); + } + }, + icon: SizedBox( + height: 36, + width: 36, + child: Icon( + Icons.arrow_back_ios, + color: CustomColor.kTextWhiteColor, + size: 18, + ), + ), + ); + static Widget appBarCloseButtonDefault(BuildContext context) => IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () { + Navigator.of(context).pop(); + }, + icon: SizedBox( + height: 36, + width: 36, + child: Icon( + Icons.close, + color: CustomColor.kTextWhiteColor, + size: 20, + ), + ), + ); + + static Text textBodyDefault( + String text, { + FontWeight fontWeight = FontWeight.w400, + TextDecoration decoration = TextDecoration.none, + Color? color, + TextAlign? textAlign, + }) => + Text( + text, + style: textStyleBodyDefault.copyWith( + fontWeight: fontWeight, + decoration: decoration, + color: color, + ), + textAlign: textAlign, + ); + + static Text textHeadline6(String text) => Text( + text, + style: textStyleHeadline6, + ); + + static Text textHeadline6White(String text) => Text( + text, + style: textStyleHeadline6.copyWith(color: CustomColor.kTextWhiteColor), + ); + + static Text textBodyDefaultWhite(String text, {FontWeight fontWeight = FontWeight.w400}) => Text( + text, + style: textStyleBodyDefault.copyWith(fontWeight: fontWeight, color: CustomColor.kTextWhiteColor), + ); + + static Text textBodySmall( + String text, { + FontWeight fontWeight = FontWeight.w400, + Color? color, + TextAlign? textAlign, + }) => + Text( + text, + style: textStyleBodySmall.copyWith( + fontWeight: fontWeight, + color: color, + ), + textAlign: textAlign, + ); + + static Text subTextBodySmall(String text, {FontWeight fontWeight = FontWeight.w400}) => Text( + text, + style: textStyleBodySmall.copyWith(fontWeight: fontWeight, color: CustomColor.colorSubText), + ); + + static Text textBodySmallWhite(String text, {FontWeight fontWeight = FontWeight.w400}) => Text( + text, + style: textStyleBodySmallWhite.copyWith(fontWeight: fontWeight, color: CustomColor.kTextWhiteColor), + ); + static Card buildCardDefault( + {required Widget child, EdgeInsets? margin, ShapeBorder? border, EdgeInsets? padding, Color? color}) => + Card( + margin: margin, + shape: border, + child: Container( + color: color, + width: double.infinity, + padding: padding ?? const EdgeInsets.all(12), + child: child, + ), + ); + + static Container defaultContainer({ + required Widget child, + double borderRadius = 8, + Color? color, + EdgeInsets? padding, + double? width, + BoxBorder? border, + }) => + Container( + width: width, + child: child, + padding: padding, + decoration: BoxDecoration( + color: color, + border: border, + borderRadius: BorderRadius.circular(borderRadius), + ), + ); + + static Container circleContainer({ + Color? color, + EdgeInsets? padding, + required Widget child, + double? size, + }) => + Container( + width: size, + height: size, + padding: padding, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + child: child, + ); + + static Row rowIconAndText(String icPath, String text) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + svgImage(icPath, color: CustomColor.colorSubText, width: 18), + ConstantWidget.widthSpace10, + Expanded( + child: Text( + text, + style: textStyleBodyDefault, + ), + ), + ], + ); + + static Container titleDateTime(BuildContext context, DateTime date) => titleBackgroundDefault( + context, + AppLocalizations.of(context)!.displayDateTime(date, isFullTime: false), + ); + + static Container titleBackgroundDefault(BuildContext context, String text) => Container( + alignment: Alignment.center, + width: double.infinity, + padding: EdgeInsets.symmetric(vertical: 6, horizontal: 2), + decoration: BoxDecoration( + color: CustomColor.colorButtonDefault, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: textStyleBodyDefault.copyWith( + color: CustomColor.colorTextButtonDefault, + fontWeight: FontWeight.w600, + ), + ), + ); + + static Center listLoadingDefault() => const Center(child: CupertinoActivityIndicator()); + static Center noData() => Center( + child: ConstantWidget.textBodyDefault("Không có dữ liệu"), + ); + + static Container bottomContainer({ + Color? color, + EdgeInsets? padding, + required Widget child, + }) => + Container( + padding: padding ?? paddingBodyDefault.copyWith(top: 10, bottom: 10), + decoration: BoxDecoration( + color: CustomColor.kTextWhiteColor, + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + spreadRadius: 5, + blurRadius: 7, + offset: Offset(0, 3), // changes position of shadow + ), + ], + ), + child: child, + ); + + static Widget defaultSafeArea({ + bool top = false, + bool bottom = true, + required Widget child, + }) => + SafeArea( + top: top, + bottom: bottom, + child: child, + ); + + static ClipRRect defaultTopClipR({required Widget child}) => ClipRRect( + child: child, //widget.userId Todo + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(28), + topRight: Radius.circular(28), + ), + ); +} diff --git a/lib/core/components/custom_pull_to_refresh.dart b/lib/core/components/custom_pull_to_refresh.dart new file mode 100644 index 0000000..2b06841 --- /dev/null +++ b/lib/core/components/custom_pull_to_refresh.dart @@ -0,0 +1,173 @@ +import 'package:baseproject/core/components/constants_widget.dart'; +import 'package:baseproject/core/theme/custom_color.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +class CustomPullToRefresh extends StatelessWidget { + const CustomPullToRefresh( + {Key? key, + required this.controller, + this.child, + this.header, + this.footer, + this.enablePullDown = true, + this.enablePullUp = false, + this.enableTwoLevel = false, + this.onRefresh, + this.onLoading, + this.onTwoLevel, + this.dragStartBehavior, + this.primary, + this.cacheExtent, + this.semanticChildCount, + this.reverse, + this.physics, + this.scrollDirection, + this.scrollController, + this.builder, + this.backgroundPullRefresh}) + : super(key: key); + + /// Refresh Content + /// + /// notice that: If child is extends ScrollView,It will help you get the internal slivers and add footer and header in it. + /// else it will put child into SliverToBoxAdapter and add footer and header + final Widget? child; + + /// header indicator displace before content + /// + /// If reverse is false,header displace at the top of content. + /// If reverse is true,header displace at the bottom of content. + /// if scrollDirection = Axis.horizontal,it will display at left or right + /// + /// from 1.5.2,it has been change RefreshIndicator to Widget,but remember only pass sliver widget, + /// if you pass not a sliver,it will throw error + final Widget? header; + + /// footer indicator display after content + /// + /// If reverse is true,header displace at the top of content. + /// If reverse is false,header displace at the bottom of content. + /// if scrollDirection = Axis.horizontal,it will display at left or right + /// + /// from 1.5.2,it has been change LoadIndicator to Widget,but remember only pass sliver widget, + // if you pass not a sliver,it will throw error + final Widget? footer; + + // This bool will affect whether or not to have the function of drop-up load. + final bool enablePullUp; + + /// controll whether open the second floor function + final bool enableTwoLevel; + + /// This bool will affect whether or not to have the function of drop-down refresh. + final bool enablePullDown; + + /// callback when header refresh + /// + /// when the callback is happening,you should use [RefreshController] + /// to end refreshing state,else it will keep refreshing state + final VoidCallback? onRefresh; + + /// callback when footer loading more data + /// + /// when the callback is happening,you should use [RefreshController] + /// to end loading state,else it will keep loading state + final VoidCallback? onLoading; + + /// callback when header ready to twoLevel + /// + /// If you want to close twoLevel,you should use [RefreshController.closeTwoLevel] + final OnTwoLevel? onTwoLevel; + + /// Controll inner state + final RefreshController controller; + + /// child content builder + final RefresherBuilder? builder; + + /// copy from ScrollView,for setting in SingleChildView,not ScrollView + final Axis? scrollDirection; + + /// copy from ScrollView,for setting in SingleChildView,not ScrollView + final bool? reverse; + + /// copy from ScrollView,for setting in SingleChildView,not ScrollView + final ScrollController? scrollController; + + /// copy from ScrollView,for setting in SingleChildView,not ScrollView + final bool? primary; + + /// copy from ScrollView,for setting in SingleChildView,not ScrollView + final ScrollPhysics? physics; + + /// copy from ScrollView,for setting in SingleChildView,not ScrollView + final double? cacheExtent; + + /// copy from ScrollView,for setting in SingleChildView,not ScrollView + final int? semanticChildCount; + + /// copy from ScrollView,for setting in SingleChildView,not ScrollView + final DragStartBehavior? dragStartBehavior; + + /// creates a widget help attach the refresh and load more function + /// controller must not be null, + /// child is your refresh content,Note that there's a big difference between children inheriting from ScrollView or not. + /// If child is extends ScrollView,inner will get the slivers from ScrollView,if not,inner will wrap child into SliverToBoxAdapter. + /// If your child inner container Scrollable,please consider about converting to Sliver,and use CustomScrollView,or use [builder] constructor + /// such as AnimatedList,RecordableList,doesn't allow to put into child,it will wrap it into SliverToBoxAdapter + /// If you don't need pull down refresh ,just enablePullDown = false, + /// If you need pull up load ,just enablePullUp = true + /// + final Color? backgroundPullRefresh; + + @override + Widget build(BuildContext context) { + return SmartRefresher( + key: key, + controller: controller, + child: child, + header: + header ?? WaterDropMaterialHeader(backgroundColor: backgroundPullRefresh ?? CustomColor.darkSecondColor), + footer: footer ?? + CustomFooter( + builder: (BuildContext context, LoadStatus? mode) { + Widget body = const SizedBox(); + if (mode == LoadStatus.idle) { + body = ConstantWidget.textBodyDefault( + "Kéo lên để tải", + ); + } else if (mode == LoadStatus.loading) { + body = const CupertinoActivityIndicator(); + } else if (mode == LoadStatus.failed) { + body = ConstantWidget.textBodyDefault("Tải xuống lỗi! Nhấn để thử lại!"); + } else if (mode == LoadStatus.canLoading) { + body = ConstantWidget.textBodyDefault("Kéo lên để lấy thêm dữ liệu"); + } else { + body = ConstantWidget.textBodyDefault("Hết dữ liệu để tải"); + } + return SizedBox( + height: 50, + child: Center( + child: body, + ), + ); + }, + ), + enablePullDown: enablePullDown, + enablePullUp: enablePullUp, + enableTwoLevel: enableTwoLevel, + onRefresh: onRefresh, + onLoading: onLoading, + onTwoLevel: onTwoLevel, + dragStartBehavior: dragStartBehavior, + primary: primary, + cacheExtent: cacheExtent, + semanticChildCount: semanticChildCount, + reverse: reverse, + physics: physics, + scrollDirection: scrollDirection, + scrollController: scrollController); + } +} diff --git a/lib/core/components/date/date_time_picker.dart b/lib/core/components/date/date_time_picker.dart new file mode 100644 index 0000000..92520e0 --- /dev/null +++ b/lib/core/components/date/date_time_picker.dart @@ -0,0 +1,450 @@ +import 'dart:io'; + +import 'package:baseproject/core/common/utils.dart'; +import 'package:baseproject/core/components/form/form_builder_field.dart'; +import 'package:baseproject/core/language/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_datetime_picker_plus/flutter_datetime_picker_plus.dart'; +import 'package:intl/intl.dart'; +import 'dart:ui' as ui; + +enum InputType { date, time, both } + +// ignore: must_be_immutable +class DateTimePicker extends FormBuilderField { + /// The date/time picker dialogs to show. + final InputType inputType; + + /// Allow manual editing of the date/time. Defaults to true. If false, the + /// picker(s) will be shown every time the field gains focus. + // final bool editable; + + /// For representing the date as a string e.g. + /// `DateFormat("EEEE, MMMM d, yyyy 'at' h:mma")` + /// (Sunday, June 3, 2018 at 9:24pm) + final DateFormat? format; + + /// The date the calendar opens to when displayed. Defaults to the current date. + /// + /// To preset the widget's value, use [initialValue] instead. + final DateTime? initialDate; + + /// The earliest choosable date. Defaults to 1900. + final DateTime? firstDate; + + /// The latest choosable date. Defaults to 2100. + final DateTime? lastDate; + + final DateTime? currentDate; + + /// The initial time prefilled in the picker dialog when it is shown. Defaults + /// to noon. Explicitly set this to `null` to use the current time. + final TimeOfDay initialTime; + + /// If defined, the TextField [decoration]'s [suffixIcon] will be + /// overridden to reset the input using the icon defined here. + /// Set this to `null` to stop that behavior. Defaults to [Icons.close]. + final Icon resetIcon; + + /// Called when an enclosing form is saved. The value passed will be `null` + /// if [format] fails to parse the text. + // final FormFieldSetter onSaved; + + /// Corresponds to the [showDatePicker()] parameter. Defaults to + /// [DatePickerMode.day]. + final DatePickerMode initialDatePickerMode; + + /// Corresponds to the [showDatePicker()] parameter. + /// + /// See [GlobalMaterialLocalizations](https://docs.flutter.io/flutter/flutter_localizations/GlobalMaterialLocalizations-class.html) + /// for acceptable values. + final Locale? locale; + + /// Corresponds to the [showDatePicker()] parameter. + final ui.TextDirection? textDirection; + + /// Corresponds to the [showDatePicker()] parameter. + final bool useRootNavigator; + + /// Called when an enclosing form is submitted. The value passed will be + /// `null` if [format] fails to parse the text. + final ValueChanged? onFieldSubmitted; + final TextEditingController? controller; + final TextInputType keyboardType; + final TextStyle? style; + final TextAlign textAlign; + + /// Preset the widget's value. + final bool autofocus; + final bool obscureText; + final bool autocorrect; + final MaxLengthEnforcement maxLengthEnforcement; + final int? maxLines; + final int? maxLength; + final List? inputFormatters; + final TransitionBuilder? transitionBuilder; + + /// Called whenever the state's value changes, e.g. after picker value(s) + /// have been selected or when the field loses focus. To listen for all text + /// changes, use the [controller] and [focusNode]. + // final ValueChanged? onChanged; + + final bool showCursor; + + final int? minLines; + + final bool expands; + + final TextInputAction? textInputAction; + + final VoidCallback? onEditingComplete; + + final InputCounterWidgetBuilder? buildCounter; + + // final VoidCallback onEditingComplete, + final Radius? cursorRadius; + final Color? cursorColor; + final Brightness? keyboardAppearance; + final EdgeInsets scrollPadding; + final bool enableInteractiveSelection; + + final double cursorWidth; + final TextCapitalization textCapitalization; + final bool alwaysUse24HourFormat; + + final String? cancelText; + final String? confirmText; + final String? errorFormatText; + final String? errorInvalidText; + final String? fieldHintText; + final String? fieldLabelText; + final String? helpText; + final DatePickerEntryMode initialEntryMode; + final RouteSettings? routeSettings; + + final TimePickerEntryMode timePickerInitialEntryMode; + final StrutStyle? strutStyle; + final SelectableDayPredicate? selectableDayPredicate; + final double? sizeHeight; + + final bool isShowAfterTime; + + DateTimePicker( + {Key? key, + //From Super + required String name, + FormFieldValidator? validator, + DateTime? initialValue, + InputDecoration decoration = const InputDecoration(), + ValueChanged? onChanged, + ValueTransformer? valueTransformer, + bool enabled = true, + FormFieldSetter? onSaved, + AutovalidateMode autovalidateMode = AutovalidateMode.disabled, + VoidCallback? onReset, + FocusNode? focusNode, + this.inputType = InputType.both, + this.scrollPadding = const EdgeInsets.all(20.0), + this.cursorWidth = 2.0, + this.enableInteractiveSelection = true, + this.resetIcon = const Icon(Icons.close), + this.initialTime = const TimeOfDay(hour: 12, minute: 0), + this.keyboardType = TextInputType.text, + this.textAlign = TextAlign.start, + this.autofocus = false, + this.obscureText = false, + this.autocorrect = true, + this.maxLines = 1, + this.expands = false, + this.initialDatePickerMode = DatePickerMode.day, + this.transitionBuilder, + this.textCapitalization = TextCapitalization.none, + this.useRootNavigator = true, + this.alwaysUse24HourFormat = false, + this.initialEntryMode = DatePickerEntryMode.calendar, + this.timePickerInitialEntryMode = TimePickerEntryMode.dial, + this.format, + this.initialDate, + this.firstDate, + this.lastDate, + this.currentDate, + this.locale, + this.maxLength, + this.textDirection, + this.onFieldSubmitted, + this.controller, + this.style, + this.maxLengthEnforcement = MaxLengthEnforcement.none, + this.inputFormatters, + this.showCursor = false, + this.minLines, + this.textInputAction, + this.onEditingComplete, + this.buildCounter, + this.cursorRadius, + this.cursorColor, + this.keyboardAppearance, + this.cancelText, + this.confirmText, + this.errorFormatText, + this.errorInvalidText, + this.fieldHintText, + this.fieldLabelText, + this.helpText, + this.routeSettings, + this.strutStyle, + this.selectableDayPredicate, + this.sizeHeight, + this.isShowAfterTime = true, + Widget? leftIcon, + Widget rightIcon = const Icon( + Icons.calendar_today_outlined, + size: 18, + ), + Color? backgroundColor, + EdgeInsets? contentPadding}) + : super( + key: key, + initialValue: initialValue, + name: name, + validator: validator, + valueTransformer: valueTransformer, + onChanged: onChanged, + autovalidateMode: autovalidateMode, + onSaved: onSaved, + enabled: enabled, + onReset: onReset, + decoration: decoration, + focusNode: focusNode, + builder: (FormFieldState field) { + final _DateTimePickerState state = field as _DateTimePickerState; + + late InputDecoration decoration = + state.decoration().copyWith(border: OutlineInputBorder(borderRadius: BorderRadius.circular(70))); + + if (backgroundColor != null) decoration = decoration.copyWith(filled: true, fillColor: backgroundColor); + if (contentPadding != null) decoration = decoration.copyWith(contentPadding: contentPadding); + if (leftIcon != null) decoration = decoration.copyWith(prefixIcon: leftIcon); + decoration = decoration + .copyWith( + suffixIcon: rightIcon, + suffixIconConstraints: const BoxConstraints(minHeight: 40, minWidth: 40), + ) + .copyWith( + errorBorder: OutlineInputBorder( + borderSide: BorderSide(color: Colors.red), + )); + + return Container( + alignment: Alignment.center, + // height: sizeHeight ?? 52, + child: TextField( + textDirection: textDirection, + textAlign: textAlign, + maxLength: maxLength, + autofocus: autofocus, + decoration: decoration, + readOnly: true, + enabled: state.enabled, + autocorrect: autocorrect, + controller: state._textFieldController, + focusNode: state.effectiveFocusNode, + inputFormatters: inputFormatters, + keyboardType: keyboardType, + maxLines: maxLines, + obscureText: obscureText, + showCursor: showCursor, + minLines: minLines, + expands: expands, + style: style, + onEditingComplete: onEditingComplete, + buildCounter: buildCounter, + cursorColor: cursorColor, + cursorRadius: cursorRadius, + cursorWidth: cursorWidth, + enableInteractiveSelection: enableInteractiveSelection, + keyboardAppearance: keyboardAppearance, + scrollPadding: scrollPadding, + strutStyle: strutStyle, + textCapitalization: textCapitalization, + textInputAction: textInputAction, + maxLengthEnforcement: maxLengthEnforcement, + ), + ); + }, + ); + + @override + _DateTimePickerState createState() => _DateTimePickerState(); +} + +class _DateTimePickerState extends FormBuilderFieldState { + late TextEditingController _textFieldController; + late bool _isShowAfterTime; + late DateFormat _dateFormat; + + @override + void initState() { + super.initState(); + _isShowAfterTime = widget.isShowAfterTime; + _textFieldController = widget.controller ?? TextEditingController(); + _dateFormat = widget.format ?? _getDefaultDateTimeFormat(); + final DateTime? initVal = initialValue; + _textFieldController.text = (initVal == null || (initVal.year == 1)) ? '' : _dateFormat.format(initVal); + effectiveFocusNode!.addListener(_handleFocus); + } + + @override + void didUpdateWidget(covariant FormField oldWidget) { + if (oldWidget.initialValue != widget.initialValue) { + setValue(widget.initialValue); + setState(() { + final DateTime? initVal = initialValue; + _textFieldController.text = initVal == null ? '' : _dateFormat.format(initVal); + }); + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + effectiveFocusNode!.removeListener(_handleFocus); + // Dispose the _textFieldController when initState created it + if (null == widget.controller) { + _textFieldController.dispose(); + } + super.dispose(); + } + + Future _handleFocus() async { + if (effectiveFocusNode!.hasFocus && enabled) { + effectiveFocusNode!.unfocus(); + await onShowPicker(context, value); + } + } + + DateFormat _getDefaultDateTimeFormat() { + final String? languageCode = widget.locale?.languageCode; + switch (widget.inputType) { + case InputType.time: + return DateFormat.Hm(languageCode); + case InputType.date: + return DateFormat.yMd(languageCode); + case InputType.both: + default: + return DateFormat.Hm(languageCode).add_yMd(); + } + } + + Future onShowPicker(BuildContext context, DateTime? currentValue) async { + // ignore: parameter_assignments + currentValue = value; + DateTime? newValue; + switch (widget.inputType) { + case InputType.date: + newValue = await _showDatePicker(context, currentValue); + break; + case InputType.time: + final TimeOfDay? newTime = await _showTimePicker(context, currentValue); + newValue = null != newTime ? convert(newTime) : null; + break; + case InputType.both: + final DateTime? date = await _showDatePicker(context, currentValue); + if (date != null) { + final TimeOfDay? time = await _showTimePicker(context, currentValue); + newValue = combine(date, time); + } + break; + default: + throw 'Unexpected input type ${widget.inputType}'; + } + DateTime? finalValue = newValue ?? currentValue; + if (finalValue != null) { + if (widget.inputType == InputType.date) finalValue = DateTime(finalValue.year, finalValue.month, finalValue.day); + } + didChange(finalValue); + return finalValue; + } + + Future _showDatePicker(BuildContext context, DateTime? currentValue) { + final DateTime _minDate = + _isShowAfterTime != null && !_isShowAfterTime ? DateTime.now() : widget.firstDate ?? DateTime(1900); + if (Platform.isIOS) { + return DatePicker.showDatePicker( + context, + minTime: _minDate, + maxTime: widget.lastDate ?? DateTime(2100), + currentTime: currentValue, + locale: getLocaleType(), + ); + } + + return showDatePicker( + context: context, + selectableDayPredicate: widget.selectableDayPredicate, + initialDatePickerMode: widget.initialDatePickerMode, + initialDate: currentValue ?? widget.initialDate ?? DateTime.now(), + firstDate: _minDate, + lastDate: widget.lastDate ?? DateTime(2100), + locale: widget.locale, + textDirection: widget.textDirection, + useRootNavigator: widget.useRootNavigator, + builder: widget.transitionBuilder, + cancelText: widget.cancelText, + confirmText: widget.confirmText, + errorFormatText: widget.errorFormatText, + errorInvalidText: widget.errorInvalidText, + fieldHintText: widget.fieldHintText, + fieldLabelText: widget.fieldLabelText, + helpText: widget.helpText, + initialEntryMode: widget.initialEntryMode, + routeSettings: widget.routeSettings, + currentDate: widget.currentDate, + ); + } + + Future _showTimePicker(BuildContext context, DateTime? currentValue) async { + if (Platform.isIOS) { + final DateTime? result = await DatePicker.showTimePicker( + context, + currentTime: currentValue, + locale: getLocaleType(), + ); + + if (result == null) return null; + return TimeOfDay.fromDateTime(result); + } + + final TimeOfDay? timePickerResult = await showTimePicker( + context: context, + initialTime: currentValue != null ? TimeOfDay.fromDateTime(currentValue) : widget.initialTime, + builder: widget.transitionBuilder, + useRootNavigator: widget.useRootNavigator, + routeSettings: widget.routeSettings, + initialEntryMode: widget.timePickerInitialEntryMode, + helpText: widget.helpText, + confirmText: widget.confirmText, + cancelText: widget.cancelText, + ); + return timePickerResult ?? (currentValue != null ? TimeOfDay.fromDateTime(currentValue) : null); + } + + LocaleType getLocaleType() { + return Utils.enumFromString(LocaleType.values, AppLocalizations.of(context)!.locale.languageCode); + } + + /// Sets the hour and minute of a [DateTime] from a [TimeOfDay]. + DateTime combine(DateTime date, TimeOfDay? time) => + DateTime(date.year, date.month, date.day, time?.hour ?? 0, time?.minute ?? 0); + + DateTime? convert(TimeOfDay? time) => time == null ? null : DateTime(1, 1, 1, time.hour, time.minute); + + @override + void didChange(DateTime? val, {bool runOnChange = true}) { + super.didChange( + val, + ); + _textFieldController.text = (val == null) ? '' : _dateFormat.format(val); + } +} diff --git a/lib/core/components/form/form_builder.dart b/lib/core/components/form/form_builder.dart new file mode 100644 index 0000000..5fea32d --- /dev/null +++ b/lib/core/components/form/form_builder.dart @@ -0,0 +1,228 @@ +import 'package:baseproject/core/common/utils.dart'; +import 'package:baseproject/core/components/form/form_builder_field.dart'; +import 'package:flutter/material.dart'; + +// ignore: always_specify_types +void setFormValue(GlobalKey formKey, String name, dynamic value) { + if (formKey.currentState == null) return; + final String runTimeType = value.runtimeType.toString(); + if (runTimeType.contains("List")) { + final List> temps = >[]; + for (final Object item in value as List) { + temps.add(Utils.convertObjectToMap(item)); + } + return formKey.currentState!.setInternalFieldValue(name, temps, isUpdateState: false); + } else if (runTimeType.contains("Model")) { + return formKey.currentState!.setInternalFieldValue(name, Utils.convertObjectToMap(value), isUpdateState: false); + } + formKey.currentState!.setInternalFieldValue(name, value, isUpdateState: false); +} + +/// A container for form fields. +class FormBuilder extends StatefulWidget { + /// Called when one of the form fields changes. + /// + /// In addition to this callback being invoked, all the form fields themselves + /// will rebuild. + final VoidCallback? onChanged; + + /// Enables the form to veto attempts by the user to dismiss the [ModalRoute] + /// that contains the form. + /// + /// If the callback returns a Future that resolves to false, the form's route + /// will not be popped. + /// + /// See also: + /// + /// * [WillPopScope], another widget that provides a way to intercept the + /// back button. + final WillPopCallback? onWillPop; + + /// The widget below this widget in the tree. + /// + /// This is the root of the widget hierarchy that contains this form. + /// + /// {@macro flutter.widgets.child} + final Widget child; + + /// Used to enable/disable form fields auto validation and update their error + /// text. + /// + /// {@macro flutter.widgets.form.autovalidateMode} + final AutovalidateMode? autovalidateMode; + + /// An optional Map of field initialValues. Keys correspond to the field's + /// name and value to the initialValue of the field. + /// + /// The initialValues set here will be ignored if the field has a local + /// initialValue set. + final Map initialValue; + + /// Whether the form should ignore submitting values from fields where + /// `enabled` is `false`. + /// This behavior is common in HTML forms where _readonly_ values are not + /// submitted when the form is submitted. + /// + /// When `true`, the final form value will not contain disabled fields. + /// Default is `false`. + final bool skipDisabled; + + /// Whether the form is able to receive user input. + /// + /// Defaults to true. + /// + /// When `false` all the form fields will be disabled - won't accept input - + /// and their enabled state will be ignored. + final bool enabled; + + /// Creates a container for form fields. + /// + /// The [child] argument must not be null. + + // T object; + + FormBuilder({ + Key? key, + required this.child, + this.onChanged, + this.autovalidateMode, + this.onWillPop, + this.initialValue = const {}, + this.skipDisabled = false, + this.enabled = true, + //required this.object, + }) : super(key: key); + + static FormBuilderState? of(BuildContext context) => context.findAncestorStateOfType(); + + @override + FormBuilderState createState() => FormBuilderState(); +} + +class FormBuilderState extends State> { + final GlobalKey _formKey = GlobalKey(); + + bool get enabled => widget.enabled; + + final Map> _fields = {}; + + final Map _value = {}; + + Map get value => Map.unmodifiable(_value); + + Map get initialValue => widget.initialValue; + + Map get fields => _fields; + + //T get object => widget.object; + + /* + bool get hasError => _fields.values.map((e) => e.hasError).firstWhere((element) => element == false, orElse: () => true); + + bool get isValid => _fields.values.map((e) => e.isValid).firstWhere((element) => element == false, orElse: () => true); + */ + @override + void initState() { + // TODO: implement initState + super.initState(); + + // set default value + initialValue.forEach((String key, dynamic value) { + _value[key] = value; + }); + } + + void setInternalFieldValue(String name, dynamic value, {bool isUpdateState = true}) { + if (name.isNotEmpty) { + if (isUpdateState) + setState(() { + _value[name] = value; + }); + else + _value[name] = value; + } + } + + void removeInternalFieldValue(String name) { + setState(() { + _value.remove(name); + }); + } + + void registerField(String name, FormBuilderFieldState field) { + // Each field must have a unique name. Ideally we could simply: + // assert(!_fields.containsKey(name)); + // However, Flutter will delay dispose of deactivated fields, so if a + // field is being replaced, the new instance is registered before the old + // one is unregistered. To accommodate that use case, but also provide + // assistance to accidental duplicate names, we check and emit a warning. + assert(() { + if (_fields.containsKey(name)) { + print('Warning! Replacing duplicate Field for $name' + ' -- this is OK to ignore as long as the field was intentionally replaced'); + } + return true; + }()); + _fields[name] = field; + } + + void unregisterField(String name, FormBuilderFieldState field) { + assert(_fields.containsKey(name)); + // Only remove the field when it is the one registered. It's possible that + // the field is replaced (registerField is called twice for a given name) + // before unregisterField is called for the name, so just emit a warning + // since it may be intentional. + if (field == _fields[name]) { + _fields.remove(name); + _value.remove(name); + } else { + assert(() { + // This is OK to ignore when you are intentionally replacing a field + // with another field using the same name. + print('Warning! Ignoring Field unregistration for $name' + ' -- this is OK to ignore as long as the field was intentionally replaced'); + return true; + }()); + } + } + + void save() { + _formKey.currentState!.save(); + } + + bool validate() { + return _formKey.currentState!.validate(); + } + + bool saveAndValidate() { + save(); + return validate(); + } + + void reset() { + _formKey.currentState!.reset(); + for (final MapEntry> item in _fields.entries) { + item.value.reset(); + } + } + + void patchValue(Map val) { + val.forEach((final String key, dynamic value) { + _fields[key]?.didChange(value); + }); + } + + @override + Widget build(BuildContext context) { + return Form( + key: _formKey, + autovalidateMode: widget.autovalidateMode, + onWillPop: widget.onWillPop, + onChanged: widget.onChanged, + child: FocusTraversalGroup( + policy: WidgetOrderTraversalPolicy(), + child: widget.child, + ), + ); + } +} diff --git a/lib/core/components/form/form_builder_field.dart b/lib/core/components/form/form_builder_field.dart new file mode 100644 index 0000000..2026d8f --- /dev/null +++ b/lib/core/components/form/form_builder_field.dart @@ -0,0 +1,196 @@ +import 'package:baseproject/core/components/form/form_builder.dart'; +import 'package:flutter/material.dart'; + +enum OptionsOrientation { horizontal, vertical, wrap } + +enum ControlAffinity { leading, trailing } + +Type typeOf() => T; // get type + +typedef ValueTransformer = dynamic Function(T value); + +/// A single form field. +/// +/// This widget maintains the current state of the form field, so that updates +/// and validation errors are visually reflected in the UI. +class FormBuilderField extends FormField { + /// Used to reference the field within the form, or to reference form data + /// after the form is submitted. + final String name; + + /// Called just before field value is saved. Used to massage data just before + /// committing the value. + /// + /// This sample shows how to convert age in a [FormBuilderTextField] to number + /// so that the final value is numeric instead of a String + /// + /// ```dart + /// FormBuilderTextField( + /// name: 'age', + /// decoration: InputDecoration(labelText: 'Age'), + /// valueTransformer: (text) => num.tryParse(text), + /// validator: FormBuilderValidators.numeric(context), + /// initialValue: '18', + /// keyboardType: TextInputType.number, + /// ), + /// ``` + final ValueTransformer? valueTransformer; + + /// Called when the field value is changed. + final ValueChanged? onChanged; + + /// The border, labels, icons, and styles used to decorate the field. + late InputDecoration decoration; + + /// Called when the field value is reset. + final VoidCallback? onReset; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + //TODO: implement bool autofocus, ValueChanged onValidated + + /// Creates a single form field. + FormBuilderField({ + Key? key, + //From Super + FormFieldSetter? onSaved, + T? initialValue, + AutovalidateMode autovalidateMode = AutovalidateMode.onUserInteraction, + bool enabled = true, + FormFieldValidator? validator, + required FormFieldBuilder builder, + required this.name, + this.valueTransformer, + this.onChanged, + this.decoration = const InputDecoration(), + this.onReset, + this.focusNode, + }) : super( + key: key, + onSaved: onSaved, + initialValue: initialValue, + autovalidateMode: autovalidateMode, + enabled: enabled, + builder: builder, + validator: validator, + ); + + /*@override + FormBuilderFieldState createState();*/ + @override + FormBuilderFieldState, T> createState() => FormBuilderFieldState, T>(); +} + +class FormBuilderFieldState, T> extends FormFieldState { + @override + F get widget => super.widget as F; + + FormBuilderState? get formState => _formBuilderState; + + bool get isDate => typeOf() == typeOf(); + + /// Returns the initial value, which may be declared at the field, or by the + /// parent [FormBuilder.initialValue]. When declared at both levels, the field + /// initialValue prevails. + T? get initialValue { + if (widget.initialValue != null) + return widget.initialValue; + else { + if (isDate) + return DateTime.tryParse((_formBuilderState?.initialValue ?? const {})[widget.name].toString()) + as T?; + else + return (_formBuilderState?.initialValue ?? const {})[widget.name] as T?; + } + } + + FormBuilderState? _formBuilderState; + + @override + bool get hasError => super.hasError || widget.decoration.errorText != null; + + @override + bool get isValid => super.isValid && widget.decoration.errorText == null; + + bool _touched = false; + + bool get enabled => widget.enabled && (_formBuilderState?.enabled ?? true); + + FocusNode? _focusNode; + + FocusNode? get effectiveFocusNode => _focusNode; + + @override + void initState() { + super.initState(); + // Register this field when there is a parent FormBuilder + _formBuilderState = FormBuilder.of(context); + _formBuilderState?.registerField(widget.name, this); + // Register a touch handler + _focusNode = widget.focusNode ?? FocusNode(); + _focusNode!.addListener(_touchedHandler); + + // Set the initial value + setValue(initialValue); + } + + @override + void dispose() { + _focusNode!.removeListener(_touchedHandler); + // Dispose focus node when created by initState + if (null == widget.focusNode) { + _focusNode!.dispose(); + } + _formBuilderState?.unregisterField(widget.name, this); + super.dispose(); + } + + @override + void save() { + super.save(); + if (_formBuilderState != null) { + if (enabled || !_formBuilderState!.widget.skipDisabled) { + _formBuilderState!.setInternalFieldValue( + widget.name, + null != widget.valueTransformer ? widget.valueTransformer!(value) : (isDate ? value.toString() : value), + ); + } else { + _formBuilderState!.removeInternalFieldValue(widget.name); + } + } + } + + void _touchedHandler() { + if (_focusNode!.hasFocus && _touched == false) { + setState(() => _touched = true); + } + } + + @override + void didChange(T? val, {bool runOnChange = true}) { + super.didChange(val); + if (runOnChange) widget.onChanged?.call(value); + } + + @override + void reset() { + super.reset(); + setValue(initialValue); + widget.onReset?.call(); + } + + @override + bool validate() { + return super.validate() && widget.decoration.errorText == null; + } + + void requestFocus() { + FocusScope.of(context).requestFocus(effectiveFocusNode); + } + + // FIXME: This could be a getter instead of a classic function + InputDecoration decoration() => widget.decoration.copyWith( + errorText: widget.decoration.errorText ?? errorText, + ); +} diff --git a/lib/core/components/form/form_builder_validators.dart b/lib/core/components/form/form_builder_validators.dart new file mode 100644 index 0000000..5ba5d78 --- /dev/null +++ b/lib/core/components/form/form_builder_validators.dart @@ -0,0 +1,271 @@ +import 'package:baseproject/core/common/utils.dart'; +import 'package:baseproject/core/common/validators.dart'; +import 'package:baseproject/core/constants/validate_keys.dart'; +import 'package:baseproject/core/language/app_localizations.dart'; +import 'package:flutter/material.dart'; + +class FormBuilderValidators { + /// [FormFieldValidator] that is composed of other [FormFieldValidator]s. + /// Each validator is run against the [FormField] value and if any returns a + /// non-null result validation fails, otherwise, validation passes + static FormFieldValidator compose(List> validators) { + return (valueCandidate) { + for (var validator in validators) { + final validatorResult = validator.call(valueCandidate); + if (validatorResult != null) { + return validatorResult; + } + } + return null; + }; + } + + /// [FormFieldValidator] that requires the field have a non-empty value. + static FormFieldValidator required( + BuildContext context, { + String? errorText, + }) { + return (T? valueCandidate) { + if (valueCandidate == null || + (valueCandidate is String && valueCandidate.trim().isEmpty) || + (valueCandidate is Iterable && valueCandidate.isEmpty) || + (valueCandidate is Map && valueCandidate.isEmpty)) { + return errorText ?? AppLocalizations.of(context)!.translate(ValidateKey.requiredErrorText); + } + return null; + }; + } + + /// [FormFieldValidator] that requires the field's value be equal to the + /// provided value. + static FormFieldValidator equal( + BuildContext context, + T value, { + String? errorText, + }) => + (valueCandidate) => valueCandidate != value + ? errorText ?? '${AppLocalizations.of(context)!.translate(ValidateKey.equalErrorText)} ${value.toString()}' + : null; + + /// [FormFieldValidator] that requires the field's value be not equal to + /// the provided value. + static FormFieldValidator notEqual( + BuildContext context, + T value, { + String? errorText, + }) => + (valueCandidate) => valueCandidate == value + ? errorText ?? '${AppLocalizations.of(context)!.translate(ValidateKey.notEqualErrorText)} ${value.toString()}' + : null; + + /// [FormFieldValidator] that requires the field's value to be greater than + /// (or equal) to the provided number. + static FormFieldValidator min( + BuildContext context, + num min, { + bool inclusive = true, + String? errorText, + }) { + return (T? valueCandidate) { + if (valueCandidate != null) { + assert(valueCandidate is num || valueCandidate is String); + final number = valueCandidate is num ? valueCandidate : num.tryParse(valueCandidate.toString()); + + if (number != null && (inclusive ? number < min : number <= min)) { + return errorText ?? '${AppLocalizations.of(context)!.translate(ValidateKey.minErrorText)} ${min.toString()}'; + } + } + return null; + }; + } + + /// [FormFieldValidator] that requires the field's value to be less than + /// (or equal) to the provided number. + static FormFieldValidator max( + BuildContext context, + num max, { + bool inclusive = true, + String? errorText, + }) { + return (T? valueCandidate) { + if (valueCandidate != null) { + assert(valueCandidate is num || valueCandidate is String); + final number = valueCandidate is num ? valueCandidate : num.tryParse(valueCandidate.toString()); + + if (number != null && (inclusive ? number > max : number >= max)) { + return errorText ?? '${AppLocalizations.of(context)!.translate(ValidateKey.maxErrorText)} ${max.toString()}'; + } + } + return null; + }; + } + + /// [FormFieldValidator] that requires the length of the field's value to be + /// greater than or equal to the provided minimum length. + static FormFieldValidator minLength( + BuildContext context, + int minLength, { + bool allowEmpty = false, + String? errorText, + }) { + assert(minLength > 0); + return (valueCandidate) { + final valueLength = valueCandidate?.length ?? 0; + return valueLength < minLength && (!allowEmpty || valueLength > 0) + ? errorText ?? + '${AppLocalizations.of(context)!.translate(ValidateKey.minLengthErrorText)} ${minLength.toString()}' + : null; + }; + } + + /// [FormFieldValidator] that requires the length of the field's value to be + /// less than or equal to the provided maximum length. + static FormFieldValidator maxLength( + BuildContext context, + int maxLength, { + String? errorText, + }) { + assert(maxLength > 0); + return (valueCandidate) => null != valueCandidate && valueCandidate.length > maxLength + ? errorText ?? + '${AppLocalizations.of(context)!.translate(ValidateKey.maxLengthErrorText)} ${maxLength.toString()}' + : null; + } + + /// [FormFieldValidator] that requires the field's value to be a valid email address. + static FormFieldValidator email( + BuildContext context, { + String? errorText, + }) => + (valueCandidate) => true == valueCandidate?.isNotEmpty && !isEmail(valueCandidate!.trim()) + ? errorText ?? AppLocalizations.of(context)!.translate(ValidateKey.emailErrorText) + : null; + + static FormFieldValidator phoneNumber( + BuildContext context, { + String? errorText, + }) => + (valueCandidate) => true == valueCandidate?.isNotEmpty && !isPhoneNumber(valueCandidate!.trim()) + ? errorText ?? AppLocalizations.of(context)!.translate(ValidateKey.phoneNumberErrorText) + : null; + + static FormFieldValidator maxValue(BuildContext context, {String? errorText, int value = 0}) => + (valueCandidate) => true == valueCandidate?.isNotEmpty && + (int.tryParse((valueCandidate ?? '').replaceAll('.', '').replaceAll('%', '')) ?? 0) > value + ? errorText ?? AppLocalizations.of(context)!.translate(ValidateKey.maxErrorText) + : null; + + static FormFieldValidator maxValueDouble(BuildContext context, {String? errorText, double value = 0}) => + (valueCandidate) => true == valueCandidate?.isNotEmpty && + (double.tryParse((valueCandidate ?? '').replaceAll('%', '')) ?? 0) > value + ? errorText ?? AppLocalizations.of(context)!.translate(ValidateKey.maxErrorText) + : null; + + /// [FormFieldValidator] that requires the field's value to be a valid url. + static FormFieldValidator url( + BuildContext context, { + String? errorText, + List protocols = const ['http', 'https', 'ftp'], + bool requireTld = true, + bool requireProtocol = false, + bool allowUnderscore = false, + List hostWhitelist = const [], + List hostBlacklist = const [], + }) => + (valueCandidate) => true == valueCandidate?.isNotEmpty && + !isURL(valueCandidate!, + protocols: protocols, + requireTld: requireTld, + requireProtocol: requireProtocol, + allowUnderscore: allowUnderscore, + hostWhitelist: hostWhitelist, + hostBlacklist: hostBlacklist) + ? errorText ?? AppLocalizations.of(context)!.translate(ValidateKey.urlErrorText) + : null; + + /// [FormFieldValidator] that requires the field's value to match the provided regex pattern. + static FormFieldValidator match( + BuildContext context, + String pattern, { + String? errorText, + }) => + (valueCandidate) => true == valueCandidate?.isNotEmpty && !RegExp(pattern).hasMatch(valueCandidate!) + ? errorText ?? AppLocalizations.of(context)!.translate(ValidateKey.matchErrorText) + : null; + + /// [FormFieldValidator] that requires the field's value to be a valid number. + static FormFieldValidator numeric( + BuildContext context, { + String? errorText, + }) => + (valueCandidate) => true == valueCandidate?.isNotEmpty && null == num.tryParse(valueCandidate!) + ? errorText ?? AppLocalizations.of(context)!.translate(ValidateKey.numericErrorText) + : null; + + /// [FormFieldValidator] that requires the field's value to be a valid integer. + static FormFieldValidator integer( + BuildContext context, { + String? errorText, + int? radix, + }) => + (valueCandidate) => true == valueCandidate?.isNotEmpty && null == int.tryParse(valueCandidate!, radix: radix) + ? errorText ?? AppLocalizations.of(context)!.translate(ValidateKey.integerErrorText) + : null; + + /// [FormFieldValidator] that requires the field's value to be a valid credit card number. + static FormFieldValidator creditCard( + BuildContext context, { + String? errorText, + }) => + (valueCandidate) => true == valueCandidate?.isNotEmpty && !isCreditCard(valueCandidate!) + ? errorText ?? AppLocalizations.of(context)!.translate(ValidateKey.creditCardErrorText) + : null; + + /// [FormFieldValidator] that requires the field's value to be a valid IP address. + /// * [version] is a `String` or an `int`. + static FormFieldValidator ip( + BuildContext context, { + int? version, + String? errorText, + }) => + (valueCandidate) => true == valueCandidate?.isNotEmpty && !isIP(valueCandidate!, version) + ? errorText ?? AppLocalizations.of(context)!.translate(ValidateKey.ipErrorText) + : null; + + /// [FormFieldValidator] that requires the field's value to be a valid date string. + static FormFieldValidator dateString( + BuildContext context, { + String? errorText, + }) => + (valueCandidate) => true == valueCandidate?.isNotEmpty && !isDate(valueCandidate!) + ? errorText ?? AppLocalizations.of(context)!.translate(ValidateKey.dateStringErrorText) + : null; + + /// [FormFieldValidator] that requires the length of the field's value to be + /// greater than or equal to the provided minimum length. + static FormFieldValidator dateGreaterThan( + BuildContext context, + DateTime date, { + bool allowEmpty = false, + String? errorText, + }) { + return (valueCandidate) { + return (valueCandidate == null || Utils.dateDiffToSeconds(valueCandidate, date) < 0) + ? errorText ?? + '${AppLocalizations.of(context)!.translate(ValidateKey.dateGreaterThanErrorText)} ${date.toString()}' + : null; + }; + } + + static FormFieldValidator dateGreaterThanNow( + BuildContext context, { + bool allowEmpty = false, + String? errorText, + }) { + return (valueCandidate) { + return (valueCandidate == null || Utils.dateDiffToSeconds(valueCandidate, DateTime.now()) < 0) + ? errorText ?? AppLocalizations.of(context)!.translate(ValidateKey.dateGreaterThanNowErrorText) + : null; + }; + } +} diff --git a/lib/core/components/form/form_control.dart b/lib/core/components/form/form_control.dart new file mode 100644 index 0000000..a7d5f7a --- /dev/null +++ b/lib/core/components/form/form_control.dart @@ -0,0 +1,51 @@ +import 'package:baseproject/core/theme/custom_color.dart'; +import 'package:baseproject/core/theme/text_style.dart'; +import 'package:flutter/material.dart'; + +// ignore: must_be_immutable +class FormControl extends StatelessWidget { + FormControl({ + Key? key, + this.isShowTextRequire = false, + required this.child, + this.labelText = "", + this.labelTextStyle, + }) : super(key: key); + final String labelText; + final bool isShowTextRequire; + TextStyle? labelTextStyle; + final Widget child; + @override + Widget build(BuildContext context) { + return _buildBody(); + } + + Widget _buildBody() { + labelTextStyle ??= textStyleBodySmall; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + labelText.isNotEmpty + ? Container( + padding: const EdgeInsets.only(bottom: 2), + color: Colors.white, + child: RichText( + text: TextSpan( + text: labelText, + style: labelTextStyle!.copyWith(color: CustomColor.textGray), + children: [ + // if (isShowTextRequire) + // TextSpan(text: ' * ', style: style?.copyWith(color: Colors.red)), + TextSpan( + text: isShowTextRequire ? ' *' : '', + style: labelTextStyle!.copyWith(color: CustomColor.redText), + ), + ]), + ), + ) + : const SizedBox(), + child, + ], + ); + } +} diff --git a/lib/core/components/grid_view/custom_gridview.dart b/lib/core/components/grid_view/custom_gridview.dart new file mode 100644 index 0000000..955d290 --- /dev/null +++ b/lib/core/components/grid_view/custom_gridview.dart @@ -0,0 +1,147 @@ +import 'package:baseproject/core/components/index.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +class CustomGridView extends StatefulWidget { + final IndexedWidgetBuilder itemBuilder; + final Widget separatorWidget; + final VoidCallback? onRefresh; + final OnLoadMore? onLoading; // for load more + final Axis scrollDirection; + final int totalItem; + final List items; + final int countItemAdd; + final bool shrinkWrap; + final ScrollPhysics? scrollPhysics; + final IndexedWidgetBuilder? separatorBuilder; + final EdgeInsets? padding; + final bool primary; + final Widget? widgetNoData; + final ScrollController? scrollController; + final Color? backgroundPullRefresh; + final int crossAxisCount; + + const CustomGridView( + {Key? key, + required this.itemBuilder, + required this.totalItem, + required this.items, + this.separatorWidget = const SizedBox(), + this.onRefresh, + this.onLoading, + this.scrollDirection = Axis.vertical, + this.countItemAdd = 0, + this.shrinkWrap = false, + this.primary = true, + this.separatorBuilder, + this.padding, + this.scrollPhysics, + this.widgetNoData, + this.scrollController, + this.backgroundPullRefresh, + this.crossAxisCount = 2}) + : super(key: key); + + @override + _CustomGridViewState createState() => _CustomGridViewState(); +} + +class _CustomGridViewState extends State> { + final RefreshController _refreshController = RefreshController(); + final ScrollController scrollController = ScrollController(); + List items = []; + late Color? _backgroundRefreshColor; + bool isLoadMore = false; + + @override + void didUpdateWidget(covariant CustomGridView oldWidget) { + if (oldWidget.items.length != widget.items.length) { + setState(() { + items = widget.items; + }); + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + super.dispose(); + _refreshController.dispose(); + scrollController.dispose(); + } + + @override + void initState() { + items = widget.items; + _backgroundRefreshColor = widget.backgroundPullRefresh; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return CustomPullToRefresh( + scrollDirection: widget.scrollDirection, + controller: _refreshController, + scrollController: widget.scrollController, + backgroundPullRefresh: _backgroundRefreshColor, + onLoading: () { + (widget.onLoading!().then( + (List? value) { + setState(() { + if (value != null) items.addAll(value); + _refreshController.loadComplete(); + _refreshController.refreshCompleted(); + }); + }, + )); + }, + enablePullUp: items.length < widget.totalItem && widget.onLoading != null, + enablePullDown: widget.onRefresh != null, + onRefresh: () { + widget.onRefresh!(); // check enablePullDown ở trên + _refreshController.loadComplete(); + _refreshController.refreshCompleted(); + }, + // header: const WaterDropMaterialHeader(), + // footer: CustomFooter( + // builder: (BuildContext context, LoadStatus? mode) { + // Widget body = const SizedBox(); + // if (mode == LoadStatus.idle) { + // body = Text( + // AppLocalizations.of(context)!.translate("pull_up_load"), + // ); + // } else if (mode == LoadStatus.loading) { + // body = const CupertinoActivityIndicator(); + // } else if (mode == LoadStatus.failed) { + // body = Text(AppLocalizations.of(context)!.translate("load_failed!_click_retry!")); + // } else if (mode == LoadStatus.canLoading) { + // body = Text(AppLocalizations.of(context)!.translate("release_to_load_more")); + // } else { + // body = Text(AppLocalizations.of(context)!.translate("no_more_data")); + // } + // return Container( + // height: 50, + // child: Center( + // child: body, + // ), + // ); + // }, + // ), + child: (widget.totalItem > 0 || items.length > 0) + ? AlignedGridView.count( + crossAxisCount: widget.crossAxisCount, + crossAxisSpacing: 18.0, + mainAxisSpacing: 18.0, + padding: widget.padding, + itemBuilder: widget.itemBuilder, + shrinkWrap: widget.shrinkWrap, + primary: widget.primary, + scrollDirection: widget.scrollDirection, + physics: widget.scrollPhysics, + itemCount: items.length + widget.countItemAdd) + : widget.widgetNoData ?? ConstantWidget.noData(), + ); + } +} diff --git a/lib/core/components/image/custom_image.dart b/lib/core/components/image/custom_image.dart new file mode 100644 index 0000000..aed8802 --- /dev/null +++ b/lib/core/components/image/custom_image.dart @@ -0,0 +1,190 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:baseproject/assets/images.dart'; +import 'package:baseproject/core/common/utils.dart'; +import 'package:baseproject/core/components/image/show_image.dart'; +import 'package:baseproject/features/presentation/app/view/app.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_spinkit/flutter_spinkit.dart'; +import 'package:flutter_svg/svg.dart'; + +// ignore: non_constant_identifier_names +Widget CustomImage({ + String? imageUrl, + BoxFit? fit, + Widget? imageDefault, + File? file, + Uint8List? data, + double? width, + double? height, + double? radius, + Color? color, + bool isCrop = true, + bool isShowLoading = true, + bool viewImage = false, + String imageCropMode = 'crop', + ImageRepeat repeat = ImageRepeat.noRepeat, +}) { + var isViewImage = viewImage; + if (imageUrl != null && imageUrl.trim().isNotEmpty) { + if (imageUrl.contains("http")) { + switch (getMediaType(imageUrl)) { + default: + return GestureDetector( + onTap: () { + isViewImage == false ? null : showImage(navigatorKey!.currentState!.context, imageUrl); + }, + child: CachedNetworkImage( + repeat: repeat, + imageUrl: isCrop + ? Utils.thumbnailImage(imageUrl, width: width, height: height, mode: imageCropMode) + : imageUrl, + width: width, + height: height, + imageBuilder: (width != null && height != null) + ? (BuildContext context, ImageProvider _imageUrl) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(radius ?? 0), + image: DecorationImage( + image: imageUrl.contains('.gif') ? NetworkImage(imageUrl) : _imageUrl, + fit: fit ?? BoxFit.contain), + ), + // child: Image.network(imageUrl, fit: fit ?? BoxFit.contain), + ); + } + : null, + placeholder: (_, __) => isShowLoading + ? const SpinKitCircle( + color: Colors.white, + size: 50.0, + ) + : const SizedBox(), + fit: fit, + errorWidget: (_, __, dynamic error) { + isViewImage = false; + return imageDefault ?? _buildImageDefault(fit, width, height, radius); + }), + ); + } + } else if (imageUrl.contains('data')) { + return Image.file( + File(imageUrl), + fit: fit, + width: width, + height: height, + repeat: repeat, + ); + } else { + return Image.asset( + imageUrl, + fit: fit, + width: width, + height: height, + color: color, + repeat: repeat, + ); + } + } else if (file != null) { + return Image.file( + file, + fit: fit, + width: width, + height: height, + repeat: repeat, + ); + } else if (data != null) { + return Image.memory( + data, + fit: fit, + width: width, + height: height, + repeat: repeat, + ); + } + + return imageDefault ?? _buildImageDefault(fit, width, height, radius); +} + +Widget _buildImageDefault(BoxFit? fit, double? width, double? height, double? radius) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(radius ?? 0), + image: DecorationImage( + image: const AssetImage(Images.imageDefault), + fit: fit ?? BoxFit.contain, + ), + ), + ); +} + +MediaType getMediaType(String path) { + if (getVideo(path)) { + return MediaType.mp4; + } else if (path.contains('http')) { + if (path.contains('sticker')) { + return MediaType.sticker; + } else if (path.contains('svg')) { + return MediaType.svgNetwork; + } else if (path.contains('jpg') || + path.contains('jpeg') || + path.contains('JPG') || + path.contains('png') || + path.contains('.gif')) { + return MediaType.jpgNetwork; + } else if (path.contains('mp4')) { + return MediaType.mp4; + } + return MediaType.file; + } else if (path.contains("data")) { + return MediaType.file; + } else if (path.contains('svg')) { + return MediaType.svgLocal; + } else if (path.contains('png') || path.contains('jpg') || path.contains('JPG') || path.contains('jpeg')) { + return MediaType.pngLocal; + } + return MediaType.waiting; +} + +enum MediaType { mp4, svgLocal, svgNetwork, pngLocal, pngNetwork, jpgNetwork, file, waiting, sticker } + +bool getVideo(String path) => + path.contains("mp4") || path.contains("MP4") || path.contains("MOV") || path.contains("mov"); + +Widget svgImage(String path, + {Color? color, + double? width, + double? height, + bool cacheColorFilter = false, + BoxFit fit = BoxFit.contain, + String? package}) { + if (path.contains("http")) { + if (path.contains('jpg') || path.contains('png')) { + return CustomImage(imageUrl: path, width: width, height: height, fit: fit); + } + return SvgPicture.network( + path, + cacheColorFilter: cacheColorFilter, + color: color, + width: width, + height: height, + fit: fit, + ); + } + + return SvgPicture.asset( + path, + cacheColorFilter: cacheColorFilter, + color: color, + width: width, + height: height, + fit: fit, + package: package, + ); +} diff --git a/lib/core/components/image/show_image.dart b/lib/core/components/image/show_image.dart new file mode 100644 index 0000000..c9fdb7a --- /dev/null +++ b/lib/core/components/image/show_image.dart @@ -0,0 +1,205 @@ +import 'package:baseproject/assets/images.dart'; +import 'package:baseproject/core/common/loading.dart'; +import 'package:baseproject/core/common/message.dart'; +import 'package:baseproject/core/components/constants_widget.dart'; +import 'package:baseproject/core/theme/custom_color.dart'; +import 'package:baseproject/features/model/file/attach_file_model.dart'; + +import 'package:flutter/material.dart'; +import 'package:flutter_spinkit/flutter_spinkit.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:photo_view/photo_view_gallery.dart'; +import 'package:gal/gal.dart'; + +Widget _buildDelete(BuildContext context) { + return GestureDetector( + onTap: () { + Navigator.of(context).pop(); + }, + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + shape: BoxShape.circle, + ), + child: const Icon( + Icons.close, + size: 20, + ), + ), + ); +} + +Widget _builSaveImage(String url) { + return Positioned.fill( + bottom: 20, + child: Align( + child: GestureDetector( + onTap: () async { + showLoading(); + try { + await Gal.putImage(url); + hideLoading(); + showSuccessMessage("Tải xuống thành công"); + } catch (e) { + hideLoading(); + showErrorMessage("Lỗi khi tải xuống: $e"); + } + }, + child: ConstantWidget.defaultContainer( + color: CustomColor.kBackgroundWhite.withOpacity(0.8), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ConstantWidget.textBodyDefault("Tải xuống"), + ConstantWidget.widthSpace10, + const Icon(Icons.download) + ], + ), + ), + ), + alignment: Alignment.bottomCenter, + ), + ); +} + +void showImages(BuildContext context, List items, {int initialPage = 0}) { + int page = 0; + PageController _controller = PageController(initialPage: initialPage); + showGeneralDialog( + barrierLabel: "Barrier", + barrierDismissible: true, + barrierColor: CustomColor.barrierColor, + transitionDuration: const Duration(milliseconds: 350), + context: context, + pageBuilder: (_, __, ___) { + return Stack( + children: [ + PhotoViewGallery.builder( + backgroundDecoration: BoxDecoration(color: CustomColor.barrierColor), + allowImplicitScrolling: false, + scrollPhysics: const BouncingScrollPhysics(), + builder: (BuildContext context, int index) { + return PhotoViewGalleryPageOptions( + imageProvider: NetworkImage(items[index].url), + initialScale: PhotoViewComputedScale.contained * 0.8, + heroAttributes: PhotoViewHeroAttributes(tag: items[index].title), + ); + }, + itemCount: items.length, + loadingBuilder: (context, event) => Center( + child: SizedBox( + width: 20.0, + height: 20.0, + child: CircularProgressIndicator( + value: event == null ? 0 : event.cumulativeBytesLoaded / (event.expectedTotalBytes ?? 1), + ), + ), + ), + // backgroundDecoration: widget.backgroundDecoration, + pageController: _controller, + onPageChanged: (index) { + page = index; + }, + ), + Positioned( + child: _buildDelete(context), + right: 10, + top: 50, + ), + _builSaveImage(items[page].url) + ], + ); + }, + // transitionBuilder: (_, Animation anim, __, Widget child) { + // return SlideTransition( + // position: Tween(begin: const Offset(0, 1), end: const Offset(0, 0)).animate(anim), + // child: child, + // ); + // }, + ); +} + +void showImage(BuildContext context, String url) { + showGeneralDialog( + barrierLabel: "Barrier", + barrierDismissible: true, + barrierColor: CustomColor.barrierColor, + transitionDuration: const Duration(milliseconds: 350), + context: context, + pageBuilder: (_, __, ___) { + return ShowImageWidget(url: url); + }, + // transitionBuilder: (_, Animation anim, __, Widget child) { + // return SlideTransition( + // position: Tween(begin: const Offset(0, 1), end: const Offset(0, 0)).animate(anim), + // child: child, + // ); + // }, + ); +} + +class ShowImageWidget extends StatelessWidget { + final String url; + const ShowImageWidget({Key? key, required this.url}) : super(key: key); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 40), + alignment: Alignment.center, + child: _buildPhotoView(context, url), + ), + ); + // return Dialog( + // child: Container( + // height: MediaQuery.of(context).size.height * 1 / 2, + // child: Center( + // child: Hero( + // tag: url, + // child: BNDImage(imageUrl: url, fit: BoxFit.fitWidth), + // ), + // ), + // ), + // ); + } + + Widget _buildImageDefault() { + return Container( + decoration: const BoxDecoration( + image: DecorationImage( + image: AssetImage(Images.imageDefault), + fit: BoxFit.contain, + ), + ), + ); + } + + Widget _buildPhotoView(BuildContext context, String imageUrl) { + return Stack( + children: [ + PhotoView( + imageProvider: NetworkImage(imageUrl), + loadingBuilder: (_, __) => const SpinKitCircle( + color: Colors.white, + size: 50.0, + ), + errorBuilder: (_, __, ___) => _buildImageDefault(), + backgroundDecoration: const BoxDecoration(color: Colors.transparent), + maxScale: PhotoViewComputedScale.covered * 4.0, + minScale: PhotoViewComputedScale.contained * 1, + // onTapDown: (_, __, ___) => Navigator.pop(context), + // initialScale: PhotoViewComputedScale.covered, + ), + Positioned( + child: _buildDelete(context), + right: 10, + top: 50, + ), + _builSaveImage(imageUrl) + ], + ); + } +} diff --git a/lib/core/components/index.dart b/lib/core/components/index.dart new file mode 100644 index 0000000..5c081c8 --- /dev/null +++ b/lib/core/components/index.dart @@ -0,0 +1,16 @@ +export 'constants_widget.dart'; +export 'select/custom_select.dart'; +export 'switch/custom_switch.dart'; +export 'tab/custom_tab_widget.dart'; +export 'image/custom_image.dart'; +export 'image/show_image.dart'; +export 'form/form_control.dart'; +export 'form/form_builder.dart'; +export 'form/form_builder_field.dart'; +export 'form/form_builder_validators.dart'; +export 'checkbox/custom_checkbox.dart'; +export 'radio/custom_radio.dart'; +export 'radio/custom_radio_list.dart'; +export 'grid_view/custom_gridview.dart'; +export 'listview/custom_listview.dart'; +export 'custom_pull_to_refresh.dart'; diff --git a/lib/core/components/listview/custom_listview.dart b/lib/core/components/listview/custom_listview.dart new file mode 100644 index 0000000..69906c5 --- /dev/null +++ b/lib/core/components/listview/custom_listview.dart @@ -0,0 +1,144 @@ +import 'package:baseproject/core/components/index.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +typedef OnLoadMore = Future?> Function(); + +class CustomListView extends StatefulWidget { + final IndexedWidgetBuilder itemBuilder; + final Widget separatorWidget; + final VoidCallback? onRefresh; + final OnLoadMore? onLoading; // for load more + final Axis scrollDirection; + final int totalItem; + final List items; + final int countItemAdd; + final bool shrinkWrap; + final ScrollPhysics? scrollPhysics; + final IndexedWidgetBuilder? separatorBuilder; + final EdgeInsets? padding; + final bool primary; + final Widget? widgetNoData; + final ScrollController? scrollController; + final Color? backgroundPullRefresh; + + const CustomListView( + {Key? key, + required this.itemBuilder, + required this.totalItem, + required this.items, + this.separatorWidget = const SizedBox(), + this.onRefresh, + this.onLoading, + this.scrollDirection = Axis.vertical, + this.countItemAdd = 0, + this.shrinkWrap = false, + this.primary = true, + this.separatorBuilder, + this.padding, + this.scrollPhysics, + this.widgetNoData, + this.scrollController, + this.backgroundPullRefresh}) + : super(key: key); + + @override + _CustomListViewState createState() => _CustomListViewState(); +} + +class _CustomListViewState extends State> { + final RefreshController _refreshController = RefreshController(); + final ScrollController scrollController = ScrollController(); + List items = []; + late Color? _backgroundRefreshColor; + bool isLoadMore = false; + + @override + void didUpdateWidget(covariant CustomListView oldWidget) { + if (oldWidget.items.length != widget.items.length) { + setState(() { + items = widget.items; + }); + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + super.dispose(); + _refreshController.dispose(); + scrollController.dispose(); + } + + @override + void initState() { + items = widget.items; + _backgroundRefreshColor = widget.backgroundPullRefresh; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return CustomPullToRefresh( + scrollDirection: widget.scrollDirection, + controller: _refreshController, + scrollController: widget.scrollController, + backgroundPullRefresh: _backgroundRefreshColor, + onLoading: () { + (widget.onLoading!().then( + (List? value) { + setState(() { + if (value != null) items.addAll(value); + _refreshController.loadComplete(); + _refreshController.refreshCompleted(); + }); + }, + )); + }, + enablePullUp: items.length < widget.totalItem && widget.onLoading != null, + enablePullDown: widget.onRefresh != null, + onRefresh: () { + widget.onRefresh!(); // check enablePullDown ở trên + _refreshController.loadComplete(); + _refreshController.refreshCompleted(); + }, + // header: const WaterDropMaterialHeader(), + // footer: CustomFooter( + // builder: (BuildContext context, LoadStatus? mode) { + // Widget body = const SizedBox(); + // if (mode == LoadStatus.idle) { + // body = Text( + // AppLocalizations.of(context)!.translate("pull_up_load"), + // ); + // } else if (mode == LoadStatus.loading) { + // body = const CupertinoActivityIndicator(); + // } else if (mode == LoadStatus.failed) { + // body = Text(AppLocalizations.of(context)!.translate("load_failed!_click_retry!")); + // } else if (mode == LoadStatus.canLoading) { + // body = Text(AppLocalizations.of(context)!.translate("release_to_load_more")); + // } else { + // body = Text(AppLocalizations.of(context)!.translate("no_more_data")); + // } + // return Container( + // height: 50, + // child: Center( + // child: body, + // ), + // ); + // }, + // ), + child: (widget.totalItem > 0 || items.length > 0) + ? ListView.separated( + padding: widget.padding, + itemBuilder: widget.itemBuilder, + separatorBuilder: widget.separatorBuilder ?? (BuildContext context, int index) => widget.separatorWidget, + shrinkWrap: widget.shrinkWrap, + primary: widget.primary, + scrollDirection: widget.scrollDirection, + physics: widget.scrollPhysics, + itemCount: items.length + widget.countItemAdd) + : widget.widgetNoData ?? ConstantWidget.noData(), + ); + } +} diff --git a/lib/core/components/radio/custom_radio.dart b/lib/core/components/radio/custom_radio.dart new file mode 100644 index 0000000..8eb02ed --- /dev/null +++ b/lib/core/components/radio/custom_radio.dart @@ -0,0 +1,146 @@ +import 'package:baseproject/core/components/form/form_builder_field.dart'; +import 'package:baseproject/core/extension/string_extension.dart'; +import 'package:baseproject/core/theme/custom_color.dart'; +import 'package:flutter/material.dart'; + +// ignore: must_be_immutable +class CustomRadio extends FormBuilderField { + CustomRadio( + {Key? key, + String name = "radio", + InputDecoration decoration = const InputDecoration(), + this.groupValue, + this.padding = const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + this.textStyle, + ValueChanged? onChanged, + FormFieldValidator? validator, + AutovalidateMode autovalidateMode = AutovalidateMode.disabled, + this.text = "", + this.value}) + : super( + key: key, + name: name, + onChanged: onChanged, + validator: validator, + autovalidateMode: autovalidateMode, + builder: (FormFieldState field) { + final _CustomRadiotate state = field as _CustomRadiotate; + return state.buildBody(state.context); + return InputDecorator( + decoration: decoration, + child: state.buildBody(state.context), + ); + }, + ); + + final EdgeInsets padding; + final TextStyle? textStyle; + final String text; + final T? value; + T? groupValue; + + @override + _CustomRadiotate createState() => _CustomRadiotate(); +} + +class _CustomRadiotate extends FormBuilderFieldState, T> { + bool isShowSelect = false; + EdgeInsets get padding => widget.padding; + bool isFirstLoad = true; + double get radioSize => 18 * 1; + + T? get radioValue => widget.value; + T? groupValue; + + @override + void initState() { + super.initState(); + groupValue = widget.groupValue; + } + + @override + void didUpdateWidget(covariant CustomRadio oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.groupValue != widget.groupValue) { + setState(() { + groupValue = widget.groupValue; + }); + } + } + + Widget buildBody(BuildContext context) { + if (widget.text.isNullOrEmpty) return _buildRadio(); + return Row( + children: [ + Container( + margin: const EdgeInsets.only(right: 12), + child: + // Transform.scale( + // scale: 1.2, + // child: Radio( + // activeColor: widget.activeColor, + // value: radioValue, + // groupValue: groupValue, + // onChanged: (T? value) { + // groupValue = value; + // onChanged(radioValue); + // }, + // ), + // ), + _buildRadio(), + ), + Expanded( + child: GestureDetector( + onTap: () { + onChanged(radioValue); + }, + child: Text(widget.text, style: widget.textStyle), + ), + ) + ], + ); + } + + Widget _buildRadio() { + bool selected = groupValue == radioValue; + return SizedBox( + width: radioSize, + height: radioSize, + child: InkWell( + onTap: () => onChanged(radioValue), + child: Container( + width: radioSize, + height: radioSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selected ? CustomColor.primaryColor : Colors.transparent, + border: Border.all( + color: selected ? CustomColor.primaryColor : CustomColor.textGray, + width: 1.0, + ), + ), + child: selected + ? Center( + child: Container( + width: radioSize * 0.4, + height: radioSize * 0.4, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + ), + ), + ) + : null, + ), + ), + ); + } + + void onChanged(T? data, {bool isFirstLoad = false}) { + if (!isFirstLoad) { + setState(() { + didChange(data); + }); + } + } +} diff --git a/lib/core/components/radio/custom_radio_list.dart b/lib/core/components/radio/custom_radio_list.dart new file mode 100644 index 0000000..ea51867 --- /dev/null +++ b/lib/core/components/radio/custom_radio_list.dart @@ -0,0 +1,346 @@ +import 'package:baseproject/core/common/utils.dart'; +import 'package:baseproject/core/components/form/form_builder_field.dart'; +import 'package:baseproject/core/language/app_localizations.dart'; +import 'package:baseproject/core/theme/custom_color.dart'; +import 'package:flutter/material.dart'; +import 'package:collection/collection.dart'; + +// ignore: must_be_immutable +class CustomRadioList extends FormBuilderField { + CustomRadioList({ + Key? key, + String name = "radio", + InputDecoration decoration = const InputDecoration(border: InputBorder.none, enabledBorder: InputBorder.none), + T? initialValue, + required this.items, + this.propertyTitleName = "title", + this.propertyIdName = "id", + this.isReturnObject = true, + this.padding = const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + this.textStyle, + this.scrollDirection = Axis.vertical, + ValueChanged? onChanged, + FormFieldValidator? validator, + AutovalidateMode autovalidateMode = AutovalidateMode.disabled, + this.nonTitleOddItem = false, + this.enable = true, + this.isTranslate = false, + this.isSameLine = false, + this.isHtmlText = false, + this.isRowTitleHorizontal = false, + this.answerTitles, + this.scale = 1.0, + this.selectedTextStyle, + this.isShowDivider = false, + this.isRunDidUpdate = false, + }) : super( + key: key, + name: name, + onChanged: onChanged, + initialValue: initialValue, + validator: validator, + autovalidateMode: autovalidateMode, + builder: (FormFieldState field) { + final _CustomRadioListState state = field as _CustomRadioListState; + return InputDecorator( + decoration: decoration, + child: state.buildBody(state.context), + ); + }, + ); + + final List items; + final bool isReturnObject; + final String propertyIdName; + final String propertyTitleName; + final EdgeInsets padding; + final TextStyle? textStyle; + final TextStyle? selectedTextStyle; + final Axis scrollDirection; + final bool isSameLine; + final bool nonTitleOddItem; + final bool enable; + final bool isTranslate; + final bool isHtmlText; + final bool isRowTitleHorizontal; + final List? answerTitles; + final double scale; + final bool isShowDivider; + final bool isRunDidUpdate; + + @override + _CustomRadioListState createState() => _CustomRadioListState(); +} + +class _CustomRadioListState extends FormBuilderFieldState, T> { + bool isShowSelect = false; + Object? selectedValue; + + List get items => widget.items; + + //String title = ""; + EdgeInsets get padding => widget.padding; + + String get propertyIdName => widget.propertyIdName; + bool isFirstLoad = true; + double get radioSize => 18 * widget.scale; + + @override + void initState() { + super.initState(); + selectedValue = getItem(); + // print(selectedValue); + } + + @override + void didUpdateWidget(FormField oldWidget) { + // selectedValue = getItem(); + super.didUpdateWidget(oldWidget); + if (oldWidget.initialValue != widget.initialValue && widget.isRunDidUpdate) { + setState(() { + selectedValue = getItem(); + }); + didChange(widget.initialValue); + } + } + + TextStyle? getTextStyle(Object item) { + if (selectedValue == item) { + return widget.selectedTextStyle ?? widget.textStyle?.copyWith(fontWeight: FontWeight.w600); + } + return widget.textStyle; + } + + List _buildListItemHorizontal(Object item) { + return [ + Container( + width: radioSize, + height: radioSize, + margin: EdgeInsets.only( + bottom: widget.isRowTitleHorizontal ? 0 : 6, + right: widget.isRowTitleHorizontal ? 6 : 0, + ), + child: _buildRadio(item), + // child: Transform.scale( + // scale: widget.scale, + // child: Radio( + // value: item, + // groupValue: selectedValue, + // onChanged: widget.enable ? (Object? value) => onChanged(value) : null, + // ), + // ), + ), + if (items.indexOf(item) % 2 == 0 || !widget.nonTitleOddItem) + // widget.isHtmlText + // ? TextHtmlWidget(widget.propertyTitleName) + // : + Text( + widget.isTranslate + ? AppLocalizations.of(context)! + .translate(Utils.getPropertyValueByName(item, widget.propertyTitleName) ?? "") + : Utils.getPropertyValueByName(item, widget.propertyTitleName) ?? "", + style: widget.textStyle, + textAlign: TextAlign.center, + ) + ]; + } + + Widget buildBody(BuildContext context) { + if (widget.scrollDirection == Axis.horizontal) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final Object item in items) + Flexible( + child: GestureDetector( + onTap: widget.enable ? () => onChanged(item) : null, + child: Container( + // padding: const EdgeInsets.only(right: 24), + color: Colors.transparent, + margin: items.indexOf(item) < items.length - 1 ? const EdgeInsets.only() : const EdgeInsets.all(0), + child: widget.isRowTitleHorizontal + ? Row(crossAxisAlignment: CrossAxisAlignment.center, children: _buildListItemHorizontal(item)) + : Column(crossAxisAlignment: CrossAxisAlignment.center, children: _buildListItemHorizontal(item)), + ), + ), + ), + ], + ); + } + if (widget.isSameLine) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final Object item in items) + GestureDetector( + onTap: widget.enable ? () => onChanged(item) : null, + child: Container( + color: Colors.white, + margin: items.indexOf(item) < items.length - 1 + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.all(0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: radioSize, + height: radioSize, + margin: const EdgeInsets.only(right: 8), + child: _buildRadio(item), + // Transform.scale( + // scale: widget.scale, + // child: Radio( + // value: item, + // groupValue: selectedValue, + // onChanged: widget.enable ? (Object? value) => onChanged(value) : null, + // ), + // ), + ), + // const SizedBox(width: 12), + Text( + widget.isTranslate + ? AppLocalizations.of(context)! + .translate(Utils.getPropertyValueByName(item, widget.propertyTitleName) ?? "") + : Utils.getPropertyValueByName(item, widget.propertyTitleName) ?? "", + style: getTextStyle(item)) + ], + ), + ), + ), + ], + ), + ); + } + return ListView.separated( + shrinkWrap: true, + itemBuilder: (context, index) { + final Object item = items[index]; + return GestureDetector( + onTap: widget.enable ? () => onChanged(item) : null, + child: Container( + color: Colors.transparent, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: radioSize, + height: radioSize, + margin: const EdgeInsets.only(right: 8), + // child: Transform.scale( + // scale: widget.scale, + // child: Radio( + // value: item, + // groupValue: selectedValue, + // onChanged: widget.enable ? (Object? value) => onChanged(value) : null, + // ), + // ), + child: _buildRadio(item), + ), + widget.answerTitles != null + ? Expanded(child: widget.answerTitles![items.indexOf(item)]) + // : widget.isHtmlText + // ? Expanded( + // child: TextHtmlWidget( + // widget.isTranslate + // ? AppLocalizations.of(context)!.translate( + // Utils.getPropertyValueByName(item, widget.propertyTitleName) ?? "") + // : Utils.getPropertyValueByName(item, widget.propertyTitleName) ?? "", + // )) + : Expanded( + child: Text( + widget.isTranslate + ? AppLocalizations.of(context)!.translate( + Utils.getPropertyValueByName(item, widget.propertyTitleName) ?? "") + : Utils.getPropertyValueByName(item, widget.propertyTitleName) ?? "", + style: getTextStyle(item)), + ) + ], + ), + ), + ); + }, + separatorBuilder: (context, index) => Padding( + padding: EdgeInsets.symmetric(vertical: widget.isShowDivider ? 7 : 10), + child: widget.isShowDivider ? const Divider() : const SizedBox(), + ), + itemCount: items.length); + } + + // void changeTitle() { + // title = Utils.getPropertyValueByName( + // selectedValue, widget.propertyTitleName) ?? + // ""; + // } + + Widget _buildRadio(Object item) { + bool selected = selectedValue != null && getValue(selectedValue) == getValue(item); //item[propertyIdName]; + return InkWell( + onTap: () => onChanged(item), + child: Container( + width: radioSize, + height: radioSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selected ? CustomColor.primaryColor : Colors.transparent, + border: Border.all( + color: selected ? CustomColor.primaryColor : CustomColor.textGray, + width: 1.0, + ), + ), + child: selected + ? Center( + child: Container( + width: radioSize * 0.4, + height: radioSize * 0.4, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + ), + ), + ) + : null, + ), + ); + } + + void onChanged(Object? data, {bool isFirstLoad = false}) { + if (selectedValue == data) { + return; + } + if (!isFirstLoad) { + setState(() { + selectedValue = data; //changeTitle(); + }); + } + + final T? temp = getValue(data); + didChange(temp); + } + + T? getValue(Object? data) { + if (widget.isReturnObject) { + return data as T?; + } else { + final T? value = Utils.getPropertyValueByName(data, propertyIdName); + return value; + } + } + + Object? getItem() { + print(initialValue); + Map temp; + if (widget.isReturnObject && initialValue != null) { + return initialValue; + } else if (propertyIdName.isNotEmpty && initialValue != null) { + return items.firstWhereOrNull((Object element) { + if (element == null) return false; + temp = Utils.convertObjectToMap(element); + return temp[propertyIdName] == initialValue; + }); + } + return null; + } +} diff --git a/lib/core/components/select/custom_select.dart b/lib/core/components/select/custom_select.dart new file mode 100644 index 0000000..b855c8d --- /dev/null +++ b/lib/core/components/select/custom_select.dart @@ -0,0 +1,259 @@ +import 'package:baseproject/assets/images.dart'; +import 'package:baseproject/core/components/constants_widget.dart'; +import 'package:baseproject/core/components/form/form_builder_field.dart'; +import 'package:baseproject/core/components/index.dart'; +import 'package:baseproject/core/theme/custom_color.dart'; +import 'package:baseproject/core/theme/text_style.dart'; +import 'package:collection/collection.dart' show IterableExtension; + +import 'package:flutter/material.dart'; + +/// Field for Dropdown button +class CustomSelect extends FormBuilderField { + /// The list of items the user can select. + /// + /// If the [onChanged] callback is null or the list of items is null + /// then the dropdown button will be disabled, i.e. its arrow will be + /// displayed in grey and it will not respond to input. A disabled button + /// will display the [disabledHint] widget if it is non-null. If + /// [disabledHint] is also null but [hint] is non-null, [hint] will instead + /// be displayed. + final List> items; + + /// A placeholder widget that is displayed by the dropdown button. + /// + /// If [value] is null, this widget is displayed as a placeholder for + /// the dropdown button's value. This widget is also displayed if the button + /// is disabled ([items] or [onChanged] is null) and [disabledHint] is null. + final Widget? hint; + + /// A message to show when the dropdown is disabled. + /// + /// Displayed if [items] or [onChanged] is null. If [hint] is non-null and + /// [disabledHint] is null, the [hint] widget will be displayed instead. + final Widget? disabledHint; + + /// Called when the dropdown button is tapped. + /// + /// This is distinct from [onChanged], which is called when the user + /// selects an item from the dropdown. + /// + /// The callback will not be invoked if the dropdown button is disabled. + final VoidCallback? onTap; + + /// A builder to customize the dropdown buttons corresponding to the + /// [DropdownMenuItem]s in [items]. + /// + /// When a [DropdownMenuItem] is selected, the widget that will be displayed + /// from the list corresponds to the [DropdownMenuItem] of the same index + /// in [items]. + /// + /// {@tool dartpad --template=stateful_widget_scaffold} + /// + /// This sample shows a `DropdownButton` with a button with [Text] that + /// corresponds to but is unique from [DropdownMenuItem]. + /// + /// If this callback is null, the [DropdownMenuItem] from [items] + /// that matches [value] will be displayed. + final DropdownButtonBuilder? selectedItemBuilder; + + /// The z-coordinate at which to place the menu when open. + /// + /// The following elevations have defined shadows: 1, 2, 3, 4, 6, 8, 9, 12, + /// 16, and 24. See [kElevationToShadow]. + /// + /// Defaults to 8, the appropriate elevation for dropdown buttons. + final int elevation; + + /// {@end-tool} + /// + /// Defaults to the [TextTheme.subtitle1] value of the current + /// [ThemeData.textTheme] of the current [Theme]. + final TextStyle? style; + + /// The widget to use for the drop-down button's icon. + /// + /// Defaults to an [Icon] with the [Icons.arrow_drop_down] glyph. + final Widget? icon; + + /// The color of any [Icon] descendant of [icon] if this button is disabled, + /// i.e. if [onChanged] is null. + /// + /// Defaults to [Colors.grey.shade400] when the theme's + /// [ThemeData.brightness] is [Brightness.light] and to + /// [Colors.white10] when it is [Brightness.dark] + final Color? iconDisabledColor; + + /// The color of any [Icon] descendant of [icon] if this button is enabled, + /// i.e. if [onChanged] is defined. + /// + /// Defaults to [Colors.grey.shade700] when the theme's + /// [ThemeData.brightness] is [Brightness.light] and to + /// [Colors.white70] when it is [Brightness.dark] + final Color? iconEnabledColor; + + /// The size to use for the drop-down button's down arrow icon button. + /// + /// Defaults to 24.0. + final double iconSize; + + /// Reduce the button's height. + /// + /// By default this button's height is the same as its menu items' heights. + /// If isDense is true, the button's height is reduced by about half. This + /// can be useful when the button is embedded in a container that adds + /// its own decorations, like [InputDecorator]. + final bool isDense; + + /// Set the dropdown's inner contents to horizontally fill its parent. + /// + /// By default this button's inner width is the minimum size of its contents. + /// If [isExpanded] is true, the inner width is expanded to fill its + /// surrounding container. + final bool isExpanded; + + /// If null, then the menu item heights will vary according to each menu item's + /// intrinsic height. + /// + /// The default value is [kMinInteractiveDimension], which is also the minimum + /// height for menu items. + /// + /// If this value is null and there isn't enough vertical room for the menu, + /// then the menu's initial scroll offset may not align the selected item with + /// the dropdown button. That's because, in this case, the initial scroll + /// offset is computed as if all of the menu item heights were + /// [kMinInteractiveDimension]. + final double itemHeight; + + /// The color for the button's [Material] when it has the input focus. + final Color? focusColor; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// The background color of the dropdown. + /// + /// If it is not provided, the theme's [ThemeData.canvasColor] will be used + /// instead. + final Color? dropdownColor; + + final bool allowClear; + final Widget clearIcon; + + /// Creates field for Dropdown button + CustomSelect({ + Key? key, + //From Super + String name = "select", + FormFieldValidator? validator, + T? initialValue, + InputDecoration decoration = const InputDecoration(), + ValueChanged? onChanged, + ValueTransformer? valueTransformer, + bool enabled = true, + FormFieldSetter? onSaved, + AutovalidateMode autovalidateMode = AutovalidateMode.disabled, + VoidCallback? onReset, + FocusNode? focusNode, + required this.items, + this.isExpanded = true, + this.isDense = true, + this.elevation = 8, + this.iconSize = 24.0, + this.hint, + this.style, + this.disabledHint, + this.icon, + this.iconDisabledColor, + this.iconEnabledColor, + this.allowClear = false, + this.clearIcon = const Icon( + Icons.close, + size: 18, + color: CustomColor.textGray, + ), + this.onTap, + this.autofocus = false, + this.dropdownColor, + this.focusColor, + this.itemHeight = kMinInteractiveDimension, + this.selectedItemBuilder, + }) : /*: assert(allowClear == true || clearIcon != null)*/ super( + key: key, + initialValue: initialValue, + name: name, + validator: validator, + valueTransformer: valueTransformer, + onChanged: onChanged, + autovalidateMode: autovalidateMode, + onSaved: onSaved, + enabled: enabled, + onReset: onReset, + decoration: decoration, + focusNode: focusNode, + builder: (FormFieldState field) { + final _SelectState state = field as _SelectState; + // DropdownButtonFormField + // TextFormField + + void changeValue(T? value) { + state.didChange(value); + } + + return InputDecorator( + decoration: state.decoration().copyWith( + floatingLabelBehavior: + hint == null ? decoration.floatingLabelBehavior : FloatingLabelBehavior.always, + filled: true, + fillColor: CustomColor.bgGrayLight, + ), + isEmpty: state.value == null, + child: Row( + children: [ + Expanded( + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: isExpanded, + hint: hint, + items: items, + value: field.value, //field.value, + style: style, + isDense: isDense, + disabledHint: field.value != null + ? (items.firstWhereOrNull((val) => val.value == field.value)?.child ?? + Text(field.value.toString())) + : disabledHint, + elevation: elevation, + iconSize: iconSize, + // icon: icon ?? svgImage(Images.icArrowDown, color: CustomColor.textGray, height: 8), + iconDisabledColor: iconDisabledColor, + iconEnabledColor: iconEnabledColor, + onChanged: state.enabled ? (T? value) => changeValue(value) : null, + onTap: onTap, + focusNode: state.effectiveFocusNode, + autofocus: autofocus, + dropdownColor: dropdownColor, + focusColor: focusColor, + itemHeight: itemHeight, + selectedItemBuilder: selectedItemBuilder, + ), + ), + ), + if (allowClear && state.enabled && field.value != null) ...[ + ConstantWidget.widthSpace10, + InkWell( + onTap: () => changeValue(null), + child: clearIcon, + ), + ] + ], + ), + ); + }, + ); + + @override + _SelectState createState() => _SelectState(); +} + +class _SelectState extends FormBuilderFieldState, T> {} diff --git a/lib/core/components/switch/custom_switch.dart b/lib/core/components/switch/custom_switch.dart new file mode 100644 index 0000000..4e1b5c7 --- /dev/null +++ b/lib/core/components/switch/custom_switch.dart @@ -0,0 +1,135 @@ +import 'package:baseproject/core/components/form/form_builder_field.dart'; +import 'package:baseproject/core/theme/custom_color.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +/// On/Off switch field +class CustomSwitch extends FormBuilderField { + /// A widget to display on the opposite side of the tile from the switch. + /// + /// Typically an [Icon] widget. + final Widget? secondary; + + /// The color to use when this switch is on. + /// + /// Defaults to [ThemeData.toggleableActiveColor]. + final Color? activeColor; + + /// The color to use on the track when this switch is on. + /// + /// Defaults to [ThemeData.toggleableActiveColor] with the opacity set at 50%. + /// + /// Ignored if this switch is created with [Switch.adaptive]. + final Color? activeTrackColor; + + /// The color to use on the thumb when this switch is off. + /// + /// Defaults to the colors described in the Material design specification. + /// + /// Ignored if this switch is created with [Switch.adaptive]. + final Color? inactiveThumbColor; + + /// The color to use on the track when this switch is off. + /// + /// Defaults to the colors described in the Material design specification. + /// + /// Ignored if this switch is created with [Switch.adaptive]. + final Color? inactiveTrackColor; + + /// The tile's internal padding. + /// + /// Insets a [SwitchListTile]'s contents: its [title], [subtitle], + /// [secondary], and [Switch] widgets. + /// + /// If null, [ListTile]'s default of `EdgeInsets.symmetric(horizontal: 16.0)` + /// is used. + final EdgeInsets contentPadding; + + /// Whether to render icons and text in the [activeColor]. + /// + /// No effort is made to automatically coordinate the [selected] state and the + /// [value] state. To have the list tile appear selected when the switch is + /// on, pass the same value to both. + /// + /// Normally, this property is left to its default value, false. + final bool selected; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// Creates On/Off switch field + CustomSwitch({ + Key? key, + //From Super + String name = "switch", + FormFieldValidator? validator, + bool? initialValue, + InputDecoration decoration = const InputDecoration( + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + ), + ValueChanged? onChanged, + ValueTransformer? valueTransformer, + bool enabled = true, + FormFieldSetter? onSaved, + AutovalidateMode autovalidateMode = AutovalidateMode.disabled, + VoidCallback? onReset, + FocusNode? focusNode, + this.activeColor = CustomColor.primaryColor, + this.activeTrackColor, + this.inactiveThumbColor = CustomColor.textGray, + this.inactiveTrackColor, + this.secondary, + this.contentPadding = EdgeInsets.zero, + this.autofocus = false, + this.selected = false, + }) : super( + key: key, + initialValue: initialValue, + name: name, + validator: validator, + valueTransformer: valueTransformer, + onChanged: onChanged, + autovalidateMode: autovalidateMode, + onSaved: onSaved, + enabled: enabled, + onReset: onReset, + decoration: decoration, + focusNode: focusNode, + builder: (FormFieldState field) { + final state = field as _SwitchState; + + return Transform.scale( + scale: 0.8, + child: CupertinoSwitch( + value: state.value!, + onChanged: state.enabled + ? (val) { + state.requestFocus(); + field.didChange(val); + } + : null, + activeColor: activeColor, + ), + ); + }, + ); + + @override + _SwitchState createState() => _SwitchState(); +} + +class _SwitchState extends FormBuilderFieldState { + @override + void didUpdateWidget(covariant CustomSwitch oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initialValue != widget.initialValue) { + setState(() { + didChange(widget.initialValue); + }); + } + } +} diff --git a/lib/core/components/switch/custom_switch_list_tile.dart b/lib/core/components/switch/custom_switch_list_tile.dart new file mode 100644 index 0000000..664b9ad --- /dev/null +++ b/lib/core/components/switch/custom_switch_list_tile.dart @@ -0,0 +1,165 @@ +import 'package:baseproject/core/components/form/form_builder_field.dart'; +import 'package:flutter/material.dart'; + +/// On/Off switch field +class CustomSwitchListTile extends FormBuilderField { + /// The primary content of the list tile. + /// + /// Typically a [Text] widget. + final Widget title; + + /// Additional content displayed below the title. + /// + /// Typically a [Text] widget. + final Widget? subtitle; + + /// A widget to display on the opposite side of the tile from the switch. + /// + /// Typically an [Icon] widget. + final Widget? secondary; + + /// The color to use when this switch is on. + /// + /// Defaults to [ThemeData.toggleableActiveColor]. + final Color? activeColor; + + /// The color to use on the track when this switch is on. + /// + /// Defaults to [ThemeData.toggleableActiveColor] with the opacity set at 50%. + /// + /// Ignored if this switch is created with [Switch.adaptive]. + final Color? activeTrackColor; + + /// The color to use on the thumb when this switch is off. + /// + /// Defaults to the colors described in the Material design specification. + /// + /// Ignored if this switch is created with [Switch.adaptive]. + final Color? inactiveThumbColor; + + /// The color to use on the track when this switch is off. + /// + /// Defaults to the colors described in the Material design specification. + /// + /// Ignored if this switch is created with [Switch.adaptive]. + final Color? inactiveTrackColor; + + /// An image to use on the thumb of this switch when the switch is on. + /// + /// Ignored if this switch is created with [Switch.adaptive]. + final ImageProvider? activeThumbImage; + + /// An image to use on the thumb of this switch when the switch is off. + /// + /// Ignored if this switch is created with [Switch.adaptive]. + final ImageProvider? inactiveThumbImage; + + /// The tile's internal padding. + /// + /// Insets a [SwitchListTile]'s contents: its [title], [subtitle], + /// [secondary], and [Switch] widgets. + /// + /// If null, [ListTile]'s default of `EdgeInsets.symmetric(horizontal: 16.0)` + /// is used. + final EdgeInsets contentPadding; + + /// {@macro flutter.cupertino.switch.dragStartBehavior} + final ListTileControlAffinity controlAffinity; + + /// Whether to render icons and text in the [activeColor]. + /// + /// No effort is made to automatically coordinate the [selected] state and the + /// [value] state. To have the list tile appear selected when the switch is + /// on, pass the same value to both. + /// + /// Normally, this property is left to its default value, false. + final bool selected; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// Creates On/Off switch field + CustomSwitchListTile({ + Key? key, + //From Super + String name = "switch", + FormFieldValidator? validator, + bool? initialValue, + InputDecoration decoration = const InputDecoration( + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + ), + ValueChanged? onChanged, + ValueTransformer? valueTransformer, + bool enabled = true, + FormFieldSetter? onSaved, + AutovalidateMode autovalidateMode = AutovalidateMode.disabled, + VoidCallback? onReset, + FocusNode? focusNode, + required this.title, + this.activeColor, + this.activeTrackColor, + this.inactiveThumbColor, + this.inactiveTrackColor, + this.activeThumbImage, + this.inactiveThumbImage, + this.subtitle, + this.secondary, + this.controlAffinity = ListTileControlAffinity.trailing, + this.contentPadding = EdgeInsets.zero, + this.autofocus = false, + this.selected = false, + }) : super( + key: key, + initialValue: initialValue, + name: name, + validator: validator, + valueTransformer: valueTransformer, + onChanged: onChanged, + autovalidateMode: autovalidateMode, + onSaved: onSaved, + enabled: enabled, + onReset: onReset, + decoration: decoration, + focusNode: focusNode, + builder: (FormFieldState field) { + final state = field as _SwitchStateListTile; + + return InputDecorator( + decoration: state.decoration(), + child: SwitchListTile( + dense: true, + isThreeLine: false, + contentPadding: contentPadding, + title: title, + value: state.value!, + onChanged: state.enabled + ? (val) { + state.requestFocus(); + field.didChange(val); + } + : null, + activeColor: activeColor, + activeThumbImage: activeThumbImage, + activeTrackColor: activeTrackColor, + inactiveThumbColor: inactiveThumbColor, + inactiveThumbImage: activeThumbImage, + inactiveTrackColor: inactiveTrackColor, + secondary: secondary, + subtitle: subtitle, + autofocus: autofocus, + selected: selected, + controlAffinity: controlAffinity, + ), + ); + }, + ); + + @override + _SwitchStateListTile createState() => _SwitchStateListTile(); +} + +class _SwitchStateListTile extends FormBuilderFieldState {} diff --git a/lib/core/components/tab/custom_tab.dart b/lib/core/components/tab/custom_tab.dart new file mode 100644 index 0000000..568e2c7 --- /dev/null +++ b/lib/core/components/tab/custom_tab.dart @@ -0,0 +1,137 @@ +import 'package:baseproject/core/components/tab/custom_tab_widget.dart'; +import 'package:baseproject/core/theme/custom_color.dart'; +import 'package:flutter/material.dart'; + +typedef OnTabChanged = Function(int tabIndex); + +class CustomTab extends StatefulWidget { + const CustomTab({ + Key? key, + required this.items, + this.borderBottomColor, + this.onTabChanged, + this.indicatorColor, + this.labelStyle, + this.unselectedLabelStyle, + this.tabItemPadding, + this.tabPadding, + this.isScrollable = false, + this.isTopIcon = false, + this.backgroundColor, + this.indicatorPadding, + this.tabBarIndicatorSize, + this.alignment = Alignment.center, + this.initialIndex = 0, + this.labelColor, + this.unselectedLabelColor, + this.tabController, + this.widgetBottom, + }) : super(key: key); + + final List items; + final Color? borderBottomColor; + final Color? backgroundColor; + final OnTabChanged? onTabChanged; + final Color? indicatorColor; + final TextStyle? labelStyle; + final TextStyle? unselectedLabelStyle; + final EdgeInsets? tabItemPadding; + final EdgeInsets? tabPadding; + final EdgeInsets? indicatorPadding; + final bool isScrollable; + final bool isTopIcon; + final Alignment alignment; + final TabBarIndicatorSize? tabBarIndicatorSize; + final int initialIndex; + final Color? labelColor; + final Color? unselectedLabelColor; + final TabController? tabController; + final Widget? widgetBottom; + + @override + _CustomTabState createState() => _CustomTabState(); +} + +class _CustomTabState extends State with TickerProviderStateMixin, AutomaticKeepAliveClientMixin { + Color get _borderBottomColor => widget.borderBottomColor ?? CustomColor.borderLight; + + int get initialIndex => widget.initialIndex; + late TabController _tabController; + + List get _items => widget.items; + + EdgeInsets get _tabItemPadding => widget.tabItemPadding ?? const EdgeInsets.symmetric(vertical: 12); + + EdgeInsets get _indicatorPadding => widget.indicatorPadding ?? const EdgeInsets.symmetric(horizontal: 12); + + @override + void initState() { + super.initState(); + _tabController = + widget.tabController ?? TabController(length: _items.length, vsync: this, initialIndex: initialIndex); + _tabController.addListener(() { + if (widget.onTabChanged != null && !_tabController.indexIsChanging) { + widget.onTabChanged!(_tabController.index); + } + }); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return Column( + children: [ + CustomTabWidget( + items: widget.items, + borderBottomColor: widget.borderBottomColor, + onTabChanged: widget.onTabChanged, + indicatorColor: widget.indicatorColor, + labelStyle: widget.labelStyle, + unselectedLabelStyle: widget.unselectedLabelStyle, + tabItemPadding: widget.tabItemPadding, + tabPadding: widget.tabPadding, + isScrollable: widget.isScrollable, + isTopIcon: widget.isTopIcon, + backgroundColor: widget.backgroundColor, + indicatorPadding: widget.indicatorPadding, + tabBarIndicatorSize: widget.tabBarIndicatorSize, + alignment: widget.alignment, + initialIndex: widget.initialIndex, + labelColor: widget.labelColor, + unselectedLabelColor: widget.unselectedLabelColor, + tabController: _tabController, + ), + widget.widgetBottom ?? const SizedBox(), + Expanded( + child: TabBarView( + controller: _tabController, + children: widget.items.map((AppBarItemModel e) => e.widget ?? const SizedBox()).toList(), + )), + ], + ); + } + + @override + bool get wantKeepAlive => true; +} + +// ignore: must_be_immutable +class AppBarItemModel { + String title; + int? itemCount; + Color? iconColor; + Color? borderColor; + Color? iconActiveColor; + Widget? widget; + int? id; + + AppBarItemModel( + this.title, { + this.iconColor, + this.borderColor, + this.iconActiveColor, + this.widget, + this.id, + this.itemCount, + }); +} diff --git a/lib/core/components/tab/custom_tab_widget.dart b/lib/core/components/tab/custom_tab_widget.dart new file mode 100644 index 0000000..5820125 --- /dev/null +++ b/lib/core/components/tab/custom_tab_widget.dart @@ -0,0 +1,189 @@ +import 'package:baseproject/core/components/tab/custom_tab.dart'; +import 'package:baseproject/core/theme/custom_color.dart'; +import 'package:flutter/material.dart'; + +class CustomTabWidget extends StatefulWidget { + const CustomTabWidget({ + Key? key, + required this.items, + this.borderBottomColor, + this.onTabChanged, + this.indicatorColor, + this.labelStyle, + this.unselectedLabelStyle, + this.tabItemPadding, + this.tabPadding, + this.isScrollable = false, + this.isTopIcon = false, + this.backgroundColor, + this.indicatorPadding, + this.tabBarIndicatorSize, + this.alignment = Alignment.center, + this.initialIndex = 0, + this.labelColor, + this.unselectedLabelColor, + this.tabController, + this.widgetBottom, + this.decoration, + }) : super(key: key); + + final List items; + final Color? borderBottomColor; + final Color? backgroundColor; + final OnTabChanged? onTabChanged; + final Color? indicatorColor; + final TextStyle? labelStyle; + final TextStyle? unselectedLabelStyle; + final EdgeInsets? tabItemPadding; + final EdgeInsets? tabPadding; + final EdgeInsets? indicatorPadding; + final bool isScrollable; + final bool isTopIcon; + final Alignment alignment; + final TabBarIndicatorSize? tabBarIndicatorSize; + final int initialIndex; + final Color? labelColor; + final Color? unselectedLabelColor; + final TabController? tabController; + final Widget? widgetBottom; + final Decoration? decoration; + @override + State createState() => _CustomTabWidgetState(); +} + +class _CustomTabWidgetState extends State with TickerProviderStateMixin { + Color get _borderBottomColor => widget.borderBottomColor ?? CustomColor.borderLight; + + int get initialIndex => widget.initialIndex; + late TabController _tabController; + + List get _items => widget.items; + + EdgeInsets get _tabItemPadding => widget.tabItemPadding ?? const EdgeInsets.symmetric(vertical: 12); + + EdgeInsets get _indicatorPadding => widget.indicatorPadding ?? const EdgeInsets.symmetric(horizontal: 12); + + @override + void initState() { + super.initState(); + _tabController = + widget.tabController ?? TabController(length: _items.length, vsync: this, initialIndex: initialIndex); + _tabController.addListener(() { + if (widget.onTabChanged != null && !_tabController.indexIsChanging) { + widget.onTabChanged!(_tabController.index); + } + }); + } + + @override + Widget build(BuildContext context) { + if (widget.widgetBottom == null) return _buildContainer(); + return Column( + children: [_buildContainer(), widget.widgetBottom!], + ); + } + + Widget _buildContainer() { + return Container( + width: double.infinity, + decoration: widget.decoration ?? + BoxDecoration( + color: widget.backgroundColor ?? Colors.transparent, + border: Border( + bottom: BorderSide(color: _borderBottomColor), + ), + ), + padding: widget.tabPadding, + alignment: widget.alignment, + child: TabBar( + isScrollable: widget.isScrollable, + controller: _tabController, + indicatorColor: widget.indicatorColor, + indicatorSize: widget.tabBarIndicatorSize ?? TabBarIndicatorSize.tab, + indicatorPadding: _indicatorPadding, + labelStyle: widget.labelStyle, + + indicator: UnderlineTabIndicator( + borderSide: BorderSide(width: 2.0, color: widget.indicatorColor ?? CustomColor.darkSecondColor), + ), + + labelColor: widget.labelColor, + unselectedLabelColor: widget.unselectedLabelColor, + + //For Selected tab + unselectedLabelStyle: widget.unselectedLabelStyle, + tabs: [ + for (int index = 0; index < _items.length; index++) _buildItem(_items[index], index), + ], + ), + ); + } + + Widget _buildItem(AppBarItemModel item, int index) { + return Padding( + padding: _tabItemPadding, + child: Row( + mainAxisAlignment: widget.isTopIcon ? MainAxisAlignment.start : MainAxisAlignment.center, + crossAxisAlignment: widget.isTopIcon ? CrossAxisAlignment.start : CrossAxisAlignment.center, + children: [ + if (item.iconColor != null && !widget.isTopIcon) _buildIcon(item, index), + if (widget.isScrollable) + Text( + item.title + ((item.itemCount != null && item.itemCount! > 0) ? " (${item.itemCount.toString()})" : ""), + // style: widget.unselectedLabelStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + else + Flexible( + child: Text( + item.title + ((item.itemCount != null && item.itemCount! > 0) ? " (${item.itemCount.toString()})" : ""), + // style: widget.unselectedLabelStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (widget.isTopIcon) _buildIconTop(item, index), + ], + ), + ); + } + + Color getIconColor(int index) { + final AppBarItemModel item = widget.items[index]; + if (_tabController.index == index && item.iconActiveColor != null) { + return item.iconActiveColor!; + } + return item.iconColor ?? Colors.transparent; + } + + Widget _buildIcon(AppBarItemModel item, int index) { + return Container( + margin: const EdgeInsets.only(right: 4), + width: 12, + height: 12, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: getIconColor(index), + border: Border.all( + color: (item.borderColor != null && _tabController.index != index) + ? item.borderColor! + : Colors.transparent)), + ); + } + + Widget _buildIconTop(AppBarItemModel item, int index) { + return Container( + margin: const EdgeInsets.only(left: 4), + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: getIconColor(index), + border: Border.all( + color: (item.borderColor != null && _tabController.index != index) + ? item.borderColor! + : Colors.transparent)), + ); + } +} diff --git a/lib/core/constants/constant_string.dart b/lib/core/constants/constant_string.dart new file mode 100644 index 0000000..b8fc3a7 --- /dev/null +++ b/lib/core/constants/constant_string.dart @@ -0,0 +1,11 @@ +class ConstantString { + static const String appName = 'VitanSchool'; + static const int blockId = 96; + static List sentryIgnores = [ + "DioError [DioErrorType.connectTimeout]: Connecting timed out [180000ms]", + "SocketException", + "WebSocket closed with status code: 1002", + "Null check operator used on a null value", + "type '_CastError' is not a subtype of type 'Exception' in type cast", + ]; +} diff --git a/lib/core/constants/storage_key.dart b/lib/core/constants/storage_key.dart new file mode 100644 index 0000000..8b96dcc --- /dev/null +++ b/lib/core/constants/storage_key.dart @@ -0,0 +1,3 @@ +class StorageKey { + static const String libraryKeywordHistoryKey = 'libraryKeywordHistoryKey'; +} diff --git a/lib/core/constants/validate_keys.dart b/lib/core/constants/validate_keys.dart new file mode 100644 index 0000000..a009734 --- /dev/null +++ b/lib/core/constants/validate_keys.dart @@ -0,0 +1,21 @@ +class ValidateKey { + static const String requiredErrorText = "required_error_text"; + static const String equalErrorText = "equalErrorText"; + static const String notEqualErrorText = "notEqualErrorText"; + static const String minErrorText = "minErrorText"; + static const String maxErrorText = "maxErrorText"; + static const String minLengthErrorText = "minLengthErrorText"; + static const String maxLengthErrorText = "maxLengthErrorText"; + static const String emailErrorText = "emailErrorText"; + static const String phoneNumberErrorText = "phoneNumberErrorText"; + static const String urlErrorText = "urlErrorText"; + static const String matchErrorText = "matchErrorText"; + static const String numericErrorText = "numericErrorText"; + static const String integerErrorText = "integerErrorText"; + static const String valueCandidate = "valueCandidate"; + static const String ipErrorText = "ipErrorText"; + static const String dateStringErrorText = "matchErrorText"; + static const String creditCardErrorText = "creditCardErrorText"; + static const String dateGreaterThanErrorText = "date_greater_than_error_text"; + static const String dateGreaterThanNowErrorText = "date_greater_than_now_error_text"; +} diff --git a/lib/core/extension/color_extension.dart b/lib/core/extension/color_extension.dart new file mode 100644 index 0000000..bb77284 --- /dev/null +++ b/lib/core/extension/color_extension.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +class HexColor extends Color { + static int _getColorFromHex(String hexColor) { + hexColor = hexColor.toUpperCase().replaceAll("#", ""); + if (hexColor.length == 6) { + hexColor = "FF" + hexColor; + } + try { + return int.parse(hexColor, radix: 16); + } catch (e) {} + return 0; + } + + HexColor(final String hexColor) : super(_getColorFromHex(hexColor)); +} diff --git a/lib/core/extension/string_extension.dart b/lib/core/extension/string_extension.dart new file mode 100644 index 0000000..644a769 --- /dev/null +++ b/lib/core/extension/string_extension.dart @@ -0,0 +1,281 @@ +import 'dart:io'; + +import 'package:intl/intl.dart'; + +const String localeVN = 'vi_VN'; +const String localeEnUS = 'en_US'; +const String localeFormat = localeVN; +final NumberFormat _numFormat = NumberFormat("#,###", localeFormat); +final NumberFormat numFormatDecimal = NumberFormat("#,##0.00", localeFormat); +final NumberFormat _numFormatCurrency = NumberFormat("###,###,###.##", localeFormat); +final NumberFormat _numFormatCurrencyDecimal = NumberFormat("###,###.###", localeFormat); +final NumberFormat _numFormatCurrencyNotDecimal = NumberFormat("###,###,###", localeFormat); +final DateFormat _dateFormatFull = DateFormat("dd/MM/yyyy HH:mm:ss"); +final DateFormat _dateFormatDMYHHmm = DateFormat("dd/MM/yyyy HH:mm"); +final DateFormat _dateFormatDMY = DateFormat("dd/MM/yyyy"); +final DateFormat _formatYMDHMS = DateFormat("yyyyMMddHHmmss"); +final DateFormat _dateFormatDM = DateFormat("dd/MM"); +final DateFormat _dateFormatY = DateFormat("yyyy"); +final DateFormat _dateFormatM = DateFormat("MM"); +final DateFormat _dateFormatD = DateFormat("dd"); +final DateFormat _dateFormatHours = DateFormat("HH"); +final DateFormat _dateFormatMinute = DateFormat("mm"); +final DateFormat _dateFormatDMYcf = DateFormat("yyyy-MM-ddTHH:mm:ss"); +final DateFormat _dateFormat24h = DateFormat("hh:mm - dd/MM/yyyy"); +final DateFormat _dayFormat24h = DateFormat("hh:mm - dd/MM"); +final NumberFormat numFormatter = NumberFormat("#,##0", localeFormat); +final DateFormat formatDate = DateFormat("dd-MM-yyyy"); +final DateFormat formatHours = DateFormat("hh:mm a"); +final DateFormat formatDateTime = DateFormat("hh:mm a dd/MM/yyyy"); +final DateFormat _formatMMMYYYY = DateFormat("MMMM yyyy"); +final DateFormat _formatMMYYYY = DateFormat("MM/yyyy"); +final DateFormat _formatEEE = DateFormat("EEE"); +final DateFormat _formatWithOutYY = DateFormat("HH:mm dd MMM"); +final DateFormat formatime24h = DateFormat("HH:mm"); +final DateFormat formaDuration = DateFormat("HH:mm:ss"); +final DateFormat formatDMYHHmm = DateFormat("dd/MM/yyyy HH:mm"); +final DateFormat _dateFormatYMD = DateFormat("yyyy-MM-dd"); +final DateFormat _dateFormatHours24h = DateFormat("HH"); + +extension StringExtension on String { + String get capitalize => this.isNullOrEmpty ? "" : '${this[0].toUpperCase()}${substring(1)}'; + + String get firstLowerCase => this.isNullOrEmpty ? "" : '${this[0].toLowerCase()}${substring(1)}'; + String get firstUpCase => this.isNullOrEmpty ? "" : '${this[0].toUpperCase()}${substring(1)}'; + + String get capitalizeFirstofEach => + replaceAll(RegExp(' +'), ' ').split(" ").map((String str) => str.capitalize).join(" "); + + String get numFormatCurrencyDecimal => _numFormatCurrencyDecimal.format((toInt ?? 0).truncate()); + + String get camelCase => replaceAll("-", " ").split(" ").map((String str) => str.capitalize).join().firstLowerCase; +} + +extension StringNullExtension on String? { + bool get isNullOrEmpty => this == null || this!.isEmpty; +} + +const _kb = 1024; +const _M = 1000000; +//exp: 1% +const _minRate = 1; + +extension Nilly on num { + num get safeNum => this; + + num roundAsFixed(int frag) { + return toStringAsFixed(frag).strSafeNum; + } + + num get safePeriodMonth { + final n = this; + final x = (n == 0) ? 12 : n; + return x; + } + + //109.139.000 -> '109,14' + //90.000.000 -> '90' + //90.123.000 -> '90,12' + //### 900.123 -> '900.123' //hâm => 900 tỉ 123 triệu + //### min: _M + //------------ + //9,012 -> '9,01' + //90,812 -> '90,81' + //90,689 -> '90,69' + //90 -> '90' + //999 -> '999' + //999,123 -> '999,12' + //999,999 -> '999,99' + //max: 999,999 (because: 1000 triệu, hâm) + String get millionFormat { + final n = safeNum; + final d = (n >= _M) ? (n / _M) : n; + return d._decimalFormat(); + } + + String pad({int pad = 2}) { + return safeNum.toString().padLeft(2, '0'); + } + + String get mbFormat { + final n = safeNum / _kb; + return "${n._decimalFormat()} Mb"; + } + + String get sign => safeNum >= 0 ? "+" : ""; + + String get currencyFormat => _numFormatCurrency.format(safeNum); + + String get currencyFormatNotDecimal => _numFormatCurrencyNotDecimal.format(safeNum); + + //for int, x >= 1000 + String get numFormat => _numFormat.format(safeNum); + + //đã chia cho total + //=> nhân với 100 + String get dividedPercentFormat { + //return "${(safeNum * 100)}%"; + return "${(safeNum * 100)._decimalFormat()}%"; + } + + //for double, nullable + String get percentFormat { + return "${safeNum._decimalFormat()}%"; + } + + String get interestRateFormat { + return "${_real._decimalFormat()}%"; + } + + //có thể đã đc api chia 100 + //0.9 => 90 + //0.2 => 20 + //1.1 => 1.1 + //12 => 12 + num get _real { + final n = safeNum; + return (n >= _minRate) ? n : n * 100; + } + + //for double not null + String _decimalFormat({int fix = 1}) => + (this is int) ? numFormat : _numFormatCurrency.format(double.parse(toStringAsFixed(fix))); + + String get signNumFormat => "$sign$numFormat"; +} + +extension Dilly on DateTime? { + String get formatDuration => (this != null) ? formaDuration.format(this!) : ""; + + String get formatDM => (this != null) ? _dateFormatDM.format(this!) : ""; + + String get formatYY => (this != null) ? _dateFormatY.format(this!) : ""; + + String get formatMM => (this != null) ? _dateFormatM.format(this!) : ""; + + String get formatDD => (this != null) ? _dateFormatD.format(this!) : ""; + + String get formatDMY => (this != null) ? _dateFormatDMY.format(this!) : ""; + + String get formatYMDHMS => (this != null) ? _formatYMDHMS.format(this!) : ""; + + String get formatDMYHHmm => (this != null) ? _dateFormatDMYHHmm.format(this!) : ""; + + String get formatFull => (this != null) ? _dateFormatFull.format(this!) : ""; + + String get format24h => (this != null) ? _dateFormat24h.format(this!) : ""; + + String get formatDateHoursMinute => (this != null) ? formatime24h.format(this!) : ""; + + String get formatdHMDM => (this != null) ? _dayFormat24h.format(this!) : ""; + + String get formatDateHours => (this != null) ? _dateFormatHours.format(this!) : ""; + + String get formatDateHours24h => (this != null) ? _dateFormatHours24h.format(this!) : ""; + + String get formatDateMinute => (this != null) ? _dateFormatMinute.format(this!) : ""; + + String get formatMMMYYYY => (this != null) ? _formatMMMYYYY.format(this!) : ""; + + String get formatEEE => (this != null) ? _formatEEE.format(this!) : ""; + + String get formatMMYYYY => (this != null) ? _formatMMYYYY.format(this!) : ""; + + String get formatWithOutYY => (this != null) ? _formatWithOutYY.format(this!) : ""; + + String get formatYMD => (this != null) ? _dateFormatYMD.format(this!) : ""; + + String get chatTime { + if (this == null) return ''; + final DateTime now = DateTime.now(); + if (now.formatDMY == this.formatDMY) { + return formatHours.format(this!); + } + return DateFormat("hh:mm a dd/MM").format(this!); + } +} + +const pdf = '.pdf'; + +extension Silly on String? { + String? get unBreak => this != null ? this!.replaceAll('\n', '') : null; + + bool isPdf({bool get = false}) { + final url = this != null ? this!.toLowerCase() : null; + return get || !Platform.isIOS ? url?.contains(pdf) ?? false : url?.endsWith(pdf) ?? false; + } + + String get toPdfPath { + var path = this != null ? this!.substring(this!.lastIndexOf("://") + 1).replaceAll("/", "_") : ""; + if (!path.isPdf()) path += pdf; + return path; + } + + //f: formatted + int? get fToInt => int.tryParse(this!.replaceAll(",", ""))?.safeNum as int; + + int get trimLength => this?.replaceAll(' ', '').length ?? 0; + + int get safeLength => this?.length ?? 0; + + String get spreadFormat => "${this} m2"; + + //2020-03-15T09:21:26.000Z + DateTime? get strToDateCf { + return isNullOrEmpty ? null : _dateFormatDMYcf.parse(this!); + } + + DateTime? get strToDate { + return isNullOrEmpty ? null : _dateFormatDMY.parse(this!); + } + + String get parenthesesFormat => "($this)"; + + int? get toInt => this != null ? int.tryParse(this!) : null; + + String? get formatPhoneNumber { + var phone = this; + if (phone != null) { + if (phone.startsWith("+84")) return phone; + phone = this!.replaceAll("[^\\d.]", ""); + if (phone.startsWith('0')) { + phone = this!.replaceFirst('0', '84'); + } + final builder = StringBuffer(); + builder.write('+'); + if (phone.startsWith('84')) { + builder.write(phone); + } else { + builder + ..write('84') + ..write(phone); + } + return builder.toString(); + } + return null; + } + + String? get correctUrl { + final url = this; + if (url != null) { + var start = url.indexOf("http://"); + if (start < 0) start = url.indexOf("https://"); + return start <= 0 ? url : url.substring(start); + } + return null; + } + + String get strDecimal { + return strSafeNum._decimalFormat(); + } + + num get strSafeNum { + if (isNullOrEmpty) return 0; + return double.tryParse(this!)!.safeNum; + } + + String get normalSearchText { + if (this == null) return ''; + return this!.trim().toLowerCase(); + } +} diff --git a/lib/core/language/app_localizations.dart b/lib/core/language/app_localizations.dart new file mode 100644 index 0000000..c9a17da --- /dev/null +++ b/lib/core/language/app_localizations.dart @@ -0,0 +1,147 @@ +import 'dart:convert'; + +import 'package:intl/intl.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class AppLocalizations { + Locale locale; + late Map _localizedStrings; + static Iterable locales = [ + const Locale( + "vi", + "VN", + ), + const Locale( + "en", + "US", + ) + ]; + String _defaultPath = "lib/core/language/"; + + AppLocalizations(this.locale); + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static LocalizationsDelegate delegate = _AppLocalizationsDelegate(locales); + + void init({List? supportLocales, String? defaultPathI18n}) { + if (supportLocales != null && supportLocales.length > 0) locales = supportLocales; + if (!defaultPathI18n!.isNotEmpty) _defaultPath = defaultPathI18n; + + if (_defaultPath[_defaultPath.length - 1] != '/') _defaultPath += "/"; + } + + Future load() async { + // set Intl + Intl.defaultLocale = locale.languageCode; + //Todo chỉnh phần này nếu muốn load API + final String jsonString = await rootBundle.loadString('$_defaultPath${locale.languageCode}.json'); + + final Map jsonMap = json.decode(jsonString); + _localizedStrings = jsonMap.map((String key, dynamic value) { + return MapEntry(key, value.toString()); + }); + + return true; + } + + //Dịch từ + String translate(String key) { + return _localizedStrings[key] ?? key; + } + + String displayNumber(dynamic value) { + if (value == null) return ""; + if (value - double.parse(value.toString()).toInt() < 1) return NumberFormat("##0.0#").format(value); + if (value - double.parse(value.toString()).toInt() > 0) return NumberFormat("###.0#").format(value); + return NumberFormat().format(value); + } + + String displayScore(BuildContext context, dynamic value) { + return displayNumber(value) + ' ' + AppLocalizations.of(context)!.translate("point").toLowerCase(); + } + + DateFormat getDefaultDateTimeFormat( + {bool isFullTime = false, bool isDateOfWeek = false, bool isOnlyTime = false, bool isDateOfMonth = false}) { + final String? languageCode = locale.languageCode; + if (isFullTime) { + return DateFormat.Hm(languageCode).add_yMd(); + } else if (isDateOfMonth) { + return DateFormat.Hm(languageCode).addPattern('-').add_Md(); + } else if (isDateOfWeek) { + return DateFormat.yMMMMEEEEd(languageCode); + } else if (isOnlyTime) { + return DateFormat.Hm(languageCode); + } else { + return DateFormat.yMd(languageCode); + } + } + + String displayDateTime(DateTime? value, + {bool isFullTime = true, bool isDateOfWeek = false, bool isOnlyTime = false, bool isDateOfMonth = false}) { + return value != null + ? getDefaultDateTimeFormat( + isFullTime: isFullTime, + isDateOfWeek: isDateOfWeek, + isOnlyTime: isOnlyTime, + isDateOfMonth: isDateOfMonth) + .format(value) + : ''; + } + + String displayTime(DateTime value) { + final String? languageCode = locale.languageCode; + return DateFormat.Hm(languageCode).format(value); + } + + //Khởi tạo Locale + static Locale localeResolutionCallback(Locale? locale, Iterable supportedLocales) { + for (final Locale supportedLocale in supportedLocales) { + if (locale != null && + supportedLocale.languageCode == locale.languageCode && + supportedLocale.countryCode == locale.countryCode) { + return supportedLocale; + } + } + return supportedLocales.first; + } + + Future changeLocale(BuildContext context, Locale locale) { + AppLocalizations.of(context)!.locale = locale; + Intl.defaultLocale = locale.languageCode; + return AppLocalizations.of(context)!.load(); + } + + // Convert tiền + String displayCurrency(double price) { + final String? languageCode = locale.languageCode; + final String curency = NumberFormat.simpleCurrency(locale: languageCode).format(price); + return curency; + } +} + +class _AppLocalizationsDelegate extends LocalizationsDelegate { + final Iterable _supportLocale; + + _AppLocalizationsDelegate(this._supportLocale); + + @override + bool isSupported(Locale locale) { + // Include all of your supported language codes here + return _supportLocale.map((e) => e.languageCode).contains(locale.languageCode); + } + + @override + Future load(Locale locale) async { + // AppLocalizations class is where the JSON loading actually runs + final AppLocalizations localizations = AppLocalizations(locale); + await localizations.load(); + return localizations; + } + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} diff --git a/lib/core/language/en.json b/lib/core/language/en.json new file mode 100644 index 0000000..3d5985f --- /dev/null +++ b/lib/core/language/en.json @@ -0,0 +1,5 @@ +{ + "first_string": "Hello! This is the first message.", + "second_string": "If this tutorial helps you, give it a like and subscribe to Reso Coder 😉", + "setting": "setting" +} \ No newline at end of file diff --git a/lib/core/language/vi.json b/lib/core/language/vi.json new file mode 100644 index 0000000..1f111b1 --- /dev/null +++ b/lib/core/language/vi.json @@ -0,0 +1,3 @@ +{ + "first_string": "Chào Hiếu nhé." +} \ No newline at end of file diff --git a/lib/core/theme/custom_color.dart b/lib/core/theme/custom_color.dart new file mode 100644 index 0000000..e82ba57 --- /dev/null +++ b/lib/core/theme/custom_color.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class CustomColor { + static const Color primaryColor = const Color(0xff663300); + static Color textColor = const Color(0xFF414B5B); + static Color kTextWhiteColor = Colors.white; + static Color dividerDefaultColor = const Color(0xff3F4053).withValues(alpha: 0.2); + static Color colorSubText = const Color(0xff858D9A); + static Color kBackgroundWhite = kTextWhiteColor; + static const Color textGray = Color(0xff858d9a); + static const Color redText = Color(0xffd72424); + static const Color borderLight = Color(0xffe0e3e9); + static const Color darkSecondColor = Color(0xffe48617); + static const Color bgGrayLight = Color(0xfff2f3f5); + +//Button + static const Color colorButtonDefault = Color(0xffE1E4FF); + static const Color colorButtonBold = Color(0xff5E67BA); + static Color colorTextButtonDefault = colorButtonBold; + + //barrierColor + static Color barrierColor = const Color(0xff1B1E37).withValues(alpha: 0.9); +} diff --git a/lib/core/theme/custom_theme.dart b/lib/core/theme/custom_theme.dart new file mode 100644 index 0000000..2018ff4 --- /dev/null +++ b/lib/core/theme/custom_theme.dart @@ -0,0 +1,91 @@ +import 'package:baseproject/core/theme/index.dart'; +import 'package:flutter/material.dart'; + +ThemeData _getThemeData(BuildContext context) { + return ThemeData( + textTheme: Theme.of(context).textTheme.apply( + bodyColor: CustomColor.textColor, + fontFamily: fontFamily, + ), + visualDensity: VisualDensity.adaptivePlatformDensity, + // appBarTheme: AppBarTheme( + // textTheme: Theme.of(context).textTheme.apply( + // fontFamily: fontFamily, + // )), + scaffoldBackgroundColor: Colors.white, + iconTheme: IconTheme.of(context).copyWith(color: CustomColor.textColor), + bottomSheetTheme: const BottomSheetThemeData(backgroundColor: Colors.white), + inputDecorationTheme: FormTheme.getDefaultInputDecorationTheme(), + // tabBarTheme: TabBarTheme( + // indicator: const UnderlineTabIndicator( + // borderSide: BorderSide(color: CustomTheme.tabBarIndicatorColor, width: 2), + // ), + // labelStyle: textStyleBodySmall.copyWith(fontWeight: FontWeight.w600), + // labelColor: CustomColor.textColor, + // unselectedLabelColor: CustomColor.textColor, + // unselectedLabelStyle: textStyleBodySmall, + // ), + // inputDecorationTheme: InputDecorationTheme( + // border: inputDecoration.border, + // enabledBorder: inputDecoration.enabledBorder, + // errorBorder: inputDecoration.errorBorder, + // errorStyle: TextStyle(color: CoreColor.colorInputTextError), + // ), + // checkboxTheme: CheckboxThemeData( + // checkColor: MaterialStateProperty.all(CheckboxSettings.checkColor), + // fillColor: MaterialStateProperty.all(CheckboxSettings.fillColor), + // side: BorderSide(color: CheckboxSettings.borderColor, width: 1), + // shape: RoundedRectangleBorder( + // borderRadius: BorderRadius.circular(6), + // ), + // ), + ); +} + +ThemeData _getThemeDataDark(BuildContext context) { + return ThemeData( + visualDensity: VisualDensity.adaptivePlatformDensity, + textTheme: Theme.of(context).textTheme.apply( + bodyColor: CustomColor.textColor, + fontFamily: fontFamily, + ), + // appBarTheme: AppBarTheme( + // textTheme: Theme.of(context).textTheme.apply( + // fontFamily: fontFamily, + // )), + scaffoldBackgroundColor: Colors.white, + iconTheme: IconTheme.of(context).copyWith(color: CustomColor.textColor), + bottomSheetTheme: const BottomSheetThemeData(backgroundColor: Colors.white), + // tabBarTheme: TabBarTheme( + // indicator: const UnderlineTabIndicator( + // borderSide: BorderSide(color: CustomTheme.tabBarIndicatorColor, width: 2), + // ), + // labelStyle: textStyleBodySmall.copyWith(fontWeight: FontWeight.w600), + // labelColor: CustomColor.textColor, + // unselectedLabelColor: CustomColor.textColor, + // unselectedLabelStyle: textStyleBodySmall, + // ), + // inputDecorationTheme: InputDecorationTheme( + // border: inputDecoration.border, + // enabledBorder: inputDecoration.enabledBorder, + // errorBorder: inputDecoration.errorBorder, + // errorStyle: TextStyle(color: CoreColor.colorInputTextError), + // ), + // checkboxTheme: CheckboxThemeData( + // checkColor: MaterialStateProperty.all(CheckboxSettings.checkColor), + // fillColor: MaterialStateProperty.all(CheckboxSettings.fillColor), + // side: BorderSide(color: CheckboxSettings.borderColor, width: 1), + // shape: RoundedRectangleBorder( + // borderRadius: BorderRadius.circular(6), + // ), + // ), + ); +} + +ThemeData getTheme(BuildContext context, bool isLight) { + if (isLight) { + return _getThemeData(context); + } else { + return _getThemeDataDark(context); + } +} diff --git a/lib/core/theme/form_theme.dart b/lib/core/theme/form_theme.dart new file mode 100644 index 0000000..9225d10 --- /dev/null +++ b/lib/core/theme/form_theme.dart @@ -0,0 +1,60 @@ +import 'package:baseproject/core/theme/custom_color.dart'; +import 'package:baseproject/core/theme/text_style.dart'; +import 'package:flutter/material.dart'; + +class FormTheme { + static InputDecoration getInputDecoration({double radius = 6, Color? borderColor}) { + return InputDecoration( + border: OutlineInputBorder( + borderSide: BorderSide(color: borderColor ?? CustomColor.textGray), + borderRadius: BorderRadius.all(Radius.circular(radius)), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: borderColor ?? CustomColor.textGray), + borderRadius: BorderRadius.all(Radius.circular(radius)), + ), + errorBorder: OutlineInputBorder( + borderSide: const BorderSide(color: CustomColor.redText), + borderRadius: BorderRadius.all(Radius.circular(radius)), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: borderColor ?? CustomColor.textGray), + borderRadius: BorderRadius.all(Radius.circular(radius)), + ), + disabledBorder: OutlineInputBorder( + borderSide: const BorderSide(color: CustomColor.textGray), + borderRadius: BorderRadius.all(Radius.circular(radius)), + ), + ); + } + + static getDefaultInputDecorationTheme({double radius = 6}) => InputDecorationTheme( + floatingLabelBehavior: FloatingLabelBehavior.never, + prefixStyle: textStyleBodySmall.copyWith(color: CustomColor.textGray), + prefixIconColor: CustomColor.textGray, + focusColor: CustomColor.textGray, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + border: FormTheme.getInputDecoration(radius: radius).border, + enabledBorder: FormTheme.getInputDecoration(radius: radius).enabledBorder, + errorBorder: FormTheme.getInputDecoration(radius: radius).errorBorder, + focusedBorder: FormTheme.getInputDecoration(radius: radius).focusedBorder, + disabledBorder: FormTheme.getInputDecoration(radius: radius).disabledBorder, + floatingLabelStyle: textStyleBodySmall.copyWith(color: CustomColor.textGray), + hintStyle: textStyleBodySmall.copyWith(color: CustomColor.textGray), + ); + + static getSecondInputDecorationTheme({double radius = 6}) => InputDecorationTheme( + floatingLabelBehavior: FloatingLabelBehavior.never, + prefixStyle: textStyleBodySmall.copyWith(color: CustomColor.textGray), + prefixIconColor: CustomColor.textGray, + focusColor: CustomColor.textGray, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + border: FormTheme.getInputDecoration(radius: 6, borderColor: CustomColor.textGray).border, + enabledBorder: FormTheme.getInputDecoration(radius: 6, borderColor: CustomColor.textGray).enabledBorder, + errorBorder: FormTheme.getInputDecoration(radius: 6).errorBorder, + focusedBorder: FormTheme.getInputDecoration(radius: 6).focusedBorder, + disabledBorder: FormTheme.getInputDecoration(radius: 6).disabledBorder, + floatingLabelStyle: textStyleBodySmall.copyWith(color: CustomColor.textGray), + hintStyle: textStyleBodySmall.copyWith(color: CustomColor.textGray), + fillColor: Colors.white); +} diff --git a/lib/core/theme/index.dart b/lib/core/theme/index.dart new file mode 100644 index 0000000..e0011b3 --- /dev/null +++ b/lib/core/theme/index.dart @@ -0,0 +1,5 @@ +export 'custom_color.dart'; +export 'custom_theme.dart'; +export 'form_theme.dart'; +export 'size.dart'; +export 'text_style.dart'; diff --git a/lib/core/theme/size.dart b/lib/core/theme/size.dart new file mode 100644 index 0000000..e4fde00 --- /dev/null +++ b/lib/core/theme/size.dart @@ -0,0 +1,8 @@ +import 'package:flutter/material.dart'; + +const double kPaddingDefault = 12; +const double kAppBarHeight = 44; +const EdgeInsets paddingBodyDefault = EdgeInsets.symmetric(horizontal: kPaddingDefault); +const EdgeInsets paddingButtonDefault = EdgeInsets.symmetric(vertical: 5, horizontal: 12); +const EdgeInsets paddingButtonMedium = EdgeInsets.symmetric(vertical: 12, horizontal: 14); +const EdgeInsets paddingButtonSmall = EdgeInsets.symmetric(vertical: 6, horizontal: 6); diff --git a/lib/core/theme/text_style.dart b/lib/core/theme/text_style.dart new file mode 100644 index 0000000..eff2f94 --- /dev/null +++ b/lib/core/theme/text_style.dart @@ -0,0 +1,61 @@ +import 'package:baseproject/core/theme/custom_color.dart'; +import 'package:flutter/material.dart'; + +// font chữ +String fontFamily = "Sarabun"; +String fontGilroy = "SVN-Gilroy"; +double fontHeadline1 = 96; +double fontHeadline2 = 60; +double fontHeadline3 = 48; +double fontHeadline4 = 34; +double fontHeadline5 = 24; +double fontBodyLarge = 22; +double fontHeadline6 = 20; +double fontBodyMedium = 18; +double fontBodyDefault = 16; // Thiết kế là Body 1 +double fontBodySmall = 14; // Thiết kế là font Body 2 +double fontBodySmallest = 12; + +TextStyle get textStyleHeadline1 => TextStyle(fontSize: fontHeadline1, fontWeight: FontWeight.normal); + +TextStyle get textStyleHeadline2 => TextStyle(fontSize: fontHeadline2, fontWeight: FontWeight.w600); + +TextStyle get textStyleHeadline3 => TextStyle(fontSize: fontHeadline3, fontWeight: FontWeight.w600); + +TextStyle get textStyleHeadline4 => TextStyle(fontSize: fontHeadline4, fontWeight: FontWeight.w600); + +TextStyle get textStyleHeadline5 => TextStyle(fontSize: fontHeadline5, fontWeight: FontWeight.w700); + +TextStyle get textStyleHeadline6 => + TextStyle(fontSize: fontHeadline6, fontWeight: FontWeight.w700, fontFamily: fontFamily); + +TextStyle get textStyleHeadline6Default => TextStyle(fontSize: fontHeadline6, fontFamily: fontFamily); + +TextStyle get textStyleBodyMedium => TextStyle(fontSize: fontBodyMedium, fontFamily: fontFamily); + +TextStyle get textStyleBodyMediumBold => + TextStyle(fontSize: fontBodyMedium, fontWeight: FontWeight.bold, fontFamily: fontFamily); + +TextStyle get textStyleBodyDefault => TextStyle(fontSize: fontBodyDefault, fontFamily: fontFamily); + +TextStyle get textStyleBodyDefaultBold => + TextStyle(fontSize: fontBodyDefault, fontWeight: FontWeight.bold, fontFamily: fontFamily); + +///font 14, +TextStyle get textStyleBodySmall => TextStyle(fontSize: fontBodySmall, fontFamily: fontFamily); + +TextStyle get textStyleBodySmallWhite => + TextStyle(fontSize: fontBodySmall, fontFamily: fontFamily, color: CustomColor.kTextWhiteColor); + +TextStyle get textStyleBodySmallBold => TextStyle(fontSize: fontBodySmall, fontWeight: FontWeight.bold); + +TextStyle get textStyleBodySmallest => TextStyle(fontSize: fontBodySmallest); + +TextStyle get textStyleBodySmallestBold => TextStyle(fontSize: fontBodySmallest, fontWeight: FontWeight.bold); + +TextStyle get textStyleBodyLarge => TextStyle(fontSize: fontBodyLarge); + +TextStyle get textStyleBodyLargeBold => TextStyle(fontSize: fontBodyLarge, fontWeight: FontWeight.bold); + +TextStyle get textAppBarDefault => + textStyleBodyMedium.copyWith(color: CustomColor.kTextWhiteColor, fontWeight: FontWeight.w600); diff --git a/lib/features/model/file/attach_file_model.dart b/lib/features/model/file/attach_file_model.dart new file mode 100644 index 0000000..e304e5e --- /dev/null +++ b/lib/features/model/file/attach_file_model.dart @@ -0,0 +1,23 @@ +import 'package:json_annotation/json_annotation.dart'; +part 'attach_file_model.g.dart'; + +@JsonSerializable(explicitToJson: true) +class AttachFileModel { + AttachFileModel({ + this.title = '', + this.url = '', + this.type = '', + }); + + factory AttachFileModel.fromJson(Map json) => _$AttachFileModelFromJson(json); + + @JsonKey(name: 'title', includeIfNull: true, defaultValue: '') + String title; + @JsonKey(name: 'url', includeIfNull: true, defaultValue: '') + String url; + @JsonKey(name: 'type', includeIfNull: true, defaultValue: '') + String type; + static const fromJsonFactory = _$AttachFileModelFromJson; + static const toJsonFactory = _$AttachFileModelToJson; + Map toJson() => _$AttachFileModelToJson(this); +} diff --git a/lib/features/model/file/attach_file_model.g.dart b/lib/features/model/file/attach_file_model.g.dart new file mode 100644 index 0000000..c9bb1d8 --- /dev/null +++ b/lib/features/model/file/attach_file_model.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'attach_file_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +AttachFileModel _$AttachFileModelFromJson(Map json) => + AttachFileModel( + title: json['title'] as String? ?? '', + url: json['url'] as String? ?? '', + type: json['type'] as String? ?? '', + ); + +Map _$AttachFileModelToJson(AttachFileModel instance) => + { + 'title': instance.title, + 'url': instance.url, + 'type': instance.type, + }; diff --git a/lib/features/presentation/app/view/app.dart b/lib/features/presentation/app/view/app.dart new file mode 100644 index 0000000..ed5fa75 --- /dev/null +++ b/lib/features/presentation/app/view/app.dart @@ -0,0 +1,72 @@ +import 'package:baseproject/core/components/alice.dart'; +import 'package:baseproject/core/language/app_localizations.dart'; +import 'package:baseproject/core/theme/custom_theme.dart'; +import 'package:baseproject/features/route/route_const.dart'; +import 'package:baseproject/features/route/route_generator.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; + +final GlobalKey? navigatorKey = GlobalKey(); + +class App extends StatefulWidget { + const App({Key? key}) : super(key: key); + + @override + State createState() => _AppState(); +} + +class _AppState extends State { + String _getLanguage() { + return 'vi'; + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + // navigatorObservers: [CustomNavigatorObserver()], + debugShowCheckedModeBanner: false, + theme: getTheme(context, true), + // navigatorKey: navigatorKey, + locale: Locale(_getLanguage()), + supportedLocales: AppLocalizations.locales, + localizationsDelegates: >[ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate + ], + navigatorKey: navigatorKey, + localeResolutionCallback: AppLocalizations.localeResolutionCallback, + initialRoute: appInitRouteName, + onGenerateRoute: RouteGenerator.generatorRoute, + builder: EasyLoading.init(builder: (BuildContext context, Widget? child) { + EasyLoading.instance.userInteractions = false; + return Container( + child: kDebugMode + ? Stack( + children: [ + child!, + Positioned( + bottom: 0, + right: 0, + child: Container( + width: 30, + height: 30, + child: FloatingActionButton( + onPressed: () { + CustomAlice.showScreen(); + }, + backgroundColor: Colors.red, + child: const Text("A"), + ), + ), + ), + ], + ) + : child!, + ); + })); + } +} diff --git a/lib/features/presentation/home/view/home.dart b/lib/features/presentation/home/view/home.dart new file mode 100644 index 0000000..2652a23 --- /dev/null +++ b/lib/features/presentation/home/view/home.dart @@ -0,0 +1,20 @@ +import 'package:baseproject/core/language/app_localizations.dart'; +import 'package:flutter/material.dart'; + +class Home extends StatefulWidget { + const Home({Key? key}) : super(key: key); + + @override + State createState() => _HomeState(); +} + +class _HomeState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Text(AppLocalizations.of(context)!.translate("first_string")), + ), + ); + } +} diff --git a/lib/features/route/route_const.dart b/lib/features/route/route_const.dart new file mode 100644 index 0000000..a8fc6d0 --- /dev/null +++ b/lib/features/route/route_const.dart @@ -0,0 +1 @@ +const String appInitRouteName = '/app_init'; diff --git a/lib/features/route/route_generator.dart b/lib/features/route/route_generator.dart new file mode 100644 index 0000000..8f14529 --- /dev/null +++ b/lib/features/route/route_generator.dart @@ -0,0 +1,16 @@ +import 'package:baseproject/features/presentation/home/view/home.dart'; +import 'package:baseproject/features/route/route_const.dart'; +import 'package:flutter/material.dart'; + +class RouteGenerator { + static Route? generatorRoute(RouteSettings setting) { + // LocalStoreManager.setCurrentScreen(setting.name ?? ''); + // tracking vào màn + switch (setting.name) { + case appInitRouteName: + return MaterialPageRoute(settings: setting, builder: (_) => const Home()); + default: + return null; + } + } +} diff --git a/lib/features/route/route_goto.dart b/lib/features/route/route_goto.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..f10d50c --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,7 @@ +import 'package:baseproject/features/presentation/app/view/app.dart'; +import 'package:flutter/material.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp(const App()); +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..825ebbe --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,131 @@ +name: baseproject +description: A new Flutter project. + +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +version: 1.0.0+1 + +environment: + sdk: ">=2.16.1 <3.0.0" + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + flutter_bloc: ^8.1.6 + cached_network_image: ^3.3.1 + flutter_svg: ^1.1.6 + dio: ^4.0.6 + retrofit: ^3.3.1 + get_it: ^7.7.0 + injectable: ^1.5.4 + intl: ^0.20.0 + flutter_easyloading: ^3.0.5 + alice: + path: ./alice/ + shared_preferences: 2.5.3 + fluttertoast: ^8.2.4 + gal: ^2.3.1 + photo_view: ^0.15.0 + animations: ^2.0.7 + url_launcher: ^6.1.11 + flutter_datetime_picker_plus: ^2.1.0 + file_picker: ^8.1.6 + flutter_staggered_grid_view: ^0.7.0 + pull_to_refresh: ^2.0.0 + +dependency_overrides: + watcher: ^1.1.0 + ffi: 2.1.4 + file: 7.0.1 + pub_semver: 2.1.4 + win32: 5.13.0 + http: ^1.4.0 + +dev_dependencies: + flutter_test: + sdk: flutter + injectable_generator: + build_runner: 2.3.3 + retrofit_generator: 4.2.0 + json_serializable: ^6.3.0 + + + + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^1.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + assets: + - lib/core/language/vi.json + - lib/core/language/en.json + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..0159b62 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,29 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility that Flutter provides. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:baseproject/features/presentation/app/view/app.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const App()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..fd615e1 --- /dev/null +++ b/web/index.html @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + baseproject + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..6985a15 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "baseproject", + "short_name": "baseproject", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..68c08c3 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,95 @@ +cmake_minimum_required(VERSION 3.14) +project(baseproject LANGUAGES CXX) + +set(BINARY_NAME "baseproject") + +cmake_policy(SET CMP0063 NEW) + +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Configure build options. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() + +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") + +# Flutter library and tool build rules. +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build +add_subdirectory("runner") + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..b2e4bd8 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,103 @@ +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + windows-x64 $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..de2d891 --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,17 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) +apply_standard_settings(${BINARY_NAME}) +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..24a3e8f --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#ifdef FLUTTER_BUILD_NUMBER +#define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER +#else +#define VERSION_AS_NUMBER 1,0,0 +#endif + +#ifdef FLUTTER_BUILD_NAME +#define VERSION_AS_STRING #FLUTTER_BUILD_NAME +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "baseproject" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "baseproject" "\0" + VALUE "LegalCopyright", "Copyright (C) 2022 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "baseproject.exe" "\0" + VALUE "ProductName", "baseproject" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..b43b909 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,61 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..db99492 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.CreateAndShow(L"baseproject", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..c977c4a --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,20 @@ + + + + + PerMonitorV2 + + + + + + + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..d19bdbb --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,64 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr); + if (target_length == 0) { + return std::string(); + } + std::string utf8_string; + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, utf8_string.data(), + target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..c10f08d --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,245 @@ +#include "win32_window.h" + +#include + +#include "resource.h" + +namespace { + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + FreeLibrary(user32_module); + } +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::CreateAndShow(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW | WS_VISIBLE, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + return OnCreate(); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..17ba431 --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,98 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates and shows a win32 window with |title| and position and size using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size to will treat the width height passed in to this function + // as logical pixels and scale to appropriate for the default monitor. Returns + // true if the window was created successfully. + bool CreateAndShow(const std::wstring& title, + const Point& origin, + const Size& size); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responsponds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_