├── .dart_tool └── flutter_gen │ └── pubspec.yaml ├── .github └── workflows │ └── dart.yml ├── .gitignore ├── .metadata ├── CHANGELOG.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── android ├── .gitignore ├── app │ ├── build.gradle │ └── src │ │ ├── debug │ │ └── AndroidManifest.xml │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ │ └── habrapp │ │ │ │ └── habr_app │ │ │ │ └── MainActivity.kt │ │ └── res │ │ │ ├── drawable-v21 │ │ │ └── launch_background.xml │ │ │ ├── drawable │ │ │ └── launch_background.xml │ │ │ ├── mipmap-hdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-mdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xhdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xxhdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xxxhdpi │ │ │ └── ic_launcher.png │ │ │ ├── values-night │ │ │ └── styles.xml │ │ │ └── values │ │ │ └── styles.xml │ │ └── profile │ │ └── AndroidManifest.xml ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties └── settings.gradle ├── assets ├── icon │ └── icon.png └── images │ ├── background.png │ ├── background.svg │ ├── default_avatar.svg │ ├── empty_comments.svg │ ├── fatal_error.webp │ ├── lot_of_entropy.webp │ ├── resolved.webp │ └── ufo.png ├── ios ├── .gitignore ├── Flutter │ ├── AppFrameworkInfo.plist │ ├── Debug.xcconfig │ └── Release.xcconfig ├── Runner.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── WorkspaceSettings.xcsettings │ └── xcshareddata │ │ └── xcschemes │ │ └── Runner.xcscheme ├── Runner.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── WorkspaceSettings.xcsettings └── Runner │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon-App-1024x1024@1x.png │ │ ├── Icon-App-20x20@1x.png │ │ ├── Icon-App-20x20@2x.png │ │ ├── Icon-App-20x20@3x.png │ │ ├── Icon-App-29x29@1x.png │ │ ├── Icon-App-29x29@2x.png │ │ ├── Icon-App-29x29@3x.png │ │ ├── Icon-App-40x40@1x.png │ │ ├── Icon-App-40x40@2x.png │ │ ├── Icon-App-40x40@3x.png │ │ ├── Icon-App-50x50@1x.png │ │ ├── Icon-App-50x50@2x.png │ │ ├── Icon-App-57x57@1x.png │ │ ├── Icon-App-57x57@2x.png │ │ ├── Icon-App-60x60@2x.png │ │ ├── Icon-App-60x60@3x.png │ │ ├── Icon-App-72x72@1x.png │ │ ├── Icon-App-72x72@2x.png │ │ ├── Icon-App-76x76@1x.png │ │ ├── Icon-App-76x76@2x.png │ │ └── Icon-App-83.5x83.5@2x.png │ └── LaunchImage.imageset │ │ ├── Contents.json │ │ ├── LaunchImage.png │ │ ├── LaunchImage@2x.png │ │ ├── LaunchImage@3x.png │ │ └── README.md │ ├── Base.lproj │ ├── LaunchScreen.storyboard │ └── Main.storyboard │ ├── Info.plist │ └── Runner-Bridging-Header.h ├── l10n.yaml ├── lib ├── app_error.dart ├── habr │ ├── api.dart │ ├── habr.dart │ └── json_parsing.dart ├── hive │ ├── adaptors.dart │ ├── author.dart │ ├── author_avatar_info.dart │ ├── cached_image_info.dart │ ├── post.dart │ ├── post_preview.dart │ ├── postpreview_filter_adapter.dart │ ├── register_in_hive.dart │ └── settings_adaptors.dart ├── l10n │ ├── app_en.arb │ └── app_ru.arb ├── main.dart ├── models │ ├── author.dart │ ├── author_avatar_info.dart │ ├── author_info.dart │ ├── cached_image_info.dart │ ├── cached_post.dart │ ├── comment.dart │ ├── models.dart │ ├── post.dart │ ├── post_preview.dart │ └── statistics.dart ├── pages │ ├── article │ │ ├── article.dart │ │ └── components │ │ │ └── post_store.dart │ ├── articles_list.dart │ ├── bookmarks.dart │ ├── cached_articles.dart │ ├── comments │ │ ├── comments.dart │ │ └── components │ │ │ └── comments_store.dart │ ├── filters.dart │ ├── image_view.dart │ ├── pages.dart │ ├── search.dart │ ├── search_results.dart │ ├── settings.dart │ └── user.dart ├── routing │ └── routing.dart ├── stores │ ├── app_settings.dart │ ├── articles_store.dart │ ├── avatar_color_store.dart │ ├── bookmarks_store.dart │ ├── filters_store.dart │ ├── habr_storage.dart │ ├── image_storage.dart │ ├── loading_state.dart │ └── user_info_store.dart ├── styles │ ├── colors │ │ ├── colors.dart │ │ └── default_avatar.dart │ └── themes │ │ ├── dark.dart │ │ ├── light.dart │ │ ├── text_theme.dart │ │ └── themes.dart ├── utils │ ├── date_to_text.dart │ ├── filters │ │ ├── article_preview_filters.dart │ │ └── filter.dart │ ├── hive_helper.dart │ ├── html_to_json.dart │ ├── html_to_json │ │ ├── element_builders.dart │ │ ├── html_to_json.dart │ │ ├── text_mode.dart │ │ └── transformer.dart │ ├── http_request_helper.dart │ ├── images_finder.dart │ ├── integer_to_text.dart │ ├── log.dart │ ├── luid.dart │ ├── message_notifier.dart │ ├── page_loaders │ │ ├── page_loader.dart │ │ └── preview_loader.dart │ ├── platform_helper.dart │ ├── url_open.dart │ ├── worker │ │ ├── id_generator.dart │ │ ├── runnable.dart │ │ └── worker.dart │ └── workers │ │ ├── hasher.dart │ │ └── image_loader.dart └── widgets │ ├── adaptive_ui.dart │ ├── article_preview.dart │ ├── articles_list_body.dart │ ├── author_avatar_icon.dart │ ├── author_previews.dart │ ├── circular_item.dart │ ├── dividing_block.dart │ ├── dropdown_list_tile.dart │ ├── hide_floating_action_button.dart │ ├── hr.dart │ ├── html_elements │ ├── headline.dart │ ├── highlight_code.dart │ ├── html_elements.dart │ ├── iframe.dart │ ├── math_formula.dart │ ├── quote_block.dart │ ├── spoiler_block.dart │ └── unordered_list.dart │ ├── html_view.dart │ ├── incrementally_loading_listview.dart │ ├── informing │ ├── empty_content.dart │ ├── informing.dart │ ├── internet_error_view.dart │ ├── load_appbar_title.dart │ └── lot_of_entropy.dart │ ├── link.dart │ ├── load_builder.dart │ ├── material_buttons.dart │ ├── medium_author_preview.dart │ ├── picture.dart │ ├── routing │ └── home_menu.dart │ ├── scroll_data.dart │ ├── slidable.dart │ ├── small_author_preview.dart │ ├── statistics_icons.dart │ └── widgets.dart ├── pubspec.yaml ├── repo_images ├── gif_1.gif ├── img_1.jpg ├── img_2.jpg ├── img_3.jpg ├── img_4.jpg ├── img_5.jpg └── img_6.jpg ├── test └── theme_time_swap_test.dart ├── web ├── favicon.png ├── icons │ ├── icon-192.png │ └── icon-512.png ├── index.html └── manifest.json └── windows ├── .gitignore ├── CMakeLists.txt ├── flutter ├── CMakeLists.txt ├── generated_plugin_registrant.cc ├── generated_plugin_registrant.h └── generated_plugins.cmake └── runner ├── CMakeLists.txt ├── Runner.rc ├── flutter_window.cpp ├── flutter_window.h ├── main.cpp ├── resource.h ├── resources └── app_icon.ico ├── run_loop.cpp ├── run_loop.h ├── runner.exe.manifest ├── utils.cpp ├── utils.h ├── win32_window.cpp └── win32_window.h /.dart_tool/flutter_gen/pubspec.yaml: -------------------------------------------------------------------------------- 1 | # Generated by the flutter tool 2 | name: synthetic_package 3 | description: The Flutter application's synthetic package. 4 | -------------------------------------------------------------------------------- /.github/workflows/dart.yml: -------------------------------------------------------------------------------- 1 | name: Dart CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | release: 9 | types: [created] 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: actions/setup-java@v2 18 | with: 19 | distribution: 'zulu' 20 | java-version: '11' 21 | - uses: subosito/flutter-action@v2 22 | with: 23 | flutter-version: '2.10.3' 24 | channel: 'stable' 25 | - name: fix 26 | run: flutter clean 27 | - name: Install dependencies 28 | run: flutter pub get 29 | - name: Codegen 30 | run: | 31 | flutter pub run build_runner build 32 | - name: Build apk 33 | run: | 34 | flutter build apk --split-per-abi 35 | - name: Make zip with apks 36 | run: | 37 | cd build/app/outputs/apk/release 38 | zip apk.zip app-arm64-v8a-release.apk app-armeabi-v7a-release.apk app-x86_64-release.apk 39 | - name: Upload apk 40 | uses: actions/upload-artifact@v2 41 | with: 42 | name: apk 43 | path: build/app/outputs/apk/release/apk.zip 44 | deploy: 45 | runs-on: ubuntu-latest 46 | 47 | if: github.ref != 'refs/heads/master' 48 | needs: [build] 49 | steps: 50 | - name: Checkout code 51 | uses: actions/checkout@v2 52 | - name: Download artifacts 53 | uses: actions/download-artifact@v2 54 | with: 55 | name: apk 56 | - name: Log files 57 | run: echo $(ls) 58 | - name: Get release 59 | id: get_release 60 | uses: bruceadams/get-release@v1.2.1 61 | env: 62 | GITHUB_TOKEN: ${{ github.token }} 63 | 64 | - name: Upload release binary 65 | uses: actions/upload-release-asset@v1.0.2 66 | env: 67 | GITHUB_TOKEN: ${{ github.token }} 68 | with: 69 | upload_url: ${{ steps.get_release.outputs.upload_url }} 70 | asset_path: ./apk.zip 71 | asset_name: apk.zip 72 | asset_content_type: application/zip 73 | 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # The .vscode folder contains launch configuration and tasks you configure in 19 | # VS Code which you may wish to be included in version control, so this line 20 | # is commented out by default. 21 | #.vscode/ 22 | 23 | # Flutter/Dart/Pub related 24 | **/doc/api/ 25 | **/ios/Flutter/.last_build_id 26 | .dart_tool/ 27 | .flutter-plugins 28 | .flutter-plugins-dependencies 29 | .packages 30 | .pub-cache/ 31 | .pub/ 32 | /build/ 33 | *.g.dart 34 | pubspec.lock 35 | 36 | # Web related 37 | lib/generated_plugin_registrant.dart 38 | 39 | # Symbolication related 40 | app.*.symbols 41 | 42 | # Obfuscation related 43 | app.*.map.json 44 | 45 | # Exceptions to above rules. 46 | !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages 47 | 48 | local.properties -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: bbfbf1770cca2da7c82e887e4e4af910034800b6 8 | channel: stable 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v1.7.3 2 | 3 | * убрал скейл svg 4 | * поддержка других вариаций инлайна формул 5 | # v1.7.2 6 | 7 | * Исправление не загружающихся комментариев 8 | * Кеширование всех аватаров 9 | 10 | # v1.7.1 11 | 12 | * Исправления редизайна 13 | 14 | # v1.7.0 15 | 16 | * Редизайн страницы статьи 17 | * Поддержка latex формул 18 | 19 | # ... 20 | 21 | # v1.0.4 22 | 23 | * реализовано кеширование статей, но без: 24 | * * возможности удалить статью из кеша 25 | * * кеширования изображений в статье 26 | * мелкие юай фиксы 27 | 28 | # v1.0.3 29 | 30 | * Comments button 31 | * Theme switching 32 | * Fix bugs 33 | * More html elements 34 | 35 | # v1.0.2 36 | 37 | * Обработка спойлеров 38 | * Обработка неупорядоченных списков 39 | * Обработка отсутствия комментариев под статьей 40 | * Блок кода теперь на всю ширину -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Nikita Avdosev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # habr app 2 | 3 | Habr application. 4 | 5 | Main features: 6 | * Cache articles 7 | * Filter articles 8 | * Visual customizations 9 | * Desktop/Web/Tablet support 10 | 11 | # Visual customization 12 | 13 | * light and dark themes of the app \ 14 | select of a specific mode: 15 | * constant (dark or light) 16 | * system theme 17 | * from time to time 18 | * font size 19 | * text alignment 20 | * line spacing 21 | * code styles \ 22 | select of a specific mode: 23 | * dark mode coloring 24 | * light mode coloring 25 | * app used theme 26 | 27 | ## Screenshots 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | ## Build 42 | 43 | ``` 44 | flutter pub get 45 | flutter pub run build_runner build 46 | flutter build apk 47 | ``` -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at 17 | # https://dart-lang.github.io/linter/lints/index.html. 18 | # 19 | # Instead of disabling a lint rule for the entire project in the 20 | # section below, it can also be suppressed for a single line of code 21 | # or a specific dart file by using the `// ignore: name_of_lint` and 22 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 23 | # producing the lint. 24 | rules: 25 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 26 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 27 | 28 | # Additional information about this file can be found at 29 | # https://dart.dev/guides/language/analysis-options 30 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | 9 | # Remember to never publicly share your keystore. 10 | # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app 11 | key.properties 12 | **/*.keystore 13 | **/*.jks 14 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | def localProperties = new Properties() 2 | def localPropertiesFile = rootProject.file('local.properties') 3 | if (localPropertiesFile.exists()) { 4 | localPropertiesFile.withReader('UTF-8') { reader -> 5 | localProperties.load(reader) 6 | } 7 | } 8 | 9 | def flutterRoot = localProperties.getProperty('flutter.sdk') 10 | if (flutterRoot == null) { 11 | throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") 12 | } 13 | 14 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 15 | if (flutterVersionCode == null) { 16 | flutterVersionCode = '1' 17 | } 18 | 19 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 20 | if (flutterVersionName == null) { 21 | flutterVersionName = '1.0' 22 | } 23 | 24 | apply plugin: 'com.android.application' 25 | apply plugin: 'kotlin-android' 26 | apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" 27 | 28 | android { 29 | compileSdkVersion flutter.compileSdkVersion 30 | 31 | compileOptions { 32 | sourceCompatibility JavaVersion.VERSION_1_8 33 | targetCompatibility JavaVersion.VERSION_1_8 34 | } 35 | 36 | kotlinOptions { 37 | jvmTarget = '1.8' 38 | } 39 | 40 | sourceSets { 41 | main.java.srcDirs += 'src/main/kotlin' 42 | } 43 | 44 | defaultConfig { 45 | applicationId "habrapp.habr_app" 46 | minSdkVersion flutter.minSdkVersion 47 | targetSdkVersion flutter.targetSdkVersion 48 | versionCode flutterVersionCode.toInteger() 49 | versionName flutterVersionName 50 | } 51 | 52 | buildTypes { 53 | release { 54 | // TODO: Add your own signing config for the release build. 55 | // Signing with the debug keys for now, so `flutter run --release` works. 56 | signingConfig signingConfigs.debug 57 | } 58 | } 59 | } 60 | 61 | flutter { 62 | source '../..' 63 | } 64 | 65 | dependencies { 66 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 67 | } 68 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 8 | 16 | 20 | 24 | 25 | 26 | 27 | 28 | 29 | 31 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/habrapp/habr_app/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package habrapp.habr_app 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() { 6 | } 7 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext.kotlin_version = '1.6.10' 3 | repositories { 4 | google() 5 | mavenCentral() 6 | } 7 | 8 | dependencies { 9 | classpath 'com.android.tools.build:gradle:4.1.0' 10 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 11 | } 12 | } 13 | 14 | allprojects { 15 | repositories { 16 | google() 17 | mavenCentral() 18 | } 19 | } 20 | 21 | rootProject.buildDir = '../build' 22 | subprojects { 23 | project.buildDir = "${rootProject.buildDir}/${project.name}" 24 | } 25 | subprojects { 26 | project.evaluationDependsOn(':app') 27 | } 28 | 29 | task clean(type: Delete) { 30 | delete rootProject.buildDir 31 | } 32 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Jun 23 08:50:38 CEST 2017 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip 7 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | 3 | def localPropertiesFile = new File(rootProject.projectDir, "local.properties") 4 | def properties = new Properties() 5 | 6 | assert localPropertiesFile.exists() 7 | localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } 8 | 9 | def flutterSdkPath = properties.getProperty("flutter.sdk") 10 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 11 | apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" 12 | -------------------------------------------------------------------------------- /assets/icon/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/assets/icon/icon.png -------------------------------------------------------------------------------- /assets/images/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/assets/images/background.png -------------------------------------------------------------------------------- /assets/images/default_avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /assets/images/fatal_error.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/assets/images/fatal_error.webp -------------------------------------------------------------------------------- /assets/images/lot_of_entropy.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/assets/images/lot_of_entropy.webp -------------------------------------------------------------------------------- /assets/images/resolved.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/assets/images/resolved.webp -------------------------------------------------------------------------------- /assets/images/ufo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/assets/images/ufo.png -------------------------------------------------------------------------------- /ios/.gitignore: -------------------------------------------------------------------------------- 1 | *.mode1v3 2 | *.mode2v3 3 | *.moved-aside 4 | *.pbxuser 5 | *.perspectivev3 6 | **/*sync/ 7 | .sconsign.dblite 8 | .tags* 9 | **/.vagrant/ 10 | **/DerivedData/ 11 | Icon? 12 | **/Pods/ 13 | **/.symlinks/ 14 | profile 15 | xcuserdata 16 | **/.generated/ 17 | Flutter/App.framework 18 | Flutter/Flutter.framework 19 | Flutter/Flutter.podspec 20 | Flutter/Generated.xcconfig 21 | Flutter/app.flx 22 | Flutter/app.zip 23 | Flutter/flutter_assets/ 24 | Flutter/flutter_export_environment.sh 25 | ServiceDefinitions.json 26 | Runner/GeneratedPluginRegistrant.* 27 | 28 | # Exceptions to above rules. 29 | !default.mode1v3 30 | !default.mode2v3 31 | !default.pbxuser 32 | !default.perspectivev3 33 | -------------------------------------------------------------------------------- /ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 8.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Generated.xcconfig" 2 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Generated.xcconfig" 2 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | 54 | 56 | 62 | 63 | 64 | 65 | 66 | 67 | 73 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Flutter 3 | 4 | @UIApplicationMain 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | GeneratedPluginRegistrant.register(with: self) 11 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-App-20x20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-App-20x20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-App-29x29@1x.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-App-29x29@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "29x29", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-App-29x29@3x.png", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-App-40x40@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "40x40", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-App-40x40@3x.png", 43 | "scale" : "3x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-App-60x60@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "60x60", 53 | "idiom" : "iphone", 54 | "filename" : "Icon-App-60x60@3x.png", 55 | "scale" : "3x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-App-20x20@1x.png", 61 | "scale" : "1x" 62 | }, 63 | { 64 | "size" : "20x20", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-App-20x20@2x.png", 67 | "scale" : "2x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-App-29x29@1x.png", 73 | "scale" : "1x" 74 | }, 75 | { 76 | "size" : "29x29", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-App-29x29@2x.png", 79 | "scale" : "2x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-App-40x40@1x.png", 85 | "scale" : "1x" 86 | }, 87 | { 88 | "size" : "40x40", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-App-40x40@2x.png", 91 | "scale" : "2x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-App-76x76@1x.png", 97 | "scale" : "1x" 98 | }, 99 | { 100 | "size" : "76x76", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-App-76x76@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "83.5x83.5", 107 | "idiom" : "ipad", 108 | "filename" : "Icon-App-83.5x83.5@2x.png", 109 | "scale" : "2x" 110 | }, 111 | { 112 | "size" : "1024x1024", 113 | "idiom" : "ios-marketing", 114 | "filename" : "Icon-App-1024x1024@1x.png", 115 | "scale" : "1x" 116 | } 117 | ], 118 | "info" : { 119 | "version" : 1, 120 | "author" : "xcode" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LaunchImage.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "LaunchImage@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "LaunchImage@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | 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. -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | habr_app 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | $(FLUTTER_BUILD_NAME) 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | $(FLUTTER_BUILD_NUMBER) 23 | LSRequiresIPhoneOS 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIMainStoryboardFile 28 | Main 29 | UISupportedInterfaceOrientations 30 | 31 | UIInterfaceOrientationPortrait 32 | UIInterfaceOrientationLandscapeLeft 33 | UIInterfaceOrientationLandscapeRight 34 | 35 | UISupportedInterfaceOrientations~ipad 36 | 37 | UIInterfaceOrientationPortrait 38 | UIInterfaceOrientationPortraitUpsideDown 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | UIViewControllerBasedStatusBarAppearance 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" 2 | -------------------------------------------------------------------------------- /l10n.yaml: -------------------------------------------------------------------------------- 1 | arb-dir: lib/l10n 2 | template-arb-file: app_en.arb 3 | output-localization-file: app_localizations.dart -------------------------------------------------------------------------------- /lib/app_error.dart: -------------------------------------------------------------------------------- 1 | enum ErrorType { 2 | BadRequest, 3 | BadResponse, 4 | ServerError, 5 | NotFound, 6 | NotCached, 7 | } 8 | 9 | class AppError { 10 | final ErrorType errCode; 11 | final String? message; 12 | 13 | const AppError({ 14 | required this.errCode, 15 | this.message, 16 | }); 17 | 18 | @override 19 | String toString() { 20 | if (message == null || message!.isEmpty) { 21 | return errCode.toString(); 22 | } 23 | return "$errCode: $message"; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/habr/api.dart: -------------------------------------------------------------------------------- 1 | import 'package:either_dart/either.dart'; 2 | import 'package:habr_app/app_error.dart'; 3 | import 'package:habr_app/models/author_info.dart'; 4 | import 'package:habr_app/models/models.dart'; 5 | import 'package:habr_app/utils/log.dart'; 6 | import 'package:habr_app/utils/http_request_helper.dart'; 7 | import 'package:http/http.dart' as http; 8 | import 'json_parsing.dart'; 9 | 10 | enum ArticleFeeds { dayTop, weekTop, yearTop, time, news } 11 | 12 | enum Flows { 13 | my, 14 | all, 15 | develop, 16 | admin, 17 | design, 18 | management, 19 | marketing, 20 | popular_science 21 | } 22 | 23 | enum Order { Date, Relevance, Rating } 24 | 25 | const orderToText = { 26 | Order.Date: 'date', 27 | Order.Rating: 'rating', 28 | Order.Relevance: 'relevance' 29 | }; 30 | 31 | class Habr { 32 | static const api_url_v2 = "https://m.habr.com/kek/v2"; 33 | 34 | Future> findPosts(String query, 35 | {int page = 1, Order order = Order.Relevance}) async { 36 | String ordString = orderToText[order]!; 37 | final url = 38 | "$api_url_v2/articles/?query=$query&order=$ordString&fl=ru&hl=ru&page=$page"; 39 | final response = await safe(http.get(Uri.parse(url))); 40 | return response 41 | .then(checkHttpStatus) 42 | .map(parseJson) 43 | .map((data) => parsePostPreviewsFromJson(data)); 44 | } 45 | 46 | Future> posts({ 47 | int page = 1, 48 | }) async { 49 | final url = 50 | "$api_url_v2/articles/?period=daily&sort=date&fl=ru&hl=ru&page=$page"; 51 | logInfo("Get articles by $url"); 52 | final response = await safe(http.get(Uri.parse(url))); 53 | return response 54 | .then(checkHttpStatus) 55 | .mapAsync(asyncParseJson) 56 | .mapRight((data) => parsePostPreviewsFromJson(data)); 57 | } 58 | 59 | Future> userPosts(String user, 60 | {int page = 1}) async { 61 | final url = 62 | "$api_url_v2/articles/?user=$user&sort=date&fl=ru&hl=ru&page=$page"; 63 | logInfo("Get articles by $url"); 64 | final response = await safe(http.get(Uri.parse(url))); 65 | return response 66 | .then(checkHttpStatus) 67 | .mapAsync(asyncParseJson) 68 | .mapRight((data) => parsePostPreviewsFromJson(data)); 69 | } 70 | 71 | Future> userInfo(String user) async { 72 | final url = "$api_url_v2/users/$user/card?fl=ru&hl=ru"; 73 | logInfo("Get user info by $url"); 74 | final response = await safe(http.get(Uri.parse(url))); 75 | return response 76 | .then(checkHttpStatus) 77 | .mapAsync(asyncParseJson) 78 | .mapRight((data) => parseAuthorInfoFromJson(data)); 79 | } 80 | 81 | Future> article(String id) async { 82 | final url = "$api_url_v2/articles/$id"; 83 | logInfo("Get article by $url"); 84 | final response = await safe(http.get(Uri.parse(url))); 85 | return response 86 | .then(checkHttpStatus) 87 | .mapAsync(asyncParseJson) 88 | .mapRight((data) => parsePostFromJson(data)); 89 | } 90 | 91 | Future> comments(String articleId) async { 92 | final url = "$api_url_v2/articles/$articleId/comments"; 93 | logInfo("Get comments by $url"); 94 | final response = await safe(http.get(Uri.parse(url))); 95 | return response 96 | .then(checkHttpStatus) 97 | .map(parseJson) 98 | .map((data) => parseCommentsFromJson(data)); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /lib/habr/habr.dart: -------------------------------------------------------------------------------- 1 | export 'api.dart'; -------------------------------------------------------------------------------- /lib/hive/adaptors.dart: -------------------------------------------------------------------------------- 1 | //id=0,1,6 2 | export 'settings_adaptors.dart'; 3 | 4 | //id=2 5 | export 'postpreview_filter_adapter.dart'; 6 | 7 | //id=3 8 | export 'author_avatar_info.dart'; 9 | 10 | //id=4 11 | export 'author.dart'; 12 | 13 | //id=5 14 | export 'post_preview.dart'; 15 | 16 | //id=7 17 | export 'post.dart'; 18 | 19 | //id=8 20 | export 'cached_image_info.dart'; 21 | -------------------------------------------------------------------------------- /lib/hive/author.dart: -------------------------------------------------------------------------------- 1 | import 'package:hive/hive.dart'; 2 | 3 | import 'package:habr_app/models/author.dart'; 4 | 5 | class AuthorAdapter extends TypeAdapter { 6 | @override 7 | final int typeId = 4; 8 | 9 | @override 10 | Author read(BinaryReader reader) { 11 | final id = reader.read(); 12 | final alias = reader.read(); 13 | final avatar = reader.read(); 14 | return Author(id: id, alias: alias, avatar: avatar); 15 | } 16 | 17 | @override 18 | void write(BinaryWriter writer, Author obj) { 19 | writer.write(obj.id); 20 | writer.write(obj.alias); 21 | writer.write(obj.avatar); 22 | } 23 | } -------------------------------------------------------------------------------- /lib/hive/author_avatar_info.dart: -------------------------------------------------------------------------------- 1 | import 'package:hive/hive.dart'; 2 | 3 | import 'package:habr_app/models/author_avatar_info.dart'; 4 | 5 | class AuthorAvatarInfoAdapter extends TypeAdapter { 6 | @override 7 | final int typeId = 3; 8 | 9 | @override 10 | AuthorAvatarInfo read(BinaryReader reader) { 11 | final url = reader.read(); 12 | final cached = reader.read(); 13 | return AuthorAvatarInfo(url: url, cached: cached); 14 | } 15 | 16 | @override 17 | void write(BinaryWriter writer, AuthorAvatarInfo obj) { 18 | writer.write(obj.url); 19 | writer.write(obj.cached); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /lib/hive/cached_image_info.dart: -------------------------------------------------------------------------------- 1 | import 'package:habr_app/models/cached_image_info.dart'; 2 | import 'package:hive/hive.dart'; 3 | 4 | class CachedImageInfoAdapter extends TypeAdapter { 5 | @override 6 | final int typeId = 8; 7 | 8 | @override 9 | CachedImageInfo read(BinaryReader reader) { 10 | final url = reader.read(); 11 | final path = reader.read(); 12 | return CachedImageInfo( 13 | url: url, 14 | path: path, 15 | ); 16 | } 17 | 18 | @override 19 | void write(BinaryWriter writer, CachedImageInfo obj) { 20 | writer.write(obj.url); 21 | writer.write(obj.path); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/hive/post.dart: -------------------------------------------------------------------------------- 1 | import 'package:hive/hive.dart'; 2 | 3 | import 'package:habr_app/models/cached_post.dart'; 4 | 5 | class CachedPostAdapter extends TypeAdapter { 6 | @override 7 | final int typeId = 7; 8 | 9 | @override 10 | CachedPost read(BinaryReader reader) { 11 | final id = reader.read(); 12 | final title = reader.read(); 13 | final body = reader.read(); 14 | final publishDate = reader.read(); 15 | final insertDate = reader.read(); 16 | final authorId = reader.read(); 17 | return CachedPost( 18 | id: id, 19 | title: title, 20 | body: body, 21 | publishDate: publishDate, 22 | insertDate: insertDate, 23 | authorId: authorId, 24 | ); 25 | } 26 | 27 | @override 28 | void write(BinaryWriter writer, CachedPost obj) { 29 | writer.write(obj.id); 30 | writer.write(obj.title); 31 | writer.write(obj.body); 32 | writer.write(obj.publishDate); 33 | writer.write(obj.insertDate); 34 | writer.write(obj.authorId); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/hive/post_preview.dart: -------------------------------------------------------------------------------- 1 | import 'package:hive/hive.dart'; 2 | 3 | import 'package:habr_app/models/post_preview.dart'; 4 | import 'package:habr_app/models/statistics.dart'; 5 | 6 | class PostPreviewAdapter extends TypeAdapter { 7 | @override 8 | final int typeId = 5; 9 | 10 | @override 11 | PostPreview read(BinaryReader reader) { 12 | final id = reader.read(); 13 | final title = reader.read(); 14 | final flows = reader.readList().cast(); 15 | final corporative = reader.read(); 16 | final publishDate = reader.read(); 17 | final author = reader.read(); 18 | return PostPreview( 19 | id: id, 20 | title: title, 21 | flows: flows, 22 | corporative: corporative, 23 | publishDate: publishDate, 24 | author: author, 25 | statistics: const Statistics.zero(), 26 | ); 27 | } 28 | 29 | @override 30 | void write(BinaryWriter writer, PostPreview obj) { 31 | writer.write(obj.id); 32 | writer.write(obj.title); 33 | writer.writeList(obj.flows); 34 | writer.write(obj.corporative); 35 | writer.write(obj.publishDate); 36 | writer.write(obj.author); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/hive/postpreview_filter_adapter.dart: -------------------------------------------------------------------------------- 1 | import 'package:habr_app/models/post_preview.dart'; 2 | import 'package:habr_app/utils/filters/article_preview_filters.dart'; 3 | import 'package:habr_app/utils/filters/filter.dart'; 4 | import 'package:hive/hive.dart'; 5 | 6 | class PostPreviewFilterAdapter extends TypeAdapter> { 7 | @override 8 | final int typeId = 2; 9 | 10 | @override 11 | Filter read(BinaryReader reader) { 12 | final obj = reader.readMap(); 13 | switch (obj['type']) { 14 | case 'nickname': 15 | return NicknameAuthorFilter(obj['value']); 16 | case 'company_name': 17 | return CompanyNameFilter(obj['value']); 18 | default: 19 | return const NoneFilter(); 20 | } 21 | } 22 | 23 | @override 24 | void write(BinaryWriter writer, Filter obj) { 25 | if (obj is NicknameAuthorFilter) { 26 | writer.writeMap({ 27 | 'type': 'nickname', 28 | 'value': obj.nickname, 29 | }); 30 | } else if (obj is CompanyNameFilter) { 31 | writer.writeMap({ 32 | 'type': 'company_name', 33 | 'value': obj.companyName, 34 | }); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/hive/register_in_hive.dart: -------------------------------------------------------------------------------- 1 | import 'package:hive/hive.dart'; 2 | import 'adaptors.dart'; 3 | 4 | void registerAdapters() { 5 | Hive.registerAdapter(ThemeAdapter()); 6 | Hive.registerAdapter(TextAlignAdapter()); 7 | Hive.registerAdapter(PostPreviewFilterAdapter()); 8 | Hive.registerAdapter(PostPreviewAdapter()); 9 | Hive.registerAdapter(AuthorAdapter()); 10 | Hive.registerAdapter(AuthorAvatarInfoAdapter()); 11 | Hive.registerAdapter(TimeOfDayAdapter()); 12 | Hive.registerAdapter(CachedPostAdapter()); 13 | Hive.registerAdapter(CachedImageInfoAdapter()); 14 | } 15 | -------------------------------------------------------------------------------- /lib/hive/settings_adaptors.dart: -------------------------------------------------------------------------------- 1 | import 'package:hive/hive.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | class ThemeAdapter extends TypeAdapter { 5 | @override 6 | final typeId = 0; 7 | 8 | @override 9 | ThemeMode read(BinaryReader reader) { 10 | return ThemeMode.values[int.parse(reader.read())]; 11 | } 12 | 13 | @override 14 | void write(BinaryWriter writer, ThemeMode obj) { 15 | writer.write(obj.index.toString()); 16 | } 17 | } 18 | 19 | class TextAlignAdapter extends TypeAdapter { 20 | @override 21 | final typeId = 1; 22 | 23 | @override 24 | TextAlign read(BinaryReader reader) { 25 | return TextAlign.values[int.parse(reader.read())]; 26 | } 27 | 28 | @override 29 | void write(BinaryWriter writer, TextAlign obj) { 30 | writer.write(obj.index.toString()); 31 | } 32 | } 33 | 34 | class TimeOfDayAdapter extends TypeAdapter { 35 | @override 36 | final typeId = 6; 37 | 38 | @override 39 | TimeOfDay read(BinaryReader reader) { 40 | final hour = reader.readInt(); 41 | final minute = reader.readInt(); 42 | return TimeOfDay(hour: hour, minute: minute); 43 | } 44 | 45 | @override 46 | void write(BinaryWriter writer, TimeOfDay obj) { 47 | writer.writeInt(obj.hour); 48 | writer.writeInt(obj.minute); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/l10n/app_en.arb: -------------------------------------------------------------------------------- 1 | { 2 | "loading": "Loading", 3 | "@loading": { 4 | "description": "Showed when resource loading" 5 | }, 6 | "removed": "Removed", 7 | "@removed": { 8 | "description": "Cached article was removed" 9 | }, 10 | "unarchive": "Unarchive", 11 | "@unarchive": { 12 | "description": "Unarchive article" 13 | }, 14 | "createFilterBy": "Create filter by", 15 | "@createFilterBy": { 16 | "description": "Create filter by" 17 | }, 18 | "authorNicknameFilter": "Author nickname filter", 19 | "@authorNicknameFilter": { 20 | "description": "Filter by author nickname" 21 | }, 22 | "companyNameFilter": "Company name filter", 23 | "@companyNameFilter": { 24 | "description": "Filter by company name" 25 | }, 26 | "articles": "Articles", 27 | "@articles": { 28 | "description": "Articles list" 29 | }, 30 | "create": "Create", 31 | "@create": { 32 | "description": "Create" 33 | }, 34 | "cancel": "Cancel", 35 | "@cancel": { 36 | "description": "Cancel" 37 | }, 38 | "search": "Search", 39 | "@search": { 40 | "description": "Search" 41 | }, 42 | "keywords": "Keywords", 43 | "@keywords": { 44 | "description": "Keywords" 45 | }, 46 | "sort": "Sorting", 47 | "@sort": { 48 | "description": "Sorting" 49 | }, 50 | "relevance": "Relevance", 51 | "@relevance": { 52 | "description": "Relevance" 53 | }, 54 | "date": "Date", 55 | "@date": { 56 | "description": "Date" 57 | }, 58 | "rating": "Rating", 59 | "@rating": { 60 | "description": "Rating" 61 | }, 62 | "left": "Left", 63 | "@left": { 64 | "description": "Left" 65 | }, 66 | "right": "right", 67 | "@right": { 68 | "description": "right" 69 | }, 70 | "fullWidth": "fullWidth", 71 | "@fullWidth": { 72 | "description": "fullWidth" 73 | }, 74 | "center": "center", 75 | "@center": { 76 | "description": "center" 77 | }, 78 | "customization": "customization", 79 | "@customization": { 80 | "description": "customization" 81 | }, 82 | "customizationCode": "customization code", 83 | "@customizationCode": { 84 | "description": "code customization" 85 | }, 86 | "lineSpacing": "lineSpacing", 87 | "@lineSpacing": { 88 | "description": "lineSpacing" 89 | }, 90 | "authorNicknameHint": "vds, for example", 91 | "@authorNicknameHint": { 92 | "description": "Author nickname hint" 93 | }, 94 | "authorNickname": "Author nickname", 95 | "@authorNickname": { 96 | "description": "Author nickname" 97 | }, 98 | "textAlign": "Text align", 99 | "@textAlign": { 100 | "description": "Text align" 101 | }, 102 | "notLoaded": "Not loaded", 103 | "@notLoaded": { 104 | "description": "Showed when resource not loaded" 105 | }, 106 | "filters": "Filters", 107 | "@filters": {}, 108 | "cachedArticles": "Cached articles", 109 | "@cachedArticles": {}, 110 | "settings": "Settings", 111 | "@settings": {}, 112 | "emptyContent": "it's empty here", 113 | "@emptyContent": {}, 114 | "reload": "reload", 115 | "@reload": {}, 116 | "lossInternet": "hey, I'm loss internet", 117 | "@lossInternet": {}, 118 | "bannedComment": "", 119 | "@bannedComment": {}, 120 | "appError": "wild funnel of entropy", 121 | "@appError": {}, 122 | "comments": "Comments", 123 | "@comments": {}, 124 | "systemTheme": "System theme", 125 | "@systemTheme": {}, 126 | "darkTheme": "Dark theme", 127 | "@darkTheme": {}, 128 | "customizations": "Customizations", 129 | "@customizations": {}, 130 | "fontSize": "Font size", 131 | "@fontSize": {}, 132 | "bookmarks": "Bookmarks", 133 | "@bookmarks": {}, 134 | "user": "User", 135 | "@user": {} 136 | } -------------------------------------------------------------------------------- /lib/l10n/app_ru.arb: -------------------------------------------------------------------------------- 1 | { 2 | "articles": "Статьи", 3 | "loading": "Загрузка", 4 | "notLoaded": "Не загруженно", 5 | "filters": "Фильтры", 6 | "removed": "удалено", 7 | "unarchive": "разархивировать", 8 | "createFilterBy": "Создать фильтр по", 9 | "authorNicknameFilter": "Никнейму автора", 10 | "companyNameFilter": "Имени компании", 11 | "create": "Создать", 12 | "cancel": "Отменить", 13 | "authorNicknameHint": "например, vds", 14 | "search": "Поиск", 15 | "keywords": "Ключевые слова", 16 | "sort": "Сортировка", 17 | "relevance": "Релевантность", 18 | "date": "Дата", 19 | "rating": "Рейтинг", 20 | "left": "Слева", 21 | "right": "Справа", 22 | "fullWidth": "По ширине", 23 | "center": "По центру", 24 | "lineSpacing": "Межстрочный интервал", 25 | "textAlign": "Выравнивание текста", 26 | "customization": "Кастомизация", 27 | "authorNickname": "Никнейм автора", 28 | "cachedArticles": "Кешированные статьи", 29 | "settings": "Настройки", 30 | "emptyContent": "Судя по всему контента тут не предвидится.", 31 | "reload": "Еще раз", 32 | "lossInternet": "НЛО прилетело и потеряло соединение", 33 | "bannedComment": "Нло прилетело и опубликовало эту надпись", 34 | "appError": "Кругом нестабильность", 35 | "comments": "Комментарии", 36 | "systemTheme": "Системная тема", 37 | "darkTheme": "Темная тема", 38 | "customizations": "Кастомизация", 39 | "customizationCode": "Отображение кода", 40 | "fontSize": "Размер текста", 41 | "bookmarks": "Закладки", 42 | "user": "Пользователь" 43 | } -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 3 | import 'package:flutter_localizations/flutter_localizations.dart'; 4 | import 'package:habr_app/stores/habr_storage.dart'; 5 | import 'package:habr_app/stores/app_settings.dart'; 6 | import 'package:habr_app/utils/workers/hasher.dart'; 7 | import 'package:habr_app/utils/workers/image_loader.dart'; 8 | import 'package:provider/provider.dart'; 9 | 10 | import 'package:habr_app/utils/hive_helper.dart'; 11 | import 'package:habr_app/styles/themes/themes.dart'; 12 | import 'package:habr_app/routing/routing.dart'; 13 | 14 | import 'habr/api.dart'; 15 | import 'stores/image_storage.dart'; 16 | 17 | void main() async { 18 | await initializeHive(); 19 | runApp(MyApp()); 20 | } 21 | 22 | class MyApp extends StatelessWidget { 23 | // This widget is the root of your application. 24 | @override 25 | Widget build(BuildContext context) { 26 | return MultiProvider( 27 | providers: [ 28 | Provider(create: (_) => Habr()), 29 | Provider( 30 | create: (_) => ImageLocalStorage( 31 | hashComputer: MD5Hash(), imageLoader: ImageHttpLoader())), 32 | Provider(create: (context) { 33 | final api = Provider.of(context, listen: false); 34 | final imageStore = 35 | Provider.of(context, listen: false); 36 | return HabrStorage(api: api, imgStore: imageStore); 37 | }), 38 | ChangeNotifierProvider(create: (context) => AppSettings()), 39 | ], 40 | builder: (context, widget) { 41 | final settings = context.watch(); 42 | final fontSize = settings.fontSize; 43 | final lineSpacing = settings.lineSpacing; 44 | return MaterialApp( 45 | title: 'Habr', 46 | theme: 47 | buildLightTheme(mainFontSize: fontSize, lineSpacing: lineSpacing), 48 | darkTheme: 49 | buildDarkTheme(mainFontSize: fontSize, lineSpacing: lineSpacing), 50 | themeMode: settings.themeMode, 51 | supportedLocales: const [ 52 | Locale('ru', ''), 53 | Locale('en', ''), 54 | ], 55 | localizationsDelegates: const [ 56 | AppLocalizations.delegate, 57 | GlobalMaterialLocalizations.delegate, 58 | GlobalWidgetsLocalizations.delegate, 59 | ], 60 | routes: routes, 61 | initialRoute: "articles", 62 | ); 63 | }, 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /lib/models/author.dart: -------------------------------------------------------------------------------- 1 | import 'author_avatar_info.dart'; 2 | 3 | class Author { 4 | final String id; 5 | final String alias; 6 | final String? fullName; 7 | final String? speciality; 8 | final AuthorAvatarInfo avatar; 9 | 10 | const Author({ 11 | required this.id, 12 | required this.alias, 13 | required this.avatar, 14 | this.speciality, 15 | this.fullName, 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /lib/models/author_avatar_info.dart: -------------------------------------------------------------------------------- 1 | class AuthorAvatarInfo { 2 | final String? url; 3 | final bool cached; 4 | const AuthorAvatarInfo({this.url, this.cached = false}); 5 | 6 | bool get isDefault => url == null || url!.isEmpty; 7 | bool get isNotDefault => !isDefault; 8 | } -------------------------------------------------------------------------------- /lib/models/author_info.dart: -------------------------------------------------------------------------------- 1 | import 'package:habr_app/models/author_avatar_info.dart'; 2 | 3 | class AuthorInfo { 4 | final String alias; 5 | final String? fullName; 6 | final String? speciality; 7 | final String? about; 8 | final AuthorAvatarInfo? avatar; 9 | 10 | final int postCount; 11 | final int? followCount; 12 | final int? folowersCount; 13 | 14 | final DateTime lastActivityTime; 15 | final DateTime registerTime; 16 | 17 | final int? rating; 18 | 19 | final num? karma; 20 | 21 | const AuthorInfo({ 22 | required this.alias, 23 | this.fullName, 24 | this.speciality, 25 | this.about, 26 | this.avatar, 27 | required this.postCount, 28 | this.followCount, 29 | this.folowersCount, 30 | required this.lastActivityTime, 31 | required this.registerTime, 32 | this.rating, 33 | this.karma, 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /lib/models/cached_image_info.dart: -------------------------------------------------------------------------------- 1 | class CachedImageInfo { 2 | CachedImageInfo({required this.url, required this.path}); 3 | 4 | String url; 5 | String path; 6 | } 7 | -------------------------------------------------------------------------------- /lib/models/cached_post.dart: -------------------------------------------------------------------------------- 1 | import 'post.dart'; 2 | 3 | class CachedPost { 4 | final String id; 5 | final String title; 6 | final String body; 7 | final DateTime publishDate; 8 | final DateTime insertDate; 9 | final String authorId; 10 | 11 | const CachedPost({ 12 | required this.id, 13 | required this.title, 14 | required this.body, 15 | required this.publishDate, 16 | required this.authorId, 17 | required this.insertDate, 18 | }); 19 | 20 | CachedPost.fromPost( 21 | Post post, { 22 | required this.insertDate, 23 | }) : id = post.id, 24 | title = post.title, 25 | body = post.body, 26 | publishDate = post.publishDate, 27 | authorId = post.author.id; 28 | } 29 | -------------------------------------------------------------------------------- /lib/models/comment.dart: -------------------------------------------------------------------------------- 1 | import 'author.dart'; 2 | 3 | class Comment { 4 | final int id; 5 | final int? parentId; 6 | final int? level; 7 | final bool banned; 8 | final DateTime? timePublished; 9 | final DateTime? timeChanged; 10 | final List? children; 11 | final Author? author; 12 | final String? message; 13 | 14 | bool get notBanned => !banned; 15 | 16 | Comment({ 17 | required this.id, 18 | this.parentId, 19 | this.level, 20 | this.timePublished, 21 | this.timeChanged, 22 | this.children, 23 | this.author, 24 | this.message, 25 | required this.banned, 26 | }); 27 | 28 | Comment copyWith({ 29 | int? id, 30 | int? parentId, 31 | int? level, 32 | bool? banned, 33 | DateTime? timePublished, 34 | DateTime? timeChanged, 35 | List? children, 36 | Author? author, 37 | String? message, 38 | }) { 39 | return Comment( 40 | id: id ?? this.id, 41 | parentId: parentId ?? this.parentId, 42 | level: level ?? this.level, 43 | timePublished: timePublished ?? this.timePublished, 44 | timeChanged: timeChanged ?? this.timeChanged, 45 | children: children ?? this.children, 46 | author: author ?? this.author, 47 | message: message ?? this.message, 48 | banned: banned ?? this.banned, 49 | ); 50 | } 51 | } 52 | 53 | class Comments { 54 | final Map comments; 55 | final List threads; 56 | 57 | Comments({ 58 | required this.comments, 59 | required this.threads, 60 | }); 61 | } 62 | -------------------------------------------------------------------------------- /lib/models/models.dart: -------------------------------------------------------------------------------- 1 | export 'author_avatar_info.dart'; 2 | export 'author.dart'; 3 | export 'comment.dart'; 4 | export 'post.dart'; 5 | export 'post_preview.dart'; 6 | export 'statistics.dart'; -------------------------------------------------------------------------------- /lib/models/post.dart: -------------------------------------------------------------------------------- 1 | import 'author.dart'; 2 | import 'package:habr_app/utils/html_to_json/element_builders.dart'; 3 | 4 | abstract class PostInfo { 5 | String get id; 6 | String get title; 7 | DateTime get publishDate; 8 | Author get author; 9 | } 10 | 11 | class Post implements PostInfo { 12 | final String id; 13 | final String title; 14 | final String body; 15 | final DateTime publishDate; 16 | final Author author; 17 | 18 | const Post({ 19 | required this.id, 20 | required this.title, 21 | required this.body, 22 | required this.publishDate, 23 | required this.author, 24 | }); 25 | } 26 | 27 | class ParsedPost implements PostInfo { 28 | final String id; 29 | final String title; 30 | final Node parsedBody; 31 | final DateTime publishDate; 32 | final Author author; 33 | 34 | const ParsedPost({ 35 | required this.id, 36 | required this.title, 37 | required this.parsedBody, 38 | required this.publishDate, 39 | required this.author, 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /lib/models/post_preview.dart: -------------------------------------------------------------------------------- 1 | import 'author.dart'; 2 | import 'statistics.dart'; 3 | 4 | class PostPreview { 5 | final String id; 6 | final String title; 7 | final List? hubs; 8 | final List flows; 9 | final String? htmlPreview; 10 | final DateTime publishDate; 11 | final Author author; 12 | final Statistics statistics; 13 | final bool corporative; 14 | 15 | const PostPreview({ 16 | required this.id, 17 | required this.title, 18 | this.hubs, 19 | required this.flows, 20 | this.htmlPreview, 21 | required this.publishDate, 22 | required this.author, 23 | required this.statistics, 24 | this.corporative = false, 25 | }); 26 | } 27 | 28 | class PostPreviews { 29 | final int maxCountPages; 30 | final List previews; 31 | 32 | const PostPreviews({ 33 | required this.previews, 34 | required this.maxCountPages, 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /lib/models/statistics.dart: -------------------------------------------------------------------------------- 1 | class Statistics { 2 | final int commentsCount; 3 | final int favoritesCount; 4 | final int readingCount; 5 | final int score; 6 | final int votesCount; 7 | 8 | const Statistics({ 9 | required this.commentsCount, 10 | required this.favoritesCount, 11 | required this.readingCount, 12 | required this.score, 13 | required this.votesCount, 14 | }); 15 | 16 | Statistics.fromJson(Map json) 17 | : commentsCount = json['commentsCount'], 18 | favoritesCount = json['favoritesCount'], 19 | readingCount = json['readingCount'], 20 | score = json['score'], 21 | votesCount = json['votesCount']; 22 | 23 | const Statistics.zero() 24 | : commentsCount = 0, 25 | favoritesCount = 0, 26 | readingCount = 0, 27 | score = 0, 28 | votesCount = 0; 29 | } 30 | -------------------------------------------------------------------------------- /lib/pages/article/components/post_store.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | 3 | import 'package:habr_app/app_error.dart'; 4 | import 'package:habr_app/models/post.dart'; 5 | import 'package:habr_app/stores/habr_storage.dart'; 6 | import 'package:habr_app/stores/loading_state.dart'; 7 | import 'package:habr_app/utils/html_to_json.dart'; 8 | import 'package:either_dart/either.dart'; 9 | 10 | class PostStorage with ChangeNotifier { 11 | final HabrStorage storage; 12 | 13 | LoadingState? loadingState; 14 | 15 | String _id; // Article id 16 | ParsedPost? post; 17 | late AppError lastError; 18 | 19 | PostStorage(this._id, {required this.storage}); 20 | 21 | set articleId(String val) { 22 | _id = val; 23 | reload(); 24 | } 25 | 26 | String get articleId => _id; 27 | 28 | void reload() { 29 | loadingState = LoadingState.inProgress; 30 | storage 31 | .article(_id) 32 | .mapRightAsync( 33 | (right) async => ParsedPost( 34 | id: right.id, 35 | title: right.title, 36 | author: right.author, 37 | publishDate: right.publishDate, 38 | parsedBody: await compute(htmlAsParsedJson, right.body), 39 | ), 40 | ) 41 | .either((left) { 42 | loadingState = LoadingState.isCorrupted; 43 | lastError = left; 44 | }, (right) { 45 | loadingState = LoadingState.isFinally; 46 | post = right; 47 | }).then((value) => notifyListeners()); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lib/pages/articles_list.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | import 'package:habr_app/stores/filters_store.dart'; 4 | import 'package:habr_app/utils/filters/article_preview_filters.dart'; 5 | import 'package:habr_app/utils/page_loaders/preview_loader.dart'; 6 | import 'package:habr_app/routing/routing.dart'; 7 | import 'package:habr_app/stores/articles_store.dart'; 8 | import 'package:habr_app/widgets/widgets.dart'; 9 | import 'package:habr_app/stores/habr_storage.dart'; 10 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 11 | 12 | class ArticlesList extends StatelessWidget { 13 | ArticlesList({Key? key}) : super(key: key); 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | final width = MediaQuery.of(context).size.width; 18 | final drawerIsPartOfBody = width > 1000; 19 | return Scaffold( 20 | drawer: drawerIsPartOfBody ? null : MainMenu(), 21 | appBar: AppBar( 22 | title: Text("Habr"), 23 | actions: [ 24 | IconButton( 25 | tooltip: AppLocalizations.of(context)!.search, 26 | icon: const Icon(Icons.search), 27 | onPressed: () => openSearch(context)) 28 | ], 29 | ), 30 | body: ChangeNotifierProvider( 31 | create: (context) { 32 | final habrStorage = Provider.of(context, listen: false); 33 | return ArticlesStorage( 34 | FlowPreviewLoader(flow: PostsFlow.dayTop, storage: habrStorage), 35 | filter: AnyFilterCombine(FiltersStorage().getAll().toList())); 36 | }, 37 | child: Row( 38 | children: [ 39 | if (drawerIsPartOfBody) DesktopHomeMenu(), 40 | Expanded(child: ArticlesListBody()), 41 | ], 42 | ), 43 | ), 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lib/pages/bookmarks.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:habr_app/stores/habr_storage.dart'; 3 | import 'package:habr_app/models/models.dart'; 4 | import 'package:habr_app/routing/routing.dart'; 5 | import 'package:habr_app/stores/bookmarks_store.dart'; 6 | import 'package:habr_app/widgets/widgets.dart'; 7 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 8 | import 'package:hive/hive.dart'; 9 | import 'package:provider/provider.dart'; 10 | 11 | class BookmarksArticlesList extends StatefulWidget { 12 | BookmarksArticlesList({Key? key}) : super(key: key); 13 | 14 | @override 15 | createState() => _BookmarksArticlesListState(); 16 | } 17 | 18 | class _BookmarksArticlesListState extends State { 19 | final store = BookmarksStore(); 20 | 21 | Widget buildItem( 22 | BuildContext context, PostPreview preview, HabrStorage habrStorage) { 23 | return SlidableArchiveDelete( 24 | child: ArticlePreview( 25 | key: Key("preview_" + preview.id), 26 | postPreview: preview, 27 | onPressed: (articleId) => openArticle(context, articleId), 28 | ), 29 | onDelete: () { 30 | store.removeBookmark(preview.id); 31 | }, 32 | onArchive: () { 33 | habrStorage.addArticleInCache(preview.id); 34 | }, 35 | ); 36 | } 37 | 38 | buildBody(BuildContext bodyContext) { 39 | return ValueListenableBuilder>( 40 | valueListenable: store.bookmarks(), 41 | builder: (context, box, _) { 42 | final habrStorage = context.watch(); 43 | final bookmarks = box.values.toList(); 44 | if (bookmarks.isEmpty) return Center(child: EmptyContent()); 45 | return ListView.separated( 46 | itemBuilder: (context, i) => DefaultConstraints( 47 | child: buildItem(context, bookmarks[i], habrStorage)), 48 | separatorBuilder: (_, __) => const DefaultConstraints(child: Hr()), 49 | itemCount: bookmarks.length); 50 | }, 51 | ); 52 | } 53 | 54 | @override 55 | Widget build(BuildContext context) { 56 | return Scaffold( 57 | appBar: AppBar( 58 | title: Text(AppLocalizations.of(context)!.bookmarks), 59 | ), 60 | body: buildBody(context), 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/pages/comments/components/comments_store.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | 3 | import 'package:habr_app/app_error.dart'; 4 | import 'package:habr_app/models/comment.dart'; 5 | import 'package:habr_app/stores/habr_storage.dart'; 6 | import 'package:habr_app/stores/loading_state.dart'; 7 | import 'package:either_dart/either.dart'; 8 | import 'package:habr_app/utils/log.dart'; 9 | 10 | class CommentsStorage with ChangeNotifier { 11 | final HabrStorage storage; 12 | 13 | LoadingState? loadingState; 14 | 15 | String _id; // Article id 16 | late List comments; 17 | late AppError lastError; 18 | 19 | CommentsStorage(this._id, {required this.storage}); 20 | 21 | set articleId(String val) { 22 | _id = val; 23 | reload(); 24 | } 25 | 26 | String get articleId => _id; 27 | 28 | void reload() { 29 | loadingState = LoadingState.inProgress; 30 | storage.comments(_id).either( 31 | (left) { 32 | loadingState = LoadingState.isCorrupted; 33 | lastError = left; 34 | }, 35 | (right) { 36 | comments = flatCommentsTree(right).toList(); 37 | loadingState = LoadingState.isFinally; 38 | }, 39 | ).catchError((err) { 40 | logError(err); 41 | loadingState = LoadingState.isCorrupted; 42 | }).then((_) => notifyListeners()); 43 | } 44 | } 45 | 46 | Iterable flatCommentsTree(Comments comments) sync* { 47 | final stack = []; // так будет меньше аллокаций 48 | for (final thread in comments.threads) { 49 | final threadStart = comments.comments[thread]!; 50 | yield threadStart; 51 | stack.addAll(threadStart.children!.reversed); 52 | while (stack.isNotEmpty) { 53 | final currentId = stack.removeLast(); 54 | final comment = comments.comments[currentId]!; 55 | stack.addAll(comment.children!.reversed); 56 | yield comment; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lib/pages/image_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/services.dart'; 2 | import 'package:photo_view/photo_view.dart'; 3 | import 'package:flutter/material.dart'; 4 | 5 | class HeroPhotoViewRouteWrapper extends StatelessWidget { 6 | const HeroPhotoViewRouteWrapper({ 7 | this.imageProvider, 8 | this.backgroundDecoration, 9 | this.minScale, 10 | this.maxScale, 11 | required this.tag, 12 | }); 13 | 14 | final ImageProvider? imageProvider; 15 | final Decoration? backgroundDecoration; 16 | final String tag; 17 | final dynamic minScale; 18 | final dynamic maxScale; 19 | 20 | @override 21 | Widget build(BuildContext context) { 22 | final focusNode = FocusNode(); 23 | return RawKeyboardListener( 24 | focusNode: focusNode, 25 | autofocus: true, 26 | onKey: (event) { 27 | if (event.isKeyPressed(LogicalKeyboardKey.escape)) { 28 | Navigator.of(context).pop(); 29 | } 30 | }, 31 | child: Scaffold( 32 | backgroundColor: Colors.black, 33 | appBar: AppBar( 34 | backgroundColor: Colors.transparent, 35 | elevation: 0, 36 | ), 37 | body: Container( 38 | child: PhotoView( 39 | imageProvider: imageProvider, 40 | backgroundDecoration: backgroundDecoration as BoxDecoration?, 41 | minScale: minScale, 42 | maxScale: maxScale, 43 | heroAttributes: PhotoViewHeroAttributes(tag: tag), 44 | ), 45 | ), 46 | ), 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lib/pages/pages.dart: -------------------------------------------------------------------------------- 1 | export 'article/article.dart'; 2 | export 'articles_list.dart'; 3 | export 'comments/comments.dart'; 4 | export 'settings.dart'; 5 | export 'cached_articles.dart'; 6 | export 'search.dart'; 7 | export 'search_results.dart'; 8 | export 'filters.dart'; 9 | export 'bookmarks.dart'; 10 | export 'user.dart'; -------------------------------------------------------------------------------- /lib/pages/search_results.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:habr_app/utils/page_loaders/preview_loader.dart'; 3 | import 'package:habr_app/stores/articles_store.dart'; 4 | import 'package:habr_app/widgets/widgets.dart'; 5 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 6 | import 'package:provider/provider.dart'; 7 | 8 | class SearchResultListPage extends StatelessWidget { 9 | final SearchLoader loader; 10 | SearchResultListPage({Key? key, required this.loader}) : super(key: key); 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | return ChangeNotifierProvider( 15 | create: (_) => ArticlesStorage(loader), 16 | builder: (context, _) => Scaffold( 17 | appBar: AppBar( 18 | title: Text("${AppLocalizations.of(context)!.search} ${loader.query}"), 19 | ), 20 | body: ArticlesListBody(), 21 | ), 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/routing/routing.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:habr_app/utils/page_loaders/preview_loader.dart'; 3 | import 'package:habr_app/pages/pages.dart'; 4 | 5 | void openArticle(BuildContext context, String articleId) { 6 | Navigator.of(context).push(MaterialPageRoute( 7 | builder: (context) => ArticlePage(articleId: articleId))); 8 | } 9 | 10 | void openCommentsPage(BuildContext context, String articleId) { 11 | Navigator.of(context).push(MaterialPageRoute( 12 | builder: (context) => CommentsPage( 13 | articleId: articleId, 14 | ))); 15 | } 16 | 17 | void openSearch(BuildContext context) { 18 | Navigator.of(context) 19 | .push(MaterialPageRoute(builder: (context) => SearchPage())); 20 | } 21 | 22 | void openSearchResult(BuildContext context, SearchData info) { 23 | final loader = SearchLoader(info); 24 | Navigator.of(context).push(MaterialPageRoute( 25 | builder: (context) => SearchResultListPage(loader: loader))); 26 | } 27 | 28 | void openFilters(BuildContext context) { 29 | Navigator.pushNamed(context, "filters"); 30 | } 31 | 32 | void openUser(BuildContext context, String username) { 33 | Navigator.of(context).push( 34 | MaterialPageRoute(builder: (context) => UserPage(username: username))); 35 | } 36 | 37 | Map routes = { 38 | "settings": (BuildContext context) => SettingsPage(), 39 | "articles": (BuildContext context) => ArticlesList(), 40 | "articles/cached": (BuildContext context) => CachedArticlesList(), 41 | "articles/bookmarks": (BuildContext context) => BookmarksArticlesList(), 42 | "filters": (BuildContext context) => FiltersPage(), 43 | }; 44 | -------------------------------------------------------------------------------- /lib/stores/app_settings.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:hive/hive.dart'; 3 | 4 | class AppSettings extends ChangeNotifier { 5 | final data = Hive.box('settings'); 6 | 7 | AppSettings() { 8 | data.watch().listen((event) { 9 | notifyListeners(); 10 | }); 11 | if (timeThemeSwitcher) { 12 | changeThemeByTime(); 13 | } 14 | } 15 | 16 | TextAlign get articleTextAlign => 17 | data.get('TextAlignArticle', defaultValue: TextAlign.left); 18 | set articleTextAlign(TextAlign align) => data.put('TextAlignArticle', align); 19 | TextAlign get commentTextAlign => 20 | data.get('TextAlignComments', defaultValue: TextAlign.left); 21 | set commentTextAlign(TextAlign align) => data.put('TextAlignComments', align); 22 | 23 | ThemeMode get themeMode => 24 | data.get("ThemeMode", defaultValue: ThemeMode.system); 25 | 26 | set themeMode(ThemeMode mode) => data.put('ThemeMode', mode); 27 | 28 | ThemeMode get codeThemeMode => 29 | data.get('CodeThemeMode', defaultValue: ThemeMode.dark); 30 | set codeThemeMode(ThemeMode mode) => data.put('CodeThemeMode', mode); 31 | 32 | String get lightCodeTheme => 33 | data.get('LightCodeTheme', defaultValue: 'github'); 34 | set lightCodeTheme(String theme) => data.put('LightCodeTheme', theme); 35 | 36 | String get darkCodeTheme => 37 | data.get('DarkCodeTheme', defaultValue: 'androidstudio'); 38 | set darkCodeTheme(String theme) => data.put('DarkCodeTheme', theme); 39 | 40 | double get fontSize => data.get("FontSize", defaultValue: 16.0); 41 | set fontSize(double size) => data.put("FontSize", size); 42 | 43 | double get lineSpacing => data.get("LineSpacing", defaultValue: 1.35); 44 | set lineSpacing(double val) => data.put("LineSpacing", val); 45 | 46 | bool get timeThemeSwitcher => 47 | data.get('TimeThemeSwitcher', defaultValue: false); 48 | set timeThemeSwitcher(bool val) => data 49 | .put('TimeThemeSwitcher', val) 50 | .whenComplete(() => changeThemeByTime()); 51 | 52 | TimeOfDay get fromTimeThemeSwitch => data.get('FromTimeThemeSwitch', 53 | defaultValue: TimeOfDay(hour: 0, minute: 0)); 54 | set fromTimeThemeSwitch(TimeOfDay val) => data 55 | .put('FromTimeThemeSwitch', val) 56 | .whenComplete(() => changeThemeByTime()); 57 | 58 | TimeOfDay get toTimeThemeSwitch => data.get('ToTimeThemeSwitch', 59 | defaultValue: TimeOfDay(hour: 0, minute: 0)); 60 | set toTimeThemeSwitch(TimeOfDay val) => data 61 | .put('ToTimeThemeSwitch', val) 62 | .whenComplete(() => changeThemeByTime()); 63 | 64 | bool get showPreviewText => data.get('ShowPreviewText', defaultValue: false); 65 | set showPreviewText(bool val) => data.put('ShowPreviewText', val); 66 | 67 | static bool needSetLightTheme( 68 | TimeOfDay current, TimeOfDay from, TimeOfDay to) { 69 | final timeLower = (TimeOfDay d1, TimeOfDay d2) => 70 | d1.hour < d2.hour || (d1.hour == d2.hour && d1.minute <= d2.minute); 71 | final timeHigherOrEqual = (TimeOfDay d1, TimeOfDay d2) => 72 | d1.hour > d2.hour || (d1.hour == d2.hour && d1.minute >= d2.minute); 73 | if (timeHigherOrEqual(to, from)) { 74 | return timeHigherOrEqual(current, from) && timeLower(current, to); 75 | } else { 76 | return timeHigherOrEqual(current, from) || timeLower(current, to); 77 | } 78 | } 79 | 80 | void changeThemeByTime() { 81 | if (!timeThemeSwitcher) return; 82 | if (needSetLightTheme( 83 | TimeOfDay.now(), 84 | fromTimeThemeSwitch, 85 | toTimeThemeSwitch, 86 | )) { 87 | themeMode = ThemeMode.light; 88 | } else { 89 | themeMode = ThemeMode.dark; 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /lib/stores/articles_store.dart: -------------------------------------------------------------------------------- 1 | import 'package:either_dart/either.dart'; 2 | import 'package:flutter/foundation.dart'; 3 | 4 | import 'package:habr_app/utils/page_loaders/page_loader.dart'; 5 | import 'package:habr_app/utils/filters/article_preview_filters.dart'; 6 | import 'package:habr_app/app_error.dart'; 7 | import 'package:habr_app/models/post_preview.dart'; 8 | 9 | import 'loading_state.dart'; 10 | 11 | class ArticlesStorage with ChangeNotifier { 12 | final PageLoader> loader; 13 | final Filter filter; 14 | 15 | ArticlesStorage( 16 | this.loader, { 17 | this.filter = const NoneFilter(), 18 | }) { 19 | loadFirstPage(); 20 | } 21 | 22 | Future> loadPage(int page) { 23 | return loader.load(page); 24 | } 25 | 26 | bool filterPreview(PostPreview preview) { 27 | return filter.filter(preview); 28 | } 29 | 30 | LoadingState? firstLoading; 31 | bool loadItems = false; 32 | List previews = []; 33 | Set _postIds = {}; 34 | 35 | int? maxPages = -1; 36 | int pages = 0; 37 | AppError? lastError; 38 | 39 | Future reload() async { 40 | maxPages = -1; 41 | pages = 0; 42 | loadItems = false; 43 | loadFirstPage(); 44 | } 45 | 46 | bool isNeedToAdd(PostPreview preview) => 47 | !filterPreview(preview) && !_postIds.contains(preview.id); 48 | 49 | Future loadFirstPage() async { 50 | firstLoading = LoadingState.inProgress; 51 | notifyListeners(); 52 | final firstPage = await loadPage(1); 53 | firstLoading = firstPage.fold((left) { 54 | lastError = left; 55 | return LoadingState.isCorrupted; 56 | }, (right) { 57 | previews.addAll(right.previews.where(isNeedToAdd)); 58 | _postIds.addAll(right.previews.map((e) => e.id)); 59 | maxPages = right.maxCountPages; 60 | pages = 1; 61 | return LoadingState.isFinally; 62 | }); 63 | notifyListeners(); 64 | } 65 | 66 | Future loadPosts(int page) async { 67 | final postOrError = await loadPage(page); 68 | return postOrError.fold((err) { 69 | // TODO: informing user 70 | return PostPreviews(previews: [], maxCountPages: -1); 71 | }, (posts) => posts); 72 | } 73 | 74 | Future loadNextPage() async { 75 | final numberLoadingPage = pages + 1; 76 | loadItems = true; 77 | final nextPage = await loadPosts(numberLoadingPage); 78 | loadItems = false; 79 | previews.addAll(nextPage.previews.where(isNeedToAdd)); 80 | _postIds.addAll(nextPage.previews.map((e) => e.id)); 81 | pages = numberLoadingPage; 82 | } 83 | 84 | bool hasNextPages() { 85 | return pages < maxPages!; 86 | } 87 | 88 | void removePreview(String? id) { 89 | previews.removeWhere((element) => element.id == id); 90 | _postIds.remove(id); 91 | previews = List.from(previews); 92 | notifyListeners(); 93 | } 94 | 95 | void removeAllPreviews() { 96 | previews = []; 97 | _postIds = {}; 98 | notifyListeners(); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /lib/stores/avatar_color_store.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:habr_app/styles/colors/default_avatar.dart'; 5 | 6 | class AvatarColorStore { 7 | AvatarColorStore(); 8 | 9 | Color getColor(String authorAlias, Brightness brightness) { 10 | final colors = _getColorsByBrightness(brightness); 11 | return colors[authorAlias.hashCode.abs() % colors.length]; 12 | } 13 | 14 | // ignore: missing_return 15 | List _getColorsByBrightness(Brightness brightness) { 16 | switch (brightness) { 17 | case Brightness.dark: 18 | return DefaultAvatarColors.darkValues; 19 | case Brightness.light: 20 | return DefaultAvatarColors.lightValues; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/stores/bookmarks_store.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:habr_app/models/models.dart'; 3 | import 'package:hive/hive.dart'; 4 | import 'package:hive_flutter/hive_flutter.dart'; 5 | 6 | class BookmarksStore { 7 | final bookmarksBox = Hive.box('bookmarks'); 8 | final bookmarkedBox = Hive.box('bookmarked'); 9 | 10 | void addBookmark(String? postId, double position, PostPreview preview) { 11 | bookmarksBox.put(postId, position); 12 | bookmarkedBox.put(postId, preview); 13 | } 14 | 15 | void removeBookmark(String? postId) { 16 | print(postId); 17 | bookmarksBox.delete(postId); 18 | bookmarkedBox.delete(postId); 19 | } 20 | 21 | double? getPosition(String? postId) { 22 | return bookmarksBox.get(postId); 23 | } 24 | 25 | ValueListenable> bookmarks() { 26 | return bookmarkedBox.listenable(); 27 | } 28 | } -------------------------------------------------------------------------------- /lib/stores/filters_store.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:hive/hive.dart'; 3 | import 'package:hive_flutter/hive_flutter.dart'; 4 | 5 | import 'package:habr_app/models/post_preview.dart'; 6 | import 'package:habr_app/utils/filters/filter.dart'; 7 | 8 | class FiltersStorage { 9 | static const _boxName = 'filters'; 10 | 11 | void addFilter(Filter filter) { 12 | Hive.box>(_boxName).add(filter); 13 | } 14 | 15 | void removeFilterAt(int index) { 16 | Hive.box>(_boxName).deleteAt(index); 17 | } 18 | 19 | Iterable> getAll() { 20 | return Hive.box>(_boxName) 21 | .values 22 | .cast>(); 23 | } 24 | 25 | ValueListenable>> listenable() { 26 | return Hive.box>(_boxName).listenable(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/stores/image_storage.dart: -------------------------------------------------------------------------------- 1 | import 'package:either_dart/either.dart'; 2 | 3 | import 'package:habr_app/app_error.dart'; 4 | import 'package:habr_app/models/cached_image_info.dart'; 5 | import 'package:habr_app/utils/workers/hasher.dart'; 6 | import 'package:habr_app/utils/workers/image_loader.dart'; 7 | import 'package:habr_app/utils/log.dart'; 8 | 9 | import 'dart:io'; 10 | import 'package:path_provider/path_provider.dart'; 11 | import 'package:hive/hive.dart'; 12 | 13 | class ImageLocalStorage { 14 | final data = Hive.lazyBox('cached_images'); 15 | final HashComputer? hashComputer; 16 | final ImageLoader? imageLoader; 17 | String? _path; 18 | 19 | ImageLocalStorage({this.hashComputer, this.imageLoader}); 20 | 21 | Future get _localPath async { 22 | if (_path == null) { 23 | final directory = await getApplicationDocumentsDirectory(); 24 | _path = directory.path; 25 | } 26 | 27 | return _path; 28 | } 29 | 30 | Future _getImagePath(String url) async { 31 | final path = await _localPath; 32 | final filename = await _generateImageName(url); 33 | return '$path/$filename'; 34 | } 35 | 36 | Future _generateImageName(String url) async { 37 | final prefix1 = await hashComputer!.hash(url); 38 | final prefix2 = DateTime.now().millisecondsSinceEpoch.toRadixString(36); 39 | String postfix = url.split('.').last; 40 | postfix = postfix.length > 8 ? 'none' : postfix; 41 | return '${prefix1}_$prefix2.$postfix'; 42 | } 43 | 44 | /// Return AppError or path to saved file 45 | Future> saveImage(String? url) async { 46 | final maybeImage = await data.get(url); 47 | if (maybeImage != null) { 48 | return Right(maybeImage.path); 49 | } 50 | 51 | final filename = await _getImagePath(url!); 52 | logInfo('Saving image to $filename'); 53 | final loaded = await imageLoader!.loadImage(url, filename); 54 | 55 | if (!loaded) { 56 | return Left(AppError( 57 | errCode: ErrorType.NotCached, 58 | message: 'img not loaded', 59 | )); 60 | } 61 | 62 | if (!data.containsKey(url)) { 63 | await data.put(url, CachedImageInfo(url: url, path: filename)); 64 | } else { 65 | // пока изображение грузилось 66 | // оно загрузилось несколько раз 67 | // повторный файл не нужен, поэтому его можно удалить 68 | File(filename).delete(); 69 | return Left(AppError( 70 | errCode: ErrorType.NotCached, 71 | message: "img url exist in cache", 72 | )); 73 | } 74 | 75 | return Right(filename); 76 | } 77 | 78 | Future deleteImage(String url) async { 79 | // картинка не удалится из кеша тк путь будет не тот, 80 | // путь нужно брать из бд 81 | final optionalImage = await getImage(url); 82 | if (optionalImage.isLeft) return; 83 | final path = optionalImage.right!; 84 | await data.delete(url); 85 | final file = File(path); 86 | if (await file.exists()) { 87 | try { 88 | await file.delete(); 89 | logInfo("Изображение удалено path:$path"); 90 | } catch (err) { 91 | logError('Изображение не удалено path:$path'); 92 | } 93 | } 94 | } 95 | 96 | Future> getImage(String? url) async { 97 | final res = await data.get(url); 98 | return Either.condLazy( 99 | res != null, 100 | () => const AppError( 101 | errCode: ErrorType.NotFound, message: "Image in cache not found"), 102 | () => res!.path); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /lib/stores/loading_state.dart: -------------------------------------------------------------------------------- 1 | enum LoadingState { inProgress, isFinally, isCorrupted } 2 | 3 | -------------------------------------------------------------------------------- /lib/stores/user_info_store.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | 3 | import 'package:habr_app/app_error.dart'; 4 | import 'package:habr_app/models/author_info.dart'; 5 | import 'package:habr_app/stores/loading_state.dart'; 6 | import 'package:habr_app/habr/api.dart'; 7 | 8 | class UserInfoStorage with ChangeNotifier { 9 | UserInfoStorage(this.username); 10 | 11 | LoadingState? loadingState; 12 | String username; 13 | AuthorInfo? info; 14 | AppError? lastError; 15 | 16 | void loadInfo() async { 17 | loadingState = LoadingState.inProgress; 18 | notifyListeners(); 19 | final userInfo = await Habr().userInfo(username); 20 | userInfo.either((left) { 21 | loadingState = LoadingState.isCorrupted; 22 | lastError = left; 23 | }, (right) { 24 | loadingState = LoadingState.isFinally; 25 | info = right; 26 | }); 27 | notifyListeners(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/styles/colors/colors.dart: -------------------------------------------------------------------------------- 1 | export 'default_avatar.dart'; -------------------------------------------------------------------------------- /lib/styles/colors/default_avatar.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | class DefaultAvatarColors { 4 | 5 | static const lilac = Color.fromRGBO(0xa0, 0x8b, 0xb5, 1); 6 | 7 | static const pink = Color.fromRGBO(0xb5, 0x8b, 0xa9, 1); 8 | 9 | static const green = Color.fromRGBO(0x8b, 0xb5, 0x8c, 1); 10 | 11 | static const blue = Color.fromRGBO(0x8b, 0xaa, 0xb5, 1); 12 | 13 | static const grey = Color.fromRGBO(0x82, 0xa3, 0xb1, 1); 14 | 15 | static const red = Color.fromRGBO(0xc6, 0x7b, 0x7b, 1); 16 | 17 | static const orange = Color.fromRGBO(0xbd, 0xa4, 0x84, 1); 18 | 19 | static const lightValues = [lilac, pink, green, blue, grey]; 20 | static const darkValues = values; 21 | static const values = [lilac, pink, green, blue, grey, red, orange]; 22 | } -------------------------------------------------------------------------------- /lib/styles/themes/dark.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'text_theme.dart'; 3 | 4 | ThemeData buildDarkTheme({ 5 | double mainFontSize = 16, 6 | double lineSpacing = 1.35, 7 | }) { 8 | return ThemeData( 9 | textTheme: buildTextTheme(Colors.white70, mainFontSize, lineSpacing), 10 | accentColor: Colors.grey, 11 | appBarTheme: AppBarTheme( 12 | color: Colors.blueGrey[600], 13 | ), 14 | primarySwatch: Colors.blueGrey, 15 | primaryColor: Colors.blueGrey[600], 16 | scaffoldBackgroundColor: const Color.fromRGBO(57, 57, 57, 1), 17 | colorScheme: 18 | ColorScheme.dark(secondary: Colors.grey, primary: Colors.blueGrey), 19 | visualDensity: VisualDensity.adaptivePlatformDensity, 20 | snackBarTheme: SnackBarThemeData( 21 | backgroundColor: Colors.blueGrey[400], 22 | ), 23 | toggleableActiveColor: Colors.blueGrey[300], 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /lib/styles/themes/light.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'text_theme.dart'; 3 | 4 | ThemeData buildLightTheme({ 5 | double mainFontSize = 16, 6 | double lineSpacing = 1.35, 7 | }) { 8 | return ThemeData( 9 | textTheme: buildTextTheme(Colors.black87, mainFontSize, lineSpacing), 10 | primarySwatch: Colors.blueGrey, 11 | primaryColor: Colors.blueGrey, 12 | visualDensity: VisualDensity.adaptivePlatformDensity, 13 | snackBarTheme: SnackBarThemeData( 14 | backgroundColor: Colors.blueGrey 15 | ) 16 | ); 17 | } -------------------------------------------------------------------------------- /lib/styles/themes/text_theme.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | TextTheme buildTextTheme(Color color, double mainSize, double lineSpacing) { 4 | return TextTheme( 5 | headline1: TextStyle(color: color, fontSize: mainSize+6, fontWeight: FontWeight.w500), 6 | headline2: TextStyle(color: color, fontSize: mainSize+5, fontWeight: FontWeight.w500), 7 | headline3: TextStyle(color: color, fontSize: mainSize+4, fontWeight: FontWeight.w500), 8 | headline4: TextStyle(color: color, fontSize: mainSize+3, fontWeight: FontWeight.w500), 9 | headline5: TextStyle(color: color, fontSize: mainSize+2, fontWeight: FontWeight.w500), 10 | headline6: TextStyle(color: color, fontSize: mainSize+1, fontWeight: FontWeight.w500), 11 | bodyText2: TextStyle(color: color, fontSize: mainSize, height: lineSpacing), 12 | subtitle1: TextStyle(color: color, fontSize: mainSize), 13 | subtitle2: TextStyle(color: color, fontSize: mainSize-2), 14 | ); 15 | } -------------------------------------------------------------------------------- /lib/styles/themes/themes.dart: -------------------------------------------------------------------------------- 1 | export 'dark.dart'; 2 | export 'light.dart'; -------------------------------------------------------------------------------- /lib/utils/date_to_text.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:intl/intl.dart'; 3 | 4 | String dateToStr(DateTime date, Locale locale) { 5 | date = date.toLocal(); 6 | final now = DateTime.now(); 7 | if (locale.languageCode != 'ru') { 8 | String format = ''; 9 | format += 'MMMM dd'; 10 | if (now.year != date.year) 11 | format += ' yyyy'; 12 | final dayDate = DateFormat(format).format(date); 13 | final time = DateFormat.Hm().format(date); 14 | return "$dayDate at $time"; 15 | } 16 | 17 | String str; 18 | final currentDay = DateTime(now.year, now.month, now.day); 19 | final yesterday = currentDay.add(Duration(days: -1)); 20 | 21 | if (date.isAfter(currentDay)) { 22 | // сегодня 23 | str = 'сегодня'; 24 | } else if (date.isAfter(yesterday)) { 25 | // вчера 26 | str = 'вчера'; 27 | } else { 28 | const month = [ 29 | 'Января', 30 | 'Февраля', 31 | 'Марта', 32 | 'Апреля', 33 | 'Мая', 34 | 'Июня', 35 | 'Июля', 36 | 'Августа', 37 | 'Сентября', 38 | 'Октября', 39 | 'Ноября', 40 | 'Декабря', 41 | ]; 42 | str = "${date.day} ${month[date.month - 1]} ${date.year}"; 43 | } 44 | str += ' в ${date.hour}:${date.minute.toString().padLeft(2, '0')}'; 45 | return str; 46 | } -------------------------------------------------------------------------------- /lib/utils/filters/article_preview_filters.dart: -------------------------------------------------------------------------------- 1 | import 'filter.dart'; 2 | import 'package:habr_app/models/post_preview.dart'; 3 | 4 | export 'filter.dart'; 5 | 6 | class NicknameAuthorFilter extends Filter { 7 | final String nickname; 8 | 9 | const NicknameAuthorFilter(this.nickname); 10 | 11 | @override 12 | bool filter(PostPreview postPreview) { 13 | return nickname == postPreview.author.alias; 14 | } 15 | } 16 | 17 | class ScoreArticleFilter extends Filter { 18 | final int? min; 19 | final int? max; 20 | 21 | const ScoreArticleFilter({this.min, this.max}); 22 | 23 | @override 24 | bool filter(PostPreview obj) { 25 | return (min != null && obj.statistics.score < min!) || 26 | (max != null && obj.statistics.score > max!); 27 | } 28 | } 29 | 30 | class CompanyNameFilter extends Filter { 31 | final String companyName; 32 | 33 | const CompanyNameFilter(this.companyName); 34 | 35 | @override 36 | bool filter(PostPreview postPreview) { 37 | final l = companyName.toLowerCase(); 38 | return postPreview.hubs 39 | ?.any((element) => element.toLowerCase().contains(l)) ?? 40 | false; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /lib/utils/filters/filter.dart: -------------------------------------------------------------------------------- 1 | abstract class Filter { 2 | const Filter(); 3 | 4 | /// Check need to filter the object 5 | bool filter(T obj); 6 | } 7 | 8 | class AnyFilterCombine extends Filter { 9 | final List> filters; 10 | AnyFilterCombine(this.filters); 11 | 12 | bool filter(T obj) { 13 | return filters.any((filter) => filter.filter(obj)); 14 | } 15 | } 16 | 17 | class AllFilterCombine extends Filter { 18 | final List> filters; 19 | AllFilterCombine(this.filters); 20 | 21 | bool filter(T obj) { 22 | return filters.every((filter) => filter.filter(obj)); 23 | } 24 | } 25 | 26 | /// Default Filter all return false 27 | class NoneFilter extends Filter { 28 | const NoneFilter(); 29 | 30 | @override 31 | bool filter(T obj) => false; 32 | } 33 | -------------------------------------------------------------------------------- /lib/utils/hive_helper.dart: -------------------------------------------------------------------------------- 1 | import 'package:habr_app/hive/register_in_hive.dart'; 2 | import 'package:habr_app/models/author.dart'; 3 | import 'package:habr_app/models/cached_image_info.dart'; 4 | import 'package:habr_app/models/cached_post.dart'; 5 | import 'package:habr_app/models/post_preview.dart'; 6 | import 'package:habr_app/utils/filters/filter.dart'; 7 | import 'package:hive/hive.dart'; 8 | import 'package:hive_flutter/hive_flutter.dart'; 9 | 10 | Future initializeHive() async { 11 | await Hive.initFlutter(); 12 | registerAdapters(); 13 | await Future.wait([ 14 | Hive.openBox('settings'), 15 | Hive.openBox>('filters'), 16 | Hive.openBox('bookmarked'), 17 | Hive.openBox('read_late'), 18 | Hive.openBox('bookmarks'), 19 | Hive.openLazyBox('cached_images'), 20 | Hive.openLazyBox('cached_articles'), 21 | Hive.openLazyBox('cached_authors'), 22 | ]); 23 | } 24 | -------------------------------------------------------------------------------- /lib/utils/html_to_json.dart: -------------------------------------------------------------------------------- 1 | export 'html_to_json/html_to_json.dart'; -------------------------------------------------------------------------------- /lib/utils/html_to_json/element_builders.dart: -------------------------------------------------------------------------------- 1 | import 'text_mode.dart'; 2 | export 'text_mode.dart'; 3 | 4 | abstract class Node { 5 | String get type; 6 | } 7 | 8 | abstract class NodeChild extends Node { 9 | Node get child; 10 | } 11 | 12 | abstract class NodeChildren extends Node { 13 | List get children; 14 | } 15 | 16 | class Paragraph implements Node { 17 | List children; 18 | 19 | Paragraph.empty() : children = []; 20 | 21 | void addSpan(Span span) { 22 | children.add(span); 23 | } 24 | 25 | @override 26 | String get type => "paragraph"; 27 | } 28 | 29 | abstract class Span {} 30 | 31 | class BlockSpan implements Span { 32 | Node child; 33 | 34 | BlockSpan(this.child); 35 | } 36 | 37 | class TextSpan implements Span { 38 | String text; 39 | List modes; 40 | 41 | TextSpan(this.text, {List modes = const []}) 42 | : modes = modes 43 | .map((mode) => mode.toString().substring('TextMode'.length + 1)) 44 | .toList(); 45 | } 46 | 47 | class LinkSpan implements Span { 48 | String text; 49 | String link; 50 | 51 | LinkSpan(this.text, this.link); 52 | } 53 | 54 | class TextParagraph implements Node { 55 | String text; 56 | 57 | TextParagraph(this.text); 58 | 59 | @override 60 | String get type => "text_paragraph"; 61 | } 62 | 63 | class HeadLine implements Node { 64 | String text; 65 | String mode; 66 | 67 | HeadLine(this.text, this.mode); 68 | 69 | @override 70 | String get type => "headline"; 71 | } 72 | 73 | class Image implements Node { 74 | String src; 75 | String? caption; 76 | 77 | Image(this.src, {this.caption}); 78 | 79 | @override 80 | String get type => "image"; 81 | } 82 | 83 | class Code implements Node { 84 | String text; 85 | String? language; 86 | 87 | Code(this.text, this.language); 88 | 89 | @override 90 | String get type => "code"; 91 | } 92 | 93 | enum ListType { unordered, ordered } 94 | 95 | class BlockList implements NodeChildren { 96 | List children; 97 | ListType listType; 98 | 99 | BlockList(this.listType, this.children); 100 | 101 | @override 102 | String get type => "${listType}_list"; 103 | } 104 | 105 | class Details implements NodeChild { 106 | String title; 107 | Node child; 108 | 109 | Details(this.title, this.child); 110 | 111 | @override 112 | String get type => "details"; 113 | } 114 | 115 | class Scrollable implements NodeChild { 116 | Node child; 117 | 118 | Scrollable(this.child); 119 | 120 | @override 121 | String get type => "scrollable"; 122 | } 123 | 124 | class BlockColumn implements NodeChildren { 125 | List children; 126 | 127 | BlockColumn(this.children); 128 | 129 | @override 130 | String get type => "column"; 131 | } 132 | 133 | class BlockQuote implements NodeChild { 134 | Node child; 135 | 136 | BlockQuote(this.child); 137 | 138 | @override 139 | String get type => "quote"; 140 | } 141 | 142 | class Iframe implements Node { 143 | String src; 144 | 145 | Iframe(this.src); 146 | 147 | @override 148 | String get type => "iframe"; 149 | } 150 | 151 | class Table implements Node { 152 | // имплементирует ноду чтобы не применялись оптимизаци 153 | List> rows; 154 | 155 | Table(this.rows); 156 | 157 | @override 158 | String get type => "table"; 159 | } 160 | 161 | class MathFormula implements Node { 162 | final String formula; 163 | 164 | MathFormula(this.formula); 165 | 166 | @override 167 | String get type => "formula"; 168 | } 169 | -------------------------------------------------------------------------------- /lib/utils/html_to_json/html_to_json.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'package:html/parser.dart'; 3 | import 'transformer.dart'; 4 | import 'element_builders.dart'; 5 | 6 | Node htmlAsParsedJson(String? input) { 7 | final doc = parse(input); 8 | final block = prepareHtmlBlocElement(doc.body!.children.first); 9 | optimizeBlock(block); 10 | return block; 11 | } 12 | -------------------------------------------------------------------------------- /lib/utils/html_to_json/text_mode.dart: -------------------------------------------------------------------------------- 1 | enum TextMode { 2 | bold, 3 | italic, 4 | emphasis, 5 | underline, 6 | strikethrough, 7 | anchor, 8 | strong 9 | } 10 | -------------------------------------------------------------------------------- /lib/utils/http_request_helper.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:habr_app/app_error.dart'; 3 | import 'package:either_dart/either.dart'; 4 | import 'package:http/http.dart' as http; 5 | import 'log.dart'; 6 | import 'dart:convert'; 7 | 8 | Future> safe(Future request) async { 9 | try { 10 | return Right(await request); 11 | } catch (e) { 12 | logError(e); 13 | return Left( 14 | AppError( 15 | errCode: ErrorType.BadRequest, 16 | message: "Request executing with errors") 17 | ); 18 | } 19 | } 20 | 21 | Either checkHttpStatus(http.Response response) { 22 | if (response.statusCode == 200) 23 | return Right(response); 24 | if (response.statusCode >= 500) 25 | return Left( 26 | AppError( 27 | errCode: ErrorType.ServerError, 28 | message: "Server error with http status ${response.statusCode}" 29 | ) 30 | ); 31 | return Left( 32 | AppError( 33 | errCode: ErrorType.BadResponse, 34 | message: "Bad http status ${response.statusCode}" 35 | ) 36 | ); 37 | } 38 | 39 | dynamic parseJson(http.Response response) { 40 | return json.decode(response.body); 41 | } 42 | 43 | dynamic _parseJson(String data) { 44 | final json = JsonCodec(); 45 | return json.decode(data); 46 | } 47 | 48 | Future asyncParseJson(http.Response response) async { 49 | return await compute(_parseJson, response.body); 50 | } -------------------------------------------------------------------------------- /lib/utils/images_finder.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:habr_app/utils/html_to_json/element_builders.dart'; 4 | import 'package:habr_app/utils/html_to_json.dart'; 5 | 6 | List getImageUrlsFromHtml(String? html) { 7 | final parsedHtml = htmlAsParsedJson(html); 8 | final urls = getImagesFromParsedPost(parsedHtml).toList(); 9 | return urls; 10 | } 11 | 12 | Iterable getImagesFromParsedPost(Node? element) sync* { 13 | if (element is Image) { 14 | yield element.src; 15 | } else if (element is NodeChild) { 16 | yield* getImagesFromParsedPost(element.child); 17 | } else if (element is NodeChildren) { 18 | for (final child in element.children) 19 | yield* getImagesFromParsedPost(child); 20 | } else if (element is Paragraph) { 21 | for (final span in element.children) { 22 | if (span is BlockSpan) { 23 | yield* getImagesFromParsedPost(span.child); 24 | } 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /lib/utils/integer_to_text.dart: -------------------------------------------------------------------------------- 1 | String intToMetricPrefix(int number) { 2 | if (number <= 1000) { 3 | return number.toString(); 4 | } else { 5 | return (number / 1000).toStringAsPrecision(2) + 'k'; 6 | } 7 | } -------------------------------------------------------------------------------- /lib/utils/log.dart: -------------------------------------------------------------------------------- 1 | void logError(Object e, [StackTrace? stackTrace]) { 2 | print(e.toString()); 3 | if (stackTrace != null) print(stackTrace); 4 | } 5 | 6 | void logInfo(Object obj) { 7 | print(obj); 8 | } -------------------------------------------------------------------------------- /lib/utils/luid.dart: -------------------------------------------------------------------------------- 1 | import 'worker/id_generator.dart'; 2 | 3 | // Local unique identifier 4 | final LUID = IdGenerator(); -------------------------------------------------------------------------------- /lib/utils/message_notifier.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | void notifySnackbarText(BuildContext context, Object message) { 4 | ScaffoldMessenger.of(context) 5 | .showSnackBar(SnackBar(content: Text(message.toString()))); 6 | } 7 | -------------------------------------------------------------------------------- /lib/utils/page_loaders/page_loader.dart: -------------------------------------------------------------------------------- 1 | abstract class PageLoader { 2 | Future load(int page); 3 | } 4 | -------------------------------------------------------------------------------- /lib/utils/page_loaders/preview_loader.dart: -------------------------------------------------------------------------------- 1 | import 'package:either_dart/either.dart'; 2 | 3 | import 'package:habr_app/models/post_preview.dart'; 4 | import 'package:habr_app/habr/habr.dart'; 5 | import 'package:habr_app/stores/habr_storage.dart'; 6 | import 'package:habr_app/pages/pages.dart'; 7 | import 'package:habr_app/app_error.dart'; 8 | import 'page_loader.dart'; 9 | 10 | export 'page_loader.dart'; 11 | 12 | class CachedPreviewLoader extends FlowPreviewLoader { 13 | final HabrStorage storage; 14 | CachedPreviewLoader({required this.storage}) 15 | : super(flow: PostsFlow.saved, storage: storage); 16 | } 17 | 18 | class FlowPreviewLoader extends PageLoader> { 19 | final PostsFlow flow; 20 | final HabrStorage storage; 21 | 22 | FlowPreviewLoader({required this.flow, required this.storage}); 23 | 24 | Future> load(int page) { 25 | return storage.posts(page: page, flow: flow); 26 | } 27 | } 28 | 29 | class SearchLoader extends PageLoader> { 30 | final String query; 31 | final Order order; 32 | 33 | SearchLoader(SearchData info) 34 | : query = info.query, 35 | order = info.order; 36 | 37 | Future> load(int page) { 38 | return Habr().findPosts(query, page: page, order: order); 39 | } 40 | } 41 | 42 | class UserPreviewsLoader extends PageLoader> { 43 | final String username; 44 | 45 | UserPreviewsLoader(this.username); 46 | 47 | @override 48 | Future> load(int page) { 49 | return Habr().userPosts(username, page: page); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/utils/platform_helper.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | bool isDesktop(BuildContext context) { 4 | final platform = Theme.of(context).platform; 5 | switch (platform) { 6 | case TargetPlatform.linux: 7 | case TargetPlatform.macOS: 8 | case TargetPlatform.windows: 9 | return true; 10 | default: 11 | return false; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lib/utils/url_open.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:habr_app/routing/routing.dart'; 3 | import 'package:url_launcher/url_launcher.dart'; 4 | 5 | launchUrl(BuildContext context, String url) async { 6 | // TODO: open habr url in app 7 | if (url.startsWith(RegExp("https?://(m\.)?habr\.com"))) { 8 | final postRegexp = RegExp( 9 | r"https?://(m\.)?habr\.com/((ru|en)/)?(post|company/\w+/blog)/(\d+)/?"); 10 | final matchPost = postRegexp.firstMatch(url); 11 | if (matchPost != null) { 12 | final postId = matchPost.group(5); // post id 13 | openArticle(context, postId!); 14 | return; 15 | } else { 16 | print("no match"); 17 | } 18 | } 19 | await launch(url); 20 | } 21 | -------------------------------------------------------------------------------- /lib/utils/worker/id_generator.dart: -------------------------------------------------------------------------------- 1 | class IdGenerator { 2 | int current = 0; 3 | IdGenerator(); 4 | 5 | int genId() { 6 | final currentTime = DateTime.now().millisecondsSinceEpoch; 7 | current = (current + 1) % (1 << 16); 8 | final guid = ((currentTime & 0xFFFFFF) << 16) + current; 9 | 10 | return guid; 11 | } 12 | 13 | int call() => genId(); 14 | } -------------------------------------------------------------------------------- /lib/utils/worker/runnable.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | typedef Fun = FutureOr Function(ArgT arg); 4 | 5 | class Runnable { 6 | final A arg; 7 | final Fun fun; 8 | 9 | Runnable({ 10 | required this.arg, 11 | required this.fun, 12 | }); 13 | 14 | FutureOr call() { 15 | return fun(arg); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /lib/utils/worker/worker.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:isolate'; 3 | 4 | import 'package:habr_app/utils/log.dart'; 5 | 6 | import 'id_generator.dart'; 7 | 8 | import 'runnable.dart'; 9 | export 'runnable.dart'; 10 | 11 | class Worker { 12 | Isolate? _isolate; 13 | late ReceivePort _receivePort; 14 | SendPort? _sendPort; 15 | StreamSubscription? _portSub; 16 | final String name; 17 | Completer? initCompleter; 18 | 19 | final _results = Map>(); 20 | final _idGen = IdGenerator(); 21 | 22 | Worker({required this.name}); 23 | 24 | Future initialize() async { 25 | final initializationBeenStarted = 26 | initCompleter != null && !initCompleter!.isCompleted; 27 | 28 | if (initializationBeenStarted) { 29 | await initCompleter!.future; 30 | return; 31 | } 32 | 33 | logInfo('create isolate: $name'); 34 | initCompleter = Completer(); 35 | _receivePort = ReceivePort(); 36 | _isolate = await Isolate.spawn(_anotherIsolate, _receivePort.sendPort); 37 | 38 | _portSub = _receivePort.listen((message) { 39 | if (message is ResultMessage) { 40 | _results[message.taskId]?.complete(message.result); 41 | _results.remove(message.taskId); 42 | } else if (message is ErrorMessage) { 43 | _results[message.taskId]?.completeError( 44 | message.error, 45 | message.stackTrace, 46 | ); 47 | _results.remove(message.taskId); 48 | } else { 49 | _sendPort = message; 50 | initCompleter!.complete(); 51 | } 52 | }); 53 | await initCompleter!.future; 54 | } 55 | 56 | Future work(Runnable task) async { 57 | if (notStarted) { 58 | await initialize(); 59 | } 60 | final completer = Completer(); 61 | final taskId = _idGen.genId(); 62 | _results[taskId] = completer; 63 | _sendPort?.send(WorkMessage(taskId: taskId, runnable: task)); 64 | return completer.future; 65 | } 66 | 67 | static void _anotherIsolate(SendPort sendPort) { 68 | final receivePort = ReceivePort(); 69 | sendPort.send(receivePort.sendPort); 70 | receivePort.listen((message) async { 71 | if (message is WorkMessage) { 72 | final taskId = message.taskId; 73 | try { 74 | final result = await message.runnable.call(); 75 | sendPort.send(ResultMessage(taskId: taskId, result: result)); 76 | } catch (error) { 77 | try { 78 | sendPort.send(ErrorMessage(error, taskId: taskId)); 79 | } catch (error) { 80 | sendPort.send(ErrorMessage( 81 | 'can`t send error with too big stackTrace, error is : ${error.toString()}', 82 | taskId: taskId)); 83 | } 84 | } 85 | } 86 | }); 87 | } 88 | 89 | bool get started => initCompleter != null && initCompleter!.isCompleted; 90 | 91 | bool get notStarted => !started; 92 | 93 | Future kill() async { 94 | initCompleter = null; 95 | final cancelableIsolate = _isolate; 96 | _isolate = null; 97 | await _portSub?.cancel(); 98 | _sendPort = null; 99 | cancelableIsolate?.kill(priority: Isolate.immediate); 100 | } 101 | } 102 | 103 | class WorkMessage { 104 | final int taskId; 105 | final Runnable runnable; 106 | 107 | WorkMessage({ 108 | required this.runnable, 109 | required this.taskId, 110 | }); 111 | } 112 | 113 | class ResultMessage { 114 | final int taskId; 115 | final Object? result; 116 | 117 | ResultMessage({required this.result, required this.taskId}); 118 | } 119 | 120 | class ErrorMessage { 121 | final int taskId; 122 | final Object error; 123 | final StackTrace? stackTrace; 124 | 125 | ErrorMessage(this.error, {this.stackTrace, required this.taskId}); 126 | } 127 | -------------------------------------------------------------------------------- /lib/utils/workers/hasher.dart: -------------------------------------------------------------------------------- 1 | import 'package:crypto/crypto.dart'; 2 | import 'dart:async'; 3 | import 'dart:convert'; 4 | 5 | import 'package:habr_app/utils/log.dart'; 6 | 7 | import 'package:habr_app/utils/worker/worker.dart'; 8 | 9 | String _computeMD5Hash(String str) { 10 | return md5.convert(utf8.encode(str)).toString(); 11 | } 12 | 13 | abstract class HashComputer { 14 | Future hash(String str); 15 | } 16 | 17 | class MD5Hash implements HashComputer { 18 | final _worker = Worker(name: 'md5 hash'); 19 | 20 | @override 21 | Future hash(String str) async { 22 | return _worker.work(Runnable(fun: _computeMD5Hash, arg: str)); 23 | } 24 | 25 | void dispose() { 26 | _worker.kill(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/utils/workers/image_loader.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:habr_app/utils/http_request_helper.dart'; 4 | import 'package:habr_app/utils/log.dart'; 5 | 6 | import 'package:habr_app/utils/worker/worker.dart'; 7 | import 'package:http/http.dart' as http; 8 | import 'dart:io'; 9 | 10 | abstract class ImageLoader { 11 | Future loadImage(String url, String path); 12 | } 13 | 14 | class _ArgObj { 15 | final String url; 16 | final String path; 17 | 18 | _ArgObj(this.url, this.path); 19 | } 20 | 21 | class ImageHttpLoader implements ImageLoader { 22 | final _worker = Worker(name: 'image loader'); 23 | 24 | @override 25 | Future loadImage(String url, String path) async { 26 | return _worker.work(Runnable(fun: _loadImage, arg: _ArgObj(url, path))); 27 | } 28 | 29 | static Future _loadImage(_ArgObj args) async { 30 | try { 31 | final response = await http.get(Uri.parse(args.url)); 32 | 33 | if (checkHttpStatus(response).isLeft) { 34 | return false; 35 | } 36 | 37 | final file = File(args.path); 38 | final image = response.bodyBytes; 39 | await file.writeAsBytes(image); 40 | } catch (err) { 41 | logError("loading image by url:${args.url} ended with err: $err"); 42 | return false; 43 | } 44 | 45 | return true; 46 | } 47 | 48 | void dispose() { 49 | _worker.kill(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/widgets/adaptive_ui.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class CenterAdaptiveConstrait extends StatelessWidget { 4 | final Widget child; 5 | 6 | const CenterAdaptiveConstrait({required this.child}); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | return Container( 11 | alignment: Alignment.center, 12 | child: ConstrainedBox( 13 | constraints: BoxConstraints(maxWidth: 880), 14 | child: child, 15 | ), 16 | ); 17 | } 18 | } 19 | 20 | class DefaultConstraints extends StatelessWidget { 21 | final Widget child; 22 | 23 | const DefaultConstraints({required this.child}); 24 | 25 | @override 26 | Widget build(BuildContext context) { 27 | return Center( 28 | child: Container( 29 | width: 880, 30 | child: child, 31 | ), 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/widgets/articles_list_body.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:habr_app/stores/habr_storage.dart'; 3 | import 'package:habr_app/routing/routing.dart'; 4 | import 'package:provider/provider.dart'; 5 | import 'package:habr_app/stores/articles_store.dart'; 6 | import 'package:habr_app/utils/message_notifier.dart'; 7 | import 'package:habr_app/app_error.dart'; 8 | import 'package:habr_app/stores/loading_state.dart'; 9 | 10 | import 'adaptive_ui.dart'; 11 | import 'incrementally_loading_listview.dart'; 12 | import 'hr.dart'; 13 | import 'informing/informing.dart'; 14 | import 'slidable.dart'; 15 | import 'circular_item.dart'; 16 | import 'article_preview.dart'; 17 | 18 | class ArticlesListBody extends StatelessWidget { 19 | Widget build(BuildContext context) { 20 | return Consumer( 21 | builder: (context, store, _) => _build(store)!, 22 | ); 23 | } 24 | 25 | Widget? _build(ArticlesStorage store) { 26 | Widget? widget; 27 | switch (store.firstLoading!) { 28 | case LoadingState.isFinally: 29 | widget = IncrementallyLoadingListView( 30 | itemBuilder: (context, index) { 31 | if (index >= store.previews.length && store.loadItems) 32 | return const Center(child: CircularItem()); 33 | final preview = store.previews[index]; 34 | final habrStorage = 35 | Provider.of(context, listen: false); 36 | return DefaultConstraints( 37 | child: SlidableArchive( 38 | child: ArticlePreview( 39 | key: ValueKey("preview_" + preview.id), 40 | postPreview: preview, 41 | onPressed: (articleId) => openArticle(context, articleId), 42 | ), 43 | onArchive: () => habrStorage.addArticleInCache(preview.id).then( 44 | (res) => notifySnackbarText( 45 | context, "${preview.title} ${res ? '' : 'не'} скачано")), 46 | )); 47 | }, 48 | separatorBuilder: (context, index) => 49 | const DefaultConstraints(child: Hr()), 50 | itemCount: () => store.previews.length + (store.loadItems ? 1 : 0), 51 | loadMore: store.loadNextPage, 52 | hasMore: store.hasNextPages, 53 | ); 54 | break; 55 | case LoadingState.inProgress: 56 | widget = Center(child: CircularProgressIndicator()); 57 | break; 58 | case LoadingState.isCorrupted: 59 | switch (store.lastError!.errCode) { 60 | case ErrorType.ServerError: 61 | widget = const Center(child: const LotOfEntropy()); 62 | break; 63 | default: 64 | widget = Center( 65 | child: LossInternetConnection(onPressReload: store.reload)); 66 | } 67 | break; 68 | } 69 | return widget; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /lib/widgets/author_avatar_icon.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:cached_network_image/cached_network_image.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:flutter_svg/flutter_svg.dart'; 6 | 7 | import 'package:habr_app/models/author_avatar_info.dart'; 8 | import 'package:habr_app/styles/colors/colors.dart'; 9 | 10 | class AuthorAvatarIcon extends StatelessWidget { 11 | final double height; 12 | final double width; 13 | final double borderWidth; 14 | final double radius; 15 | final AuthorAvatarInfo? avatar; 16 | final Color? defaultColor; 17 | 18 | AuthorAvatarIcon({ 19 | required this.avatar, 20 | this.height = 20, 21 | this.width = 20, 22 | this.defaultColor, 23 | this.borderWidth = 1.0, 24 | this.radius = 5, 25 | Key? key, 26 | }) : super(key: key); 27 | 28 | @override 29 | Widget build(BuildContext context) { 30 | Color colorForDefault = defaultColor ?? DefaultAvatarColors.lilac; 31 | Widget image; 32 | 33 | if (avatar!.isDefault) { 34 | image = Container( 35 | decoration: BoxDecoration( 36 | // color: Colors.white, 37 | border: Border.all(color: colorForDefault, width: borderWidth), 38 | borderRadius: BorderRadius.all(Radius.circular(radius)), 39 | ), 40 | child: SvgPicture.asset( 41 | "assets/images/default_avatar.svg", 42 | color: colorForDefault, 43 | height: height, 44 | width: width, 45 | ), 46 | ); 47 | } else { 48 | if (avatar!.cached) { 49 | image = Image.file(File(avatar!.url!), height: height, width: width); 50 | } else { 51 | image = CachedNetworkImage( 52 | imageUrl: avatar!.url!, 53 | height: height, 54 | width: width, 55 | ); 56 | } 57 | } 58 | 59 | return ClipRRect( 60 | borderRadius: BorderRadius.all(Radius.circular(radius)), 61 | child: image, 62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /lib/widgets/author_previews.dart: -------------------------------------------------------------------------------- 1 | export 'small_author_preview.dart'; 2 | export 'medium_author_preview.dart'; -------------------------------------------------------------------------------- /lib/widgets/circular_item.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class CircularItem extends StatelessWidget { 4 | const CircularItem(); 5 | 6 | @override 7 | Widget build(BuildContext context) { 8 | return Container( 9 | padding: const EdgeInsets.all(10), 10 | child: const CircularProgressIndicator() 11 | ); 12 | } 13 | } -------------------------------------------------------------------------------- /lib/widgets/dividing_block.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class WrappedContainer extends StatelessWidget { 4 | final List children; 5 | final double distance; 6 | 7 | WrappedContainer({required this.children, this.distance = 20}); 8 | 9 | @override 10 | Widget build(BuildContext context) { 11 | final wrappedChildren = []; 12 | for (int i = 0; i < children.length; i++) { 13 | wrappedChildren.add(children[i]); 14 | if (i != children.length - 1) 15 | wrappedChildren.add(SizedBox(height: distance)); 16 | } 17 | return Column( 18 | crossAxisAlignment: CrossAxisAlignment.stretch, 19 | children: wrappedChildren, 20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lib/widgets/dropdown_list_tile.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class DropDownListTile extends StatelessWidget { 4 | final List> items; 5 | final Function(Key? key) onChanged; 6 | final Key defaultKey; 7 | final Widget title; 8 | final Widget? trailing; 9 | final Widget? leading; 10 | 11 | DropDownListTile({ 12 | required Map values, 13 | required this.title, 14 | this.trailing, 15 | this.leading, 16 | required this.defaultKey, 17 | required this.onChanged, 18 | }) : items = values.entries.toList(); 19 | 20 | @override 21 | Widget build(BuildContext context) { 22 | return ListTile( 23 | title: title, 24 | trailing: DropdownButton( 25 | value: defaultKey, 26 | onChanged: onChanged, 27 | items: items 28 | .map>((item) => 29 | DropdownMenuItem(value: item.key, child: Text(item.value))) 30 | .toList(), 31 | ), 32 | leading: leading, 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /lib/widgets/hide_floating_action_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class HideFloatingActionButton extends StatelessWidget { 4 | final bool visible; 5 | final Widget? child; 6 | final String? tooltip; 7 | final VoidCallback? onPressed; 8 | final Duration duration; 9 | 10 | HideFloatingActionButton({Key? key, 11 | this.visible = true, 12 | this.child, 13 | this.onPressed, 14 | this.tooltip, 15 | required 16 | this.duration, 17 | }) : super(key: key); 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | return AnimatedSwitcher( 22 | duration: duration, 23 | transitionBuilder: (Widget child, Animation animation) { 24 | return ScaleTransition(child: FadeTransition(child: child, opacity: animation), scale: animation); 25 | }, 26 | child: Visibility( 27 | visible: visible, 28 | key: UniqueKey(), 29 | child: FloatingActionButton( 30 | tooltip: tooltip, 31 | child: child, 32 | onPressed: onPressed, 33 | ) 34 | ) 35 | ); 36 | } 37 | 38 | } -------------------------------------------------------------------------------- /lib/widgets/hr.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class Hr extends StatelessWidget { 4 | const Hr(); 5 | 6 | @override 7 | Widget build(BuildContext context) { 8 | return const Divider(height: 1,); 9 | } 10 | } -------------------------------------------------------------------------------- /lib/widgets/html_elements/headline.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | enum HeadLineType { 4 | h1, h2, h3, h4, h5, h6 5 | } 6 | 7 | class HeadLine extends StatelessWidget { 8 | final String? text; 9 | final HeadLineType? type; 10 | const HeadLine({this.text, this.type}); 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | final textTheme = Theme.of(context).textTheme; 15 | final textStyle = { 16 | HeadLineType.h1: textTheme.headline1, 17 | HeadLineType.h2: textTheme.headline2, 18 | HeadLineType.h3: textTheme.headline3, 19 | HeadLineType.h4: textTheme.headline4, 20 | HeadLineType.h5: textTheme.headline5, 21 | HeadLineType.h6: textTheme.headline6, 22 | }[HeadLineType.h6]; // Todo: решить нужна ли впринципе эта таблица 23 | return Text(text!, style: textStyle); 24 | } 25 | } -------------------------------------------------------------------------------- /lib/widgets/html_elements/html_elements.dart: -------------------------------------------------------------------------------- 1 | export 'quote_block.dart'; 2 | export 'spoiler_block.dart'; 3 | export 'headline.dart'; 4 | export 'unordered_list.dart'; 5 | export 'highlight_code.dart'; 6 | export 'iframe.dart'; 7 | export 'math_formula.dart'; 8 | -------------------------------------------------------------------------------- /lib/widgets/html_elements/iframe.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:habr_app/widgets/link.dart'; 3 | 4 | class Iframe extends StatelessWidget { 5 | final String? src; 6 | Iframe({this.src}); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | return TextLink(url: src, title: 'Медиаэлемент'); 11 | } 12 | } -------------------------------------------------------------------------------- /lib/widgets/html_elements/math_formula.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_math_fork/flutter_math.dart'; 3 | 4 | class MathFormula extends StatelessWidget { 5 | final String formula; 6 | 7 | const MathFormula(this.formula, {Key? key}) : super(key: key); 8 | 9 | @override 10 | Widget build(BuildContext context) { 11 | return Math.tex( 12 | formula, 13 | mathStyle: MathStyle.text, 14 | textStyle: Theme.of(context).textTheme.bodyText2, 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /lib/widgets/html_elements/quote_block.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class BlockQuote extends StatelessWidget { 4 | final Widget? child; 5 | BlockQuote({this.child}); 6 | 7 | @override 8 | Widget build(BuildContext context) { 9 | ThemeData themeData = Theme.of(context); 10 | return Container( 11 | decoration: BoxDecoration( 12 | border: Border( 13 | left: BorderSide( 14 | color: themeData.primaryColor, 15 | width: 4, 16 | ) 17 | ) 18 | ), 19 | padding: const EdgeInsets.only(left: 10, top: 10, bottom: 10), 20 | child: child, 21 | ); 22 | } 23 | } -------------------------------------------------------------------------------- /lib/widgets/html_elements/spoiler_block.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:habr_app/widgets/link.dart'; 4 | 5 | class Spoiler extends StatefulWidget { 6 | final String? title; 7 | final Widget? child; 8 | 9 | Spoiler({this.title, this.child}); 10 | 11 | @override 12 | State createState() => _SpoilerState(); 13 | } 14 | 15 | class _SpoilerState extends State with TickerProviderStateMixin { 16 | late bool visible; 17 | late Animation _arrowAnimation; 18 | late AnimationController _arrowAnimationController; 19 | static const duration = Duration(milliseconds: 100); 20 | 21 | @override 22 | initState() { 23 | super.initState(); 24 | visible = false; 25 | _arrowAnimationController = 26 | AnimationController(vsync: this, duration: duration); 27 | _arrowAnimation = 28 | Tween(begin: 0.0, end: pi / 2).animate(_arrowAnimationController); 29 | } 30 | 31 | @override 32 | void dispose() { 33 | _arrowAnimationController.dispose(); 34 | super.dispose(); 35 | } 36 | 37 | onTap() { 38 | setState(() { 39 | visible 40 | ? _arrowAnimationController.reverse() 41 | : _arrowAnimationController.forward(); 42 | visible = !visible; 43 | }); 44 | } 45 | 46 | @override 47 | Widget build(BuildContext context) { 48 | ThemeData themeData = Theme.of(context); 49 | return Container( 50 | child: Column( 51 | crossAxisAlignment: CrossAxisAlignment.start, 52 | children: [ 53 | GestureDetector( 54 | child: Row( 55 | children: [ 56 | AnimatedBuilder( 57 | animation: _arrowAnimationController, 58 | builder: (context, child) => Transform.rotate( 59 | angle: _arrowAnimation.value, child: child), 60 | child: Icon(Icons.arrow_right, color: linkColorFrom(context)), 61 | ), 62 | Expanded( 63 | child: Text( 64 | widget.title!, 65 | style: TextStyle( 66 | color: linkColorFrom(context), 67 | decorationColor: linkColorFrom(context), 68 | decoration: TextDecoration.underline, 69 | decorationStyle: TextDecorationStyle.dashed, 70 | ), 71 | )) 72 | ], 73 | ), 74 | onTap: onTap, 75 | ), 76 | SizeTransition( 77 | sizeFactor: Tween(begin: 0, end: 1) 78 | .animate(_arrowAnimationController), 79 | axisAlignment: -1.0, 80 | child: Column(children: [ 81 | const SizedBox(height: 10), 82 | widget.child!, 83 | ]), 84 | ) 85 | ], 86 | )); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /lib/widgets/html_elements/unordered_list.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class UnorderedList extends StatelessWidget { 4 | final List children; 5 | UnorderedList({required this.children}); 6 | 7 | @override 8 | Widget build(BuildContext context) { 9 | return Column( 10 | crossAxisAlignment: CrossAxisAlignment.start, 11 | children: children 12 | .map( 13 | (child) => UnorderedItem(child: child), 14 | ) 15 | .toList(), 16 | ); 17 | } 18 | } 19 | 20 | class UnorderedItem extends StatelessWidget { 21 | final Widget child; 22 | 23 | UnorderedItem({required this.child}); 24 | 25 | @override 26 | Widget build(BuildContext context) { 27 | final theme = Theme.of(context).textTheme.bodyText2!; 28 | final bulletSize = 5; 29 | final centerBaseline = 30 | (theme.fontSize! * theme.height! - bulletSize / 2) / 2; 31 | return Row( 32 | crossAxisAlignment: CrossAxisAlignment.start, 33 | mainAxisAlignment: MainAxisAlignment.start, 34 | children: [ 35 | Container( 36 | child: const Bullet(), 37 | padding: EdgeInsets.only(right: 10, top: centerBaseline), 38 | ), 39 | Expanded(child: child), 40 | ], 41 | ); 42 | } 43 | } 44 | 45 | class Bullet extends StatelessWidget { 46 | final double height; 47 | final double width; 48 | final Color? color; 49 | const Bullet({this.height = 5, this.width = 5, this.color}); 50 | 51 | @override 52 | Widget build(BuildContext context) { 53 | final bulletColor = color ?? Theme.of(context).iconTheme.color; 54 | return Container( 55 | height: height, 56 | width: width, 57 | decoration: BoxDecoration( 58 | color: bulletColor, 59 | shape: BoxShape.circle, 60 | ), 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/widgets/informing/empty_content.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_svg/flutter_svg.dart'; 3 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 4 | 5 | class EmptyContent extends StatelessWidget { 6 | final double pictureHeight; 7 | final double pictureWidth; 8 | 9 | const EmptyContent({this.pictureHeight = 150, this.pictureWidth = 100}); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | return Column( 14 | mainAxisAlignment: MainAxisAlignment.center, 15 | children: [ 16 | SvgPicture.asset( 17 | "assets/images/empty_comments.svg", 18 | height: pictureHeight, width: pictureWidth, 19 | ), 20 | const SizedBox(height: 40,), 21 | Text(AppLocalizations.of(context)!.emptyContent), 22 | ], 23 | ); 24 | } 25 | } -------------------------------------------------------------------------------- /lib/widgets/informing/informing.dart: -------------------------------------------------------------------------------- 1 | export 'empty_content.dart'; 2 | export 'internet_error_view.dart'; 3 | export 'lot_of_entropy.dart'; 4 | export 'load_appbar_title.dart'; -------------------------------------------------------------------------------- /lib/widgets/informing/internet_error_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 3 | 4 | class LossInternetConnection extends StatelessWidget { 5 | final VoidCallback onPressReload; 6 | 7 | const LossInternetConnection({required this.onPressReload}); 8 | 9 | @override 10 | Widget build(BuildContext context) { 11 | final localization = AppLocalizations.of(context)!; 12 | return Column( 13 | mainAxisAlignment: MainAxisAlignment.center, 14 | crossAxisAlignment: CrossAxisAlignment.center, 15 | children: [ 16 | Image.asset("assets/images/ufo.png"), 17 | const SizedBox(height: 30), 18 | Text(localization.lossInternet), 19 | const SizedBox(height: 10), 20 | TextButton( 21 | onPressed: onPressReload, 22 | child: Text(localization.reload), 23 | ) 24 | ]); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/widgets/informing/load_appbar_title.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 3 | 4 | class LoadAppBarTitle extends StatefulWidget { 5 | @override 6 | State createState() => _LoadAppBarTitleState(); 7 | } 8 | 9 | class _LoadAppBarTitleState extends State 10 | with SingleTickerProviderStateMixin { 11 | late AnimationController controller; 12 | late Animation animation; 13 | late int alpha; 14 | late bool direction; 15 | 16 | @override 17 | void initState() { 18 | super.initState(); 19 | controller = 20 | AnimationController(duration: const Duration(milliseconds: 800), vsync: this); 21 | animation = Tween(begin: 30, end: 85).animate(controller) 22 | ..addListener(() { 23 | setState(() { 24 | alpha = animation.value.round(); 25 | }); 26 | }) 27 | ..addStatusListener((status) { 28 | if (status == AnimationStatus.completed) { 29 | if (direction) { 30 | controller.forward(); 31 | } else { 32 | controller.reverse(); 33 | } 34 | direction = !direction; 35 | } else if (status == AnimationStatus.dismissed) { 36 | direction = false; 37 | controller.forward(); 38 | } 39 | }); 40 | alpha = 30; 41 | direction = false; 42 | controller.forward(); 43 | } 44 | 45 | @override 46 | Widget build(BuildContext context) { 47 | final color = Theme.of(context).primaryTextTheme.headline6!.color!; 48 | final loadingText = AppLocalizations.of(context)!.loading; 49 | return Text.rich( 50 | TextSpan(children: [ 51 | TextSpan(text: loadingText), 52 | TextSpan( 53 | text: '.', 54 | style: TextStyle( 55 | color: color.withAlpha(255 - (alpha.toDouble() * 1.1).round()), 56 | ), 57 | ), 58 | TextSpan( 59 | text: '.', 60 | style: TextStyle( 61 | color: color.withAlpha(255 - (alpha * 2)), 62 | ), 63 | ), 64 | TextSpan( 65 | text: '.', 66 | style: TextStyle( 67 | color: color.withAlpha(255 - (alpha * 3)), 68 | ), 69 | ), 70 | ]), 71 | overflow: TextOverflow.fade, 72 | ); 73 | } 74 | 75 | @override 76 | void dispose() { 77 | controller.dispose(); 78 | super.dispose(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /lib/widgets/informing/lot_of_entropy.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 3 | 4 | class LotOfEntropy extends StatelessWidget { 5 | const LotOfEntropy(); 6 | 7 | @override 8 | Widget build(BuildContext context) { 9 | return Column( 10 | mainAxisAlignment: MainAxisAlignment.center, 11 | crossAxisAlignment: CrossAxisAlignment.center, 12 | children: [ 13 | Image.asset("assets/images/lot_of_entropy.webp", height: 300, width: 300,), 14 | SizedBox(height: 30,), 15 | Text(AppLocalizations.of(context)!.appError), 16 | ] 17 | ); 18 | } 19 | } -------------------------------------------------------------------------------- /lib/widgets/link.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/gestures.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:habr_app/utils/url_open.dart'; 4 | 5 | class Link extends StatelessWidget { 6 | final Widget? child; 7 | final String? url; 8 | const Link({this.child, required this.url}); 9 | 10 | @override 11 | Widget build(BuildContext context) { 12 | return InkWell( 13 | onTap: () => launchUrl(context, url!), 14 | child: child, 15 | ); 16 | } 17 | } 18 | 19 | TextSpan InlineTextLink( 20 | {required String title, 21 | required String? url, 22 | required BuildContext context}) { 23 | return TextSpan( 24 | text: title, 25 | style: linkTextStyleFrom(context), 26 | recognizer: TapGestureRecognizer()..onTap = () => launchUrl(context, url!), 27 | ); 28 | } 29 | 30 | class TextLink extends StatelessWidget { 31 | final String title; 32 | final String? url; 33 | const TextLink({required this.title, required this.url}); 34 | 35 | @override 36 | Widget build(BuildContext context) { 37 | return Link( 38 | child: Text( 39 | title, 40 | style: linkTextStyleFrom(context), 41 | ), 42 | url: url, 43 | ); 44 | } 45 | } 46 | 47 | Color linkColorFrom(BuildContext context) { 48 | return Theme.of(context).toggleableActiveColor; 49 | } 50 | 51 | TextStyle linkTextStyleFrom(BuildContext context) { 52 | final color = linkColorFrom(context); 53 | return TextStyle( 54 | decoration: TextDecoration.underline, 55 | color: color, 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /lib/widgets/load_builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:either_dart/either.dart'; 3 | 4 | typedef ValueBuilder = Widget Function(BuildContext, Value); 5 | 6 | class LoadBuilder extends StatelessWidget { 7 | final Future> future; 8 | final ValueBuilder onRightBuilder; 9 | final ValueBuilder? onLeftBuilder; 10 | final ValueBuilder onErrorBuilder; 11 | 12 | LoadBuilder({ 13 | required this.future, 14 | required this.onRightBuilder, 15 | this.onLeftBuilder, 16 | required this.onErrorBuilder, 17 | }); 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | return FutureBuilder>( 22 | future: future, 23 | builder: (context, snapshot) { 24 | switch (snapshot.connectionState) { 25 | case ConnectionState.waiting: 26 | return Center(child: CircularProgressIndicator()); 27 | case ConnectionState.done: 28 | if (snapshot.hasError) 29 | return onErrorBuilder(context, snapshot.error); 30 | return snapshot.data!.fold( 31 | (err) => (onLeftBuilder ?? onErrorBuilder)(context, err), 32 | (data) => onRightBuilder(context, data)); 33 | default: 34 | return const Text('Something went wrong'); 35 | } 36 | }, 37 | ); 38 | } 39 | } -------------------------------------------------------------------------------- /lib/widgets/material_buttons.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class MaterialButton extends StatelessWidget { 4 | final VoidCallback? onPressed; 5 | final Color? color; 6 | final IconData? iconData; 7 | final String? text; 8 | 9 | MaterialButton({this.onPressed, this.color, this.iconData, this.text}); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | final mainColor = color ?? Theme.of(context).colorScheme.secondary; 14 | return TextButton( 15 | style: ButtonStyle( 16 | padding: MaterialStateProperty.all( 17 | const EdgeInsets.all(15)), 18 | shape: MaterialStateProperty.all( 19 | RoundedRectangleBorder( 20 | borderRadius: BorderRadius.circular(12.0), 21 | side: BorderSide(width: 2, color: mainColor)), 22 | ), 23 | ), 24 | onPressed: onPressed, 25 | child: Row( 26 | mainAxisAlignment: MainAxisAlignment.center, 27 | crossAxisAlignment: CrossAxisAlignment.center, 28 | children: [ 29 | Icon( 30 | iconData, 31 | color: mainColor, 32 | ), 33 | const SizedBox( 34 | width: 10, 35 | ), 36 | Text( 37 | text!, 38 | style: TextStyle(color: mainColor), 39 | ), 40 | ], 41 | ), 42 | ); 43 | } 44 | } 45 | 46 | class CommentsButton extends StatelessWidget { 47 | final VoidCallback? onPressed; 48 | final Color? color; 49 | 50 | CommentsButton({this.onPressed, this.color}); 51 | 52 | @override 53 | Widget build(BuildContext context) { 54 | return MaterialButton( 55 | onPressed: onPressed, 56 | color: color, 57 | iconData: Icons.chat_bubble, 58 | text: "Комментарии"); 59 | } 60 | } 61 | 62 | class SearchButton extends StatelessWidget { 63 | final VoidCallback? onPressed; 64 | final Color? color; 65 | 66 | SearchButton({this.onPressed, this.color}); 67 | 68 | @override 69 | Widget build(BuildContext context) { 70 | return MaterialButton( 71 | onPressed: onPressed, 72 | color: color, 73 | iconData: Icons.search, 74 | text: "Поиск"); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /lib/widgets/medium_author_preview.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 3 | 4 | import 'package:habr_app/models/author.dart'; 5 | import 'package:habr_app/stores/avatar_color_store.dart'; 6 | 7 | import 'author_avatar_icon.dart'; 8 | import 'package:habr_app/widgets/link.dart'; 9 | 10 | class MediumAuthorPreview extends StatelessWidget { 11 | final Author author; 12 | 13 | MediumAuthorPreview(this.author); 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | final theme = Theme.of(context); 18 | final font = theme.textTheme.bodyText2!; 19 | return Row( 20 | crossAxisAlignment: CrossAxisAlignment.start, 21 | children: [ 22 | Padding( 23 | padding: EdgeInsets.only(top: (font.height! - 1) * font.fontSize!), 24 | child: AuthorAvatarIcon( 25 | avatar: author.avatar, 26 | height: 40, 27 | width: 40, 28 | borderWidth: 1.5, 29 | defaultColor: 30 | AvatarColorStore().getColor(author.alias, theme.brightness), 31 | ), 32 | ), 33 | SizedBox(width: 15), 34 | Expanded( 35 | child: Column( 36 | children: [ 37 | Text.rich( 38 | TextSpan(children: [ 39 | if (author.fullName != null) ...[ 40 | TextSpan(text: author.fullName), 41 | TextSpan(text: ', ') 42 | ], 43 | TextSpan( 44 | children: [ 45 | TextSpan(text: '@'), 46 | TextSpan(text: author.alias), 47 | ], 48 | style: TextStyle(color: linkColorFrom(context)), 49 | ), 50 | ]), 51 | maxLines: 2, 52 | overflow: TextOverflow.fade, 53 | ), 54 | Text(author.speciality ?? AppLocalizations.of(context)!.user), 55 | ], 56 | crossAxisAlignment: CrossAxisAlignment.start, 57 | ), 58 | ), 59 | ], 60 | ); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /lib/widgets/picture.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutter/cupertino.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:flutter_svg/flutter_svg.dart'; 6 | import 'package:habr_app/stores/habr_storage.dart'; 7 | import 'package:habr_app/utils/log.dart'; 8 | import 'package:habr_app/utils/luid.dart'; 9 | import 'package:habr_app/widgets/widgets.dart'; 10 | import 'package:provider/provider.dart'; 11 | import 'package:habr_app/pages/image_view.dart'; 12 | 13 | class Picture extends StatelessWidget { 14 | final String? url; 15 | final bool clickable; 16 | final double? height; 17 | final double? width; 18 | 19 | Picture.network(this.url, 20 | {this.clickable = false, this.height, this.width, Key? key}) 21 | : super(key: key); 22 | 23 | // TODO: make asset constructor 24 | // Picture.asset( 25 | // String assetName, { 26 | // double height, 27 | // 28 | // }) : 29 | // image = url.endsWith("svg") ? SvgPicture.asset(assetName) 30 | 31 | @override 32 | Widget build(BuildContext context) { 33 | final textTheme = Theme.of(context).textTheme.bodyText2!; 34 | final habrStorage = context.watch(); 35 | return Container( 36 | height: height, 37 | width: width, 38 | alignment: Alignment.center, 39 | child: LoadBuilder( 40 | future: habrStorage.imgStore.getImage(url), 41 | onRightBuilder: (context, dynamic filePath) { 42 | final file = File(filePath); 43 | if (url!.endsWith("svg")) { 44 | return SvgPicture.file(file, color: textTheme.color); 45 | } 46 | Widget widget = Image.file( 47 | file, 48 | height: height, 49 | width: width, 50 | ); 51 | if (clickable) { 52 | widget = _buildClickableImage( 53 | context, widget, FileImage(File(filePath))); 54 | } 55 | return widget; 56 | }, 57 | onErrorBuilder: (context, err) { 58 | logError(err); 59 | if (url!.endsWith("svg")) { 60 | return SvgPicture.network(url!, color: textTheme.color); 61 | } 62 | Widget widget = Image.network( 63 | url!, 64 | height: height, 65 | width: width, 66 | ); 67 | if (clickable) { 68 | widget = _buildClickableImage(context, widget, NetworkImage(url!)); 69 | } 70 | return widget; 71 | }, 72 | ), 73 | ); 74 | } 75 | 76 | _buildClickableImage( 77 | BuildContext context, Widget child, ImageProvider imgProvider) { 78 | final heroTag = url! + LUID.genId().toString(); 79 | return GestureDetector( 80 | onTap: () { 81 | Navigator.push( 82 | context, 83 | MaterialPageRoute( 84 | builder: (context) => HeroPhotoViewRouteWrapper( 85 | tag: heroTag, 86 | imageProvider: imgProvider, 87 | ), 88 | ), 89 | ); 90 | }, 91 | child: Container( 92 | child: Hero( 93 | tag: heroTag, 94 | child: child, 95 | ), 96 | ), 97 | ); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /lib/widgets/routing/home_menu.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_svg/svg.dart'; 3 | import 'package:habr_app/routing/routing.dart'; 4 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 5 | import 'package:habr_app/utils/platform_helper.dart'; 6 | 7 | class MainMenu extends StatelessWidget { 8 | @override 9 | Widget build(BuildContext context) { 10 | final localization = AppLocalizations.of(context)!; 11 | return Drawer( 12 | child: ListView( 13 | children: [ 14 | DrawerHeader( 15 | padding: EdgeInsets.zero, 16 | child: Container( 17 | decoration: BoxDecoration( 18 | image: DecorationImage( 19 | image: AssetImage('assets/images/background.png'), 20 | fit: BoxFit.cover), 21 | ), 22 | // child: Text("Puk"), // TODO: Add user icon if auth 23 | alignment: Alignment.bottomLeft, 24 | ), 25 | ), 26 | ListTile( 27 | trailing: const Icon(Icons.settings), 28 | title: Text(localization.settings), 29 | onTap: () => Navigator.popAndPushNamed(context, "settings"), 30 | ), 31 | ListTile( 32 | trailing: const Icon(Icons.archive), 33 | title: Text(localization.cachedArticles), 34 | onTap: () => Navigator.popAndPushNamed(context, 'articles/cached'), 35 | ), 36 | ListTile( 37 | trailing: const Icon(Icons.bookmark), 38 | title: Text(localization.bookmarks), 39 | onTap: () => 40 | Navigator.popAndPushNamed(context, 'articles/bookmarks'), 41 | ), 42 | ListTile( 43 | trailing: const Icon(Icons.filter_alt), 44 | title: Text(localization.filters), 45 | onTap: () => Navigator.popAndPushNamed(context, 'filters'), 46 | ), 47 | ], 48 | ), 49 | ); 50 | } 51 | } 52 | 53 | class DesktopHomeMenu extends StatelessWidget { 54 | @override 55 | Widget build(BuildContext context) { 56 | final localization = AppLocalizations.of(context)!; 57 | return Drawer( 58 | child: Column( 59 | children: [ 60 | DrawerHeader( 61 | padding: EdgeInsets.zero, 62 | child: Container( 63 | decoration: BoxDecoration( 64 | image: DecorationImage( 65 | image: AssetImage('assets/images/background.png'), 66 | fit: BoxFit.cover), 67 | ), 68 | // child: Text("Puk"), // TODO: Add user icon if auth 69 | alignment: Alignment.bottomLeft, 70 | ), 71 | ), 72 | ListTile( 73 | trailing: const Icon(Icons.settings), 74 | title: Text(localization.settings), 75 | onTap: () => Navigator.pushNamed(context, "settings"), 76 | ), 77 | ListTile( 78 | trailing: const Icon(Icons.archive), 79 | title: Text(localization.cachedArticles), 80 | onTap: () => Navigator.pushNamed(context, 'articles/cached'), 81 | ), 82 | ListTile( 83 | trailing: const Icon(Icons.bookmark), 84 | title: Text(localization.bookmarks), 85 | onTap: () => Navigator.pushNamed(context, 'articles/bookmarks'), 86 | ), 87 | ListTile( 88 | trailing: const Icon(Icons.filter_alt), 89 | title: Text(localization.filters), 90 | onTap: () => Navigator.pushNamed(context, 'filters'), 91 | ), 92 | ], 93 | ), 94 | ); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /lib/widgets/scroll_data.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | 3 | class ScrollData extends ScrollBehavior { 4 | final double? thinkness; 5 | final bool? isAlwaysShow; 6 | 7 | const ScrollData({this.thinkness, this.isAlwaysShow}); 8 | 9 | @override 10 | Widget buildScrollbar( 11 | BuildContext context, Widget child, ScrollableDetails details) { 12 | // When modifying this function, consider modifying the implementation in 13 | // the Material and Cupertino subclasses as well. 14 | switch (getPlatform(context)) { 15 | case TargetPlatform.linux: 16 | case TargetPlatform.macOS: 17 | case TargetPlatform.windows: 18 | return RawScrollbar( 19 | thickness: thinkness, 20 | isAlwaysShown: isAlwaysShow, 21 | child: child, 22 | controller: details.controller, 23 | ); 24 | default: 25 | return child; 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/widgets/slidable.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_slidable/flutter_slidable.dart'; 3 | 4 | class SlidableArchive extends StatelessWidget { 5 | final Widget? child; 6 | final VoidCallback? onArchive; 7 | SlidableArchive({this.child, this.onArchive}); 8 | 9 | @override 10 | Widget build(BuildContext context) { 11 | return Slidable( 12 | child: child!, 13 | actionPane: SlidableDrawerActionPane(), 14 | actionExtentRatio: 0.25, 15 | secondaryActions: [ 16 | IconSlideAction( 17 | caption: 'Archive', 18 | color: Theme.of(context).scaffoldBackgroundColor, 19 | icon: Icons.archive, 20 | onTap: onArchive 21 | ), 22 | ] 23 | ); 24 | } 25 | } 26 | 27 | class SlidableDelete extends StatelessWidget { 28 | final Key key; 29 | final Widget? child; 30 | final VoidCallback? onDelete; 31 | SlidableDelete({this.child, this.onDelete, required this.key}); 32 | 33 | @override 34 | Widget build(BuildContext context) { 35 | return Slidable( 36 | key: key, 37 | dismissal: SlidableDismissal( 38 | child: SlidableDrawerDismissal(), 39 | onDismissed: (actionType) { 40 | onDelete!(); 41 | }, 42 | dismissThresholds: { 43 | SlideActionType.secondary: 0.3 44 | }, 45 | ), 46 | child: child!, 47 | actionPane: SlidableDrawerActionPane(), 48 | actionExtentRatio: 0, 49 | secondaryActions: [ 50 | IconSlideAction( 51 | caption: 'Delete', 52 | color: Theme.of(context).scaffoldBackgroundColor, 53 | icon: Icons.delete, 54 | ), 55 | ] 56 | 57 | ); 58 | } 59 | } 60 | 61 | class SlidableArchiveDelete extends StatelessWidget { 62 | final Widget? child; 63 | final VoidCallback? onArchive; 64 | final VoidCallback? onDelete; 65 | SlidableArchiveDelete({this.child, this.onArchive, this.onDelete}); 66 | 67 | @override 68 | Widget build(BuildContext context) { 69 | final color = Theme.of(context).scaffoldBackgroundColor; 70 | return Slidable( 71 | child: child!, 72 | actionPane: SlidableDrawerActionPane(), 73 | actionExtentRatio: 0.25, 74 | secondaryActions: [ 75 | IconSlideAction( 76 | caption: 'Archive', 77 | color: color, 78 | icon: Icons.archive, 79 | onTap: onArchive 80 | ), 81 | IconSlideAction( 82 | caption: 'Delete', 83 | color: color, 84 | icon: Icons.delete, 85 | onTap: onDelete, 86 | ), 87 | ] 88 | ); 89 | } 90 | } -------------------------------------------------------------------------------- /lib/widgets/small_author_preview.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'package:habr_app/models/author.dart'; 4 | import 'package:habr_app/stores/avatar_color_store.dart'; 5 | 6 | import 'author_avatar_icon.dart'; 7 | 8 | class SmallAuthorPreview extends StatelessWidget { 9 | final Author author; 10 | final TextStyle? textStyle; 11 | 12 | SmallAuthorPreview(this.author, {this.textStyle}); 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | final themeData = Theme.of(context); 17 | return Row( 18 | crossAxisAlignment: CrossAxisAlignment.center, 19 | children: [ 20 | AuthorAvatarIcon( 21 | key: ValueKey('avatar_${author.avatar.hashCode}'), 22 | avatar: author.avatar, 23 | defaultColor: 24 | AvatarColorStore().getColor(author.alias, themeData.brightness), 25 | ), 26 | SizedBox( 27 | width: 5, 28 | ), 29 | Text(author.alias, style: textStyle), 30 | ], 31 | mainAxisSize: MainAxisSize.min, 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/widgets/statistics_icons.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:habr_app/utils/integer_to_text.dart'; 3 | 4 | typedef ValueToStringTransformer = String Function(int); 5 | 6 | class Statistics extends StatelessWidget { 7 | final Widget leading; 8 | final int value; 9 | final TextStyle? textStyle; 10 | final ValueToStringTransformer valueToStringTransformer; 11 | 12 | const Statistics.widget({ 13 | required this.value, 14 | required this.leading, 15 | this.textStyle, 16 | ValueToStringTransformer? valueTransformer, 17 | }) : valueToStringTransformer = valueTransformer ?? intToMetricPrefix; 18 | 19 | Statistics.icon({ 20 | required IconData iconData, 21 | required this.value, 22 | double size = 20, 23 | this.textStyle, 24 | ValueToStringTransformer? valueTransformer, 25 | }) : valueToStringTransformer = valueTransformer ?? intToMetricPrefix, 26 | leading = Icon(iconData, size: size, color: Colors.grey); 27 | 28 | @override 29 | Widget build(BuildContext context) { 30 | return Row(children: [ 31 | leading, 32 | SizedBox( 33 | width: 5, 34 | ), 35 | Text( 36 | valueToStringTransformer(value), 37 | style: textStyle, 38 | ), 39 | ]); 40 | } 41 | } 42 | 43 | class StatisticsFavoritesIcon extends StatelessWidget { 44 | final int favorites; 45 | final TextStyle? textStyle; 46 | 47 | StatisticsFavoritesIcon(this.favorites, {this.textStyle}); 48 | 49 | @override 50 | Widget build(BuildContext context) { 51 | return Statistics.icon( 52 | value: favorites, 53 | iconData: Icons.bookmark, 54 | textStyle: textStyle, 55 | ); 56 | } 57 | } 58 | 59 | class StatisticsScoreIcon extends StatelessWidget { 60 | final int score; 61 | final TextStyle? textStyle; 62 | 63 | Color scoreToColor(int score) { 64 | Color? color; 65 | switch (score.sign) { 66 | case -1: 67 | color = Colors.red[800]; 68 | break; 69 | case 0: 70 | color = Colors.grey[600]; 71 | break; 72 | case 1: 73 | color = Colors.green[800]; 74 | break; 75 | } 76 | return color!; 77 | } 78 | 79 | StatisticsScoreIcon(this.score, {this.textStyle}); 80 | 81 | @override 82 | Widget build(BuildContext context) { 83 | return Statistics.icon( 84 | iconData: Icons.equalizer, 85 | value: score, 86 | textStyle: textStyle!.copyWith(color: scoreToColor(score)), 87 | valueTransformer: (value) { 88 | String res = intToMetricPrefix(value); 89 | if (value > 0) res = '+' + res; 90 | return res; 91 | }, 92 | ); 93 | } 94 | } 95 | 96 | class StatisticsViewsIcon extends StatelessWidget { 97 | final int views; 98 | final TextStyle? textStyle; 99 | 100 | StatisticsViewsIcon(this.views, {this.textStyle}); 101 | 102 | @override 103 | Widget build(BuildContext context) { 104 | return Statistics.icon( 105 | iconData: Icons.remove_red_eye, 106 | value: views, 107 | textStyle: textStyle, 108 | ); 109 | } 110 | } 111 | 112 | class StatisticsCommentsIcon extends StatelessWidget { 113 | final int comments; 114 | final TextStyle? textStyle; 115 | 116 | StatisticsCommentsIcon(this.comments, {this.textStyle}); 117 | 118 | @override 119 | Widget build(BuildContext context) { 120 | return Statistics.icon( 121 | iconData: Icons.forum, 122 | value: comments, 123 | textStyle: textStyle, 124 | ); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /lib/widgets/widgets.dart: -------------------------------------------------------------------------------- 1 | export 'hide_floating_action_button.dart'; 2 | export 'html_view.dart'; 3 | export 'author_avatar_icon.dart'; 4 | export 'author_previews.dart'; 5 | export 'material_buttons.dart'; 6 | export 'routing/home_menu.dart'; 7 | export 'slidable.dart'; 8 | export 'statistics_icons.dart'; 9 | export 'article_preview.dart'; 10 | export 'load_builder.dart'; 11 | export 'picture.dart'; 12 | export 'circular_item.dart'; 13 | export 'hr.dart'; 14 | export 'articles_list_body.dart'; 15 | 16 | // local packages 17 | export 'informing/informing.dart'; 18 | export 'html_elements/html_elements.dart'; 19 | export 'adaptive_ui.dart'; 20 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: habr_app 2 | description: Habr application. 3 | 4 | publish_to: 'none' # Remove this line if you wish to publish to pub.dev 5 | 6 | version: 1.7.2+4 7 | 8 | environment: 9 | sdk: '>=2.12.0 <3.0.0' 10 | 11 | dependencies: 12 | flutter: 13 | 14 | sdk: flutter 15 | flutter_localizations: 16 | sdk: flutter 17 | 18 | intl: "^0.17.0" 19 | cupertino_icons: ^1.0.2 20 | http: ">=0.12.0" 21 | html: "^0.15.0" 22 | share: ^2.0.1 23 | flutter_highlight: "^0.7.0" 24 | either_dart: ^0.1.3 25 | flutter_svg: ^1.0.3 26 | flutter_math_fork: ^0.6.2 27 | url_launcher: "^6.0.9" 28 | hive: ^2.0.4 29 | hive_flutter: ^1.1.0 30 | flutter_slidable: 0.6.0 31 | photo_view: ^0.13.0 32 | path_provider: "^2.0.9" 33 | crypto: ">=2.1.5" 34 | provider: ^6.0.0 35 | itertools: ">=0.1.0" 36 | cached_network_image: ^3.2.0 37 | 38 | dependency_overrides: 39 | platform: ^3.1.0 40 | 41 | dev_dependencies: 42 | flutter_test: 43 | sdk: flutter 44 | 45 | flutter_launcher_icons: "^0.9.0" 46 | build_runner: ^2.0.1 47 | test: '>=1.14.0' 48 | 49 | 50 | flutter_icons: 51 | android: true 52 | ios: true 53 | remove_alpha_ios: true 54 | image_path: "assets/icon/icon.png" 55 | 56 | 57 | flutter: 58 | uses-material-design: true 59 | generate: true 60 | 61 | 62 | assets: 63 | - assets/images/ufo.png 64 | - assets/images/default_avatar.svg 65 | - assets/images/empty_comments.svg 66 | - assets/images/background.png 67 | - assets/images/lot_of_entropy.webp 68 | - assets/images/resolved.webp 69 | - assets/images/fatal_error.webp 70 | 71 | 72 | -------------------------------------------------------------------------------- /repo_images/gif_1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/repo_images/gif_1.gif -------------------------------------------------------------------------------- /repo_images/img_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/repo_images/img_1.jpg -------------------------------------------------------------------------------- /repo_images/img_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/repo_images/img_2.jpg -------------------------------------------------------------------------------- /repo_images/img_3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/repo_images/img_3.jpg -------------------------------------------------------------------------------- /repo_images/img_4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/repo_images/img_4.jpg -------------------------------------------------------------------------------- /repo_images/img_5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/repo_images/img_5.jpg -------------------------------------------------------------------------------- /repo_images/img_6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/repo_images/img_6.jpg -------------------------------------------------------------------------------- /test/theme_time_swap_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | import 'package:habr_app/stores/app_settings.dart'; 5 | 6 | void main() { 7 | group('to more than from', () { 8 | final from = TimeOfDay(hour: 10, minute: 0); 9 | final to = TimeOfDay(hour: 20, minute: 0); 10 | test('current equal from', () { 11 | expect( 12 | AppSettings.needSetLightTheme( 13 | TimeOfDay(hour: 10, minute: 0), from, to), 14 | true); 15 | }); 16 | test('current between from and to', () { 17 | expect( 18 | AppSettings.needSetLightTheme( 19 | TimeOfDay(hour: 10, minute: 1), from, to), 20 | true); 21 | expect( 22 | AppSettings.needSetLightTheme( 23 | TimeOfDay(hour: 11, minute: 0), from, to), 24 | true); 25 | }); 26 | test('current more to', () { 27 | expect( 28 | AppSettings.needSetLightTheme( 29 | TimeOfDay(hour: 20, minute: 1), from, to), 30 | false); 31 | }); 32 | test('current less from', () { 33 | expect( 34 | AppSettings.needSetLightTheme( 35 | TimeOfDay(hour: 9, minute: 59), from, to), 36 | false); 37 | }); 38 | }); 39 | group('from more than to', () { 40 | final from = TimeOfDay(hour: 20, minute: 0); 41 | final to = TimeOfDay(hour: 10, minute: 0); 42 | test('current equal from', () { 43 | expect( 44 | AppSettings.needSetLightTheme( 45 | TimeOfDay(hour: 20, minute: 0), from, to), 46 | true); 47 | }); 48 | test('current between from and to', () { 49 | expect( 50 | AppSettings.needSetLightTheme( 51 | TimeOfDay(hour: 20, minute: 1), from, to), 52 | true); 53 | expect( 54 | AppSettings.needSetLightTheme( 55 | TimeOfDay(hour: 21, minute: 0), from, to), 56 | true); 57 | expect( 58 | AppSettings.needSetLightTheme( 59 | TimeOfDay(hour: 0, minute: 1), from, to), 60 | true); 61 | expect( 62 | AppSettings.needSetLightTheme( 63 | TimeOfDay(hour: 9, minute: 59), from, to), 64 | true); 65 | }); 66 | test('current more to', () { 67 | expect( 68 | AppSettings.needSetLightTheme( 69 | TimeOfDay(hour: 10, minute: 1), from, to), 70 | false); 71 | expect( 72 | AppSettings.needSetLightTheme( 73 | TimeOfDay(hour: 19, minute: 59), from, to), 74 | false); 75 | }); 76 | }); 77 | } 78 | -------------------------------------------------------------------------------- /web/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/web/favicon.png -------------------------------------------------------------------------------- /web/icons/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/web/icons/icon-192.png -------------------------------------------------------------------------------- /web/icons/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/web/icons/icon-512.png -------------------------------------------------------------------------------- /web/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "habr_app", 3 | "short_name": "habr_app", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "background_color": "#0175C2", 7 | "theme_color": "#0175C2", 8 | "description": "A new Flutter project.", 9 | "orientation": "portrait-primary", 10 | "prefer_related_applications": false, 11 | "icons": [ 12 | { 13 | "src": "icons/Icon-192.png", 14 | "sizes": "192x192", 15 | "type": "image/png" 16 | }, 17 | { 18 | "src": "icons/Icon-512.png", 19 | "sizes": "512x512", 20 | "type": "image/png" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /windows/.gitignore: -------------------------------------------------------------------------------- 1 | flutter 2 | 3 | # Visual Studio user-specific files. 4 | *.suo 5 | *.user 6 | *.userosscache 7 | *.sln.docstates 8 | 9 | # Visual Studio build-related files. 10 | x64/ 11 | x86/ 12 | 13 | # Visual Studio cache files 14 | # files ending in .cache can be ignored 15 | *.[Cc]ache 16 | # but keep track of directories ending in .cache 17 | !*.[Cc]ache/ 18 | -------------------------------------------------------------------------------- /windows/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.15) 2 | project(habr_app LANGUAGES CXX) 3 | 4 | set(BINARY_NAME "habr_app") 5 | 6 | cmake_policy(SET CMP0063 NEW) 7 | 8 | set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") 9 | 10 | # Configure build options. 11 | get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) 12 | if(IS_MULTICONFIG) 13 | set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" 14 | CACHE STRING "" FORCE) 15 | else() 16 | if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) 17 | set(CMAKE_BUILD_TYPE "Debug" CACHE 18 | STRING "Flutter build mode" FORCE) 19 | set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS 20 | "Debug" "Profile" "Release") 21 | endif() 22 | endif() 23 | 24 | set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") 25 | set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") 26 | set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") 27 | set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") 28 | 29 | # Use Unicode for all projects. 30 | add_definitions(-DUNICODE -D_UNICODE) 31 | 32 | # Compilation settings that should be applied to most targets. 33 | function(APPLY_STANDARD_SETTINGS TARGET) 34 | target_compile_features(${TARGET} PUBLIC cxx_std_17) 35 | target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") 36 | target_compile_options(${TARGET} PRIVATE /EHsc) 37 | target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") 38 | target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") 39 | endfunction() 40 | 41 | set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") 42 | 43 | # Flutter library and tool build rules. 44 | add_subdirectory(${FLUTTER_MANAGED_DIR}) 45 | 46 | # Application build 47 | add_subdirectory("runner") 48 | 49 | # Generated plugin build rules, which manage building the plugins and adding 50 | # them to the application. 51 | include(flutter/generated_plugins.cmake) 52 | 53 | 54 | # === Installation === 55 | # Support files are copied into place next to the executable, so that it can 56 | # run in place. This is done instead of making a separate bundle (as on Linux) 57 | # so that building and running from within Visual Studio will work. 58 | set(BUILD_BUNDLE_DIR "$") 59 | # Make the "install" step default, as it's required to run. 60 | set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) 61 | if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) 62 | set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) 63 | endif() 64 | 65 | set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") 66 | set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") 67 | 68 | install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" 69 | COMPONENT Runtime) 70 | 71 | install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" 72 | COMPONENT Runtime) 73 | 74 | install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" 75 | COMPONENT Runtime) 76 | 77 | if(PLUGIN_BUNDLED_LIBRARIES) 78 | install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" 79 | DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" 80 | COMPONENT Runtime) 81 | endif() 82 | 83 | # Fully re-copy the assets directory on each build to avoid having stale files 84 | # from a previous install. 85 | set(FLUTTER_ASSET_DIR_NAME "flutter_assets") 86 | install(CODE " 87 | file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") 88 | " COMPONENT Runtime) 89 | install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" 90 | DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) 91 | 92 | # Install the AOT library on non-Debug builds only. 93 | install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" 94 | CONFIGURATIONS Profile;Release 95 | COMPONENT Runtime) 96 | -------------------------------------------------------------------------------- /windows/flutter/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.15) 2 | 3 | set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") 4 | 5 | # Configuration provided via flutter tool. 6 | include(${EPHEMERAL_DIR}/generated_config.cmake) 7 | 8 | # TODO: Move the rest of this into files in ephemeral. See 9 | # https://github.com/flutter/flutter/issues/57146. 10 | set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") 11 | 12 | # === Flutter Library === 13 | set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") 14 | 15 | # Published to parent scope for install step. 16 | set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) 17 | set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) 18 | set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) 19 | set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) 20 | 21 | list(APPEND FLUTTER_LIBRARY_HEADERS 22 | "flutter_export.h" 23 | "flutter_windows.h" 24 | "flutter_messenger.h" 25 | "flutter_plugin_registrar.h" 26 | "flutter_texture_registrar.h" 27 | ) 28 | list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") 29 | add_library(flutter INTERFACE) 30 | target_include_directories(flutter INTERFACE 31 | "${EPHEMERAL_DIR}" 32 | ) 33 | target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") 34 | add_dependencies(flutter flutter_assemble) 35 | 36 | # === Wrapper === 37 | list(APPEND CPP_WRAPPER_SOURCES_CORE 38 | "core_implementations.cc" 39 | "standard_codec.cc" 40 | ) 41 | list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") 42 | list(APPEND CPP_WRAPPER_SOURCES_PLUGIN 43 | "plugin_registrar.cc" 44 | ) 45 | list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") 46 | list(APPEND CPP_WRAPPER_SOURCES_APP 47 | "flutter_engine.cc" 48 | "flutter_view_controller.cc" 49 | ) 50 | list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") 51 | 52 | # Wrapper sources needed for a plugin. 53 | add_library(flutter_wrapper_plugin STATIC 54 | ${CPP_WRAPPER_SOURCES_CORE} 55 | ${CPP_WRAPPER_SOURCES_PLUGIN} 56 | ) 57 | apply_standard_settings(flutter_wrapper_plugin) 58 | set_target_properties(flutter_wrapper_plugin PROPERTIES 59 | POSITION_INDEPENDENT_CODE ON) 60 | set_target_properties(flutter_wrapper_plugin PROPERTIES 61 | CXX_VISIBILITY_PRESET hidden) 62 | target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) 63 | target_include_directories(flutter_wrapper_plugin PUBLIC 64 | "${WRAPPER_ROOT}/include" 65 | ) 66 | add_dependencies(flutter_wrapper_plugin flutter_assemble) 67 | 68 | # Wrapper sources needed for the runner. 69 | add_library(flutter_wrapper_app STATIC 70 | ${CPP_WRAPPER_SOURCES_CORE} 71 | ${CPP_WRAPPER_SOURCES_APP} 72 | ) 73 | apply_standard_settings(flutter_wrapper_app) 74 | target_link_libraries(flutter_wrapper_app PUBLIC flutter) 75 | target_include_directories(flutter_wrapper_app PUBLIC 76 | "${WRAPPER_ROOT}/include" 77 | ) 78 | add_dependencies(flutter_wrapper_app flutter_assemble) 79 | 80 | # === Flutter tool backend === 81 | # _phony_ is a non-existent file to force this command to run every time, 82 | # since currently there's no way to get a full input/output list from the 83 | # flutter tool. 84 | set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") 85 | set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) 86 | add_custom_command( 87 | OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} 88 | ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} 89 | ${CPP_WRAPPER_SOURCES_APP} 90 | ${PHONY_OUTPUT} 91 | COMMAND ${CMAKE_COMMAND} -E env 92 | ${FLUTTER_TOOL_ENVIRONMENT} 93 | "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" 94 | windows-x64 $ 95 | VERBATIM 96 | ) 97 | add_custom_target(flutter_assemble DEPENDS 98 | "${FLUTTER_LIBRARY}" 99 | ${FLUTTER_LIBRARY_HEADERS} 100 | ${CPP_WRAPPER_SOURCES_CORE} 101 | ${CPP_WRAPPER_SOURCES_PLUGIN} 102 | ${CPP_WRAPPER_SOURCES_APP} 103 | ) 104 | -------------------------------------------------------------------------------- /windows/flutter/generated_plugin_registrant.cc: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | // clang-format off 6 | 7 | #include "generated_plugin_registrant.h" 8 | 9 | #include 10 | 11 | void RegisterPlugins(flutter::PluginRegistry* registry) { 12 | UrlLauncherWindowsRegisterWithRegistrar( 13 | registry->GetRegistrarForPlugin("UrlLauncherWindows")); 14 | } 15 | -------------------------------------------------------------------------------- /windows/flutter/generated_plugin_registrant.h: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | // clang-format off 6 | 7 | #ifndef GENERATED_PLUGIN_REGISTRANT_ 8 | #define GENERATED_PLUGIN_REGISTRANT_ 9 | 10 | #include 11 | 12 | // Registers Flutter plugins. 13 | void RegisterPlugins(flutter::PluginRegistry* registry); 14 | 15 | #endif // GENERATED_PLUGIN_REGISTRANT_ 16 | -------------------------------------------------------------------------------- /windows/flutter/generated_plugins.cmake: -------------------------------------------------------------------------------- 1 | # 2 | # Generated file, do not edit. 3 | # 4 | 5 | list(APPEND FLUTTER_PLUGIN_LIST 6 | url_launcher_windows 7 | ) 8 | 9 | set(PLUGIN_BUNDLED_LIBRARIES) 10 | 11 | foreach(plugin ${FLUTTER_PLUGIN_LIST}) 12 | add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) 13 | target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) 14 | list(APPEND PLUGIN_BUNDLED_LIBRARIES $) 15 | list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) 16 | endforeach(plugin) 17 | -------------------------------------------------------------------------------- /windows/runner/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.15) 2 | project(runner LANGUAGES CXX) 3 | 4 | add_executable(${BINARY_NAME} WIN32 5 | "flutter_window.cpp" 6 | "main.cpp" 7 | "run_loop.cpp" 8 | "utils.cpp" 9 | "win32_window.cpp" 10 | "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" 11 | "Runner.rc" 12 | "runner.exe.manifest" 13 | ) 14 | apply_standard_settings(${BINARY_NAME}) 15 | target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") 16 | target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) 17 | target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") 18 | add_dependencies(${BINARY_NAME} flutter_assemble) 19 | -------------------------------------------------------------------------------- /windows/runner/Runner.rc: -------------------------------------------------------------------------------- 1 | // Microsoft Visual C++ generated resource script. 2 | // 3 | #pragma code_page(65001) 4 | #include "resource.h" 5 | 6 | #define APSTUDIO_READONLY_SYMBOLS 7 | ///////////////////////////////////////////////////////////////////////////// 8 | // 9 | // Generated from the TEXTINCLUDE 2 resource. 10 | // 11 | #include "winres.h" 12 | 13 | ///////////////////////////////////////////////////////////////////////////// 14 | #undef APSTUDIO_READONLY_SYMBOLS 15 | 16 | ///////////////////////////////////////////////////////////////////////////// 17 | // English (United States) resources 18 | 19 | #if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) 20 | LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US 21 | 22 | #ifdef APSTUDIO_INVOKED 23 | ///////////////////////////////////////////////////////////////////////////// 24 | // 25 | // TEXTINCLUDE 26 | // 27 | 28 | 1 TEXTINCLUDE 29 | BEGIN 30 | "resource.h\0" 31 | END 32 | 33 | 2 TEXTINCLUDE 34 | BEGIN 35 | "#include ""winres.h""\r\n" 36 | "\0" 37 | END 38 | 39 | 3 TEXTINCLUDE 40 | BEGIN 41 | "\r\n" 42 | "\0" 43 | END 44 | 45 | #endif // APSTUDIO_INVOKED 46 | 47 | 48 | ///////////////////////////////////////////////////////////////////////////// 49 | // 50 | // Icon 51 | // 52 | 53 | // Icon with lowest ID value placed first to ensure application icon 54 | // remains consistent on all systems. 55 | IDI_APP_ICON ICON "resources\\app_icon.ico" 56 | 57 | 58 | ///////////////////////////////////////////////////////////////////////////// 59 | // 60 | // Version 61 | // 62 | 63 | #ifdef FLUTTER_BUILD_NUMBER 64 | #define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER 65 | #else 66 | #define VERSION_AS_NUMBER 1,0,0 67 | #endif 68 | 69 | #ifdef FLUTTER_BUILD_NAME 70 | #define VERSION_AS_STRING #FLUTTER_BUILD_NAME 71 | #else 72 | #define VERSION_AS_STRING "1.0.0" 73 | #endif 74 | 75 | VS_VERSION_INFO VERSIONINFO 76 | FILEVERSION VERSION_AS_NUMBER 77 | PRODUCTVERSION VERSION_AS_NUMBER 78 | FILEFLAGSMASK VS_FFI_FILEFLAGSMASK 79 | #ifdef _DEBUG 80 | FILEFLAGS VS_FF_DEBUG 81 | #else 82 | FILEFLAGS 0x0L 83 | #endif 84 | FILEOS VOS__WINDOWS32 85 | FILETYPE VFT_APP 86 | FILESUBTYPE 0x0L 87 | BEGIN 88 | BLOCK "StringFileInfo" 89 | BEGIN 90 | BLOCK "040904e4" 91 | BEGIN 92 | VALUE "CompanyName", "habrapp" "\0" 93 | VALUE "FileDescription", "A new Flutter project." "\0" 94 | VALUE "FileVersion", VERSION_AS_STRING "\0" 95 | VALUE "InternalName", "habr_app" "\0" 96 | VALUE "LegalCopyright", "Copyright (C) 2021 habrapp. All rights reserved." "\0" 97 | VALUE "OriginalFilename", "habr_app.exe" "\0" 98 | VALUE "ProductName", "habr_app" "\0" 99 | VALUE "ProductVersion", VERSION_AS_STRING "\0" 100 | END 101 | END 102 | BLOCK "VarFileInfo" 103 | BEGIN 104 | VALUE "Translation", 0x409, 1252 105 | END 106 | END 107 | 108 | #endif // English (United States) resources 109 | ///////////////////////////////////////////////////////////////////////////// 110 | 111 | 112 | 113 | #ifndef APSTUDIO_INVOKED 114 | ///////////////////////////////////////////////////////////////////////////// 115 | // 116 | // Generated from the TEXTINCLUDE 3 resource. 117 | // 118 | 119 | 120 | ///////////////////////////////////////////////////////////////////////////// 121 | #endif // not APSTUDIO_INVOKED 122 | -------------------------------------------------------------------------------- /windows/runner/flutter_window.cpp: -------------------------------------------------------------------------------- 1 | #include "flutter_window.h" 2 | 3 | #include 4 | 5 | #include "flutter/generated_plugin_registrant.h" 6 | 7 | FlutterWindow::FlutterWindow(RunLoop* run_loop, 8 | const flutter::DartProject& project) 9 | : run_loop_(run_loop), project_(project) {} 10 | 11 | FlutterWindow::~FlutterWindow() {} 12 | 13 | bool FlutterWindow::OnCreate() { 14 | if (!Win32Window::OnCreate()) { 15 | return false; 16 | } 17 | 18 | RECT frame = GetClientArea(); 19 | 20 | // The size here must match the window dimensions to avoid unnecessary surface 21 | // creation / destruction in the startup path. 22 | flutter_controller_ = std::make_unique( 23 | frame.right - frame.left, frame.bottom - frame.top, project_); 24 | // Ensure that basic setup of the controller was successful. 25 | if (!flutter_controller_->engine() || !flutter_controller_->view()) { 26 | return false; 27 | } 28 | RegisterPlugins(flutter_controller_->engine()); 29 | run_loop_->RegisterFlutterInstance(flutter_controller_->engine()); 30 | SetChildContent(flutter_controller_->view()->GetNativeWindow()); 31 | return true; 32 | } 33 | 34 | void FlutterWindow::OnDestroy() { 35 | if (flutter_controller_) { 36 | run_loop_->UnregisterFlutterInstance(flutter_controller_->engine()); 37 | flutter_controller_ = nullptr; 38 | } 39 | 40 | Win32Window::OnDestroy(); 41 | } 42 | 43 | LRESULT 44 | FlutterWindow::MessageHandler(HWND hwnd, UINT const message, 45 | WPARAM const wparam, 46 | LPARAM const lparam) noexcept { 47 | // Give Flutter, including plugins, an opportunity to handle window messages. 48 | if (flutter_controller_) { 49 | std::optional result = 50 | flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, 51 | lparam); 52 | if (result) { 53 | return *result; 54 | } 55 | } 56 | 57 | switch (message) { 58 | case WM_FONTCHANGE: 59 | flutter_controller_->engine()->ReloadSystemFonts(); 60 | break; 61 | } 62 | 63 | return Win32Window::MessageHandler(hwnd, message, wparam, lparam); 64 | } 65 | -------------------------------------------------------------------------------- /windows/runner/flutter_window.h: -------------------------------------------------------------------------------- 1 | #ifndef RUNNER_FLUTTER_WINDOW_H_ 2 | #define RUNNER_FLUTTER_WINDOW_H_ 3 | 4 | #include 5 | #include 6 | 7 | #include 8 | 9 | #include "run_loop.h" 10 | #include "win32_window.h" 11 | 12 | // A window that does nothing but host a Flutter view. 13 | class FlutterWindow : public Win32Window { 14 | public: 15 | // Creates a new FlutterWindow driven by the |run_loop|, hosting a 16 | // Flutter view running |project|. 17 | explicit FlutterWindow(RunLoop* run_loop, 18 | const flutter::DartProject& project); 19 | virtual ~FlutterWindow(); 20 | 21 | protected: 22 | // Win32Window: 23 | bool OnCreate() override; 24 | void OnDestroy() override; 25 | LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, 26 | LPARAM const lparam) noexcept override; 27 | 28 | private: 29 | // The run loop driving events for this window. 30 | RunLoop* run_loop_; 31 | 32 | // The project to run. 33 | flutter::DartProject project_; 34 | 35 | // The Flutter instance hosted by this window. 36 | std::unique_ptr flutter_controller_; 37 | }; 38 | 39 | #endif // RUNNER_FLUTTER_WINDOW_H_ 40 | -------------------------------------------------------------------------------- /windows/runner/main.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #include "flutter_window.h" 6 | #include "run_loop.h" 7 | #include "utils.h" 8 | 9 | int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, 10 | _In_ wchar_t *command_line, _In_ int show_command) { 11 | // Attach to console when present (e.g., 'flutter run') or create a 12 | // new console when running with a debugger. 13 | if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { 14 | CreateAndAttachConsole(); 15 | } 16 | 17 | // Initialize COM, so that it is available for use in the library and/or 18 | // plugins. 19 | ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); 20 | 21 | RunLoop run_loop; 22 | 23 | flutter::DartProject project(L"data"); 24 | 25 | std::vector command_line_arguments = 26 | GetCommandLineArguments(); 27 | 28 | project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); 29 | 30 | FlutterWindow window(&run_loop, project); 31 | Win32Window::Point origin(10, 10); 32 | Win32Window::Size size(1280, 720); 33 | if (!window.CreateAndShow(L"habr_app", origin, size)) { 34 | return EXIT_FAILURE; 35 | } 36 | window.SetQuitOnClose(true); 37 | 38 | run_loop.Run(); 39 | 40 | ::CoUninitialize(); 41 | return EXIT_SUCCESS; 42 | } 43 | -------------------------------------------------------------------------------- /windows/runner/resource.h: -------------------------------------------------------------------------------- 1 | //{{NO_DEPENDENCIES}} 2 | // Microsoft Visual C++ generated include file. 3 | // Used by Runner.rc 4 | // 5 | #define IDI_APP_ICON 101 6 | 7 | // Next default values for new objects 8 | // 9 | #ifdef APSTUDIO_INVOKED 10 | #ifndef APSTUDIO_READONLY_SYMBOLS 11 | #define _APS_NEXT_RESOURCE_VALUE 102 12 | #define _APS_NEXT_COMMAND_VALUE 40001 13 | #define _APS_NEXT_CONTROL_VALUE 1001 14 | #define _APS_NEXT_SYMED_VALUE 101 15 | #endif 16 | #endif 17 | -------------------------------------------------------------------------------- /windows/runner/resources/app_icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdosev/habr_app/1efc2308126c8dfe836791a4bd92cfa9afee6f06/windows/runner/resources/app_icon.ico -------------------------------------------------------------------------------- /windows/runner/run_loop.cpp: -------------------------------------------------------------------------------- 1 | #include "run_loop.h" 2 | 3 | #include 4 | 5 | #include 6 | 7 | RunLoop::RunLoop() {} 8 | 9 | RunLoop::~RunLoop() {} 10 | 11 | void RunLoop::Run() { 12 | bool keep_running = true; 13 | TimePoint next_flutter_event_time = TimePoint::clock::now(); 14 | while (keep_running) { 15 | std::chrono::nanoseconds wait_duration = 16 | std::max(std::chrono::nanoseconds(0), 17 | next_flutter_event_time - TimePoint::clock::now()); 18 | ::MsgWaitForMultipleObjects( 19 | 0, nullptr, FALSE, static_cast(wait_duration.count() / 1000), 20 | QS_ALLINPUT); 21 | bool processed_events = false; 22 | MSG message; 23 | // All pending Windows messages must be processed; MsgWaitForMultipleObjects 24 | // won't return again for items left in the queue after PeekMessage. 25 | while (::GetMessage(&message, nullptr, 0, 0)) { 26 | processed_events = true; 27 | if (message.message == WM_QUIT) { 28 | keep_running = false; 29 | break; 30 | } 31 | ::TranslateMessage(&message); 32 | ::DispatchMessage(&message); 33 | // Allow Flutter to process messages each time a Windows message is 34 | // processed, to prevent starvation. 35 | next_flutter_event_time = 36 | std::min(next_flutter_event_time, ProcessFlutterMessages()); 37 | } 38 | // If the PeekMessage loop didn't run, process Flutter messages. 39 | if (!processed_events) { 40 | next_flutter_event_time = 41 | std::min(next_flutter_event_time, ProcessFlutterMessages()); 42 | } 43 | } 44 | } 45 | 46 | void RunLoop::RegisterFlutterInstance( 47 | flutter::FlutterEngine* flutter_instance) { 48 | flutter_instances_.insert(flutter_instance); 49 | } 50 | 51 | void RunLoop::UnregisterFlutterInstance( 52 | flutter::FlutterEngine* flutter_instance) { 53 | flutter_instances_.erase(flutter_instance); 54 | } 55 | 56 | RunLoop::TimePoint RunLoop::ProcessFlutterMessages() { 57 | TimePoint next_event_time = TimePoint::max(); 58 | for (auto instance : flutter_instances_) { 59 | std::chrono::nanoseconds wait_duration = instance->ProcessMessages(); 60 | if (wait_duration != std::chrono::nanoseconds::max()) { 61 | next_event_time = 62 | std::min(next_event_time, TimePoint::clock::now() + wait_duration); 63 | } 64 | } 65 | return next_event_time; 66 | } 67 | -------------------------------------------------------------------------------- /windows/runner/run_loop.h: -------------------------------------------------------------------------------- 1 | #ifndef RUNNER_RUN_LOOP_H_ 2 | #define RUNNER_RUN_LOOP_H_ 3 | 4 | #include 5 | 6 | #include 7 | #include 8 | 9 | // A runloop that will service events for Flutter instances as well 10 | // as native messages. 11 | class RunLoop { 12 | public: 13 | RunLoop(); 14 | ~RunLoop(); 15 | 16 | // Prevent copying 17 | RunLoop(RunLoop const&) = delete; 18 | RunLoop& operator=(RunLoop const&) = delete; 19 | 20 | // Runs the run loop until the application quits. 21 | void Run(); 22 | 23 | // Registers the given Flutter instance for event servicing. 24 | void RegisterFlutterInstance( 25 | flutter::FlutterEngine* flutter_instance); 26 | 27 | // Unregisters the given Flutter instance from event servicing. 28 | void UnregisterFlutterInstance( 29 | flutter::FlutterEngine* flutter_instance); 30 | 31 | private: 32 | using TimePoint = std::chrono::steady_clock::time_point; 33 | 34 | // Processes all currently pending messages for registered Flutter instances. 35 | TimePoint ProcessFlutterMessages(); 36 | 37 | std::set flutter_instances_; 38 | }; 39 | 40 | #endif // RUNNER_RUN_LOOP_H_ 41 | -------------------------------------------------------------------------------- /windows/runner/runner.exe.manifest: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PerMonitorV2 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /windows/runner/utils.cpp: -------------------------------------------------------------------------------- 1 | #include "utils.h" 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #include 9 | 10 | void CreateAndAttachConsole() { 11 | if (::AllocConsole()) { 12 | FILE *unused; 13 | if (freopen_s(&unused, "CONOUT$", "w", stdout)) { 14 | _dup2(_fileno(stdout), 1); 15 | } 16 | if (freopen_s(&unused, "CONOUT$", "w", stderr)) { 17 | _dup2(_fileno(stdout), 2); 18 | } 19 | std::ios::sync_with_stdio(); 20 | FlutterDesktopResyncOutputStreams(); 21 | } 22 | } 23 | 24 | std::vector GetCommandLineArguments() { 25 | // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. 26 | int argc; 27 | wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); 28 | if (argv == nullptr) { 29 | return std::vector(); 30 | } 31 | 32 | std::vector command_line_arguments; 33 | 34 | // Skip the first argument as it's the binary name. 35 | for (int i = 1; i < argc; i++) { 36 | command_line_arguments.push_back(Utf8FromUtf16(argv[i])); 37 | } 38 | 39 | ::LocalFree(argv); 40 | 41 | return command_line_arguments; 42 | } 43 | 44 | std::string Utf8FromUtf16(const wchar_t* utf16_string) { 45 | if (utf16_string == nullptr) { 46 | return std::string(); 47 | } 48 | int target_length = ::WideCharToMultiByte( 49 | CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, 50 | -1, nullptr, 0, nullptr, nullptr); 51 | if (target_length == 0) { 52 | return std::string(); 53 | } 54 | std::string utf8_string; 55 | utf8_string.resize(target_length); 56 | int converted_length = ::WideCharToMultiByte( 57 | CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, 58 | -1, utf8_string.data(), 59 | target_length, nullptr, nullptr); 60 | if (converted_length == 0) { 61 | return std::string(); 62 | } 63 | return utf8_string; 64 | } 65 | -------------------------------------------------------------------------------- /windows/runner/utils.h: -------------------------------------------------------------------------------- 1 | #ifndef RUNNER_UTILS_H_ 2 | #define RUNNER_UTILS_H_ 3 | 4 | #include 5 | #include 6 | 7 | // Creates a console for the process, and redirects stdout and stderr to 8 | // it for both the runner and the Flutter library. 9 | void CreateAndAttachConsole(); 10 | 11 | // Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string 12 | // encoded in UTF-8. Returns an empty std::string on failure. 13 | std::string Utf8FromUtf16(const wchar_t* utf16_string); 14 | 15 | // Gets the command line arguments passed in as a std::vector, 16 | // encoded in UTF-8. Returns an empty std::vector on failure. 17 | std::vector GetCommandLineArguments(); 18 | 19 | #endif // RUNNER_UTILS_H_ 20 | -------------------------------------------------------------------------------- /windows/runner/win32_window.h: -------------------------------------------------------------------------------- 1 | #ifndef RUNNER_WIN32_WINDOW_H_ 2 | #define RUNNER_WIN32_WINDOW_H_ 3 | 4 | #include 5 | 6 | #include 7 | #include 8 | #include 9 | 10 | // A class abstraction for a high DPI-aware Win32 Window. Intended to be 11 | // inherited from by classes that wish to specialize with custom 12 | // rendering and input handling 13 | class Win32Window { 14 | public: 15 | struct Point { 16 | unsigned int x; 17 | unsigned int y; 18 | Point(unsigned int x, unsigned int y) : x(x), y(y) {} 19 | }; 20 | 21 | struct Size { 22 | unsigned int width; 23 | unsigned int height; 24 | Size(unsigned int width, unsigned int height) 25 | : width(width), height(height) {} 26 | }; 27 | 28 | Win32Window(); 29 | virtual ~Win32Window(); 30 | 31 | // Creates and shows a win32 window with |title| and position and size using 32 | // |origin| and |size|. New windows are created on the default monitor. Window 33 | // sizes are specified to the OS in physical pixels, hence to ensure a 34 | // consistent size to will treat the width height passed in to this function 35 | // as logical pixels and scale to appropriate for the default monitor. Returns 36 | // true if the window was created successfully. 37 | bool CreateAndShow(const std::wstring& title, 38 | const Point& origin, 39 | const Size& size); 40 | 41 | // Release OS resources associated with window. 42 | void Destroy(); 43 | 44 | // Inserts |content| into the window tree. 45 | void SetChildContent(HWND content); 46 | 47 | // Returns the backing Window handle to enable clients to set icon and other 48 | // window properties. Returns nullptr if the window has been destroyed. 49 | HWND GetHandle(); 50 | 51 | // If true, closing this window will quit the application. 52 | void SetQuitOnClose(bool quit_on_close); 53 | 54 | // Return a RECT representing the bounds of the current client area. 55 | RECT GetClientArea(); 56 | 57 | protected: 58 | // Processes and route salient window messages for mouse handling, 59 | // size change and DPI. Delegates handling of these to member overloads that 60 | // inheriting classes can handle. 61 | virtual LRESULT MessageHandler(HWND window, 62 | UINT const message, 63 | WPARAM const wparam, 64 | LPARAM const lparam) noexcept; 65 | 66 | // Called when CreateAndShow is called, allowing subclass window-related 67 | // setup. Subclasses should return false if setup fails. 68 | virtual bool OnCreate(); 69 | 70 | // Called when Destroy is called. 71 | virtual void OnDestroy(); 72 | 73 | private: 74 | friend class WindowClassRegistrar; 75 | 76 | // OS callback called by message pump. Handles the WM_NCCREATE message which 77 | // is passed when the non-client area is being created and enables automatic 78 | // non-client DPI scaling so that the non-client area automatically 79 | // responsponds to changes in DPI. All other messages are handled by 80 | // MessageHandler. 81 | static LRESULT CALLBACK WndProc(HWND const window, 82 | UINT const message, 83 | WPARAM const wparam, 84 | LPARAM const lparam) noexcept; 85 | 86 | // Retrieves a class instance pointer for |window| 87 | static Win32Window* GetThisFromHandle(HWND const window) noexcept; 88 | 89 | bool quit_on_close_ = false; 90 | 91 | // window handle for top level window. 92 | HWND window_handle_ = nullptr; 93 | 94 | // window handle for hosted content. 95 | HWND child_content_ = nullptr; 96 | }; 97 | 98 | #endif // RUNNER_WIN32_WINDOW_H_ 99 | --------------------------------------------------------------------------------