Compare commits

...

112 Commits

Author SHA1 Message Date
Filip Krawczyk 0a95fefba0
Update README.md 2023-02-04 15:57:01 +01:00
Filip Krawczyk b7cd985ba2
Update README.md 2023-02-03 15:44:28 +01:00
Filip Krawczyk c002632e15
Merge pull request #340 from LemmurOrg/changelog-update 2022-08-29 12:52:39 +02:00
Filip Krawczyk d2a2190151 typo fix 2022-08-29 12:08:02 +02:00
Filip Krawczyk 8a9ce69c5a update changelog 2022-08-29 01:03:37 +02:00
Filip Krawczyk eacbda0b5d
Merge pull request #338 from LemmurOrg/feature/markdown-editor 2022-08-26 22:47:29 +02:00
Filip Krawczyk 9ff62f86c5 revise the way editor toolbar sticks to the bottom 2022-08-25 23:56:11 +02:00
Filip Krawczyk 2d7d2a64bd move part of the functionality to EditorController for clutter reduction 2022-08-25 23:46:43 +02:00
Filip Krawczyk c15cfdf02c add Material back in -_- cuz it's needed for splash 2022-08-25 23:21:28 +02:00
Filip Krawczyk 87726f283b make end indice exclusive 2022-08-25 21:56:49 +02:00
Filip Krawczyk 8aefdfbf27 remove list item if empty + enter pressed 2022-08-25 18:46:32 +02:00
Filip Krawczyk b2ef0883e3 remove store from being a property. also better animation 2022-08-25 18:22:14 +02:00
Filip Krawczyk e3b561835b remove magic number in favor of calculating it on the fly 2022-08-25 17:50:06 +02:00
Filip Krawczyk 9ec5410273 remove unneed FocusScope & Focus 2022-08-25 17:45:22 +02:00
Filip Krawczyk d11a46393b remove unneeded material 2022-08-25 17:42:31 +02:00
Filip Krawczyk c83e93c755 add transition for toolbar appearing and disappearing 2022-08-25 17:40:37 +02:00
Filip Krawczyk 2cc82e6a45 fix 2022-08-25 17:30:02 +02:00
Filip Krawczyk 90553794b2
Update lib/widgets/editor/editor_toolbar.dart
Co-authored-by: Marcin Wojnarowski <xmarcinmarcin@gmail.com>
2022-08-25 17:29:28 +02:00
Filip Krawczyk b39d6b06d7 create widget for stuff that sticks to bottom 2022-08-25 17:27:40 +02:00
Filip Krawczyk 6729a040ea make list completion more reusable 2022-08-25 17:24:05 +02:00
Filip Krawczyk cc8441dabc toolbar is now only present when textfield is focused
also added it to manage account page
2022-08-23 19:29:01 +02:00
Filip Krawczyk 55daacf221 format 2022-08-23 01:07:40 +02:00
Filip Krawczyk 12084a3421 remove some unnecessary regex escapes 2022-08-23 00:36:31 +02:00
Filip Krawczyk cda72a1174 CR Small Changes
* regex fix http?s -> https?
* file rename: formatter.dart -> markdown_formatter.dart to match the class name inside
* add + as continuoable list
* rename: listContinuation          -> unorderedListContinuation
           numberedListContinuation -> orderedListContinuation
* fix typo: convenience
* fix: doc instead of comment
* rename for readability: startingIndex -> from
* function & arg rename: lineBefore(int endingIndex) -> lineUpTo(int characterIndex)
* parse -> tryParse
* localize user picker & commmunity picker
* HookWidget -> StatelessWidget where needed
* Toolbar -> EditorToolbar for less ambiguity
* fix typo: surroungs -> surrounds
* remove debug logA
* more localization stuff
* title -> label on add link dialog
* Reformat -> _Reformat
* use store when in scope instead of context.read
* remove useless Stack (oops)
2022-08-23 00:34:10 +02:00
Filip Krawczyk 821558314e
Update lib/widgets/editor/editor_toolbar.dart
Co-authored-by: Marcin Wojnarowski <xmarcinmarcin@gmail.com>
2022-08-22 23:59:56 +02:00
Filip Krawczyk ecb95d3bdb
Update lib/util/text_lines_iterator.dart
Co-authored-by: Marcin Wojnarowski <xmarcinmarcin@gmail.com>
2022-08-22 23:04:05 +02:00
Filip Krawczyk 43fb2a8ceb add placeholder text to l10n 2022-08-21 23:49:03 +02:00
Filip Krawczyk 4cd8b9855c add toolbar to comments 2022-08-21 23:09:25 +02:00
Filip Krawczyk 1fcc95d6b9 make sure link is a link 2022-08-21 23:08:00 +02:00
Filip Krawczyk cd1f7a3be3 add tooltips 2022-08-21 22:57:56 +02:00
Filip Krawczyk 579b4e1d5d cleanup 2022-08-21 22:23:25 +02:00
Filip Krawczyk 09f1f54c05 implement more buttons
* header
* quote
2022-08-21 21:52:31 +02:00
Filip Krawczyk 462ce5df76 added functionality to:
* info button
* spoiler button
2022-08-21 20:47:30 +02:00
Filip Krawczyk 63032ebae1 add selecting of users and communities
also made simplified version of reformat
2022-08-21 19:03:22 +02:00
Filip Krawczyk 116b0d7961 add store with purpose of uploading images 2022-08-21 16:00:54 +02:00
Filip Krawczyk 663b45bc21 oops 😅 2022-08-09 00:06:14 +02:00
Filip Krawczyk 79f68ee732 rename file to conform to convention 2022-08-09 00:05:55 +02:00
Filip Krawczyk 34be4c13a1 Merge branch 'feature/markdown-editor' of https://github.com/krawieck/lemmur into feature/markdown-editor 2022-08-05 11:09:57 +02:00
Filip Krawczyk 52498a6be3 minor string tweaks 2022-08-05 11:01:52 +02:00
Filip Krawczyk 3c295552df list button implementation 2022-08-05 11:01:52 +02:00
Filip Krawczyk 062a53fdd9 add safearea for toolbar 2022-08-05 11:01:52 +02:00
Filip Krawczyk 33ff99510f put regex to private variable 2022-08-05 11:01:52 +02:00
Filip Krawczyk e91e5789ee improve list continuation, add support for numbered list
* make list continuation more universal
* add support for indentation
* add support for numbered list continuation
2022-08-05 11:01:52 +02:00
Filip Krawczyk 81fadffa14 regex typo fix 2022-08-05 11:01:52 +02:00
Filip Krawczyk 6f271ffc91 remove focus node that is not needed thanks to flutter being smart 2022-08-05 11:01:52 +02:00
Filip Krawczyk 14b7813243 add functionality to more buttons
* add several extensions on TextEditingController for convinience
* add "add link" dialog + functionality
* add functionality to surround buttons:
  * italics,
  * stikethough,
  * superscript,
  * subscript,
  * code
2022-08-05 11:01:52 +02:00
Filip Krawczyk ce41b7e18c editor reorganization + first changes
* added toolbar with buttons
* bold button works
* added input formatter for user convieniance
2022-08-05 11:01:52 +02:00
Filip Krawczyk 85108d8965 maintenance/autogenerated files 2022-08-05 11:01:52 +02:00
Filip Krawczyk 08831afbcf minor string tweaks 2022-08-05 10:17:21 +02:00
Marcin Wojnarowski 44cd556fef
Merge pull request #336 from LemmurOrg/feat/flutter-3 2022-07-21 11:22:44 +02:00
shilangyu 22030d6291 Fix ios build 2022-07-15 21:32:51 +02:00
shilangyu 9ed7b89d68 Regenerate files 2022-07-15 21:21:02 +02:00
shilangyu 75622a735b Bump deps 2022-07-15 21:07:10 +02:00
shilangyu d113e01eeb Bump native versions 2022-07-15 21:02:42 +02:00
Filip Krawczyk 3c274adee7 list button implementation 2022-07-06 11:28:06 +02:00
Filip Krawczyk 8c0c478847 add safearea for toolbar 2022-07-04 17:20:41 +02:00
Filip Krawczyk cab4aeebb7 put regex to private variable 2022-07-04 17:18:38 +02:00
Filip Krawczyk 7db538084a improve list continuation, add support for numbered list
* make list continuation more universal
* add support for indentation
* add support for numbered list continuation
2022-07-04 17:17:35 +02:00
Filip Krawczyk b5bb5dc1ff regex typo fix 2022-06-28 01:18:45 +02:00
Filip Krawczyk f21c6b7c8c remove focus node that is not needed thanks to flutter being smart 2022-06-28 00:41:00 +02:00
Filip Krawczyk b972e4485a add functionality to more buttons
* add several extensions on TextEditingController for convinience
* add "add link" dialog + functionality
* add functionality to surround buttons:
  * italics,
  * stikethough,
  * superscript,
  * subscript,
  * code
