├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── ---bug-report.md │ └── ---feature-request.md └── workflows │ ├── ci.yml │ ├── release.yml │ └── weblate.yml ├── .gitignore ├── .metadata ├── .vscode ├── launch.json ├── lemmur.code-snippets ├── settings.json └── tasks.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── PRIVACY_POLICY.md ├── README.md ├── analysis_options.yaml ├── android ├── .gitignore ├── app │ ├── build.gradle │ └── src │ │ ├── debug │ │ └── AndroidManifest.xml │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── krawieck │ │ │ │ └── lemmur │ │ │ │ └── MainActivity.kt │ │ └── res │ │ │ ├── drawable-hdpi │ │ │ ├── ic_launcher_foreground.png │ │ │ └── splash.png │ │ │ ├── drawable-mdpi │ │ │ ├── ic_launcher_foreground.png │ │ │ └── splash.png │ │ │ ├── drawable-xhdpi │ │ │ ├── ic_launcher_foreground.png │ │ │ └── splash.png │ │ │ ├── drawable-xxhdpi │ │ │ ├── ic_launcher_foreground.png │ │ │ └── splash.png │ │ │ ├── drawable-xxxhdpi │ │ │ ├── ic_launcher_foreground.png │ │ │ └── splash.png │ │ │ ├── drawable │ │ │ └── splash_screen.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ └── ic_launcher.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 │ │ │ └── colors.xml │ │ │ └── values │ │ │ ├── colors.xml │ │ │ └── styles.xml │ │ └── profile │ │ └── AndroidManifest.xml ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties └── settings.gradle ├── assets ├── adaptive_icon_fg.png ├── app_icon.png ├── app_icon.svg ├── l10n │ ├── intl_ar.arb │ ├── intl_bg.arb │ ├── intl_bn.arb │ ├── intl_ca.arb │ ├── intl_cs.arb │ ├── intl_cy.arb │ ├── intl_da.arb │ ├── intl_de.arb │ ├── intl_el.arb │ ├── intl_en.arb │ ├── intl_eo.arb │ ├── intl_es.arb │ ├── intl_eu.arb │ ├── intl_fa.arb │ ├── intl_fi.arb │ ├── intl_fr.arb │ ├── intl_ga.arb │ ├── intl_gl.arb │ ├── intl_hi.arb │ ├── intl_hr.arb │ ├── intl_hu.arb │ ├── intl_id.arb │ ├── intl_it.arb │ ├── intl_ja.arb │ ├── intl_ka.arb │ ├── intl_km.arb │ ├── intl_ko.arb │ ├── intl_ml.arb │ ├── intl_mnc.arb │ ├── intl_nb.arb │ ├── intl_nl.arb │ ├── intl_oc.arb │ ├── intl_pl.arb │ ├── intl_pt.arb │ ├── intl_pt_BR.arb │ ├── intl_ru.arb │ ├── intl_sk.arb │ ├── intl_sq.arb │ ├── intl_sr.arb │ ├── intl_sr_Latn.arb │ ├── intl_sv.arb │ ├── intl_th.arb │ ├── intl_tr.arb │ ├── intl_uk.arb │ ├── intl_vi.arb │ ├── intl_zh.arb │ └── intl_zh_Hant.arb └── readme_icon.svg ├── fastlane ├── Fastfile ├── Gemfile ├── Gemfile.lock ├── README.md └── metadata │ └── android │ ├── de-DE │ ├── full_description.txt │ ├── short_description.txt │ └── title.txt │ └── en-US │ ├── changelogs │ ├── 11.txt │ ├── 12.txt │ ├── 13.txt │ ├── 14.txt │ ├── 15.txt │ ├── 16.txt │ ├── 17.txt │ ├── 18.txt │ └── 19.txt │ ├── full_description.txt │ ├── images │ ├── featureGraphic.png │ ├── icon.png │ └── phoneScreenshots │ │ ├── 1.png │ │ ├── 2.png │ │ ├── 3.png │ │ ├── 4.png │ │ ├── 5.png │ │ └── 6.png │ ├── short_description.txt │ └── title.txt ├── ios ├── .gitignore ├── Flutter │ ├── .last_build_id │ ├── AppFrameworkInfo.plist │ ├── Debug.xcconfig │ └── Release.xcconfig ├── Podfile ├── Podfile.lock ├── Runner.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── WorkspaceSettings.xcsettings │ └── xcshareddata │ │ └── xcschemes │ │ ├── dev.xcscheme │ │ └── prod.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-60x60@2x.png │ │ ├── Icon-App-60x60@3x.png │ │ ├── Icon-App-76x76@1x.png │ │ ├── Icon-App-76x76@2x.png │ │ └── Icon-App-83.5x83.5@2x.png │ ├── Contents.json │ └── LaunchImage.imageset │ │ ├── Contents.json │ │ ├── README.md │ │ ├── phone-dark.png │ │ ├── phone-light.png │ │ ├── universal-dark.png │ │ └── universal-light.png │ ├── Base.lproj │ ├── LaunchScreen.storyboard │ └── Main.storyboard │ ├── Info.plist │ └── Runner-Bridging-Header.h ├── l10n.yaml ├── lib ├── app.dart ├── app_config.dart ├── comment_tree.dart ├── gen │ └── assets.gen.dart ├── hooks │ ├── debounce.dart │ ├── delayed_loading.dart │ ├── infinite_scroll.dart │ ├── logged_in_action.dart │ ├── memo_future.dart │ ├── refreshable.dart │ └── stores.dart ├── l10n │ ├── l10n.dart │ ├── l10n_api.dart │ ├── l10n_from_string.dart │ └── timeago │ │ └── pl.dart ├── main_common.dart ├── main_dev.dart ├── main_prod.dart ├── markdown_formatter.dart ├── pages │ ├── communities_list.dart │ ├── communities_tab.dart │ ├── community │ │ ├── community.dart │ │ ├── community_about_tab.dart │ │ ├── community_follow_button.dart │ │ ├── community_more_menu.dart │ │ ├── community_overview.dart │ │ ├── community_store.dart │ │ └── community_store.g.dart │ ├── create_post │ │ ├── create_post.dart │ │ ├── create_post_community_picker.dart │ │ ├── create_post_fab.dart │ │ ├── create_post_instance_picker.dart │ │ ├── create_post_store.dart │ │ ├── create_post_store.g.dart │ │ └── create_post_url_field.dart │ ├── full_post │ │ ├── comment_section.dart │ │ ├── full_post.dart │ │ ├── full_post_store.dart │ │ └── full_post_store.g.dart │ ├── home_page.dart │ ├── home_tab.dart │ ├── inbox.dart │ ├── instance │ │ ├── instance.dart │ │ ├── instance_about_tab.dart │ │ ├── instance_more_menu.dart │ │ ├── instance_store.dart │ │ └── instance_store.g.dart │ ├── log_console │ │ ├── log_console.dart │ │ ├── log_console_page_store.dart │ │ └── log_console_page_store.g.dart │ ├── manage_account.dart │ ├── media_view.dart │ ├── modlog │ │ ├── modlog.dart │ │ ├── modlog_entry.dart │ │ ├── modlog_page_store.dart │ │ ├── modlog_page_store.g.dart │ │ └── modlog_table.dart │ ├── profile_tab.dart │ ├── saved_page.dart │ ├── search_results.dart │ ├── search_tab.dart │ ├── settings │ │ ├── add_account_page.dart │ │ ├── add_instance_page.dart │ │ ├── blocks │ │ │ ├── block_dialog.dart │ │ │ ├── block_tile.dart │ │ │ ├── blocks.dart │ │ │ ├── blocks_store.dart │ │ │ ├── blocks_store.g.dart │ │ │ ├── community_block_store.dart │ │ │ ├── community_block_store.g.dart │ │ │ ├── user_block_store.dart │ │ │ └── user_block_store.g.dart │ │ └── settings.dart │ ├── user.dart │ ├── users_list.dart │ └── write_message.dart ├── resources │ ├── links.dart │ └── theme.dart ├── stores │ ├── accounts_store.dart │ ├── accounts_store.g.dart │ ├── config_store.dart │ └── config_store.g.dart ├── url_launcher.dart ├── util │ ├── async_store.dart │ ├── async_store.freezed.dart │ ├── async_store.g.dart │ ├── async_store_listener.dart │ ├── cleanup_url.dart │ ├── delayed_action.dart │ ├── extensions │ │ ├── api.dart │ │ ├── brightness.dart │ │ ├── context.dart │ │ ├── iterators.dart │ │ └── spaced.dart │ ├── files.dart │ ├── goto.dart │ ├── hot_rank.dart │ ├── icons.dart │ ├── mobx_provider.dart │ ├── observer_consumers.dart │ ├── pictrs.dart │ ├── share.dart │ ├── text_color.dart │ └── text_lines_iterator.dart └── widgets │ ├── about_tile.dart │ ├── avatar.dart │ ├── bottom_modal.dart │ ├── bottom_safe.dart │ ├── cached_network_image.dart │ ├── comment │ ├── comment.dart │ ├── comment_actions.dart │ ├── comment_more_menu_button.dart │ ├── comment_store.dart │ └── comment_store.g.dart │ ├── editor │ ├── editor.dart │ ├── editor_picking_dialog.dart │ ├── editor_toolbar.dart │ ├── editor_toolbar_store.dart │ └── editor_toolbar_store.g.dart │ ├── failed_to_load.dart │ ├── fullscreenable_image.dart │ ├── infinite_scroll.dart │ ├── info_table_popup.dart │ ├── markdown_mode_icon.dart │ ├── markdown_text.dart │ ├── post │ ├── post.dart │ ├── post_actions.dart │ ├── post_body.dart │ ├── post_info_section.dart │ ├── post_link_preview.dart │ ├── post_media.dart │ ├── post_more_menu.dart │ ├── post_status.dart │ ├── post_store.dart │ ├── post_store.g.dart │ ├── post_title.dart │ ├── post_voting.dart │ └── save_post_button.dart │ ├── post_list_options.dart │ ├── pull_to_refresh.dart │ ├── radio_picker.dart │ ├── report_dialog.dart │ ├── reveal_after_scroll.dart │ ├── sortable_infinite_list.dart │ ├── tile_action.dart │ ├── user_profile.dart │ ├── user_tile.dart │ └── write_comment.dart ├── linux ├── .gitignore ├── CMakeLists.txt ├── flutter │ ├── CMakeLists.txt │ ├── generated_plugin_registrant.cc │ ├── generated_plugin_registrant.h │ └── generated_plugins.cmake ├── main.cc ├── my_application.cc └── my_application.h ├── pubspec.lock ├── pubspec.yaml ├── scripts ├── common.dart ├── gen_l10n_from_string.dart ├── migrate_lemmy_l10n.dart └── release.dart ├── test ├── pages │ └── modlog │ │ └── modlog_page_test.dart ├── stores │ └── config_store_test.dart └── util │ └── async_store_test.dart └── 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 /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: lemmur 2 | custom: 3 | - https://www.buymeacoffee.com/lemmur 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F41B Bug report" 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | ### Device info 10 | 11 | - OS: [eg. iOS/Android] 12 | - OS version: [eg. 10] 13 | - Device: [eg. OnePlus 6] 14 | - Lemmur version: [eg. v1.2.3] 15 | 16 | ### Describe the bug 17 | 18 | A clear and concise description of what the bug is. 19 | 20 | ### Steps to reproduce 21 | 22 | 1. Go to '...' 23 | 2. Click on '....' 24 | 3. Scroll down to '....' 25 | 4. See error 26 | 27 | ### Relevant logs 28 | 29 |
30 | Logs 31 | 32 | Paste your logs here. Logs can be found in lemmur: settings > about lemmur > logs. 33 |
34 | 35 | ### Expected behavior 36 | 37 | A clear and concise description of what you expected to happen. 38 | 39 | ### Screenshots/Screencasts 40 | 41 | If applicable, add screenshots to help explain your problem. 42 | 43 | ### Additional context 44 | 45 | Add any other context about the problem here. 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F680 Feature request" 3 | about: Suggest an idea for Lemmur 4 | title: "" 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | 9 | ### Is your feature request related to a problem? Please describe. 10 | 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | ### Describe the solution you'd like 14 | 15 | A clear and concise description of what you want to happen. 16 | 17 | ### Describe alternatives you've considered 18 | 19 | A clear and concise description of any alternative solutions or features you've considered. 20 | 21 | ### Additional context 22 | 23 | Add any other context or screenshots about the feature request here. 24 | -------------------------------------------------------------------------------- /.github/workflows/weblate.yml: -------------------------------------------------------------------------------- 1 | name: weblate 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | # every friday at 19:00 UTC 7 | - cron: "0 19 * * 5" 8 | 9 | jobs: 10 | weblate: 11 | name: Pull Weblate changes to repo 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Fetch changes 19 | run: | 20 | git remote add weblate https://weblate.yerbamate.ml/git/lemmur/lemmur/ 21 | git fetch weblate 22 | git merge weblate/master 23 | 24 | - name: Regenerate l10n_from_string 25 | run: | 26 | dart run scripts/gen_l10n_from_string.dart 27 | 28 | - name: Create Pull Request 29 | uses: peter-evans/create-pull-request@v3.12.0 30 | with: 31 | reviewers: shilangyu,krawieck 32 | title: Weblate update 33 | branch: weblate 34 | -------------------------------------------------------------------------------- /.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 | # Flutter/Dart/Pub related 19 | **/doc/api/ 20 | .dart_tool/ 21 | .flutter-plugins 22 | .flutter-plugins-dependencies 23 | .packages 24 | .pub-cache/ 25 | .pub/ 26 | /build/ 27 | lib/l10n/gen 28 | 29 | # Web related 30 | lib/generated_plugin_registrant.dart 31 | 32 | # Symbolication related 33 | app.*.symbols 34 | 35 | # Obfuscation related 36 | app.*.map.json 37 | 38 | # Exceptions to above rules. 39 | !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages 40 | 41 | # Xcode build files 42 | ios/build 43 | -------------------------------------------------------------------------------- /.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: 8af6b2f038c1172e61d418869363a28dffec3cb4 8 | channel: stable 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug", 6 | "request": "launch", 7 | "type": "dart", 8 | "program": "lib/main_dev.dart", 9 | "args": ["--flavor", "dev"] 10 | }, 11 | { 12 | "name": "Profile", 13 | "request": "launch", 14 | "type": "dart", 15 | "flutterMode": "profile", 16 | "program": "lib/main_dev.dart", 17 | "args": ["--flavor", "dev"] 18 | }, 19 | { 20 | "name": "Release", 21 | "request": "launch", 22 | "type": "dart", 23 | "flutterMode": "release", 24 | "program": "lib/main_dev.dart", 25 | "args": ["--flavor", "dev"] 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.vscode/lemmur.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "New ARB file l10n term": { 3 | "scope": "json", 4 | "prefix": "term", 5 | "body": ["\"$1\": \"$2\",", "\"@$1\": {}$0"] 6 | }, 7 | "Assert not null": { 8 | "scope": "dart", 9 | "prefix": "assnotnull", 10 | "body": ["assert($1 != null)$0"] 11 | }, 12 | "sizedbox": { 13 | "scope": "dart", 14 | "prefix": "sizedbox", 15 | "body": ["const SizedBox($1)$0"] 16 | }, 17 | "theme": { 18 | "scope": "dart", 19 | "prefix": "theme", 20 | "body": ["final theme = Theme.of(context);"] 21 | }, 22 | "sleep": { 23 | "scope": "dart", 24 | "prefix": "sleep", 25 | "body": [ 26 | "await Future.delayed(const Duration(milliseconds: ${1:1000}));$0" 27 | ] 28 | }, 29 | "repeat widget": { 30 | "scope": "dart", 31 | "prefix": "repeat", 32 | "body": ["for(int i = 0; i < $1; i++)$0"] 33 | }, 34 | "L10n string": { 35 | "scope": "dart", 36 | "prefix": "l10n", 37 | "body": ["L10n.of(context).$0"] 38 | }, 39 | "Mobx store": { 40 | "prefix": "mobxstore", 41 | "body": [ 42 | "import 'package:mobx/mobx.dart';", 43 | "", 44 | "part '$TM_FILENAME_BASE.g.dart';", 45 | "", 46 | "class ${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/g} = _${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/g} with _$${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/g};", 47 | "", 48 | "abstract class _${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/g} with Store {", 49 | "\t@observable", 50 | "\t$0", 51 | "}" 52 | ] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "*.arb": "json" 4 | }, 5 | "dart.showTodos": false, 6 | "xml.format.preserveAttributeLineBreaks": true 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "flutter", 6 | "command": "flutter", 7 | "args": [ 8 | "pub", 9 | "run", 10 | "build_runner", 11 | "build", 12 | "--delete-conflicting-outputs" 13 | ], 14 | "problemMatcher": ["$dart-build_runner"], 15 | "group": "build", 16 | "label": "flutter: build_runner build" 17 | }, 18 | { 19 | "type": "flutter", 20 | "command": "flutter", 21 | "args": [ 22 | "pub", 23 | "run", 24 | "build_runner", 25 | "watch", 26 | "--delete-conflicting-outputs" 27 | ], 28 | "problemMatcher": ["$dart-build_runner"], 29 | "group": "build", 30 | "label": "flutter: flutter pub run build_runner watch" 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /PRIVACY_POLICY.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy 2 | 3 | We don't store your data. We don't use any intermediary services that could store your data. 4 | 5 | For any questions contact us at lemmurapp@protonmail.com 6 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | 22 | 23 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/com/krawieck/lemmur/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.krawieck.lemmur 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() { 6 | } 7 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-hdpi/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/android/app/src/main/res/drawable-hdpi/splash.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-mdpi/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/android/app/src/main/res/drawable-mdpi/splash.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xhdpi/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/android/app/src/main/res/drawable-xhdpi/splash.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxhdpi/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/android/app/src/main/res/drawable-xxhdpi/splash.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxxhdpi/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/android/app/src/main/res/drawable-xxxhdpi/splash.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/splash_screen.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #303030 4 | 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #ffffff 4 | #fafafa 5 | 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext.kotlin_version = '1.7.0' 3 | repositories { 4 | google() 5 | mavenCentral() 6 | } 7 | 8 | dependencies { 9 | classpath 'com.android.tools.build:gradle:7.2.1' 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 | project.evaluationDependsOn(':app') 25 | } 26 | 27 | task clean(type: Delete) { 28 | delete rootProject.buildDir 29 | } 30 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.enableR8=true 3 | android.useAndroidX=true 4 | android.enableJetifier=true 5 | -------------------------------------------------------------------------------- /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-7.4.2-all.zip 7 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | // Copyright 2014 The Flutter Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | include ':app' 6 | 7 | def localPropertiesFile = new File(rootProject.projectDir, "local.properties") 8 | def properties = new Properties() 9 | 10 | assert localPropertiesFile.exists() 11 | localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } 12 | 13 | def flutterSdkPath = properties.getProperty("flutter.sdk") 14 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 15 | apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" 16 | -------------------------------------------------------------------------------- /assets/adaptive_icon_fg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/assets/adaptive_icon_fg.png -------------------------------------------------------------------------------- /assets/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/assets/app_icon.png -------------------------------------------------------------------------------- /assets/app_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /assets/l10n/intl_bn.arb: -------------------------------------------------------------------------------- 1 | { 2 | "@@locale": "bn", 3 | "settings": "পছন্দসমূহ", 4 | "posts": "ভুক্তি", 5 | "comments": "মন্তব্য", 6 | "modlog": "ব্যবস্থাপনা সূচী", 7 | "post": "ভুক্তি", 8 | "deleted_by_creator": "লেখক মুছেছে", 9 | "more": "আরো", 10 | "mark_as_read": "পঠিত", 11 | "mark_as_unread": "অপঠিত", 12 | "reply": "উত্তর", 13 | "edit": "সম্পাদনা", 14 | "delete": "মুছো", 15 | "avatar": "অবতার", 16 | "banner": "কেতন", 17 | "delete_account": "অ্যাকাউন্ট মুছো", 18 | "communities": "সম্প্রদায়", 19 | "users": "ব্যবহারকারী", 20 | "admin": "প্রশাসক", 21 | "locked": "বন্ধ", 22 | "invalid_community_name": "অগ্রহণযোগ্য নাম।", 23 | "number_of_comments": "{formattedCount,plural, =1{{formattedCount}টি মন্তব্য} other{{formattedCount}টি মন্তব্য}}", 24 | "number_of_posts": "{formattedCount,plural, =1{{formattedCount}টি ভুক্তি} other{{formattedCount}টি ভুক্তি}}", 25 | "delete_account_confirm": "সতর্কতা:", 26 | "show_avatars": "অবতার দেখাও", 27 | "send_message": "বার্তা পাঠাও", 28 | "bot_account": "বট অ্যাকাউন্ট", 29 | "show_bot_accounts": "বট অ্যাকাউন্ট দেখাও", 30 | "show_read_posts": "পঠিত ভুক্তি দেখাও" 31 | } 32 | -------------------------------------------------------------------------------- /assets/l10n/intl_cy.arb: -------------------------------------------------------------------------------- 1 | { 2 | "@@locale": "cy", 3 | "posts": "Postiadau", 4 | "comments": "Sylwadau", 5 | "post": "post" 6 | } 7 | -------------------------------------------------------------------------------- /assets/l10n/intl_hi.arb: -------------------------------------------------------------------------------- 1 | { 2 | "@@locale": "hi", 3 | "settings": "समायोजन (सेटिंग्स)", 4 | "posts": "पोस्ट", 5 | "comments": "टिप्पणी (कमेंट )", 6 | "community": "समुदाय", 7 | "post": "पोस्ट", 8 | "more": "और भी", 9 | "reply": "जवाब दें", 10 | "edit": "संपादित करें ( एडिट करें )", 11 | "avatar": "अवतार", 12 | "communities": "सामुदायिक", 13 | "users": "उपयोगकर्ता", 14 | "admin": "प्रशासक", 15 | "couldnt_find_post": "पोस्ट नहीं ढूंढ़ पाएं |", 16 | "locked": "बंद", 17 | "couldnt_create_comment": "टिप्पणी (कमेंट) नहीं बना पाईं |", 18 | "couldnt_find_community": "समुदायों नहीं ढूंढ़ पाएं |", 19 | "community_already_exists": "यह समुदाय पहले स मौजूद है |", 20 | "number_of_comments": "{formattedCount,plural, =1{{{ count }} टिप्पणी (कमेंट )} other{{{ count }} टिप्पणियाँ (कोम्मेंट्स )}}", 21 | "number_of_posts": "{formattedCount,plural, =1{{formattedCount} पोस्ट} other{{formattedCount} पोस्ट्स}}", 22 | "show_avatars": "अवतार दिखाएँ", 23 | "send_message": "संदेश भेजें" 24 | } 25 | -------------------------------------------------------------------------------- /assets/l10n/intl_hr.arb: -------------------------------------------------------------------------------- 1 | { 2 | "@@locale": "hr" 3 | } 4 | -------------------------------------------------------------------------------- /assets/l10n/intl_km.arb: -------------------------------------------------------------------------------- 1 | { 2 | "@@locale": "km" 3 | } 4 | -------------------------------------------------------------------------------- /assets/l10n/intl_mnc.arb: -------------------------------------------------------------------------------- 1 | { 2 | "@@locale": "mnc", 3 | "posts": "ᡧᡠ", 4 | "comments": "ᠯᡝᠣᠯᡝᠨ", 5 | "post": "ᡧ", 6 | "number_of_comments": "{formattedCount,plural, =1{{formattedCount} ᠯᡝᠣᠯᡝᠨ} other{{formattedCount} ᠯᡝᠣᠯᡝᠨ}}", 7 | "number_of_posts": "{formattedCount,plural, =1{{formattedCount} ᡧᡠ} other{{formattedCount} ᡧᡠ}}" 8 | } 9 | -------------------------------------------------------------------------------- /assets/l10n/intl_nb.arb: -------------------------------------------------------------------------------- 1 | { 2 | "@@locale": "nb", 3 | "posts": "innleggene", 4 | "comments": "Kommentarer", 5 | "post": "Innlegg", 6 | "more": "mer", 7 | "reply": "svare", 8 | "edit": "Redigere", 9 | "avatar": "Profilbilde", 10 | "banner": "Banner", 11 | "display_name": "Vis navn", 12 | "communities": "Nettsamfunn", 13 | "users": "Brukere", 14 | "number_of_comments": "{formattedCount,plural, =1{{formattedCount} Kommentar} other{{formattedCount} Kommentarer}}", 15 | "number_of_posts": "{formattedCount,plural, =1{{formattedCount} Innlegg} other{{formattedCount} innleggene}}", 16 | "show_avatars": "Vis profilbilder", 17 | "send_message": "Sende melding", 18 | "bot_account": "Bot Konto", 19 | "show_bot_accounts": "Vis Bot Kontoer" 20 | } 21 | -------------------------------------------------------------------------------- /assets/l10n/intl_oc.arb: -------------------------------------------------------------------------------- 1 | { 2 | "@@locale": "oc", 3 | "settings": "Paramètres", 4 | "password": "Senhal", 5 | "posts": "Publicacions", 6 | "comments": "Comentaris", 7 | "community": "Comunitat", 8 | "url": "URL", 9 | "title": "Títol", 10 | "body": "Còs", 11 | "post": "publicar", 12 | "replies": "Responsas", 13 | "mentions": "Mencions", 14 | "more": "mai", 15 | "reply": "respondre", 16 | "edit": "editar", 17 | "delete": "suprimir", 18 | "restore": "restaurar", 19 | "yes": "òc", 20 | "no": "non", 21 | "avatar": "Avatar", 22 | "banner": "Bandièra", 23 | "display_name": "Nom d'afichatge", 24 | "bio": "Biografia", 25 | "email": "Adreça electronica", 26 | "sort_type": "Triar per tipe", 27 | "type": "Tipe", 28 | "delete_account": "Suprimir lo compte", 29 | "saved": "Enregistrat", 30 | "communities": "Comunitats", 31 | "users": "Utilizaires", 32 | "theme": "Tèma", 33 | "language": "Lenga", 34 | "new_": "Nòu", 35 | "by": "per", 36 | "not_logged_in": "Pas connectat.", 37 | "invalid_url": "URL invalida.", 38 | "invalid_community_name": "Nom invalid.", 39 | "invalid_username": "Nom d'utilizaire invalid.", 40 | "user_already_exists": "L’utilizaire existís ja.", 41 | "number_of_users_online": "{formattedCount,plural, =1{{formattedCount} utilizaire en linha} other{{formattedCount} utilizaires en linha}}", 42 | "number_of_comments": "{formattedCount,plural, =1{{formattedCount} comentari} other{{formattedCount} comentaris}}", 43 | "number_of_posts": "{formattedCount,plural, =1{{formattedCount} publicacion} other{{formattedCount} publicacions}}", 44 | "number_of_users": "{formattedCount,plural, =1{{formattedCount} utilizaire} other{{formattedCount} utilizaires}}", 45 | "messages": "Messatges", 46 | "new_password": "Senhal novèl", 47 | "verify_password": "Verificar lo senhal", 48 | "old_password": "Senhal ancian", 49 | "show_avatars": "Mostrar los avatars", 50 | "search": "Recercar", 51 | "send_message": "Enviar un messatge", 52 | "new_comments": "Comentaris novèls", 53 | "bot_account": "Compte de robòt", 54 | "show_bot_accounts": "Mostrar los comptes de robòts" 55 | } 56 | -------------------------------------------------------------------------------- /assets/l10n/intl_sk.arb: -------------------------------------------------------------------------------- 1 | { 2 | "@@locale": "sk", 3 | "posts": "Príspevky", 4 | "comments": "Komentáre", 5 | "post": "Poslať", 6 | "communities": "Komunity", 7 | "users": "Užívatelia" 8 | } 9 | -------------------------------------------------------------------------------- /assets/l10n/intl_sr.arb: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /assets/l10n/intl_sr_Latn.arb: -------------------------------------------------------------------------------- 1 | { 2 | "@@locale": "sr_Latn", 3 | "password": "Lozinka", 4 | "community": "Zajednica", 5 | "url": "URL", 6 | "body": "Sadržaj", 7 | "nsfw": "NSFW", 8 | "from": "od", 9 | "to": "do", 10 | "yes": "da", 11 | "no": "ne", 12 | "email": "Email", 13 | "matrix_user": "Korisnik Matrixa", 14 | "show_nsfw": "Prikaži NSFW sadržaj", 15 | "send_notifications_to_email": "Primajte notifikacie na Vaš Email", 16 | "theme": "Tema", 17 | "language": "Jezik", 18 | "chat": "Ćaskanje", 19 | "by": "od", 20 | "downvotes_disabled": "Onemogućite negativne glasove", 21 | "registration_closed": "Zatvorena registracija", 22 | "new_password": "Nova Lozinka", 23 | "verify_password": "Potvrdite Loziku", 24 | "old_password": "Stara Lozinka" 25 | } 26 | -------------------------------------------------------------------------------- /assets/l10n/intl_th.arb: -------------------------------------------------------------------------------- 1 | { 2 | "@@locale": "th", 3 | "settings": "การตั้งค่า", 4 | "password": "รหัสผ่าน", 5 | "email_or_username": "อีเมลหรือชื่อผู้ใช้", 6 | "posts": "โพสต์", 7 | "comments": "ความคิดเห็น", 8 | "community": "ชุมชน", 9 | "url": "ลิงก์", 10 | "title": "หัวข้อ", 11 | "body": "แสดง", 12 | "post": "โพสต์", 13 | "save": "บันทึก", 14 | "all": "ทั้งหมด", 15 | "replies": "การตอบกลับ", 16 | "mentions": "การกล่าวถึง", 17 | "from": "จาก", 18 | "to": "ไปยัง", 19 | "more": "เพิ่มเติม", 20 | "mark_as_read": "ทำเครื่องหมายว่าอ่านแล้ว", 21 | "mark_as_unread": "ทำเครื่องหมายว่ายังไม่อ่าน", 22 | "reply": "ตอบกลับ", 23 | "edit": "แก้ไข", 24 | "delete": "ลบ", 25 | "yes": "ใช่", 26 | "no": "ไม่", 27 | "avatar": "รูปประจำตัว", 28 | "banner": "แบนเนอร์", 29 | "bio": "ชีวประวัติ", 30 | "email": "อีเมล", 31 | "type": "ชนิด", 32 | "delete_account": "ลบบัญชี", 33 | "saved": "บันทึกแล้ว", 34 | "communities": "ชุมชน", 35 | "users": "ผู้ใช้งาน", 36 | "theme": "ธีม", 37 | "language": "ภาษา", 38 | "new_": "ใหม่", 39 | "old": "เก่า", 40 | "chat": "แชท", 41 | "admin": "ผู้ดูแล", 42 | "by": "โดย", 43 | "not_logged_in": "ไม่ได้เข้าสู่ระบบ", 44 | "couldnt_save_comment": "ไม่สามารถบันทึกความคิดเห็นได้", 45 | "report_too_long": "รายงานยาวเกินไป", 46 | "couldnt_create_report": "ไม่สามารถสร้างรายงานได้", 47 | "couldnt_create_post": "ไม่สามารถสร้างโพสต์ได้", 48 | "couldnt_save_post": "ไม่สามารถบันทึกโพสต์ได้", 49 | "invalid_community_name": "ชื่อไม่ถูกต้อง", 50 | "password_incorrect": "รหัสผ่านไม่ถูกต้อง", 51 | "registration_closed": "การลงทะเบียนปิดอยู่", 52 | "passwords_dont_match": "รหัสผ่านไม่ตรงกัน", 53 | "post_title_too_long": "หัวข้อโพสต์ยาวเกินไป", 54 | "email_already_exists": "อีเมลถูกใช้งานแล้ว", 55 | "number_of_comments": "{formattedCount,plural, other{{formattedCount} ความคิดเห็น}}", 56 | "number_of_posts": "{formattedCount,plural, other{{formattedCount} โพสต์}}", 57 | "number_of_users": "{formattedCount,plural, other{ผู้ใช้ {formattedCount} คน}}", 58 | "unsubscribe": "เลิกติดตาม", 59 | "subscribe": "ติดตาม", 60 | "messages": "ข้อความ", 61 | "new_password": "รหัสผ่านใหม่", 62 | "verify_password": "ยืนยันรหัสผ่าน", 63 | "old_password": "รหัสผ่านเก่า", 64 | "show_avatars": "แสดงรูปประจำตัว", 65 | "search": "ค้นหา", 66 | "send_message": "ส่งข้อความ", 67 | "bot_account": "บัญชีบอต", 68 | "show_bot_accounts": "แสดงบัญชีบอต", 69 | "show_read_posts": "แสดงโพสต์ที่อ่านแล้ว" 70 | } 71 | -------------------------------------------------------------------------------- /assets/l10n/intl_zh_Hant.arb: -------------------------------------------------------------------------------- 1 | { 2 | "@@locale": "zh_Hant", 3 | "settings": "設定", 4 | "posts": "貼文", 5 | "comments": "評論", 6 | "modlog": "管理紀錄", 7 | "post": "回文", 8 | "deleted_by_creator": "作者已刪除", 9 | "more": "更多", 10 | "mark_as_read": "標記為已讀", 11 | "mark_as_unread": "標記為未讀", 12 | "reply": "回覆", 13 | "edit": "編輯", 14 | "delete": "刪除", 15 | "avatar": "頭貼", 16 | "banner": "橫幅", 17 | "delete_account": "刪除帳號", 18 | "communities": "社群", 19 | "users": "使用者", 20 | "locked": "已鎖定", 21 | "invalid_community_name": "無效的名稱。", 22 | "number_of_comments": "{formattedCount,plural, other{{formattedCount} 則評論}}", 23 | "number_of_posts": "{formattedCount,plural, other{{formattedCount} 貼文}}", 24 | "show_avatars": "顯示頭貼", 25 | "send_message": "發送私人訊息", 26 | "bot_account": "機器人帳號", 27 | "show_bot_accounts": "顯示機器人帳號", 28 | "show_read_posts": "顯示已讀貼文" 29 | } 30 | -------------------------------------------------------------------------------- /assets/readme_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | platform :android do 2 | desc "Deploy a new version to the Google Play" 3 | lane :prod do 4 | upload_to_play_store( 5 | package_name: "com.krawieck.lemmur", 6 | json_key: ENV["GOOGLE_SERVICE_ACCOUNT_KEY_PATH"], 7 | aab: ENV["ABB_PATH"] 8 | ) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /fastlane/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "fastlane" 4 | -------------------------------------------------------------------------------- /fastlane/README.md: -------------------------------------------------------------------------------- 1 | fastlane documentation 2 | ================ 3 | # Installation 4 | 5 | Make sure you have the latest version of the Xcode command line tools installed: 6 | 7 | ``` 8 | xcode-select --install 9 | ``` 10 | 11 | Install _fastlane_ using 12 | ``` 13 | [sudo] gem install fastlane -NV 14 | ``` 15 | or alternatively using `brew install fastlane` 16 | 17 | # Available Actions 18 | ## Android 19 | ### android prod 20 | ``` 21 | fastlane android prod 22 | ``` 23 | Deploy a new version to the Google Play 24 | 25 | ---- 26 | 27 | This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. 28 | More information about fastlane can be found on [fastlane.tools](https://fastlane.tools). 29 | The documentation of fastlane can be found on [docs.fastlane.tools](https://docs.fastlane.tools). 30 | -------------------------------------------------------------------------------- /fastlane/metadata/android/de-DE/full_description.txt: -------------------------------------------------------------------------------- 1 | Ein mobiler Client für Lemmy - eine föderierte Reddit-Alternative 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/de-DE/short_description.txt: -------------------------------------------------------------------------------- 1 | Ein mobiler Client für Lemmy - eine föderierte Reddit-Alternative 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/de-DE/title.txt: -------------------------------------------------------------------------------- 1 | lemmur 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/11.txt: -------------------------------------------------------------------------------- 1 | Lemmur is now available on the [play store](https://play.google.com/store/apps/details?id=com.krawieck.lemmur) and [f-droid](https://f-droid.org/packages/com.krawieck.lemmur) 2 | 3 | ### Changed 4 | 5 | - Posts with large amount of text are now truncated in infinite scroll views 6 | - Changed image viewer dismissal to be more fun. The image now also moves on the x axis, changes scale and rotates a bit for more user enjoyment 7 | 8 | ### Fixed 9 | 10 | - Fixed issue where the "About lemmur" tile would not appear on Windows/Linux 11 | - Added a bigger bottom margin in the comment section to prevent the floating action button from covering the last comment 12 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/12.txt: -------------------------------------------------------------------------------- 1 | WARNING: due to some internal changes your local settings will be reset (logged out of accounts, removed instances, theme back to default) 2 | 3 | ### Added 4 | 5 | - Added inbox page, that can be accessed by tapping bell in the home tab 6 | - Added page with saved posts/comments. It can be accessed from the profile tab under the bookmark icon 7 | - Added ability to send private messages 8 | - Added modlog page. Can be visited in the context of an instance or community from the about tab 9 | 10 | ### Changed 11 | 12 | - Titles on some pages, have an appear affect when scrolling down 13 | - Long pressing comments now has a ripple effect 14 | - Nerd stuff now contains more nerd stuff 15 | - Communities that a user follows will no longer appear on a user's profile in most scenarios 16 | 17 | ### Fixed 18 | 19 | - Time of posts is now displayed properly. Unless you live in UTC zone, then you won't notice a difference 20 | - Fixed a bug where links would not work on Android 11 21 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/13.txt: -------------------------------------------------------------------------------- 1 | ### Added 2 | 3 | - Share buttons on windows/linux now copy the data to the clipboard 4 | - Initial translations have been incorporated into lemmur. It is not yet possible to contribute translation strings 5 | 6 | ### Changed 7 | 8 | - Transitioned to Lemmy API v3 9 | 10 | ### Fixed 11 | 12 | - Quote blocks in posts and comments are now much prettier 13 | - Code blocks now have monospace font. As they should 14 | - Switching accounts in the profile tab now correctly reacts to the change 15 | - You can no longer add the same instance twice just by changing capitalization (thanks to @ryg-git) 16 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/14.txt: -------------------------------------------------------------------------------- 1 | ### Fixed 2 | 3 | - Some actions would pass the wrong user id around causing infinite spinners, this is now fixed 4 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/15.txt: -------------------------------------------------------------------------------- 1 | ### Changed 2 | 3 | - Disable commenting on locked posts 4 | - Enhanced keyboard experience 5 | - appropriate keyboard types are opened 6 | - correct capitalization 7 | - added text input hints for things like password managers 8 | - Account actions in settings are more obvious to access: long press an account/instance to see possible actions such as setting as default or removal 9 | 10 | ### Added 11 | 12 | - When writing a comment, the parent text is now selectable 13 | - Text of a post is now selectable 14 | - Tapping outside of a text input hides the keyboard 15 | 16 | ### Fixed 17 | 18 | - Actually fixed the thing that v0.4.1 supposedly fixed 19 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/16.txt: -------------------------------------------------------------------------------- 1 | ### Added 2 | 3 | - Editing posts 4 | - Editing comments 5 | - Show avatars setting toggle 6 | - Show scores setting toggle 7 | - Default sort type setting 8 | - Default listing type setting 9 | - Import Lemmy settings: long press an account in account settings then choose the import option 10 | - Support lemmy v0.11.0 11 | 12 | ### Fixed 13 | 14 | - Added deduplication in infinite scrolls 15 | - Fixed bug where creating post would crash after uploading a picture 16 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/17.txt: -------------------------------------------------------------------------------- 1 | ### Added 2 | 3 | - Support for Lemmy v0.12.0 4 | - Show cake day on a user's profile and next to their name in a comment 5 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/18.txt: -------------------------------------------------------------------------------- 1 | ### Added 2 | 3 | - Blocking of users and communities (from post and from comment) 4 | - Reporting posts and comments 5 | - Android theme-aware splash screen (thanks to [@mimi89999](https://github.com/mimi89999)) 6 | - Logging: local logs about some actions/errors. Can be accessed from **settings > about lemmur > logs** 7 | 8 | ### Fixed 9 | 10 | - Fixed a bug where post would go out of sync with full version of the post 11 | - Fixed a bug where making a comment selectable would not always result in making the comment selectable 12 | - Full post will now open no matter where you press on the post card 13 | - Fixed overflows in various places 14 | 15 | ### Changed 16 | 17 | - User banner photo now fits better on user profile 18 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/19.txt: -------------------------------------------------------------------------------- 1 | ### Added 2 | 3 | - Support for Lemmy v0.15.0 4 | 5 | ### Changed 6 | 7 | - "Time ago" strings, dates, and compact numbers are now localized 8 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 | Lemmur aims to provide a seamless experience when browsing different Lemmy instances. 2 | 3 | You can have multiple multiple instances added at the same time without having to awkwardly switch between them. 4 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/featureGraphic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/fastlane/metadata/android/en-US/images/featureGraphic.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/fastlane/metadata/android/en-US/images/icon.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | A mobile client for lemmy - a federated reddit alternative 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/title.txt: -------------------------------------------------------------------------------- 1 | lemmur 2 | -------------------------------------------------------------------------------- /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/.last_build_id: -------------------------------------------------------------------------------- 1 | 20ad19f2b9a812ac7774ca58ddf04b2e -------------------------------------------------------------------------------- /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 | 9.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment this line to define a global platform for your project 2 | # platform :ios, '9.0' 3 | 4 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 5 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 6 | 7 | project 'Runner', { 8 | 'Debug' => :debug, 9 | 'Profile' => :release, 10 | 'Release' => :release, 11 | } 12 | 13 | def flutter_root 14 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) 15 | unless File.exist?(generated_xcode_build_settings_path) 16 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" 17 | end 18 | 19 | File.foreach(generated_xcode_build_settings_path) do |line| 20 | matches = line.match(/FLUTTER_ROOT\=(.*)/) 21 | return matches[1].strip if matches 22 | end 23 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" 24 | end 25 | 26 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) 27 | 28 | flutter_ios_podfile_setup 29 | 30 | target 'Runner' do 31 | use_frameworks! 32 | use_modular_headers! 33 | 34 | flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) 35 | end 36 | 37 | post_install do |installer| 38 | installer.pods_project.targets.each do |target| 39 | flutter_additional_ios_build_settings(target) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /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.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /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/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/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/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/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/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/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/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/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/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/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/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/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/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/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/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/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/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/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/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/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/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/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/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/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/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "appearances" : [ 9 | { 10 | "appearance" : "luminosity", 11 | "value" : "dark" 12 | } 13 | ], 14 | "idiom" : "universal", 15 | "scale" : "1x" 16 | }, 17 | { 18 | "idiom" : "universal", 19 | "scale" : "2x" 20 | }, 21 | { 22 | "appearances" : [ 23 | { 24 | "appearance" : "luminosity", 25 | "value" : "dark" 26 | } 27 | ], 28 | "idiom" : "universal", 29 | "scale" : "2x" 30 | }, 31 | { 32 | "filename" : "universal-light.png", 33 | "idiom" : "universal", 34 | "scale" : "3x" 35 | }, 36 | { 37 | "appearances" : [ 38 | { 39 | "appearance" : "luminosity", 40 | "value" : "dark" 41 | } 42 | ], 43 | "filename" : "universal-dark.png", 44 | "idiom" : "universal", 45 | "scale" : "3x" 46 | }, 47 | { 48 | "idiom" : "iphone", 49 | "scale" : "1x" 50 | }, 51 | { 52 | "appearances" : [ 53 | { 54 | "appearance" : "luminosity", 55 | "value" : "dark" 56 | } 57 | ], 58 | "idiom" : "iphone", 59 | "scale" : "1x" 60 | }, 61 | { 62 | "idiom" : "iphone", 63 | "scale" : "2x" 64 | }, 65 | { 66 | "appearances" : [ 67 | { 68 | "appearance" : "luminosity", 69 | "value" : "dark" 70 | } 71 | ], 72 | "idiom" : "iphone", 73 | "scale" : "2x" 74 | }, 75 | { 76 | "filename" : "phone-light.png", 77 | "idiom" : "iphone", 78 | "scale" : "3x" 79 | }, 80 | { 81 | "appearances" : [ 82 | { 83 | "appearance" : "luminosity", 84 | "value" : "dark" 85 | } 86 | ], 87 | "filename" : "phone-dark.png", 88 | "idiom" : "iphone", 89 | "scale" : "3x" 90 | } 91 | ], 92 | "info" : { 93 | "author" : "xcode", 94 | "version" : 1 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /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/Assets.xcassets/LaunchImage.imageset/phone-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/ios/Runner/Assets.xcassets/LaunchImage.imageset/phone-dark.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/phone-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/ios/Runner/Assets.xcassets/LaunchImage.imageset/phone-light.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/universal-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/ios/Runner/Assets.xcassets/LaunchImage.imageset/universal-dark.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/universal-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/ios/Runner/Assets.xcassets/LaunchImage.imageset/universal-light.png -------------------------------------------------------------------------------- /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 | $(BUNDLE_NAME) 15 | CFBundleDisplayName 16 | $(BUNDLE_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(FLUTTER_BUILD_NAME) 21 | CFBundleSignature 22 | ???? 23 | CFBundleVersion 24 | $(FLUTTER_BUILD_NUMBER) 25 | LSRequiresIPhoneOS 26 | 27 | UILaunchStoryboardName 28 | LaunchScreen 29 | UIMainStoryboardFile 30 | Main 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | UIViewControllerBasedStatusBarAppearance 45 | 46 | CADisableMinimumFrameDurationOnPhone 47 | 48 | 49 | 50 | NSPhotoLibraryUsageDescription 51 | For uploading images for posts/avatars 52 | NSCameraUsageDescription 53 | For uploading images for posts/avatars 54 | NSMicrophoneUsageDescription 55 | For recording videos for posts 56 | CADisableMinimumFrameDurationOnPhone 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" 2 | -------------------------------------------------------------------------------- /l10n.yaml: -------------------------------------------------------------------------------- 1 | arb-dir: assets/l10n 2 | output-dir: lib/l10n/gen 3 | template-arb-file: intl_en.arb 4 | output-localization-file: l10n.dart 5 | preferred-supported-locales: [en] 6 | output-class: L10n 7 | synthetic-package: false 8 | nullable-getter: false 9 | -------------------------------------------------------------------------------- /lib/app.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:keyboard_dismisser/keyboard_dismisser.dart'; 3 | 4 | import 'l10n/l10n.dart'; 5 | import 'pages/home_page.dart'; 6 | import 'resources/theme.dart'; 7 | import 'stores/config_store.dart'; 8 | import 'util/observer_consumers.dart'; 9 | 10 | class MyApp extends StatelessWidget { 11 | const MyApp(); 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return KeyboardDismisser( 16 | child: ObserverBuilder( 17 | builder: (context, store) => MaterialApp( 18 | title: 'lemmur', 19 | supportedLocales: L10n.supportedLocales, 20 | localizationsDelegates: L10n.localizationsDelegates, 21 | themeMode: store.theme, 22 | darkTheme: store.amoledDarkMode ? amoledTheme : darkTheme, 23 | locale: store.locale, 24 | theme: lightTheme, 25 | home: const HomePage(), 26 | ), 27 | ), 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/app_config.dart: -------------------------------------------------------------------------------- 1 | class AppConfig { 2 | final bool debugMode; 3 | 4 | const AppConfig({ 5 | required this.debugMode, 6 | }); 7 | } 8 | -------------------------------------------------------------------------------- /lib/comment_tree.dart: -------------------------------------------------------------------------------- 1 | import 'package:lemmy_api_client/v3.dart'; 2 | 3 | import 'util/hot_rank.dart'; 4 | 5 | enum CommentSortType { 6 | hot, 7 | top, 8 | new_, 9 | old, 10 | chat; 11 | 12 | /// returns a compare function for sorting a CommentTree according 13 | /// to the comment sort type 14 | int Function(CommentTree a, CommentTree b) get sortFunction { 15 | switch (this) { 16 | case CommentSortType.chat: 17 | throw Exception('Sorting a CommentTree in chat mode is not supported' 18 | ' because it would restructure the whole tree'); 19 | 20 | case CommentSortType.hot: 21 | return (b, a) => 22 | a.comment.computedHotRank.compareTo(b.comment.computedHotRank); 23 | 24 | case CommentSortType.new_: 25 | return (b, a) => 26 | a.comment.comment.published.compareTo(b.comment.comment.published); 27 | 28 | case CommentSortType.old: 29 | return (b, a) => 30 | b.comment.comment.published.compareTo(a.comment.comment.published); 31 | 32 | case CommentSortType.top: 33 | return (b, a) => 34 | a.comment.counts.score.compareTo(b.comment.counts.score); 35 | } 36 | } 37 | } 38 | 39 | extension SortCommentTreeList on List { 40 | void sortBy(CommentSortType sortType) { 41 | sort(sortType.sortFunction); 42 | for (final el in this) { 43 | el._sort(sortType.sortFunction); 44 | } 45 | } 46 | } 47 | 48 | class CommentTree { 49 | CommentView comment; 50 | List children = []; 51 | 52 | CommentTree(this.comment); 53 | 54 | /// takes raw linear comments and turns them into a CommentTree 55 | static List fromList(List comments) { 56 | CommentTree gatherChildren(CommentTree parent) { 57 | for (final el in comments) { 58 | if (el.comment.parentId == parent.comment.comment.id) { 59 | parent.children.add(gatherChildren(CommentTree(el))); 60 | } 61 | } 62 | return parent; 63 | } 64 | 65 | final topLevelParents = 66 | comments.where((e) => e.comment.parentId == null).map(CommentTree.new); 67 | 68 | final result = topLevelParents.map(gatherChildren).toList(); 69 | return result; 70 | } 71 | 72 | /// recursive sorter 73 | void _sort(int compare(CommentTree a, CommentTree b)) { 74 | children.sort(compare); 75 | for (final el in children) { 76 | el._sort(compare); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /lib/gen/assets.gen.dart: -------------------------------------------------------------------------------- 1 | /// GENERATED CODE - DO NOT MODIFY BY HAND 2 | /// ***************************************************** 3 | /// FlutterGen 4 | /// ***************************************************** 5 | 6 | import 'package:flutter/widgets.dart'; 7 | 8 | class Assets { 9 | Assets._(); 10 | 11 | static const AssetGenImage appIcon = AssetGenImage('assets/app_icon.png'); 12 | } 13 | 14 | class AssetGenImage extends AssetImage { 15 | const AssetGenImage(String assetName) 16 | : _assetName = assetName, 17 | super(assetName); 18 | final String _assetName; 19 | 20 | Image image({ 21 | Key? key, 22 | ImageFrameBuilder? frameBuilder, 23 | ImageLoadingBuilder? loadingBuilder, 24 | ImageErrorWidgetBuilder? errorBuilder, 25 | String? semanticLabel, 26 | bool excludeFromSemantics = false, 27 | double? width, 28 | double? height, 29 | Color? color, 30 | BlendMode? colorBlendMode, 31 | BoxFit? fit, 32 | AlignmentGeometry alignment = Alignment.center, 33 | ImageRepeat repeat = ImageRepeat.noRepeat, 34 | Rect? centerSlice, 35 | bool matchTextDirection = false, 36 | bool gaplessPlayback = false, 37 | bool isAntiAlias = false, 38 | FilterQuality filterQuality = FilterQuality.low, 39 | }) { 40 | return Image( 41 | key: key, 42 | image: this, 43 | frameBuilder: frameBuilder, 44 | loadingBuilder: loadingBuilder, 45 | errorBuilder: errorBuilder, 46 | semanticLabel: semanticLabel, 47 | excludeFromSemantics: excludeFromSemantics, 48 | width: width, 49 | height: height, 50 | color: color, 51 | colorBlendMode: colorBlendMode, 52 | fit: fit, 53 | alignment: alignment, 54 | repeat: repeat, 55 | centerSlice: centerSlice, 56 | matchTextDirection: matchTextDirection, 57 | gaplessPlayback: gaplessPlayback, 58 | isAntiAlias: isAntiAlias, 59 | filterQuality: filterQuality, 60 | ); 61 | } 62 | 63 | String get path => _assetName; 64 | } 65 | -------------------------------------------------------------------------------- /lib/hooks/debounce.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_hooks/flutter_hooks.dart'; 5 | 6 | class Debounce { 7 | final bool loading; 8 | final VoidCallback callback; 9 | 10 | const Debounce({ 11 | required this.loading, 12 | required this.callback, 13 | }); 14 | 15 | void call() => callback(); 16 | } 17 | 18 | /// will run `callback()` after debounce hook hasn't been called for the 19 | /// specified `delayDuration` 20 | Debounce useDebounce( 21 | Future Function() callback, [ 22 | Duration delayDuration = const Duration(seconds: 1), 23 | ]) { 24 | final loading = useState(false); 25 | final timerHandle = useRef(null); 26 | 27 | cancel() { 28 | timerHandle.value?.cancel(); 29 | loading.value = false; 30 | } 31 | 32 | useEffect(() => () => timerHandle.value?.cancel(), []); 33 | 34 | start() { 35 | timerHandle.value = Timer(delayDuration, () async { 36 | loading.value = true; 37 | await callback(); 38 | cancel(); 39 | }); 40 | } 41 | 42 | return Debounce( 43 | loading: loading.value, 44 | callback: () { 45 | cancel(); 46 | start(); 47 | }, 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /lib/hooks/delayed_loading.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_hooks/flutter_hooks.dart'; 5 | 6 | class DelayedLoading { 7 | final bool pending; 8 | final bool loading; 9 | final VoidCallback start; 10 | final VoidCallback cancel; 11 | 12 | const DelayedLoading({ 13 | required this.pending, 14 | required this.loading, 15 | required this.start, 16 | required this.cancel, 17 | }); 18 | } 19 | 20 | /// When loading is [.start()]ed, it goes into a pending state 21 | /// and loading is triggered after [delayDuration]. 22 | /// Everything can be reset with [.cancel()] 23 | DelayedLoading useDelayedLoading( 24 | [Duration delayDuration = const Duration(milliseconds: 500)]) { 25 | final loading = useState(false); 26 | final pending = useState(false); 27 | final timerHandle = useRef(null); 28 | 29 | return DelayedLoading( 30 | loading: loading.value, 31 | pending: pending.value, 32 | start: () { 33 | timerHandle.value = Timer(delayDuration, () => loading.value = true); 34 | pending.value = true; 35 | }, 36 | cancel: () { 37 | timerHandle.value?.cancel(); 38 | pending.value = false; 39 | loading.value = false; 40 | }, 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /lib/hooks/infinite_scroll.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_hooks/flutter_hooks.dart'; 2 | 3 | import '../widgets/infinite_scroll.dart'; 4 | 5 | InfiniteScrollController useInfiniteScrollController() => 6 | useMemoized(InfiniteScrollController.new); 7 | -------------------------------------------------------------------------------- /lib/hooks/logged_in_action.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_hooks/flutter_hooks.dart'; 3 | import 'package:lemmy_api_client/v3.dart'; 4 | 5 | import '../pages/settings/settings.dart'; 6 | import '../util/goto.dart'; 7 | import 'stores.dart'; 8 | 9 | /// If user has an account for the given instance the passed wrapper will call 10 | /// the passed action with a Jwt token. Otherwise the action is ignored and a 11 | /// Snackbar is rendered. If [any] is set to true, this check is performed for 12 | /// all instances and if any of them have an account, the wrapped action will be 13 | /// called with a null token. 14 | 15 | VoidCallback Function( 16 | void Function(Jwt token) action, [ 17 | String? message, 18 | ]) useAnyLoggedInAction() { 19 | final context = useContext(); 20 | final store = useAccountsStore(); 21 | 22 | return (action, [message]) { 23 | if (store.hasNoAccount) { 24 | return () { 25 | ScaffoldMessenger.of(context).showSnackBar(SnackBar( 26 | content: Text(message ?? 'you have to be logged in to do that'), 27 | action: SnackBarAction( 28 | label: 'log in', 29 | onPressed: () => goTo(context, (_) => AccountsConfigPage())), 30 | )); 31 | }; 32 | } 33 | return () => action(store.defaultUserData!.jwt); 34 | }; 35 | } 36 | 37 | VoidCallback Function( 38 | void Function(Jwt token) action, [ 39 | String? message, 40 | ]) useLoggedInAction(String instanceHost) { 41 | final context = useContext(); 42 | final store = useAccountsStore(); 43 | 44 | return (action, [message]) { 45 | if (store.isAnonymousFor(instanceHost)) { 46 | return () { 47 | ScaffoldMessenger.of(context).showSnackBar(SnackBar( 48 | content: Text(message ?? 'you have to be logged in to do that'), 49 | action: SnackBarAction( 50 | label: 'log in', 51 | onPressed: () => goTo(context, (_) => AccountsConfigPage())), 52 | )); 53 | }; 54 | } 55 | final token = store.defaultUserDataFor(instanceHost)!.jwt; 56 | return () => action(token); 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /lib/hooks/memo_future.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_hooks/flutter_hooks.dart'; 3 | 4 | /// creates an [AsyncSnapshot] from the Future returned from the valueBuilder. 5 | /// [keys] can be used to rebuild the Future 6 | AsyncSnapshot useMemoFuture( 7 | Future Function() valueBuilder, [ 8 | List keys = const [], 9 | ]) => 10 | useFuture( 11 | useMemoized>(valueBuilder, keys), 12 | preserveState: false, 13 | initialData: null, 14 | ); 15 | -------------------------------------------------------------------------------- /lib/hooks/refreshable.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_hooks/flutter_hooks.dart'; 4 | 5 | import 'memo_future.dart'; 6 | 7 | class Refreshable { 8 | const Refreshable({required this.snapshot, required this.refresh}); 9 | 10 | final AsyncSnapshot snapshot; 11 | final AsyncCallback refresh; 12 | } 13 | 14 | /// Similar to [useMemoFuture] but adds a `.refresh` method which 15 | /// allows to re-run the fetcher. Calling `.refresh` will not 16 | /// turn AsyncSnapshot into a loading state. Instead it will 17 | /// replace the ready state with the new data when available 18 | /// 19 | /// `keys` will re-run the initial fetching thus yielding a 20 | /// loading state in the AsyncSnapshot 21 | Refreshable useRefreshable( 22 | AsyncValueGetter fetcher, [ 23 | List keys = const [], 24 | ]) { 25 | final newData = useState(null); 26 | final snapshot = useMemoFuture(() async { 27 | newData.value = null; 28 | return fetcher(); 29 | }, keys); 30 | 31 | final outSnapshot = () { 32 | if (newData.value != null) { 33 | return AsyncSnapshot.withData(ConnectionState.done, newData.value!); 34 | } 35 | return snapshot; 36 | }(); 37 | 38 | return Refreshable( 39 | snapshot: outSnapshot, 40 | refresh: () async { 41 | newData.value = await fetcher(); 42 | }, 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /lib/hooks/stores.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_hooks/flutter_hooks.dart' hide Store; 2 | import 'package:mobx/mobx.dart'; 3 | import 'package:provider/provider.dart'; 4 | 5 | import '../stores/accounts_store.dart'; 6 | 7 | AccountsStore useAccountsStore() => useContext().watch(); 8 | T useAccountsStoreSelect(T selector(AccountsStore store)) => 9 | useContext().select(selector); 10 | 11 | V useStore(V Function(S value) selector) { 12 | final context = useContext(); 13 | final store = context.read(); 14 | final state = useState(selector(store)); 15 | 16 | useEffect(() { 17 | return autorun((_) { 18 | state.value = selector(store); 19 | }); 20 | }, []); 21 | 22 | return state.value; 23 | } 24 | -------------------------------------------------------------------------------- /lib/l10n/l10n_api.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:lemmy_api_client/v3.dart'; 3 | 4 | import 'gen/l10n.dart'; 5 | 6 | extension SortTypeL10n on SortType { 7 | String tr(BuildContext context) { 8 | switch (this) { 9 | case SortType.hot: 10 | return L10n.of(context).hot; 11 | case SortType.new_: 12 | return L10n.of(context).new_; 13 | case SortType.topYear: 14 | return L10n.of(context).top_year; 15 | case SortType.topMonth: 16 | return L10n.of(context).top_month; 17 | case SortType.topWeek: 18 | return L10n.of(context).top_week; 19 | case SortType.topDay: 20 | return L10n.of(context).top_day; 21 | case SortType.topAll: 22 | return L10n.of(context).top_all; 23 | case SortType.newComments: 24 | return L10n.of(context).new_comments; 25 | case SortType.active: 26 | return L10n.of(context).active; 27 | case SortType.mostComments: 28 | return L10n.of(context).most_comments; 29 | default: 30 | throw Exception('unreachable'); 31 | } 32 | } 33 | } 34 | 35 | extension PostListingTypeL10n on PostListingType { 36 | String tr(BuildContext context) { 37 | switch (this) { 38 | case PostListingType.all: 39 | return L10n.of(context).all; 40 | case PostListingType.community: 41 | return L10n.of(context).community; 42 | case PostListingType.local: 43 | return L10n.of(context).local; 44 | case PostListingType.subscribed: 45 | return L10n.of(context).subscribed; 46 | default: 47 | throw Exception('unreachable'); 48 | } 49 | } 50 | } 51 | 52 | extension SearchTypeL10n on SearchType { 53 | String tr(BuildContext context) { 54 | switch (this) { 55 | case SearchType.all: 56 | return L10n.of(context).all; 57 | case SearchType.comments: 58 | return L10n.of(context).comments; 59 | case SearchType.communities: 60 | return L10n.of(context).communities; 61 | case SearchType.posts: 62 | return L10n.of(context).posts; 63 | case SearchType.url: 64 | return L10n.of(context).url; 65 | case SearchType.users: 66 | return L10n.of(context).users; 67 | default: 68 | throw Exception('unreachable'); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /lib/l10n/timeago/pl.dart: -------------------------------------------------------------------------------- 1 | import 'package:timeago/timeago.dart'; 2 | 3 | class PlShortMessages implements LookupMessages { 4 | @override 5 | String prefixAgo() => ''; 6 | @override 7 | String prefixFromNow() => ''; 8 | @override 9 | String suffixAgo() => ''; 10 | @override 11 | String suffixFromNow() => ''; 12 | @override 13 | String lessThanOneMinute(int seconds) => 'teraz'; 14 | @override 15 | String aboutAMinute(int minutes) => '1min.'; 16 | @override 17 | String minutes(int minutes) => '${minutes}min.'; 18 | @override 19 | String aboutAnHour(int minutes) => '~1g.'; 20 | @override 21 | String hours(int hours) => '${hours}g.'; 22 | @override 23 | String aDay(int hours) => '~1d.'; 24 | @override 25 | String days(int days) => '${days}d.'; 26 | @override 27 | String aboutAMonth(int days) => '~1mies.'; 28 | @override 29 | String months(int months) => '${months}mies.'; 30 | @override 31 | String aboutAYear(int year) => '~1r.'; 32 | @override 33 | String years(int years) => _pluralize(years, 'lata', 'lat'); 34 | @override 35 | String wordSeparator() => ' '; 36 | 37 | String _pluralize(int n, String form1, String form2) { 38 | // Rules as per https://www.gnu.org/software/gettext/manual/html_node/Plural-forms.html 39 | if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) { 40 | return '$n $form1'; 41 | } 42 | 43 | return '$n $form2'; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/main_dev.dart: -------------------------------------------------------------------------------- 1 | import 'app_config.dart'; 2 | import 'main_common.dart'; 3 | 4 | void main() { 5 | mainCommon(const AppConfig(debugMode: true)); 6 | } 7 | -------------------------------------------------------------------------------- /lib/main_prod.dart: -------------------------------------------------------------------------------- 1 | import 'app_config.dart'; 2 | import 'main_common.dart'; 3 | 4 | void main() { 5 | mainCommon(const AppConfig(debugMode: false)); 6 | } 7 | -------------------------------------------------------------------------------- /lib/pages/communities_list.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:lemmy_api_client/v3.dart'; 3 | 4 | import '../util/goto.dart'; 5 | import '../widgets/avatar.dart'; 6 | import '../widgets/markdown_text.dart'; 7 | import '../widgets/sortable_infinite_list.dart'; 8 | 9 | /// Infinite list of Communities fetched by the given fetcher 10 | class CommunitiesListPage extends StatelessWidget { 11 | final String title; 12 | final FetcherWithSorting fetcher; 13 | 14 | const CommunitiesListPage({ 15 | super.key, 16 | required this.fetcher, 17 | this.title = '', 18 | }); 19 | 20 | @override 21 | Widget build(BuildContext context) { 22 | final theme = Theme.of(context); 23 | 24 | return Scaffold( 25 | appBar: AppBar( 26 | backgroundColor: theme.cardColor, 27 | title: Text(title), 28 | ), 29 | body: SortableInfiniteList( 30 | fetcher: fetcher, 31 | itemBuilder: (community) => Column( 32 | children: [ 33 | const Divider(), 34 | CommunitiesListItem( 35 | community: community, 36 | ) 37 | ], 38 | ), 39 | uniqueProp: (item) => item.community.actorId, 40 | ), 41 | ); 42 | } 43 | } 44 | 45 | class CommunitiesListItem extends StatelessWidget { 46 | final CommunityView community; 47 | 48 | const CommunitiesListItem({super.key, required this.community}); 49 | 50 | @override 51 | Widget build(BuildContext context) => ListTile( 52 | title: Text(community.community.name), 53 | subtitle: community.community.description != null 54 | ? Opacity( 55 | opacity: 0.7, 56 | child: MarkdownText( 57 | community.community.description!, 58 | instanceHost: community.instanceHost, 59 | ), 60 | ) 61 | : null, 62 | onTap: () => goToCommunity.byId( 63 | context, community.instanceHost, community.community.id), 64 | leading: Avatar(url: community.community.icon), 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /lib/pages/community/community_follow_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_hooks/flutter_hooks.dart'; 3 | import 'package:lemmy_api_client/v3.dart'; 4 | 5 | import '../../hooks/logged_in_action.dart'; 6 | import '../../l10n/l10n.dart'; 7 | import '../../util/observer_consumers.dart'; 8 | import 'community_store.dart'; 9 | 10 | class CommunityFollowButton extends HookWidget { 11 | final CommunityView communityView; 12 | 13 | const CommunityFollowButton(this.communityView); 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | final theme = Theme.of(context); 18 | 19 | final loggedInAction = 20 | useLoggedInAction(context.read().instanceHost); 21 | 22 | return ObserverBuilder(builder: (context, store) { 23 | return ElevatedButtonTheme( 24 | data: ElevatedButtonThemeData( 25 | style: theme.elevatedButtonTheme.style?.copyWith( 26 | shape: MaterialStateProperty.all(const StadiumBorder()), 27 | textStyle: MaterialStateProperty.all(theme.textTheme.subtitle1), 28 | ), 29 | ), 30 | child: Center( 31 | child: SizedBox( 32 | height: 27, 33 | width: 160, 34 | child: ElevatedButton( 35 | onPressed: store.subscribingState.isLoading 36 | ? () {} 37 | : loggedInAction(store.subscribe), 38 | child: store.subscribingState.isLoading 39 | ? const SizedBox( 40 | width: 15, 41 | height: 15, 42 | child: CircularProgressIndicator.adaptive(), 43 | ) 44 | : Row( 45 | mainAxisSize: MainAxisSize.min, 46 | children: [ 47 | if (communityView.subscribed) 48 | const Icon(Icons.remove, size: 18) 49 | else 50 | const Icon(Icons.add, size: 18), 51 | const SizedBox(width: 5), 52 | Flexible( 53 | child: Text(communityView.subscribed 54 | ? L10n.of(context).unsubscribe 55 | : L10n.of(context).subscribe)) 56 | ], 57 | ), 58 | )), 59 | ), 60 | ); 61 | }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/pages/community/community_more_menu.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_hooks/flutter_hooks.dart'; 3 | import 'package:lemmy_api_client/v3.dart'; 4 | 5 | import '../../hooks/logged_in_action.dart'; 6 | import '../../url_launcher.dart'; 7 | import '../../util/extensions/api.dart'; 8 | import '../../util/mobx_provider.dart'; 9 | import '../../util/observer_consumers.dart'; 10 | import '../../widgets/bottom_modal.dart'; 11 | import '../../widgets/info_table_popup.dart'; 12 | import 'community_store.dart'; 13 | 14 | class CommunityMoreMenu extends HookWidget { 15 | final FullCommunityView fullCommunityView; 16 | 17 | const CommunityMoreMenu({super.key, required this.fullCommunityView}); 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | final communityView = fullCommunityView.communityView; 22 | 23 | final loggedInAction = useLoggedInAction(communityView.instanceHost); 24 | 25 | return Column( 26 | children: [ 27 | ListTile( 28 | leading: const Icon(Icons.open_in_browser), 29 | title: const Text('Open in browser'), 30 | onTap: () => launchLink( 31 | link: communityView.community.actorId, 32 | context: context, 33 | ), 34 | ), 35 | ObserverBuilder(builder: (context, store) { 36 | return ListTile( 37 | leading: store.blockingState.isLoading 38 | ? const CircularProgressIndicator.adaptive() 39 | : const Icon(Icons.block), 40 | title: Text( 41 | '${fullCommunityView.communityView.blocked ? 'Unblock' : 'Block'} ${communityView.community.preferredName}'), 42 | onTap: store.blockingState.isLoading 43 | ? null 44 | : loggedInAction((token) { 45 | store.block(token); 46 | Navigator.of(context).pop(); 47 | }), 48 | ); 49 | }), 50 | ListTile( 51 | leading: const Icon(Icons.info_outline), 52 | title: const Text('Nerd stuff'), 53 | onTap: () { 54 | showInfoTablePopup(context: context, table: communityView.toJson()); 55 | }, 56 | ), 57 | ], 58 | ); 59 | } 60 | 61 | static void open(BuildContext context, FullCommunityView fullCommunityView) { 62 | final store = context.read(); 63 | 64 | showBottomModal( 65 | context: context, 66 | builder: (context) => MobxProvider.value( 67 | value: store, 68 | child: CommunityMoreMenu( 69 | fullCommunityView: fullCommunityView, 70 | ), 71 | ), 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lib/pages/community/community_store.dart: -------------------------------------------------------------------------------- 1 | import 'package:lemmy_api_client/v3.dart'; 2 | import 'package:mobx/mobx.dart'; 3 | 4 | import '../../util/async_store.dart'; 5 | 6 | part 'community_store.g.dart'; 7 | 8 | class CommunityStore = _CommunityStore with _$CommunityStore; 9 | 10 | abstract class _CommunityStore with Store { 11 | final String instanceHost; 12 | final String? communityName; 13 | final int? id; 14 | 15 | // ignore: unused_element 16 | _CommunityStore.fromName({ 17 | required String this.communityName, 18 | required this.instanceHost, 19 | }) : id = null; 20 | // ignore: unused_element 21 | _CommunityStore.fromId({required this.id, required this.instanceHost}) 22 | : communityName = null; 23 | 24 | final communityState = AsyncStore(); 25 | final subscribingState = AsyncStore(); 26 | final blockingState = AsyncStore(); 27 | 28 | @action 29 | Future refresh(Jwt? token) async { 30 | await communityState.runLemmy( 31 | instanceHost, 32 | GetCommunity( 33 | auth: token?.raw, 34 | id: id, 35 | name: communityName, 36 | ), 37 | refresh: true, 38 | ); 39 | } 40 | 41 | Future block(Jwt token) async { 42 | final state = communityState.asyncState; 43 | if (state is! AsyncStateData) { 44 | throw StateError('communityState should be ready at this point'); 45 | } 46 | 47 | final res = await blockingState.runLemmy( 48 | instanceHost, 49 | BlockCommunity( 50 | communityId: state.data.communityView.community.id, 51 | block: !state.data.communityView.blocked, 52 | auth: token.raw, 53 | ), 54 | ); 55 | 56 | if (res != null) { 57 | communityState 58 | .setData(state.data.copyWith(communityView: res.communityView)); 59 | } 60 | } 61 | 62 | @action 63 | Future subscribe(Jwt token) async { 64 | final state = communityState.asyncState; 65 | 66 | if (state is! AsyncStateData) { 67 | throw StateError('FullCommunityView should be not null at this point'); 68 | } 69 | final communityView = state.data.communityView; 70 | 71 | final res = await subscribingState.runLemmy( 72 | instanceHost, 73 | FollowCommunity( 74 | communityId: communityView.community.id, 75 | follow: !communityView.subscribed, 76 | auth: token.raw, 77 | ), 78 | ); 79 | 80 | if (res != null) { 81 | communityState.setData(state.data.copyWith(communityView: res)); 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /lib/pages/community/community_store.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'community_store.dart'; 4 | 5 | // ************************************************************************** 6 | // StoreGenerator 7 | // ************************************************************************** 8 | 9 | // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers 10 | 11 | mixin _$CommunityStore on _CommunityStore, Store { 12 | late final _$refreshAsyncAction = 13 | AsyncAction('_CommunityStore.refresh', context: context); 14 | 15 | @override 16 | Future refresh(Jwt? token) { 17 | return _$refreshAsyncAction.run(() => super.refresh(token)); 18 | } 19 | 20 | late final _$subscribeAsyncAction = 21 | AsyncAction('_CommunityStore.subscribe', context: context); 22 | 23 | @override 24 | Future subscribe(Jwt token) { 25 | return _$subscribeAsyncAction.run(() => super.subscribe(token)); 26 | } 27 | 28 | @override 29 | String toString() { 30 | return ''' 31 | 32 | '''; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/pages/create_post/create_post_fab.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_hooks/flutter_hooks.dart'; 3 | import 'package:lemmy_api_client/v3.dart'; 4 | 5 | import '../../hooks/logged_in_action.dart'; 6 | import '../full_post/full_post.dart'; 7 | import 'create_post.dart'; 8 | 9 | /// Fab that triggers the [CreatePost] modal 10 | /// After creation it will navigate to the newly created post 11 | class CreatePostFab extends HookWidget { 12 | final CommunityView? community; 13 | 14 | const CreatePostFab({this.community}); 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | final loggedInAction = useAnyLoggedInAction(); 19 | 20 | return FloatingActionButton( 21 | onPressed: loggedInAction((_) async { 22 | final postView = await Navigator.of(context).push( 23 | community == null 24 | ? CreatePostPage.route() 25 | : CreatePostPage.toCommunityRoute(community!), 26 | ); 27 | 28 | if (postView != null) { 29 | await Navigator.of(context) 30 | .push(FullPostPage.fromPostViewRoute(postView)); 31 | } 32 | }), 33 | child: const Icon(Icons.add), 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/pages/create_post/create_post_instance_picker.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import '../../stores/accounts_store.dart'; 4 | import '../../util/observer_consumers.dart'; 5 | import '../../widgets/radio_picker.dart'; 6 | import 'create_post_store.dart'; 7 | 8 | class CreatePostInstancePicker extends StatelessWidget { 9 | const CreatePostInstancePicker({super.key}); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | final loggedInInstances = 14 | context.watch().loggedInInstances.toList(); 15 | 16 | return ObserverBuilder( 17 | builder: (context, store) => RadioPicker( 18 | values: loggedInInstances, 19 | groupValue: store.instanceHost, 20 | onChanged: store.isEdit ? null : (value) => store.instanceHost = value, 21 | buttonBuilder: (context, displayValue, onPressed) => TextButton( 22 | onPressed: onPressed, 23 | child: Row( 24 | mainAxisAlignment: MainAxisAlignment.center, 25 | children: [ 26 | Text(displayValue), 27 | const Icon(Icons.arrow_drop_down), 28 | ], 29 | ), 30 | ), 31 | ), 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/pages/home_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | import 'package:flutter_hooks/flutter_hooks.dart'; 4 | 5 | import '../util/extensions/brightness.dart'; 6 | import 'communities_tab.dart'; 7 | import 'create_post/create_post_fab.dart'; 8 | import 'home_tab.dart'; 9 | import 'profile_tab.dart'; 10 | import 'search_tab.dart'; 11 | 12 | class HomePage extends HookWidget { 13 | const HomePage(); 14 | 15 | static const List pages = [ 16 | HomeTab(), 17 | CommunitiesTab(), 18 | SearchTab(), 19 | UserProfileTab(), 20 | ]; 21 | 22 | @override 23 | Widget build(BuildContext context) { 24 | final theme = Theme.of(context); 25 | final currentTab = useState(0); 26 | 27 | useEffect(() { 28 | Future.microtask( 29 | () => SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( 30 | systemNavigationBarColor: theme.scaffoldBackgroundColor, 31 | systemNavigationBarIconBrightness: theme.brightness.reverse, 32 | )), 33 | ); 34 | 35 | return null; 36 | }, [theme.scaffoldBackgroundColor]); 37 | 38 | var tabCounter = 0; 39 | 40 | tabButton(IconData icon) { 41 | final tabNum = tabCounter++; 42 | 43 | return IconButton( 44 | icon: Icon(icon), 45 | color: tabNum == currentTab.value ? theme.colorScheme.secondary : null, 46 | onPressed: () => currentTab.value = tabNum, 47 | ); 48 | } 49 | 50 | return Scaffold( 51 | extendBody: true, 52 | body: Column( 53 | children: [ 54 | Expanded( 55 | child: IndexedStack( 56 | index: currentTab.value, 57 | children: pages, 58 | ), 59 | ), 60 | const SizedBox(height: kMinInteractiveDimension / 2), 61 | ], 62 | ), 63 | floatingActionButton: const CreatePostFab(), 64 | floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, 65 | bottomNavigationBar: BottomAppBar( 66 | shape: const CircularNotchedRectangle(), 67 | notchMargin: 7, 68 | child: SizedBox( 69 | height: 60, 70 | child: Row( 71 | mainAxisAlignment: MainAxisAlignment.spaceAround, 72 | children: [ 73 | tabButton(Icons.home), 74 | tabButton(Icons.list), 75 | const SizedBox.shrink(), 76 | const SizedBox.shrink(), 77 | tabButton(Icons.search), 78 | tabButton(Icons.person), 79 | ], 80 | ), 81 | ), 82 | ), 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /lib/pages/instance/instance_more_menu.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:lemmy_api_client/v3.dart'; 3 | 4 | import '../../l10n/l10n.dart'; 5 | import '../../stores/accounts_store.dart'; 6 | import '../../url_launcher.dart'; 7 | import '../../util/observer_consumers.dart'; 8 | import '../../widgets/bottom_modal.dart'; 9 | import '../../widgets/info_table_popup.dart'; 10 | 11 | class InstanceMoreMenu extends StatelessWidget { 12 | final FullSiteView site; 13 | 14 | const InstanceMoreMenu({super.key, required this.site}); 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | final instanceUrl = 'https://${site.instanceHost}'; 19 | final accountsStore = context.watch(); 20 | 21 | return Column( 22 | children: [ 23 | if (!accountsStore.instances.contains(site.instanceHost)) 24 | ListTile( 25 | leading: const Icon(Icons.add), 26 | title: Text(L10n.of(context).add_instance), 27 | onTap: () { 28 | accountsStore.addInstance(site.instanceHost, assumeValid: true); 29 | Navigator.of(context).pop(); 30 | ScaffoldMessenger.of(context) 31 | ..hideCurrentSnackBar() 32 | ..showSnackBar( 33 | SnackBar(content: Text(L10n.of(context).instance_added)), 34 | ); 35 | }, 36 | ), 37 | ListTile( 38 | leading: const Icon(Icons.open_in_browser), 39 | title: Text(L10n.of(context).open_in_browser), 40 | onTap: () => launchLink(link: instanceUrl, context: context), 41 | ), 42 | ListTile( 43 | leading: const Icon(Icons.info_outline), 44 | title: Text(L10n.of(context).nerd_stuff), 45 | onTap: () { 46 | showInfoTablePopup(context: context, table: site.toJson()); 47 | }, 48 | ), 49 | ], 50 | ); 51 | } 52 | 53 | static void open(BuildContext context, FullSiteView site) { 54 | showBottomModal( 55 | context: context, 56 | builder: (context) => InstanceMoreMenu(site: site), 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lib/pages/instance/instance_store.dart: -------------------------------------------------------------------------------- 1 | import 'package:lemmy_api_client/v3.dart'; 2 | import 'package:mobx/mobx.dart'; 3 | 4 | import '../../util/async_store.dart'; 5 | 6 | part 'instance_store.g.dart'; 7 | 8 | class InstanceStore = _InstanceStore with _$InstanceStore; 9 | 10 | abstract class _InstanceStore with Store { 11 | final String instanceHost; 12 | 13 | _InstanceStore(this.instanceHost); 14 | 15 | final siteState = AsyncStore(); 16 | final communitiesState = AsyncStore>(); 17 | 18 | @action 19 | Future fetch(Jwt? token, {bool refresh = false}) async { 20 | await Future.wait([ 21 | siteState.runLemmy( 22 | instanceHost, 23 | GetSite(auth: token?.raw), 24 | refresh: refresh, 25 | ), 26 | fetchCommunites(token, refresh: refresh), 27 | ]); 28 | } 29 | 30 | @action 31 | Future fetchCommunites(Jwt? token, {bool refresh = false}) async { 32 | await communitiesState.runLemmy( 33 | instanceHost, 34 | ListCommunities( 35 | type: PostListingType.local, 36 | sort: SortType.hot, 37 | limit: 6, 38 | auth: token?.raw, 39 | ), 40 | refresh: refresh, 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/pages/instance/instance_store.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'instance_store.dart'; 4 | 5 | // ************************************************************************** 6 | // StoreGenerator 7 | // ************************************************************************** 8 | 9 | // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers 10 | 11 | mixin _$InstanceStore on _InstanceStore, Store { 12 | late final _$fetchAsyncAction = 13 | AsyncAction('_InstanceStore.fetch', context: context); 14 | 15 | @override 16 | Future fetch(Jwt? token, {bool refresh = false}) { 17 | return _$fetchAsyncAction.run(() => super.fetch(token, refresh: refresh)); 18 | } 19 | 20 | late final _$fetchCommunitesAsyncAction = 21 | AsyncAction('_InstanceStore.fetchCommunites', context: context); 22 | 23 | @override 24 | Future fetchCommunites(Jwt? token, {bool refresh = false}) { 25 | return _$fetchCommunitesAsyncAction 26 | .run(() => super.fetchCommunites(token, refresh: refresh)); 27 | } 28 | 29 | @override 30 | String toString() { 31 | return ''' 32 | 33 | '''; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /lib/pages/log_console/log_console_page_store.dart: -------------------------------------------------------------------------------- 1 | import 'package:logging/logging.dart'; 2 | import 'package:mobx/mobx.dart'; 3 | 4 | part 'log_console_page_store.g.dart'; 5 | 6 | class LogConsolePageStore = _LogConsolePageStore with _$LogConsolePageStore; 7 | 8 | abstract class _LogConsolePageStore with Store { 9 | // TODO: implement as an ObservableDeque 10 | final logs = ObservableList(); 11 | static const _bufferSize = 200; 12 | 13 | @action 14 | void addLog(LogRecord logRecord) { 15 | if (logs.length == _bufferSize) { 16 | logs.removeAt(0); 17 | } 18 | 19 | logs.add(logRecord); 20 | } 21 | 22 | List stringified() { 23 | return logs.map( 24 | (log) { 25 | var str = '${log.time} $log'; 26 | 27 | if (log.stackTrace != null) str += '\n${log.stackTrace}'; 28 | 29 | return str; 30 | }, 31 | ).toList(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/pages/log_console/log_console_page_store.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'log_console_page_store.dart'; 4 | 5 | // ************************************************************************** 6 | // StoreGenerator 7 | // ************************************************************************** 8 | 9 | // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers 10 | 11 | mixin _$LogConsolePageStore on _LogConsolePageStore, Store { 12 | late final _$_LogConsolePageStoreActionController = 13 | ActionController(name: '_LogConsolePageStore', context: context); 14 | 15 | @override 16 | void addLog(LogRecord logRecord) { 17 | final _$actionInfo = _$_LogConsolePageStoreActionController.startAction( 18 | name: '_LogConsolePageStore.addLog'); 19 | try { 20 | return super.addLog(logRecord); 21 | } finally { 22 | _$_LogConsolePageStoreActionController.endAction(_$actionInfo); 23 | } 24 | } 25 | 26 | @override 27 | String toString() { 28 | return ''' 29 | 30 | '''; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/pages/modlog/modlog_page_store.dart: -------------------------------------------------------------------------------- 1 | import 'package:lemmy_api_client/v3.dart'; 2 | import 'package:mobx/mobx.dart'; 3 | 4 | import '../../util/async_store.dart'; 5 | import '../../util/mobx_provider.dart'; 6 | 7 | part 'modlog_page_store.g.dart'; 8 | 9 | class ModlogPageStore = _ModlogPageStore with _$ModlogPageStore; 10 | 11 | abstract class _ModlogPageStore with Store, DisposableStore { 12 | final String instanceHost; 13 | final int? communityId; 14 | 15 | // ignore: unused_element 16 | _ModlogPageStore(this.instanceHost, [this.communityId]) { 17 | addReaction(reaction((_) => page, (_) => fetchPage())); 18 | } 19 | 20 | @observable 21 | int page = 1; 22 | 23 | final modlogState = AsyncStore(); 24 | 25 | @computed 26 | bool get hasPreviousPage => page != 1; 27 | 28 | @computed 29 | bool get hasNextPage => 30 | modlogState.asyncState.whenOrNull( 31 | data: (data, error) => 32 | data.removedPosts.length + 33 | data.lockedPosts.length + 34 | data.stickiedPosts.length + 35 | data.removedComments.length + 36 | data.removedCommunities.length + 37 | data.bannedFromCommunity.length + 38 | data.banned.length + 39 | data.addedToCommunity.length + 40 | data.transferredToCommunity.length + 41 | data.added.length != 42 | 0, 43 | ) ?? 44 | true; 45 | 46 | @action 47 | Future fetchPage() async { 48 | await modlogState.runLemmy( 49 | instanceHost, 50 | GetModlog(page: page, communityId: communityId), 51 | ); 52 | } 53 | 54 | @action 55 | void previousPage() { 56 | page--; 57 | } 58 | 59 | @action 60 | void nextPage() { 61 | page++; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/pages/modlog/modlog_page_store.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'modlog_page_store.dart'; 4 | 5 | // ************************************************************************** 6 | // StoreGenerator 7 | // ************************************************************************** 8 | 9 | // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers 10 | 11 | mixin _$ModlogPageStore on _ModlogPageStore, Store { 12 | Computed? _$hasPreviousPageComputed; 13 | 14 | @override 15 | bool get hasPreviousPage => 16 | (_$hasPreviousPageComputed ??= Computed(() => super.hasPreviousPage, 17 | name: '_ModlogPageStore.hasPreviousPage')) 18 | .value; 19 | Computed? _$hasNextPageComputed; 20 | 21 | @override 22 | bool get hasNextPage => 23 | (_$hasNextPageComputed ??= Computed(() => super.hasNextPage, 24 | name: '_ModlogPageStore.hasNextPage')) 25 | .value; 26 | 27 | late final _$pageAtom = Atom(name: '_ModlogPageStore.page', context: context); 28 | 29 | @override 30 | int get page { 31 | _$pageAtom.reportRead(); 32 | return super.page; 33 | } 34 | 35 | @override 36 | set page(int value) { 37 | _$pageAtom.reportWrite(value, super.page, () { 38 | super.page = value; 39 | }); 40 | } 41 | 42 | late final _$fetchPageAsyncAction = 43 | AsyncAction('_ModlogPageStore.fetchPage', context: context); 44 | 45 | @override 46 | Future fetchPage() { 47 | return _$fetchPageAsyncAction.run(() => super.fetchPage()); 48 | } 49 | 50 | late final _$_ModlogPageStoreActionController = 51 | ActionController(name: '_ModlogPageStore', context: context); 52 | 53 | @override 54 | void previousPage() { 55 | final _$actionInfo = _$_ModlogPageStoreActionController.startAction( 56 | name: '_ModlogPageStore.previousPage'); 57 | try { 58 | return super.previousPage(); 59 | } finally { 60 | _$_ModlogPageStoreActionController.endAction(_$actionInfo); 61 | } 62 | } 63 | 64 | @override 65 | void nextPage() { 66 | final _$actionInfo = _$_ModlogPageStoreActionController.startAction( 67 | name: '_ModlogPageStore.nextPage'); 68 | try { 69 | return super.nextPage(); 70 | } finally { 71 | _$_ModlogPageStoreActionController.endAction(_$actionInfo); 72 | } 73 | } 74 | 75 | @override 76 | String toString() { 77 | return ''' 78 | page: ${page}, 79 | hasPreviousPage: ${hasPreviousPage}, 80 | hasNextPage: ${hasNextPage} 81 | '''; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /lib/pages/saved_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_hooks/flutter_hooks.dart'; 3 | import 'package:lemmy_api_client/v3.dart'; 4 | 5 | import '../hooks/stores.dart'; 6 | import '../l10n/l10n.dart'; 7 | import '../widgets/sortable_infinite_list.dart'; 8 | 9 | /// Page with saved posts/comments. Fetches such saved data from the default user 10 | /// Assumes there is at least one logged in user 11 | class SavedPage extends HookWidget { 12 | @override 13 | Widget build(BuildContext context) { 14 | final accountStore = useAccountsStore(); 15 | 16 | if (accountStore.hasNoAccount) { 17 | Scaffold( 18 | appBar: AppBar(), 19 | body: const Center( 20 | child: Text('no account found'), 21 | ), 22 | ); 23 | } 24 | 25 | return DefaultTabController( 26 | length: 2, 27 | child: Scaffold( 28 | appBar: AppBar( 29 | title: Text(L10n.of(context).saved), 30 | bottom: TabBar( 31 | tabs: [ 32 | Tab(text: L10n.of(context).posts), 33 | Tab(text: L10n.of(context).comments), 34 | ], 35 | ), 36 | ), 37 | body: TabBarView( 38 | children: [ 39 | InfinitePostList( 40 | fetcher: (page, batchSize, sortType) => 41 | LemmyApiV3(accountStore.defaultInstanceHost!).run( 42 | GetPosts( 43 | type: PostListingType.all, 44 | sort: sortType, 45 | savedOnly: true, 46 | page: page, 47 | limit: batchSize, 48 | auth: accountStore.defaultUserData!.jwt.raw, 49 | ), 50 | ), 51 | ), 52 | InfiniteCommentList( 53 | fetcher: (page, batchSize, sortType) => 54 | LemmyApiV3(accountStore.defaultInstanceHost!).run( 55 | GetComments( 56 | type: CommentListingType.all, 57 | sort: sortType, 58 | savedOnly: true, 59 | page: page, 60 | limit: batchSize, 61 | auth: accountStore.defaultUserData!.jwt.raw, 62 | ), 63 | ), 64 | ), 65 | ], 66 | ), 67 | ), 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lib/pages/search_tab.dart: -------------------------------------------------------------------------------- 1 | import 'package:collection/collection.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_hooks/flutter_hooks.dart'; 4 | 5 | import '../hooks/stores.dart'; 6 | import '../l10n/l10n.dart'; 7 | import '../util/goto.dart'; 8 | import '../widgets/radio_picker.dart'; 9 | import 'search_results.dart'; 10 | 11 | class SearchTab extends HookWidget { 12 | const SearchTab(); 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | final searchInputController = useListenable(useTextEditingController()); 17 | 18 | final accStore = useAccountsStore(); 19 | // null if there are no added instances 20 | final instanceHost = useState( 21 | accStore.instances.firstWhereOrNull((_) => true), 22 | ); 23 | 24 | if (instanceHost.value == null) { 25 | return Scaffold( 26 | appBar: AppBar(), 27 | body: const Center( 28 | child: Text('You do not have any instances added'), 29 | ), 30 | ); 31 | } 32 | 33 | handleSearch() => searchInputController.text.isNotEmpty 34 | ? goTo( 35 | context, 36 | (context) => SearchResultsPage( 37 | instanceHost: instanceHost.value!, 38 | query: searchInputController.text, 39 | ), 40 | ) 41 | : null; 42 | 43 | return Scaffold( 44 | appBar: AppBar(), 45 | body: ListView( 46 | padding: const EdgeInsets.symmetric(horizontal: 20), 47 | children: [ 48 | TextField( 49 | controller: searchInputController, 50 | keyboardType: TextInputType.text, 51 | textAlign: TextAlign.center, 52 | onSubmitted: (_) => handleSearch(), 53 | decoration: InputDecoration(hintText: L10n.of(context).search), 54 | ), 55 | const SizedBox(height: 5), 56 | Row( 57 | mainAxisAlignment: MainAxisAlignment.center, 58 | children: [ 59 | Expanded( 60 | child: Text('instance:', 61 | style: Theme.of(context).textTheme.subtitle1), 62 | ), 63 | Expanded( 64 | child: RadioPicker( 65 | values: accStore.instances.toList(), 66 | groupValue: instanceHost.value!, 67 | onChanged: (value) => instanceHost.value = value, 68 | ), 69 | ), 70 | ], 71 | ), 72 | if (searchInputController.text.isNotEmpty) 73 | ElevatedButton( 74 | onPressed: handleSearch, 75 | child: Text(L10n.of(context).search), 76 | ) 77 | ], 78 | ), 79 | ); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /lib/pages/settings/blocks/block_tile.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_hooks/flutter_hooks.dart'; 3 | 4 | import '../../../util/async_store_listener.dart'; 5 | import '../../../util/extensions/api.dart'; 6 | import '../../../util/goto.dart'; 7 | import '../../../util/observer_consumers.dart'; 8 | import '../../../widgets/avatar.dart'; 9 | import 'community_block_store.dart'; 10 | import 'user_block_store.dart'; 11 | 12 | class BlockPersonTile extends StatelessWidget { 13 | const BlockPersonTile({super.key}); 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return AsyncStoreListener( 18 | asyncStore: context.read().unblockingState, 19 | child: ObserverBuilder( 20 | builder: (context, store) { 21 | return ListTile( 22 | leading: Avatar(url: store.person.avatar), 23 | title: Text(store.person.originPreferredName), 24 | trailing: IconButton( 25 | icon: store.unblockingState.isLoading 26 | ? const CircularProgressIndicator.adaptive() 27 | : const Icon(Icons.cancel), 28 | tooltip: 'unblock', 29 | onPressed: store.unblock, 30 | ), 31 | onTap: () { 32 | goToUser.byId( 33 | context, store.person.instanceHost, store.person.id); 34 | }, 35 | ); 36 | }, 37 | ), 38 | ); 39 | } 40 | } 41 | 42 | class BlockCommunityTile extends HookWidget { 43 | const BlockCommunityTile({super.key}); 44 | 45 | @override 46 | Widget build(BuildContext context) { 47 | return AsyncStoreListener( 48 | asyncStore: context.read().unblockingState, 49 | child: ObserverBuilder( 50 | builder: (context, store) { 51 | return ListTile( 52 | leading: Avatar(url: store.community.icon), 53 | title: Text(store.community.originPreferredName), 54 | trailing: IconButton( 55 | icon: store.unblockingState.isLoading 56 | ? const CircularProgressIndicator.adaptive() 57 | : const Icon(Icons.cancel), 58 | tooltip: 'unblock', 59 | onPressed: store.unblock, 60 | ), 61 | onTap: () { 62 | goToCommunity.byId( 63 | context, store.community.instanceHost, store.community.id); 64 | }, 65 | ); 66 | }, 67 | ), 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lib/pages/settings/blocks/community_block_store.dart: -------------------------------------------------------------------------------- 1 | import 'package:lemmy_api_client/v3.dart'; 2 | import 'package:mobx/mobx.dart'; 3 | 4 | import '../../../util/async_store.dart'; 5 | 6 | part 'community_block_store.g.dart'; 7 | 8 | class CommunityBlockStore = _CommunityBlockStore with _$CommunityBlockStore; 9 | 10 | abstract class _CommunityBlockStore with Store { 11 | final String instanceHost; 12 | final Jwt token; 13 | final CommunitySafe community; 14 | 15 | _CommunityBlockStore({ 16 | required this.instanceHost, 17 | required this.token, 18 | required this.community, 19 | }); 20 | 21 | final unblockingState = AsyncStore(); 22 | 23 | @observable 24 | bool blocked = true; 25 | 26 | Future unblock() async { 27 | final result = await unblockingState.runLemmy( 28 | instanceHost, 29 | BlockCommunity( 30 | communityId: community.id, 31 | block: false, 32 | auth: token.raw, 33 | )); 34 | if (result != null) { 35 | blocked = result.blocked; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/pages/settings/blocks/community_block_store.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'community_block_store.dart'; 4 | 5 | // ************************************************************************** 6 | // StoreGenerator 7 | // ************************************************************************** 8 | 9 | // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers 10 | 11 | mixin _$CommunityBlockStore on _CommunityBlockStore, Store { 12 | late final _$blockedAtom = 13 | Atom(name: '_CommunityBlockStore.blocked', context: context); 14 | 15 | @override 16 | bool get blocked { 17 | _$blockedAtom.reportRead(); 18 | return super.blocked; 19 | } 20 | 21 | @override 22 | set blocked(bool value) { 23 | _$blockedAtom.reportWrite(value, super.blocked, () { 24 | super.blocked = value; 25 | }); 26 | } 27 | 28 | @override 29 | String toString() { 30 | return ''' 31 | blocked: ${blocked} 32 | '''; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/pages/settings/blocks/user_block_store.dart: -------------------------------------------------------------------------------- 1 | import 'package:lemmy_api_client/v3.dart'; 2 | import 'package:mobx/mobx.dart'; 3 | 4 | import '../../../util/async_store.dart'; 5 | 6 | part 'user_block_store.g.dart'; 7 | 8 | class UserBlockStore = _UserBlockStore with _$UserBlockStore; 9 | 10 | abstract class _UserBlockStore with Store { 11 | final String instanceHost; 12 | final Jwt token; 13 | final PersonSafe person; 14 | 15 | _UserBlockStore({ 16 | required this.instanceHost, 17 | required this.token, 18 | required this.person, 19 | }); 20 | 21 | final unblockingState = AsyncStore(); 22 | 23 | @observable 24 | bool blocked = true; 25 | 26 | Future unblock() async { 27 | final result = await unblockingState.runLemmy( 28 | instanceHost, 29 | BlockPerson( 30 | personId: person.id, 31 | block: false, 32 | auth: token.raw, 33 | )); 34 | if (result != null) { 35 | blocked = result.blocked; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/pages/settings/blocks/user_block_store.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'user_block_store.dart'; 4 | 5 | // ************************************************************************** 6 | // StoreGenerator 7 | // ************************************************************************** 8 | 9 | // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers 10 | 11 | mixin _$UserBlockStore on _UserBlockStore, Store { 12 | late final _$blockedAtom = 13 | Atom(name: '_UserBlockStore.blocked', context: context); 14 | 15 | @override 16 | bool get blocked { 17 | _$blockedAtom.reportRead(); 18 | return super.blocked; 19 | } 20 | 21 | @override 22 | set blocked(bool value) { 23 | _$blockedAtom.reportWrite(value, super.blocked, () { 24 | super.blocked = value; 25 | }); 26 | } 27 | 28 | @override 29 | String toString() { 30 | return ''' 31 | blocked: ${blocked} 32 | '''; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/pages/user.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_hooks/flutter_hooks.dart'; 3 | import 'package:lemmy_api_client/v3.dart'; 4 | 5 | import '../hooks/logged_in_action.dart'; 6 | import '../util/icons.dart'; 7 | import '../util/share.dart'; 8 | import '../widgets/user_profile.dart'; 9 | import 'write_message.dart'; 10 | 11 | /// Page showing posts, comments, and general info about a user. 12 | class UserPage extends HookWidget { 13 | final int? userId; 14 | final String instanceHost; 15 | final Future _userDetails; 16 | 17 | UserPage({required this.userId, required this.instanceHost}) 18 | : _userDetails = LemmyApiV3(instanceHost).run(GetPersonDetails( 19 | personId: userId, savedOnly: true, sort: SortType.active)); 20 | 21 | UserPage.fromName({required this.instanceHost, required String username}) 22 | : userId = null, 23 | _userDetails = LemmyApiV3(instanceHost).run(GetPersonDetails( 24 | username: username, savedOnly: true, sort: SortType.active)); 25 | 26 | @override 27 | Widget build(BuildContext context) { 28 | final userDetailsSnap = useFuture(_userDetails); 29 | 30 | final body = () { 31 | if (userDetailsSnap.hasData) { 32 | return UserProfile.fromFullPersonView(userDetailsSnap.data!); 33 | } else if (userDetailsSnap.hasError) { 34 | return const Center(child: Text('Could not find that user.')); 35 | } else { 36 | return const Center(child: CircularProgressIndicator.adaptive()); 37 | } 38 | }(); 39 | 40 | return Scaffold( 41 | extendBodyBehindAppBar: true, 42 | appBar: AppBar( 43 | actions: [ 44 | if (userDetailsSnap.hasData) ...[ 45 | SendMessageButton(userDetailsSnap.data!.personView.person), 46 | IconButton( 47 | icon: Icon(shareIcon), 48 | onPressed: () => share( 49 | userDetailsSnap.data!.personView.person.actorId, 50 | context: context, 51 | ), 52 | ), 53 | ] 54 | ], 55 | ), 56 | body: body, 57 | ); 58 | } 59 | } 60 | 61 | class SendMessageButton extends HookWidget { 62 | final PersonSafe user; 63 | 64 | const SendMessageButton(this.user); 65 | 66 | @override 67 | Widget build(BuildContext context) { 68 | final loggedInAction = useLoggedInAction(user.instanceHost); 69 | 70 | return IconButton( 71 | icon: const Icon(Icons.email), 72 | onPressed: loggedInAction( 73 | (token) => Navigator.of(context).push( 74 | WriteMessagePage.sendRoute( 75 | instanceHost: user.instanceHost, 76 | recipient: user, 77 | ), 78 | ), 79 | ), 80 | ); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /lib/pages/users_list.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:lemmy_api_client/v3.dart'; 3 | 4 | import '../util/extensions/api.dart'; 5 | import '../util/goto.dart'; 6 | import '../widgets/avatar.dart'; 7 | import '../widgets/infinite_scroll.dart'; 8 | import '../widgets/markdown_text.dart'; 9 | 10 | /// Infinite list of Users fetched by the given fetcher 11 | class UsersListPage extends StatelessWidget { 12 | final String title; 13 | final Fetcher fetcher; 14 | 15 | const UsersListPage({super.key, required this.fetcher, this.title = ''}); 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | final theme = Theme.of(context); 20 | 21 | return Scaffold( 22 | appBar: AppBar( 23 | backgroundColor: theme.cardColor, 24 | title: Text(title), 25 | ), 26 | body: InfiniteScroll( 27 | fetcher: fetcher, 28 | itemBuilder: (user) => Column( 29 | children: [ 30 | const Divider(), 31 | UsersListItem(user: user), 32 | ], 33 | ), 34 | uniqueProp: (user) => user.person.actorId, 35 | ), 36 | ); 37 | } 38 | } 39 | 40 | class UsersListItem extends StatelessWidget { 41 | final PersonViewSafe user; 42 | 43 | const UsersListItem({super.key, required this.user}); 44 | 45 | @override 46 | Widget build(BuildContext context) { 47 | return ListTile( 48 | title: Text(user.person.originPreferredName), 49 | subtitle: user.person.bio != null 50 | ? Opacity( 51 | opacity: 0.7, 52 | child: MarkdownText( 53 | user.person.bio!, 54 | instanceHost: user.instanceHost, 55 | ), 56 | ) 57 | : null, 58 | onTap: () => goToUser.fromPersonSafe(context, user.person), 59 | leading: Avatar(url: user.person.avatar), 60 | ); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /lib/resources/links.dart: -------------------------------------------------------------------------------- 1 | const lemmurRepositoryUrl = 'https://github.com/LemmurOrg/lemmur'; 2 | const patreonUrl = 'https://patreon.com/lemmur'; 3 | const buyMeACoffeeUrl = 'https://buymeacoff.ee/lemmur'; 4 | const markdownGuide = 5 | 'https://join-lemmy.org/docs/en/about/guide.html#using-markdown'; 6 | -------------------------------------------------------------------------------- /lib/stores/accounts_store.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'accounts_store.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | AccountsStore _$AccountsStoreFromJson(Map json) => 10 | AccountsStore() 11 | ..accounts = (json['accounts'] as Map?)?.map( 12 | (k, e) => MapEntry( 13 | k, 14 | (e as Map).map( 15 | (k, e) => 16 | MapEntry(k, UserData.fromJson(e as Map)), 17 | )), 18 | ) ?? 19 | {'lemmy.ml': {}} 20 | ..defaultAccounts = 21 | (json['defaultAccounts'] as Map?)?.map( 22 | (k, e) => MapEntry(k, e as String), 23 | ) ?? 24 | {} 25 | ..defaultAccount = json['defaultAccount'] as String?; 26 | 27 | Map _$AccountsStoreToJson(AccountsStore instance) => 28 | { 29 | 'accounts': instance.accounts, 30 | 'defaultAccounts': instance.defaultAccounts, 31 | 'defaultAccount': instance.defaultAccount, 32 | }; 33 | 34 | UserData _$UserDataFromJson(Map json) => UserData( 35 | jwt: Jwt.fromJson(json['jwt'] as String), 36 | userId: json['userId'] as int, 37 | ); 38 | 39 | Map _$UserDataToJson(UserData instance) => { 40 | 'jwt': instance.jwt, 41 | 'userId': instance.userId, 42 | }; 43 | -------------------------------------------------------------------------------- /lib/util/async_store_listener.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/single_child_widget.dart'; 3 | 4 | import '../l10n/l10n_from_string.dart'; 5 | import 'async_store.dart'; 6 | import 'observer_consumers.dart'; 7 | 8 | class AsyncStoreListener extends SingleChildStatelessWidget { 9 | final AsyncStore asyncStore; 10 | final String Function( 11 | BuildContext context, 12 | T data, 13 | )? successMessageBuilder; 14 | 15 | final void Function( 16 | BuildContext context, 17 | T data, 18 | )? onSuccess; 19 | 20 | const AsyncStoreListener({ 21 | super.key, 22 | required this.asyncStore, 23 | this.successMessageBuilder, 24 | this.onSuccess, 25 | super.child, 26 | }); 27 | 28 | @override 29 | Widget buildWithChild(BuildContext context, Widget? child) { 30 | return ObserverListener>( 31 | store: asyncStore, 32 | listener: (context, store) { 33 | store.map( 34 | loading: () {}, 35 | error: (errorTerm) { 36 | ScaffoldMessenger.of(context) 37 | ..hideCurrentSnackBar() 38 | ..showSnackBar(SnackBar(content: Text(errorTerm.tr(context)))); 39 | }, 40 | data: (data) { 41 | onSuccess?.call(context, data); 42 | 43 | if (successMessageBuilder != null) { 44 | ScaffoldMessenger.of(context) 45 | ..hideCurrentSnackBar() 46 | ..showSnackBar( 47 | SnackBar( 48 | content: Text(successMessageBuilder!(context, data)), 49 | ), 50 | ); 51 | } 52 | }, 53 | ); 54 | }, 55 | child: child ?? const SizedBox(), 56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lib/util/cleanup_url.dart: -------------------------------------------------------------------------------- 1 | /// Returns a normalized host of a (maybe) url without a leading www. 2 | String normalizeInstanceHost(String maybeUrl) { 3 | try { 4 | return urlHost( 5 | maybeUrl.startsWith('https://') ? maybeUrl : 'https://$maybeUrl'); 6 | } on FormatException { 7 | return ''; 8 | } 9 | } 10 | 11 | // Returns host of a url without a leading 'www.' if present 12 | String urlHost(String url) { 13 | final host = Uri.parse(url).host; 14 | 15 | if (host.startsWith('www.')) { 16 | return host.substring(4); 17 | } 18 | 19 | return host; 20 | } 21 | -------------------------------------------------------------------------------- /lib/util/delayed_action.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:lemmy_api_client/v3.dart'; 3 | 4 | import '../hooks/delayed_loading.dart'; 5 | 6 | /// Executes an API action that uses [DelayedLoading], has a try-catch 7 | /// that displays a [SnackBar] when the action fails 8 | Future delayedAction({ 9 | required BuildContext context, 10 | required DelayedLoading delayedLoading, 11 | required String instanceHost, 12 | required LemmyApiQuery query, 13 | void Function(T)? onSuccess, 14 | void Function(T?)? cleanup, 15 | }) async { 16 | T? val; 17 | try { 18 | delayedLoading.start(); 19 | val = await LemmyApiV3(instanceHost).run(query); 20 | onSuccess?.call(val as T); 21 | } catch (e) { 22 | ScaffoldMessenger.of(context) 23 | .showSnackBar(SnackBar(content: Text(e.toString()))); 24 | } finally { 25 | cleanup?.call(val); 26 | delayedLoading.cancel(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/util/extensions/api.dart: -------------------------------------------------------------------------------- 1 | import 'package:lemmy_api_client/v3.dart'; 2 | 3 | import '../cleanup_url.dart'; 4 | 5 | // Extensions to lemmy api objects which give a [.originInstanceHost] getter 6 | // allowing for a convenient way of knowing what is the origin of the object 7 | // For example if a post on lemmy.ml is federated from lemmygrad.ml then 8 | // `post.instanceHost == 'lemmy.ml' 9 | // && post.originInstanceHost == 'lemmygrad.ml`` 10 | 11 | extension GetOriginInstanceCommunitySafe on CommunitySafe { 12 | String get originInstanceHost => _extract(actorId); 13 | } 14 | 15 | extension GetOriginInstancePersonSafe on PersonSafe { 16 | String get originInstanceHost => _extract(actorId); 17 | } 18 | 19 | extension GetOriginInstancePostView on Post { 20 | String get originInstanceHost => _extract(apId); 21 | } 22 | 23 | extension GetOriginInstanceCommentView on Comment { 24 | String get originInstanceHost => _extract(apId); 25 | } 26 | 27 | String _extract(String url) => urlHost(url); 28 | 29 | extension CommunityPreferredNames on CommunitySafe { 30 | String get preferredName => '!$name'; 31 | String get originPreferredName => 32 | local ? preferredName : '!$name@$originInstanceHost'; 33 | } 34 | 35 | extension UserPreferredNames on PersonSafe { 36 | String get preferredName { 37 | final dispName = displayName; 38 | if (dispName != null && dispName.isNotEmpty) { 39 | return dispName; 40 | } 41 | 42 | return '@$name'; 43 | } 44 | 45 | String get originPreferredName { 46 | if (!local) return '$preferredName@$originInstanceHost'; 47 | 48 | return preferredName; 49 | } 50 | } 51 | 52 | extension CommentLink on Comment { 53 | String get link => 'https://$instanceHost/post/$postId/comment/$id'; 54 | } 55 | 56 | // inspired by https://github.com/LemmyNet/lemmy-ui/blob/66c846ededef8c0afd5aaadca4aaedcbaeab3ee6/src/shared/utils.ts#L533 57 | extension PersonSafeCakeDay on PersonSafe { 58 | bool get isCakeDay { 59 | final now = DateTime.now().toUtc(); 60 | 61 | return now.day == published.day && 62 | now.month == published.month && 63 | now.year != published.year; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /lib/util/extensions/brightness.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | extension ReverseBrightness on Brightness { 4 | Brightness get reverse => 5 | this == Brightness.dark ? Brightness.light : Brightness.dark; 6 | } 7 | -------------------------------------------------------------------------------- /lib/util/extensions/context.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:lemmy_api_client/v3.dart'; 3 | 4 | import '../../stores/accounts_store.dart'; 5 | import '../observer_consumers.dart'; 6 | 7 | extension BuildContextExtensions on BuildContext { 8 | /// Get default [Jwt] for an instance 9 | Jwt? defaultJwt(String instanceHost) => 10 | read().defaultUserDataFor(instanceHost)?.jwt; 11 | } 12 | -------------------------------------------------------------------------------- /lib/util/extensions/iterators.dart: -------------------------------------------------------------------------------- 1 | extension ExtraIterators on Iterable { 2 | /// A `.map` but with an index as the second argument 3 | Iterable mapWithIndex(T f(E e, int i)) { 4 | var i = 0; 5 | return map((e) => f(e, i++)); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /lib/util/extensions/spaced.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// Creates gaps between given widgets 4 | extension SpaceWidgets on List { 5 | List spaced(double gap) => expand((item) sync* { 6 | yield SizedBox(width: gap, height: gap); 7 | yield item; 8 | }).skip(1).toList(); 9 | } 10 | -------------------------------------------------------------------------------- /lib/util/files.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:file_picker/file_picker.dart'; 4 | import 'package:flutter/foundation.dart'; 5 | import 'package:image_picker/image_picker.dart'; 6 | 7 | /// Picks a single image from the system 8 | Future pickImage() async { 9 | if (kIsWeb || Platform.isIOS || Platform.isAndroid) { 10 | return ImagePicker().pickImage(source: ImageSource.gallery); 11 | } else { 12 | final result = await FilePicker.platform.pickFiles(type: FileType.image); 13 | 14 | if (result == null) return null; 15 | 16 | return XFile(result.files.single.path!); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/util/goto.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:lemmy_api_client/v3.dart'; 3 | 4 | import '../pages/community/community.dart'; 5 | import '../pages/full_post/full_post.dart'; 6 | import '../pages/media_view.dart'; 7 | import '../pages/user.dart'; 8 | 9 | /// Pushes onto the navigator stack the given widget 10 | Future goTo( 11 | BuildContext context, 12 | Widget Function(BuildContext context) builder, 13 | ) => 14 | Navigator.of(context).push(MaterialPageRoute( 15 | builder: builder, 16 | )); 17 | 18 | /// Replaces the top of the navigator stack with the given widget 19 | Future goToReplace( 20 | BuildContext context, 21 | Widget Function(BuildContext context) builder, 22 | ) => 23 | Navigator.of(context).pushReplacement(MaterialPageRoute( 24 | builder: builder, 25 | )); 26 | 27 | // ignore: camel_case_types 28 | abstract class goToCommunity { 29 | /// Navigates to `CommunityPage` 30 | static void byId( 31 | BuildContext context, String instanceHost, int communityId) => 32 | Navigator.of(context) 33 | .push(CommunityPage.fromIdRoute(instanceHost, communityId)); 34 | 35 | static void byName( 36 | BuildContext context, String instanceHost, String communityName) => 37 | Navigator.of(context) 38 | .push(CommunityPage.fromNameRoute(instanceHost, communityName)); 39 | } 40 | 41 | // ignore: camel_case_types 42 | abstract class goToUser { 43 | static void byId(BuildContext context, String instanceHost, int userId) => 44 | goTo(context, 45 | (context) => UserPage(instanceHost: instanceHost, userId: userId)); 46 | 47 | static void byName( 48 | BuildContext context, String instanceHost, String userName) => 49 | throw UnimplementedError('need to create UserProfile constructor first'); 50 | 51 | static void fromPersonSafe(BuildContext context, PersonSafe personSafe) => 52 | goToUser.byId(context, personSafe.instanceHost, personSafe.id); 53 | } 54 | 55 | void goToPost(BuildContext context, String instanceHost, int postId) => 56 | Navigator.of(context).push(FullPostPage.route(postId, instanceHost)); 57 | 58 | void goToMedia(BuildContext context, String url) => Navigator.push( 59 | context, 60 | PageRouteBuilder( 61 | pageBuilder: (_, __, ___) => MediaViewPage(url), 62 | opaque: false, 63 | transitionsBuilder: (_, animation, __, child) => 64 | FadeTransition(opacity: animation, child: child), 65 | ), 66 | ); 67 | -------------------------------------------------------------------------------- /lib/util/hot_rank.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math' show log, max, pow, ln10; 2 | 3 | import 'package:lemmy_api_client/v3.dart'; 4 | 5 | /// Calculates hot rank 6 | /// because API always claims it's `0` 7 | /// and web version of lemmy also calculates it when loading comments 8 | /// 9 | /// implementation taken from here: 10 | /// https://github.com/LemmyNet/lemmy/blob/main/ui/src/utils.ts#L182-L203 11 | double _calculateHotRank(int score, DateTime time) { 12 | log10(num x) => log(x) / ln10; 13 | 14 | final elapsed = (time.difference(DateTime.now()).inMilliseconds).abs() / 36e5; 15 | 16 | return (10000 * log10(max(1, 3 + score))) / pow(elapsed + 2, 1.8); 17 | } 18 | 19 | extension CommentHotRank on CommentView { 20 | double get computedHotRank => 21 | _calculateHotRank(counts.score, comment.published); 22 | } 23 | -------------------------------------------------------------------------------- /lib/util/icons.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io' show Platform; 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | final _isApple = Platform.isIOS || Platform.isMacOS; 6 | 7 | final moreIcon = _isApple ? Icons.more_horiz : Icons.more_vert; 8 | 9 | final shareIcon = _isApple ? Icons.ios_share : Icons.share; 10 | -------------------------------------------------------------------------------- /lib/util/mobx_provider.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:mobx/mobx.dart'; 3 | 4 | import 'observer_consumers.dart'; 5 | 6 | /// Provides a mobx store and disposes it if it implements [DisposableStore] 7 | /// 8 | /// Important: this will not make [context.watch] react to changes 9 | class MobxProvider extends Provider { 10 | MobxProvider({ 11 | super.key, 12 | required super.create, 13 | super.lazy, 14 | super.builder, 15 | super.child, 16 | }) : super( 17 | dispose: (context, store) { 18 | if (store is DisposableStore) store.dispose(); 19 | }, 20 | ); 21 | 22 | /// will not dispose the store 23 | MobxProvider.value({ 24 | super.key, 25 | required super.value, 26 | super.builder, 27 | super.child, 28 | }) : super.value(); 29 | } 30 | 31 | /// tracks reactions and disposes them in [DisposableStore.dispose] 32 | mixin DisposableStore on Store { 33 | final List _disposers = []; 34 | 35 | @protected 36 | void addReaction(ReactionDisposer reaction) => _disposers.add(reaction); 37 | 38 | void dispose() { 39 | for (final disposer in _disposers) { 40 | disposer(); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/util/observer_consumers.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_hooks/flutter_hooks.dart' hide Store; 3 | import 'package:flutter_mobx/flutter_mobx.dart'; 4 | import 'package:mobx/mobx.dart'; 5 | import 'package:provider/provider.dart'; 6 | 7 | export 'package:provider/provider.dart'; 8 | 9 | typedef MobxBuilder = Widget Function(BuildContext, T); 10 | typedef MobxListener = void Function(BuildContext, T); 11 | 12 | class ObserverBuilder extends StatelessWidget { 13 | final T? store; 14 | final MobxBuilder builder; 15 | 16 | const ObserverBuilder({ 17 | super.key, 18 | this.store, 19 | required this.builder, 20 | }); 21 | 22 | @override 23 | Widget build(BuildContext context) { 24 | return Observer( 25 | builder: (context) { 26 | return builder( 27 | context, 28 | store ?? context.read(), 29 | ); 30 | }, 31 | ); 32 | } 33 | } 34 | 35 | class ObserverListener extends HookWidget { 36 | final T? store; 37 | final MobxListener listener; 38 | final Widget child; 39 | 40 | const ObserverListener({ 41 | super.key, 42 | this.store, 43 | required this.listener, 44 | required this.child, 45 | }); 46 | 47 | @override 48 | Widget build(BuildContext context) { 49 | useEffect(() { 50 | final disposer = autorun( 51 | (_) => listener(context, store ?? context.read()), 52 | ); 53 | 54 | return disposer; 55 | }, []); 56 | 57 | return child; 58 | } 59 | } 60 | 61 | class ObserverConsumer extends HookWidget { 62 | final T? store; 63 | final MobxListener listener; 64 | final MobxBuilder builder; 65 | 66 | const ObserverConsumer({ 67 | super.key, 68 | this.store, 69 | required this.listener, 70 | required this.builder, 71 | }); 72 | 73 | @override 74 | Widget build(BuildContext context) { 75 | return ObserverListener( 76 | listener: listener, 77 | child: ObserverBuilder(store: store, builder: builder), 78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /lib/util/pictrs.dart: -------------------------------------------------------------------------------- 1 | String pathToPictrs(String instanceHost, String imgId) => 2 | 'https://$instanceHost/pictrs/image/$imgId'; 3 | -------------------------------------------------------------------------------- /lib/util/share.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter/services.dart'; 5 | import 'package:share_plus/share_plus.dart'; 6 | 7 | /// A `package:share` wrapper that fallbacks to copying contents to the clipboard 8 | /// on platforms that do not support native sharing 9 | Future share( 10 | String text, { 11 | String? subject, 12 | Rect? sharePositionOrigin, 13 | required BuildContext context, 14 | }) async { 15 | if (Platform.isLinux || Platform.isWindows) { 16 | await _fallbackShare(text, context: context); 17 | return; 18 | } 19 | 20 | try { 21 | await Share.share( 22 | text, 23 | subject: subject, 24 | sharePositionOrigin: sharePositionOrigin, 25 | ); 26 | } on MissingPluginException { 27 | await _fallbackShare(text, context: context); 28 | } 29 | } 30 | 31 | Future _fallbackShare( 32 | String text, { 33 | required BuildContext context, 34 | }) async { 35 | await Clipboard.setData(ClipboardData(text: text)); 36 | ScaffoldMessenger.of(context).showSnackBar( 37 | const SnackBar(content: Text('Copied data to clipboard!')), 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /lib/util/text_color.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// Given the background color, returns a text color 4 | /// with a good contrast ratio 5 | Color textColorBasedOnBackground(Color color) { 6 | if (color.computeLuminance() > 0.5) { 7 | return Colors.black; 8 | } else { 9 | return Colors.white; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /lib/util/text_lines_iterator.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | 3 | /// utililty class for traversing through multiline text 4 | class TextLinesIterator extends Iterator { 5 | String text; 6 | int beg; 7 | int end; 8 | TextSelection? selection; 9 | 10 | TextLinesIterator(this.text, {this.selection}) 11 | : end = -1, 12 | beg = -1; 13 | 14 | TextLinesIterator.fromController(TextEditingController controller) 15 | : this(controller.text, selection: controller.selection); 16 | 17 | bool get isWithinSelection { 18 | final selection = this.selection; 19 | if (selection == null || beg == -1) { 20 | return false; 21 | } else { 22 | return (selection.end >= beg && beg >= selection.start) || 23 | (selection.end >= end && end >= selection.start) || 24 | (end >= selection.start && selection.start >= beg) || 25 | (end >= selection.end && selection.end >= beg) || 26 | (beg <= selection.start && 27 | selection.start <= end && 28 | beg <= selection.end && 29 | selection.end <= end); 30 | } 31 | } 32 | 33 | @override 34 | String get current { 35 | return text.substring(beg, end); 36 | } 37 | 38 | set current(String newVal) { 39 | final selected = isWithinSelection; 40 | text = text.replaceRange(beg, end, newVal); 41 | final wordLen = end - beg; 42 | final dif = newVal.length - wordLen; 43 | end += dif; 44 | 45 | final selection = this.selection; 46 | if (selection == null) return; 47 | 48 | if (selected || selection.baseOffset > end) { 49 | this.selection = 50 | selection.copyWith(extentOffset: selection.extentOffset + dif); 51 | } 52 | } 53 | 54 | void reset() { 55 | end = -1; 56 | beg = -1; 57 | } 58 | 59 | @override 60 | bool moveNext() { 61 | if (end == text.length) { 62 | return false; 63 | } 64 | if (beg == -1) { 65 | end = 0; 66 | beg = 0; 67 | } else { 68 | end += 1; 69 | beg = end; 70 | } 71 | for (; end < text.length; end++) { 72 | if (text[end] == '\n') { 73 | return true; 74 | } 75 | } 76 | end = text.length; 77 | return true; 78 | } 79 | 80 | /// returns the lines as a list but also moves the pointer to the back 81 | List get asList { 82 | reset(); 83 | final list = []; 84 | while (moveNext()) { 85 | list.add(current); 86 | } 87 | return list; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /lib/widgets/avatar.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_hooks/flutter_hooks.dart'; 3 | 4 | import '../hooks/stores.dart'; 5 | import '../stores/config_store.dart'; 6 | import 'cached_network_image.dart'; 7 | 8 | /// User's avatar. Respects the `showAvatars` setting from configStore 9 | /// If passed url is null, a blank box is displayed to prevent weird indents 10 | /// Can be disabled with `noBlank` 11 | class Avatar extends HookWidget { 12 | const Avatar({ 13 | super.key, 14 | required this.url, 15 | this.radius = 25, 16 | this.noBlank = false, 17 | this.alwaysShow = false, 18 | this.padding = EdgeInsets.zero, 19 | this.onTap, 20 | }); 21 | 22 | final String? url; 23 | final double radius; 24 | final bool noBlank; 25 | final VoidCallback? onTap; 26 | 27 | /// padding is applied unless blank widget is returned 28 | final EdgeInsetsGeometry padding; 29 | 30 | /// Overrides the `showAvatars` setting 31 | final bool alwaysShow; 32 | 33 | @override 34 | Widget build(BuildContext context) { 35 | final showAvatars = 36 | useStore((ConfigStore store) => store.showAvatars) || alwaysShow; 37 | 38 | final blankWidget = () { 39 | if (noBlank) return const SizedBox.shrink(); 40 | 41 | return SizedBox( 42 | width: radius * 2, 43 | height: radius * 2, 44 | ); 45 | }(); 46 | 47 | final imageUrl = url; 48 | 49 | if (imageUrl == null || !showAvatars) { 50 | return blankWidget; 51 | } 52 | 53 | return Padding( 54 | padding: padding, 55 | child: InkWell( 56 | onTap: onTap, 57 | borderRadius: BorderRadius.circular(radius), 58 | child: ClipOval( 59 | child: CachedNetworkImage( 60 | height: radius * 2, 61 | width: radius * 2, 62 | imageUrl: imageUrl, 63 | fit: BoxFit.cover, 64 | errorBuilder: (_, __) => blankWidget, 65 | ), 66 | ), 67 | ), 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lib/widgets/bottom_modal.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; 3 | 4 | /// Should be spawned with a [showBottomModal], not routed to. 5 | class BottomModal extends StatelessWidget { 6 | final String? title; 7 | final EdgeInsets padding; 8 | final Widget child; 9 | 10 | const BottomModal({ 11 | this.title, 12 | this.padding = EdgeInsets.zero, 13 | required this.child, 14 | }); 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | final theme = Theme.of(context); 19 | 20 | return SafeArea( 21 | child: Padding( 22 | padding: const EdgeInsets.all(8), 23 | child: Container( 24 | decoration: BoxDecoration( 25 | border: Border.all( 26 | color: Colors.grey.withOpacity(0.3), 27 | ), 28 | borderRadius: BorderRadius.circular(10), 29 | ), 30 | child: Material( 31 | clipBehavior: Clip.antiAlias, 32 | borderRadius: BorderRadius.circular(10), 33 | child: SingleChildScrollView( 34 | child: Column( 35 | crossAxisAlignment: CrossAxisAlignment.start, 36 | children: [ 37 | if (title != null) ...[ 38 | const SizedBox(height: 10), 39 | Padding( 40 | padding: const EdgeInsets.only(left: 70), 41 | child: Text( 42 | title!, 43 | style: theme.textTheme.subtitle2, 44 | textAlign: TextAlign.left, 45 | ), 46 | ), 47 | const Divider( 48 | indent: 20, 49 | endIndent: 20, 50 | ) 51 | ], 52 | Padding( 53 | padding: padding, 54 | child: child, 55 | ), 56 | ], 57 | ), 58 | ), 59 | ), 60 | ), 61 | ), 62 | ); 63 | } 64 | } 65 | 66 | /// Helper function for showing a [BottomModal] 67 | Future showBottomModal({ 68 | required BuildContext context, 69 | required WidgetBuilder builder, 70 | String? title, 71 | EdgeInsets padding = EdgeInsets.zero, 72 | }) => 73 | showCustomModalBottomSheet( 74 | context: context, 75 | animationCurve: Curves.easeInOutCubic, 76 | duration: const Duration(milliseconds: 300), 77 | backgroundColor: Colors.transparent, 78 | builder: builder, 79 | containerWidget: (context, animation, child) => BottomModal( 80 | title: title, 81 | padding: padding, 82 | child: child, 83 | ), 84 | ); 85 | -------------------------------------------------------------------------------- /lib/widgets/bottom_safe.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class BottomSafe extends StatelessWidget { 4 | final double additionalPadding; 5 | 6 | static const fabPadding = 7 | // FAB size + FAB margin, 56 is as per https://material.io/components/buttons-floating-action-button#anatomy 8 | 56 + kFloatingActionButtonMargin; 9 | 10 | const BottomSafe([this.additionalPadding = 0]); 11 | const BottomSafe.fab() : this(fabPadding); 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return SizedBox( 16 | height: MediaQuery.of(context).padding.bottom + additionalPadding, 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /lib/widgets/cached_network_image.dart: -------------------------------------------------------------------------------- 1 | import 'package:extended_image/extended_image.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | typedef ImageBuilder = Widget Function( 5 | BuildContext context, ImageProvider imageProvider); 6 | 7 | typedef LoadingBuilder = Widget Function( 8 | BuildContext context, ImageChunkEvent? progress); 9 | 10 | typedef ErrorBuilder = Widget Function( 11 | BuildContext context, Object? lastException); 12 | 13 | extension Progress on ImageChunkEvent { 14 | double? get progress { 15 | if (expectedTotalBytes == null || 16 | cumulativeBytesLoaded > expectedTotalBytes!) { 17 | return null; 18 | } 19 | 20 | return cumulativeBytesLoaded / expectedTotalBytes!; 21 | } 22 | } 23 | 24 | class CachedNetworkImage extends StatelessWidget { 25 | final String imageUrl; 26 | final double? height; 27 | final double? width; 28 | final BoxFit? fit; 29 | final bool cache; 30 | 31 | final ErrorBuilder? errorBuilder; 32 | final LoadingBuilder? loadingBuilder; 33 | final ImageBuilder? imageBuilder; 34 | 35 | const CachedNetworkImage({ 36 | required this.imageUrl, 37 | this.errorBuilder, 38 | this.loadingBuilder, 39 | this.imageBuilder, 40 | this.height, 41 | this.width, 42 | this.fit, 43 | this.cache = true, 44 | super.key, 45 | }); 46 | 47 | @override 48 | Widget build(BuildContext context) { 49 | return ExtendedImage.network( 50 | imageUrl, 51 | height: height, 52 | width: width, 53 | fit: fit, 54 | cache: cache, 55 | loadStateChanged: (state) { 56 | switch (state.extendedImageLoadState) { 57 | case LoadState.loading: 58 | return loadingBuilder?.call(context, state.loadingProgress) ?? 59 | SizedBox(height: height, width: width); 60 | case LoadState.completed: 61 | return imageBuilder?.call(context, state.imageProvider); 62 | case LoadState.failed: 63 | return errorBuilder?.call(context, state.lastException); 64 | } 65 | }, 66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /lib/widgets/editor/editor.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_hooks/flutter_hooks.dart'; 3 | 4 | import '../../markdown_formatter.dart'; 5 | import '../markdown_text.dart'; 6 | 7 | export 'editor_toolbar.dart'; 8 | 9 | class EditorController { 10 | final TextEditingController textEditingController; 11 | final FocusNode focusNode; 12 | final String instanceHost; 13 | 14 | EditorController({ 15 | required this.textEditingController, 16 | required this.focusNode, 17 | required this.instanceHost, 18 | }); 19 | } 20 | 21 | EditorController useEditorController({ 22 | required String instanceHost, 23 | String? text, 24 | }) { 25 | final focusNode = useFocusNode(); 26 | final textEditingController = useTextEditingController(text: text); 27 | return EditorController( 28 | textEditingController: textEditingController, 29 | focusNode: focusNode, 30 | instanceHost: instanceHost); 31 | } 32 | 33 | /// A text field with added functionality for ease of editing 34 | class Editor extends HookWidget { 35 | final EditorController controller; 36 | 37 | final ValueChanged? onSubmitted; 38 | final ValueChanged? onChanged; 39 | final int? minLines; 40 | final int? maxLines; 41 | final String? labelText; 42 | final String? initialValue; 43 | final bool autofocus; 44 | 45 | /// Whether the editor should be preview the contents 46 | final bool fancy; 47 | 48 | const Editor({ 49 | super.key, 50 | required this.controller, 51 | this.onSubmitted, 52 | this.onChanged, 53 | this.minLines = 5, 54 | this.maxLines, 55 | this.labelText, 56 | this.initialValue, 57 | this.fancy = false, 58 | this.autofocus = false, 59 | }); 60 | 61 | @override 62 | Widget build(BuildContext context) { 63 | if (fancy) { 64 | return Padding( 65 | padding: const EdgeInsets.all(8), 66 | child: MarkdownText( 67 | controller.textEditingController.text, 68 | instanceHost: controller.instanceHost, 69 | ), 70 | ); 71 | } 72 | 73 | return TextField( 74 | focusNode: controller.focusNode, 75 | controller: controller.textEditingController, 76 | autofocus: autofocus, 77 | keyboardType: TextInputType.multiline, 78 | textCapitalization: TextCapitalization.sentences, 79 | onChanged: onChanged, 80 | onSubmitted: onSubmitted, 81 | maxLines: maxLines, 82 | minLines: minLines, 83 | decoration: InputDecoration(labelText: labelText), 84 | inputFormatters: [MarkdownFormatter()], 85 | ); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /lib/widgets/editor/editor_toolbar_store.dart: -------------------------------------------------------------------------------- 1 | import 'package:lemmy_api_client/pictrs.dart'; 2 | import 'package:lemmy_api_client/v3.dart'; 3 | import 'package:mobx/mobx.dart'; 4 | 5 | import '../../util/async_store.dart'; 6 | import '../../util/pictrs.dart'; 7 | 8 | part 'editor_toolbar_store.g.dart'; 9 | 10 | class EditorToolbarStore = _EditorToolbarStore with _$EditorToolbarStore; 11 | 12 | abstract class _EditorToolbarStore with Store { 13 | final String instanceHost; 14 | 15 | _EditorToolbarStore(this.instanceHost); 16 | 17 | @observable 18 | String? url; 19 | 20 | final imageUploadState = AsyncStore(); 21 | 22 | @computed 23 | bool get hasUploadedImage => imageUploadState.map( 24 | loading: () => false, 25 | error: (_) => false, 26 | data: (_) => true, 27 | ); 28 | 29 | @action 30 | Future uploadImage(String filePath, Jwt token) async { 31 | final instanceHost = this.instanceHost; 32 | 33 | final upload = await imageUploadState.run( 34 | () => PictrsApi(instanceHost) 35 | .upload( 36 | filePath: filePath, 37 | auth: token.raw, 38 | ) 39 | .then((value) => value.files.single), 40 | ); 41 | 42 | if (upload != null) { 43 | final url = pathToPictrs(instanceHost, upload.file); 44 | return url; 45 | } 46 | return null; 47 | } 48 | 49 | @action 50 | void removeImage() { 51 | final pictrsFile = imageUploadState.map( 52 | data: (data) => data, 53 | loading: () => null, 54 | error: (_) => null, 55 | ); 56 | if (pictrsFile == null) return; 57 | 58 | PictrsApi(instanceHost).delete(pictrsFile).catchError((_) {}); 59 | 60 | imageUploadState.reset(); 61 | url = ''; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/widgets/editor/editor_toolbar_store.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'editor_toolbar_store.dart'; 4 | 5 | // ************************************************************************** 6 | // StoreGenerator 7 | // ************************************************************************** 8 | 9 | // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers 10 | 11 | mixin _$EditorToolbarStore on _EditorToolbarStore, Store { 12 | Computed? _$hasUploadedImageComputed; 13 | 14 | @override 15 | bool get hasUploadedImage => (_$hasUploadedImageComputed ??= Computed( 16 | () => super.hasUploadedImage, 17 | name: '_EditorToolbarStore.hasUploadedImage')) 18 | .value; 19 | 20 | late final _$urlAtom = 21 | Atom(name: '_EditorToolbarStore.url', context: context); 22 | 23 | @override 24 | String? get url { 25 | _$urlAtom.reportRead(); 26 | return super.url; 27 | } 28 | 29 | @override 30 | set url(String? value) { 31 | _$urlAtom.reportWrite(value, super.url, () { 32 | super.url = value; 33 | }); 34 | } 35 | 36 | late final _$uploadImageAsyncAction = 37 | AsyncAction('_EditorToolbarStore.uploadImage', context: context); 38 | 39 | @override 40 | Future uploadImage(String filePath, Jwt token) { 41 | return _$uploadImageAsyncAction 42 | .run(() => super.uploadImage(filePath, token)); 43 | } 44 | 45 | late final _$_EditorToolbarStoreActionController = 46 | ActionController(name: '_EditorToolbarStore', context: context); 47 | 48 | @override 49 | void removeImage() { 50 | final _$actionInfo = _$_EditorToolbarStoreActionController.startAction( 51 | name: '_EditorToolbarStore.removeImage'); 52 | try { 53 | return super.removeImage(); 54 | } finally { 55 | _$_EditorToolbarStoreActionController.endAction(_$actionInfo); 56 | } 57 | } 58 | 59 | @override 60 | String toString() { 61 | return ''' 62 | url: ${url}, 63 | hasUploadedImage: ${hasUploadedImage} 64 | '''; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /lib/widgets/failed_to_load.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class FailedToLoad extends StatelessWidget { 4 | final String message; 5 | final VoidCallback refresh; 6 | 7 | const FailedToLoad({required this.refresh, required this.message}); 8 | 9 | @override 10 | Widget build(BuildContext context) { 11 | return Column( 12 | mainAxisAlignment: MainAxisAlignment.center, 13 | children: [ 14 | Text(message), 15 | const SizedBox(height: 5), 16 | ElevatedButton.icon( 17 | onPressed: refresh, 18 | icon: const Icon(Icons.refresh), 19 | label: const Text('try again'), 20 | ) 21 | ], 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/widgets/fullscreenable_image.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import '../pages/media_view.dart'; 4 | import '../util/goto.dart'; 5 | 6 | /// If the media is pressed, it opens itself in a [MediaViewPage] 7 | class FullscreenableImage extends StatelessWidget { 8 | final String url; 9 | final Widget child; 10 | 11 | const FullscreenableImage({ 12 | super.key, 13 | required this.url, 14 | required this.child, 15 | }); 16 | 17 | @override 18 | Widget build(BuildContext context) => InkWell( 19 | onTap: () => goToMedia(context, url), 20 | child: Hero( 21 | tag: url, 22 | child: child, 23 | ), 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /lib/widgets/info_table_popup.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'bottom_modal.dart'; 4 | 5 | void showInfoTablePopup({ 6 | required BuildContext context, 7 | required Map table, 8 | String? title, 9 | }) { 10 | showBottomModal( 11 | context: context, 12 | title: title, 13 | builder: (context) => Padding( 14 | padding: const EdgeInsets.symmetric( 15 | horizontal: 20, 16 | vertical: 15, 17 | ), 18 | child: Column( 19 | children: [ 20 | Table(children: [ 21 | for (final e in table.entries) 22 | TableRow(children: [ 23 | Text('${e.key}:'), 24 | if (e.value is Map) 25 | GestureDetector( 26 | onTap: () => showInfoTablePopup( 27 | context: context, 28 | table: e.value as Map, 29 | title: e.key, 30 | ), 31 | child: Text( 32 | '[tap to show]', 33 | style: TextStyle( 34 | color: Theme.of(context).colorScheme.secondary, 35 | ), 36 | ), 37 | ) 38 | else 39 | Text(e.value.toString()) 40 | ]) 41 | ]), 42 | ], 43 | ), 44 | ), 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /lib/widgets/markdown_mode_icon.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// shows either brush icon if fancy is true, or build icon if it's false 4 | /// used mostly for pages where markdown editor is used 5 | /// 6 | /// brush icon is rotated to look similarly to build icon 7 | Widget markdownModeIcon({required bool fancy}) => fancy 8 | ? const Icon(Icons.build) 9 | : const RotatedBox( 10 | quarterTurns: 1, 11 | child: Icon(Icons.brush), 12 | ); 13 | -------------------------------------------------------------------------------- /lib/widgets/markdown_text.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_markdown/flutter_markdown.dart'; 5 | import 'package:markdown/markdown.dart' as md; 6 | 7 | import '../url_launcher.dart'; 8 | import 'cached_network_image.dart'; 9 | import 'fullscreenable_image.dart'; 10 | 11 | /// A Markdown renderer with link/image handling 12 | class MarkdownText extends StatelessWidget { 13 | final String instanceHost; 14 | final String text; 15 | final bool selectable; 16 | 17 | const MarkdownText(this.text, 18 | {required this.instanceHost, this.selectable = false}); 19 | 20 | @override 21 | Widget build(BuildContext context) { 22 | final theme = Theme.of(context); 23 | 24 | return MarkdownBody( 25 | key: ValueKey(Object.hashAll([selectable, text, instanceHost])), 26 | selectable: selectable, 27 | data: text, 28 | extensionSet: md.ExtensionSet.gitHubWeb, 29 | styleSheet: MarkdownStyleSheet.fromTheme(theme).copyWith( 30 | blockquoteDecoration: BoxDecoration( 31 | color: Colors.grey.withOpacity(0.3), 32 | border: Border( 33 | left: BorderSide(width: 2, color: theme.colorScheme.secondary), 34 | ), 35 | ), 36 | code: theme.textTheme.bodyText1 37 | // TODO: use a font from google fonts maybe? the defaults aren't very pretty 38 | ?.copyWith(fontFamily: Platform.isIOS ? 'Courier' : 'monospace'), 39 | ), 40 | onTapLink: (text, href, title) { 41 | if (href == null) return; 42 | linkLauncher(context: context, url: href, instanceHost: instanceHost) 43 | .catchError( 44 | (e) => ScaffoldMessenger.of(context).showSnackBar(SnackBar( 45 | content: Row( 46 | children: [ 47 | const Icon(Icons.warning), 48 | Text("couldn't open link, ${e.toString()}"), 49 | ], 50 | ), 51 | ))); 52 | }, 53 | imageBuilder: (uri, title, alt) => FullscreenableImage( 54 | url: uri.toString(), 55 | child: CachedNetworkImage( 56 | imageUrl: uri.toString(), 57 | errorBuilder: (context, error) => Row( 58 | children: [ 59 | const Icon(Icons.warning), 60 | Text("couldn't load image, ${error.toString()}") 61 | ], 62 | ), 63 | ), 64 | ), 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/widgets/post/post_actions.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_hooks/flutter_hooks.dart'; 3 | 4 | import '../../l10n/l10n.dart'; 5 | import '../../util/icons.dart'; 6 | import '../../util/observer_consumers.dart'; 7 | import '../../util/share.dart'; 8 | import 'post_status.dart'; 9 | import 'post_store.dart'; 10 | import 'post_voting.dart'; 11 | import 'save_post_button.dart'; 12 | 13 | class PostActions extends HookWidget { 14 | const PostActions(); 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | final fullPost = context.read(); 19 | 20 | // assemble actions section 21 | return ObserverBuilder(builder: (context, store) { 22 | return Padding( 23 | padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), 24 | child: Row( 25 | children: [ 26 | const Icon(Icons.comment), 27 | const SizedBox(width: 6), 28 | Expanded( 29 | child: Text( 30 | L10n.of(context) 31 | .number_of_comments(store.postView.counts.comments), 32 | overflow: TextOverflow.fade, 33 | softWrap: false, 34 | ), 35 | ), 36 | if (!fullPost) 37 | IconButton( 38 | icon: Icon(shareIcon), 39 | onPressed: () => 40 | share(store.postView.post.apId, context: context), 41 | ), 42 | if (!fullPost) const SavePostButton(), 43 | const PostVoting(), 44 | ], 45 | ), 46 | ); 47 | }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lib/widgets/post/post_link_preview.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import '../../url_launcher.dart'; 4 | import '../../util/observer_consumers.dart'; 5 | import 'post_store.dart'; 6 | 7 | class PostLinkPreview extends StatelessWidget { 8 | const PostLinkPreview(); 9 | 10 | @override 11 | Widget build(BuildContext context) { 12 | return ObserverBuilder( 13 | builder: (context, store) { 14 | final url = store.postView.post.url; 15 | 16 | if (store.hasMedia || url == null || url.isEmpty) { 17 | return const SizedBox(); 18 | } 19 | 20 | final theme = Theme.of(context); 21 | 22 | return Padding( 23 | padding: const EdgeInsets.all(10), 24 | child: InkWell( 25 | onTap: () => linkLauncher( 26 | context: context, 27 | url: url, 28 | instanceHost: store.postView.instanceHost, 29 | ), 30 | child: DecoratedBox( 31 | decoration: BoxDecoration( 32 | border: Border.all( 33 | color: Theme.of(context).iconTheme.color!.withAlpha(170)), 34 | borderRadius: BorderRadius.circular(5)), 35 | child: Padding( 36 | padding: const EdgeInsets.all(10), 37 | child: Column( 38 | crossAxisAlignment: CrossAxisAlignment.start, 39 | children: [ 40 | Row( 41 | children: [ 42 | const Spacer(), 43 | Text('${store.urlDomain} ', 44 | style: theme.textTheme.caption 45 | ?.apply(fontStyle: FontStyle.italic)), 46 | const Icon(Icons.launch, size: 12), 47 | ], 48 | ), 49 | Text( 50 | store.postView.post.embedTitle ?? '', 51 | style: 52 | theme.textTheme.subtitle1?.apply(fontWeightDelta: 2), 53 | maxLines: 2, 54 | overflow: TextOverflow.ellipsis, 55 | ), 56 | if (store.postView.post.embedDescription?.isNotEmpty ?? 57 | false) 58 | Text( 59 | store.postView.post.embedDescription!, 60 | maxLines: 4, 61 | overflow: TextOverflow.ellipsis, 62 | ), 63 | ], 64 | ), 65 | ), 66 | ), 67 | ), 68 | ); 69 | }, 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /lib/widgets/post/post_media.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import '../../util/observer_consumers.dart'; 4 | import '../cached_network_image.dart'; 5 | import '../fullscreenable_image.dart'; 6 | import 'post_store.dart'; 7 | 8 | /// assembles image 9 | class PostMedia extends StatelessWidget { 10 | const PostMedia(); 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | return ObserverBuilder( 15 | builder: (context, store) { 16 | final post = store.postView.post; 17 | if (!store.hasMedia) return const SizedBox(); 18 | 19 | final url = post.url!; // hasMedia returns false if url is null 20 | 21 | return FullscreenableImage( 22 | url: url, 23 | child: CachedNetworkImage( 24 | imageUrl: url, 25 | errorBuilder: (_, ___) => const Icon(Icons.warning), 26 | loadingBuilder: (context, progress) => 27 | CircularProgressIndicator.adaptive(value: progress?.progress), 28 | ), 29 | ); 30 | }, 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/widgets/post/post_status.dart: -------------------------------------------------------------------------------- 1 | typedef IsFullPost = bool; 2 | -------------------------------------------------------------------------------- /lib/widgets/post/post_title.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import '../../url_launcher.dart'; 4 | import '../../util/observer_consumers.dart'; 5 | import '../cached_network_image.dart'; 6 | import 'post_store.dart'; 7 | 8 | class PostTitle extends StatelessWidget { 9 | const PostTitle(); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | return ObserverBuilder( 14 | builder: (context, store) { 15 | final post = store.postView.post; 16 | final thumbnailUrl = post.thumbnailUrl; 17 | final url = post.url; 18 | 19 | return Padding( 20 | padding: const EdgeInsets.all(10).copyWith(top: 0), 21 | child: Row( 22 | children: [ 23 | Expanded( 24 | child: Text( 25 | post.name, 26 | textAlign: TextAlign.left, 27 | softWrap: true, 28 | style: const TextStyle( 29 | fontSize: 18, fontWeight: FontWeight.w600), 30 | ), 31 | ), 32 | if (!store.hasMedia && thumbnailUrl != null && url != null) ...[ 33 | InkWell( 34 | borderRadius: BorderRadius.circular(20), 35 | onTap: () => linkLauncher( 36 | context: context, 37 | url: url, 38 | instanceHost: store.postView.instanceHost), 39 | child: Stack( 40 | children: [ 41 | ClipRRect( 42 | borderRadius: BorderRadius.circular(20), 43 | child: CachedNetworkImage( 44 | imageUrl: thumbnailUrl, 45 | width: 70, 46 | height: 70, 47 | fit: BoxFit.cover, 48 | errorBuilder: (context, error) => 49 | Text(error.toString()), 50 | ), 51 | ), 52 | const Positioned( 53 | top: 8, 54 | right: 8, 55 | child: Icon( 56 | Icons.launch, 57 | size: 20, 58 | ), 59 | ) 60 | ], 61 | ), 62 | ), 63 | ], 64 | ], 65 | ), 66 | ); 67 | }, 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lib/widgets/post/post_voting.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_hooks/flutter_hooks.dart'; 3 | import 'package:lemmy_api_client/v3.dart'; 4 | 5 | import '../../hooks/logged_in_action.dart'; 6 | import '../../hooks/stores.dart'; 7 | import '../../l10n/l10n.dart'; 8 | import '../../stores/config_store.dart'; 9 | import '../../util/observer_consumers.dart'; 10 | import 'post_store.dart'; 11 | 12 | class PostVoting extends HookWidget { 13 | const PostVoting(); 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | final theme = Theme.of(context); 18 | final showScores = useStore((ConfigStore store) => store.showScores); 19 | final loggedInAction = useLoggedInAction(context 20 | .select((store) => store.postView.instanceHost)); 21 | 22 | return ObserverBuilder(builder: (context, store) { 23 | return Row( 24 | children: [ 25 | IconButton( 26 | icon: Icon( 27 | Icons.arrow_upward, 28 | color: store.postView.myVote == VoteType.up 29 | ? theme.colorScheme.secondary 30 | : null, 31 | ), 32 | onPressed: loggedInAction(store.upVote), 33 | ), 34 | if (store.votingState.isLoading) 35 | const SizedBox( 36 | width: 20, 37 | height: 20, 38 | child: CircularProgressIndicator.adaptive(), 39 | ) 40 | else if (showScores) 41 | Text(store.postView.counts.score.compact(context)), 42 | IconButton( 43 | icon: Icon( 44 | Icons.arrow_downward, 45 | color: store.postView.myVote == VoteType.down ? Colors.red : null, 46 | ), 47 | onPressed: loggedInAction(store.downVote), 48 | ), 49 | ], 50 | ); 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lib/widgets/post/save_post_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_hooks/flutter_hooks.dart'; 3 | 4 | import '../../hooks/logged_in_action.dart'; 5 | import '../../util/observer_consumers.dart'; 6 | import 'post_store.dart'; 7 | 8 | class SavePostButton extends HookWidget { 9 | const SavePostButton(); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | final loggedInAction = useLoggedInAction(context 14 | .select((store) => store.postView.instanceHost)); 15 | 16 | return ObserverBuilder( 17 | builder: (context, store) { 18 | final savedIcon = 19 | store.postView.saved ? Icons.bookmark : Icons.bookmark_border; 20 | 21 | return IconButton( 22 | tooltip: 'Save post', 23 | icon: store.savingState.isLoading 24 | ? const CircularProgressIndicator.adaptive() 25 | : Icon(savedIcon), 26 | onPressed: loggedInAction(store.save), 27 | ); 28 | }, 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/widgets/post_list_options.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:lemmy_api_client/v3.dart'; 3 | 4 | import '../l10n/l10n.dart'; 5 | import 'radio_picker.dart'; 6 | 7 | /// Dropdown filters where you can change sorting or viewing type 8 | class PostListOptions extends StatelessWidget { 9 | final ValueChanged onSortChanged; 10 | final SortType sortValue; 11 | final bool styleButton; 12 | 13 | const PostListOptions({ 14 | required this.onSortChanged, 15 | required this.sortValue, 16 | this.styleButton = true, 17 | }); 18 | 19 | @override 20 | Widget build(BuildContext context) => Padding( 21 | padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 10), 22 | child: Row( 23 | children: [ 24 | RadioPicker( 25 | title: 'sort by', 26 | values: SortType.values, 27 | groupValue: sortValue, 28 | onChanged: onSortChanged, 29 | mapValueToString: (value) => value.tr(context), 30 | ), 31 | const Spacer(), 32 | if (styleButton) 33 | const IconButton( 34 | icon: Icon(Icons.view_stream), 35 | // TODO: create compact post and dropdown for selecting 36 | onPressed: null, 37 | ), 38 | ], 39 | ), 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /lib/widgets/pull_to_refresh.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | 4 | class PullToRefresh extends StatelessWidget { 5 | final RefreshCallback onRefresh; 6 | final Widget child; 7 | 8 | const PullToRefresh({ 9 | super.key, 10 | required this.onRefresh, 11 | required this.child, 12 | }); 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | return RefreshIndicator( 17 | onRefresh: () async { 18 | await HapticFeedback.mediumImpact(); 19 | await onRefresh(); 20 | }, 21 | child: child, 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/widgets/radio_picker.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'bottom_modal.dart'; 4 | 5 | /// A picker with radio values (only one value can be picked at once) 6 | class RadioPicker extends StatelessWidget { 7 | final List values; 8 | final T groupValue; 9 | final ValueChanged? onChanged; 10 | 11 | /// Map a given value to a string for display 12 | final String Function(T)? mapValueToString; 13 | final String? title; 14 | 15 | /// custom button builder. When null, an OutlinedButton is used 16 | final Widget Function( 17 | BuildContext context, String displayValue, VoidCallback? onPressed)? 18 | buttonBuilder; 19 | 20 | final Widget? trailing; 21 | 22 | const RadioPicker({ 23 | super.key, 24 | required this.values, 25 | required this.groupValue, 26 | required this.onChanged, 27 | this.mapValueToString, 28 | this.buttonBuilder, 29 | this.title, 30 | this.trailing, 31 | }); 32 | 33 | @override 34 | Widget build(BuildContext context) { 35 | final mapValueToString = 36 | this.mapValueToString ?? (value) => value.toString(); 37 | 38 | final buttonBuilder = this.buttonBuilder ?? 39 | (context, displayString, onPressed) => OutlinedButton( 40 | onPressed: onPressed, 41 | child: Row( 42 | mainAxisAlignment: MainAxisAlignment.center, 43 | children: [ 44 | Text(displayString), 45 | const Icon(Icons.arrow_drop_down), 46 | ], 47 | ), 48 | ); 49 | 50 | Future onPressed() async { 51 | final value = await showBottomModal( 52 | context: context, 53 | title: title, 54 | builder: (context) => Column( 55 | mainAxisSize: MainAxisSize.min, 56 | children: [ 57 | for (final value in values) 58 | RadioListTile( 59 | value: value, 60 | groupValue: groupValue, 61 | title: Text(mapValueToString(value)), 62 | onChanged: (value) => Navigator.of(context).pop(value), 63 | ), 64 | if (trailing != null) trailing! 65 | ], 66 | ), 67 | ); 68 | 69 | if (value != null) { 70 | onChanged?.call(value); 71 | } 72 | } 73 | 74 | return buttonBuilder( 75 | context, 76 | mapValueToString(groupValue), 77 | onChanged == null ? null : onPressed, 78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /lib/widgets/report_dialog.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_hooks/flutter_hooks.dart'; 3 | 4 | class ReportDialog extends HookWidget { 5 | const ReportDialog(); 6 | 7 | @override 8 | Widget build(BuildContext context) { 9 | final controller = useListenable(useTextEditingController()); 10 | 11 | return AlertDialog( 12 | title: const Text('Report'), 13 | content: TextField( 14 | autofocus: true, 15 | controller: controller, 16 | decoration: const InputDecoration( 17 | label: Text('reason'), 18 | ), 19 | minLines: 1, 20 | maxLines: 3, 21 | ), 22 | actions: [ 23 | TextButton( 24 | onPressed: () => Navigator.of(context).pop(), 25 | child: const Text('cancel'), 26 | ), 27 | TextButton( 28 | onPressed: controller.text.trim().isEmpty 29 | ? null 30 | : () => Navigator.of(context).pop(controller.text.trim()), 31 | child: const Text('report'), 32 | ), 33 | ], 34 | ); 35 | } 36 | 37 | static Future show(BuildContext context) async => 38 | showDialog(context: context, builder: (context) => const ReportDialog()); 39 | } 40 | -------------------------------------------------------------------------------- /lib/widgets/reveal_after_scroll.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math' show max, min; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_hooks/flutter_hooks.dart'; 5 | 6 | /// Makes the child reveal itself after given distance 7 | class RevealAfterScroll extends HookWidget { 8 | final Widget child; 9 | 10 | /// distance after which [child] should appear 11 | final int after; 12 | final int transition; 13 | final ScrollController scrollController; 14 | final bool fade; 15 | 16 | const RevealAfterScroll({ 17 | required this.scrollController, 18 | required this.child, 19 | required this.after, 20 | this.transition = 15, 21 | this.fade = false, 22 | }); 23 | 24 | @override 25 | Widget build(BuildContext context) { 26 | useListenable(scrollController); 27 | 28 | final scroll = scrollController.position.pixels; 29 | 30 | return Opacity( 31 | opacity: 32 | fade ? max(0, min(transition, scroll - after + 20)) / transition : 1, 33 | child: Transform.translate( 34 | offset: Offset(0, max(0, after - scroll)), 35 | child: child, 36 | ), 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/widgets/tile_action.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import '../hooks/delayed_loading.dart'; 4 | 5 | /// [IconButton], usually at the bottom of some tile, that performs an async 6 | /// action that uses [DelayedLoading], has reduced size to be more compact, 7 | /// and has built in spinner 8 | class TileAction extends StatelessWidget { 9 | final IconData icon; 10 | final VoidCallback onPressed; 11 | final String tooltip; 12 | final DelayedLoading? delayedLoading; 13 | final bool loading; 14 | final Color? iconColor; 15 | 16 | const TileAction({ 17 | super.key, 18 | this.delayedLoading, 19 | this.iconColor, 20 | required this.icon, 21 | required this.onPressed, 22 | required this.tooltip, 23 | this.loading = false, 24 | }); 25 | 26 | @override 27 | Widget build(BuildContext context) => IconButton( 28 | constraints: BoxConstraints.tight(const Size(36, 30)), 29 | padding: EdgeInsets.zero, 30 | splashRadius: 25, 31 | iconSize: 25, 32 | tooltip: tooltip, 33 | onPressed: delayedLoading?.pending ?? loading ? () {} : onPressed, 34 | icon: delayedLoading?.loading ?? loading 35 | ? SizedBox.fromSize( 36 | size: const Size.square(22), 37 | child: const CircularProgressIndicator.adaptive(), 38 | ) 39 | : Icon( 40 | icon, 41 | color: iconColor ?? 42 | Theme.of(context).iconTheme.color?.withAlpha(190), 43 | ), 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /lib/widgets/user_tile.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:lemmy_api_client/v3.dart'; 3 | 4 | import '../util/extensions/api.dart'; 5 | import '../util/goto.dart'; 6 | import 'avatar.dart'; 7 | import 'markdown_text.dart'; 8 | 9 | class PersonTile extends StatelessWidget { 10 | final PersonSafe person; 11 | final bool expanded; 12 | const PersonTile( 13 | this.person, { 14 | this.expanded = false, 15 | super.key, 16 | }); 17 | 18 | @override 19 | Widget build(BuildContext context) { 20 | return ListTile( 21 | title: Text(person.originPreferredName), 22 | subtitle: person.bio != null && expanded 23 | ? Opacity( 24 | opacity: 0.7, 25 | child: 26 | MarkdownText(person.bio!, instanceHost: person.instanceHost), 27 | ) 28 | : null, 29 | onTap: () => goToUser.fromPersonSafe(context, person), 30 | leading: Avatar(url: person.avatar), 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /linux/.gitignore: -------------------------------------------------------------------------------- 1 | flutter/ephemeral 2 | -------------------------------------------------------------------------------- /linux/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 fl_register_plugins(FlPluginRegistry* registry) { 12 | g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = 13 | fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); 14 | url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); 15 | } 16 | -------------------------------------------------------------------------------- /linux/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 fl_register_plugins(FlPluginRegistry* registry); 14 | 15 | #endif // GENERATED_PLUGIN_REGISTRANT_ 16 | -------------------------------------------------------------------------------- /linux/flutter/generated_plugins.cmake: -------------------------------------------------------------------------------- 1 | # 2 | # Generated file, do not edit. 3 | # 4 | 5 | list(APPEND FLUTTER_PLUGIN_LIST 6 | url_launcher_linux 7 | ) 8 | 9 | list(APPEND FLUTTER_FFI_PLUGIN_LIST 10 | ) 11 | 12 | set(PLUGIN_BUNDLED_LIBRARIES) 13 | 14 | foreach(plugin ${FLUTTER_PLUGIN_LIST}) 15 | add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) 16 | target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) 17 | list(APPEND PLUGIN_BUNDLED_LIBRARIES $) 18 | list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) 19 | endforeach(plugin) 20 | 21 | foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) 22 | add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) 23 | list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) 24 | endforeach(ffi_plugin) 25 | -------------------------------------------------------------------------------- /linux/main.cc: -------------------------------------------------------------------------------- 1 | #include "my_application.h" 2 | 3 | int main(int argc, char** argv) { 4 | g_autoptr(MyApplication) app = my_application_new(); 5 | return g_application_run(G_APPLICATION(app), argc, argv); 6 | } 7 | -------------------------------------------------------------------------------- /linux/my_application.h: -------------------------------------------------------------------------------- 1 | #ifndef FLUTTER_MY_APPLICATION_H_ 2 | #define FLUTTER_MY_APPLICATION_H_ 3 | 4 | #include 5 | 6 | G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, 7 | GtkApplication) 8 | 9 | /** 10 | * my_application_new: 11 | * 12 | * Creates a new Flutter-based application. 13 | * 14 | * Returns: a new #MyApplication. 15 | */ 16 | MyApplication* my_application_new(); 17 | 18 | #endif // FLUTTER_MY_APPLICATION_H_ 19 | -------------------------------------------------------------------------------- /scripts/common.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_print 2 | import 'dart:io'; 3 | 4 | void confirm(String message) { 5 | stdout.write('$message [y/n] '); 6 | 7 | switch (stdin.readLineSync()) { 8 | case 'y': 9 | case 'yes': 10 | break; 11 | default: 12 | print('Exiting'); 13 | exit(1); 14 | } 15 | } 16 | 17 | Never printError(String message) { 18 | stderr.writeln('\x1B[31m$message\x1B[0m'); 19 | 20 | exit(1); 21 | } 22 | -------------------------------------------------------------------------------- /scripts/gen_l10n_from_string.dart: -------------------------------------------------------------------------------- 1 | /// creates a file with l10n translations from string 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | 5 | import 'migrate_lemmy_l10n.dart'; 6 | 7 | const baseFile = 'intl_en.arb'; 8 | const autoGenHeader = '// FILE GENERATED AUTOMATICALLY, TO NOT EDIT BY HAND'; 9 | 10 | Future main(List args) async { 11 | final strings = jsonDecode(await File('$outDir/$baseFile').readAsString()) 12 | as Map; 13 | 14 | final keys = strings.keys.where((key) => !key.startsWith('@')).toSet(); 15 | final keysWithoutVariables = keys.where((key) { 16 | final metadata = strings['@$key'] as Map; 17 | final placeholders = metadata['placeholders'] as Map?; 18 | 19 | return placeholders?.isEmpty ?? true; 20 | }).toSet(); 21 | 22 | await File('lib/l10n/l10n_from_string.dart').writeAsString('''$autoGenHeader 23 | // ignore_for_file: constant_identifier_names 24 | 25 | import 'package:flutter/material.dart'; 26 | 27 | import 'gen/l10n.dart'; 28 | 29 | abstract class L10nStrings { 30 | ${keys.map((key) => " static const $key = '$key';").join('\n')} 31 | } 32 | 33 | extension L10nFromString on String { 34 | String tr(BuildContext context) { 35 | switch (this) { 36 | ${keysWithoutVariables.map((key) => " case L10nStrings.$key:\n return L10n.of(context).$key;").join('\n')} 37 | 38 | default: 39 | return this; 40 | } 41 | } 42 | } 43 | '''); 44 | 45 | await Process.run('flutter', ['format', '.']); 46 | } 47 | -------------------------------------------------------------------------------- /test/pages/modlog/modlog_page_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:lemmur/pages/modlog/modlog_page_store.dart'; 3 | import 'package:lemmur/util/async_store.dart'; 4 | import 'package:lemmy_api_client/v3.dart'; 5 | 6 | void main() { 7 | group('ModlogPageStore', () { 8 | late ModlogPageStore store; 9 | const instanceHost = 'lemmy.ml'; 10 | 11 | setUp(() { 12 | store = ModlogPageStore(instanceHost); 13 | }); 14 | tearDown(() { 15 | store.dispose(); 16 | }); 17 | 18 | test('Initial states are correct', () { 19 | expect(store.communityId, null); 20 | expect(store.instanceHost, instanceHost); 21 | expect(store.hasNextPage, true); 22 | expect(store.hasPreviousPage, false); 23 | expect(store.modlogState.asyncState, const AsyncState.initial()); 24 | expect(store.page, 1); 25 | }); 26 | 27 | test('Fetches a page when changed', () { 28 | store.nextPage(); 29 | 30 | expect(store.page, 2); 31 | expect(store.hasPreviousPage, true); 32 | expect(store.modlogState.asyncState, const AsyncState.loading()); 33 | 34 | store.previousPage(); 35 | 36 | expect(store.page, 1); 37 | expect(store.hasPreviousPage, false); 38 | }); 39 | 40 | test('Stops listening after disposal', () { 41 | store 42 | ..dispose() 43 | ..nextPage(); 44 | 45 | expect(store.page, 2); 46 | expect(store.hasPreviousPage, true); 47 | expect(store.modlogState.asyncState, const AsyncState.initial()); 48 | }); 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /windows/.gitignore: -------------------------------------------------------------------------------- 1 | flutter/ephemeral/ 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/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 | list(APPEND FLUTTER_FFI_PLUGIN_LIST 10 | ) 11 | 12 | set(PLUGIN_BUNDLED_LIBRARIES) 13 | 14 | foreach(plugin ${FLUTTER_PLUGIN_LIST}) 15 | add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) 16 | target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) 17 | list(APPEND PLUGIN_BUNDLED_LIBRARIES $) 18 | list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) 19 | endforeach(plugin) 20 | 21 | foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) 22 | add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) 23 | list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) 24 | endforeach(ffi_plugin) 25 | -------------------------------------------------------------------------------- /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/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 opporutunity 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 | FlutterWindow window(&run_loop, project); 25 | Win32Window::Point origin(10, 10); 26 | Win32Window::Size size(1280, 720); 27 | if (!window.CreateAndShow(L"lemmur", origin, size)) { 28 | return EXIT_FAILURE; 29 | } 30 | window.SetQuitOnClose(true); 31 | 32 | run_loop.Run(); 33 | 34 | ::CoUninitialize(); 35 | return EXIT_SUCCESS; 36 | } 37 | -------------------------------------------------------------------------------- /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/LemmurOrg/lemmur/0a95fefba087d4109440cd5d371516d9f846aec5/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 (::PeekMessage(&message, nullptr, 0, 0, PM_REMOVE)) { 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 | -------------------------------------------------------------------------------- /windows/runner/utils.h: -------------------------------------------------------------------------------- 1 | #ifndef RUNNER_UTILS_H_ 2 | #define RUNNER_UTILS_H_ 3 | 4 | // Creates a console for the process, and redirects stdout and stderr to 5 | // it for both the runner and the Flutter library. 6 | void CreateAndAttachConsole(); 7 | 8 | #endif // RUNNER_UTILS_H_ 9 | --------------------------------------------------------------------------------