2022-06-28 00:40:37 +02:00
Filip Krawczyk 6f8fed149c editor reorganization + first changes
* added toolbar with buttons
* bold button works
* added input formatter for user convieniance
2022-06-26 23:43:36 +02:00
Filip Krawczyk fa2a3be6b5 maintenance/autogenerated files 2022-06-26 23:41:53 +02:00
shilangyu 6136e4a408 Bump mobx 2022-05-13 15:58:01 +02:00
shilangyu 9f588a5ea8 Upgrade deps 2022-05-11 22:31:07 +02:00
shilangyu 4533be634e Enable new lints 2022-05-11 22:23:18 +02:00
shilangyu 3f33cac3ed Upgrade to flutter 3 2022-05-11 22:11:07 +02:00
Marcin Wojnarowski 28be50a89e
Merge pull request #320 from LemmurOrg/feature/create-post-redesign 2022-05-09 08:31:04 +02:00
shilangyu 926c0afe0a Add extra controller condition 2022-05-08 10:43:12 +02:00
shilangyu df85b6271e Merge branch 'master' into feature/create-post-redesign 2022-05-03 18:09:33 +02:00
Marcin Wojnarowski 476ad85a38
Merge pull request #333 from LemmurOrg/chore/bump-deps
Bump deps and LAC
2022-05-03 17:18:17 +02:00
shilangyu ab1873a19f Fix immutability 2022-05-03 09:48:04 +02:00
shilangyu 7bb8d2b33f Fix code review comments 2022-05-03 09:44:07 +02:00
Filip Krawczyk d2c85cb305 Merge branch 'feature/create-post-redesign' of https://github.com/krawieck/lemmur into feature/create-post-redesign 2022-04-30 17:29:31 +02:00
shilangyu c730216e13 Add changelog entry 2022-04-30 16:28:50 +02:00
shilangyu d0b7c0776f Update l10n_from_string 2022-04-30 16:28:50 +02:00
shilangyu 44162f282c Update ios files 2022-04-30 16:28:50 +02:00
shilangyu 23f64387a7 Add missing community default 2022-04-30 16:28:50 +02:00
shilangyu d64fe48328 Add image upload 2022-04-30 16:28:50 +02:00
shilangyu 85f9d3fd0e Add cross platform file picker 2022-04-30 16:28:50 +02:00
shilangyu 8838efc073 Add create post page 2022-04-30 16:28:50 +02:00
shilangyu f6191936e6 Add community picker 2022-04-30 16:28:50 +02:00
shilangyu c320585810 Add searching to CreatePostStore 2022-04-30 16:28:50 +02:00
shilangyu 8c54e38e99 Regenerate weblate strings on PR 2022-04-30 16:28:50 +02:00
shilangyu a821681903 Initial refactor 2022-04-30 16:28:50 +02:00
shilangyu 5068eb900f Update link launcher 2022-04-30 16:24:29 +02:00
shilangyu 816b7d1346 Update ios podfile 2022-04-30 16:24:29 +02:00
shilangyu f5b02a369b Bump deps 2022-04-30 16:24:29 +02:00
shilangyu 873ad16baf Remove lint 2022-04-30 16:24:29 +02:00
shilangyu c34f681602 WIP new lints 2022-04-30 16:24:29 +02:00
Marcin Wojnarowski 11d63e0522
Upgrade to flutter 2.10 (#325) 2022-04-30 15:44:03 +02:00
shilangyu 553340aa38 Fix weblate 2022-01-25 19:10:18 +01:00
shilangyu cd0343e999 Fix weblate 2022-01-25 18:23:48 +01:00
shilangyu bea100ed46 Fix weblate workflow 2022-01-25 11:15:24 +01:00
shilangyu 8f88ad4fe5 Fix weblate workflow 2022-01-25 11:10:14 +01:00
Marcin Wojnarowski c414033e57
Add manual weblate workflow trigger (#323) 2022-01-25 10:27:31 +01:00
shilangyu 389d1381b4 Add changelog entry 2022-01-20 12:03:04 +01:00
shilangyu 652e912950 Update l10n_from_string 2022-01-20 11:57:14 +01:00
shilangyu a615b27d64 Update ios files 2022-01-20 11:55:23 +01:00
shilangyu 766762078c Add missing community default 2022-01-20 11:55:23 +01:00
shilangyu 9e7793f949 Add image upload 2022-01-20 11:55:23 +01:00
shilangyu 2b6ce0e6b2 Add cross platform file picker 2022-01-20 11:55:23 +01:00
shilangyu eeb9a84b6b Add create post page 2022-01-20 11:55:23 +01:00
shilangyu 7aad355b21 Add community picker 2022-01-20 11:55:23 +01:00
shilangyu 6a814ab128 Add searching to CreatePostStore 2022-01-20 11:55:23 +01:00
shilangyu 8eb4672bcd Regenerate weblate strings on PR 2022-01-20 11:55:23 +01:00
shilangyu 7a13a94e51 Initial refactor 2022-01-20 11:55:22 +01:00
Marcin Wojnarowski 88608ea9e1
Migrate instance page to mobx + l10 strings (#316) 2022-01-20 11:50:24 +01:00
Filip Krawczyk 56bba4d6af
Merge pull request #322 from LemmurOrg/privacy-policy 2022-01-20 10:09:37 +01:00
Filip Krawczyk a0b8fd4c05 Add privacy policy 2022-01-19 23:24:17 +01:00
github-actions[bot] 52bd797eb8
Weblate update (#319)
* Translated using Weblate (Polish)

Currently translated at 96.2% (128 of 133 strings)

Translation: Lemmur/lemmur
Translate-URL: http://weblate.yerbamate.ml/projects/lemmur/lemmur/pl/

* Translated using Weblate (Japanese)

Currently translated at 100.0% (133 of 133 strings)

Translation: Lemmur/lemmur
Translate-URL: http://weblate.yerbamate.ml/projects/lemmur/lemmur/ja/

* Translated using Weblate (Polish)

Currently translated at 96.9% (129 of 133 strings)

Translation: Lemmur/lemmur
Translate-URL: http://weblate.yerbamate.ml/projects/lemmur/lemmur/pl/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (133 of 133 strings)

Translation: Lemmur/lemmur
Translate-URL: http://weblate.yerbamate.ml/projects/lemmur/lemmur/pt_BR/

Co-authored-by: shilangyu <xmarcinmarcin@gmail.com>
Co-authored-by: DignifiedSilence <5Dd12Ck@protonmail.com>
Co-authored-by: uma chinchila com um teclado <28pbznk6uk@protonmail.com>
2022-01-16 11:12:30 +01:00
Marcin Wojnarowski 93735dfad8
Stop being a beta (#318) 2022-01-14 21:28:23 +01:00
109 changed files with 3964 additions and 3372 deletions

View File

@ -69,7 +69,7 @@ jobs:
steps:
- uses: actions/checkout@v2
- uses: subosito/flutter-action@v1
- uses: subosito/flutter-action@v2
with:
channel: "stable"
@ -91,10 +91,6 @@ jobs:
sudo apt-get update -y
sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libblkid-dev
- name: Enable linux support
run: |
flutter config --enable-linux-desktop
- name: Build
run: |
flutter build linux --release --target lib/main_prod.dart
@ -110,10 +106,6 @@ jobs:
with:
channel: "stable"
- name: Enable windows support
run: |
flutter config --enable-windows-desktop
- name: Build
run: |
flutter build windows --release --target lib/main_prod.dart

View File

@ -90,10 +90,6 @@ jobs:
sudo apt-get update -y
sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libblkid-dev
- name: Enable linux support
run: |
flutter config --enable-linux-desktop
- name: Build
run: |
flutter build linux --release --target lib/main_prod.dart
@ -119,10 +115,6 @@ jobs:
with:
channel: "stable"
- name: Enable windows support
run: |
flutter config --enable-windows-desktop
- name: Build
run: |
flutter build windows --release --target lib/main_prod.dart
@ -165,7 +157,6 @@ jobs:
linux-build/*
windows-build/*
draft: true
prerelease: true
body_path: current-changelog.txt
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,6 +1,7 @@
name: weblate
on:
workflow_dispatch:
schedule:
# every friday at 19:00 UTC
- cron: "0 19 * * 5"
@ -11,13 +12,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Fetch changes
run: |
git remote add weblate http://weblate.yerbamate.ml/git/lemmur/lemmur
git remote add weblate https://weblate.yerbamate.ml/git/lemmur/lemmur/
git fetch weblate
git merge weblate/master
- name: Regenerate l10n_from_string
run: |
dart run scripts/gen_l10n_from_string.dart
- name: Create Pull Request
uses: peter-evans/create-pull-request@v3.12.0
with:

View File

@ -34,7 +34,7 @@
"L10n string": {
"scope": "dart",
"prefix": "l10n",
"body": ["L10n.of(context)!.$0"]
"body": ["L10n.of(context).$0"]
},
"Mobx store": {
"prefix": "mobxstore",

View File

@ -1,3 +1,11 @@
## Unreleased
### Added
- Create post community picker now has autocomplete
- You can now add an instance from the three dots menu on the instance page
- Editor for writing comments, posts, and profile bio now has a toolbar with shortcuts to all the common markdown things
## v0.8.0 - 2022-01-14
### Added

5
PRIVACY_POLICY.md Normal file
View File

@ -0,0 +1,5 @@
# Privacy Policy
We don't store your data. We don't use any intermediary services that could store your data.
For any questions contact us at lemmurapp@protonmail.com

View File

@ -1,6 +1,13 @@
# ⚠️ THIS PROJECT IS NOT MAINTAINED ANYMORE ⚠️
This project has been officially dropped due to lack of interest and political differences. If anyone is interested in continuing developement, feel free to fork it. For any questions you can message [krawieck](https://matrix.to/#/@krawieck:matrix.org) (who was responsible for the flutter app) or [shilangyu](https://matrix.to/#/@shilangyu:matrix.org) (who was responsible for lemmy_api_client).
---
<div align="center">
[![](https://github.com/LemmurOrg/lemmur/workflows/ci/badge.svg)](https://github.com/LemmurOrg/lemmur/actions)
[![Translation status](http://weblate.yerbamate.ml/widgets/lemmur/-/lemmur/svg-badge.svg)](http://weblate.yerbamate.ml/engage/lemmur/)
<img width=200px height=200px src="https://raw.githubusercontent.com/LemmurOrg/lemmur/master/assets/readme_icon.svg"/>
@ -43,22 +50,14 @@ The apk will be in `build/app/outputs/flutter-apk/app-prod-release.apk`
### Linux
1. Make sure you have the additional [linux requirements](https://flutter.dev/desktop#additional-linux-requirements) (verify with `flutter doctor`)
2. Enable linux desktop:
```sh
flutter config --enable-linux-desktop
```
3. Build: `flutter build linux --target lib/main_prod.dart --release`
2. Build: `flutter build linux --target lib/main_prod.dart --release`
The executable will be in `build/linux/x64/release/bundle/lemmur` (be aware, however, that this executable is not standalone)
### Windows
1. Make sure you have the additional [windows requirements](https://flutter.dev/desktop#additional-windows-requirements) (verify with `flutter doctor`)
2. Enable windows desktop:
```sh
flutter config --enable-windows-desktop
```
3. Build: `flutter build windows --target lib/main_prod.dart --release`
2. Build: `flutter build windows --target lib/main_prod.dart --release`
The executable will be in `build\windows\runner\Release\lemmur.exe` (be aware, however, that this executable is not standalone)

View File

@ -5,6 +5,7 @@ linter:
- avoid_catching_errors
- avoid_equals_and_hash_code_on_mutable_classes
- avoid_escaping_inner_quotes
- avoid_final_parameters
- avoid_function_literals_in_foreach_calls
- avoid_init_to_null
- avoid_null_checks_in_equality_operators
@ -29,6 +30,7 @@ linter:
- cascade_invocations
- cast_nullable_to_non_nullable
- constant_identifier_names
- conditional_uri_does_not_exist
- curly_braces_in_flow_control_structures
- directives_ordering
- empty_catches
@ -41,8 +43,11 @@ linter:
- invariant_booleans
- library_names
- library_prefixes
- literal_only_boolean_expressions
- non_constant_identifier_names
- noop_primitive_operations
- no_leading_underscores_for_library_prefixes
- no_leading_underscores_for_local_identifiers
- null_check_on_nullable_type_parameter
- omit_local_variable_types
- one_member_abstracts
@ -81,7 +86,9 @@ linter:
- prefer_spread_collections
- prefer_typing_uninitialized_variables
- recursive_getters
- secure_pubspec_urls
- sized_box_for_whitespace
- sized_box_shrink_expand
- slash_for_doc_comments
- sort_child_properties_last
- sort_unnamed_constructors_first
@ -94,6 +101,7 @@ linter:
- unnecessary_constructor_name
- unnecessary_getters_setters
- unnecessary_lambdas
- unnecessary_late
- unnecessary_new
- unnecessary_null_aware_assignments
- unnecessary_null_checks
@ -104,12 +112,15 @@ linter:
- unnecessary_string_interpolations
- unnecessary_this
- unrelated_type_equality_checks
- use_colored_box
- use_enums
- use_full_hex_values_for_flutter_colors
- use_is_even_rather_than_modulo
- use_named_constants
- use_raw_strings
- use_rethrow_when_possible
- use_setters_to_change_properties
- use_super_parameters
- use_test_throws_matchers
- use_to_and_as_if_applicable
- void_checks

View File

@ -26,7 +26,7 @@ apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
android {
compileSdkVersion 30
compileSdkVersion Math.max(flutter.compileSdkVersion, 32)
lintOptions {
disable 'InvalidPackage'
@ -47,8 +47,8 @@ android {
defaultConfig {
applicationId "com.krawieck.lemmur"
minSdkVersion 16
targetSdkVersion 30
minSdkVersion Math.max(flutter.minSdkVersion, 16)
targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}

View File

@ -7,12 +7,13 @@
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<application
android:name="io.flutter.app.FlutterApplication"
android:name="${applicationName}"
android:label="${appName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"

View File

@ -1,12 +1,12 @@
buildscript {
ext.kotlin_version = '1.3.50'
ext.kotlin_version = '1.7.0'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.0.2'
classpath 'com.android.tools.build:gradle:7.2.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}

View File

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-all.zip

View File

@ -215,7 +215,10 @@
"number_of_users_online": "{formattedCount,plural, =1{{formattedCount} user online} other{{formattedCount} users online}}",
"@number_of_users_online": {
"placeholders": {
"formattedCount": {}
"formattedCount": {
"type": "int",
"format": "compact"
}
}
},
"number_of_comments": "{formattedCount,plural, =1{{formattedCount} comment} other{{formattedCount} comments}}",
@ -239,13 +242,28 @@
"number_of_subscribers": "{formattedCount,plural, =1{{formattedCount} subscriber} other{{formattedCount} subscribers}}",
"@number_of_subscribers": {
"placeholders": {
"formattedCount": {}
"formattedCount": {
"type": "int",
"format": "compact"
}
}
},
"number_of_users": "{formattedCount,plural, =1{{formattedCount} user} other{{formattedCount} users}}",
"@number_of_users": {
"placeholders": {
"formattedCount": {}
"formattedCount": {
"type": "int",
"format": "compact"
}
}
},
"number_of_communities": "{formattedCount,plural, =1{{formattedCount} community} other{{formattedCount} communities}}",
"@number_of_communities": {
"placeholders": {
"formattedCount": {
"type": "int",
"format": "compact"
}
}
},
"unsubscribe": "unsubscribe",
@ -291,5 +309,135 @@
"show_bot_accounts": "Show Bot Accounts",
"@show_bot_accounts": {},
"show_read_posts": "Show Read Posts",
"@show_read_posts": {}
"@show_read_posts": {},
"site_not_set_up": "This site has not yet been set up",
"@site_not_set_up": {},
"nerd_stuff": "Nerd stuff",
"@nerd_stuff": {},
"open_in_browser": "Open in browser",
"@open_in_browser": {},
"cannot_open_in_browser": "Can't open in browser",
"@cannot_open_in_browser": {},
"about": "About",
"@about": {},
"see_all": "See all",
"@see_all": {},
"admins": "Admins",
"@admins": {},
"trending_communities": "Trending communities",
"@trending_communities": {},
"communities_of_instance": "Communities of {instance}",
"@communities_of_instance": {
"placeholders": {
"instance": {
"type": "String"
}
}
},
"day": "day",
"@day": {},
"week": "week",
"@week": {},
"month": "month",
"@month": {},
"six_months": "6 months",
"@six_months": {},
"add_instance": "Add instance",
"@add_instance": {},
"instance_added": "Instance successfully added",
"@instance_added": {},
"required_field": "required field",
"@required_field": {},
"no_communities_found": "No communities found",
"@no_communities_found": {},
"network_error": "Network error",
"@network_error": {},
"editor_bold": "bold",
"@editor_bold": {
"description": "tooltip for button making text bold in markdown editor toolbar"
},
"editor_italics": "italics",
"@editor_italics": {
"description": "tooltip for button making text italics in markdown editor toolbar"
},
"editor_link": "insert link",
"@editor_link": {
"description": "tooltip for button that inserts link in markdown editor toolbar"
},
"editor_image": "insert image",
"@editor_image": {
"description": "tooltip for button that inserts image in markdown editor toolbar"
},
"editor_user": "link user",
"@editor_user": {
"description": "tooltip for button that opens a popup to select user to be linked in markdown editor toolbar"
},
"editor_community": "link community",
"@editor_community": {
"description": "tooltip for button that opens a popup to select community to be linked in markdown editor toolbar"
},
"editor_header": "insert header",
"@editor_header": {
"description": "tooltip for button that inserts header in markdown editor toolbar"
},
"editor_strikethrough": "strikethrough",
"@editor_strikethrough": {
"description": "tooltip for button that makes text strikethrough in markdown editor toolbar"
},
"editor_quote": "quote",
"@editor_quote": {
"description": "tooltip for button that makes selected text into quote blocks in markdown editor toolbar"
},
"editor_list": "list",
"@editor_list": {
"description": "tooltip for button that makes selected text into list in markdown editor toolbar"
},
"editor_code": "code",
"@editor_code": {
"description": "tooltip for button that makes text into code in markdown editor toolbar"
},
"editor_subscript": "subscript",
"@editor_subscript": {
"description": "tooltip for button that makes text into subscript in markdown editor toolbar"
},
"editor_superscript": "superscript",
"@editor_superscript": {
"description": "tooltip for button that makes text into superscript in markdown editor toolbar"
},
"editor_spoiler": "spoiler",
"@editor_spoiler": {
"description": "tooltip for button that inserts spoiler in markdown editor toolbar"
},
"editor_help": "markdown guide",
"@editor_help": {
"description": "tooltip for button that goes to page containing a guide for markdown"
},
"insert_text_here_placeholder": "[write text here]",
"@insert_text_here_placeholder": {
"description": "placeholder for text in markdown editor when inserting stuff like * for italics or ** for bold, etc."
},
"select_user": "Select User",
"@select_user": {
"description": "Title on a popup that lets a user search and select another user"
},
"select_community": "Select Community",
"@select_community": {
"description": "Title on a popup that lets a user search and select a community"
},
"add_link": "Add link",
"@add_link": {
"description": "title on top of a link insertion popup in a markdown editor"
},
"cancel": "Cancel",
"@cancel": {
"description": "Cancel button on popup"
},
"editor_add_link_label": "label",
"@editor_add_link_label": {
"description": "palceholder for link label on an Add link popup in markdown editor"
},
"failed_to_upload_image": "Failed to upload image",
"@failed_to_upload_image": {
"description": "shows up on a snackbar when the image upload failed (duh)"
}
}

View File

@ -1,132 +1,269 @@
{
"@@locale": "ja",
"settings": "設定",
"@settings": {},
"password": "パスワード",
"@password": {},
"email_or_username": "メールアドレスまたはユーザー名",
"@email_or_username": {},
"posts": "投稿",
"@posts": {},
"comments": "コメント",
"@comments": {},
"modlog": "モデレーションログ",
"@modlog": {},
"community": "コミュニティ",
"@community": {},
"url": "URL",
"@url": {},
"title": "タイトル",
"@title": {},
"body": "本文",
"@body": {},
"nsfw": "閲覧注意",
"@nsfw": {},
"post": "投稿",
"@post": {},
"save": "保存",
"@save": {},
"subscribed": "登録済み",
"@subscribed": {},
"local": "インスタンス内",
"@local": {},
"all": "全て",
"@all": {},
"replies": "返信",
"@replies": {},
"mentions": "言及",
"@mentions": {},
"from": "から",
"@from": {},
"to": "投稿先",
"@to": {},
"deleted_by_creator": "投稿者によって削除済み",
"@deleted_by_creator": {},
"more": "さらに表示",
"@more": {},
"mark_as_read": "既読にする",
"@mark_as_read": {},
"mark_as_unread": "未読にする",
"@mark_as_unread": {},
"reply": "返信",
"@reply": {},
"edit": "編集",
"@edit": {},
"delete": "削除",
"@delete": {},
"restore": "復元",
"@restore": {},
"yes": "はい",
"@yes": {},
"no": "いいえ",
"@no": {},
"avatar": "アバター",
"@avatar": {},
"banner": "バナー",
"display_name": "アカウント名",
"@banner": {},
"display_name": "表示名",
"@display_name": {},
"bio": "自己紹介",
"@bio": {},
"email": "メールアドレス",
"@email": {},
"matrix_user": "Matrix のユーザーアカウント",
"@matrix_user": {},
"sort_type": "投稿やコメントの並び順",
"type": "コミュニティの表示",
"@sort_type": {},
"type": "検索対象",
"@type": {},
"show_nsfw": "閲覧注意のコンテンツを表示",
"@show_nsfw": {},
"send_notifications_to_email": "通知を設定したメールアドレスに送信",
"@send_notifications_to_email": {},
"delete_account": "アカウントを削除",
"@delete_account": {},
"saved": "保存済み",
"@saved": {},
"communities": "コミュニティ",
"@communities": {},
"users": "ユーザー",
"@users": {},
"theme": "テーマ",
"@theme": {},
"language": "言語",
"@language": {},
"hot": "人気",
"@hot": {},
"new_": "新しい順",
"@new_": {},
"old": "古い順",
"@old": {},
"top": "トップ",
"@top": {},
"chat": "会話",
"@chat": {},
"admin": "管理者",
"@admin": {},
"by": "投稿者",
"@by": {},
"not_a_mod_or_admin": "モデレーターまたは管理者ではありません。",
"@not_a_mod_or_admin": {},
"not_an_admin": "管理者ではありません。",
"@not_an_admin": {},
"couldnt_find_post": "投稿が見つかりせんでした。",
"@couldnt_find_post": {},
"not_logged_in": "ログインしていません。",
"@not_logged_in": {},
"site_ban": "サイトへのアクセスを禁止されています",
"@site_ban": {},
"community_ban": "このコミュニティへのアクセスを禁止されています。",
"@community_ban": {},
"downvotes_disabled": "反対票を無効化",
"@downvotes_disabled": {},
"invalid_url": "無効なURL。",
"@invalid_url": {},
"locked": "凍結中",
"@locked": {},
"couldnt_create_comment": "投稿が作成されませんでした。",
"@couldnt_create_comment": {},
"couldnt_like_comment": "コメントを「いいね」できませんでした。",
"@couldnt_like_comment": {},
"couldnt_update_comment": "コメントが更新されませんでした。",
"@couldnt_update_comment": {},
"no_comment_edit_allowed": "コメントの編集権限がありません。",
"@no_comment_edit_allowed": {},
"couldnt_save_comment": "コメントが保存されませんでした。",
"@couldnt_save_comment": {},
"couldnt_get_comments": "コメントを読み込みできませんでした。",
"@couldnt_get_comments": {},
"invalid_post_title": "無効な投稿のタイトル",
"@invalid_post_title": {},
"couldnt_create_post": "投稿が作成されませんでした。",
"@couldnt_create_post": {},
"couldnt_like_post": "投稿を「いいね」できませんでした。",
"@couldnt_like_post": {},
"couldnt_find_community": "コミュニティが見つかりませんでした。",
"@couldnt_find_community": {},
"couldnt_get_posts": "投稿を読み込みできませんでした",
"@couldnt_get_posts": {},
"no_post_edit_allowed": "投稿の編集権限がありません。",
"@no_post_edit_allowed": {},
"couldnt_save_post": "投稿が保存されませんでした。",
"@couldnt_save_post": {},
"site_already_exists": "サイトは既に存在します。",
"@site_already_exists": {},
"couldnt_update_site": "サイトが更新されませんでした。",
"invalid_community_name": "無効な名前です。",
"@couldnt_update_site": {},
"invalid_community_name": "無効なコミュニティの名前です。",
"@invalid_community_name": {},
"community_already_exists": "コミュニティは既に存在します。",
"@community_already_exists": {},
"community_moderator_already_exists": "コミュニティのモデレーターは既に存在しています。",
"@community_moderator_already_exists": {},
"community_follower_already_exists": "コミュニティへのフォロワーは既に存在します。",
"@community_follower_already_exists": {},
"not_a_moderator": "モデレーターではありません。",
"@not_a_moderator": {},
"couldnt_update_community": "コミュニティを更新できませんでした。",
"@couldnt_update_community": {},
"no_community_edit_allowed": "コミュニティの編集許可がありません。",
"system_err_login": "システムエラーが発生しました。一度ログアウトして、再度ログインをお試しください。",
"@no_community_edit_allowed": {},
"system_err_login": "システムエラーが発生しました。一度ログアウトして再度ログインしてから、お試しください。",
"@system_err_login": {},
"community_user_already_banned": "コミュニティのユーザーは既にアクセスが禁止されています。",
"@community_user_already_banned": {},
"couldnt_find_that_username_or_email": "ユーザー名またはメールアドレスが見つかりませんでした。",
"@couldnt_find_that_username_or_email": {},
"password_incorrect": "無効なパスワードです。",
"@password_incorrect": {},
"registration_closed": "新規登録は受け付けていません",
"@registration_closed": {},
"invalid_password": "無効なパスワードです。パスワードは必ず60文字以下にしてください。",
"@invalid_password": {},
"passwords_dont_match": "パスワードが一致しません。",
"@passwords_dont_match": {},
"captcha_incorrect": "Captchaコードが合っていません。",
"invalid_username": "無効なユーザーネームです。",
"@captcha_incorrect": {},
"invalid_username": "無効なユーザー名です。",
"@invalid_username": {},
"bio_length_overflow": "自己紹介は 300 文字までです。",
"couldnt_update_user": "ユーザーが更新されない。",
"@bio_length_overflow": {},
"couldnt_update_user": "ユーザーが更新されませんでした。",
"@couldnt_update_user": {},
"couldnt_update_private_message": "プライベートメッセージが更新されませんでした。",
"@couldnt_update_private_message": {},
"couldnt_update_post": "投稿が更新されませんでした",
"@couldnt_update_post": {},
"couldnt_create_private_message": "プライベートメッセージが作成されませんでした。",
"@couldnt_create_private_message": {},
"no_private_message_edit_allowed": "プライベートメッセージの編集許可がありません。",
"@no_private_message_edit_allowed": {},
"post_title_too_long": "投稿のタイトルが長すぎます。",
"@post_title_too_long": {},
"email_already_exists": "メールアドレスが既に使用されています。",
"@email_already_exists": {},
"user_already_exists": "ユーザーは既に存在します。",
"number_of_users_online": "{formattedCount,plural, other{{formattedCount}名のユーザーがオンライン}}",
"number_of_comments": "{formattedCount,plural, other{{formattedCount}件のコメント}}",
"number_of_posts": "{formattedCount,plural, other{{formattedCount}件の投稿}}",
"number_of_subscribers": "{formattedCount,plural, other{{formattedCount}名の登録者}}",
"number_of_users": "{formattedCount,plural, other{{formattedCount}名のユーザー}}",
"@user_already_exists": {},
"number_of_users_online": "{formattedCount,plural, =1{{formattedCount} 名のユーザーがオンライン} other{{formattedCount} 名のユーザーがオンライン}}",
"@number_of_users_online": {},
"number_of_comments": "{formattedCount,plural, =1{{formattedCount} 件のコメント} other{{formattedCount} 件のコメント}}",
"@number_of_comments": {},
"number_of_posts": "{formattedCount,plural, =1{{formattedCount} 件の投稿} other{{formattedCount} 件の投稿}}",
"@number_of_posts": {},
"number_of_subscribers": "{formattedCount,plural, =1{{formattedCount} 名の登録者} other{{formattedCount} 名の登録者}}",
"@number_of_subscribers": {},
"number_of_users": "{formattedCount,plural, =1{{formattedCount} 名のユーザー} other{{formattedCount} 名のユーザー}}",
"@number_of_users": {},
"unsubscribe": "登録解除",
"@unsubscribe": {},
"subscribe": "登録",
"@subscribe": {},
"messages": "メッセージ",
"@messages": {},
"banned_users": "アクセス禁止してるユーザー",
"delete_account_confirm": "警告: あなたの全てのデータをこのインスタンスから永久に削除します。連合機能によって他のインスタンスにあるあなたのデータは、削除されないかもしれません。確認のため、パスワードを入力してください。",
"@banned_users": {},
"delete_account_confirm": "警告: あなたの全てのデータは、このインスタンスから永久に削除されます。しかし、Lemmyの連合機能によって他のインスタンスにある、あなたの一部のデータは削除されないかもしれません。確認のため、パスワードを入力してください。",
"@delete_account_confirm": {},
"new_password": "新しいパスワード",
"@new_password": {},
"verify_password": "パスワードの確認",
"@verify_password": {},
"old_password": "現在のパスワード",
"@old_password": {},
"show_avatars": "アバターを表示",
"@show_avatars": {},
"search": "検索",
"@search": {},
"send_message": "メッセージを送信",
"@send_message": {},
"top_day": "今日の人気順",
"@top_day": {},
"top_week": "週間での人気順",
"@top_week": {},
"top_month": "月間での人気順",
"@top_month": {},
"top_year": "年間での人気順",
"@top_year": {},
"top_all": "全ての期間での人気順",
"@top_all": {},
"most_comments": "コメントの多い順",
"@most_comments": {},
"new_comments": "新しくコメントが入った順",
"@new_comments": {},
"active": "活発",
"@active": {},
"bot_account": "このアカウントをbotにする",
"show_bot_accounts": "ボットアカウントを表示",
"show_read_posts": "読んだ投稿を表示"
"@bot_account": {},
"show_bot_accounts": "botアカウントを表示",
"@show_bot_accounts": {},
"show_read_posts": "読んだ投稿を表示",
"@show_read_posts": {},
"report_reason_required": "通報理由は必須です。",
"@report_reason_required": {},
"report_too_long": "通報理由が長すぎます。",
"@report_too_long": {},
"couldnt_create_report": "通報を作成できませんでした。",
"@couldnt_create_report": {},
"couldnt_resolve_report": "通報を解決できませんでした。",
"@couldnt_resolve_report": {}
}

View File

@ -1,131 +1,268 @@
{
"@@locale": "pl",
"settings": "Ustawienia",
"settings": "Ustawieniad",
"@settings": {},
"password": "Hasło",
"@password": {},
"email_or_username": "Email lub Login",
"@email_or_username": {},
"posts": "Posty",
"@posts": {},
"comments": "Komentarze",
"@comments": {},
"modlog": "Log moderatorski",
"@modlog": {},
"community": "Społeczność",
"@community": {},
"url": "Link",
"@url": {},
"title": "Tytuł",
"@title": {},
"body": "Treść",
"@body": {},
"nsfw": "NSFW (18+)",
"@nsfw": {},
"post": "post",
"@post": {},
"save": "zapisz",
"@save": {},
"subscribed": "Subskrybowane",
"@subscribed": {},
"local": "Lokalne",
"@local": {},
"all": "Wszystko",
"@all": {},
"replies": "Odpowiedzi",
"@replies": {},
"mentions": "Wzmianki",
"@mentions": {},
"from": "od",
"@from": {},
"to": "do",
"@to": {},
"deleted_by_creator": "usunięte przez osobę publikującą",
"@deleted_by_creator": {},
"more": "więcej",
"@more": {},
"mark_as_read": "zaznacz jako przeczytane",
"@mark_as_read": {},
"mark_as_unread": "zaznacz jako nieprzeczytane",
"@mark_as_unread": {},
"reply": "odpowiedz",
"@reply": {},
"edit": "edytuj",
"@edit": {},
"delete": "usuń",
"@delete": {},
"restore": "przywróć",
"@restore": {},
"yes": "tak",
"@yes": {},
"no": "nie",
"@no": {},
"avatar": "Awatar",
"@avatar": {},
"banner": "Baner",
"@banner": {},
"display_name": "Nazwa wyświetlana",
"@display_name": {},
"bio": "Opis",
"@bio": {},
"email": "Email",
"@email": {},
"matrix_user": "Login na Matrixie",
"@matrix_user": {},
"sort_type": "Sortuj typ",
"@sort_type": {},
"type": "Rodzaj",
"@type": {},
"show_nsfw": "Pokaż treści NSFW (18+)",
"@show_nsfw": {},
"send_notifications_to_email": "Wysyłaj powiadomienia na Email",
"@send_notifications_to_email": {},
"delete_account": "Usuń Konto",
"@delete_account": {},
"saved": "Zapisane",
"@saved": {},
"communities": "Społeczności",
"@communities": {},
"users": "Osoby zalogowane",
"@users": {},
"theme": "Motyw",
"@theme": {},
"language": "Język",
"@language": {},
"hot": "Popularne",
"@hot": {},
"new_": "Nowe",
"@new_": {},
"old": "Stare",
"@old": {},
"top": "Najpopularniejsze",
"@top": {},
"chat": "Dyskusja",
"@chat": {},
"admin": "admin",
"@admin": {},
"by": "przez",
"@by": {},
"not_a_mod_or_admin": "Nie moderuje ani administruje.",
"@not_a_mod_or_admin": {},
"not_an_admin": "Nie jest administratorem.",
"@not_an_admin": {},
"couldnt_find_post": "Nie udało się znaleźć posta.",
"@couldnt_find_post": {},
"not_logged_in": "Nie jesteś zalogowana/y.",
"@not_logged_in": {},
"site_ban": "Zostałaś/eś zbanowana/y z tej witryny",
"@site_ban": {},
"community_ban": "Zostałaś/eś zbanowana/y z tej społeczności.",
"@community_ban": {},
"downvotes_disabled": "Wdółgłosy wyłączone",
"@downvotes_disabled": {},
"invalid_url": "Nieprawidłowy link.",
"@invalid_url": {},
"locked": "zablokowane",
"@locked": {},
"couldnt_create_comment": "Nie udało się stworzyć komentarza.",
"@couldnt_create_comment": {},
"couldnt_like_comment": "Polubienie komentarza nie powiodło się.",
"@couldnt_like_comment": {},
"couldnt_update_comment": "Zaktualizowanie komentarza nie powiodło się.",
"@couldnt_update_comment": {},
"no_comment_edit_allowed": "Nie masz uprawnień do edycji komentarza.",
"@no_comment_edit_allowed": {},
"couldnt_save_comment": "Zapisanie komentarza nie powiodło się.",
"@couldnt_save_comment": {},
"couldnt_get_comments": "Pobranie komentarzy nie powiodło się.",
"@couldnt_get_comments": {},
"report_reason_required": "Wymagane jest uzasadnienie zgłoszenia.",
"@report_reason_required": {},
"report_too_long": "Zgłoszenie jest zbyt długie.",
"@report_too_long": {},
"couldnt_create_report": "Nie udało się stworzyć zgłoszenia.",
"@couldnt_create_report": {},
"couldnt_resolve_report": "Nie udało się rozwiązać zgłoszenia.",
"@couldnt_resolve_report": {},
"invalid_post_title": "Nieprawidłowy tytuł posta",
"@invalid_post_title": {},
"couldnt_create_post": "Nie udało się stworzyć posta.",
"@couldnt_create_post": {},
"couldnt_like_post": "Nie udało się polubić posta.",
"@couldnt_like_post": {},
"couldnt_find_community": "Nie udało się znaleźć społeczności.",
"@couldnt_find_community": {},
"couldnt_get_posts": "Nie udało się pobrać postów",
"@couldnt_get_posts": {},
"no_post_edit_allowed": "Nie masz uprawnień do edycji posta.",
"@no_post_edit_allowed": {},
"couldnt_save_post": "Nie udało się zapisać posta.",
"@couldnt_save_post": {},
"site_already_exists": "Witryna już istnieje.",
"@site_already_exists": {},
"couldnt_update_site": "Nie udało się zaktualizować witryny.",
"@couldnt_update_site": {},
"invalid_community_name": "Niepoprawna nazwa.",
"@invalid_community_name": {},
"community_already_exists": "Społeczność już istnieje.",
"@community_already_exists": {},
"community_moderator_already_exists": "Moderator społeczności już istnieje.",
"@community_moderator_already_exists": {},
"community_follower_already_exists": "Osoba obserwująca społeczność już istnieje.",
"@community_follower_already_exists": {},
"not_a_moderator": "Nie jest moderatorem.",
"@not_a_moderator": {},
"couldnt_update_community": "Nie udało się zaktualizować Społeczności.",
"@couldnt_update_community": {},
"no_community_edit_allowed": "Nie masz uprawnień do edycji społeczności.",
"@no_community_edit_allowed": {},
"system_err_login": "Błąd systemu. Spróbuj wylogować się i następnie zalogować ponownie.",
"@system_err_login": {},
"community_user_already_banned": "Login już zablokowany w tej społeczności.",
"@community_user_already_banned": {},
"couldnt_find_that_username_or_email": "Nie udało się znaleźć takiego loginu lub adresu email.",
"@couldnt_find_that_username_or_email": {},
"password_incorrect": "Hasło niepoprawne.",
"@password_incorrect": {},
"registration_closed": "Rejestracja Zamknięta",
"@registration_closed": {},
"invalid_password": "Nieprawidłowe hasło. Hasło musi mieć mniej niż 60 znaków.",
"@invalid_password": {},
"passwords_dont_match": "Hasła nie pasują do siebie.",
"@passwords_dont_match": {},
"captcha_incorrect": "Captcha niepoprawna.",
"@captcha_incorrect": {},
"invalid_username": "Nieprawidłowy login.",
"@invalid_username": {},
"bio_length_overflow": "To pole nie może przekraczać 300 znaków.",
"@bio_length_overflow": {},
"couldnt_update_user": "Nie udało się zaktualizować.",
"@couldnt_update_user": {},
"couldnt_update_private_message": "Nie udało się zaktualizować prywatnej wiadomości.",
"@couldnt_update_private_message": {},
"couldnt_update_post": "Nie udało się zaktualizować postów",
"@couldnt_update_post": {},
"couldnt_create_private_message": "Nie udało się stworzyć prywatnej wiadomości.",
"@couldnt_create_private_message": {},
"no_private_message_edit_allowed": "Brak uprawnień do edycji prywatnej wiadomości.",
"@no_private_message_edit_allowed": {},
"post_title_too_long": "Tytuł posta zbyt długi.",
"@post_title_too_long": {},
"email_already_exists": "Email już istnieje.",
"@email_already_exists": {},
"user_already_exists": "Login już istnieje.",
"@user_already_exists": {},
"unsubscribe": "Odsubskrybuj",
"@unsubscribe": {},
"subscribe": "Subskrybuj",
"@subscribe": {},
"messages": "Wiadomości",
"@messages": {},
"banned_users": "Zbanowani Użytkownicy",
"@banned_users": {},
"delete_account_confirm": "Ostrzeżenie: twoje dane zostaną bezpowrotnie usunięte na tej instancji. Mogą one pozostać na innych instancjach. Wpisz swoje hasło aby potwierdzić.",
"@delete_account_confirm": {},
"new_password": "Nowe Hasło",
"@new_password": {},
"verify_password": "Zweryfikuj Hasło",
"@verify_password": {},
"old_password": "Stare Hasło",
"@old_password": {},
"show_avatars": "Pokaż Awatary",
"@show_avatars": {},
"search": "Szukaj",
"@search": {},
"send_message": "Wyślij Wiadomość",
"@send_message": {},
"top_day": "Najpopularniejsze dziś",
"@top_day": {},
"top_week": "Najpopularniejsze tydzień",
"@top_week": {},
"top_month": "Najpopularniejsze miesiąc",
"@top_month": {},
"top_year": "Najpopularniejsze rok",
"@top_year": {},
"top_all": "Najpopularniejsze kiedykolwiek",
"@top_all": {},
"most_comments": "Najwięcej komentarzy",
"@most_comments": {},
"new_comments": "Nowe komentarze",
"@new_comments": {},
"active": "Aktywne",
"@active": {},
"bot_account": "Konto bota",
"@bot_account": {},
"show_bot_accounts": "Pokaż konta botów",
"show_read_posts": "Pokaż przeczytane posty"
"@show_bot_accounts": {},
"show_read_posts": "Pokaż przeczytane posty",
"@show_read_posts": {},
"number_of_posts": "{formattedCount,plural, =1{1 post} other{{formattedCount} postów}}",
"@number_of_posts": {
"placeholders": {
"formattedCount": {
"type": "int",
"format": "compact"
}
}
}
}

View File

@ -1,128 +1,269 @@
{
"@@locale": "pt_BR",
"settings": "Configurações",
"@settings": {},
"password": "Senha",
"@password": {},
"email_or_username": "E-mail ou nome de usuário",
"@email_or_username": {},
"posts": "Publicações",
"@posts": {},
"comments": "Comentários",
"@comments": {},
"modlog": "Registro de moderação",
"@modlog": {},
"community": "Comunidade",
"@community": {},
"url": "URL",
"@url": {},
"title": "Título",
"@title": {},
"body": "Conteúdo",
"@body": {},
"nsfw": "NSFW",
"@nsfw": {},
"post": "publicação",
"@post": {},
"save": "guardar",
"@save": {},
"subscribed": "Inscrito",
"@subscribed": {},
"local": "Local",
"@local": {},
"all": "Tudo",
"@all": {},
"replies": "Respostas",
"@replies": {},
"mentions": "Menções",
"@mentions": {},
"from": "de",
"@from": {},
"to": "para",
"@to": {},
"deleted_by_creator": "apagado pelo criador",
"@deleted_by_creator": {},
"more": "mais",
"@more": {},
"mark_as_read": "marcar como lido",
"@mark_as_read": {},
"mark_as_unread": "marcar como não lido",
"@mark_as_unread": {},
"reply": "responder",
"@reply": {},
"edit": "editar",
"@edit": {},
"delete": "apagar",
"@delete": {},
"restore": "restaurar",
"@restore": {},
"yes": "sim",
"@yes": {},
"no": "não",
"@no": {},
"avatar": "Avatar",
"@avatar": {},
"banner": "Banner",
"@banner": {},
"display_name": "Nome de exibição",
"@display_name": {},
"bio": "Biografia",
"@bio": {},
"email": "E-mail",
"@email": {},
"matrix_user": "Usuário Matrix",
"@matrix_user": {},
"sort_type": "Ordenação",
"@sort_type": {},
"type": "Tipo",
"@type": {},
"show_nsfw": "Mostrar conteúdo NSFW",
"@show_nsfw": {},
"send_notifications_to_email": "Enviar notificações para o e-mail",
"@send_notifications_to_email": {},
"delete_account": "Apagar conta",
"@delete_account": {},
"saved": "Guardado",
"@saved": {},
"communities": "Comunidades",
"@communities": {},
"users": "Usuários",
"@users": {},
"theme": "Tema",
"@theme": {},
"language": "Idioma",
"@language": {},
"hot": "Popular",
"@hot": {},
"new_": "Novo",
"@new_": {},
"old": "Velho",
"@old": {},
"top": "Top",
"@top": {},
"chat": "Chat",
"@chat": {},
"admin": "administrador",
"@admin": {},
"by": "por",
"@by": {},
"not_a_mod_or_admin": "Não é moderador ou administrador.",
"@not_a_mod_or_admin": {},
"not_an_admin": "Não é administrador.",
"@not_an_admin": {},
"couldnt_find_post": "Não foi possível encontrar a publicação.",
"@couldnt_find_post": {},
"not_logged_in": "Não autenticado.",
"@not_logged_in": {},
"site_ban": "Você foi banido do site",
"@site_ban": {},
"community_ban": "Você foi banido desta comunidade.",
"@community_ban": {},
"downvotes_disabled": "Votos negativos desativados",
"@downvotes_disabled": {},
"invalid_url": "URL inválida.",
"@invalid_url": {},
"locked": "trancado",
"@locked": {},
"couldnt_create_comment": "Não foi possível criar o comentário.",
"@couldnt_create_comment": {},
"couldnt_like_comment": "Não foi possível curtir o comentário.",
"@couldnt_like_comment": {},
"couldnt_update_comment": "Não foi possível atualizar o comentário.",
"@couldnt_update_comment": {},
"no_comment_edit_allowed": "Sem permissão para editar comentário.",
"@no_comment_edit_allowed": {},
"couldnt_save_comment": "Não foi possível salvar o comentário.",
"@couldnt_save_comment": {},
"couldnt_get_comments": "Não foi possível obter os comentários.",
"@couldnt_get_comments": {},
"invalid_post_title": "Título de publicação inválido",
"@invalid_post_title": {},
"couldnt_create_post": "Não foi possível criar a publicação.",
"@couldnt_create_post": {},
"couldnt_like_post": "Não foi possível curtir a publicação.",
"@couldnt_like_post": {},
"couldnt_find_community": "Não foi possível encontrar a comunidade.",
"@couldnt_find_community": {},
"couldnt_get_posts": "Não foi possível obter as publicações",
"@couldnt_get_posts": {},
"no_post_edit_allowed": "Sem permissão para editar publicação.",
"@no_post_edit_allowed": {},
"couldnt_save_post": "Não foi possível guardar a publicação.",
"@couldnt_save_post": {},
"site_already_exists": "O site já existe.",
"@site_already_exists": {},
"couldnt_update_site": "Não foi possível atualizar o site.",
"@couldnt_update_site": {},
"invalid_community_name": "Nome inválido.",
"@invalid_community_name": {},
"community_already_exists": "Esta comunidade já existe.",
"@community_already_exists": {},
"community_moderator_already_exists": "Este moderador da comunidade já existe.",
"@community_moderator_already_exists": {},
"community_follower_already_exists": "Este seguidor da comunidade já existe.",
"@community_follower_already_exists": {},
"not_a_moderator": "Não é um(a) moderador(a).",
"@not_a_moderator": {},
"couldnt_update_community": "Não foi possível atualizar a comunidade.",
"@couldnt_update_community": {},
"no_community_edit_allowed": "Sem permissão para editar comunidade.",
"@no_community_edit_allowed": {},
"system_err_login": "Erro no sistema. Tente sair e autenticar-se outra vez.",
"@system_err_login": {},
"community_user_already_banned": "Este usuário da comunidade já foi banido.",
"@community_user_already_banned": {},
"couldnt_find_that_username_or_email": "Não foi possível encontrar esse usuário ou e-mail.",
"@couldnt_find_that_username_or_email": {},
"password_incorrect": "Senha incorreta.",
"@password_incorrect": {},
"registration_closed": "Registros desativados",
"@registration_closed": {},
"invalid_password": "Senha inválida. A senha deve ter no máximo 60 caracteres.",
"@invalid_password": {},
"passwords_dont_match": "As senhas não são iguais.",
"@passwords_dont_match": {},
"captcha_incorrect": "Captcha incorreto.",
"@captcha_incorrect": {},
"invalid_username": "Nome de usuário inválido.",
"@invalid_username": {},
"bio_length_overflow": "Uma biografia de usuário não pode ter mais de 300 caracters.",
"@bio_length_overflow": {},
"couldnt_update_user": "Não foi possível atualizar o usuário.",
"@couldnt_update_user": {},
"couldnt_update_private_message": "Não foi possível atualizar a mensagem privada.",
"@couldnt_update_private_message": {},
"couldnt_update_post": "Não foi possível atualizar a publicação",
"@couldnt_update_post": {},
"couldnt_create_private_message": "Não foi possível criar mensagem privada.",
"@couldnt_create_private_message": {},
"no_private_message_edit_allowed": "Sem permissão para editar mensagem privada.",
"@no_private_message_edit_allowed": {},
"post_title_too_long": "Título da publicação muito longo.",
"@post_title_too_long": {},
"email_already_exists": "Este e-mail já existe.",
"@email_already_exists": {},
"user_already_exists": "Este usuário já existe.",
"@user_already_exists": {},
"number_of_users_online": "{formattedCount,plural, =1{{formattedCount} usuário online} other{{formattedCount} usuários online}}",
"@number_of_users_online": {},
"number_of_comments": "{formattedCount,plural, =1{{formattedCount} comentário} other{{formattedCount} comentários}}",
"@number_of_comments": {},
"number_of_posts": "{formattedCount,plural, =1{{formattedCount} publicação} other{{formattedCount} publicações}}",
"@number_of_posts": {},
"number_of_subscribers": "{formattedCount,plural, =1{{formattedCount} inscrito} other{{formattedCount} inscritos}}",
"@number_of_subscribers": {},
"number_of_users": "{formattedCount,plural, =1{{formattedCount} usuário} other{{formattedCount} usuários}}",
"@number_of_users": {},
"unsubscribe": "Cancelar inscrição",
"@unsubscribe": {},
"subscribe": "Inscrever-se",
"@subscribe": {},
"messages": "Mensagens",
"@messages": {},
"banned_users": "Usuários Banidos",
"@banned_users": {},
"delete_account_confirm": "Aviso: isso vai apagar seus dados de forma permanente. Escreva sua senha para confirmar.",
"@delete_account_confirm": {},
"new_password": "Nova senha",
"@new_password": {},
"verify_password": "Verifique a senha",
"@verify_password": {},
"old_password": "Senha antiga",
"@old_password": {},
"show_avatars": "Mostrar Avatares",
"@show_avatars": {},
"search": "Busca",
"@search": {},
"send_message": "Enviar mensagem",
"@send_message": {},
"top_day": "Melhor do dia",
"@top_day": {},
"top_week": "Melhor da semana",
"@top_week": {},
"top_month": "Melhor do mês",
"@top_month": {},
"top_year": "Melhor do ano",
"@top_year": {},
"most_comments": "Mais comentados",
"@most_comments": {},
"new_comments": "Novos comentários",
"active": "Ativo"
"@new_comments": {},
"active": "Ativo",
"@active": {},
"report_reason_required": "Motivo da denúncia é necessário.",
"@report_reason_required": {},
"couldnt_create_report": "Não foi possível criar a denúncia.",
"@couldnt_create_report": {},
"bot_account": "Conta de bot",
"@bot_account": {},
"report_too_long": "Denúncia longa demais.",
"@report_too_long": {},
"couldnt_resolve_report": "Não foi possível resolver a denúncia.",
"@couldnt_resolve_report": {},
"top_all": "Melhor de sempre",
"@top_all": {},
"show_bot_accounts": "Mostrar contas de bots",
"@show_bot_accounts": {},
"show_read_posts": "Mostrar publicações lidas",
"@show_read_posts": {}
}

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,6 @@ platform :android do
desc "Deploy a new version to the Google Play"
lane :prod do
upload_to_play_store(
track: "beta",
package_name: "com.krawieck.lemmur",
json_key: ENV["GOOGLE_SERVICE_ACCOUNT_KEY_PATH"],
aab: ENV["ABB_PATH"]

View File

@ -1,37 +1,85 @@
PODS:
- DKImagePickerController/Core (4.3.3):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.3)
- DKImagePickerController/PhotoGallery (4.3.3):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.3)
- DKPhotoGallery (0.0.17):
- DKPhotoGallery/Core (= 0.0.17)
- DKPhotoGallery/Model (= 0.0.17)
- DKPhotoGallery/Preview (= 0.0.17)
- DKPhotoGallery/Resource (= 0.0.17)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.17):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.17):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.17):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.17):
- SDWebImage
- SwiftyGif
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Flutter (1.0.0)
- flutter_keyboard_visibility (0.0.1):
- Flutter
- image_picker (0.0.1):
- image_picker_ios (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_ios (0.0.1):
- Flutter
- SDWebImage (5.12.5):
- SDWebImage/Core (= 5.12.5)
- SDWebImage/Core (5.12.5)
- share_plus (0.0.1):
- Flutter
- shared_preferences_ios (0.0.1):
- Flutter
- SwiftyGif (5.4.3)
- url_launcher_ios (0.0.1):
- Flutter
DEPENDENCIES:
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`)
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
- image_picker (from `.symlinks/plugins/image_picker/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
SPEC REPOS:
trunk:
- DKImagePickerController
- DKPhotoGallery
- SDWebImage
- SwiftyGif
EXTERNAL SOURCES:
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
Flutter:
:path: Flutter
flutter_keyboard_visibility:
:path: ".symlinks/plugins/flutter_keyboard_visibility/ios"
image_picker:
:path: ".symlinks/plugins/image_picker/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_ios:
@ -44,15 +92,20 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/url_launcher_ios/ios"
SPEC CHECKSUMS:
DKImagePickerController: 72fd378f244cef3d27288e0aebf217a4467e4012
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: 817ab1d8cd2da9d2da412a417162deee3500fc95
Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
image_picker: 9aa50e1d8cdacdbed739e925b7eea16d014367e6
image_picker_ios: b786a5dcf033a8336a657191401bfdf12017dabb
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
SDWebImage: 0905f1b7760fc8ac4198cae0036600d67478751e
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
shared_preferences_ios: aef470a42dc4675a1cdd50e3158b42e3d1232b32
url_launcher_ios: 02f1989d4e14e998335b02b67a7590fa34f971af
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de
PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c
COCOAPODS: 1.11.2
COCOAPODS: 1.11.3

View File

@ -23,7 +23,7 @@
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<true />
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
@ -42,7 +42,9 @@
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<false />
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
<!-- Image picker -->
<key>NSPhotoLibraryUsageDescription</key>
@ -51,5 +53,7 @@
<string>For uploading images for posts/avatars</string>
<key>NSMicrophoneUsageDescription</key>
<string>For recording videos for posts</string>
</dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
</dict>
</plist>

View File

@ -4,6 +4,5 @@ template-arb-file: intl_en.arb
output-localization-file: l10n.dart
preferred-supported-locales: [en]
output-class: L10n
untranslated-messages-file: assets/l10n/untranslated.json
synthetic-package: false
nullable-getter: false

View File

@ -5,13 +5,10 @@ import 'util/hot_rank.dart';
enum CommentSortType {
hot,
top,
// ignore: constant_identifier_names
new_,
old,
chat,
}
chat;
extension on CommentSortType {
/// returns a compare function for sorting a CommentTree according
/// to the comment sort type
int Function(CommentTree a, CommentTree b) get sortFunction {

View File

@ -1,4 +0,0 @@
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:image_picker/image_picker.dart';
ImagePicker useImagePicker() => useMemoized(ImagePicker.new);

View File

@ -123,6 +123,7 @@ abstract class L10nStrings {
static const number_of_posts = 'number_of_posts';
static const number_of_subscribers = 'number_of_subscribers';
static const number_of_users = 'number_of_users';
static const number_of_communities = 'number_of_communities';
static const unsubscribe = 'unsubscribe';
static const subscribe = 'subscribe';
static const messages = 'messages';
@ -145,6 +146,24 @@ abstract class L10nStrings {
static const bot_account = 'bot_account';
static const show_bot_accounts = 'show_bot_accounts';
static const show_read_posts = 'show_read_posts';
static const site_not_set_up = 'site_not_set_up';
static const nerd_stuff = 'nerd_stuff';
static const open_in_browser = 'open_in_browser';
static const cannot_open_in_browser = 'cannot_open_in_browser';
static const about = 'about';
static const see_all = 'see_all';
static const admins = 'admins';
static const trending_communities = 'trending_communities';
static const communities_of_instance = 'communities_of_instance';
static const day = 'day';
static const week = 'week';
static const month = 'month';
static const six_months = 'six_months';
static const add_instance = 'add_instance';
static const instance_added = 'instance_added';
static const required_field = 'required_field';
static const no_communities_found = 'no_communities_found';
static const network_error = 'network_error';
}
extension L10nFromString on String {
@ -406,6 +425,40 @@ extension L10nFromString on String {
return L10n.of(context).show_bot_accounts;
case L10nStrings.show_read_posts:
return L10n.of(context).show_read_posts;
case L10nStrings.site_not_set_up:
return L10n.of(context).site_not_set_up;
case L10nStrings.nerd_stuff:
return L10n.of(context).nerd_stuff;
case L10nStrings.open_in_browser:
return L10n.of(context).open_in_browser;
case L10nStrings.cannot_open_in_browser:
return L10n.of(context).cannot_open_in_browser;
case L10nStrings.about:
return L10n.of(context).about;
case L10nStrings.see_all:
return L10n.of(context).see_all;
case L10nStrings.admins:
return L10n.of(context).admins;
case L10nStrings.trending_communities:
return L10n.of(context).trending_communities;
case L10nStrings.day:
return L10n.of(context).day;
case L10nStrings.week:
return L10n.of(context).week;
case L10nStrings.month:
return L10n.of(context).month;
case L10nStrings.six_months:
return L10n.of(context).six_months;
case L10nStrings.add_instance:
return L10n.of(context).add_instance;
case L10nStrings.instance_added:
return L10n.of(context).instance_added;
case L10nStrings.required_field:
return L10n.of(context).required_field;
case L10nStrings.no_communities_found:
return L10n.of(context).no_communities_found;
case L10nStrings.network_error:
return L10n.of(context).network_error;
default:
return this;

125
lib/markdown_formatter.dart Normal file
View File

@ -0,0 +1,125 @@
import 'package:flutter/services.dart';
const unorderedListTypes = ['*', '+', '-'];
const orderedListTypes = [')', '.'];
extension Utilities on String {
int getBeginningOfTheLine(int from) {
if (from <= 0) return 0;
for (var i = from; i >= 0; i--) {
if (this[i] == '\n') return i + 1;
}
return 0;
}
int getEndOfTheLine(int from) {
for (var i = from; i < length; i++) {
if (this[i] == '\n') return i + 1;
}
return length;
}
/// returns the line that ends at endingIndex
String lineUpTo(int characterIndex) {
return substring(getBeginningOfTheLine(characterIndex), characterIndex + 1);
}
}
extension on TextEditingValue {
/// Append a string after the cursor
TextEditingValue append(String s) {
final beg = text.substring(0, selection.baseOffset);
final end = text.substring(selection.baseOffset);
return copyWith(
text: '$beg$s$end',
selection: selection.copyWith(
baseOffset: selection.baseOffset + s.length,
extentOffset: selection.extentOffset + s.length,
),
);
}
/// cuts [characterCount] number of chars from before the cursor
TextEditingValue trimBeforeCursor(int characterCount) {
final beg = text.substring(0, selection.baseOffset);
final end = text.substring(selection.baseOffset);
return copyWith(
text: beg.substring(0, beg.length - characterCount - 1) + end,
selection: selection.copyWith(
baseOffset: selection.baseOffset - characterCount,
extentOffset: selection.extentOffset - characterCount,
));
}
}
/// Provides convenience formatting in markdown text fields
class MarkdownFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue, TextEditingValue newValue) {
if (oldValue.text.length > newValue.text.length) return newValue;
var newVal = newValue;
final char = newValue.text[newValue.selection.baseOffset - 1];
if (char == '\n') {
final lineBefore =
newValue.text.lineUpTo(newValue.selection.baseOffset - 2);
TextEditingValue unorderedListContinuation(
String listChar, TextEditingValue tev) {
final regex = RegExp(r'(\s*)' '${RegExp.escape(listChar)} (.*)');
final match = regex.matchAsPrefix(lineBefore);
if (match == null) {
return tev;
}
final listItemBody = match.group(2);
final indent = match.group(1);
if (listItemBody == null || listItemBody.isEmpty) {
return tev.trimBeforeCursor(listChar.length + (indent?.length ?? 1));
}
return tev.append('$indent$listChar ');
}
TextEditingValue orderedListContinuation(
String afterNumberChar, TextEditingValue tev) {
final regex =
RegExp(r'(\s*)(\d+)' '${RegExp.escape(afterNumberChar)} (.*)');
final match = regex.matchAsPrefix(lineBefore);
if (match == null) {
return tev;
}
final listItemBody = match.group(3)!;
final indent = match.group(1)!;
final numberStr = match.group(2)!;
if (listItemBody.isEmpty) {
return tev.trimBeforeCursor(
indent.length + numberStr.length + afterNumberChar.length + 1);
}
final number = (int.tryParse(match.group(2)!) ?? 0) + 1;
return tev.append('$indent$number$afterNumberChar ');
}
for (final c in unorderedListTypes) {
newVal = unorderedListContinuation(c, newVal);
}
for (final c in orderedListTypes) {
newVal = orderedListContinuation(c, newVal);
}
}
return newVal;
}
}

View File

@ -9,18 +9,18 @@ import '../widgets/sortable_infinite_list.dart';
/// Infinite list of Communities fetched by the given fetcher
class CommunitiesListPage extends StatelessWidget {
final String title;
final Future<List<CommunityView>> Function(
int page,
int batchSize,
SortType sortType,
) fetcher;
final FetcherWithSorting<CommunityView> fetcher;
const CommunitiesListPage({Key? key, required this.fetcher, this.title = ''})
: super(key: key);
const CommunitiesListPage({
super.key,
required this.fetcher,
this.title = '',
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
backgroundColor: theme.cardColor,
@ -45,8 +45,7 @@ class CommunitiesListPage extends StatelessWidget {
class CommunitiesListItem extends StatelessWidget {
final CommunityView community;
const CommunitiesListItem({Key? key, required this.community})
: super(key: key);
const CommunitiesListItem({super.key, required this.community});
@override
Widget build(BuildContext context) => ListTile(

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzy/fuzzy.dart';
@ -15,6 +16,7 @@ import '../util/goto.dart';
import '../util/text_color.dart';
import '../widgets/avatar.dart';
import '../widgets/pull_to_refresh.dart';
import 'instance/instance.dart';
/// List of subscribed communities per instance
class CommunitiesTab extends HookWidget {
@ -59,15 +61,14 @@ class CommunitiesTab extends HookWidget {
return Future.wait(futures);
}
final _loggedInAccounts = accountsStore.loggedInInstances
final loggedInAccounts = accountsStore.loggedInInstances
.map((instanceHost) =>
'$instanceHost${accountsStore.defaultUsernameFor(instanceHost)}')
.toList();
final instancesRefreshable =
useRefreshable(getInstances, _loggedInAccounts);
final instancesRefreshable = useRefreshable(getInstances, loggedInAccounts);
final communitiesRefreshable =
useRefreshable(getCommunities, _loggedInAccounts);
useRefreshable(getCommunities, loggedInAccounts);
if (communitiesRefreshable.snapshot.hasError ||
instancesRefreshable.snapshot.hasError) {
@ -112,8 +113,11 @@ class CommunitiesTab extends HookWidget {
final instances = instancesRefreshable.snapshot.data!;
final communities = communitiesRefreshable.snapshot.data!
..forEach((e) =>
e.sort((a, b) => a.community.name.compareTo(b.community.name)));
.map(
(e) =>
e.sorted((a, b) => a.community.name.compareTo(b.community.name)),
)
.toList();
final filterIcon = () {
if (filterController.text.isEmpty) {
@ -171,8 +175,11 @@ class CommunitiesTab extends HookWidget {
Column(
children: [
ListTile(
onTap: () => goToInstance(context,
accountsStore.loggedInInstances.elementAt(i)),
onTap: () => Navigator.of(context).push(
InstancePage.route(
accountsStore.loggedInInstances.elementAt(i),
),
),
onLongPress: () => toggleCollapse(i),
leading: Avatar(
url: instances[i].icon,
@ -234,9 +241,11 @@ class _CommunitySubscribeToggle extends HookWidget {
final int communityId;
final String instanceHost;
const _CommunitySubscribeToggle(
{required this.instanceHost, required this.communityId, Key? key})
: super(key: key);
const _CommunitySubscribeToggle({
required this.instanceHost,
required this.communityId,
super.key,
});
@override
Widget build(BuildContext context) {

View File

@ -16,7 +16,7 @@ import '../../util/share.dart';
import '../../widgets/failed_to_load.dart';
import '../../widgets/reveal_after_scroll.dart';
import '../../widgets/sortable_infinite_list.dart';
import '../create_post.dart';
import '../create_post/create_post_fab.dart';
import 'community_about_tab.dart';
import 'community_more_menu.dart';
import 'community_overview.dart';

View File

@ -15,8 +15,7 @@ import 'community_store.dart';
class CommmunityAboutTab extends StatelessWidget {
final FullCommunityView fullCommunityView;
const CommmunityAboutTab(this.fullCommunityView, {Key? key})
: super(key: key);
const CommmunityAboutTab(this.fullCommunityView, {super.key});
@override
Widget build(BuildContext context) {

View File

@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:url_launcher/url_launcher.dart' as ul;
import '../../hooks/logged_in_action.dart';
import '../../url_launcher.dart';
import '../../util/extensions/api.dart';
import '../../util/mobx_provider.dart';
import '../../util/observer_consumers.dart';
@ -14,8 +14,7 @@ import 'community_store.dart';
class CommunityMoreMenu extends HookWidget {
final FullCommunityView fullCommunityView;
const CommunityMoreMenu({Key? key, required this.fullCommunityView})
: super(key: key);
const CommunityMoreMenu({super.key, required this.fullCommunityView});
@override
Widget build(BuildContext context) {
@ -28,10 +27,10 @@ class CommunityMoreMenu extends HookWidget {
ListTile(
leading: const Icon(Icons.open_in_browser),
title: const Text('Open in browser'),
onTap: () async => await ul.canLaunch(communityView.community.actorId)
? ul.launch(communityView.community.actorId)
: ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("can't open in browser"))),
onTap: () => launchLink(
link: communityView.community.actorId,
context: context,
),
),
ObserverBuilder<CommunityStore>(builder: (context, store) {
return ListTile(

View File

@ -4,10 +4,10 @@ import 'package:lemmy_api_client/v3.dart';
import '../../l10n/l10n.dart';
import '../../util/extensions/api.dart';
import '../../util/goto.dart';
import '../../widgets/avatar.dart';
import '../../widgets/cached_network_image.dart';
import '../../widgets/fullscreenable_image.dart';
import '../instance/instance.dart';
import 'community_follow_button.dart';
class CommunityOverview extends StatelessWidget {
@ -93,9 +93,10 @@ class CommunityOverview extends StatelessWidget {
text: community.community.originInstanceHost,
style: const TextStyle(fontWeight: FontWeight.w600),
recognizer: TapGestureRecognizer()
..onTap = () => goToInstance(
context,
community.community.originInstanceHost,
..onTap = () => Navigator.of(context).push(
InstancePage.route(
community.community.originInstanceHost,
),
),
),
],

View File

@ -6,17 +6,19 @@ part of 'community_store.dart';
// StoreGenerator
// **************************************************************************
// 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
// 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
mixin _$CommunityStore on _CommunityStore, Store {
final _$refreshAsyncAction = AsyncAction('_CommunityStore.refresh');
late final _$refreshAsyncAction =
AsyncAction('_CommunityStore.refresh', context: context);
@override
Future<void> refresh(Jwt? token) {
return _$refreshAsyncAction.run(() => super.refresh(token));
}
final _$subscribeAsyncAction = AsyncAction('_CommunityStore.subscribe');
late final _$subscribeAsyncAction =
AsyncAction('_CommunityStore.subscribe', context: context);
@override
Future<void> subscribe(Jwt token) {

View File

@ -1,366 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:image_picker/image_picker.dart';
import 'package:lemmy_api_client/pictrs.dart';
import 'package:lemmy_api_client/v3.dart';
import '../hooks/delayed_loading.dart';
import '../hooks/image_picker.dart';
import '../hooks/logged_in_action.dart';
import '../hooks/memo_future.dart';
import '../hooks/stores.dart';
import '../l10n/l10n.dart';
import '../util/extensions/api.dart';
import '../util/extensions/spaced.dart';
import '../util/pictrs.dart';
import '../widgets/editor.dart';
import '../widgets/markdown_mode_icon.dart';
import '../widgets/radio_picker.dart';
import 'full_post/full_post.dart';
/// Fab that triggers the [CreatePost] modal
/// After creation it will navigate to the newly created post
class CreatePostFab extends HookWidget {
final CommunityView? community;
const CreatePostFab({this.community});
@override
Widget build(BuildContext context) {
final loggedInAction = useAnyLoggedInAction();
return FloatingActionButton(
onPressed: loggedInAction((_) async {
final postView = await Navigator.of(context).push(
community == null
? CreatePostPage.route()
: CreatePostPage.toCommunityRoute(community!),
);
if (postView != null) {
await Navigator.of(context)
.push(FullPostPage.fromPostViewRoute(postView));
}
}),
child: const Icon(Icons.add),
);
}
}
/// Modal for creating a post to some community in some instance
/// Pops the navigator stack with a [PostView]
class CreatePostPage extends HookWidget {
final CommunityView? community;
final bool _isEdit;
final Post? post;
const CreatePostPage()
: community = null,
_isEdit = false,
post = null;
const CreatePostPage.toCommunity(CommunityView this.community)
: _isEdit = false,
post = null;
const CreatePostPage.edit(Post this.post)
: _isEdit = true,
community = null;
@override
Widget build(BuildContext context) {
final urlController =
useTextEditingController(text: _isEdit ? post?.url : null);
final titleController =
useTextEditingController(text: _isEdit ? post?.name : null);
final bodyController =
useTextEditingController(text: _isEdit ? post?.body : null);
final accStore = useAccountsStore();
final selectedInstance = useState(_isEdit
? post!.instanceHost
: community?.instanceHost ?? accStore.loggedInInstances.first);
final selectedCommunity = useState(community);
final showFancy = useState(false);
final nsfw = useState(_isEdit && post!.nsfw);
final delayed = useDelayedLoading();
final imagePicker = useImagePicker();
final imageUploadLoading = useState(false);
final pictrsDeleteToken = useState<PictrsUploadFile?>(null);
final loggedInAction = useLoggedInAction(selectedInstance.value);
final titleFocusNode = useFocusNode();
final bodyFocusNode = useFocusNode();
final allCommunitiesSnap = useMemoFuture(
() => LemmyApiV3(selectedInstance.value)
.run(ListCommunities(
type: PostListingType.all,
sort: SortType.hot,
limit: 9999,
auth: accStore.defaultUserDataFor(selectedInstance.value)?.jwt.raw,
))
.then(
(value) {
value.sort((a, b) => a.community.name.compareTo(b.community.name));
return value;
},
),
[selectedInstance.value],
);
uploadPicture(Jwt token) async {
try {
final pic = await imagePicker.pickImage(source: ImageSource.gallery);
// pic is null when the picker was cancelled
if (pic != null) {
imageUploadLoading.value = true;
final pictrs = PictrsApi(selectedInstance.value);
final upload =
await pictrs.upload(filePath: pic.path, auth: token.raw);
pictrsDeleteToken.value = upload.files[0];
urlController.text =
pathToPictrs(selectedInstance.value, upload.files[0].file);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to upload image')));
} finally {
imageUploadLoading.value = false;
}
}
removePicture(PictrsUploadFile deleteToken) {
PictrsApi(selectedInstance.value).delete(deleteToken).catchError((_) {});
pictrsDeleteToken.value = null;
urlController.text = '';
}
final instanceDropdown = RadioPicker<String>(
values: accStore.loggedInInstances.toList(),
groupValue: selectedInstance.value,
onChanged: _isEdit ? null : (value) => selectedInstance.value = value,
buttonBuilder: (context, displayValue, onPressed) => TextButton(
onPressed: onPressed,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(displayValue),
const Icon(Icons.arrow_drop_down),
],
),
),
);
DropdownMenuItem<int> communityDropDownItem(CommunityView e) =>
DropdownMenuItem(
value: e.community.id,
child: Text(e.community.local
? e.community.name
: '${e.community.originInstanceHost}/${e.community.name}'),
);
List<DropdownMenuItem<int>> communitiesList() {
if (allCommunitiesSnap.hasData) {
return allCommunitiesSnap.data!.map(communityDropDownItem).toList();
} else {
if (selectedCommunity.value != null) {
return [communityDropDownItem(selectedCommunity.value!)];
} else {
return const [
DropdownMenuItem(
value: -1,
child: CircularProgressIndicator.adaptive(),
)
];
}
}
}
handleSubmit(Jwt token) async {
if ((!_isEdit && selectedCommunity.value == null) ||
titleController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Choosing a community and a title is required'),
));
return;
}
final api = LemmyApiV3(selectedInstance.value);
delayed.start();
try {
final res = await () {
if (_isEdit) {
return api.run(EditPost(
url: urlController.text.isEmpty ? null : urlController.text,
body: bodyController.text.isEmpty ? null : bodyController.text,
nsfw: nsfw.value,
name: titleController.text,
postId: post!.id,
auth: token.raw,
));
} else {
return api.run(CreatePost(
url: urlController.text.isEmpty ? null : urlController.text,
body: bodyController.text.isEmpty ? null : bodyController.text,
nsfw: nsfw.value,
name: titleController.text,
communityId: selectedCommunity.value!.community.id,
auth: token.raw,
));
}
}();
Navigator.of(context).pop(res);
return;
} catch (e) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Failed to post')));
}
delayed.cancel();
}
// TODO: use lazy autocomplete
final communitiesDropdown = InputDecorator(
decoration: const InputDecoration(
contentPadding: EdgeInsets.symmetric(vertical: 1, horizontal: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: selectedCommunity.value?.community.id,
hint: Text(L10n.of(context).community),
onChanged: _isEdit
? null
: (communityId) {
selectedCommunity.value = allCommunitiesSnap.data
?.firstWhere((e) => e.community.id == communityId);
},
items: communitiesList(),
),
),
);
final enabledUrlField = pictrsDeleteToken.value == null;
final url = Row(children: [
Expanded(
child: TextField(
enabled: enabledUrlField,
controller: urlController,
autofillHints: enabledUrlField ? const [AutofillHints.url] : null,
keyboardType: TextInputType.url,
onSubmitted: (_) => titleFocusNode.requestFocus(),
decoration: InputDecoration(
labelText: L10n.of(context).url,
suffixIcon: const Icon(Icons.link),
),
),
),
const SizedBox(width: 5),
IconButton(
icon: imageUploadLoading.value
? const CircularProgressIndicator.adaptive()
: Icon(pictrsDeleteToken.value == null
? Icons.add_photo_alternate
: Icons.close),
onPressed: pictrsDeleteToken.value == null
? loggedInAction(uploadPicture)
: () => removePicture(pictrsDeleteToken.value!),
tooltip:
pictrsDeleteToken.value == null ? 'Add picture' : 'Delete picture',
)
]);
final title = TextField(
controller: titleController,
focusNode: titleFocusNode,
keyboardType: TextInputType.text,
textCapitalization: TextCapitalization.sentences,
onSubmitted: (_) => bodyFocusNode.requestFocus(),
minLines: 1,
maxLines: 2,
decoration: InputDecoration(labelText: L10n.of(context).title),
);
final body = Editor(
controller: bodyController,
focusNode: bodyFocusNode,
onSubmitted: (_) =>
delayed.pending ? () {} : loggedInAction(handleSubmit),
labelText: L10n.of(context).body,
instanceHost: selectedInstance.value,
fancy: showFancy.value,
);
return Scaffold(
appBar: AppBar(
leading: const CloseButton(),
actions: [
IconButton(
icon: markdownModeIcon(fancy: showFancy.value),
onPressed: () => showFancy.value = !showFancy.value,
),
],
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(5),
children: [
instanceDropdown,
if (!_isEdit) communitiesDropdown,
url,
title,
body,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
GestureDetector(
onTap: () => nsfw.value = !nsfw.value,
child: Row(
children: [
Checkbox(
value: nsfw.value,
onChanged: (val) {
if (val != null) nsfw.value = val;
},
),
Text(L10n.of(context).nsfw)
],
),
),
TextButton(
onPressed:
delayed.pending ? () {} : loggedInAction(handleSubmit),
child: delayed.loading
? const CircularProgressIndicator.adaptive()
: Text(_isEdit
? L10n.of(context).edit
: L10n.of(context).post),
)
],
),
].spaced(6),
),
),
);
}
static Route<PostView> route() => MaterialPageRoute(
builder: (context) => const CreatePostPage(),
fullscreenDialog: true,
);
static Route<PostView> toCommunityRoute(CommunityView community) =>
MaterialPageRoute(
builder: (context) => CreatePostPage.toCommunity(community),
fullscreenDialog: true,
);
static Route<PostView> editRoute(Post post) => MaterialPageRoute(
builder: (context) => CreatePostPage.edit(post),
fullscreenDialog: true,
);
}

View File

@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:wc_form_validators/wc_form_validators.dart';
import '../../hooks/logged_in_action.dart';
import '../../hooks/stores.dart';
import '../../l10n/l10n.dart';
import '../../stores/accounts_store.dart';
import '../../util/async_store_listener.dart';
import '../../util/extensions/spaced.dart';
import '../../util/mobx_provider.dart';
import '../../util/observer_consumers.dart';
import '../../widgets/editor/editor.dart';
import '../../widgets/markdown_mode_icon.dart';
import 'create_post_community_picker.dart';
import 'create_post_instance_picker.dart';
import 'create_post_store.dart';
import 'create_post_url_field.dart';
/// Modal for creating a post to some community in some instance
/// Pops the navigator stack with a [PostView]
class CreatePostPage extends HookWidget {
const CreatePostPage();
@override
Widget build(BuildContext context) {
final formKey = useMemoized(GlobalKey<FormState>.new);
final loggedInAction = useLoggedInAction(
useStore((CreatePostStore store) => store.instanceHost),
);
final editorController = useEditorController(
instanceHost: context.read<CreatePostStore>().instanceHost,
text: context.read<CreatePostStore>().body);
final titleFocusNode = useFocusNode();
handleSubmit(Jwt token) async {
if (formKey.currentState!.validate()) {
await context.read<CreatePostStore>().submit(token);
}
}
final title = ObserverBuilder<CreatePostStore>(
builder: (context, store) => TextFormField(
initialValue: store.title,
focusNode: titleFocusNode,
keyboardType: TextInputType.text,
textCapitalization: TextCapitalization.sentences,
textInputAction: TextInputAction.next,
validator: Validators.required(L10n.of(context).required_field),
onChanged: (title) => store.title = title,
minLines: 1,
maxLines: 2,
decoration: InputDecoration(labelText: L10n.of(context).title),
),
);
final body = ObserverBuilder<CreatePostStore>(
builder: (context, store) => Editor(
controller: editorController,
onChanged: (body) => store.body = body,
labelText: L10n.of(context).body,
fancy: store.showFancy,
),
);
return AsyncStoreListener<PostView>(
asyncStore: context.read<CreatePostStore>().submitState,
onSuccess: (context, data) {
Navigator.of(context).pop(data);
},
child: Scaffold(
appBar: AppBar(
actions: [
ObserverBuilder<CreatePostStore>(
builder: (context, store) => IconButton(
icon: markdownModeIcon(fancy: store.showFancy),
onPressed: () => store.showFancy = !store.showFancy,
),
),
],
),
body: Stack(
children: [
SafeArea(
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(5),
child: Form(
key: formKey,
child: Column(
children: [
if (!context.read<CreatePostStore>().isEdit) ...const [
CreatePostInstancePicker(),
CreatePostCommunityPicker(),
],
CreatePostUrlField(titleFocusNode),
title,
body,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ObserverBuilder<CreatePostStore>(
builder: (context, store) => GestureDetector(
onTap: () => store.nsfw = !store.nsfw,
child: Row(
children: [
Checkbox(
value: store.nsfw,
onChanged: (val) {
if (val != null) store.nsfw = val;
},
),
Text(L10n.of(context).nsfw)
],
),
),
),
ObserverBuilder<CreatePostStore>(
builder: (context, store) => TextButton(
onPressed: store.submitState.isLoading
? () {}
: loggedInAction(handleSubmit),
child: store.submitState.isLoading
? const CircularProgressIndicator.adaptive()
: Text(
store.isEdit
? L10n.of(context).edit
: L10n.of(context).post,
),
),
)
],
),
EditorToolbar.safeArea,
].spaced(6),
),
),
),
),
BottomSticky(
child: EditorToolbar(editorController),
),
],
),
),
);
}
static Route<PostView> route() => MaterialPageRoute(
builder: (context) => MobxProvider(
create: (context) => CreatePostStore(
instanceHost: context.read<AccountsStore>().loggedInInstances.first,
),
child: const CreatePostPage(),
),
fullscreenDialog: true,
);
static Route<PostView> toCommunityRoute(CommunityView community) =>
MaterialPageRoute(
builder: (context) => MobxProvider(
create: (context) => CreatePostStore(
instanceHost: community.instanceHost,
selectedCommunity: community,
),
child: const CreatePostPage(),
),
fullscreenDialog: true,
);
static Route<PostView> editRoute(Post post) => MaterialPageRoute(
builder: (context) => MobxProvider(
create: (context) => CreatePostStore(
instanceHost: post.instanceHost,
postToEdit: post,
),
child: const CreatePostPage(),
),
fullscreenDialog: true,
);
}

View File

@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:wc_form_validators/wc_form_validators.dart';
import '../../l10n/l10n.dart';
import '../../util/async_store_listener.dart';
import '../../util/extensions/api.dart';
import '../../util/extensions/context.dart';
import '../../util/observer_consumers.dart';
import '../../widgets/avatar.dart';
import 'create_post_store.dart';
class CreatePostCommunityPicker extends HookWidget {
const CreatePostCommunityPicker({super.key});
@override
Widget build(BuildContext context) {
final store = context.read<CreatePostStore>();
final controller = useTextEditingController(
text: store.selectedCommunity != null
? _communityString(store.selectedCommunity!)
: '',
);
return AsyncStoreListener(
asyncStore: context.read<CreatePostStore>().searchCommunitiesState,
child: Focus(
onFocusChange: (hasFocus) {
if (!hasFocus && store.selectedCommunity == null) {
controller.text = '';
}
},
child: ObserverBuilder<CreatePostStore>(builder: (context, store) {
return TypeAheadFormField<CommunityView>(
textFieldConfiguration: TextFieldConfiguration(
controller: controller,
enabled: !store.isEdit,
decoration: InputDecoration(
hintText: L10n.of(context).community,
contentPadding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 20,
),
suffixIcon:
store.selectedCommunity == null && controller.text.isEmpty
? const Icon(Icons.arrow_drop_down)
: IconButton(
onPressed: () {
store.selectedCommunity = null;
controller.clear();
},
icon: const Icon(Icons.close),
),
),
onChanged: (_) => store.selectedCommunity = null,
),
validator: Validators.required(L10n.of(context).required_field),
suggestionsCallback: (pattern) async {
final communities = await store.searchCommunities(
pattern,
context.defaultJwt(store.instanceHost),
);
return communities ?? [];
},
itemBuilder: (context, community) {
return ListTile(
leading: Avatar(
url: community.community.icon,
radius: 20,
),
title: Text(_communityString(community)),
);
},
onSuggestionSelected: (community) {
store.selectedCommunity = community;
controller.text = _communityString(community);
},
noItemsFoundBuilder: (context) => SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(
L10n.of(context).no_communities_found,
textAlign: TextAlign.center,
),
),
),
loadingBuilder: (context) => Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Padding(
padding: EdgeInsets.all(8),
child: CircularProgressIndicator.adaptive(),
),
],
),
debounceDuration: const Duration(milliseconds: 400),
);
}),
),
);
}
}
String _communityString(CommunityView communityView) {
if (communityView.community.local) {
return communityView.community.title;
} else {
return '${communityView.community.originInstanceHost}/${communityView.community.title}';
}
}

View File

@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/v3.dart';
import '../../hooks/logged_in_action.dart';
import '../full_post/full_post.dart';
import 'create_post.dart';
/// Fab that triggers the [CreatePost] modal
/// After creation it will navigate to the newly created post
class CreatePostFab extends HookWidget {
final CommunityView? community;
const CreatePostFab({this.community});
@override
Widget build(BuildContext context) {
final loggedInAction = useAnyLoggedInAction();
return FloatingActionButton(
onPressed: loggedInAction((_) async {
final postView = await Navigator.of(context).push(
community == null
? CreatePostPage.route()
: CreatePostPage.toCommunityRoute(community!),
);
if (postView != null) {
await Navigator.of(context)
.push(FullPostPage.fromPostViewRoute(postView));
}
}),
child: const Icon(Icons.add),
);
}
}

View File

@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import '../../stores/accounts_store.dart';
import '../../util/observer_consumers.dart';
import '../../widgets/radio_picker.dart';
import 'create_post_store.dart';
class CreatePostInstancePicker extends StatelessWidget {
const CreatePostInstancePicker({super.key});
@override
Widget build(BuildContext context) {
final loggedInInstances =
context.watch<AccountsStore>().loggedInInstances.toList();
return ObserverBuilder<CreatePostStore>(
builder: (context, store) => RadioPicker<String>(
values: loggedInInstances,
groupValue: store.instanceHost,
onChanged: store.isEdit ? null : (value) => store.instanceHost = value,
buttonBuilder: (context, displayValue, onPressed) => TextButton(
onPressed: onPressed,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(displayValue),
const Icon(Icons.arrow_drop_down),
],
),
),
),
);
}
}

View File

@ -0,0 +1,171 @@
import 'package:lemmy_api_client/pictrs.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:mobx/mobx.dart';
import '../../util/async_store.dart';
import '../../util/pictrs.dart';
part 'create_post_store.g.dart';
class CreatePostStore = _CreatePostStore with _$CreatePostStore;
abstract class _CreatePostStore with Store {
final Post? postToEdit;
bool get isEdit => postToEdit != null;
_CreatePostStore({
required this.instanceHost,
this.postToEdit,
// ignore: unused_element
this.selectedCommunity,
}) : title = postToEdit?.name ?? '',
nsfw = postToEdit?.nsfw ?? false,
body = postToEdit?.body ?? '',
url = postToEdit?.url ?? '';
@observable
bool showFancy = false;
@observable
String instanceHost;
@observable
CommunityView? selectedCommunity;
@observable
String url;
@observable
String title;
@observable
String body;
@observable
bool nsfw;
final submitState = AsyncStore<PostView>();
final searchCommunitiesState = AsyncStore<List<CommunityView>>();
final imageUploadState = AsyncStore<PictrsUploadFile>();
@computed
bool get hasUploadedImage => imageUploadState.map(
loading: () => false,
error: (_) => false,
data: (_) => true,
);
@action
Future<List<CommunityView>?> searchCommunities(
String searchTerm,
Jwt? token,
) {
if (searchTerm.isEmpty) {
return searchCommunitiesState.runLemmy(
instanceHost,
ListCommunities(
type: PostListingType.all,
sort: SortType.topAll,
limit: 10,
auth: token?.raw,
),
);
} else {
return searchCommunitiesState.runLemmy(
instanceHost,
SearchCommunities(
q: searchTerm,
sort: SortType.topAll,
listingType: PostListingType.all,
limit: 10,
auth: token?.raw,
),
);
}
}
@action
Future<void> submit(Jwt token) async {
await submitState.runLemmy(
instanceHost,
isEdit
? EditPost(
url: url.isEmpty ? null : url,
body: body.isEmpty ? null : body,
nsfw: nsfw,
name: title,
postId: postToEdit!.id,
auth: token.raw,
)
: CreatePost(
url: url.isEmpty ? null : url,
body: body.isEmpty ? null : body,
nsfw: nsfw,
name: title,
communityId: selectedCommunity!.community.id,
auth: token.raw,
),
);
}
@action
Future<void> uploadImage(String filePath, Jwt token) async {
final instanceHost = this.instanceHost;
final upload = await imageUploadState.run(
() => PictrsApi(instanceHost)
.upload(
filePath: filePath,
auth: token.raw,
)
.then((value) => value.files.single),
);
if (upload != null) {
url = pathToPictrs(instanceHost, upload.file);
}
}
@action
void removeImage() {
final pictrsFile = imageUploadState.map<PictrsUploadFile?>(
data: (data) => data,
loading: () => null,
error: (_) => null,
);
if (pictrsFile == null) return;
PictrsApi(instanceHost).delete(pictrsFile).catchError((_) {});
imageUploadState.reset();
url = '';
}
}
class SearchCommunities implements LemmyApiQuery<List<CommunityView>> {
final Search base;
SearchCommunities({
required String q,
PostListingType? listingType,
SortType? sort,
int? page,
int? limit,
String? auth,
}) : base = Search(
q: q,
type: SearchType.communities,
listingType: listingType,
sort: sort,
page: page,
limit: limit,
auth: auth,
);
@override
String get path => base.path;
@override
HttpMethod get httpMethod => base.httpMethod;
@override
List<CommunityView> responseFactory(Map<String, dynamic> json) =>
base.responseFactory(json).communities;
@override
Map<String, dynamic> toJson() => base.toJson();
}

View File

@ -0,0 +1,185 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'create_post_store.dart';
// **************************************************************************
// StoreGenerator
// **************************************************************************
// 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
mixin _$CreatePostStore on _CreatePostStore, Store {
Computed<bool>? _$hasUploadedImageComputed;
@override
bool get hasUploadedImage => (_$hasUploadedImageComputed ??= Computed<bool>(
() => super.hasUploadedImage,
name: '_CreatePostStore.hasUploadedImage'))
.value;
late final _$showFancyAtom =
Atom(name: '_CreatePostStore.showFancy', context: context);
@override
bool get showFancy {
_$showFancyAtom.reportRead();
return super.showFancy;
}
@override
set showFancy(bool value) {
_$showFancyAtom.reportWrite(value, super.showFancy, () {
super.showFancy = value;
});
}
late final _$instanceHostAtom =
Atom(name: '_CreatePostStore.instanceHost', context: context);
@override
String get instanceHost {
_$instanceHostAtom.reportRead();
return super.instanceHost;
}
@override
set instanceHost(String value) {
_$instanceHostAtom.reportWrite(value, super.instanceHost, () {
super.instanceHost = value;
});
}
late final _$selectedCommunityAtom =
Atom(name: '_CreatePostStore.selectedCommunity', context: context);
@override
CommunityView? get selectedCommunity {
_$selectedCommunityAtom.reportRead();
return super.selectedCommunity;
}
@override
set selectedCommunity(CommunityView? value) {
_$selectedCommunityAtom.reportWrite(value, super.selectedCommunity, () {
super.selectedCommunity = value;
});
}
late final _$urlAtom = Atom(name: '_CreatePostStore.url', context: context);
@override
String get url {
_$urlAtom.reportRead();
return super.url;
}
@override
set url(String value) {
_$urlAtom.reportWrite(value, super.url, () {
super.url = value;
});
}
late final _$titleAtom =
Atom(name: '_CreatePostStore.title', context: context);
@override
String get title {
_$titleAtom.reportRead();
return super.title;
}
@override
set title(String value) {
_$titleAtom.reportWrite(value, super.title, () {
super.title = value;
});
}
late final _$bodyAtom = Atom(name: '_CreatePostStore.body', context: context);
@override
String get body {
_$bodyAtom.reportRead();
return super.body;
}
@override
set body(String value) {
_$bodyAtom.reportWrite(value, super.body, () {
super.body = value;
});
}
late final _$nsfwAtom = Atom(name: '_CreatePostStore.nsfw', context: context);
@override
bool get nsfw {
_$nsfwAtom.reportRead();
return super.nsfw;
}
@override
set nsfw(bool value) {
_$nsfwAtom.reportWrite(value, super.nsfw, () {
super.nsfw = value;
});
}
late final _$submitAsyncAction =
AsyncAction('_CreatePostStore.submit', context: context);
@override
Future<void> submit(Jwt token) {
return _$submitAsyncAction.run(() => super.submit(token));
}
late final _$uploadImageAsyncAction =
AsyncAction('_CreatePostStore.uploadImage', context: context);
@override
Future<void> uploadImage(String filePath, Jwt token) {
return _$uploadImageAsyncAction
.run(() => super.uploadImage(filePath, token));
}
late final _$_CreatePostStoreActionController =
ActionController(name: '_CreatePostStore', context: context);
@override
Future<List<CommunityView>?> searchCommunities(
String searchTerm, Jwt? token) {
final _$actionInfo = _$_CreatePostStoreActionController.startAction(
name: '_CreatePostStore.searchCommunities');
try {
return super.searchCommunities(searchTerm, token);
} finally {
_$_CreatePostStoreActionController.endAction(_$actionInfo);
}
}
@override
void removeImage() {
final _$actionInfo = _$_CreatePostStoreActionController.startAction(
name: '_CreatePostStore.removeImage');
try {
return super.removeImage();
} finally {
_$_CreatePostStoreActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''
showFancy: ${showFancy},
instanceHost: ${instanceHost},
selectedCommunity: ${selectedCommunity},
url: ${url},
title: ${title},
body: ${body},
nsfw: ${nsfw},
hasUploadedImage: ${hasUploadedImage}
''';
}
}

View File

@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/v3.dart';
import '../../hooks/logged_in_action.dart';
import '../../hooks/stores.dart';
import '../../l10n/l10n.dart';
import '../../util/files.dart';
import '../../util/observer_consumers.dart';
import 'create_post_store.dart';
class CreatePostUrlField extends HookWidget {
final FocusNode titleFocusNode;
const CreatePostUrlField(this.titleFocusNode);
@override
Widget build(BuildContext context) {
final controller = useTextEditingController(
text: context.read<CreatePostStore>().url,
);
final loggedInAction = useLoggedInAction(
useStore((CreatePostStore store) => store.instanceHost),
);
uploadImage(Jwt token) async {
final pic = await pickImage();
// pic is null when the picker was cancelled
if (pic != null) {
await context.read<CreatePostStore>().uploadImage(pic.path, token);
}
}
return ObserverConsumer<CreatePostStore>(
listener: (context, store) {
// needed to keep the controller and store data in sync
if (controller.text != store.url) {
controller.text = store.url;
}
},
builder: (context, store) => Row(
children: [
Expanded(
child: TextFormField(
controller: controller,
enabled: !store.hasUploadedImage,
autofillHints:
!store.hasUploadedImage ? const [AutofillHints.url] : null,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) => titleFocusNode.requestFocus(),
onChanged: (url) => store.url = url,
decoration: InputDecoration(
labelText: L10n.of(context).url,
suffixIcon: const Icon(Icons.link),
),
),
),
const SizedBox(width: 5),
IconButton(
icon: store.imageUploadState.isLoading
? const CircularProgressIndicator.adaptive()
: Icon(
store.hasUploadedImage
? Icons.close
: Icons.add_photo_alternate,
),
onPressed: store.hasUploadedImage
? () => store.removeImage()
: loggedInAction(uploadImage),
),
],
),
);
}
}

View File

@ -27,7 +27,7 @@ class CommentSection extends StatelessWidget {
CommentSortType.chat: _SortSelection(Icons.chat, L10nStrings.chat),
};
const CommentSection({Key? key}) : super(key: key);
const CommentSection({super.key});
@override
Widget build(BuildContext context) {

View File

@ -14,7 +14,6 @@ abstract class _FullPostStore with Store {
final String instanceHost;
_FullPostStore({
this.postStore,
required this.postId,
required this.instanceHost,
});

View File

@ -6,7 +6,7 @@ part of 'full_post_store.dart';
// StoreGenerator
// **************************************************************************
// 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
// 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
mixin _$FullPostStore on _FullPostStore, Store {
Computed<List<CommentTree>?>? _$commentTreeComputed;
@ -38,7 +38,8 @@ mixin _$FullPostStore on _FullPostStore, Store {
name: '_FullPostStore.comments'))
.value;
final _$fullPostViewAtom = Atom(name: '_FullPostStore.fullPostView');
late final _$fullPostViewAtom =
Atom(name: '_FullPostStore.fullPostView', context: context);
@override
FullPostView? get fullPostView {
@ -53,7 +54,8 @@ mixin _$FullPostStore on _FullPostStore, Store {
});
}
final _$newCommentsAtom = Atom(name: '_FullPostStore.newComments');
late final _$newCommentsAtom =
Atom(name: '_FullPostStore.newComments', context: context);
@override
ObservableList<CommentView> get newComments {
@ -68,7 +70,8 @@ mixin _$FullPostStore on _FullPostStore, Store {
});
}
final _$sortingAtom = Atom(name: '_FullPostStore.sorting');
late final _$sortingAtom =
Atom(name: '_FullPostStore.sorting', context: context);
@override
CommentSortType get sorting {
@ -83,7 +86,8 @@ mixin _$FullPostStore on _FullPostStore, Store {
});
}
final _$postStoreAtom = Atom(name: '_FullPostStore.postStore');
late final _$postStoreAtom =
Atom(name: '_FullPostStore.postStore', context: context);
@override
PostStore? get postStore {
@ -98,23 +102,24 @@ mixin _$FullPostStore on _FullPostStore, Store {
});
}
final _$refreshAsyncAction = AsyncAction('_FullPostStore.refresh');
late final _$refreshAsyncAction =
AsyncAction('_FullPostStore.refresh', context: context);
@override
Future<void> refresh([Jwt? token]) {
return _$refreshAsyncAction.run(() => super.refresh(token));
}
final _$blockCommunityAsyncAction =
AsyncAction('_FullPostStore.blockCommunity');
late final _$blockCommunityAsyncAction =
AsyncAction('_FullPostStore.blockCommunity', context: context);
@override
Future<void> blockCommunity(Jwt token) {
return _$blockCommunityAsyncAction.run(() => super.blockCommunity(token));
}
final _$_FullPostStoreActionController =
ActionController(name: '_FullPostStore');
late final _$_FullPostStoreActionController =
ActionController(name: '_FullPostStore', context: context);
@override
void updateSorting(CommentSortType sort) {

View File

@ -4,7 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import '../util/extensions/brightness.dart';
import 'communities_tab.dart';
import 'create_post.dart';
import 'create_post/create_post_fab.dart';
import 'home_tab.dart';
import 'profile_tab.dart';
import 'search_tab.dart';

View File

@ -15,6 +15,7 @@ import '../widgets/cached_network_image.dart';
import '../widgets/infinite_scroll.dart';
import '../widgets/sortable_infinite_list.dart';
import 'inbox.dart';
import 'instance/instance.dart';
import 'settings/add_account_page.dart';
/// First thing users sees when opening the app
@ -128,7 +129,9 @@ class HomeTab extends HookWidget {
color:
theme.textTheme.bodyText1?.color?.withOpacity(0.7)),
),
onTap: () => goToInstance(context, instance),
onTap: () => Navigator.of(context).push(
InstancePage.route(instance),
),
dense: true,
contentPadding: EdgeInsets.zero,
visualDensity: const VisualDensity(

View File

@ -1,387 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:url_launcher/url_launcher.dart' as ul;
import '../hooks/stores.dart';
import '../l10n/l10n.dart';
import '../util/extensions/spaced.dart';
import '../util/goto.dart';
import '../util/icons.dart';
import '../util/share.dart';
import '../util/text_color.dart';
import '../widgets/avatar.dart';
import '../widgets/bottom_modal.dart';
import '../widgets/cached_network_image.dart';
import '../widgets/fullscreenable_image.dart';
import '../widgets/info_table_popup.dart';
import '../widgets/markdown_text.dart';
import '../widgets/reveal_after_scroll.dart';
import '../widgets/sortable_infinite_list.dart';
import '../widgets/user_tile.dart';
import 'communities_list.dart';
import 'modlog/modlog.dart';
/// Displays posts, comments, and general info about the given instance
class InstancePage extends HookWidget {
final String instanceHost;
final Future<FullSiteView> siteFuture;
final Future<List<CommunityView>> communitiesFuture;
InstancePage({required this.instanceHost})
: siteFuture = LemmyApiV3(instanceHost).run(const GetSite()),
communitiesFuture = LemmyApiV3(instanceHost).run(const ListCommunities(
type: PostListingType.local, sort: SortType.hot, limit: 6));
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final siteSnap = useFuture(siteFuture);
final colorOnCard = textColorBasedOnBackground(theme.cardColor);
final accStore = useAccountsStore();
final scrollController = useScrollController();
if (!siteSnap.hasData || siteSnap.data!.siteView == null) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (siteSnap.hasError) ...[
const Icon(Icons.error),
Padding(
padding: const EdgeInsets.all(8),
child: Text('ERROR: ${siteSnap.error}'),
)
] else if (siteSnap.hasData && siteSnap.data!.siteView == null)
const Text('ERROR')
else
const CircularProgressIndicator.adaptive(
semanticsLabel: 'loading')
],
),
),
);
}
final site = siteSnap.data!;
final siteView = site.siteView!;
void _share() => share('https://$instanceHost', context: context);
void _openMoreMenu() {
showBottomModal(
context: context,
builder: (context) => Column(
children: [
ListTile(
leading: const Icon(Icons.open_in_browser),
title: const Text('Open in browser'),
onTap: () async => await ul
.canLaunch('https://${site.instanceHost}')
? ul.launch('https://${site.instanceHost}')
: ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("can't open in browser"))),
),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('Nerd stuff'),
onTap: () {
showInfoTablePopup(context: context, table: site.toJson());
},
),
],
),
);
}
return Scaffold(
body: DefaultTabController(
length: 3,
child: NestedScrollView(
controller: scrollController,
headerSliverBuilder: (context, innerBoxIsScrolled) => <Widget>[
SliverAppBar(
expandedHeight: 250,
pinned: true,
backgroundColor: theme.cardColor,
title: RevealAfterScroll(
after: 150,
fade: true,
scrollController: scrollController,
child: Text(
siteView.site.name,
style: TextStyle(color: colorOnCard),
),
),
actions: [
IconButton(icon: Icon(shareIcon), onPressed: _share),
IconButton(icon: Icon(moreIcon), onPressed: _openMoreMenu),
],
flexibleSpace: FlexibleSpaceBar(
background: Stack(children: [
if (siteView.site.banner != null)
FullscreenableImage(
url: siteView.site.banner!,
child: CachedNetworkImage(
imageUrl: siteView.site.banner!,
errorBuilder: (_, ___) => const SizedBox.shrink(),
),
),
SafeArea(
child: Center(
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 40),
child: siteView.site.icon == null
? const SizedBox(height: 100, width: 100)
: FullscreenableImage(
url: siteView.site.icon!,
child: CachedNetworkImage(
width: 100,
height: 100,
imageUrl: siteView.site.icon!,
errorBuilder: (_, ___) =>
const Icon(Icons.warning),
),
),
),
Text(siteView.site.name,
style: theme.textTheme.headline6),
Text(instanceHost, style: theme.textTheme.caption)
],
),
),
),
]),
),
bottom: PreferredSize(
preferredSize: const TabBar(tabs: []).preferredSize,
child: Material(
color: theme.cardColor,
child: TabBar(
tabs: [
Tab(text: L10n.of(context).posts),
Tab(text: L10n.of(context).comments),
const Tab(text: 'About'),
],
),
),
),
),
],
body: TabBarView(
children: [
InfinitePostList(
fetcher: (page, batchSize, sort) =>
LemmyApiV3(instanceHost).run(GetPosts(
// TODO: switch between all and subscribed
type: PostListingType.all,
sort: sort,
limit: batchSize,
page: page,
savedOnly: false,
auth:
accStore.defaultUserDataFor(instanceHost)?.jwt.raw,
))),
InfiniteCommentList(
fetcher: (page, batchSize, sort) =>
LemmyApiV3(instanceHost).run(GetComments(
type: CommentListingType.all,
sort: sort,
limit: batchSize,
page: page,
savedOnly: false,
auth:
accStore.defaultUserDataFor(instanceHost)?.jwt.raw,
))),
_AboutTab(site,
communitiesFuture: communitiesFuture,
instanceHost: instanceHost),
],
),
),
),
);
}
}
class _AboutTab extends HookWidget {
final FullSiteView site;
final Future<List<CommunityView>> communitiesFuture;
final String instanceHost;
const _AboutTab(
this.site, {
required this.communitiesFuture,
required this.instanceHost,
});
// void goToBannedUsers(BuildContext context) {
// goTo(
// context,
// (_) => UsersListPage(
// users: site.banned.reversed.toList(),
// title: L10n.of(context).banned_users,
// ),
// );
// }
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final commSnap = useFuture(communitiesFuture);
final accStore = useAccountsStore();
void goToCommunities() {
goTo(
context,
(_) => CommunitiesListPage(
fetcher: (page, batchSize, sortType) => LemmyApiV3(instanceHost).run(
ListCommunities(
type: PostListingType.local,
sort: sortType,
limit: batchSize,
page: page,
auth: accStore.defaultUserDataFor(instanceHost)?.jwt.raw,
),
),
title: 'Communities of ${site.siteView?.site.name}',
),
);
}
final siteView = site.siteView;
if (siteView == null) {
return const SingleChildScrollView(
child: Center(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('error'),
)));
}
return SingleChildScrollView(
child: SafeArea(
top: false,
child: Column(
children: [
if (siteView.site.description != null) ...[
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 15, vertical: 15),
child: MarkdownText(
siteView.site.description!,
instanceHost: instanceHost,
),
),
const _Divider(),
],
SizedBox(
height: 32,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 15),
children: [
Chip(
label: Text(L10n.of(context)
.number_of_users_online(site.online))),
Chip(
label: Text(
'${siteView.counts.usersActiveDay} users / day')),
Chip(
label: Text(
'${siteView.counts.usersActiveWeek} users / week')),
Chip(
label: Text(
'${siteView.counts.usersActiveMonth} users / month')),
Chip(
label: Text(
'${siteView.counts.usersActiveHalfYear} users / 6 months')),
Chip(
label: Text(L10n.of(context)
.number_of_users(siteView.counts.users))),
Chip(
label:
Text('${siteView.counts.communities} communities')),
Chip(label: Text('${siteView.counts.posts} posts')),
Chip(label: Text('${siteView.counts.comments} comments')),
].spaced(8),
),
),
const _Divider(),
ListTile(
title: Center(
child: Text(
'Trending communities:',
style: theme.textTheme.headline6?.copyWith(fontSize: 18),
),
),
),
if (commSnap.hasData)
for (final c in commSnap.data!)
ListTile(
onTap: () => goToCommunity.byId(
context, c.instanceHost, c.community.id),
title: Text(c.community.name),
leading: Avatar(url: c.community.icon),
)
else if (commSnap.hasError)
Padding(
padding: const EdgeInsets.all(8),
child: Text("Can't load communities, ${commSnap.error}"),
)
else
const Padding(
padding: EdgeInsets.symmetric(vertical: 10),
child: CircularProgressIndicator.adaptive(),
),
ListTile(
title: const Center(child: Text('See all')),
onTap: goToCommunities,
),
const _Divider(),
ListTile(
title: Center(
child: Text(
'Admins:',
style: theme.textTheme.headline6?.copyWith(fontSize: 18),
),
),
),
for (final u in site.admins)
PersonTile(
u.person,
expanded: true,
),
const _Divider(),
// TODO: transition to new API
// ListTile(
// title: Center(child: Text(L10n.of(context).banned_users)),
// onTap: () => goToBannedUsers(context),
// ),
ListTile(
title: Center(child: Text(L10n.of(context).modlog)),
onTap: () => Navigator.of(context).push(
ModlogPage.forInstanceRoute(instanceHost),
),
),
],
),
),
);
}
}
class _Divider extends StatelessWidget {
const _Divider();
@override
Widget build(BuildContext context) => const Padding(
padding: EdgeInsets.symmetric(horizontal: 15, vertical: 10),
child: Divider(),
);
}

View File

@ -0,0 +1,204 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/v3.dart';
import '../../l10n/l10n.dart';
import '../../util/extensions/context.dart';
import '../../util/icons.dart';
import '../../util/mobx_provider.dart';
import '../../util/observer_consumers.dart';
import '../../util/share.dart';
import '../../util/text_color.dart';
import '../../widgets/cached_network_image.dart';
import '../../widgets/failed_to_load.dart';
import '../../widgets/fullscreenable_image.dart';
import '../../widgets/reveal_after_scroll.dart';
import '../../widgets/sortable_infinite_list.dart';
import 'instance_about_tab.dart';
import 'instance_more_menu.dart';
import 'instance_store.dart';
/// Displays posts, comments, and general info about the given instance
class InstancePage extends HookWidget {
const InstancePage();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorOnCard = textColorBasedOnBackground(theme.cardColor);
final scrollController = useScrollController();
return ObserverBuilder<InstanceStore>(
builder: (context, store) {
final instanceUrl = 'https://${store.instanceHost}';
return store.siteState.map(
loading: () => Scaffold(
appBar: AppBar(),
body: const Center(child: CircularProgressIndicator.adaptive()),
),
error: (errorTerm) => Scaffold(
appBar: AppBar(),
body: Center(
child: FailedToLoad(
refresh: () => store.fetch(
context.defaultJwt(store.instanceHost),
),
message: errorTerm.tr(context),
),
),
),
data: (site) {
final siteView = site.siteView;
if (siteView == null) {
return Scaffold(
appBar: AppBar(),
body: Center(child: Text(L10n.of(context).site_not_set_up)),
);
}
return Scaffold(
body: DefaultTabController(
length: 3,
child: NestedScrollView(
controller: scrollController,
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverAppBar(
expandedHeight: 250,
pinned: true,
backgroundColor: theme.cardColor,
title: RevealAfterScroll(
after: 150,
fade: true,
scrollController: scrollController,
child: Text(
siteView.site.name,
style: TextStyle(color: colorOnCard),
),
),
actions: [
IconButton(
icon: Icon(shareIcon),
onPressed: () => share(instanceUrl, context: context),
),
IconButton(
icon: Icon(moreIcon),
onPressed: () => InstanceMoreMenu.open(context, site),
),
],
flexibleSpace: FlexibleSpaceBar(
background: Stack(
children: [
if (siteView.site.banner != null)
FullscreenableImage(
url: siteView.site.banner!,
child: CachedNetworkImage(
imageUrl: siteView.site.banner!,
errorBuilder: (_, ___) => const SizedBox(),
),
),
SafeArea(
child: Center(
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 40),
child: siteView.site.icon == null
? const SizedBox(
height: 100,
width: 100,
)
: FullscreenableImage(
url: siteView.site.icon!,
child: CachedNetworkImage(
width: 100,
height: 100,
imageUrl: siteView.site.icon!,
errorBuilder: (_, ___) =>
const Icon(Icons.warning),
),
),
),
Text(
siteView.site.name,
style: theme.textTheme.headline6,
),
Text(
store.instanceHost,
style: theme.textTheme.caption,
)
],
),
),
),
],
),
),
bottom: PreferredSize(
preferredSize: const TabBar(tabs: []).preferredSize,
child: Material(
color: theme.cardColor,
child: TabBar(
tabs: [
Tab(text: L10n.of(context).posts),
Tab(text: L10n.of(context).comments),
Tab(text: L10n.of(context).about),
],
),
),
),
),
],
body: TabBarView(
children: [
InfinitePostList(
fetcher: (page, batchSize, sort) =>
LemmyApiV3(store.instanceHost).run(GetPosts(
// TODO: switch between all and subscribed
type: PostListingType.all,
sort: sort,
limit: batchSize,
page: page,
savedOnly: false,
auth: context.defaultJwt(store.instanceHost)?.raw,
)),
),
InfiniteCommentList(
fetcher: (page, batchSize, sort) =>
LemmyApiV3(store.instanceHost).run(GetComments(
type: CommentListingType.all,
sort: sort,
limit: batchSize,
page: page,
savedOnly: false,
auth: context.defaultJwt(store.instanceHost)?.raw,
)),
),
InstanceAboutTab(
site: site,
siteView: siteView,
),
],
),
),
),
);
},
);
},
);
}
static Route route(String instanceHost) {
return MaterialPageRoute(
builder: (context) {
return MobxProvider(
create: (context) => InstanceStore(instanceHost)
..fetch(context.defaultJwt(instanceHost)),
child: const InstancePage(),
);
},
);
}
}

View File

@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/v3.dart';
import '../../l10n/l10n.dart';
import '../../util/extensions/context.dart';
import '../../util/extensions/spaced.dart';
import '../../util/goto.dart';
import '../../util/observer_consumers.dart';
import '../../widgets/avatar.dart';
import '../../widgets/failed_to_load.dart';
import '../../widgets/markdown_text.dart';
import '../../widgets/pull_to_refresh.dart';
import '../../widgets/user_tile.dart';
import '../communities_list.dart';
import '../community/community.dart';
import '../modlog/modlog.dart';
import 'instance_store.dart';
class InstanceAboutTab extends HookWidget {
final FullSiteView site;
final SiteView siteView;
const InstanceAboutTab({required this.site, required this.siteView});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = L10n.of(context);
void goToCommunities() {
goTo(
context,
(_) => CommunitiesListPage(
fetcher: (page, batchSize, sortType) =>
LemmyApiV3(site.instanceHost).run(
ListCommunities(
type: PostListingType.local,
sort: sortType,
limit: batchSize,
page: page,
auth: context.defaultJwt(site.instanceHost)?.raw,
),
),
title: l10n.communities_of_instance(siteView.site.name),
),
);
}
return PullToRefresh(
onRefresh: () => context
.read<InstanceStore>()
.fetch(context.defaultJwt(site.instanceHost), refresh: true),
child: SingleChildScrollView(
child: SafeArea(
top: false,
child: Column(
children: [
if (siteView.site.description != null) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 15,
vertical: 15,
),
child: MarkdownText(
siteView.site.description!,
instanceHost: site.instanceHost,
),
),
const _Divider(),
],
SizedBox(
height: 32,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 15),
children: [
Chip(
label: Text(
l10n.number_of_users_online(site.online),
),
),
Chip(
label: Text(
'${l10n.number_of_users(siteView.counts.usersActiveDay)} / ${l10n.day}',
),
),
Chip(
label: Text(
'${l10n.number_of_users(siteView.counts.usersActiveWeek)} / ${l10n.week}',
),
),
Chip(
label: Text(
'${l10n.number_of_users(siteView.counts.usersActiveMonth)} / ${l10n.month}',
),
),
Chip(
label: Text(
'${l10n.number_of_users(siteView.counts.usersActiveHalfYear)} / ${l10n.six_months}',
),
),
Chip(
label: Text(
l10n.number_of_users(siteView.counts.users),
),
),
Chip(
label: Text(
l10n.number_of_communities(siteView.counts.communities),
),
),
Chip(
label: Text(
l10n.number_of_posts(siteView.counts.posts),
),
),
Chip(
label: Text(
l10n.number_of_comments(siteView.counts.comments),
),
),
].spaced(8),
),
),
const _Divider(),
ListTile(
title: Center(
child: Text(
l10n.trending_communities,
style: theme.textTheme.headline6?.copyWith(fontSize: 18),
),
),
),
ObserverBuilder<InstanceStore>(
builder: (context, store) => store.communitiesState.map(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 10),
child: CircularProgressIndicator.adaptive(),
),
error: (errorTerm) => FailedToLoad(
refresh: () => store.fetchCommunites(
context.defaultJwt(store.instanceHost)),
message: errorTerm.tr(context),
),
data: (communities) => Column(
children: [
for (final c in communities)
ListTile(
onTap: () => Navigator.of(context).push(
CommunityPage.fromIdRoute(
store.instanceHost,
c.community.id,
),
),
title: Text(c.community.name),
leading: Avatar(url: c.community.icon),
)
],
),
),
),
ListTile(
title: Center(child: Text(l10n.see_all)),
onTap: goToCommunities,
),
const _Divider(),
ListTile(
title: Center(
child: Text(
l10n.admins,
style: theme.textTheme.headline6?.copyWith(fontSize: 18),
),
),
),
for (final u in site.admins)
PersonTile(
u.person,
expanded: true,
),
const _Divider(),
ListTile(
title: Center(child: Text(l10n.modlog)),
onTap: () => Navigator.of(context).push(
ModlogPage.forInstanceRoute(site.instanceHost),
),
),
],
),
),
),
);
}
}
class _Divider extends StatelessWidget {
const _Divider();
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 15, vertical: 10),
child: Divider(),
);
}
}

View File

@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:lemmy_api_client/v3.dart';
import '../../l10n/l10n.dart';
import '../../stores/accounts_store.dart';
import '../../url_launcher.dart';
import '../../util/observer_consumers.dart';
import '../../widgets/bottom_modal.dart';
import '../../widgets/info_table_popup.dart';
class InstanceMoreMenu extends StatelessWidget {
final FullSiteView site;
const InstanceMoreMenu({super.key, required this.site});
@override
Widget build(BuildContext context) {
final instanceUrl = 'https://${site.instanceHost}';
final accountsStore = context.watch<AccountsStore>();
return Column(
children: [
if (!accountsStore.instances.contains(site.instanceHost))
ListTile(
leading: const Icon(Icons.add),
title: Text(L10n.of(context).add_instance),
onTap: () {
accountsStore.addInstance(site.instanceHost, assumeValid: true);
Navigator.of(context).pop();
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(L10n.of(context).instance_added)),
);
},
),
ListTile(
leading: const Icon(Icons.open_in_browser),
title: Text(L10n.of(context).open_in_browser),
onTap: () => launchLink(link: instanceUrl, context: context),
),
ListTile(
leading: const Icon(Icons.info_outline),
title: Text(L10n.of(context).nerd_stuff),
onTap: () {
showInfoTablePopup(context: context, table: site.toJson());
},
),
],
);
}
static void open(BuildContext context, FullSiteView site) {
showBottomModal(
context: context,
builder: (context) => InstanceMoreMenu(site: site),
);
}
}

View File

@ -0,0 +1,43 @@
import 'package:lemmy_api_client/v3.dart';
import 'package:mobx/mobx.dart';
import '../../util/async_store.dart';
part 'instance_store.g.dart';
class InstanceStore = _InstanceStore with _$InstanceStore;
abstract class _InstanceStore with Store {
final String instanceHost;
_InstanceStore(this.instanceHost);
final siteState = AsyncStore<FullSiteView>();
final communitiesState = AsyncStore<List<CommunityView>>();
@action
Future<void> fetch(Jwt? token, {bool refresh = false}) async {
await Future.wait([
siteState.runLemmy(
instanceHost,
GetSite(auth: token?.raw),
refresh: refresh,
),
fetchCommunites(token, refresh: refresh),
]);
}
@action
Future<void> fetchCommunites(Jwt? token, {bool refresh = false}) async {
await communitiesState.runLemmy(
instanceHost,
ListCommunities(
type: PostListingType.local,
sort: SortType.hot,
limit: 6,
auth: token?.raw,
),
refresh: refresh,
);
}
}

View File

@ -0,0 +1,35 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'instance_store.dart';
// **************************************************************************
// StoreGenerator
// **************************************************************************
// 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
mixin _$InstanceStore on _InstanceStore, Store {
late final _$fetchAsyncAction =
AsyncAction('_InstanceStore.fetch', context: context);
@override
Future<void> fetch(Jwt? token, {bool refresh = false}) {
return _$fetchAsyncAction.run(() => super.fetch(token, refresh: refresh));
}
late final _$fetchCommunitesAsyncAction =
AsyncAction('_InstanceStore.fetchCommunites', context: context);
@override
Future<void> fetchCommunites(Jwt? token, {bool refresh = false}) {
return _$fetchCommunitesAsyncAction
.run(() => super.fetchCommunites(token, refresh: refresh));
}
@override
String toString() {
return '''
''';
}
}

View File

@ -6,11 +6,11 @@ part of 'log_console_page_store.dart';
// StoreGenerator
// **************************************************************************
// 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
// 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
mixin _$LogConsolePageStore on _LogConsolePageStore, Store {
final _$_LogConsolePageStoreActionController =
ActionController(name: '_LogConsolePageStore');
late final _$_LogConsolePageStoreActionController =
ActionController(name: '_LogConsolePageStore', context: context);
@override
void addLog(LogRecord logRecord) {

View File

@ -1,20 +1,19 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:image_picker/image_picker.dart';
import 'package:lemmy_api_client/pictrs.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:url_launcher/url_launcher.dart' as ul;
import '../hooks/delayed_loading.dart';
import '../hooks/image_picker.dart';
import '../hooks/stores.dart';
import '../l10n/l10n.dart';
import '../url_launcher.dart';
import '../util/files.dart';
import '../util/icons.dart';
import '../util/pictrs.dart';
import '../widgets/bottom_modal.dart';
import '../widgets/bottom_safe.dart';
import '../widgets/cached_network_image.dart';
import '../widgets/editor.dart';
import '../widgets/editor/editor.dart';
/// Page for managing things like username, email, avatar etc
/// This page will assume the manage account is logged in and
@ -48,13 +47,12 @@ class ManageAccountPage extends HookWidget {
final userProfileUrl =
await userFuture.then((e) => e.person.actorId);
if (await ul.canLaunch(userProfileUrl)) {
await ul.launch(userProfileUrl);
final didLaunch = await launchLink(
link: userProfileUrl,
context: context,
);
if (didLaunch) {
Navigator.of(context).pop();
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("can't open in browser")),
);
}
},
),
@ -88,7 +86,7 @@ class ManageAccountPage extends HookWidget {
}
class _ManageAccount extends HookWidget {
const _ManageAccount({Key? key, required this.user}) : super(key: key);
const _ManageAccount({required this.user});
final LocalUserSettingsView user;
@ -101,7 +99,6 @@ class _ManageAccount extends HookWidget {
final displayNameController =
useTextEditingController(text: user.person.displayName);
final bioController = useTextEditingController(text: user.person.bio);
final emailController =
useTextEditingController(text: user.localUser.email);
final matrixUserController =
@ -124,13 +121,15 @@ class _ManageAccount extends HookWidget {
final deleteAccountPasswordController = useTextEditingController();
final bioFocusNode = useFocusNode();
final emailFocusNode = useFocusNode();
final matrixUserFocusNode = useFocusNode();
final newPasswordFocusNode = useFocusNode();
// final verifyPasswordFocusNode = useFocusNode();
// final oldPasswordFocusNode = useFocusNode();
final bioController = useEditorController(
instanceHost: user.instanceHost, text: user.person.bio);
final token =
accountsStore.userDataFor(user.instanceHost, user.person.name)!.jwt;
@ -158,7 +157,9 @@ class _ManageAccount extends HookWidget {
displayName: displayNameController.text.isEmpty
? null
: displayNameController.text,
bio: bioController.text.isEmpty ? null : bioController.text,
bio: bioController.textEditingController.text.isEmpty
? null
: bioController.textEditingController.text,
email: emailController.text.isEmpty ? null : emailController.text,
));
@ -236,150 +237,157 @@ class _ManageAccount extends HookWidget {
}
}
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 15),
return Stack(
children: [
_ImagePicker(
user: user,
name: L10n.of(context).avatar,
initialUrl: avatar.value,
onChange: (value) => avatar.value = value,
informAcceptedRef: informAcceptedAvatarRef,
ListView(
padding: const EdgeInsets.symmetric(horizontal: 15),
children: [
_ImagePicker(
user: user,
name: L10n.of(context).avatar,
initialUrl: avatar.value,
onChange: (value) => avatar.value = value,
informAcceptedRef: informAcceptedAvatarRef,
),
const SizedBox(height: 8),
_ImagePicker(
user: user,
name: L10n.of(context).banner,
initialUrl: banner.value,
onChange: (value) => banner.value = value,
informAcceptedRef: informAcceptedBannerRef,
),
const SizedBox(height: 8),
Text(L10n.of(context).display_name,
style: theme.textTheme.headline6),
TextField(
controller: displayNameController,
onSubmitted: (_) => bioController.focusNode.requestFocus(),
),
const SizedBox(height: 8),
Text(L10n.of(context).bio, style: theme.textTheme.headline6),
Editor(
controller: bioController,
onSubmitted: (_) => emailFocusNode.requestFocus(),
maxLines: 10,
),
const SizedBox(height: 8),
Text(L10n.of(context).email, style: theme.textTheme.headline6),
TextField(
focusNode: emailFocusNode,
controller: emailController,
autofillHints: const [AutofillHints.email],
keyboardType: TextInputType.emailAddress,
onSubmitted: (_) => matrixUserFocusNode.requestFocus(),
),
const SizedBox(height: 8),
Text(L10n.of(context).matrix_user,
style: theme.textTheme.headline6),
TextField(
focusNode: matrixUserFocusNode,
controller: matrixUserController,
onSubmitted: (_) => newPasswordFocusNode.requestFocus(),
),
const SizedBox(height: 8),
// Text(L10n.of(context)!.new_password, style: theme.textTheme.headline6),
// TextField(
// focusNode: newPasswordFocusNode,
// controller: newPasswordController,
// autofillHints: const [AutofillHints.newPassword],
// keyboardType: TextInputType.visiblePassword,
// obscureText: true,
// onSubmitted: (_) => verifyPasswordFocusNode.requestFocus(),
// ),
// const SizedBox(height: 8),
// Text(L10n.of(context)!.verify_password,
// style: theme.textTheme.headline6),
// TextField(
// focusNode: verifyPasswordFocusNode,
// controller: newPasswordVerifyController,
// autofillHints: const [AutofillHints.newPassword],
// keyboardType: TextInputType.visiblePassword,
// obscureText: true,
// onSubmitted: (_) => oldPasswordFocusNode.requestFocus(),
// ),
// const SizedBox(height: 8),
// Text(L10n.of(context)!.old_password, style: theme.textTheme.headline6),
// TextField(
// focusNode: oldPasswordFocusNode,
// controller: oldPasswordController,
// autofillHints: const [AutofillHints.password],
// keyboardType: TextInputType.visiblePassword,
// obscureText: true,
// ),
// const SizedBox(height: 8),
SwitchListTile.adaptive(
value: showNsfw.value,
onChanged: (checked) {
showNsfw.value = checked;
},
title: Text(L10n.of(context).show_nsfw),
dense: true,
),
const SizedBox(height: 8),
SwitchListTile.adaptive(
value: botAccount.value,
onChanged: (checked) {
botAccount.value = checked;
},
title: Text(L10n.of(context).bot_account),
dense: true,
),
const SizedBox(height: 8),
SwitchListTile.adaptive(
value: showBotAccounts.value,
onChanged: (checked) {
showBotAccounts.value = checked;
},
title: Text(L10n.of(context).show_bot_accounts),
dense: true,
),
const SizedBox(height: 8),
SwitchListTile.adaptive(
value: showReadPosts.value,
onChanged: (checked) {
showReadPosts.value = checked;
},
title: Text(L10n.of(context).show_read_posts),
dense: true,
),
const SizedBox(height: 8),
SwitchListTile.adaptive(
value: sendNotificationsToEmail.value,
onChanged: (checked) {
sendNotificationsToEmail.value = checked;
},
title: Text(L10n.of(context).send_notifications_to_email),
dense: true,
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: saveDelayedLoading.loading ? null : handleSubmit,
child: saveDelayedLoading.loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator.adaptive(),
)
: Text(L10n.of(context).save),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: deleteAccountDialog,
style: ElevatedButton.styleFrom(
primary: Colors.red,
),
child: Text(L10n.of(context).delete_account.toUpperCase()),
),
const BottomSafe(),
],
),
const SizedBox(height: 8),
_ImagePicker(
user: user,
name: L10n.of(context).banner,
initialUrl: banner.value,
onChange: (value) => banner.value = value,
informAcceptedRef: informAcceptedBannerRef,
),
const SizedBox(height: 8),
Text(L10n.of(context).display_name, style: theme.textTheme.headline6),
TextField(
controller: displayNameController,
onSubmitted: (_) => bioFocusNode.requestFocus(),
),
const SizedBox(height: 8),
Text(L10n.of(context).bio, style: theme.textTheme.headline6),
Editor(
controller: bioController,
focusNode: bioFocusNode,
onSubmitted: (_) => emailFocusNode.requestFocus(),
instanceHost: user.instanceHost,
maxLines: 10,
),
const SizedBox(height: 8),
Text(L10n.of(context).email, style: theme.textTheme.headline6),
TextField(
focusNode: emailFocusNode,
controller: emailController,
autofillHints: const [AutofillHints.email],
keyboardType: TextInputType.emailAddress,
onSubmitted: (_) => matrixUserFocusNode.requestFocus(),
),
const SizedBox(height: 8),
Text(L10n.of(context).matrix_user, style: theme.textTheme.headline6),
TextField(
focusNode: matrixUserFocusNode,
controller: matrixUserController,
onSubmitted: (_) => newPasswordFocusNode.requestFocus(),
),
const SizedBox(height: 8),
// Text(L10n.of(context)!.new_password, style: theme.textTheme.headline6),
// TextField(
// focusNode: newPasswordFocusNode,
// controller: newPasswordController,
// autofillHints: const [AutofillHints.newPassword],
// keyboardType: TextInputType.visiblePassword,
// obscureText: true,
// onSubmitted: (_) => verifyPasswordFocusNode.requestFocus(),
// ),
// const SizedBox(height: 8),
// Text(L10n.of(context)!.verify_password,
// style: theme.textTheme.headline6),
// TextField(
// focusNode: verifyPasswordFocusNode,
// controller: newPasswordVerifyController,
// autofillHints: const [AutofillHints.newPassword],
// keyboardType: TextInputType.visiblePassword,
// obscureText: true,
// onSubmitted: (_) => oldPasswordFocusNode.requestFocus(),
// ),
// const SizedBox(height: 8),
// Text(L10n.of(context)!.old_password, style: theme.textTheme.headline6),
// TextField(
// focusNode: oldPasswordFocusNode,
// controller: oldPasswordController,
// autofillHints: const [AutofillHints.password],
// keyboardType: TextInputType.visiblePassword,
// obscureText: true,
// ),
// const SizedBox(height: 8),
SwitchListTile.adaptive(
value: showNsfw.value,
onChanged: (checked) {
showNsfw.value = checked;
},
title: Text(L10n.of(context).show_nsfw),
dense: true,
),
const SizedBox(height: 8),
SwitchListTile.adaptive(
value: botAccount.value,
onChanged: (checked) {
botAccount.value = checked;
},
title: Text(L10n.of(context).bot_account),
dense: true,
),
const SizedBox(height: 8),
SwitchListTile.adaptive(
value: showBotAccounts.value,
onChanged: (checked) {
showBotAccounts.value = checked;
},
title: Text(L10n.of(context).show_bot_accounts),
dense: true,
),
const SizedBox(height: 8),
SwitchListTile.adaptive(
value: showReadPosts.value,
onChanged: (checked) {
showReadPosts.value = checked;
},
title: Text(L10n.of(context).show_read_posts),
dense: true,
),
const SizedBox(height: 8),
SwitchListTile.adaptive(
value: sendNotificationsToEmail.value,
onChanged: (checked) {
sendNotificationsToEmail.value = checked;
},
title: Text(L10n.of(context).send_notifications_to_email),
dense: true,
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: saveDelayedLoading.loading ? null : handleSubmit,
child: saveDelayedLoading.loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator.adaptive(),
)
: Text(L10n.of(context).save),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: deleteAccountDialog,
style: ElevatedButton.styleFrom(
primary: Colors.red,
),
child: Text(L10n.of(context).delete_account.toUpperCase()),
),
const BottomSafe(),
BottomSticky(
child: EditorToolbar(bioController),
)
],
);
}
@ -398,13 +406,12 @@ class _ImagePicker extends HookWidget {
final ObjectRef<VoidCallback?> informAcceptedRef;
const _ImagePicker({
Key? key,
required this.initialUrl,
required this.name,
required this.user,
required this.onChange,
required this.informAcceptedRef,
}) : super(key: key);
});
@override
Widget build(BuildContext context) {
@ -415,13 +422,12 @@ class _ImagePicker extends HookWidget {
final url = useState(initialUrl.value);
final pictrsDeleteToken = useState<PictrsUploadFile?>(null);
final imagePicker = useImagePicker();
final accountsStore = useAccountsStore();
final delayedLoading = useDelayedLoading();
uploadImage() async {
try {
final pic = await imagePicker.pickImage(source: ImageSource.gallery);
final pic = await pickImage();
// pic is null when the picker was cancelled
if (pic != null) {
delayedLoading.start();

View File

@ -12,6 +12,7 @@ abstract class _ModlogPageStore with Store, DisposableStore {
final String instanceHost;
final int? communityId;
// ignore: unused_element
_ModlogPageStore(this.instanceHost, [this.communityId]) {
addReaction(reaction((_) => page, (_) => fetchPage()));
}

View File

@ -6,7 +6,7 @@ part of 'modlog_page_store.dart';
// StoreGenerator
// **************************************************************************
// 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
// 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
mixin _$ModlogPageStore on _ModlogPageStore, Store {
Computed<bool>? _$hasPreviousPageComputed;
@ -24,7 +24,7 @@ mixin _$ModlogPageStore on _ModlogPageStore, Store {
name: '_ModlogPageStore.hasNextPage'))
.value;
final _$pageAtom = Atom(name: '_ModlogPageStore.page');
late final _$pageAtom = Atom(name: '_ModlogPageStore.page', context: context);
@override
int get page {
@ -39,15 +39,16 @@ mixin _$ModlogPageStore on _ModlogPageStore, Store {
});
}
final _$fetchPageAsyncAction = AsyncAction('_ModlogPageStore.fetchPage');
late final _$fetchPageAsyncAction =
AsyncAction('_ModlogPageStore.fetchPage', context: context);
@override
Future<void> fetchPage() {
return _$fetchPageAsyncAction.run(() => super.fetchPage());
}
final _$_ModlogPageStoreActionController =
ActionController(name: '_ModlogPageStore');
late final _$_ModlogPageStoreActionController =
ActionController(name: '_ModlogPageStore', context: context);
@override
void previousPage() {

View File

@ -8,7 +8,7 @@ import '../../widgets/avatar.dart';
import 'modlog_entry.dart';
class ModlogTable extends StatelessWidget {
const ModlogTable({Key? key, required this.modlog}) : super(key: key);
const ModlogTable({super.key, required this.modlog});
final Modlog modlog;

View File

@ -2,13 +2,13 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart' as ul;
import '../../hooks/delayed_loading.dart';
import '../../hooks/stores.dart';
import '../../l10n/l10n.dart';
import '../../stores/accounts_store.dart';
import '../../stores/config_store.dart';
import '../../url_launcher.dart';
import '../../widgets/cached_network_image.dart';
import '../../widgets/fullscreenable_image.dart';
import '../../widgets/radio_picker.dart';
@ -173,8 +173,11 @@ class AddAccountPage extends HookWidget {
),
TextButton(
onPressed: () {
// TODO: extract to LemmyUrls or something
ul.launch('https://${selectedInstance.value}/login');
launchLink(
// TODO: extract to LemmyUrls or something
link: 'https://${selectedInstance.value}/login',
context: context,
);
},
child: const Text('Register'),
),

View File

@ -10,7 +10,7 @@ import 'community_block_store.dart';
import 'user_block_store.dart';
class BlockPersonTile extends StatelessWidget {
const BlockPersonTile({Key? key}) : super(key: key);
const BlockPersonTile({super.key});
@override
Widget build(BuildContext context) {
@ -40,7 +40,7 @@ class BlockPersonTile extends StatelessWidget {
}
class BlockCommunityTile extends HookWidget {
const BlockCommunityTile({Key? key}) : super(key: key);
const BlockCommunityTile({super.key});
@override
Widget build(BuildContext context) {

View File

@ -6,7 +6,7 @@ part of 'blocks_store.dart';
// StoreGenerator
// **************************************************************************
// 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
// 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
mixin _$BlocksStore on _BlocksStore, Store {
Computed<Iterable<UserBlockStore>?>? _$blockedUsersComputed;
@ -32,7 +32,8 @@ mixin _$BlocksStore on _BlocksStore, Store {
Computed<bool>(() => super.isUsable, name: '_BlocksStore.isUsable'))
.value;
final _$_blockedUsersAtom = Atom(name: '_BlocksStore._blockedUsers');
late final _$_blockedUsersAtom =
Atom(name: '_BlocksStore._blockedUsers', context: context);
@override
ObservableList<UserBlockStore>? get _blockedUsers {
@ -47,8 +48,8 @@ mixin _$BlocksStore on _BlocksStore, Store {
});
}
final _$_blockedCommunitiesAtom =
Atom(name: '_BlocksStore._blockedCommunities');
late final _$_blockedCommunitiesAtom =
Atom(name: '_BlocksStore._blockedCommunities', context: context);
@override
ObservableList<CommunityBlockStore>? get _blockedCommunities {
@ -63,15 +64,16 @@ mixin _$BlocksStore on _BlocksStore, Store {
});
}
final _$blockUserAsyncAction = AsyncAction('_BlocksStore.blockUser');
late final _$blockUserAsyncAction =
AsyncAction('_BlocksStore.blockUser', context: context);
@override
Future<void> blockUser(Jwt token, int id) {
return _$blockUserAsyncAction.run(() => super.blockUser(token, id));
}
final _$blockCommunityAsyncAction =
AsyncAction('_BlocksStore.blockCommunity');
late final _$blockCommunityAsyncAction =
AsyncAction('_BlocksStore.blockCommunity', context: context);
@override
Future<void> blockCommunity(Jwt token, int id) {
@ -79,7 +81,8 @@ mixin _$BlocksStore on _BlocksStore, Store {
.run(() => super.blockCommunity(token, id));
}
final _$refreshAsyncAction = AsyncAction('_BlocksStore.refresh');
late final _$refreshAsyncAction =
AsyncAction('_BlocksStore.refresh', context: context);
@override
Future<void> refresh() {

View File

@ -6,10 +6,11 @@ part of 'community_block_store.dart';
// StoreGenerator
// **************************************************************************
// 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
// 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
mixin _$CommunityBlockStore on _CommunityBlockStore, Store {
final _$blockedAtom = Atom(name: '_CommunityBlockStore.blocked');
late final _$blockedAtom =
Atom(name: '_CommunityBlockStore.blocked', context: context);
@override
bool get blocked {

View File

@ -6,10 +6,11 @@ part of 'user_block_store.dart';
// StoreGenerator
// **************************************************************************
// 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
// 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
mixin _$UserBlockStore on _UserBlockStore, Store {
final _$blockedAtom = Atom(name: '_UserBlockStore.blocked');
late final _$blockedAtom =
Atom(name: '_UserBlockStore.blocked', context: context);
@override
bool get blocked {

View File

@ -185,10 +185,9 @@ class _AccountOptions extends HookWidget {
final String username;
const _AccountOptions({
Key? key,
required this.instanceHost,
required this.username,
}) : super(key: key);
});
@override
Widget build(BuildContext context) {

View File

@ -4,29 +4,34 @@ import 'package:lemmy_api_client/v3.dart';
import '../util/extensions/api.dart';
import '../util/goto.dart';
import '../widgets/avatar.dart';
import '../widgets/infinite_scroll.dart';
import '../widgets/markdown_text.dart';
/// Infinite list of Users fetched by the given fetcher
class UsersListPage extends StatelessWidget {
final String title;
final List<PersonViewSafe> users;
final Fetcher<PersonViewSafe> fetcher;
const UsersListPage({Key? key, required this.users, this.title = ''})
: super(key: key);
const UsersListPage({super.key, required this.fetcher, this.title = ''});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// TODO: change to infinite scroll
return Scaffold(
appBar: AppBar(
backgroundColor: theme.cardColor,
title: Text(title),
),
body: ListView.builder(
itemBuilder: (context, i) => UsersListItem(user: users[i]),
itemCount: users.length,
body: InfiniteScroll<PersonViewSafe>(
fetcher: fetcher,
itemBuilder: (user) => Column(
children: [
const Divider(),
UsersListItem(user: user),
],
),
uniqueProp: (user) => user.person.actorId,
),
);
}
@ -35,21 +40,23 @@ class UsersListPage extends StatelessWidget {
class UsersListItem extends StatelessWidget {
final PersonViewSafe user;
const UsersListItem({Key? key, required this.user}) : super(key: key);
const UsersListItem({super.key, required this.user});
@override
Widget build(BuildContext context) => ListTile(
title: Text(user.person.originPreferredName),
subtitle: user.person.bio != null
? Opacity(
opacity: 0.7,
child: MarkdownText(
user.person.bio!,
instanceHost: user.instanceHost,
),
)
: null,
onTap: () => goToUser.fromPersonSafe(context, user.person),
leading: Avatar(url: user.person.avatar),
);
Widget build(BuildContext context) {
return ListTile(
title: Text(user.person.originPreferredName),
subtitle: user.person.bio != null
? Opacity(
opacity: 0.7,
child: MarkdownText(
user.person.bio!,
instanceHost: user.instanceHost,
),
)
: null,
onTap: () => goToUser.fromPersonSafe(context, user.person),
leading: Avatar(url: user.person.avatar),
);
}
}

View File

@ -1 +1,5 @@
const lemmurRepositoryLink = 'https://github.com/LemmurOrg/lemmur';
const lemmurRepositoryUrl = 'https://github.com/LemmurOrg/lemmur';
const patreonUrl = 'https://patreon.com/lemmur';
const buyMeACoffeeUrl = 'https://buymeacoff.ee/lemmur';
const markdownGuide =
'https://join-lemmy.org/docs/en/about/guide.html#using-markdown';

View File

@ -19,7 +19,7 @@ ConfigStore _$ConfigStoreFromJson(Map<String, dynamic> json) => ConfigStore()
Map<String, dynamic> _$ConfigStoreToJson(ConfigStore instance) =>
<String, dynamic>{
'theme': _$ThemeModeEnumMap[instance.theme],
'theme': _$ThemeModeEnumMap[instance.theme]!,
'amoledDarkMode': instance.amoledDarkMode,
'locale': const LocaleConverter().toJson(instance.locale),
'showAvatars': instance.showAvatars,
@ -38,10 +38,10 @@ const _$ThemeModeEnumMap = {
// StoreGenerator
// **************************************************************************
// 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
// 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
mixin _$ConfigStore on _ConfigStore, Store {
final _$themeAtom = Atom(name: '_ConfigStore.theme');
late final _$themeAtom = Atom(name: '_ConfigStore.theme', context: context);
@override
ThemeMode get theme {
@ -56,7 +56,8 @@ mixin _$ConfigStore on _ConfigStore, Store {
});
}
final _$amoledDarkModeAtom = Atom(name: '_ConfigStore.amoledDarkMode');
late final _$amoledDarkModeAtom =
Atom(name: '_ConfigStore.amoledDarkMode', context: context);
@override
bool get amoledDarkMode {
@ -71,7 +72,7 @@ mixin _$ConfigStore on _ConfigStore, Store {
});
}
final _$localeAtom = Atom(name: '_ConfigStore.locale');
late final _$localeAtom = Atom(name: '_ConfigStore.locale', context: context);
@override
Locale get locale {
@ -86,7 +87,8 @@ mixin _$ConfigStore on _ConfigStore, Store {
});
}
final _$showAvatarsAtom = Atom(name: '_ConfigStore.showAvatars');
late final _$showAvatarsAtom =
Atom(name: '_ConfigStore.showAvatars', context: context);
@override
bool get showAvatars {
@ -101,7 +103,8 @@ mixin _$ConfigStore on _ConfigStore, Store {
});
}
final _$showScoresAtom = Atom(name: '_ConfigStore.showScores');
late final _$showScoresAtom =
Atom(name: '_ConfigStore.showScores', context: context);
@override
bool get showScores {
@ -116,7 +119,8 @@ mixin _$ConfigStore on _ConfigStore, Store {
});
}
final _$defaultSortTypeAtom = Atom(name: '_ConfigStore.defaultSortType');
late final _$defaultSortTypeAtom =
Atom(name: '_ConfigStore.defaultSortType', context: context);
@override
SortType get defaultSortType {
@ -131,8 +135,8 @@ mixin _$ConfigStore on _ConfigStore, Store {
});
}
final _$defaultListingTypeAtom =
Atom(name: '_ConfigStore.defaultListingType');
late final _$defaultListingTypeAtom =
Atom(name: '_ConfigStore.defaultListingType', context: context);
@override
PostListingType get defaultListingType {
@ -147,8 +151,8 @@ mixin _$ConfigStore on _ConfigStore, Store {
});
}
final _$importLemmyUserSettingsAsyncAction =
AsyncAction('_ConfigStore.importLemmyUserSettings');
late final _$importLemmyUserSettingsAsyncAction =
AsyncAction('_ConfigStore.importLemmyUserSettings', context: context);
@override
Future<void> importLemmyUserSettings(Jwt token) {
@ -156,7 +160,8 @@ mixin _$ConfigStore on _ConfigStore, Store {
.run(() => super.importLemmyUserSettings(token));
}
final _$_ConfigStoreActionController = ActionController(name: '_ConfigStore');
late final _$_ConfigStoreActionController =
ActionController(name: '_ConfigStore', context: context);
@override
void copyLemmyUserSettings(LocalUserSettings localUserSettings) {

View File

@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart' as ul;
import 'l10n/l10n.dart';
import 'pages/community/community.dart';
import 'pages/instance.dart';
import 'pages/instance/instance.dart';
import 'pages/media_view.dart';
import 'pages/user.dart';
import 'stores/accounts_store.dart';
@ -23,7 +25,10 @@ Future<void> linkLauncher({
final instances = context.read<AccountsStore>().instances;
final chonks = url.split('/');
if (chonks.length == 1) return openInBrowser(url);
if (chonks.length == 1) {
await launchLink(link: url, context: context);
return;
}
// CHECK IF LINK TO USER
if (url.startsWith('/u/')) {
@ -48,7 +53,7 @@ Future<void> linkLauncher({
if (matchedInstance != null && instances.any((e) => e == match?.group(1))) {
if (rest == null || rest.isEmpty || rest == '/') {
return push(() => InstancePage(instanceHost: matchedInstance));
return Navigator.of(context).push<void>(InstancePage.route(instanceHost));
}
final split = rest.split('/');
switch (split[1]) {
@ -97,14 +102,25 @@ Future<void> linkLauncher({
// FALLBACK TO REGULAR LINK STUFF
return openInBrowser(url);
await launchLink(link: url, context: context);
}
Future<void> openInBrowser(String url) async {
if (await ul.canLaunch(url)) {
await ul.launch(url);
final _logger = Logger('launchLink');
/// Returns whether launching was successful.
Future<bool> launchLink({
required String link,
required BuildContext context,
}) async {
final uri = Uri.tryParse(link);
if (uri != null && await ul.canLaunchUrl(uri)) {
await ul.launchUrl(uri);
return true;
} else {
throw Exception();
// TODO: handle opening links to stuff in app
_logger.warning('Failed to launch a link: $link');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context).cannot_open_in_browser)),
);
return false;
}
}

View File

@ -5,6 +5,8 @@ import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:mobx/mobx.dart';
import '../l10n/l10n_from_string.dart';
part 'async_store.freezed.dart';
part 'async_store.g.dart';
@ -28,6 +30,10 @@ abstract class _AsyncStore<T> with Store {
@action
void setData(T data) => asyncState = AsyncState.data(data);
/// reset an asyncState to its initial one
@action
void reset() => asyncState = AsyncState<T>.initial();
/// runs some async action and reflects the progress in [asyncState].
/// If successful, the result is returned, otherwise null is returned.
/// If this [AsyncStore] is already running some action, it will exit immediately and do nothing
@ -51,11 +57,10 @@ abstract class _AsyncStore<T> with Store {
return result;
} on SocketException {
// TODO: use an existing l10n key
if (data != null) {
asyncState = data.copyWith(errorTerm: 'network_error');
asyncState = data.copyWith(errorTerm: L10nStrings.network_error);
} else {
asyncState = const AsyncState.error('network_error');
asyncState = const AsyncState.error(L10nStrings.network_error);
}
} catch (err) {
if (data != null) {
@ -65,6 +70,8 @@ abstract class _AsyncStore<T> with Store {
}
rethrow;
}
return null;
}
/// [run] but specialized for a [LemmyApiQuery].
@ -76,8 +83,10 @@ abstract class _AsyncStore<T> with Store {
bool refresh = false,
}) async {
try {
return await run(() => LemmyApiV3(instanceHost).run(query),
refresh: refresh);
return await run(
() => LemmyApiV3(instanceHost).run(query),
refresh: refresh,
);
} on LemmyApiException catch (err) {
final data = refresh ? asyncState.mapOrNull(data: (data) => data) : null;
if (data != null) {
@ -86,6 +95,8 @@ abstract class _AsyncStore<T> with Store {
asyncState = AsyncState<T>.error(err.message);
}
}
return null;
}
/// helper function for mapping [asyncState] into 3 variants

View File

@ -12,36 +12,7 @@ part of 'async_store.dart';
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more informations: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
/// @nodoc
class _$AsyncStateTearOff {
const _$AsyncStateTearOff();
AsyncStateInitial<T> initial<T>() {
return AsyncStateInitial<T>();
}
AsyncStateData<T> data<T>(T data, [String? errorTerm]) {
return AsyncStateData<T>(
data,
errorTerm,
);
}
AsyncStateLoading<T> loading<T>() {
return AsyncStateLoading<T>();
}
AsyncStateError<T> error<T>(String errorTerm) {
return AsyncStateError<T>(
errorTerm,
);
}
}
/// @nodoc
const $AsyncState = _$AsyncStateTearOff();
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
/// @nodoc
mixin _$AsyncState<T> {
@ -115,22 +86,22 @@ class _$AsyncStateCopyWithImpl<T, $Res>
}
/// @nodoc
abstract class $AsyncStateInitialCopyWith<T, $Res> {
factory $AsyncStateInitialCopyWith(AsyncStateInitial<T> value,
$Res Function(AsyncStateInitial<T>) then) =
_$AsyncStateInitialCopyWithImpl<T, $Res>;
abstract class _$$AsyncStateInitialCopyWith<T, $Res> {
factory _$$AsyncStateInitialCopyWith(_$AsyncStateInitial<T> value,
$Res Function(_$AsyncStateInitial<T>) then) =
__$$AsyncStateInitialCopyWithImpl<T, $Res>;
}
/// @nodoc
class _$AsyncStateInitialCopyWithImpl<T, $Res>
class __$$AsyncStateInitialCopyWithImpl<T, $Res>
extends _$AsyncStateCopyWithImpl<T, $Res>
implements $AsyncStateInitialCopyWith<T, $Res> {
_$AsyncStateInitialCopyWithImpl(
AsyncStateInitial<T> _value, $Res Function(AsyncStateInitial<T>) _then)
: super(_value, (v) => _then(v as AsyncStateInitial<T>));
implements _$$AsyncStateInitialCopyWith<T, $Res> {
__$$AsyncStateInitialCopyWithImpl(_$AsyncStateInitial<T> _value,
$Res Function(_$AsyncStateInitial<T>) _then)
: super(_value, (v) => _then(v as _$AsyncStateInitial<T>));
@override
AsyncStateInitial<T> get _value => super._value as AsyncStateInitial<T>;
_$AsyncStateInitial<T> get _value => super._value as _$AsyncStateInitial<T>;
}
/// @nodoc
@ -148,13 +119,13 @@ class _$AsyncStateInitial<T>
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties..add(DiagnosticsProperty('type', 'AsyncState<$T>.initial'));
properties.add(DiagnosticsProperty('type', 'AsyncState<$T>.initial'));
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType && other is AsyncStateInitial<T>);
(other.runtimeType == runtimeType && other is _$AsyncStateInitial<T>);
}
@override
@ -240,30 +211,30 @@ abstract class AsyncStateInitial<T> implements AsyncState<T> {
}
/// @nodoc
abstract class $AsyncStateDataCopyWith<T, $Res> {
factory $AsyncStateDataCopyWith(
AsyncStateData<T> value, $Res Function(AsyncStateData<T>) then) =
_$AsyncStateDataCopyWithImpl<T, $Res>;
abstract class _$$AsyncStateDataCopyWith<T, $Res> {
factory _$$AsyncStateDataCopyWith(
_$AsyncStateData<T> value, $Res Function(_$AsyncStateData<T>) then) =
__$$AsyncStateDataCopyWithImpl<T, $Res>;
$Res call({T data, String? errorTerm});
}
/// @nodoc
class _$AsyncStateDataCopyWithImpl<T, $Res>
class __$$AsyncStateDataCopyWithImpl<T, $Res>
extends _$AsyncStateCopyWithImpl<T, $Res>
implements $AsyncStateDataCopyWith<T, $Res> {
_$AsyncStateDataCopyWithImpl(
AsyncStateData<T> _value, $Res Function(AsyncStateData<T>) _then)
: super(_value, (v) => _then(v as AsyncStateData<T>));
implements _$$AsyncStateDataCopyWith<T, $Res> {
__$$AsyncStateDataCopyWithImpl(
_$AsyncStateData<T> _value, $Res Function(_$AsyncStateData<T>) _then)
: super(_value, (v) => _then(v as _$AsyncStateData<T>));
@override
AsyncStateData<T> get _value => super._value as AsyncStateData<T>;
_$AsyncStateData<T> get _value => super._value as _$AsyncStateData<T>;
@override
$Res call({
Object? data = freezed,
Object? errorTerm = freezed,
}) {
return _then(AsyncStateData<T>(
return _then(_$AsyncStateData<T>(
data == freezed
? _value.data
: data // ignore: cast_nullable_to_non_nullable
@ -306,7 +277,7 @@ class _$AsyncStateData<T>
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is AsyncStateData<T> &&
other is _$AsyncStateData<T> &&
const DeepCollectionEquality().equals(other.data, data) &&
const DeepCollectionEquality().equals(other.errorTerm, errorTerm));
}
@ -319,8 +290,8 @@ class _$AsyncStateData<T>
@JsonKey(ignore: true)
@override
$AsyncStateDataCopyWith<T, AsyncStateData<T>> get copyWith =>
_$AsyncStateDataCopyWithImpl<T, AsyncStateData<T>>(this, _$identity);
_$$AsyncStateDataCopyWith<T, _$AsyncStateData<T>> get copyWith =>
__$$AsyncStateDataCopyWithImpl<T, _$AsyncStateData<T>>(this, _$identity);
@override
@optionalTypeArgs
@ -398,33 +369,33 @@ class _$AsyncStateData<T>
}
abstract class AsyncStateData<T> implements AsyncState<T> {
const factory AsyncStateData(T data, [String? errorTerm]) =
const factory AsyncStateData(final T data, [final String? errorTerm]) =
_$AsyncStateData<T>;
T get data;
String? get errorTerm;
@JsonKey(ignore: true)
$AsyncStateDataCopyWith<T, AsyncStateData<T>> get copyWith =>
_$$AsyncStateDataCopyWith<T, _$AsyncStateData<T>> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $AsyncStateLoadingCopyWith<T, $Res> {
factory $AsyncStateLoadingCopyWith(AsyncStateLoading<T> value,
$Res Function(AsyncStateLoading<T>) then) =
_$AsyncStateLoadingCopyWithImpl<T, $Res>;
abstract class _$$AsyncStateLoadingCopyWith<T, $Res> {
factory _$$AsyncStateLoadingCopyWith(_$AsyncStateLoading<T> value,
$Res Function(_$AsyncStateLoading<T>) then) =
__$$AsyncStateLoadingCopyWithImpl<T, $Res>;
}
/// @nodoc
class _$AsyncStateLoadingCopyWithImpl<T, $Res>
class __$$AsyncStateLoadingCopyWithImpl<T, $Res>
extends _$AsyncStateCopyWithImpl<T, $Res>
implements $AsyncStateLoadingCopyWith<T, $Res> {
_$AsyncStateLoadingCopyWithImpl(
AsyncStateLoading<T> _value, $Res Function(AsyncStateLoading<T>) _then)
: super(_value, (v) => _then(v as AsyncStateLoading<T>));
implements _$$AsyncStateLoadingCopyWith<T, $Res> {
__$$AsyncStateLoadingCopyWithImpl(_$AsyncStateLoading<T> _value,
$Res Function(_$AsyncStateLoading<T>) _then)
: super(_value, (v) => _then(v as _$AsyncStateLoading<T>));
@override
AsyncStateLoading<T> get _value => super._value as AsyncStateLoading<T>;
_$AsyncStateLoading<T> get _value => super._value as _$AsyncStateLoading<T>;
}
/// @nodoc
@ -442,13 +413,13 @@ class _$AsyncStateLoading<T>
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties..add(DiagnosticsProperty('type', 'AsyncState<$T>.loading'));
properties.add(DiagnosticsProperty('type', 'AsyncState<$T>.loading'));
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType && other is AsyncStateLoading<T>);
(other.runtimeType == runtimeType && other is _$AsyncStateLoading<T>);
}
@override
@ -534,29 +505,29 @@ abstract class AsyncStateLoading<T> implements AsyncState<T> {
}
/// @nodoc
abstract class $AsyncStateErrorCopyWith<T, $Res> {
factory $AsyncStateErrorCopyWith(
AsyncStateError<T> value, $Res Function(AsyncStateError<T>) then) =
_$AsyncStateErrorCopyWithImpl<T, $Res>;
abstract class _$$AsyncStateErrorCopyWith<T, $Res> {
factory _$$AsyncStateErrorCopyWith(_$AsyncStateError<T> value,
$Res Function(_$AsyncStateError<T>) then) =
__$$AsyncStateErrorCopyWithImpl<T, $Res>;
$Res call({String errorTerm});
}
/// @nodoc
class _$AsyncStateErrorCopyWithImpl<T, $Res>
class __$$AsyncStateErrorCopyWithImpl<T, $Res>
extends _$AsyncStateCopyWithImpl<T, $Res>
implements $AsyncStateErrorCopyWith<T, $Res> {
_$AsyncStateErrorCopyWithImpl(
AsyncStateError<T> _value, $Res Function(AsyncStateError<T>) _then)
: super(_value, (v) => _then(v as AsyncStateError<T>));
implements _$$AsyncStateErrorCopyWith<T, $Res> {
__$$AsyncStateErrorCopyWithImpl(
_$AsyncStateError<T> _value, $Res Function(_$AsyncStateError<T>) _then)
: super(_value, (v) => _then(v as _$AsyncStateError<T>));
@override
AsyncStateError<T> get _value => super._value as AsyncStateError<T>;
_$AsyncStateError<T> get _value => super._value as _$AsyncStateError<T>;
@override
$Res call({
Object? errorTerm = freezed,
}) {
return _then(AsyncStateError<T>(
return _then(_$AsyncStateError<T>(
errorTerm == freezed
? _value.errorTerm
: errorTerm // ignore: cast_nullable_to_non_nullable
@ -592,7 +563,7 @@ class _$AsyncStateError<T>
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is AsyncStateError<T> &&
other is _$AsyncStateError<T> &&
const DeepCollectionEquality().equals(other.errorTerm, errorTerm));
}
@ -602,8 +573,9 @@ class _$AsyncStateError<T>
@JsonKey(ignore: true)
@override
$AsyncStateErrorCopyWith<T, AsyncStateError<T>> get copyWith =>
_$AsyncStateErrorCopyWithImpl<T, AsyncStateError<T>>(this, _$identity);
_$$AsyncStateErrorCopyWith<T, _$AsyncStateError<T>> get copyWith =>
__$$AsyncStateErrorCopyWithImpl<T, _$AsyncStateError<T>>(
this, _$identity);
@override
@optionalTypeArgs
@ -681,10 +653,10 @@ class _$AsyncStateError<T>
}
abstract class AsyncStateError<T> implements AsyncState<T> {
const factory AsyncStateError(String errorTerm) = _$AsyncStateError<T>;
const factory AsyncStateError(final String errorTerm) = _$AsyncStateError<T>;
String get errorTerm;
@JsonKey(ignore: true)
$AsyncStateErrorCopyWith<T, AsyncStateError<T>> get copyWith =>
_$$AsyncStateErrorCopyWith<T, _$AsyncStateError<T>> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@ -6,7 +6,7 @@ part of 'async_store.dart';
// StoreGenerator
// **************************************************************************
// 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
// 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
mixin _$AsyncStore<T> on _AsyncStore<T>, Store {
Computed<bool>? _$isLoadingComputed;
@ -23,7 +23,8 @@ mixin _$AsyncStore<T> on _AsyncStore<T>, Store {
name: '_AsyncStore.errorTerm'))
.value;
final _$asyncStateAtom = Atom(name: '_AsyncStore.asyncState');
late final _$asyncStateAtom =
Atom(name: '_AsyncStore.asyncState', context: context);
@override
AsyncState<T> get asyncState {
@ -38,14 +39,16 @@ mixin _$AsyncStore<T> on _AsyncStore<T>, Store {
});
}
final _$runAsyncAction = AsyncAction('_AsyncStore.run');
late final _$runAsyncAction =
AsyncAction('_AsyncStore.run', context: context);
@override
Future<T?> run(AsyncValueGetter<T> callback, {bool refresh = false}) {
return _$runAsyncAction.run(() => super.run(callback, refresh: refresh));
}
final _$runLemmyAsyncAction = AsyncAction('_AsyncStore.runLemmy');
late final _$runLemmyAsyncAction =
AsyncAction('_AsyncStore.runLemmy', context: context);
@override
Future<T?> runLemmy(String instanceHost, LemmyApiQuery<T> query,
@ -54,7 +57,8 @@ mixin _$AsyncStore<T> on _AsyncStore<T>, Store {
.run(() => super.runLemmy(instanceHost, query, refresh: refresh));
}
final _$_AsyncStoreActionController = ActionController(name: '_AsyncStore');
late final _$_AsyncStoreActionController =
ActionController(name: '_AsyncStore', context: context);
@override
void setData(T data) {
@ -67,6 +71,17 @@ mixin _$AsyncStore<T> on _AsyncStore<T>, Store {
}
}
@override
void reset() {
final _$actionInfo =
_$_AsyncStoreActionController.startAction(name: '_AsyncStore.reset');
try {
return super.reset();
} finally {
_$_AsyncStoreActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''

View File

@ -12,32 +12,45 @@ class AsyncStoreListener<T> extends SingleChildStatelessWidget {
T data,
)? successMessageBuilder;
final void Function(
BuildContext context,
T data,
)? onSuccess;
const AsyncStoreListener({
Key? key,
super.key,
required this.asyncStore,
this.successMessageBuilder,
Widget? child,
}) : super(key: key, child: child);
this.onSuccess,
super.child,
});
@override
Widget buildWithChild(BuildContext context, Widget? child) {
return ObserverListener<AsyncStore<T>>(
store: asyncStore,
listener: (context, store) {
final errorTerm = store.errorTerm;
store.map(
loading: () {},
error: (errorTerm) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(errorTerm.tr(context))));
},
data: (data) {
onSuccess?.call(context, data);
if (errorTerm != null) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(errorTerm.tr(context))));
} else if (store.asyncState is AsyncStateData &&
(successMessageBuilder != null)) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(successMessageBuilder!(
context, (store.asyncState as AsyncStateData).data))));
}
if (successMessageBuilder != null) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(successMessageBuilder!(context, data)),
),
);
}
},
);
},
child: child ?? const SizedBox(),
);

View File

@ -52,3 +52,14 @@ extension UserPreferredNames on PersonSafe {
extension CommentLink on Comment {
String get link => 'https://$instanceHost/post/$postId/comment/$id';
}
// inspired by https://github.com/LemmyNet/lemmy-ui/blob/66c846ededef8c0afd5aaadca4aaedcbaeab3ee6/src/shared/utils.ts#L533
extension PersonSafeCakeDay on PersonSafe {
bool get isCakeDay {
final now = DateTime.now().toUtc();
return now.day == published.day &&
now.month == published.month &&
now.year != published.year;
}
}

View File

@ -1,12 +0,0 @@
import 'package:lemmy_api_client/v3.dart';
// inspired by https://github.com/LemmyNet/lemmy-ui/blob/66c846ededef8c0afd5aaadca4aaedcbaeab3ee6/src/shared/utils.ts#L533
extension PersonSafeCakeDay on PersonSafe {
bool get isCakeDay {
final now = DateTime.now().toUtc();
return now.day == published.day &&
now.month == published.month &&
now.year != published.year;
}
}

View File

@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
import 'package:lemmy_api_client/v3.dart';
import '../../stores/accounts_store.dart';
import '../observer_consumers.dart';
extension BuildContextExtensions on BuildContext {
/// Get default [Jwt] for an instance
Jwt? defaultJwt(String instanceHost) =>
read<AccountsStore>().defaultUserDataFor(instanceHost)?.jwt;
}

18
lib/util/files.dart Normal file
View File

@ -0,0 +1,18 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:image_picker/image_picker.dart';
/// Picks a single image from the system
Future<XFile?> pickImage() async {
if (kIsWeb || Platform.isIOS || Platform.isAndroid) {
return ImagePicker().pickImage(source: ImageSource.gallery);
} else {
final result = await FilePicker.platform.pickFiles(type: FileType.image);
if (result == null) return null;
return XFile(result.files.single.path!);
}
}

View File

@ -3,7 +3,6 @@ import 'package:lemmy_api_client/v3.dart';
import '../pages/community/community.dart';
import '../pages/full_post/full_post.dart';
import '../pages/instance.dart';
import '../pages/media_view.dart';
import '../pages/user.dart';
@ -25,9 +24,6 @@ Future<dynamic> goToReplace(
builder: builder,
));
void goToInstance(BuildContext context, String instanceHost) =>
goTo(context, (context) => InstancePage(instanceHost: instanceHost));
// ignore: camel_case_types
abstract class goToCommunity {
/// Navigates to `CommunityPage`
@ -63,7 +59,6 @@ void goToMedia(BuildContext context, String url) => Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (_, __, ___) => MediaViewPage(url),
transitionDuration: const Duration(milliseconds: 300),
opaque: false,
transitionsBuilder: (_, animation, __, child) =>
FadeTransition(opacity: animation, child: child),

View File

@ -8,34 +8,24 @@ import 'observer_consumers.dart';
/// Important: this will not make [context.watch] react to changes
class MobxProvider<T extends Store> extends Provider<T> {
MobxProvider({
Key? key,
required Create<T> create,
bool? lazy,
TransitionBuilder? builder,
Widget? child,
super.key,
required super.create,
super.lazy,
super.builder,
super.child,
}) : super(
key: key,
create: create,
dispose: (context, store) {
if (store is DisposableStore) store.dispose();
},
lazy: lazy,
builder: builder,
child: child,
);
/// will not dispose the store
MobxProvider.value({
Key? key,
required T value,
TransitionBuilder? builder,
Widget? child,
}) : super.value(
key: key,
builder: builder,
value: value,
child: child,
);
super.key,
required super.value,
super.builder,
super.child,
}) : super.value();
}
/// tracks reactions and disposes them in [DisposableStore.dispose]

View File

@ -14,10 +14,10 @@ class ObserverBuilder<T extends Store> extends StatelessWidget {
final MobxBuilder<T> builder;
const ObserverBuilder({
Key? key,
super.key,
this.store,
required this.builder,
}) : super(key: key);
});
@override
Widget build(BuildContext context) {
@ -38,11 +38,11 @@ class ObserverListener<T extends Store> extends HookWidget {
final Widget child;
const ObserverListener({
Key? key,
super.key,
this.store,
required this.listener,
required this.child,
}) : super(key: key);
});
@override
Widget build(BuildContext context) {
@ -64,11 +64,11 @@ class ObserverConsumer<T extends Store> extends HookWidget {
final MobxBuilder<T> builder;
const ObserverConsumer({
Key? key,
super.key,
this.store,
required this.listener,
required this.builder,
}) : super(key: key);
});
@override
Widget build(BuildContext context) {

View File

@ -0,0 +1,89 @@
import 'package:flutter/widgets.dart';
/// utililty class for traversing through multiline text
class TextLinesIterator extends Iterator {
String text;
int beg;
int end;
TextSelection? selection;
TextLinesIterator(this.text, {this.selection})
: end = -1,
beg = -1;
TextLinesIterator.fromController(TextEditingController controller)
: this(controller.text, selection: controller.selection);
bool get isWithinSelection {
final selection = this.selection;
if (selection == null || beg == -1) {
return false;
} else {
return (selection.end >= beg && beg >= selection.start) ||
(selection.end >= end && end >= selection.start) ||
(end >= selection.start && selection.start >= beg) ||
(end >= selection.end && selection.end >= beg) ||
(beg <= selection.start &&
selection.start <= end &&
beg <= selection.end &&
selection.end <= end);
}
}
@override
String get current {
return text.substring(beg, end);
}
set current(String newVal) {
final selected = isWithinSelection;
text = text.replaceRange(beg, end, newVal);
final wordLen = end - beg;
final dif = newVal.length - wordLen;
end += dif;
final selection = this.selection;
if (selection == null) return;
if (selected || selection.baseOffset > end) {
this.selection =
selection.copyWith(extentOffset: selection.extentOffset + dif);
}
}
void reset() {
end = -1;
beg = -1;
}
@override
bool moveNext() {
if (end == text.length) {
return false;
}
if (beg == -1) {
end = 0;
beg = 0;
} else {
end += 1;
beg = end;
}
for (; end < text.length; end++) {
if (text[end] == '\n') {
return true;
}
}
end = text.length;
return true;
}
/// returns the lines as a list but also moves the pointer to the back
List<String> get asList {
reset();
final list = <String>[];
while (moveNext()) {
list.add(current);
}
return list;
}
}

View File

@ -43,7 +43,8 @@ class AboutTile extends HookWidget {
TextButton.icon(
icon: const Icon(Icons.code),
label: const Text('source code'),
onPressed: () => openInBrowser(lemmurRepositoryLink),
onPressed: () =>
launchLink(link: lemmurRepositoryUrl, context: context),
),
TextButton.icon(
icon: const Icon(Icons.monetization_on),
@ -56,13 +57,17 @@ class AboutTile extends HookWidget {
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: () =>
openInBrowser('https://patreon.com/lemmur'),
onPressed: () => launchLink(
link: patreonUrl,
context: context,
),
child: const Text('Patreon'),
),
TextButton(
onPressed: () =>
openInBrowser('https://buymeacoff.ee/lemmur'),
onPressed: () => launchLink(
link: buyMeACoffeeUrl,
context: context,
),
child: const Text('Buy Me a Coffee'),
),
],
@ -91,7 +96,7 @@ class AboutTile extends HookWidget {
class ChangelogPage extends StatelessWidget {
final String changelog;
const ChangelogPage(this.changelog, {Key? key}) : super(key: key);
const ChangelogPage(this.changelog, {super.key});
@override
Widget build(BuildContext context) {

View File

@ -10,14 +10,14 @@ import 'cached_network_image.dart';
/// Can be disabled with `noBlank`
class Avatar extends HookWidget {
const Avatar({
Key? key,
super.key,
required this.url,
this.radius = 25,
this.noBlank = false,
this.alwaysShow = false,
this.padding = EdgeInsets.zero,
this.onTap,
}) : super(key: key);
});
final String? url;
final double radius;

View File

@ -41,8 +41,8 @@ class CachedNetworkImage extends StatelessWidget {
this.width,
this.fit,
this.cache = true,
Key? key,
}) : super(key: key);
super.key,
});
@override
Widget build(BuildContext context) {

View File

@ -9,7 +9,6 @@ import '../../l10n/l10n.dart';
import '../../stores/config_store.dart';
import '../../util/async_store_listener.dart';
import '../../util/extensions/api.dart';
import '../../util/extensions/cake_day.dart';
import '../../util/goto.dart';
import '../../util/mobx_provider.dart';
import '../../util/observer_consumers.dart';
@ -36,8 +35,8 @@ class CommentWidget extends StatelessWidget {
this.canBeMarkedAsRead = false,
this.hideOnRead = false,
this.userMentionId,
Key? key,
}) : super(key: key);
super.key,
});
CommentWidget.fromCommentView(
CommentView cv, {

View File

@ -13,7 +13,7 @@ import 'comment_more_menu_button.dart';
import 'comment_store.dart';
class CommentActions extends HookWidget {
const CommentActions({Key? key}) : super(key: key);
const CommentActions({super.key});
@override
Widget build(BuildContext context) {

View File

@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:url_launcher/url_launcher.dart' as ul;
import '../../hooks/logged_in_action.dart';
import '../../l10n/l10n.dart';
import '../../url_launcher.dart';
import '../../util/extensions/api.dart';
import '../../util/icons.dart';
import '../../util/observer_consumers.dart';
@ -18,7 +18,7 @@ import 'comment.dart';
import 'comment_store.dart';
class CommentMoreMenuButton extends HookWidget {
const CommentMoreMenuButton({Key? key}) : super(key: key);
const CommentMoreMenuButton({super.key});
@override
Widget build(BuildContext context) {
@ -44,9 +44,8 @@ class _CommentMoreMenuPopup extends HookWidget {
final CommentStore store;
const _CommentMoreMenuPopup({
Key? key,
required this.store,
}) : super(key: key);
});
@override
Widget build(BuildContext context) {
@ -83,13 +82,7 @@ class _CommentMoreMenuPopup extends HookWidget {
leading: const Icon(Icons.open_in_browser),
title: const Text('Open in browser'),
onTap: () async {
if (await ul.canLaunch(comment.link)) {
await ul.launch(comment.link);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("can't open in browser")),
);
}
await launchLink(link: comment.link, context: context);
Navigator.of(context).pop();
},

View File

@ -54,6 +54,7 @@ abstract class _CommentStore with Store {
_CommentStore(
this._accountsStore, {
required CommentTree commentTree,
// ignore: unused_element
this.userMentionId,
required this.depth,
required this.canBeMarkedAsRead,

View File

@ -6,7 +6,7 @@ part of 'comment_store.dart';
// StoreGenerator
// **************************************************************************
// 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
// 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
mixin _$CommentStore on _CommentStore, Store {
Computed<bool>? _$isMineComputed;
@ -28,7 +28,8 @@ mixin _$CommentStore on _CommentStore, Store {
Computed<bool>(() => super.isOP, name: '_CommentStore.isOP'))
.value;
final _$commentAtom = Atom(name: '_CommentStore.comment');
late final _$commentAtom =
Atom(name: '_CommentStore.comment', context: context);
@override
CommentView get comment {
@ -43,7 +44,8 @@ mixin _$CommentStore on _CommentStore, Store {
});
}
final _$selectableAtom = Atom(name: '_CommentStore.selectable');
late final _$selectableAtom =
Atom(name: '_CommentStore.selectable', context: context);
@override
bool get selectable {
@ -58,7 +60,8 @@ mixin _$CommentStore on _CommentStore, Store {
});
}
final _$collapsedAtom = Atom(name: '_CommentStore.collapsed');
late final _$collapsedAtom =
Atom(name: '_CommentStore.collapsed', context: context);
@override
bool get collapsed {
@ -73,7 +76,8 @@ mixin _$CommentStore on _CommentStore, Store {
});
}
final _$showRawAtom = Atom(name: '_CommentStore.showRaw');
late final _$showRawAtom =
Atom(name: '_CommentStore.showRaw', context: context);
@override
bool get showRaw {
@ -88,64 +92,72 @@ mixin _$CommentStore on _CommentStore, Store {
});
}
final _$reportAsyncAction = AsyncAction('_CommentStore.report');
late final _$reportAsyncAction =
AsyncAction('_CommentStore.report', context: context);
@override
Future<void> report(Jwt token, String reason) {
return _$reportAsyncAction.run(() => super.report(token, reason));
}
final _$deleteAsyncAction = AsyncAction('_CommentStore.delete');
late final _$deleteAsyncAction =
AsyncAction('_CommentStore.delete', context: context);
@override
Future<void> delete(Jwt token) {
return _$deleteAsyncAction.run(() => super.delete(token));
}
final _$saveAsyncAction = AsyncAction('_CommentStore.save');
late final _$saveAsyncAction =
AsyncAction('_CommentStore.save', context: context);
@override
Future<void> save(Jwt token) {
return _$saveAsyncAction.run(() => super.save(token));
}
final _$blockAsyncAction = AsyncAction('_CommentStore.block');
late final _$blockAsyncAction =
AsyncAction('_CommentStore.block', context: context);
@override
Future<void> block(Jwt token) {
return _$blockAsyncAction.run(() => super.block(token));
}
final _$markAsReadAsyncAction = AsyncAction('_CommentStore.markAsRead');
late final _$markAsReadAsyncAction =
AsyncAction('_CommentStore.markAsRead', context: context);
@override
Future<void> markAsRead(Jwt token) {
return _$markAsReadAsyncAction.run(() => super.markAsRead(token));
}
final _$_voteAsyncAction = AsyncAction('_CommentStore._vote');
late final _$_voteAsyncAction =
AsyncAction('_CommentStore._vote', context: context);
@override
Future<void> _vote(VoteType voteType, Jwt token) {
return _$_voteAsyncAction.run(() => super._vote(voteType, token));
}
final _$upVoteAsyncAction = AsyncAction('_CommentStore.upVote');
late final _$upVoteAsyncAction =
AsyncAction('_CommentStore.upVote', context: context);
@override
Future<void> upVote(Jwt token) {
return _$upVoteAsyncAction.run(() => super.upVote(token));
}
final _$downVoteAsyncAction = AsyncAction('_CommentStore.downVote');
late final _$downVoteAsyncAction =
AsyncAction('_CommentStore.downVote', context: context);
@override
Future<void> downVote(Jwt token) {
return _$downVoteAsyncAction.run(() => super.downVote(token));
}
final _$_CommentStoreActionController =
ActionController(name: '_CommentStore');
late final _$_CommentStoreActionController =
ActionController(name: '_CommentStore', context: context);
@override
void toggleShowRaw() {

View File

@ -1,60 +1,87 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'markdown_text.dart';
import '../../markdown_formatter.dart';
import '../markdown_text.dart';
export 'editor_toolbar.dart';
class EditorController {
final TextEditingController textEditingController;
final FocusNode focusNode;
final String instanceHost;
EditorController({
required this.textEditingController,
required this.focusNode,
required this.instanceHost,
});
}
EditorController useEditorController({
required String instanceHost,
String? text,
}) {
final focusNode = useFocusNode();
final textEditingController = useTextEditingController(text: text);
return EditorController(
textEditingController: textEditingController,
focusNode: focusNode,
instanceHost: instanceHost);
}
/// A text field with added functionality for ease of editing
class Editor extends HookWidget {
final TextEditingController? controller;
final FocusNode? focusNode;
final EditorController controller;
final ValueChanged<String>? onSubmitted;
final ValueChanged<String>? onChanged;
final int? minLines;
final int? maxLines;
final String? labelText;
final String? initialValue;
final bool autofocus;
/// Whether the editor should be preview the contents
final bool fancy;
final String instanceHost;
const Editor({
Key? key,
this.controller,
this.focusNode,
super.key,
required this.controller,
this.onSubmitted,
this.onChanged,
this.minLines = 5,
this.maxLines,
this.labelText,
this.initialValue,
this.fancy = false,
required this.instanceHost,
this.autofocus = false,
}) : super(key: key);
});
@override
Widget build(BuildContext context) {
final defaultController = useTextEditingController();
final actualController = controller ?? defaultController;
if (fancy) {
return Padding(
padding: const EdgeInsets.all(8),
child: MarkdownText(
actualController.text,
instanceHost: instanceHost,
controller.textEditingController.text,
instanceHost: controller.instanceHost,
),
);
}
return TextField(
controller: actualController,
focusNode: focusNode,
focusNode: controller.focusNode,
controller: controller.textEditingController,
autofocus: autofocus,
keyboardType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
onChanged: onChanged,
onSubmitted: onSubmitted,
maxLines: maxLines,
minLines: minLines,
decoration: InputDecoration(labelText: labelText),
inputFormatters: [MarkdownFormatter()],
);
}
}

View File

@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:provider/provider.dart';
import '../../../util/extensions/api.dart';
import '../../../widgets/avatar.dart';
import '../../l10n/l10n.dart';
import '../../stores/accounts_store.dart';
import 'editor_toolbar_store.dart';
class PickPersonDialog extends StatelessWidget {
final EditorToolbarStore store;
const PickPersonDialog._(this.store);
@override
Widget build(BuildContext context) {
final userData =
context.read<AccountsStore>().defaultUserDataFor(store.instanceHost);
return AlertDialog(
title: Text(L10n.of(context).select_user),
content: TypeAheadField<PersonViewSafe>(
suggestionsCallback: (pattern) async {
if (pattern.trim().isEmpty) return const Iterable.empty();
return LemmyApiV3(store.instanceHost)
.run(Search(
q: pattern,
auth: userData?.jwt.raw,
type: SearchType.users,
limit: 10,
))
.then((value) => value.users);
},
itemBuilder: (context, user) {
return ListTile(
leading: Avatar(
url: user.person.avatar,
radius: 20,
),
title: Text(user.person.originPreferredName),
);
},
onSuggestionSelected: (suggestion) =>
Navigator.of(context).pop(suggestion),
loadingBuilder: (context) => Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator.adaptive(),
),
],
),
keepSuggestionsOnLoading: false,
noItemsFoundBuilder: (context) => const SizedBox(),
hideOnEmpty: true,
textFieldConfiguration: const TextFieldConfiguration(autofocus: true),
),
);
}
static Future<PersonViewSafe?> show(BuildContext context) async {
final store = context.read<EditorToolbarStore>();
return showDialog(
context: context,
builder: (context) => PickPersonDialog._(store),
);
}
}
class PickCommunityDialog extends StatelessWidget {
final EditorToolbarStore store;
const PickCommunityDialog._(this.store);
@override
Widget build(BuildContext context) {
final userData =
context.read<AccountsStore>().defaultUserDataFor(store.instanceHost);
return AlertDialog(
title: Text(L10n.of(context).select_community),
content: TypeAheadField<CommunityView>(
suggestionsCallback: (pattern) async {
if (pattern.trim().isEmpty) return const Iterable.empty();
return LemmyApiV3(store.instanceHost)
.run(Search(
q: pattern,
auth: userData?.jwt.raw,
type: SearchType.communities,
limit: 10,
))
.then((value) => value.communities);
},
itemBuilder: (context, community) {
return ListTile(
leading: Avatar(
url: community.community.icon,
radius: 20,
),
title: Text(community.community.originPreferredName),
);
},
onSuggestionSelected: (suggestion) =>
Navigator.of(context).pop(suggestion),
loadingBuilder: (context) => Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator.adaptive(),
),
],
),
keepSuggestionsOnLoading: false,
noItemsFoundBuilder: (context) => const SizedBox(),
hideOnEmpty: true,
textFieldConfiguration: const TextFieldConfiguration(autofocus: true),
),
);
}
static Future<CommunityView?> show(BuildContext context) async {
final store = context.read<EditorToolbarStore>();
return showDialog(
context: context,
builder: (context) => PickCommunityDialog._(store),
);
}
}

View File

@ -0,0 +1,504 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../../hooks/logged_in_action.dart';
import '../../l10n/l10n.dart';
import '../../markdown_formatter.dart';
import '../../resources/links.dart';
import '../../url_launcher.dart';
import '../../util/async_store_listener.dart';
import '../../util/extensions/api.dart';
import '../../util/extensions/spaced.dart';
import '../../util/files.dart';
import '../../util/mobx_provider.dart';
import '../../util/observer_consumers.dart';
import '../../util/text_lines_iterator.dart';
import 'editor.dart';
import 'editor_picking_dialog.dart';
import 'editor_toolbar_store.dart';
class _Reformat {
final String text;
final int selectionBeginningShift;
final int selectionEndingShift;
_Reformat({
required this.text,
this.selectionBeginningShift = 0,
this.selectionEndingShift = 0,
});
}
enum HeaderLevel {
h1(1),
h2(2),
h3(3),
h4(4),
h5(5),
h6(6);
const HeaderLevel(this.value);
final int value;
}
class EditorToolbar extends HookWidget {
final EditorController controller;
static const _height = 50.0;
const EditorToolbar(this.controller);
@override
Widget build(BuildContext context) {
final visible = useListenable(controller.focusNode).hasFocus;
return MobxProvider(
create: (context) => EditorToolbarStore(controller.instanceHost),
child: Builder(builder: (context) {
return AsyncStoreListener(
asyncStore: context.read<EditorToolbarStore>().imageUploadState,
child: AnimatedSwitcher(
duration: kThemeAnimationDuration,
transitionBuilder: (child, animation) {
final offsetAnimation =
Tween<Offset>(begin: const Offset(0, 1.5), end: Offset.zero)
.animate(animation);
return SlideTransition(
position: offsetAnimation,
child: child,
);
},
child: visible
? Material(
color: Theme.of(context).canvasColor,
child: SafeArea(
child: SizedBox(
height: _height,
width: double.infinity,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: _ToolbarBody(
controller: controller.textEditingController,
instanceHost: controller.instanceHost,
),
),
),
),
)
: const SizedBox.shrink(),
),
);
}),
);
}
static const safeArea = SizedBox(height: _height);
}
class BottomSticky extends StatelessWidget {
final Widget child;
const BottomSticky({required this.child});
@override
Widget build(BuildContext context) => Positioned(
bottom: 0,
child: SizedBox(
width: MediaQuery.of(context).size.width,
child: child,
),
);
}
class _ToolbarBody extends HookWidget {
const _ToolbarBody({
required this.controller,
required this.instanceHost,
});
final TextEditingController controller;
final String instanceHost;
@override
Widget build(BuildContext context) {
final loggedInAction = useLoggedInAction(instanceHost);
return Row(
children: [
IconButton(
onPressed: () => controller.surround(
before: '**',
placeholder: L10n.of(context).insert_text_here_placeholder),
icon: const Icon(Icons.format_bold),
tooltip: L10n.of(context).editor_bold,
),
IconButton(
onPressed: () => controller.surround(
before: '*',
placeholder: L10n.of(context).insert_text_here_placeholder),
icon: const Icon(Icons.format_italic),
tooltip: L10n.of(context).editor_italics,
),
IconButton(
onPressed: () async {
final r =
await AddLinkDialog.show(context, controller.selectionText);
if (r != null) controller.reformat((_) => r);
},
icon: const Icon(Icons.link),
tooltip: L10n.of(context).editor_link,
),
// Insert image
ObserverBuilder<EditorToolbarStore>(
builder: (context, store) {
return IconButton(
onPressed: loggedInAction((token) async {
if (store.imageUploadState.isLoading) {
return;
}
try {
final pic = await pickImage();
// pic is null when the picker was cancelled
if (pic != null) {
final picUrl = await store.uploadImage(pic.path, token);
if (picUrl != null) {
controller.reformatSimple('![]($picUrl)');
}
}
} on Exception catch (_) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(L10n.of(context).failed_to_upload_image)));
}
}),
icon: store.imageUploadState.isLoading
? const CircularProgressIndicator.adaptive()
: const Icon(Icons.image),
tooltip: L10n.of(context).editor_image,
);
},
),
IconButton(
onPressed: () async {
final person = await PickPersonDialog.show(context);
if (person != null) {
final name =
'@${person.person.name}@${person.person.originInstanceHost}';
final link = person.person.actorId;
controller.reformatSimple('[$name]($link)');
}
},
icon: const Icon(Icons.person),
tooltip: L10n.of(context).editor_user,
),
IconButton(
onPressed: () async {
final community = await PickCommunityDialog.show(context);
if (community != null) {
final name =
'!${community.community.name}@${community.community.originInstanceHost}';
final link = community.community.actorId;
controller.reformatSimple('[$name]($link)');
}
},
icon: const Icon(Icons.home),
tooltip: L10n.of(context).editor_community,
),
PopupMenuButton<HeaderLevel>(
itemBuilder: (context) => [
for (final h in HeaderLevel.values)
PopupMenuItem(
value: h,
child: Text(h.name.toUpperCase()),
onTap: () {
final header = '${'#' * h.value} ';
if (!controller.firstSelectedLine.startsWith(header)) {
controller.insertAtBeginningOfFirstSelectedLine(header);
}
},
),
],
tooltip: L10n.of(context).editor_header,
child: const Icon(Icons.h_mobiledata),
),
IconButton(
onPressed: () => controller.surround(
before: '~~',
placeholder: L10n.of(context).insert_text_here_placeholder,
),
icon: const Icon(Icons.format_strikethrough),
tooltip: L10n.of(context).editor_strikethrough,
),
IconButton(
onPressed: () {
controller.insertAtBeginningOfEverySelectedLine('> ');
},
icon: const Icon(Icons.format_quote),
tooltip: L10n.of(context).editor_quote,
),
IconButton(
onPressed: () {
final line = controller.firstSelectedLine;
// if theres a list in place, remove it
final listRemoved = () {
for (final c in unorderedListTypes) {
if (line.startsWith('$c ')) {
controller.removeAtBeginningOfEverySelectedLine('$c ');
return true;
}
}
return false;
}();
// if no list, then let's add one
if (!listRemoved) {
controller.insertAtBeginningOfEverySelectedLine(
'${unorderedListTypes.last} ');
}
},
icon: const Icon(Icons.format_list_bulleted),
tooltip: L10n.of(context).editor_list,
),
IconButton(
onPressed: () => controller.surround(
before: '`',
placeholder: L10n.of(context).insert_text_here_placeholder,
),
icon: const Icon(Icons.code),
tooltip: L10n.of(context).editor_code,
),
IconButton(
onPressed: () => controller.surround(
before: '~',
placeholder: L10n.of(context).insert_text_here_placeholder,
),
icon: const Icon(Icons.subscript),
tooltip: L10n.of(context).editor_subscript,
),
IconButton(
onPressed: () => controller.surround(
before: '^',
placeholder: L10n.of(context).insert_text_here_placeholder,
),
icon: const Icon(Icons.superscript),
tooltip: L10n.of(context).editor_superscript,
),
//spoiler
IconButton(
onPressed: () {
controller.reformat((selection) {
const textBeg = '\n::: spoiler spoiler\n';
final textMid = selection.isNotEmpty ? selection : '___';
const textEnd = '\n:::\n';
return _Reformat(
text: textBeg + textMid + textEnd,
selectionBeginningShift: textBeg.length,
selectionEndingShift:
textBeg.length + textMid.length - selection.length,
);
});
},
icon: const Icon(Icons.warning),
tooltip: L10n.of(context).editor_spoiler,
),
IconButton(
onPressed: () {
launchLink(link: markdownGuide, context: context);
},
icon: const Icon(Icons.question_mark),
tooltip: L10n.of(context).editor_help,
),
],
);
}
}
class AddLinkDialog extends HookWidget {
final String label;
final String url;
final String selection;
static final _websiteRegex = RegExp(r'https?:\/\/', caseSensitive: false);
AddLinkDialog(this.selection)
: label = selection.startsWith(_websiteRegex) ? '' : selection,
url = selection.startsWith(_websiteRegex) ? selection : '';
@override
Widget build(BuildContext context) {
final labelController = useTextEditingController(text: label);
final urlController = useTextEditingController(text: url);
void submit() {
final link = () {
if (urlController.text.startsWith(RegExp('https?://'))) {
return urlController.text;
} else {
return 'https://${urlController.text}';
}
}();
final finalString = '[${labelController.text}]($link)';
Navigator.of(context).pop(_Reformat(
text: finalString,
selectionBeginningShift: finalString.length,
selectionEndingShift: finalString.length - selection.length,
));
}
return AlertDialog(
title: Text(L10n.of(context).add_link),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: labelController,
decoration: InputDecoration(
hintText: L10n.of(context).editor_add_link_label),
textInputAction: TextInputAction.next,
autofocus: true,
),
TextField(
controller: urlController,
decoration: const InputDecoration(hintText: 'https://example.com'),
onEditingComplete: submit,
autocorrect: false,
),
].spaced(10),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(L10n.of(context).cancel)),
ElevatedButton(
onPressed: submit,
child: Text(L10n.of(context).add_link),
)
],
);
}
static Future<_Reformat?> show(BuildContext context, String selection) async {
return showDialog(
context: context,
builder: (context) => AddLinkDialog(selection),
);
}
}
extension on TextEditingController {
String get selectionText =>
text.substring(selection.baseOffset, selection.extentOffset);
String get beforeSelectionText => text.substring(0, selection.baseOffset);
String get afterSelectionText => text.substring(selection.extentOffset);
/// surrounds selection with given strings. If nothing is selected, placeholder is used in the middle
void surround({
required String before,
required String placeholder,
/// after = before if null
String? after,
}) {
after ??= before;
final beg = text.substring(0, selection.baseOffset);
final mid = () {
final m = text.substring(selection.baseOffset, selection.extentOffset);
if (m.isEmpty) return placeholder;
return m;
}();
final end = text.substring(selection.extentOffset);
value = value.copyWith(
text: '$beg$before$mid$after$end',
selection: selection.copyWith(
baseOffset: selection.baseOffset + before.length,
extentOffset: selection.baseOffset + before.length + mid.length,
));
}
String get firstSelectedLine {
if (text.isEmpty) {
return '';
}
final val = text.substring(text.getBeginningOfTheLine(selection.start - 1),
text.getEndOfTheLine(selection.end) - 1);
return val;
}
void insertAtBeginningOfFirstSelectedLine(String s) {
final lines = TextLinesIterator.fromController(this)..moveNext();
lines.current = s + lines.current;
value = value.copyWith(
text: lines.text,
selection: selection.copyWith(
baseOffset: selection.baseOffset + s.length,
extentOffset: selection.extentOffset + s.length,
),
);
}
void removeAtBeginningOfEverySelectedLine(String s) {
final lines = TextLinesIterator.fromController(this);
var linesCount = 0;
while (lines.moveNext()) {
if (lines.isWithinSelection) {
if (lines.current.startsWith(s)) {
lines.current = lines.current.substring(s.length);
linesCount++;
}
}
}
value = value.copyWith(
text: lines.text,
selection: selection.copyWith(
baseOffset: selection.baseOffset - s.length,
extentOffset: selection.extentOffset - s.length * linesCount,
),
);
}
void insertAtBeginningOfEverySelectedLine(String s) {
final lines = TextLinesIterator.fromController(this);
var linesCount = 0;
while (lines.moveNext()) {
if (lines.isWithinSelection) {
if (!lines.current.startsWith(s)) {
lines.current = '$s${lines.current}';
linesCount++;
}
}
}
value = value.copyWith(
text: lines.text,
selection: selection.copyWith(
baseOffset: selection.baseOffset + s.length,
extentOffset: selection.extentOffset + s.length * linesCount,
),
);
}
void reformat(_Reformat Function(String selection) reformatter) {
final beg = beforeSelectionText;
final mid = selectionText;
final end = afterSelectionText;
final r = reformatter(mid);
value = value.copyWith(
text: '$beg${r.text}$end',
selection: selection.copyWith(
baseOffset: selection.baseOffset + r.selectionBeginningShift,
extentOffset: selection.extentOffset + r.selectionEndingShift,
),
);
}
void reformatSimple(String text) =>
reformat((selection) => _Reformat(text: text));
}

View File

@ -0,0 +1,63 @@
import 'package:lemmy_api_client/pictrs.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:mobx/mobx.dart';
import '../../util/async_store.dart';
import '../../util/pictrs.dart';
part 'editor_toolbar_store.g.dart';
class EditorToolbarStore = _EditorToolbarStore with _$EditorToolbarStore;
abstract class _EditorToolbarStore with Store {
final String instanceHost;
_EditorToolbarStore(this.instanceHost);
@observable
String? url;
final imageUploadState = AsyncStore<PictrsUploadFile>();
@computed
bool get hasUploadedImage => imageUploadState.map(
loading: () => false,
error: (_) => false,
data: (_) => true,
);
@action
Future<String?> uploadImage(String filePath, Jwt token) async {
final instanceHost = this.instanceHost;
final upload = await imageUploadState.run(
() => PictrsApi(instanceHost)
.upload(
filePath: filePath,
auth: token.raw,
)
.then((value) => value.files.single),
);
if (upload != null) {
final url = pathToPictrs(instanceHost, upload.file);
return url;
}
return null;
}
@action
void removeImage() {
final pictrsFile = imageUploadState.map<PictrsUploadFile?>(
data: (data) => data,
loading: () => null,
error: (_) => null,
);
if (pictrsFile == null) return;
PictrsApi(instanceHost).delete(pictrsFile).catchError((_) {});
imageUploadState.reset();
url = '';
}
}

View File

@ -0,0 +1,66 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'editor_toolbar_store.dart';
// **************************************************************************
// StoreGenerator
// **************************************************************************
// 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
mixin _$EditorToolbarStore on _EditorToolbarStore, Store {
Computed<bool>? _$hasUploadedImageComputed;
@override
bool get hasUploadedImage => (_$hasUploadedImageComputed ??= Computed<bool>(
() => super.hasUploadedImage,
name: '_EditorToolbarStore.hasUploadedImage'))
.value;
late final _$urlAtom =
Atom(name: '_EditorToolbarStore.url', context: context);
@override
String? get url {
_$urlAtom.reportRead();
return super.url;
}
@override
set url(String? value) {
_$urlAtom.reportWrite(value, super.url, () {
super.url = value;
});
}
late final _$uploadImageAsyncAction =
AsyncAction('_EditorToolbarStore.uploadImage', context: context);
@override
Future<String?> uploadImage(String filePath, Jwt token) {
return _$uploadImageAsyncAction
.run(() => super.uploadImage(filePath, token));
}
late final _$_EditorToolbarStoreActionController =
ActionController(name: '_EditorToolbarStore', context: context);
@override
void removeImage() {
final _$actionInfo = _$_EditorToolbarStoreActionController.startAction(
name: '_EditorToolbarStore.removeImage');
try {
return super.removeImage();
} finally {
_$_EditorToolbarStoreActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''
url: ${url},
hasUploadedImage: ${hasUploadedImage}
''';
}
}

View File

@ -9,10 +9,10 @@ class FullscreenableImage extends StatelessWidget {
final Widget child;
const FullscreenableImage({
Key? key,
super.key,
required this.url,
required this.child,
}) : super(key: key);
});
@override
Widget build(BuildContext context) => InkWell(

View File

@ -17,6 +17,8 @@ class InfiniteScrollController {
}
}
typedef Fetcher<T> = Future<List<T>> Function(int page, int batchSize);
/// `ListView.builder` with asynchronous data fetching and no `itemCount`
class InfiniteScroll<T> extends HookWidget {
/// How many items should be fetched per call
@ -31,7 +33,7 @@ class InfiniteScroll<T> extends HookWidget {
/// Fetches data to be displayed. It is important to respect `batchSize`,
/// if the returned list has less than `batchSize` then the InfiniteScroll
/// is considered finished
final Future<List<T>> Function(int page, int batchSize) fetcher;
final Fetcher<T> fetcher;
final InfiniteScrollController? controller;

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../../l10n/l10n.dart';
import '../../pages/community/community.dart';
import '../../pages/instance/instance.dart';
import '../../util/extensions/api.dart';
import '../../util/goto.dart';
import '../../util/observer_consumers.dart';
@ -67,9 +68,10 @@ class PostInfoSection extends StatelessWidget {
text: post.post.originInstanceHost,
style: const TextStyle(fontWeight: FontWeight.w600),
recognizer: TapGestureRecognizer()
..onTap = () => goToInstance(
context,
post.post.originInstanceHost,
..onTap = () => Navigator.of(context).push(
InstancePage.route(
post.post.originInstanceHost,
),
),
),
],

View File

@ -1,11 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:url_launcher/url_launcher.dart' as ul;
import '../../hooks/logged_in_action.dart';
import '../../pages/create_post.dart';
import '../../pages/create_post/create_post.dart';
import '../../pages/full_post/full_post_store.dart';
import '../../stores/accounts_store.dart';
import '../../url_launcher.dart';
import '../../util/icons.dart';
import '../../util/observer_consumers.dart';
import '../bottom_modal.dart';
@ -72,10 +72,7 @@ class PostMoreMenu extends HookWidget {
ListTile(
leading: const Icon(Icons.open_in_browser),
title: const Text('Open in browser'),
onTap: () async => await ul.canLaunch(post.post.apId)
? ul.launch(post.post.apId)
: ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("can't open in browser"))),
onTap: () => launchLink(link: post.post.apId, context: context),
),
if (isMine)
ListTile(

View File

@ -6,7 +6,7 @@ part of 'post_store.dart';
// StoreGenerator
// **************************************************************************
// 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
// 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
mixin _$PostStore on _PostStore, Store {
Computed<String?>? _$urlDomainComputed;
@ -23,7 +23,8 @@ mixin _$PostStore on _PostStore, Store {
Computed<bool>(() => super.hasMedia, name: '_PostStore.hasMedia'))
.value;
final _$postViewAtom = Atom(name: '_PostStore.postView');
late final _$postViewAtom =
Atom(name: '_PostStore.postView', context: context);
@override
PostView get postView {
@ -38,35 +39,40 @@ mixin _$PostStore on _PostStore, Store {
});
}
final _$saveAsyncAction = AsyncAction('_PostStore.save');
late final _$saveAsyncAction =
AsyncAction('_PostStore.save', context: context);
@override
Future<void> save(Jwt token) {
return _$saveAsyncAction.run(() => super.save(token));
}
final _$reportAsyncAction = AsyncAction('_PostStore.report');
late final _$reportAsyncAction =
AsyncAction('_PostStore.report', context: context);
@override
Future<void> report(Jwt token, String reason) {
return _$reportAsyncAction.run(() => super.report(token, reason));
}
final _$blockUserAsyncAction = AsyncAction('_PostStore.blockUser');
late final _$blockUserAsyncAction =
AsyncAction('_PostStore.blockUser', context: context);
@override
Future<void> blockUser(Jwt token) {
return _$blockUserAsyncAction.run(() => super.blockUser(token));
}
final _$_voteAsyncAction = AsyncAction('_PostStore._vote');
late final _$_voteAsyncAction =
AsyncAction('_PostStore._vote', context: context);
@override
Future<void> _vote(Jwt token, VoteType voteType) {
return _$_voteAsyncAction.run(() => super._vote(token, voteType));
}
final _$_PostStoreActionController = ActionController(name: '_PostStore');
late final _$_PostStoreActionController =
ActionController(name: '_PostStore', context: context);
@override
void updatePostView(PostView postView) {

View File

@ -6,10 +6,10 @@ class PullToRefresh extends StatelessWidget {
final Widget child;
const PullToRefresh({
Key? key,
super.key,
required this.onRefresh,
required this.child,
}) : super(key: key);
});
@override
Widget build(BuildContext context) {

View File

@ -20,7 +20,7 @@ class RadioPicker<T> extends StatelessWidget {
final Widget? trailing;
const RadioPicker({
Key? key,
super.key,
required this.values,
required this.groupValue,
required this.onChanged,
@ -28,7 +28,7 @@ class RadioPicker<T> extends StatelessWidget {
this.buttonBuilder,
this.title,
this.trailing,
}) : super(key: key);
});
@override
Widget build(BuildContext context) {

View File

@ -70,8 +70,8 @@ class SortableInfiniteList<T> extends HookWidget {
class InfinitePostList extends SortableInfiniteList<PostView> {
InfinitePostList({
required FetcherWithSorting<PostView> fetcher,
InfiniteScrollController? controller,
required super.fetcher,
super.controller,
}) : super(
itemBuilder: (post) => Column(
children: [
@ -79,8 +79,6 @@ class InfinitePostList extends SortableInfiniteList<PostView> {
const SizedBox(height: 20),
],
),
fetcher: fetcher,
controller: controller,
noItems: const Text('there are no posts'),
uniqueProp: (item) => item.post.apId,
);
@ -88,15 +86,13 @@ class InfinitePostList extends SortableInfiniteList<PostView> {
class InfiniteCommentList extends SortableInfiniteList<CommentView> {
InfiniteCommentList({
required FetcherWithSorting<CommentView> fetcher,
InfiniteScrollController? controller,
required super.fetcher,
super.controller,
}) : super(
itemBuilder: (comment) => CommentWidget(
CommentTree(comment),
detached: true,
),
fetcher: fetcher,
controller: controller,
noItems: const Text('there are no comments'),
uniqueProp: (item) => item.comment.apId,
);

View File

@ -14,14 +14,14 @@ class TileAction extends StatelessWidget {
final Color? iconColor;
const TileAction({
Key? key,
super.key,
this.delayedLoading,
this.iconColor,
required this.icon,
required this.onPressed,
required this.tooltip,
this.loading = false,
}) : super(key: key);
});
@override
Widget build(BuildContext context) => IconButton(

Some files were not shown because too many files have changed in this diff Show More