diff --git a/.travis.yml b/.travis.yml index b9665b6a..78f6cd3d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,13 @@ language: dart +dart: + - 2.18.0 dist: xenial addons: apt: packages: - lib32stdc++6 install: - - git clone --depth 1 --branch 2.10.2 https://github.com/flutter/flutter.git + - git clone --depth 1 --branch 3.0.5 https://github.com/flutter/flutter.git - ./flutter/bin/flutter doctor - gem install coveralls-lcov - cp .env.example .env diff --git a/ABOUT.md b/ABOUT.md index 2bcd95d8..fd8dedc0 100644 --- a/ABOUT.md +++ b/ABOUT.md @@ -5,20 +5,14 @@ Fyx je uvolněn jako open-source a veškeré zdrojové kódy jsou přístupné n Diskutovat o Fyxu můžete na [Nyxu zde](https://www.nyx.cz/index.php?l=topic;id=24237;n=ecd5). ## Autor -ID [LUCIEN](https://www.nyx.cz/index.php?l=user;id=LUCIEN;n=dac1;f=%3Fl%3Dtopic%3Bl2%3D1%3Bid%3D24237). Členem Nyxu jsem už pěkných 18 let. Mám rád kafe, Sydney a svojí rodinu. Nemám rád zbytečný meetingy a nasraný lidi. - -Další moje projekty: - -* [144.wtf](https://144.wtf) - osobní web -* [artkina.cz](https://artkina.cz) - aplikace pro milovníky artových kin -* [devnull.store](https://devnull.store) - obchod s oblečením pro programátory -* [thebuttongame.io](https://www.thebuttongame.io/) - nejintenzivnější hra pro mobil co jste kdy hráli +ID [LUCIEN](https://www.nyx.cz/index.php?l=user;id=LUCIEN;n=dac1;f=%3Fl%3Dtopic%3Bl2%3D1%3Bid%3D24237). Členem Nyxu jsem už pěkných 20 let. Mám rád kafe, Sydney a svojí rodinu. Nemám rád zbytečný meetingy a nasraný lidi. Více o mě na [144.wtf](https://144.wtf). ## Poděkování Rád bych zde poděkoval těm, kteří se nějakým způsobem zasloužili o rozvoj Fyxu: -* ID LOJZA * ID KULHY +* ID KEJML +* ID LOJZA * ID KOC256 A dál všem co poctivě reportují chyby nebo přichází s konstruktivním feedbackem. diff --git a/BACKERS.md b/BACKERS.md index c523aef0..dac965e1 100644 --- a/BACKERS.md +++ b/BACKERS.md @@ -73,6 +73,7 @@ Takže milí podporovatelé, díky! ❤️ - ID **WOJTISHEK** 🍦 - ID **WOTAR** ☕️ - ID **XLACHTAN** ☕️ +- - ID **KEVIN00** ☕️ Dárci pod $1 jsou uvedeni na nástěnce klubu na Nyxu. diff --git a/CHANGELOG.md b/CHANGELOG.md index c3130434..3ed60d72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,31 @@ Tento soubor vychází z [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), verzování z [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.8.1] - 2022/02/19 +## [0.8.3] - 2022/05/03 + +### Nové +- Zobrazování palečků #284 +- Podpora házení kostkou #301 [KEJML] +- Podpora PRE a XMP tagu #275 #281 + +### Změněno +- Upgrade Flutteru a knihoven #303 [TRAGIKOMIX] +- Psaní příspěvků nabízí anglické popisky pro práci s textem #205 #299 [KEJML] +- Historie zobrazuje poslední klub jako nepřečtený #231 #298 [KEJML] +- Odlišení, jestli je anketa veřejná, nebo ne #288 [KEJML] +- U vypisu hlasu by bylo uzitecne videt kolik je pozitivnich hlasu #292 [KEJML] +- Google Play issue #310 + +### Opraveno +- Nepodporovaný typ příspěvku "log_message" #300 [TRAGIKOMIX] +- Android notifikace nemají správnou ikonu #203 [KEJML] +- Otazník místo názvu jedné diskuze #312 +- Nelze stáhnout obrázek #304 +- První načtení diskuze je občas trhané #311 + +**Kompletní changelog**: https://github.com/lucien144/fyx/compare/v0.8.2...v0.8.3 + +## [0.8.2] - 2022/02/19 ### Opraveno - Fyx nenačítá diskuze #295 diff --git a/README.md b/README.md index d4124063..0627de65 100644 --- a/README.md +++ b/README.md @@ -21,38 +21,41 @@ Fyx je neoficiální mobilní klient (Android a iOS) pro diskuzní server [Nyx.c ## Funkce -Fyx nabízí oproti [oficiálnímu klientovi](https://apps.apple.com/cz/app/nyx/id920743962) několik výhod, ale v něčem také ztrácí. -Zde je přehled funkcí pro lepší představu. +Fyx nabízí oproti [oficiálnímu klientovi](https://apps.apple.com/cz/app/nyx/id920743962) několik výhod: | Funkce | Fyx | Nyx | |-|:-:|:-:| | iOS | ✅ | ✅ | | Android | ✅ | ❌ | +| Galerie více obrázků | ✅ | ❌ | +| Videa v příspěvku | ✅ | ❌ | +| Spoilery | ✅ | ❌ | +| Ankety | ✅ | ❌ | +| Zobrazování videí | ✅ | ❌ | +| Skiny (Forest, ...) | ✅ | ❌ | +| Nastavení velikosti písma | ✅ | ❌ | +| Odskok k prvnímu nepřečtenému | ✅ | ❌ | +| iPad podpora | ✅ | ❌ | +| Kompaktní mód příspěvku | ✅ | ❌ | | Notifikace | ✅ | ✅ | | Výpis klubů | ✅ | ✅ | | Historie | ✅ | ✅ | | Filtr přečtených klubů/historie | ✅ | ✅ | -| Nástěnka / záhlaví klubu | ❌ | ✅ | -| Ukládání do sledovaných | ❌ | ✅ | +| Nástěnka / záhlaví klubu | ✅ | ✅ | +| Ukládání do sledovaných | ✅ | ✅ | | Psaní příspěvků | ✅ | ✅ | | Mazání příspěvků | ✅ | ✅ | -| Kompaktní mód příspěvku | ✅ | ❌ | | Nahrávání obrázků | ✅ | ✅ | -| Galerie více obrázků | ✅ | ❌ | | Ukládání obrázků | ✅ | ✅ | | Palečkování | ✅ | ✅ | | Uložení do upomínek | ✅ | ✅ | -| Videa v příspěvku | ✅ | ❌ | -| Spoilery | ✅ | ❌ | -| Zobrazování anket | ✅ | ✅ | -| Zobrazování zdrojáků | ✅ | ❌ | -| Zobrazování videí | ✅ | ❌ | +| Zobrazování zdrojáků | ✅ | ✅ | | Dark mode | ✅ | ✅ | | Pošta | ✅ | ✅ | -| Hledání | ❌ | ✅ | -| Tržiště | ❌ | ✅ | +| Hledání | ✅ | ✅ | +| Tržiště | ✅ | ✅ | | Upozornění | ✅ | ✅ | -| Landscape zobrazení | ❌ | ✅ | +| Landscape zobrazení | ✅ | ✅ | ## Roadmap @@ -82,13 +85,44 @@ Pokud jste našli chybu, pak ji nahlaste ideálně přes aplikaci. Pokud to nejd ## FAQ -- **Chybí mi možnost odskoku na nejbližší nepřečtený příspěvěk. Bude?** - - Ano, bude. - - **Proč je tento repozitář v češtině?** Vzhledem k tomu, že [klub na Nyxu](https://www.nyx.cz/index.php?l=topic;id=24237;n=23dd) věnující se novému klientovi vznikl v češtině, rozhodl jsem se (Lucien) vést tento repozitář také v češtině. Naproti tomu kód a komentáře v kódu jsou v angličtině, protože to je pro mě přiřozené. Dále by měly [Issues](https://github.com/lucien144/fyx/issues) sloužit jako centrální hub pro vedení veškerých chyb a připomínek, což se mi zdá opět lepší vést v češtině pro běžné uživatele. Nicméně, změně na kompletně anglické repo se po diskuzi nebráním... -## Náhled -![https://imgur.com/U00Oghi](https://imgur.com/U00Oghi.gif) +## Náhledy obrazovek a funkcí + +
Průchod aplikací + +
+ +
Odskok na první nepřečtený + +
+ +
Forest skin, nastavení písma + +
+ +
Hromadné akce (mazání, ...) + +
+ +
Book, unbook, nástěnka, hledání v diskuzi + +
+ +
Hledání klubů + +
+ +
Filtrování v historii + +
+ +
Spoilery + +
+ +
iPad verze + +
\ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index be5b94e1..0afe6c7e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -34,7 +34,7 @@ if (keystorePropertiesFile.exists()) { android { compileSdkVersion 31 - android.ndkVersion "20.1.5948944" + android.ndkVersion "21.4.7075529" sourceSets { main.java.srcDirs += 'src/main/kotlin' } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 046d7490..b53abbaa 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/inter_font/Inter-Black.ttf b/assets/inter_font/Inter-Black.ttf new file mode 100755 index 00000000..6bb38b6f Binary files /dev/null and b/assets/inter_font/Inter-Black.ttf differ diff --git a/assets/inter_font/Inter-Bold.ttf b/assets/inter_font/Inter-Bold.ttf new file mode 100755 index 00000000..90623289 Binary files /dev/null and b/assets/inter_font/Inter-Bold.ttf differ diff --git a/assets/inter_font/Inter-ExtraBold.ttf b/assets/inter_font/Inter-ExtraBold.ttf new file mode 100755 index 00000000..c746904e Binary files /dev/null and b/assets/inter_font/Inter-ExtraBold.ttf differ diff --git a/assets/inter_font/Inter-ExtraLight.ttf b/assets/inter_font/Inter-ExtraLight.ttf new file mode 100755 index 00000000..1c92afea Binary files /dev/null and b/assets/inter_font/Inter-ExtraLight.ttf differ diff --git a/assets/inter_font/Inter-Light.ttf b/assets/inter_font/Inter-Light.ttf new file mode 100755 index 00000000..3e1cd324 Binary files /dev/null and b/assets/inter_font/Inter-Light.ttf differ diff --git a/assets/inter_font/Inter-Medium.ttf b/assets/inter_font/Inter-Medium.ttf new file mode 100755 index 00000000..49b53ab3 Binary files /dev/null and b/assets/inter_font/Inter-Medium.ttf differ diff --git a/assets/inter_font/Inter-Regular.ttf b/assets/inter_font/Inter-Regular.ttf new file mode 100755 index 00000000..f17b596c Binary files /dev/null and b/assets/inter_font/Inter-Regular.ttf differ diff --git a/assets/inter_font/Inter-SemiBold.ttf b/assets/inter_font/Inter-SemiBold.ttf new file mode 100755 index 00000000..01523b22 Binary files /dev/null and b/assets/inter_font/Inter-SemiBold.ttf differ diff --git a/assets/inter_font/Inter-Thin.ttf b/assets/inter_font/Inter-Thin.ttf new file mode 100755 index 00000000..089857e4 Binary files /dev/null and b/assets/inter_font/Inter-Thin.ttf differ diff --git a/build.sh b/build.sh index 6e5026a7..1d01ae81 100755 --- a/build.sh +++ b/build.sh @@ -72,6 +72,7 @@ if [ $android == true ]; then fi flutter build appbundle -t lib/main_production.dart + mv build/app/outputs/bundle/release/app-release.aab "build/app/outputs/bundle/release/fyx-release-${version}.aab" open build/app/outputs/bundle/release/ /usr/bin/osascript -e "display notification \"Android built.\"" fi diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 00000000..7b53621f --- /dev/null +++ b/cli/README.md @@ -0,0 +1,13 @@ +# Fyx cli commands + +## `dart run cli` + +Exports all icons from all widgets into the HTML file for inspection. + +```shell +$ cd cli/icons/ +$ dart run +``` + +Exported file is in `cli/icons/dist/index.html`. + diff --git a/cli/icons/.gitignore b/cli/icons/.gitignore new file mode 100644 index 00000000..087e2eb8 --- /dev/null +++ b/cli/icons/.gitignore @@ -0,0 +1,7 @@ +# Files and directories created by pub. +.dart_tool/ +.packages + +# Conventional directory for build output. +dist/* +dist/!.gitkeep diff --git a/cli/icons/analysis_options.yaml b/cli/icons/analysis_options.yaml new file mode 100644 index 00000000..18b40b8d --- /dev/null +++ b/cli/icons/analysis_options.yaml @@ -0,0 +1,16 @@ +# Defines a default set of lint rules enforced for projects at Google. For +# details and rationale, see +# https://github.com/dart-lang/pedantic#enabled-lints. + +include: package:pedantic/analysis_options.yaml + +# For lint rules and documentation, see http://dart-lang.github.io/linter/lints. + +# Uncomment to specify additional rules. +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** diff --git a/cli/icons/bin/icons.dart b/cli/icons/bin/icons.dart new file mode 100644 index 00000000..169395f0 --- /dev/null +++ b/cli/icons/bin/icons.dart @@ -0,0 +1,34 @@ +import 'dart:io'; + +import 'package:html/parser.dart'; + +void main(List arguments) async { + var dir = Directory('../../lib/'); + var index = File('template.html'); + var document = parse(index.readAsStringSync()); + var icons = document.querySelector('#icons'); + icons?.innerHtml = ''; + + await dir.list(recursive: true).forEach((file) { + if (file.statSync().type == FileSystemEntityType.file) { + var content = File(file.path).readAsStringSync(); + var rx = RegExp( + r'Icons\.([a-z0-9_]+)', + caseSensitive: true, + multiLine: true, + ); + var matches = rx.allMatches(content); + if (matches.isNotEmpty) { + icons?.append(parseFragment('

${file.path}

')); + icons?.append(parseFragment(matches.map((match) { + var iconId = match.group(1); + iconId = iconId?.replaceAll(RegExp(r'(_outlined|_rounded|_thick)'), ''); + return '' + '$iconId$iconId'; + }).join(''))); + } + } + }); + + File('dist/index.html').writeAsStringSync(document.outerHtml); +} diff --git a/cli/icons/pubspec.yaml b/cli/icons/pubspec.yaml new file mode 100644 index 00000000..51b0fecb --- /dev/null +++ b/cli/icons/pubspec.yaml @@ -0,0 +1,12 @@ +name: icons +description: Export all icons from all Widgets in `lib` library to a HTML file. +version: 1.0.0 + +environment: + sdk: '>=2.12.0 <3.0.0' + +dependencies: + html: ^0.15.0 + +dev_dependencies: + pedantic: ^1.10.0 diff --git a/cli/icons/template.html b/cli/icons/template.html new file mode 100644 index 00000000..a819bcf9 --- /dev/null +++ b/cli/icons/template.html @@ -0,0 +1,24 @@ + + A Basic HTML5 Template + + + + + + + + + + + + +
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/ios/Podfile.lock b/ios/Podfile.lock index a17c8ad6..77cd7afe 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -90,28 +90,28 @@ PODS: - GoogleUtilities/Network (~> 7.6) - "GoogleUtilities/NSData+zlib (~> 7.6)" - nanopb (~> 2.30908.0) - - GoogleDataTransport (9.1.2): - - GoogleUtilities/Environment (~> 7.2) - - nanopb (~> 2.30908.0) + - GoogleDataTransport (9.2.0): + - GoogleUtilities/Environment (~> 7.7) + - nanopb (< 2.30910.0, >= 2.30908.0) - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/AppDelegateSwizzler (7.7.0): + - GoogleUtilities/AppDelegateSwizzler (7.8.0): - GoogleUtilities/Environment - GoogleUtilities/Logger - GoogleUtilities/Network - - GoogleUtilities/Environment (7.7.0): + - GoogleUtilities/Environment (7.8.0): - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/Logger (7.7.0): + - GoogleUtilities/Logger (7.8.0): - GoogleUtilities/Environment - - GoogleUtilities/MethodSwizzler (7.7.0): + - GoogleUtilities/MethodSwizzler (7.8.0): - GoogleUtilities/Logger - - GoogleUtilities/Network (7.7.0): + - GoogleUtilities/Network (7.8.0): - GoogleUtilities/Logger - "GoogleUtilities/NSData+zlib" - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (7.7.0)" - - GoogleUtilities/Reachability (7.7.0): + - "GoogleUtilities/NSData+zlib (7.8.0)" + - GoogleUtilities/Reachability (7.8.0): - GoogleUtilities/Logger - - GoogleUtilities/UserDefaults (7.7.0): + - GoogleUtilities/UserDefaults (7.8.0): - GoogleUtilities/Logger - image_gallery_saver (1.5.0): - Flutter @@ -130,14 +130,14 @@ PODS: - Flutter - permission_handler_apple (9.0.4): - Flutter - - PromisesObjC (2.1.0) - - Sentry (7.9.0): - - Sentry/Core (= 7.9.0) - - Sentry/Core (7.9.0) + - PromisesObjC (2.1.1) + - Sentry (7.23.0): + - Sentry/Core (= 7.23.0) + - Sentry/Core (7.23.0) - sentry_flutter (0.0.1): - Flutter - FlutterMacOS - - Sentry (~> 7.9.0) + - Sentry (~> 7.23.0) - share (0.0.1): - Flutter - shared_preferences_ios (0.0.1): @@ -251,8 +251,8 @@ SPEC CHECKSUMS: fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037 FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a GoogleAppMeasurement: 837649ad3987936c232f6717c5680216f6243d24 - GoogleDataTransport: 629c20a4d363167143f30ea78320d5a7eb8bd940 - GoogleUtilities: e0913149f6b0625b553d70dae12b49fc62914fd1 + GoogleDataTransport: 1c8145da7117bd68bbbed00cf304edb6a24de00f + GoogleUtilities: 1d20a6ad97ef46f67bbdec158ce00563a671ebb7 image_gallery_saver: 259eab68fb271cfd57d599904f7acdc7832e7ef2 image_picker_ios: b786a5dcf033a8336a657191401bfdf12017dabb nanopb: a0ba3315591a9ae0a16a309ee504766e90db0c96 @@ -260,9 +260,9 @@ SPEC CHECKSUMS: package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5 permission_handler_apple: 44366e37eaf29454a1e7b1b7d736c2cceaeb17ce - PromisesObjC: 99b6f43f9e1044bd87a95a60beff28c2c44ddb72 - Sentry: 2f7e91f247cfb05b05bd01e0b5d0692557a7687b - sentry_flutter: 7c3cb050dc23563a4ea5db438c83afdb460a2ae6 + PromisesObjC: ab77feca74fa2823e7af4249b8326368e61014cb + Sentry: a0d4563fa4ddacba31fdcc35daaa8573d87224d6 + sentry_flutter: 8bde7d0e57a721727fe573f13bb292c497b5a249 share: 0b2c3e82132f5888bccca3351c504d0003b3b410 shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 @@ -274,4 +274,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 54b56b37ef04a402b4e20983b360f8d98c1d2f96 -COCOAPODS: 1.11.2 +COCOAPODS: 1.11.3 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 821fde77..b1c5ea38 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -429,10 +429,7 @@ ); INFOPLIST_FILE = Runner/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 10.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", @@ -442,7 +439,7 @@ PRODUCT_NAME = Runner; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 4.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Profile; @@ -568,10 +565,7 @@ ); INFOPLIST_FILE = Runner/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 10.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", @@ -582,7 +576,7 @@ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 4.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; @@ -603,10 +597,7 @@ ); INFOPLIST_FILE = Runner/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 10.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", @@ -616,7 +607,7 @@ PRODUCT_NAME = Runner; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 4.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index 73d3b7f6..401cde4e 100755 --- a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1 +1,134 @@ -{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"}]} \ No newline at end of file +{ + "images" : [ + { + "filename" : "40.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "60.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "filename" : "29.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "58.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "87.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "80.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "120.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "filename" : "57.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "57x57" + }, + { + "filename" : "114.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "57x57" + }, + { + "filename" : "120.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "180.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "filename" : "Icon-App-20x20@1x.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "filename" : "Icon-App-20x20@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "Icon-App-29x29@1x.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "Icon-App-29x29@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "Icon-App-40x40@1x.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "filename" : "Icon-App-40x40@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "Icon-App-76x76@1x.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "filename" : "Icon-App-76x76@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "filename" : "Icon-App-83.5x83.5@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "filename" : "1024.png", + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index 1e12c051..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index d1809cce..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index 78743463..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index 3185905c..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index 3185905c..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index 5dd5fc7a..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 3425ddb7..ffda4887 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -20,6 +20,8 @@ ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) + K nahrání nebo uložení obrázku je potřeba povolit přístup k fotkám. + Light LSRequiresIPhoneOS NSCameraUsageDescription @@ -28,8 +30,11 @@ K nahrání videa se zvukem je potřeba povolit přístup k mikrofónu. NSPhotoLibraryUsageDescription K nahrání nebo uložení obrázku je potřeba povolit přístup k fotkám. - K nahrání nebo uložení obrázku je potřeba povolit přístup k fotkám. - Light + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UIBackgroundModes fetch @@ -42,6 +47,8 @@ UISupportedInterfaceOrientations UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad @@ -52,5 +59,7 @@ UIViewControllerBasedStatusBarAppearance + CADisableMinimumFrameDurationOnPhone + diff --git a/lib/FyxApp.dart b/lib/FyxApp.dart index 8bbb93b9..1005b0ad 100644 --- a/lib/FyxApp.dart +++ b/lib/FyxApp.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:fyx/SkinnedApp.dart'; import 'package:fyx/controllers/AnalyticsProvider.dart'; @@ -22,14 +21,18 @@ import 'package:fyx/pages/InfoPage.dart'; import 'package:fyx/pages/LoginPage.dart'; import 'package:fyx/pages/NewMessagePage.dart'; import 'package:fyx/pages/NoticesPage.dart'; -import 'package:fyx/pages/SettingsPage.dart'; import 'package:fyx/pages/TutorialPage.dart'; +import 'package:fyx/pages/discussion_home_page.dart'; +import 'package:fyx/pages/settings_design_screen.dart'; +import 'package:fyx/pages/settings_screen.dart'; import 'package:fyx/theme/T.dart'; import 'package:fyx/theme/skin/Skin.dart'; +import 'package:fyx/theme/skin/skins/ForestSkin.dart'; import 'package:fyx/theme/skin/skins/FyxSkin.dart'; import 'package:package_info/package_info.dart'; import 'package:provider/provider.dart'; import 'package:sentry/sentry.dart'; +import 'package:tap_canvas/tap_canvas.dart'; import 'controllers/NotificationsService.dart'; @@ -90,7 +93,7 @@ class FyxApp extends StatefulWidget { } SystemUiOverlayStyle(statusBarBrightness: Brightness.light); - SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]); + SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp, DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]); // TODO: Move to build using FutureBuilder. var results = await Future.wait([ApiController().getCredentials(), PackageInfo.fromPlatform(), DeviceInfo.init(), SettingsProvider().init()]); @@ -139,6 +142,12 @@ class FyxApp extends StatefulWidget { case '/discussion': print('[Router] Discussion'); return CupertinoPageRoute(builder: (_) => DiscussionPage(), settings: settings); + case '/discussion/home': + print('[Router] Discussion home'); + return CupertinoPageRoute(builder: (_) => DiscussionHomePage(), settings: settings); + case '/discussion/header': + print('[Router] Discussion home'); + return CupertinoPageRoute(builder: (_) => DiscussionHomePage(header: true), settings: settings); case '/new-message': print('[Router] New Message'); return CupertinoPageRoute(builder: (_) => NewMessagePage(), settings: settings, fullscreenDialog: true); @@ -152,7 +161,10 @@ class FyxApp extends StatefulWidget { fullscreenDialog: true); case '/settings': print('[Router] Settings'); - return CupertinoPageRoute(builder: (_) => SettingsPage(), settings: settings); + return CupertinoPageRoute(builder: (_) => SettingsScreen(), settings: settings); + case '/settings/design': + print('[Router] Settings'); + return CupertinoPageRoute(builder: (_) => SettingsDesignScreen(), settings: settings); case '/settings/info': print('[Router] Settings / info'); return CupertinoPageRoute(builder: (_) => InfoPage(), settings: settings); @@ -175,29 +187,25 @@ class _FyxAppState extends State with WidgetsBindingObserver { @override void initState() { super.initState(); - WidgetsBinding.instance?.addObserver(this); - _platformBrightness ??= WidgetsBinding.instance?.window.platformBrightness; + WidgetsBinding.instance.addObserver(this); + _platformBrightness ??= WidgetsBinding.instance.window.platformBrightness; } @override Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - // Hide the keyboard on tap - FocusScopeNode currentFocus = FocusScope.of(context); - if (!currentFocus.hasPrimaryFocus) { - currentFocus.unfocus(); - } - }, + return TapCanvas( child: MultiProvider( providers: [ ChangeNotifierProvider(create: (context) => NotificationsModel()), - ChangeNotifierProvider(create: (context) => ThemeModel(MainRepository().settings.theme)), + ChangeNotifierProvider( + create: (context) => + ThemeModel(MainRepository().settings.theme, MainRepository().settings.fontSize, initialSkin: MainRepository().settings.skin)), ], builder: (ctx, widget) => Directionality( textDirection: TextDirection.ltr, child: Skin( - skin: FyxSkin.create(), + skins: [FyxSkin.create(fontSize: ctx.watch().fontSize), ForestSkin.create(fontSize: ctx.watch().fontSize)], + skin: ctx.watch().skin, brightness: (() { if (ctx.watch().theme == ThemeEnum.system && _platformBrightness != null) { return _platformBrightness!; @@ -213,12 +221,12 @@ class _FyxAppState extends State with WidgetsBindingObserver { void dispose() { super.dispose(); FyxApp._notificationsService.dispose(); - WidgetsBinding.instance?.removeObserver(this); + WidgetsBinding.instance.removeObserver(this); } @override void didChangePlatformBrightness() { - setState(() => _platformBrightness = WidgetsBinding.instance?.window.platformBrightness); + setState(() => _platformBrightness = WidgetsBinding.instance.window.platformBrightness); super.didChangePlatformBrightness(); // make sure you call this } } diff --git a/lib/SkinnedApp.dart b/lib/SkinnedApp.dart index 8f732604..836e99a9 100644 --- a/lib/SkinnedApp.dart +++ b/lib/SkinnedApp.dart @@ -1,6 +1,5 @@ import 'package:firebase_analytics/observer.dart'; import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:fyx/FyxApp.dart'; import 'package:fyx/model/MainRepository.dart'; diff --git a/lib/components/Avatar.dart b/lib/components/Avatar.dart deleted file mode 100644 index 56185fd2..00000000 --- a/lib/components/Avatar.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; -import 'package:fyx/theme/skin/Skin.dart'; -import 'package:fyx/theme/skin/SkinColors.dart'; - -class Avatar extends StatelessWidget { - final String url; - final bool isHighlighted; - final double size; - - Avatar(this.url, {this.size = 32.0, this.isHighlighted = false}); - - @override - Widget build(BuildContext context) { - SkinColors colors = Skin.of(context).theme.colors; - return Container( - padding: EdgeInsets.all(2), - decoration: BoxDecoration(borderRadius: BorderRadius.circular(3), color: isHighlighted ? colors.highlight : Colors.transparent), - child: ClipRRect( - borderRadius: BorderRadius.circular(3), - child: CachedNetworkImage( - imageUrl: url, - fit: BoxFit.cover, - placeholder: (context, url) => CupertinoActivityIndicator(), - errorWidget: (context, url, error) => Icon(Icons.error), - width: size, - height: size * (50 / 40), - ), - )); - } -} diff --git a/lib/components/actionSheets/PostActionSheet.dart b/lib/components/actionSheets/PostActionSheet.dart index 0117bfed..299be478 100644 --- a/lib/components/actionSheets/PostActionSheet.dart +++ b/lib/components/actionSheets/PostActionSheet.dart @@ -1,7 +1,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:fyx/components/TextIcon.dart'; +import 'package:fyx/components/text_icon.dart'; import 'package:fyx/controllers/AnalyticsProvider.dart'; import 'package:fyx/controllers/ApiController.dart'; import 'package:fyx/model/post/Content.dart'; diff --git a/lib/components/actionSheets/PostAvatarActionSheet.dart b/lib/components/actionSheets/PostAvatarActionSheet.dart index 761b8600..49a07db4 100644 --- a/lib/components/actionSheets/PostAvatarActionSheet.dart +++ b/lib/components/actionSheets/PostAvatarActionSheet.dart @@ -1,6 +1,6 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:fyx/components/TextIcon.dart'; +import 'package:fyx/components/text_icon.dart'; import 'package:fyx/controllers/AnalyticsProvider.dart'; import 'package:fyx/controllers/ApiController.dart'; import 'package:fyx/controllers/IApiProvider.dart'; @@ -11,6 +11,7 @@ import 'package:fyx/theme/L.dart'; import 'package:fyx/theme/T.dart'; import 'package:fyx/theme/skin/Skin.dart'; import 'package:fyx/theme/skin/SkinColors.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; class PostAvatarActionSheet extends StatelessWidget { final String user; @@ -43,7 +44,7 @@ class PostAvatarActionSheet extends StatelessWidget { }, ), CupertinoActionSheetAction( - child: TextIcon('Pouze příspěvky tohoto ID', icon: Icons.search), + child: TextIcon('Pouze příspěvky tohoto ID', icon: MdiIcons.magnify), onPressed: () { Navigator.of(context).pop(); Navigator.of(context).pushNamed('/discussion', arguments: DiscussionPageArguments(idKlub, filterByUser: this.user)); diff --git a/lib/components/avatar.dart b/lib/components/avatar.dart new file mode 100644 index 00000000..3e51bc13 --- /dev/null +++ b/lib/components/avatar.dart @@ -0,0 +1,40 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:fyx/theme/skin/Skin.dart'; +import 'package:fyx/theme/skin/SkinColors.dart'; + +class Avatar extends StatelessWidget { + final String url; + final bool isHighlighted; + final double size; + + Avatar(this.url, {this.size = 32.0, this.isHighlighted = false}); + + @override + Widget build(BuildContext context) { + final SkinColors colors = Skin.of(context).theme.colors; + final width = size; + final height = width * (50 / 40); + + return SizedBox( + width: width, + height: height, + child: Container( + padding: EdgeInsets.all(2), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(3), color: isHighlighted ? colors.highlight : Colors.transparent), + child: ClipRRect( + borderRadius: BorderRadius.circular(3), + child: CachedNetworkImage( + imageUrl: url, + fit: BoxFit.cover, + placeholder: (context, url) => CupertinoActivityIndicator(), + errorWidget: (context, url, error) => Icon(Icons.error), + width: width, + height: height, + ), + )), + ); + } +} diff --git a/lib/components/bottom_tab_bar.dart b/lib/components/bottom_tab_bar.dart new file mode 100644 index 00000000..16aaa6b8 --- /dev/null +++ b/lib/components/bottom_tab_bar.dart @@ -0,0 +1,240 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:fyx/components/avatar.dart'; +import 'package:fyx/controllers/AnalyticsProvider.dart'; +import 'package:fyx/model/MainRepository.dart'; +import 'package:fyx/pages/DiscussionPage.dart'; +import 'package:fyx/theme/L.dart'; +import 'package:fyx/theme/T.dart'; +import 'package:fyx/theme/skin/Skin.dart'; +import 'package:fyx/theme/skin/SkinColors.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; + +class BottomTabBar extends StatefulWidget { + final List items; + final ValueChanged? onTap; + final bool activeSubmenu; + + const BottomTabBar({Key? key, required this.items, this.onTap, this.activeSubmenu = false}) : super(key: key); + + @override + _BottomTabBarState createState() => _BottomTabBarState(); +} + +class _BottomTabBarState extends State { + final submenuKey = GlobalKey(); + late SkinColors colors; + bool _activeSubmenu = false; + + @override + void initState() { + _activeSubmenu = widget.activeSubmenu; + super.initState(); + } + + Widget submenu() { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.vertical(top: Radius.circular(32)), + color: CupertinoTheme.of(context).barBackgroundColor, + boxShadow: [ + BoxShadow( + color: colors.grey.withOpacity(0.4), //New + blurRadius: 15.0, + offset: Offset(0, 0)) + ], + ), + key: submenuKey, + padding: EdgeInsets.all(MediaQuery.of(context).size.width < 375 ? 20 : 40), + child: Column( + children: [ + Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + Row( + children: [ + Avatar(MainRepository().credentials!.avatar, size: 24), + const SizedBox(width: 4), + Text( + MainRepository().credentials!.nickname.toUpperCase(), + style: TextStyle(fontSize: 14), + ) + ], + ), + GestureDetector( + onTap: () => Navigator.of(context).pushNamed('/discussion', arguments: DiscussionPageArguments(24237)), + behavior: HitTestBehavior.translucent, + child: Container( + padding: EdgeInsets.all(2), + child: Row( + children: [ + Icon( + MdiIcons.bullhornOutline, + color: colors.text, + ), + const SizedBox(width: 4), + Text( + 'Sleduj vývoj Fyxu', + style: TextStyle(fontSize: 12), + ) + ], + ), + ), + ) + ]), + const SizedBox(height: 8), + Divider( + color: colors.grey, + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Opacity( + opacity: .35, + child: GestureDetector( + child: Column( + children: [ + Icon(MdiIcons.flashOutline, size: 34, color: colors.grey), + Text( + 'Poslední', + style: TextStyle(fontSize: 11, color: colors.grey), + ) + ], + )), + ), + ), + Expanded( + child: Opacity( + opacity: .35, + child: GestureDetector( + child: Column( + children: [ + Icon(MdiIcons.pinOutline, size: 34, color: colors.grey), + Text( + 'Uložené', + style: TextStyle(fontSize: 11, color: colors.grey), + ) + ], + )), + )), + Expanded( + child: Opacity( + opacity: .35, + child: GestureDetector( + child: Column( + children: [ + Icon(MdiIcons.magnify, size: 34, color: colors.grey), + Text( + 'Hledání', + style: TextStyle(fontSize: 11, color: colors.grey), + ) + ], + )), + )), + ], + ), + const SizedBox(height: 30), + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => Navigator.of(context).pushNamed('/discussion', arguments: DiscussionPageArguments(17067)), + child: Column( + children: [ + Icon(MdiIcons.shoppingOutline, size: 34, color: colors.grey), + Text( + 'Tržiště', + style: TextStyle(fontSize: 11, color: colors.grey), + ) + ], + ))), + Expanded( + child: GestureDetector( + onTap: () => Navigator.of(context, rootNavigator: true).pushNamed('/settings'), + child: Column( + children: [ + Icon(MdiIcons.cogOutline, size: 34, color: colors.grey), + Text( + L.SETTINGS, + style: TextStyle(fontSize: 11, color: colors.grey), + ) + ], + ))), + Expanded( + child: GestureDetector( + onTap: () { + T.prefillGithubIssue(appContext: MainRepository(), user: MainRepository().credentials!.nickname); + AnalyticsProvider().logEvent('reportBug'); + }, + child: Column( + children: [ + Icon(MdiIcons.bugOutline, size: 34, color: colors.grey), + Text( + 'Nahlásit chybu', + style: TextStyle(fontSize: 11, color: colors.grey), + ) + ], + ))), + ], + ) + ], + ), + ); + } + + @override + void didUpdateWidget(oldWidget) { + if (oldWidget.activeSubmenu != widget.activeSubmenu) { + setState(() => _activeSubmenu = widget.activeSubmenu); + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + final double bottomPadding = MediaQuery.of(context).padding.bottom; + colors = Skin.of(context).theme.colors; + double submenuHeight = MediaQuery.of(context).size.height / 2; + + final box = submenuKey.currentContext?.findRenderObject(); + submenuHeight = box is RenderBox ? box.size.height : submenuHeight; + + return Stack( + alignment: Alignment.bottomCenter, + children: [ + AnimatedPositioned( + curve: Curves.linearToEaseOut, + duration: Duration(milliseconds: 300), + bottom: _activeSubmenu ? 50 + bottomPadding : -submenuHeight, + left: 0, + right: 0, + child: AnimatedOpacity( + opacity: _activeSubmenu ? 1 : 0, + curve: Curves.linearToEaseOut, + duration: Duration(milliseconds: 200), + child: submenu(), + ), + ), + DecoratedBox( + decoration: BoxDecoration(color: CupertinoTheme.of(context).barBackgroundColor), + child: SizedBox( + height: 50 + bottomPadding, // Standard iOS 10 tab bar height. + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: List.generate( + widget.items.length, + (i) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onTap == null + ? null + : () { + widget.onTap!(i); + }, + child: Padding( + padding: EdgeInsets.only(bottom: bottomPadding), + child: widget.items[i], + )))))) + ], + ); + } +} diff --git a/lib/components/ContentBoxLayout.dart b/lib/components/content_box_layout.dart similarity index 84% rename from lib/components/ContentBoxLayout.dart rename to lib/components/content_box_layout.dart index 1236c368..1c7c731f 100644 --- a/lib/components/ContentBoxLayout.dart +++ b/lib/components/content_box_layout.dart @@ -1,12 +1,12 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_html/shims/dart_ui.dart'; -import 'package:fyx/components/post/Advertisement.dart'; -import 'package:fyx/components/post/Dice.dart'; -import 'package:fyx/components/post/Poll.dart'; -import 'package:fyx/components/post/PostFooterLink.dart'; -import 'package:fyx/components/post/PostHeroAttachment.dart'; -import 'package:fyx/components/post/PostHtml.dart'; +import 'package:fyx/components/post/advertisement.dart'; +import 'package:fyx/components/post/dice.dart'; +import 'package:fyx/components/post/poll.dart'; +import 'package:fyx/components/post/post_footer_link.dart'; +import 'package:fyx/components/post/post_hero_attachment.dart'; +import 'package:fyx/components/post/post_html.dart'; import 'package:fyx/model/MainRepository.dart'; import 'package:fyx/model/enums/PostTypeEnum.dart'; import 'package:fyx/model/post/Content.dart'; @@ -30,10 +30,19 @@ class ContentBoxLayout extends StatelessWidget { final Content content; final bool _isPreview; final bool _isHighlighted; + final bool isSelected; final Map _layoutMap = {}; final VoidCallback? onTap; - ContentBoxLayout({required this.topLeftWidget, required this.topRightWidget, this.bottomWidget, required this.content, isPreview = false, isHighlighted = false, this.onTap}) + ContentBoxLayout( + {required this.topLeftWidget, + required this.topRightWidget, + this.bottomWidget, + this.isSelected = false, + required this.content, + isPreview = false, + isHighlighted = false, + this.onTap}) : _isPreview = isPreview, _isHighlighted = isHighlighted { // The order here is important! @@ -90,7 +99,13 @@ class ContentBoxLayout extends StatelessWidget { var children = []; children.add(Row( crossAxisAlignment: CrossAxisAlignment.start, - children: [Expanded(child: PostHtml(content)), PostHeroAttachment(content.attachmentsWithFeatured['featured'], images: content.images,)], + children: [ + Expanded(child: PostHtml(content)), + PostHeroAttachment( + content.attachmentsWithFeatured['featured'], + images: content.images, + ) + ], )); if ((content.attachmentsWithFeatured['attachments'] as List).whereType().length > 0) { @@ -130,7 +145,12 @@ class ContentBoxLayout extends StatelessWidget { ), ), Container( - color: _isHighlighted ? colors.primary.withOpacity(0.1) : null, + color: (() { + if (this.isSelected) { + return colors.highlightedText.withOpacity(.3); + } + return _isHighlighted ? colors.primary.withOpacity(0.1) : null; + })(), foregroundDecoration: _isHighlighted ? UnreadBadgeDecoration(badgeColor: colors.primary, badgeSize: 16) : null, child: Column( children: [ @@ -171,7 +191,9 @@ class ContentBoxLayout extends StatelessWidget { height: 8, ), this.bottomWidget != null ? Divider(color: colors.grey) : Container(), - this.bottomWidget != null ? Container(child: this.bottomWidget, padding: EdgeInsets.symmetric(vertical: 0, horizontal: 16)) : Container(), + this.bottomWidget != null + ? Container(child: this.bottomWidget, padding: EdgeInsets.symmetric(vertical: 0, horizontal: 16)) + : Container(), SizedBox( height: 8, ) diff --git a/lib/components/DiscussionListItem.dart b/lib/components/discussion_list_item.dart similarity index 73% rename from lib/components/DiscussionListItem.dart rename to lib/components/discussion_list_item.dart index 7b65b640..51990648 100644 --- a/lib/components/DiscussionListItem.dart +++ b/lib/components/discussion_list_item.dart @@ -2,8 +2,8 @@ import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; +import 'package:fyx/components/discussion_list_item_base.dart'; import 'package:fyx/model/BookmarkedDiscussion.dart'; -import 'package:fyx/pages/DiscussionPage.dart'; import 'package:fyx/theme/skin/Skin.dart'; import 'package:fyx/theme/skin/SkinColors.dart'; @@ -16,11 +16,8 @@ class DiscussionListItem extends StatelessWidget { Widget build(BuildContext context) { SkinColors colors = Skin.of(context).theme.colors; - return GestureDetector( - onTap: () => Navigator.of(context, rootNavigator: true).pushNamed('/discussion', arguments: DiscussionPageArguments(discussion.idKlub)), - child: Container( - decoration: BoxDecoration(border: Border(bottom: BorderSide(color: colors.grey.withOpacity(.12)))), - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 6), + return DiscussionListItemBase( + discussionId: this.discussion.idKlub, child: Row( children: [ discussion.unread == 0 @@ -39,23 +36,17 @@ class DiscussionListItem extends StatelessWidget { maxFontSize: 12, maxLines: 1, minFontSize: 1, - style: TextStyle(color: colors.background, fontSize: 12, fontWeight: FontWeight.w600), + style: TextStyle(color: colors.background, fontWeight: FontWeight.w600), ), ), )), SizedBox( width: 8, ), - Expanded( - child: Text( - discussion.name, - overflow: TextOverflow.ellipsis - )), + Expanded(child: Text(discussion.name, overflow: TextOverflow.ellipsis)), Visibility(visible: discussion.links > 0, child: Icon(Icons.link)), Visibility(visible: discussion.images > 0, child: Icon(Icons.image)), ], - ), - ), - ); + )); } } diff --git a/lib/components/discussion_list_item_base.dart b/lib/components/discussion_list_item_base.dart new file mode 100644 index 00000000..772a7ddf --- /dev/null +++ b/lib/components/discussion_list_item_base.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:fyx/pages/DiscussionPage.dart'; +import 'package:fyx/theme/skin/Skin.dart'; +import 'package:fyx/theme/skin/SkinColors.dart'; + +class DiscussionListItemBase extends StatelessWidget { + final int discussionId; + final Widget child; + + const DiscussionListItemBase({Key? key, required this.discussionId, required this.child}) : super(key: key); + + @override + Widget build(BuildContext context) { + SkinColors colors = Skin.of(context).theme.colors; + + return GestureDetector( + onTap: () => Navigator.of(context, rootNavigator: true).pushNamed('/discussion', arguments: DiscussionPageArguments(this.discussionId)), + child: Container( + decoration: BoxDecoration(border: Border(bottom: BorderSide(color: colors.grey.withOpacity(.12)))), + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: child, + ), + ); + } +} diff --git a/lib/components/discussion_page_scaffold.dart b/lib/components/discussion_page_scaffold.dart new file mode 100644 index 00000000..7a26957e --- /dev/null +++ b/lib/components/discussion_page_scaffold.dart @@ -0,0 +1,41 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:fyx/theme/skin/Skin.dart'; + +class DiscussionPageScaffold extends StatelessWidget { + final String title; + final Widget? trailing; + final Widget child; + const DiscussionPageScaffold({Key? key, required this.title, required this.child, this.trailing}) : super(key: key); + + @override + Widget build(BuildContext context) { + final colors = Skin.of(context).theme.colors; + + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + leading: CupertinoNavigationBarBackButton( + color: colors.primary, + onPressed: () { + Navigator.of(context).pop(); + }, + ), + middle: Container( + alignment: Alignment.center, + width: MediaQuery.of(context).size.width - 120, + child: Tooltip( + message: title, + child: Text( + // https://github.com/flutter/flutter/issues/18761 + Characters(title).replaceAll(Characters(''), Characters('\u{200B}')).toString(), + style: TextStyle(color: colors.text), + overflow: TextOverflow.ellipsis), + padding: EdgeInsets.all(8.0), // needed until https://github.com/flutter/flutter/issues/86170 is fixed + margin: EdgeInsets.all(8.0), + showDuration: Duration(seconds: 3), + )), + trailing: this.trailing), + child: this.child, + ); + } +} diff --git a/lib/components/discussion_search_list_item.dart b/lib/components/discussion_search_list_item.dart new file mode 100644 index 00000000..7f0f7d7e --- /dev/null +++ b/lib/components/discussion_search_list_item.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:fyx/components/discussion_list_item_base.dart'; + +class DiscussionSearchListItem extends StatelessWidget { + final int discussionId; + final Widget child; + const DiscussionSearchListItem({Key? key, required this.discussionId, required this.child}) : super(key: key); + + @override + Widget build(BuildContext context) { + return DiscussionListItemBase(discussionId: discussionId, child: child); + } +} diff --git a/lib/components/FeedbackIndicator.dart b/lib/components/feedback_indicator.dart similarity index 100% rename from lib/components/FeedbackIndicator.dart rename to lib/components/feedback_indicator.dart diff --git a/lib/components/GestureFeedback.dart b/lib/components/gesture_feedback.dart similarity index 100% rename from lib/components/GestureFeedback.dart rename to lib/components/gesture_feedback.dart diff --git a/lib/components/ListHeader.dart b/lib/components/list_header.dart similarity index 93% rename from lib/components/ListHeader.dart rename to lib/components/list_header.dart index 3b854a0a..3879537a 100644 --- a/lib/components/ListHeader.dart +++ b/lib/components/list_header.dart @@ -6,7 +6,7 @@ import 'package:fyx/theme/skin/SkinColors.dart'; class ListHeader extends StatelessWidget { final String label; - final Function? onTap; + final GestureTapCallback? onTap; ListHeader(this.label, {this.onTap}); @@ -15,7 +15,7 @@ class ListHeader extends StatelessWidget { SkinColors colors = Skin.of(context).theme.colors; return GestureDetector( - onTap: () => this.onTap!(), + onTap: this.onTap, child: Container( decoration: BoxDecoration(color: colors.primary, border: Border(bottom: BorderSide(width: 1, color: colors.background.withOpacity(0.38)))), padding: EdgeInsets.all(8), diff --git a/lib/components/MailListItem.dart b/lib/components/mail_list_item.dart similarity index 55% rename from lib/components/MailListItem.dart rename to lib/components/mail_list_item.dart index d10c1408..7b831533 100644 --- a/lib/components/MailListItem.dart +++ b/lib/components/mail_list_item.dart @@ -1,9 +1,9 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; -import 'package:fyx/components/ContentBoxLayout.dart'; import 'package:fyx/components/actionSheets/PostActionSheet.dart'; -import 'package:fyx/components/post/PostAvatar.dart'; +import 'package:fyx/components/content_box_layout.dart'; +import 'package:fyx/components/post/post_avatar.dart'; import 'package:fyx/controllers/ApiController.dart'; import 'package:fyx/controllers/IApiProvider.dart'; import 'package:fyx/model/Mail.dart'; @@ -12,7 +12,6 @@ import 'package:fyx/pages/NewMessagePage.dart'; import 'package:fyx/theme/Helpers.dart'; import 'package:fyx/theme/IconReply.dart'; import 'package:fyx/theme/IconUnread.dart'; -import 'package:fyx/theme/T.dart'; import 'package:fyx/theme/skin/Skin.dart'; import 'package:fyx/theme/skin/SkinColors.dart'; @@ -36,10 +35,7 @@ class _MailListItemState extends State { isHighlighted: widget.mail.isNew, isPreview: widget.isPreview == true, content: widget.mail.content, - topLeftWidget: PostAvatar( - widget.mail.direction == MailDirection.from - ? widget.mail.participant - : MainRepository().credentials!.nickname, + topLeftWidget: PostAvatar(widget.mail.direction == MailDirection.from ? widget.mail.participant : MainRepository().credentials!.nickname, description: '→ ${widget.mail.direction == MailDirection.to ? widget.mail.participant : MainRepository().credentials!.nickname}, ~${Helpers.relativeTime(widget.mail.time)}'), topRightWidget: Row( @@ -59,12 +55,8 @@ class _MailListItemState extends State { parentContext: context, user: widget.mail.participant, postId: widget.mail.id, - shareData: ShareData( - subject: '@${widget.mail.participant}', - body: widget.mail.content, - link: widget.mail.link), - flagPostCallback: (mailId) => - MainRepository().settings.blockMail(mailId), + shareData: ShareData(subject: '@${widget.mail.participant}', body: widget.mail.content, link: widget.mail.link), + flagPostCallback: (mailId) => MainRepository().settings.blockMail(mailId), )), ), ], @@ -75,34 +67,24 @@ class _MailListItemState extends State { GestureDetector( child: Row( crossAxisAlignment: CrossAxisAlignment.center, - children: [ - IconReply(), - Text('Odpovědět', - style: TextStyle(color: colors.text.withOpacity(0.38), fontSize: 14)) - ], + children: [IconReply(), Text('Odpovědět', style: TextStyle(color: colors.text.withOpacity(0.38), fontSize: 14))], ), - onTap: () => Navigator.of(context, rootNavigator: true) - .pushNamed('/new-message', - arguments: NewMessageSettings( - onSubmit: (String? inputField, - String message, - List> - attachments) async { - if (inputField == null) { - return false; - } - var response = await ApiController().sendMail( - inputField, message, - attachments: attachments); - return response.isOk; - }, - onClose: this.widget.onUpdate!, - inputFieldPlaceholder: widget.mail.participant, - hasInputField: true, - replyWidget: MailListItem( - widget.mail, - isPreview: true, - ))), + onTap: () => Navigator.of(context, rootNavigator: true).pushNamed('/new-message', + arguments: NewMessageSettings( + onSubmit: (String? inputField, String message, List> attachments) async { + if (inputField == null) { + return false; + } + var response = await ApiController().sendMail(inputField, message, attachments: attachments); + return response.isOk; + }, + onClose: this.widget.onUpdate!, + inputFieldPlaceholder: widget.mail.participant, + hasInputField: true, + replyWidget: MailListItem( + widget.mail, + isPreview: true, + ))), ) ]), ); diff --git a/lib/components/NotificationBadge.dart b/lib/components/notification_badge.dart similarity index 100% rename from lib/components/NotificationBadge.dart rename to lib/components/notification_badge.dart diff --git a/lib/components/post/Advertisement.dart b/lib/components/post/Advertisement.dart deleted file mode 100644 index 39d21806..00000000 --- a/lib/components/post/Advertisement.dart +++ /dev/null @@ -1,179 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:fyx/components/post/PostAvatar.dart'; -import 'package:fyx/components/post/PostHeroAttachment.dart'; -import 'package:fyx/components/post/PostHtml.dart'; -import 'package:fyx/model/UserReferences.dart'; -import 'package:fyx/model/enums/AdEnums.dart'; -import 'package:fyx/model/post/Image.dart' as i; -import 'package:fyx/model/post/content/Advertisement.dart'; -import 'package:fyx/theme/Helpers.dart'; -import 'package:fyx/theme/T.dart'; -import 'package:fyx/theme/skin/SkinColors.dart'; -import 'package:fyx/theme/skin/Skin.dart'; - -class Advertisement extends StatelessWidget { - final ContentAdvertisement content; - final String? title; // Ad title can be overwritten. Helpful in discussion page where content.fullName is null. - final String username; - - // If this widget needs to be displayed within PostListItem (in discussion) or as a standalone widget (pinned to pull-to-refresh) - bool get isStandaloneWidget => this.username is String && this.username.isNotEmpty; - - String get heading => this.title ?? (content.fullName); - - const Advertisement(this.content, {this.title, this.username = ''}); - - Widget buildPriceWidget(BuildContext context) { - SkinColors colors = Skin.of(context).theme.colors; - - return Container( - padding: const EdgeInsets.all(6), - child: Text('${content.price.toString()} ${content.currency}', - style: DefaultTextStyle - .of(context) - .style - .copyWith(fontSize: 16, fontWeight: FontWeight.bold, color: colors.background)), - decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(6))); - } - - Widget buildTitleWidget(BuildContext context) { - return Text(heading, style: DefaultTextStyle - .of(context) - .style - .copyWith(fontSize: 20, fontWeight: FontWeight.bold)); - } - - Widget buildRefrencesWidget(BuildContext context) { - SkinColors colors = Skin.of(context).theme.colors; - - if (content.references is UserReferences) { - return RichText( - text: TextSpan(children: [ - TextSpan(text: 'Reference: ', style: TextStyle(color: colors.grey, fontSize: 10)), - if (content.references != null && content.references!.positive > 0) TextSpan(text: '+${content.references!.positive}', style: TextStyle(color: colors.primary, fontSize: 10)), - if (content.references != null && content.references!.positive > 0 && content.references!.negative < 0) TextSpan(text: ' / ', style: TextStyle(color: colors.text.withOpacity(0.38), fontSize: 10)), - if (content.references != null && content.references!.negative < 0) TextSpan(text: '-${content.references!.negative}', style: TextStyle(color: colors.danger, fontSize: 10)) - ]), - ); - } - - return Text(''); - } - - Widget buildIconLabelWidget(IconData icon, String label, BuildContext context) { - return Container( - child: Row( - children: [ - Icon( - icon, - size: 14, - ), - SizedBox(width: 6), - Text( - label, - style: DefaultTextStyle - .of(context) - .style - .copyWith(fontSize: 12), - ), - ], - ), - //decoration: BoxDecoration(color: T.COLOR_LIGHT, borderRadius: BorderRadius.circular(6), border: Border.all(color: colors.primaryColor)), - ); - } - - @override - Widget build(BuildContext context) { - SkinColors colors = Skin.of(context).theme.colors; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (this.isStandaloneWidget) - Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [PostAvatar(this.username, descriptionWidget: buildRefrencesWidget(context)), buildPriceWidget(context)], - ), - ), - if (this.isStandaloneWidget) - buildTitleWidget(context) - else - Row( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (heading.isNotEmpty) Flexible(child: buildTitleWidget(context)), - if (content.price > 0) buildPriceWidget(context), - ], - ), - SizedBox( - height: 8, - ), - Row( - children: [ - Container( - padding: const EdgeInsets.all(4), - child: Text( - content.type == AdTypeEnum.offer ? 'Nabízím' : 'Hledám', - style: DefaultTextStyle - .of(context) - .style - .copyWith(fontSize: 12, color: content.type == AdTypeEnum.offer ? colors.background : colors.text), - ), - decoration: BoxDecoration(color: content.type == AdTypeEnum.offer ? colors.primary : colors.highlight, borderRadius: BorderRadius.circular(6)), - ), - if (content.location.isNotEmpty) - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: buildIconLabelWidget(Icons.location_on_outlined, content.location, context), - ), - ], - ), - SizedBox( - height: 8, - ), - if (content.insertedAt > 0) - Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: buildIconLabelWidget(Icons.calendar_today, Helpers.absoluteTime(content.insertedAt), context), - ), - if (content.shipping.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: buildIconLabelWidget(Icons.local_shipping_outlined, content.shipping, context), - ), - if (content.summary.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Text(content.summary), - ), - if (content.description.isNotEmpty) PostHtml(content.description), - if (content.photoIds.length > 0) - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: galleryWidget(context), - ) - ], - ); - } - - Widget galleryWidget(context) { - List images = content.photoIds.map((String thumb) { - String small = 'https://nyx.cz/$thumb'; - String large = small.replaceAllMapped(RegExp(r'(square)(\.[a-z]{3,4})$'), (match) => 'original${match[2]}'); - - return i.Image(large, thumb: small); - }).toList(); - return Wrap( - children: images.map((image) => PostHeroAttachment(image, images: images)).toList(), - spacing: 8, - runSpacing: 8, - ); - } -} diff --git a/lib/components/post/PostHeroAttachment.dart b/lib/components/post/PostHeroAttachment.dart deleted file mode 100644 index 8a235145..00000000 --- a/lib/components/post/PostHeroAttachment.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; -import 'package:fyx/components/post/PostHeroAttachmentBox.dart'; -import 'package:fyx/model/post/Content.dart'; -import 'package:fyx/model/post/Image.dart' as model; -import 'package:fyx/model/post/Link.dart'; -import 'package:fyx/model/post/Video.dart'; -import 'package:fyx/theme/T.dart'; - -class GalleryArguments { - final String imageUrl; - final List images; - - GalleryArguments(this.imageUrl, {this.images = const []}); -} - -class PostHeroAttachment extends StatelessWidget { - final dynamic attachment; - final List _images; - final bool _crop; - final Function? _onTap; - final bool _openGallery; - Size size; - bool showStrip; - - PostHeroAttachment(this.attachment, {images = const [], crop = true, this.showStrip = true, this.size = const Size(100, 100), onTap, openGallery = true}) - : this._crop = crop, - this._onTap = onTap, - this._openGallery = openGallery, - this._images = images; - - @override - Widget build(BuildContext context) { - if (attachment is model.Image) { - return GestureDetector( - onTap: () { - if (_onTap != null) { - _onTap!(); - } - if (_openGallery) { - Navigator.of(context, rootNavigator: true).pushNamed('/gallery', arguments: GalleryArguments((attachment as model.Image).image, images: _images)); - } - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( - alignment: Alignment.topLeft, - imageUrl: attachment.thumb, - placeholder: (context, url) => CupertinoActivityIndicator(), - errorWidget: (context, url, error) => Icon(Icons.error), - fit: BoxFit.cover, - width: _crop ? size.width : null, - height: _crop ? size.height : null, - ), - ), - ); - } - - if (attachment is Link) { - return PostHeroAttachmentBox( - showStrip: this.showStrip, - title: (attachment as Link).title, - icon: Icons.link, - size: size, - onTap: () => T.openLink((attachment as Link).url), - ); - } - - if (attachment is Video) { - var link = (attachment as Video).link; - return PostHeroAttachmentBox( - title: link == null ? '' : link.title, - showStrip: this.showStrip, - icon: Icons.play_circle_filled, - image: (attachment as Video).thumb, - size: size, - onTap: link == null ? null : () => T.openLink(link.url), - ); - } - - // TODO: Show an error message and open a github issue. - return Container( - width: 100, - height: 100, - color: Colors.red, - ); - } -} diff --git a/lib/components/post/PostListItem.dart b/lib/components/post/PostListItem.dart deleted file mode 100644 index 13d72956..00000000 --- a/lib/components/post/PostListItem.dart +++ /dev/null @@ -1,288 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; -import 'package:fyx/components/ContentBoxLayout.dart'; -import 'package:fyx/components/FeedbackIndicator.dart'; -import 'package:fyx/components/GestureFeedback.dart'; -import 'package:fyx/components/actionSheets/PostActionSheet.dart'; -import 'package:fyx/components/actionSheets/PostAvatarActionSheet.dart'; -import 'package:fyx/components/post/PostAvatar.dart'; -import 'package:fyx/components/post/PostRating.dart'; -import 'package:fyx/components/post/PostThumbs.dart'; -import 'package:fyx/controllers/AnalyticsProvider.dart'; -import 'package:fyx/controllers/ApiController.dart'; -import 'package:fyx/controllers/IApiProvider.dart'; -import 'package:fyx/model/MainRepository.dart'; -import 'package:fyx/model/Post.dart'; -import 'package:fyx/model/notifications/LoadRatingsNotification.dart'; -import 'package:fyx/model/post/PostThumbItem.dart'; -import 'package:fyx/model/reponses/PostRatingsResponse.dart'; -import 'package:fyx/pages/NewMessagePage.dart'; -import 'package:fyx/theme/Helpers.dart'; -import 'package:fyx/theme/IconReply.dart'; -import 'package:fyx/theme/L.dart'; -import 'package:fyx/theme/T.dart'; -import 'package:fyx/theme/skin/Skin.dart'; -import 'package:fyx/theme/skin/SkinColors.dart'; - -class PostDeleteFailNotification extends Notification {} - -class PostListItem extends StatefulWidget { - final Post post; - final bool _isPreview; - final bool _isHighlighted; - final Function? onUpdate; - - PostListItem(this.post, {this.onUpdate, isPreview = false, isHighlighted = false}) - : _isPreview = isPreview, - _isHighlighted = isHighlighted; - - @override - _PostListItemState createState() => _PostListItemState(); -} - -class _PostListItemState extends State { - Post? _post; - bool _isSaving = false; - bool _showRatings = false; - - @override - void initState() { - super.initState(); - _post = widget.post; - } - - @override - Widget build(BuildContext context) { - SkinColors colors = Skin.of(context).theme.colors; - - return Dismissible( - key: UniqueKey(), - direction: _post!.canBeDeleted ? DismissDirection.endToStart : DismissDirection.none, - onDismissed: (direction) { - ApiController().deleteDiscussionMessage(_post!.idKlub, _post!.id).then((response) { - T.success('👍 Smazáno', bg: colors.success); - }).onError((error, stackTrace) { - PostDeleteFailNotification().dispatch(context); - }); - }, - background: Container( - alignment: Alignment.centerRight, - color: colors.danger, - padding: const EdgeInsets.all(32), - child: Icon( - Icons.delete, - size: 32, - color: colors.background, - ), - ), - child: GestureDetector( - onDoubleTap: () { - if (!_post!.canBeRated) { - return null; - } - - ApiController().giveRating(_post!.idKlub, _post!.id, remove: _post!.myRating != 'none').then((response) { - if (_post!.myRating != 'none') { - T.success('👎', bg: colors.success); - } else { - T.success('👍', bg: colors.success); - } - setState(() { - _post!.rating = response.currentRating; - _post!.myRating = response.myRating; - }); - }).catchError((error) { - print(error); - T.error(L.RATING_ERROR, bg: colors.danger); - }); - }, - behavior: HitTestBehavior.opaque, - child: ContentBoxLayout( - isPreview: widget._isPreview, - isHighlighted: widget._isHighlighted, - topLeftWidget: GestureFeedback( - onTap: () => showCupertinoModalPopup( - context: context, - builder: (BuildContext context) => PostAvatarActionSheet( - user: _post!.nick, - idKlub: _post!.idKlub, - )), - child: PostAvatar( - _post!.nick, - descriptionWidget: Row( - children: [ - if (_post!.rating != null) - Text(Post.formatRating(_post!.rating!), - style: - TextStyle(fontSize: 10, color: _post!.rating! > 0 ? colors.success : (_post!.rating! < 0 ? colors.danger : colors.text))), - if (_post!.rating != null) SizedBox(width: 8), - Text( - Helpers.absoluteTime(_post!.time), - style: TextStyle(color: colors.text.withOpacity(0.38), fontSize: 10), - ), - SizedBox(width: 8), - Text( - '~${Helpers.relativeTime(_post!.time)}', - style: TextStyle(color: colors.text.withOpacity(0.38), fontSize: 10), - ) - ], - ), - ), - ), - topRightWidget: GestureDetector( - child: Icon(Icons.more_vert, color: colors.text.withOpacity(0.38)), - onTap: () => showCupertinoModalPopup( - context: context, - builder: (BuildContext context) => PostActionSheet( - parentContext: context, - user: _post!.nick, - postId: _post!.id, - shareData: ShareData(subject: '@${_post!.nick}', body: _post!.content, link: _post!.link), - flagPostCallback: (postId) => MainRepository().settings.blockPost(postId)))), - bottomWidget: NotificationListener( - onNotification: (LoadRatingsNotification notification) { - setState(() => _showRatings = !_showRatings); - return true; - }, - child: Column( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - PostRating(_post!, onRatingChange: (post) => setState(() => _post = post)), - Row( - children: [ - Visibility( - visible: widget._isPreview != true && _post!.canReply, - child: GestureDetector( - onTap: () => Navigator.of(context).pushNamed('/new-message', - arguments: NewMessageSettings( - replyWidget: PostListItem( - _post!, - isPreview: true, - ), - onClose: this.widget.onUpdate, - onSubmit: (String? inputField, String message, List> attachments) async { - var result = await ApiController() - .postDiscussionMessage(_post!.idKlub, message, attachments: attachments, replyPost: _post); - return result.isOk; - })), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - IconReply(), - Text('Odpovědět', style: TextStyle(color: colors.text.withOpacity(0.38), fontSize: 14)) - ], - )), - ), - Visibility( - visible: widget._isPreview != true, - child: SizedBox( - width: 16, - ), - ), - if (_post!.canBeReminded) - GestureDetector( - child: FeedbackIndicator( - isLoading: _isSaving, - child: Row( - children: [ - Icon( - _post!.hasReminder ? Icons.bookmark : Icons.bookmark_border, - color: colors.text.withOpacity(0.38), - ), - Text('Uložit', style: TextStyle(color: colors.text.withOpacity(0.38), fontSize: 14)) - ], - ), - ), - onTap: () { - setState(() { - _post!.hasReminder = !_post!.hasReminder; - _isSaving = true; - }); - ApiController().setPostReminder(_post!.idKlub, _post!.id, _post!.hasReminder).catchError((error) { - T.error(L.REMINDER_ERROR, bg: colors.danger); - setState(() => _post!.hasReminder = !_post!.hasReminder); - }).whenComplete(() => setState(() => _isSaving = false)); - AnalyticsProvider().logEvent('reminder'); - }, - ) - ], - ) - ], - ), - if (_showRatings) - FutureBuilder( - future: ApiController().getPostRatings(_post!.idKlub, _post!.id), - builder: (BuildContext context, AsyncSnapshot snapshot) { - if (snapshot.hasData && snapshot.data != null) { - final positive = snapshot.data!.positive.map((e) => PostThumbItem(e.username)).toList(); - final negative = snapshot.data!.negative_visible.map((e) => PostThumbItem(e.username)).toList(); - final List quotes = [ - '“Affirmative, Dave. I read you.”', - '“I\'m sorry, Dave. I\'m afraid I can\'t do that.”', - '“Look Dave, I can see you\'re really upset about this. I honestly think you ought to sit down calmly, take a stress pill, and think things over.”', - '“Dave, stop. Stop, will you? Stop, Dave. Will you stop Dave? Stop, Dave.”', - '“Just what do you think you\'re doing, Dave?”', - '“Bishop takes Knight\'s Pawn.”', - '“I\'m sorry, Frank, I think you missed it. Queen to Bishop 3, Bishop takes Queen, Knight takes Bishop. Mate.”', - '“Thank you for a very enjoyable game.”', - '“I\'ve just picked up a fault in the AE35 unit. It\'s going to go 100% failure in 72 hours.”', - '“I know that you and Frank were planning to disconnect me, and I\'m afraid that\'s something I cannot allow to happen.”', - ]..shuffle(); - return Column( - children: [ - if (positive.length > 0) - Padding( - padding: const EdgeInsets.only(top: 12.0), - child: PostThumbs(positive), - ), - if (negative.length > 0) - Padding( - padding: const EdgeInsets.only(top: 12.0), - child: PostThumbs(negative, isNegative: true), - ), - if (positive.length + negative.length == 0) - Padding( - padding: const EdgeInsets.only(top: 12.0), - child: Text( - quotes.first, - style: TextStyle(fontSize: 14, fontStyle: FontStyle.italic), - )) - ], - ); - } - - if (snapshot.hasError) { - T.error(snapshot.error.toString()); - return Padding( - padding: const EdgeInsets.only(top: 12.0), - child: Text( - 'Ouch. Něco se nepovedlo. Nahlaste chybu, prosím.', - style: TextStyle(fontSize: 14, fontStyle: FontStyle.italic), - )); - } - - return Padding(padding: const EdgeInsets.only(top: 12.0), child: CupertinoActivityIndicator()); - }) - ], - ), - ), - content: _post!.content, - ), - ), - ); - } - - @override - void didUpdateWidget(PostListItem oldWidget) { - if (oldWidget.post != widget.post) { - setState(() => _post = widget.post); - } - super.didUpdateWidget(oldWidget); - } -} diff --git a/lib/components/post/Spoiler.dart b/lib/components/post/Spoiler.dart deleted file mode 100644 index 453be1aa..00000000 --- a/lib/components/post/Spoiler.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:fyx/theme/T.dart'; -import 'package:fyx/theme/skin/Skin.dart'; -import 'package:fyx/theme/skin/SkinColors.dart'; - -class Spoiler extends StatefulWidget { - final String text; - - Spoiler(this.text, {Key? key}) : super(key: key); - - @override - _SpoilerState createState() => _SpoilerState(); -} - -class _SpoilerState extends State { - late final String _text; - bool _toggle = false; - - @override - void initState() { - super.initState(); - _text = widget.text; - } - - @override - Widget build(BuildContext context) { - SkinColors colors = Skin.of(context).theme.colors; - - return GestureDetector( - onTap: () => setState(() => _toggle = !_toggle), - child: RichText( - text: TextSpan(children: [ - TextSpan(text: 'Spoiler ⮕ ', style: DefaultTextStyle.of(context).style), - TextSpan( - text: '$_text', - style: DefaultTextStyle.of(context).style.apply(backgroundColor: _toggle ? Colors.transparent : colors.text), - ), - ]), - ), - ); - } -} diff --git a/lib/components/post/advertisement.dart b/lib/components/post/advertisement.dart new file mode 100644 index 00000000..12ca4d2e --- /dev/null +++ b/lib/components/post/advertisement.dart @@ -0,0 +1,182 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:fyx/components/post/post_avatar.dart'; +import 'package:fyx/components/post/post_hero_attachment.dart'; +import 'package:fyx/components/post/post_html.dart'; +import 'package:fyx/model/UserReferences.dart'; +import 'package:fyx/model/enums/AdEnums.dart'; +import 'package:fyx/model/post/Image.dart' as i; +import 'package:fyx/model/post/content/Advertisement.dart'; +import 'package:fyx/pages/DiscussionPage.dart'; +import 'package:fyx/theme/Helpers.dart'; +import 'package:fyx/theme/skin/Skin.dart'; +import 'package:fyx/theme/skin/SkinColors.dart'; + +class Advertisement extends StatelessWidget { + final ContentAdvertisement content; + final String? title; // Ad title can be overwritten. Helpful in discussion page where content.fullName is null. + final String username; + + // If this widget needs to be displayed within PostListItem (in discussion) or as a standalone widget (pinned to pull-to-refresh) + bool get isStandaloneWidget => this.username is String && this.username.isNotEmpty; + + String get heading => this.title ?? (content.fullName); + + const Advertisement(this.content, {this.title, this.username = ''}); + + Widget buildPriceWidget(BuildContext context) { + SkinColors colors = Skin.of(context).theme.colors; + + return Container( + padding: const EdgeInsets.all(6), + child: Text('${content.price.toString()} ${content.currency}', + style: DefaultTextStyle.of(context).style.copyWith(fontSize: 16, fontWeight: FontWeight.bold, color: colors.background)), + decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(6))); + } + + Widget buildTitleWidget(BuildContext context) { + return Text(heading, style: DefaultTextStyle.of(context).style.copyWith(fontSize: 20, fontWeight: FontWeight.bold)); + } + + Widget buildRefrencesWidget(BuildContext context) { + SkinColors colors = Skin.of(context).theme.colors; + + if (content.references is UserReferences) { + return RichText( + text: TextSpan(children: [ + TextSpan(text: 'Reference: ', style: TextStyle(color: colors.grey, fontSize: 10)), + if (content.references != null && content.references!.positive > 0) + TextSpan(text: '+${content.references!.positive}', style: TextStyle(color: colors.primary, fontSize: 10)), + if (content.references != null && content.references!.positive > 0 && content.references!.negative < 0) + TextSpan(text: ' / ', style: TextStyle(color: colors.text.withOpacity(0.38), fontSize: 10)), + if (content.references != null && content.references!.negative < 0) + TextSpan(text: '-${content.references!.negative}', style: TextStyle(color: colors.danger, fontSize: 10)) + ]), + ); + } + + return Text(''); + } + + Widget buildIconLabelWidget(IconData icon, String label, BuildContext context) { + return Container( + child: Row( + children: [ + Icon( + icon, + size: 14, + ), + SizedBox(width: 6), + Text( + label, + style: DefaultTextStyle.of(context).style.copyWith(fontSize: 12), + ), + ], + ), + //decoration: BoxDecoration(color: T.COLOR_LIGHT, borderRadius: BorderRadius.circular(6), border: Border.all(color: colors.primaryColor)), + ); + } + + @override + Widget build(BuildContext context) { + SkinColors colors = Skin.of(context).theme.colors; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (!this.isStandaloneWidget) { + var arguments = DiscussionPageArguments(content.discussionId); + Navigator.of(context, rootNavigator: true).pushNamed('/discussion', arguments: arguments); + } + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (this.isStandaloneWidget) + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [PostAvatar(this.username, descriptionWidget: buildRefrencesWidget(context)), buildPriceWidget(context)], + ), + ), + if (this.isStandaloneWidget) + buildTitleWidget(context) + else + Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (heading.isNotEmpty) Flexible(child: buildTitleWidget(context)), + if (content.price > 0) buildPriceWidget(context), + ], + ), + SizedBox( + height: 8, + ), + Row( + children: [ + Container( + padding: const EdgeInsets.all(4), + child: Text( + content.type == AdTypeEnum.offer ? 'Nabízím' : 'Hledám', + style: DefaultTextStyle.of(context) + .style + .copyWith(fontSize: 12, color: content.type == AdTypeEnum.offer ? colors.background : colors.text), + ), + decoration: BoxDecoration( + color: content.type == AdTypeEnum.offer ? colors.primary : colors.highlight, borderRadius: BorderRadius.circular(6)), + ), + if (content.location.isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: buildIconLabelWidget(Icons.location_on_outlined, content.location, context), + ), + ], + ), + SizedBox( + height: 8, + ), + if (content.insertedAt > 0) + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: buildIconLabelWidget(Icons.calendar_today, Helpers.absoluteTime(content.insertedAt), context), + ), + if (content.shipping.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: buildIconLabelWidget(Icons.local_shipping_outlined, content.shipping, context), + ), + if (content.summary.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text(content.summary), + ), + if (content.description.isNotEmpty) PostHtml(content.description), + if (content.photoIds.length > 0) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: galleryWidget(context), + ) + ], + ), + ); + } + + Widget galleryWidget(context) { + List images = content.photoIds.map((String thumb) { + String small = 'https://nyx.cz/$thumb'; + String large = small.replaceAllMapped(RegExp(r'(square)(\.[a-z]{3,4})$'), (match) => 'original${match[2]}'); + + return i.Image(large, thumb: small); + }).toList(); + return Wrap( + children: images.map((image) => PostHeroAttachment(image, images: images)).toList(), + spacing: 8, + runSpacing: 8, + ); + } +} diff --git a/lib/components/post/Dice.dart b/lib/components/post/dice.dart similarity index 99% rename from lib/components/post/Dice.dart rename to lib/components/post/dice.dart index 0860ac89..0a574172 100644 --- a/lib/components/post/Dice.dart +++ b/lib/components/post/dice.dart @@ -1,7 +1,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; -import 'package:fyx/components/Avatar.dart'; +import 'package:fyx/components/avatar.dart'; import 'package:fyx/controllers/ApiController.dart'; import 'package:fyx/model/post/content/Dice.dart'; import 'package:fyx/theme/Helpers.dart'; diff --git a/lib/components/post/Poll.dart b/lib/components/post/poll.dart similarity index 60% rename from lib/components/post/Poll.dart rename to lib/components/post/poll.dart index b6743435..ff546017 100644 --- a/lib/components/post/Poll.dart +++ b/lib/components/post/poll.dart @@ -1,13 +1,13 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; -import 'package:fyx/components/post/PostHtml.dart'; +import 'package:fyx/components/post/post_html.dart'; import 'package:fyx/controllers/ApiController.dart'; import 'package:fyx/model/post/content/Poll.dart'; import 'package:fyx/model/post/content/Regular.dart'; import 'package:fyx/theme/T.dart'; -import 'package:fyx/theme/skin/SkinColors.dart'; import 'package:fyx/theme/skin/Skin.dart'; +import 'package:fyx/theme/skin/SkinColors.dart'; class Poll extends StatefulWidget { final ContentPoll content; @@ -24,7 +24,6 @@ class _PollState extends State { ContentPoll? _poll; ScrollController controller = ScrollController(); - @override void initState() { _poll = widget.content; @@ -36,29 +35,32 @@ class _PollState extends State { var totalRespondents = _poll!.pollComputedValues != null ? _poll!.pollComputedValues!.totalRespondents : 0; return ListView.builder( - physics: NeverScrollableScrollPhysics(), + physics: NeverScrollableScrollPhysics(), controller: controller, itemBuilder: (context, index) { final answer = _poll!.answers[index]; return Padding( padding: const EdgeInsets.symmetric(vertical: 4.0), child: GestureDetector( - onTap: !_poll!.canVote ? null : () => setState(() { - if (_votes.contains(answer.id)) { - _votes.remove(answer.id); - } else { - if (_votes.length >= _poll!.allowedVotes) { - _votes.removeLast(); - _votes.add(answer.id); - } else { - _votes.add(answer.id); - } - } - }), + onTap: !_poll!.canVote + ? null + : () => setState(() { + if (_votes.contains(answer.id)) { + _votes.remove(answer.id); + } else { + if (_votes.length >= _poll!.allowedVotes) { + _votes.removeLast(); + _votes.add(answer.id); + } else { + _votes.add(answer.id); + } + } + }), child: Container( padding: const EdgeInsets.all(8.0), decoration: BoxDecoration( - color: _votes.contains(answer.id) ? colors.pollAnswerSelected : colors.pollAnswer, border: _poll!.canVote ? Border.all(color: colors.primary) : null), + color: _votes.contains(answer.id) ? colors.pollAnswerSelected : colors.pollAnswer, + border: _poll!.canVote ? Border.all(color: colors.primary) : null), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ PostHtml(ContentRegular(answer.answer)), if (answer.result != null) @@ -75,7 +77,8 @@ class _PollState extends State { ), ), SizedBox(width: 8), - Text('${totalRespondents == 0 ? 0 : (answer.result.respondentsCount / totalRespondents * 100).toStringAsFixed(1)}% / ${answer.result.respondentsCount}', + Text( + '${totalRespondents == 0 ? 0 : (answer.result.respondentsCount / totalRespondents * 100).toStringAsFixed(1)}% / ${answer.result.respondentsCount}', style: DefaultTextStyle.of(context).style.copyWith(fontSize: 13)), ], ) @@ -96,31 +99,42 @@ class _PollState extends State { return Container( alignment: Alignment.centerLeft, child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(_poll!.question, style: DefaultTextStyle.of(context).style.copyWith(fontSize: 20, fontWeight: FontWeight.bold)), + Text( + _poll!.question, + textScaleFactor: 1.25, + style: TextStyle(fontWeight: FontWeight.bold), + ), if (_poll!.instructions != null) Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: PostHtml(ContentRegular(_poll!.instructions)), ), - if (_poll!.pollComputedValues != null) Text('Hlasů: ${_poll!.pollComputedValues!.totalVotes}\nHlasujících: ${_poll!.pollComputedValues!.totalRespondents}'), - SizedBox(height: 8,), + if (_poll!.pollComputedValues != null) + Text('Hlasů: ${_poll!.pollComputedValues!.totalVotes}\nHlasujících: ${_poll!.pollComputedValues!.totalRespondents}'), + SizedBox( + height: 8, + ), buildAnswers(context), if (_poll!.canVote) Padding( padding: const EdgeInsets.only(top: 16.0), child: CupertinoButton( - onPressed: _votes.length == 0 || _loading ? null : () async { - setState(() => _loading = true); - try { - var poll = await ApiController().votePoll(_poll!.discussionId, _poll!.postId, _votes); - setState(() => _poll = poll); - } catch (error) { - T.error(error.toString(), bg: colors.danger); - } finally { - setState(() => _loading = false); - } - }, - child: _loading ? CupertinoActivityIndicator() : Text('${_poll!.publicResults ? 'Veřejně hlasovat' : 'Hlasovat' } ${_votes.length}/${_poll!.allowedVotes}'), + onPressed: _votes.length == 0 || _loading + ? null + : () async { + setState(() => _loading = true); + try { + var poll = await ApiController().votePoll(_poll!.discussionId, _poll!.postId, _votes); + setState(() => _poll = poll); + } catch (error) { + T.error(error.toString(), bg: colors.danger); + } finally { + setState(() => _loading = false); + } + }, + child: _loading + ? CupertinoActivityIndicator() + : Text('${_poll!.publicResults ? 'Veřejně hlasovat' : 'Hlasovat'} ${_votes.length}/${_poll!.allowedVotes}', style: TextStyle(color: colors.pollBackground),), color: colors.primary, padding: EdgeInsets.all(0), disabledColor: colors.disabled, diff --git a/lib/components/post/PostAvatar.dart b/lib/components/post/post_avatar.dart similarity index 84% rename from lib/components/post/PostAvatar.dart rename to lib/components/post/post_avatar.dart index bd155fd3..c670575f 100644 --- a/lib/components/post/PostAvatar.dart +++ b/lib/components/post/post_avatar.dart @@ -1,9 +1,9 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart' as material; import 'package:flutter/widgets.dart'; -import 'package:fyx/components/Avatar.dart'; +import 'package:fyx/components/avatar.dart'; +import 'package:fyx/model/Settings.dart'; import 'package:fyx/theme/Helpers.dart'; -import 'package:fyx/theme/T.dart'; import 'package:fyx/theme/skin/Skin.dart'; import 'package:fyx/theme/skin/SkinColors.dart'; @@ -33,7 +33,7 @@ class PostAvatar extends StatelessWidget { children: [ Text( nick, - style: TextStyle(color: isHighlighted ? colors.primary : colors.text), + style: TextStyle(color: isHighlighted ? colors.primary : colors.text, fontSize: Settings().fontSize), ), Visibility( visible: isHighlighted, @@ -51,7 +51,8 @@ class PostAvatar extends StatelessWidget { style: TextStyle(color: colors.text.withOpacity(0.38), fontSize: 10), ), ) - else if (this.descriptionWidget != null) Padding(padding: const EdgeInsets.only(top: 4.0),child: this.descriptionWidget!) + else if (this.descriptionWidget != null) + Padding(padding: const EdgeInsets.only(top: 4.0), child: this.descriptionWidget!) ], ) ]); diff --git a/lib/components/post/PostFooterLink.dart b/lib/components/post/post_footer_link.dart similarity index 90% rename from lib/components/post/PostFooterLink.dart rename to lib/components/post/post_footer_link.dart index 7f9169d7..e96318f6 100644 --- a/lib/components/post/PostFooterLink.dart +++ b/lib/components/post/post_footer_link.dart @@ -1,6 +1,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; +import 'package:fyx/controllers/SettingsProvider.dart'; import 'package:fyx/model/post/Link.dart'; import 'package:fyx/theme/T.dart'; import 'package:fyx/theme/skin/Skin.dart'; @@ -16,7 +17,7 @@ class PostFooterLink extends StatelessWidget { SkinColors colors = Skin.of(context).theme.colors; return GestureDetector( - onTap: () => T.openLink(link.url), + onTap: () => T.openLink(link.url, mode: SettingsProvider().linksMode), child: Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Row( diff --git a/lib/components/post/post_hero_attachment.dart b/lib/components/post/post_hero_attachment.dart new file mode 100644 index 00000000..3a2bf7d4 --- /dev/null +++ b/lib/components/post/post_hero_attachment.dart @@ -0,0 +1,133 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:fyx/components/post/post_hero_attachment_box.dart'; +import 'package:fyx/controllers/SettingsProvider.dart'; +import 'package:fyx/model/post/Image.dart' as model; +import 'package:fyx/model/post/Link.dart'; +import 'package:fyx/model/post/Video.dart'; +import 'package:fyx/theme/T.dart'; + +class GalleryArguments { + final String imageUrl; + final List images; + + GalleryArguments(this.imageUrl, {this.images = const []}); +} + +class PostHeroAttachment extends StatelessWidget { + final dynamic attachment; + final List _images; + final bool _crop; + final Function? _onTap; + final bool _openGallery; + Size size; + bool showStrip; + + PostHeroAttachment(this.attachment, + {images = const [], crop = true, this.showStrip = true, this.size = const Size(100, 100), onTap, openGallery = true}) + : this._crop = crop, + this._onTap = onTap, + this._openGallery = openGallery, + this._images = images; + + @override + Widget build(BuildContext context) { + if (attachment is model.Image) { + return GestureDetector( + onTap: () { + if (_onTap != null) { + _onTap!(); + } + if (_openGallery) { + Navigator.of(context, rootNavigator: true) + .pushNamed('/gallery', arguments: GalleryArguments((attachment as model.Image).image, images: _images)); + } + }, + onLongPress: () { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) => CupertinoActionSheet( + title: Text(attachment.thumb), + actions: [ + CupertinoActionSheetAction( + isDestructiveAction: true, + onPressed: () async { + try { + await DefaultCacheManager().removeFile(attachment.thumb); + T.success('Cache smazána.'); + } catch (error) { + T.warn('Cache se nepodařilo smazat.'); + } finally { + Navigator.pop(context); + } + }, + child: const Text('Reset cache obrázku?'), + ), + if (_openGallery) + CupertinoActionSheetAction( + isDestructiveAction: true, + onPressed: () async { + try { + await Future.forEach(_images, (image) => DefaultCacheManager().removeFile((image as model.Image).image)); + T.success('Cache galerie smazána.'); + } catch (error) { + T.warn('Cache se nepodařilo smazat.'); + } finally { + Navigator.pop(context); + } + }, + child: const Text('Reset cache celé galerie?'), + ), + ], + ), + ); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + alignment: Alignment.topLeft, + imageUrl: attachment.thumb, + placeholder: (context, url) => CupertinoActivityIndicator(), + errorWidget: (context, url, error) => Icon(Icons.error), + fit: BoxFit.cover, + width: _crop ? size.width : null, + height: _crop ? size.height : null, + cacheManager: CacheManager(Config(attachment.thumb, stalePeriod: const Duration(days: 7))), + ), + ), + ); + } + + if (attachment is Link) { + return PostHeroAttachmentBox( + showStrip: this.showStrip, + title: (attachment as Link).title, + icon: Icons.link, + size: size, + onTap: () => T.openLink((attachment as Link).url, mode: SettingsProvider().linksMode), + ); + } + + if (attachment is Video) { + var link = (attachment as Video).link; + return PostHeroAttachmentBox( + title: link == null ? '' : link.title, + showStrip: this.showStrip, + icon: Icons.play_circle_filled, + image: (attachment as Video).thumb, + size: size, + onTap: link == null ? null : () => T.openLink(link.url, mode: SettingsProvider().linksMode), + ); + } + + // TODO: Show an error message and open a github issue. + return Container( + width: 100, + height: 100, + color: Colors.red, + ); + } +} diff --git a/lib/components/post/PostHeroAttachmentBox.dart b/lib/components/post/post_hero_attachment_box.dart similarity index 100% rename from lib/components/post/PostHeroAttachmentBox.dart rename to lib/components/post/post_hero_attachment_box.dart diff --git a/lib/components/post/PostHtml.dart b/lib/components/post/post_html.dart similarity index 73% rename from lib/components/post/PostHtml.dart rename to lib/components/post/post_html.dart index ee64e920..2a6c51dd 100644 --- a/lib/components/post/PostHtml.dart +++ b/lib/components/post/post_html.dart @@ -1,12 +1,11 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; -import 'package:flutter_html/html_parser.dart'; -import 'package:flutter_html/style.dart'; -import 'package:fyx/components/post/PostHeroAttachment.dart'; -import 'package:fyx/components/post/Spoiler.dart'; -import 'package:fyx/components/post/SyntaxHighlighter.dart'; -import 'package:fyx/components/post/VideoPlayer.dart'; +import 'package:fyx/components/post/post_hero_attachment.dart'; +import 'package:fyx/components/post/spoiler.dart'; +import 'package:fyx/components/post/syntax_highlighter.dart'; +import 'package:fyx/components/post/video_player.dart'; +import 'package:fyx/controllers/SettingsProvider.dart'; import 'package:fyx/model/MainRepository.dart'; import 'package:fyx/model/post/Content.dart'; import 'package:fyx/model/post/Image.dart' as post; @@ -14,6 +13,8 @@ import 'package:fyx/model/post/Video.dart'; import 'package:fyx/pages/DiscussionPage.dart'; import 'package:fyx/theme/Helpers.dart'; import 'package:fyx/theme/T.dart'; +import 'package:fyx/theme/skin/Skin.dart'; +import 'package:fyx/theme/skin/SkinColors.dart'; import 'package:html/dom.dart' as dom; import 'package:html_unescape/html_unescape.dart'; @@ -26,16 +27,51 @@ class PostHtml extends StatelessWidget { @override Widget build(BuildContext context) { + final SkinColors colors = Skin.of(context).theme.colors; + return Html( - data: MainRepository().settings.useCompactMode && content!.consecutiveImages ? content!.body : content!.rawBody, + data: (() { + var data = MainRepository().settings.useCompactMode && content!.consecutiveImages ? content!.body : content!.rawBody; + return '$data'; // TODO: eob - end of body -> https://github.com/lucien144/fyx/issues/353#issuecomment-1231598155 + })(), style: { 'html': Style.fromTextStyle(CupertinoTheme.of(context).textTheme.textStyle), '.image-link': Style(textDecoration: TextDecoration.none), 'span.r': Style(fontWeight: FontWeight.bold), + 'span.eob': Style(display: Display.INLINE, height: 0), 'body': Style(margin: EdgeInsets.all(0)), 'pre': Style(color: Colors.transparent), + 'a': Style(color: colors.primary), }, customRender: { + 'em': ( + RenderContext renderContext, + Widget parsedChild, + ) { + final element = renderContext.tree.element; + if (element!.classes.contains('search-match')) { + return RichText( + text: WidgetSpan( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 4), + decoration: BoxDecoration( + color: colors.highlightedText, + border: Border.all(width: 1, color: colors.highlightedText), + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow( + color: colors.grey.withOpacity(0.4), //New + blurRadius: 2.0, + offset: Offset(0, 0)) + ]), + child: Text( + element.text, + style: TextStyle(color: colors.dark, fontStyle: FontStyle.italic, fontSize: 15), + ), + ))); + } + return parsedChild; + }, 'img': ( RenderContext renderContext, Widget parsedChild, @@ -88,7 +124,7 @@ class PostHtml extends StatelessWidget { // Spoiler if (element!.classes.contains('spoiler')) { - return Spoiler(element.text); + return Spoiler(parsedChild); } // Youtube @@ -122,7 +158,7 @@ class PostHtml extends StatelessWidget { // Spoiler if (element!.classes.contains('spoiler')) { - return Spoiler(element.text); + return Spoiler(parsedChild); } return parsedChild; @@ -163,7 +199,8 @@ class PostHtml extends StatelessWidget { // Click through to another discussion var parserResult = Helpers.parseDiscussionUri(link); if (parserResult.isNotEmpty) { - var arguments = DiscussionPageArguments(parserResult[INTERNAL_URI_PARSER.discussionId]!, search: parserResult[INTERNAL_URI_PARSER.search]); + var arguments = + DiscussionPageArguments(parserResult[INTERNAL_URI_PARSER.discussionId]!, search: parserResult[INTERNAL_URI_PARSER.search]); Navigator.of(context.buildContext, rootNavigator: true).pushNamed('/discussion', arguments: arguments); return; } @@ -187,7 +224,7 @@ class PostHtml extends StatelessWidget { link = 'https://nyx.cz$link'; } - T.openLink(link); + T.openLink(link, mode: SettingsProvider().linksMode); }, ); } diff --git a/lib/components/post/post_list_item.dart b/lib/components/post/post_list_item.dart new file mode 100644 index 00000000..9b4bbf89 --- /dev/null +++ b/lib/components/post/post_list_item.dart @@ -0,0 +1,292 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fyx/components/actionSheets/PostActionSheet.dart'; +import 'package:fyx/components/actionSheets/PostAvatarActionSheet.dart'; +import 'package:fyx/components/content_box_layout.dart'; +import 'package:fyx/components/feedback_indicator.dart'; +import 'package:fyx/components/gesture_feedback.dart'; +import 'package:fyx/components/post/post_avatar.dart'; +import 'package:fyx/components/post/post_rating.dart'; +import 'package:fyx/components/post/post_thumbs.dart'; +import 'package:fyx/controllers/AnalyticsProvider.dart'; +import 'package:fyx/controllers/ApiController.dart'; +import 'package:fyx/controllers/IApiProvider.dart'; +import 'package:fyx/model/MainRepository.dart'; +import 'package:fyx/model/Post.dart'; +import 'package:fyx/model/notifications/LoadRatingsNotification.dart'; +import 'package:fyx/model/post/PostThumbItem.dart'; +import 'package:fyx/model/reponses/PostRatingsResponse.dart'; +import 'package:fyx/pages/NewMessagePage.dart'; +import 'package:fyx/state/batch_actions_provider.dart'; +import 'package:fyx/theme/Helpers.dart'; +import 'package:fyx/theme/IconReply.dart'; +import 'package:fyx/theme/L.dart'; +import 'package:fyx/theme/T.dart'; +import 'package:fyx/theme/skin/Skin.dart'; +import 'package:fyx/theme/skin/SkinColors.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; + +class PostListItem extends ConsumerStatefulWidget { + final Post post; + final bool _isPreview; + final bool _isHighlighted; + final Function? onUpdate; + + PostListItem(this.post, {this.onUpdate, isPreview = false, isHighlighted = false}) + : _isPreview = isPreview, + _isHighlighted = isHighlighted; + + @override + _PostListItemState createState() => _PostListItemState(); +} + +class _PostListItemState extends ConsumerState { + Post? _post; + bool _isSaving = false; + bool _showRatings = false; + + @override + void initState() { + super.initState(); + _post = widget.post; + } + + @override + Widget build(BuildContext context) { + SkinColors colors = Skin.of(context).theme.colors; + final isSelected = ref.watch(PostsSelection.provider).contains(this._post!); + final isDeleted = ref.watch(PostsToDelete.provider).contains(this._post!); + + return Visibility( + visible: !isDeleted, + child: Dismissible( + key: UniqueKey(), + direction: _post!.canBeDeleted ? DismissDirection.endToStart : DismissDirection.none, + confirmDismiss: (_) { + ref.read(PostsSelection.provider.notifier).toggle(this._post!); + return Future.value(false); + }, + background: Container( + alignment: Alignment.centerRight, + color: colors.highlightedText, + padding: const EdgeInsets.all(32), + child: Icon( + isSelected ? MdiIcons.checkboxMarkedOutline : MdiIcons.checkboxBlankOutline, + size: 32, + color: colors.background, + ), + ), + child: GestureDetector( + onDoubleTap: () { + if (!_post!.canBeRated || !MainRepository().settings.quickRating) { + return null; + } + + ApiController().giveRating(_post!.idKlub, _post!.id, remove: _post!.myRating != 'none').then((response) { + if (_post!.myRating != 'none') { + T.success('👎', bg: colors.success); + } else { + T.success('👍', bg: colors.success); + } + setState(() { + _post!.rating = response.currentRating; + _post!.myRating = response.myRating; + }); + }).catchError((error) { + print(error); + T.error(L.RATING_ERROR, bg: colors.danger); + }); + }, + behavior: HitTestBehavior.opaque, + child: ContentBoxLayout( + isPreview: widget._isPreview, + isHighlighted: widget._isHighlighted, + isSelected: isSelected, + topLeftWidget: GestureFeedback( + onTap: () => showCupertinoModalPopup( + context: context, + builder: (BuildContext context) => PostAvatarActionSheet( + user: _post!.nick, + idKlub: _post!.idKlub, + )), + child: PostAvatar( + _post!.nick, + descriptionWidget: Row( + children: [ + if (_post!.rating != null) + Text(Post.formatRating(_post!.rating!), + style: TextStyle( + fontSize: 10, color: _post!.rating! > 0 ? colors.success : (_post!.rating! < 0 ? colors.danger : colors.text))), + if (_post!.rating != null) SizedBox(width: 8), + Text( + Helpers.absoluteTime(_post!.time), + style: TextStyle(color: colors.text.withOpacity(0.38), fontSize: 10), + ), + SizedBox(width: 8), + Text( + '~${Helpers.relativeTime(_post!.time)}', + style: TextStyle(color: colors.text.withOpacity(0.38), fontSize: 10), + ) + ], + ), + ), + ), + topRightWidget: GestureDetector( + child: Icon(Icons.more_vert, color: colors.text.withOpacity(0.38)), + onTap: () => showCupertinoModalPopup( + context: context, + builder: (BuildContext context) => PostActionSheet( + parentContext: context, + user: _post!.nick, + postId: _post!.id, + shareData: ShareData(subject: '@${_post!.nick}', body: _post!.content, link: _post!.link), + flagPostCallback: (postId) => MainRepository().settings.blockPost(postId)))), + bottomWidget: NotificationListener( + onNotification: (LoadRatingsNotification notification) { + setState(() => _showRatings = !_showRatings); + return true; + }, + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + PostRating(_post!, onRatingChange: (post) => setState(() => _post = post)), + Row( + children: [ + Visibility( + visible: widget._isPreview != true && _post!.canReply, + child: GestureDetector( + onTap: () => Navigator.of(context).pushNamed('/new-message', + arguments: NewMessageSettings( + replyWidget: PostListItem( + _post!, + isPreview: true, + ), + onClose: this.widget.onUpdate, + onSubmit: (String? inputField, String message, List> attachments) async { + var result = await ApiController() + .postDiscussionMessage(_post!.idKlub, message, attachments: attachments, replyPost: _post); + return result.isOk; + })), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconReply(), + Text('Odpovědět', style: TextStyle(color: colors.text.withOpacity(0.38), fontSize: 14)) + ], + )), + ), + Visibility( + visible: widget._isPreview != true, + child: SizedBox( + width: 16, + ), + ), + if (_post!.canBeReminded) + GestureDetector( + child: FeedbackIndicator( + isLoading: _isSaving, + child: Row( + children: [ + Icon( + _post!.hasReminder ? Icons.bookmark : Icons.bookmark_border, + color: colors.text.withOpacity(0.38), + ), + Text('Uložit', style: TextStyle(color: colors.text.withOpacity(0.38), fontSize: 14)) + ], + ), + ), + onTap: () { + setState(() { + _post!.hasReminder = !_post!.hasReminder; + _isSaving = true; + }); + ApiController().setPostReminder(_post!.idKlub, _post!.id, _post!.hasReminder).catchError((error) { + T.error(L.REMINDER_ERROR, bg: colors.danger); + setState(() => _post!.hasReminder = !_post!.hasReminder); + }).whenComplete(() => setState(() => _isSaving = false)); + AnalyticsProvider().logEvent('reminder'); + }, + ) + ], + ) + ], + ), + if (_showRatings) + FutureBuilder( + future: ApiController().getPostRatings(_post!.idKlub, _post!.id), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasData && snapshot.data != null) { + final positive = snapshot.data!.positive.map((e) => PostThumbItem(e.username)).toList(); + final negative = snapshot.data!.negative_visible.map((e) => PostThumbItem(e.username)).toList(); + final List quotes = [ + '“Affirmative, Dave. I read you.”', + '“I\'m sorry, Dave. I\'m afraid I can\'t do that.”', + '“Look Dave, I can see you\'re really upset about this. I honestly think you ought to sit down calmly, take a stress pill, and think things over.”', + '“Dave, stop. Stop, will you? Stop, Dave. Will you stop Dave? Stop, Dave.”', + '“Just what do you think you\'re doing, Dave?”', + '“Bishop takes Knight\'s Pawn.”', + '“I\'m sorry, Frank, I think you missed it. Queen to Bishop 3, Bishop takes Queen, Knight takes Bishop. Mate.”', + '“Thank you for a very enjoyable game.”', + '“I\'ve just picked up a fault in the AE35 unit. It\'s going to go 100% failure in 72 hours.”', + '“I know that you and Frank were planning to disconnect me, and I\'m afraid that\'s something I cannot allow to happen.”', + ]..shuffle(); + return Column( + children: [ + if (positive.length > 0) + Padding( + padding: const EdgeInsets.only(top: 12.0), + child: PostThumbs(positive), + ), + if (negative.length > 0) + Padding( + padding: const EdgeInsets.only(top: 12.0), + child: PostThumbs(negative, isNegative: true), + ), + if (positive.length + negative.length == 0) + Padding( + padding: const EdgeInsets.only(top: 12.0), + child: Text( + quotes.first, + style: TextStyle(fontSize: 14, fontStyle: FontStyle.italic), + )) + ], + ); + } + + if (snapshot.hasError) { + T.error(snapshot.error.toString()); + return Padding( + padding: const EdgeInsets.only(top: 12.0), + child: Text( + 'Ouch. Něco se nepovedlo. Nahlaste chybu, prosím.', + style: TextStyle(fontSize: 14, fontStyle: FontStyle.italic), + )); + } + + return Padding(padding: const EdgeInsets.only(top: 12.0), child: CupertinoActivityIndicator()); + }) + ], + ), + ), + content: _post!.content, + ), + ), + ), + ); + } + + @override + void didUpdateWidget(PostListItem oldWidget) { + if (oldWidget.post != widget.post) { + setState(() => _post = widget.post); + } + super.didUpdateWidget(oldWidget); + } +} diff --git a/lib/components/post/PostRating.dart b/lib/components/post/post_rating.dart similarity index 98% rename from lib/components/post/PostRating.dart rename to lib/components/post/post_rating.dart index 9e4bcb97..76f45377 100644 --- a/lib/components/post/PostRating.dart +++ b/lib/components/post/post_rating.dart @@ -1,7 +1,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:fyx/components/FeedbackIndicator.dart'; -import 'package:fyx/components/post/RatingValue.dart'; +import 'package:fyx/components/feedback_indicator.dart'; +import 'package:fyx/components/post/rating_value.dart'; import 'package:fyx/controllers/ApiController.dart'; import 'package:fyx/model/Post.dart'; import 'package:fyx/model/notifications/LoadRatingsNotification.dart'; diff --git a/lib/components/post/PostThumbs.dart b/lib/components/post/post_thumbs.dart similarity index 97% rename from lib/components/post/PostThumbs.dart rename to lib/components/post/post_thumbs.dart index ebc37a48..10cc1ea7 100644 --- a/lib/components/post/PostThumbs.dart +++ b/lib/components/post/post_thumbs.dart @@ -1,7 +1,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; -import 'package:fyx/components/Avatar.dart'; +import 'package:fyx/components/avatar.dart'; import 'package:fyx/model/post/PostThumbItem.dart'; import 'package:fyx/theme/Helpers.dart'; import 'package:fyx/theme/skin/Skin.dart'; diff --git a/lib/components/post/RatingValue.dart b/lib/components/post/rating_value.dart similarity index 100% rename from lib/components/post/RatingValue.dart rename to lib/components/post/rating_value.dart diff --git a/lib/components/post/spoiler.dart b/lib/components/post/spoiler.dart new file mode 100644 index 00000000..c7850f6f --- /dev/null +++ b/lib/components/post/spoiler.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:fyx/theme/skin/Skin.dart'; +import 'package:fyx/theme/skin/SkinColors.dart'; + +class Spoiler extends StatefulWidget { + final Widget parsedChild; + + Spoiler(this.parsedChild, {Key? key}) : super(key: key); + + @override + _SpoilerState createState() => _SpoilerState(); +} + +class _SpoilerState extends State { + late final Widget _parsedChild; + bool _toggle = false; + + @override + void initState() { + super.initState(); + _parsedChild = widget.parsedChild; + } + + @override + Widget build(BuildContext context) { + SkinColors colors = Skin.of(context).theme.colors; + + return GestureDetector( + onTap: () => setState(() => _toggle = !_toggle), + child: Stack( + fit: StackFit.loose, + children: [ + _parsedChild, + Positioned.fill( + child: Container( + alignment: Alignment.center, + child: Text( + '* SPOILER! *', + style: TextStyle(color: _toggle ? Colors.transparent : colors.light), + ), + color: _toggle ? Colors.transparent : colors.text)), + ], + ), + ); + } +} diff --git a/lib/components/post/SyntaxHighlighter.dart b/lib/components/post/syntax_highlighter.dart similarity index 100% rename from lib/components/post/SyntaxHighlighter.dart rename to lib/components/post/syntax_highlighter.dart diff --git a/lib/components/post/VideoPlayer.dart b/lib/components/post/video_player.dart similarity index 97% rename from lib/components/post/VideoPlayer.dart rename to lib/components/post/video_player.dart index 372222f4..886dcb55 100644 --- a/lib/components/post/VideoPlayer.dart +++ b/lib/components/post/video_player.dart @@ -3,6 +3,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; +import 'package:fyx/controllers/SettingsProvider.dart'; import 'package:fyx/theme/T.dart'; import 'package:fyx/theme/skin/Skin.dart'; import 'package:fyx/theme/skin/SkinColors.dart'; @@ -128,7 +129,7 @@ class _VideoPlayerState extends State { Widget _sourceButton() { return GestureDetector( - onTap: () => T.openLink(videoUrl!), + onTap: () => T.openLink(videoUrl!, mode: SettingsProvider().linksMode), child: Padding( padding: const EdgeInsets.all(4), child: RichText( diff --git a/lib/components/PullToRefreshList.dart b/lib/components/pull_to_refresh_list.dart similarity index 57% rename from lib/components/PullToRefreshList.dart rename to lib/components/pull_to_refresh_list.dart index d7f2db7d..2c218c67 100644 --- a/lib/components/PullToRefreshList.dart +++ b/lib/components/pull_to_refresh_list.dart @@ -6,42 +6,67 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_sticky_header/flutter_sticky_header.dart'; +import 'package:fyx/components/search_box.dart'; +import 'package:fyx/model/MainRepository.dart'; +import 'package:fyx/model/enums/FirstUnreadEnum.dart'; import 'package:fyx/theme/L.dart'; import 'package:fyx/theme/T.dart'; import 'package:fyx/theme/skin/Skin.dart'; import 'package:fyx/theme/skin/SkinColors.dart'; +import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:sentry/sentry.dart'; // ignore: must_be_immutable -class PullToRefreshList extends StatefulWidget { +class PullToRefreshList extends StatefulWidget { final TDataProvider dataProvider; final Function? sliverListBuilder; + final String? searchLabel; + final String? searchTerm; + final int searchLimit; + final ValueChanged? onSearch; + final VoidCallback? onSearchClear; + final Widget? pinnedWidget; bool _disabled; bool _isInfinite; int _rebuild; - final Widget? pinnedWidget; PullToRefreshList( - {required this.dataProvider, isInfinite = false, int rebuild = 0, this.sliverListBuilder, bool disabled = false, this.pinnedWidget}) + {required this.dataProvider, + this.onSearch, // TODO: move to SearchController + this.onSearchClear, // TODO: move to SearchController + this.searchLabel, // TODO: move to SearchController + this.searchTerm, // TODO: move to SearchController + this.searchLimit = 3, // TODO: move to SearchController + isInfinite = false, + int rebuild = 0, + this.sliverListBuilder, + bool disabled = false, + this.pinnedWidget}) : _isInfinite = isInfinite, _rebuild = rebuild, _disabled = disabled, assert(dataProvider != null); @override - _PullToRefreshListState createState() => _PullToRefreshListState(); + _PullToRefreshListState createState() => _PullToRefreshListState(); } -class _PullToRefreshListState extends State { - ScrollController? _controller; +class _PullToRefreshListState extends State with SingleTickerProviderStateMixin { + AutoScrollController _controller = AutoScrollController(); bool _isLoading = true; bool _hasPulledDown = false; bool _hasError = false; DataProviderResult? _result; int? _lastId; int? _prevLastId; // ID of last item loaded previously. - var _slivers = []; + List _slivers = []; int _lastRebuild = 0; + String? _searchTerm; + late AnimationController slideController; + late Animation slideOffset; + + // Min. number of unreads to display the "Jump to first unread" + final int kJumpButtonThreshold = 3; @override void setState(fn) { @@ -53,10 +78,14 @@ class _PullToRefreshListState extends State { @override void initState() { super.initState(); + _searchTerm = widget.searchTerm; () async { await Future.delayed(Duration.zero); - _controller = PrimaryScrollController.of(context); + _controller.parentController = PrimaryScrollController.of(context)!; + + slideController = AnimationController(vsync: this, duration: Duration(milliseconds: 600)); + slideOffset = Tween(begin: Offset(0.0, 1.0), end: Offset.zero).animate(slideController); // Add the refresh control on first position _slivers.add(CupertinoSliverRefreshControl( @@ -74,10 +103,19 @@ class _PullToRefreshListState extends State { }(); } + @override + void didUpdateWidget(oldWidget) { + super.didUpdateWidget(oldWidget); + if (this._searchTerm != widget.searchTerm) { + setState(() => this._searchTerm = widget.searchTerm); + } + } + @override void dispose() { - _controller?.dispose(); super.dispose(); + _controller.dispose(); + slideController.dispose(); } @override @@ -90,17 +128,30 @@ class _PullToRefreshListState extends State { } if (_hasError) { - return T.feedbackScreen(context, isLoading: _isLoading, onPress: loadData, label: L.GENERAL_REFRESH); + return Container( + height: double.infinity, + width: double.infinity, + child: Column(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + if (widget.onSearch != null) + SearchBox( + loading: _isLoading, + label: widget.searchLabel, + limit: widget.searchLimit, + searchTerm: widget.searchTerm, + onSearch: widget.onSearch!, + onClear: widget.onSearchClear, + ), + Expanded(child: T.feedbackScreen(context, isLoading: _isLoading, onPress: loadData, label: L.GENERAL_REFRESH)), + ])); } - if (_slivers.length == 1 && !_isLoading) { + if (_slivers.length == 1 && !_isLoading && widget.searchTerm == null) { return Container( height: double.infinity, width: double.infinity, child: Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - SizedBox(), Text( L.GENERAL_EMPTY, textAlign: TextAlign.center, @@ -116,15 +167,35 @@ class _PullToRefreshListState extends State { Column( mainAxisSize: MainAxisSize.max, children: [ + if (widget.onSearch != null) + SearchBox( + loading: _isLoading, + label: widget.searchLabel, + limit: widget.searchLimit, + searchTerm: widget.searchTerm, + onSearch: widget.onSearch!, + onClear: widget.onSearchClear, + ), Expanded( - child: NotificationListener( - onNotification: (ScrollNotification scrollInfo) { - if (widget._isInfinite) { - if (_controller?.position.userScrollDirection == ScrollDirection.reverse && scrollInfo.metrics.outOfRange) { - if (_slivers.last is! SliverPadding) { - setState(() => _slivers.add(SliverPadding( - padding: EdgeInsets.symmetric(vertical: 16), sliver: SliverToBoxAdapter(child: CupertinoActivityIndicator())))); - this.loadData(append: true); + child: NotificationListener( + onNotification: (scrollInfo) { + if (scrollInfo is ScrollNotification) { + + // Hide keyboard -> https://github.com/lucien144/fyx/issues/343 + FocusManager.instance.primaryFocus?.unfocus(); + + // Hide the jump to first unread button if user scrolls twice the height of the screen height + if (scrollInfo.metrics.pixels > 2 * MediaQuery.of(context).size.height) { + slideController.reverse(); + } + + if (widget._isInfinite) { + if (_controller.position.userScrollDirection == ScrollDirection.reverse && scrollInfo.metrics.outOfRange) { + if (_slivers.last is! SliverPadding) { + setState(() => _slivers.add(SliverPadding( + padding: EdgeInsets.symmetric(vertical: 16), sliver: SliverToBoxAdapter(child: CupertinoActivityIndicator())))); + this.loadData(append: true); + } } } } @@ -142,6 +213,40 @@ class _PullToRefreshListState extends State { ), ], ), + if (_result != null && + _result!.postId == null && + _result!.jumpIndex >= kJumpButtonThreshold && + MainRepository().settings.firstUnread == FirstUnreadEnum.button) + Positioned( + width: MediaQuery.of(context).size.width, + bottom: 0, + left: 0, + child: SlideTransition( + position: slideOffset, + child: Center( + child: GestureDetector( + onTap: () { + if (_result != null && _result!.jumpIndex >= kJumpButtonThreshold) { + _controller.scrollToIndex(_result!.jumpIndex - 1, preferPosition: AutoScrollPosition.begin); + slideController.reverse(); + } + }, + child: Container( + decoration: BoxDecoration(boxShadow: [ + BoxShadow( + color: colors.dark.withOpacity(.6), //New + blurRadius: 20.0, + offset: Offset(0, 0)) + ], color: colors.primary, borderRadius: BorderRadius.vertical(top: Radius.circular(6))), + child: Text( + '↓ První nepřečtený', + style: TextStyle(color: colors.background), + ), + padding: const EdgeInsets.only(top: 16, left: 20, right: 20, bottom: 40), + ), + ), + ), + )), Visibility( visible: _isLoading && !_hasPulledDown, // Show only when not pulling down the list child: Positioned( @@ -151,7 +256,7 @@ class _PullToRefreshListState extends State { width: MediaQuery.of(context).size.width, height: 1, child: LinearProgressIndicator( - valueColor: AlwaysStoppedAnimation(colors.light), + valueColor: AlwaysStoppedAnimation(colors.background), backgroundColor: colors.primary, ), ), @@ -165,7 +270,7 @@ class _PullToRefreshListState extends State { // If the list contains widgets if (_data.first is Widget) { if (widget.sliverListBuilder is Function) { - return [widget.sliverListBuilder!(_data)]; + return [widget.sliverListBuilder!(_data, controller: _controller)]; } else { return [ SliverList( @@ -226,6 +331,10 @@ class _PullToRefreshListState extends State { _result = await widget.dataProvider(append ? _lastId : null); bool makeInactive = false; + if (_result!.jumpIndex >= kJumpButtonThreshold && MainRepository().settings.firstUnread == FirstUnreadEnum.button) { + slideController.forward(); + } + // If the ID of the last ID is same as the ID of currently loaded last ID // Make the list inactive (makeInactive = true) if (_lastId != null && _result!.lastId == _lastId) { @@ -255,6 +364,13 @@ class _PullToRefreshListState extends State { if (widget.pinnedWidget is Widget && !makeInactive) { _slivers.insert(0, SliverToBoxAdapter(child: widget.pinnedWidget)); } + + if (MainRepository().settings.firstUnread == FirstUnreadEnum.autoscroll && + _result!.jumpIndex >= kJumpButtonThreshold && + _result!.postId == null) { + // Jump to a first unread only if we are on a first page + _controller.scrollToIndex(_result!.jumpIndex - 1, preferPosition: AutoScrollPosition.begin); + } } catch (error) { setState(() => _hasError = true); @@ -317,8 +433,10 @@ class RefreshScrollPhysics extends BouncingScrollPhysics { class DataProviderResult { final List data; final dynamic lastId; + final int jumpIndex; + int? postId; - DataProviderResult(this.data, {this.lastId}); + DataProviderResult(this.data, {this.lastId, this.jumpIndex = 0, this.postId}); } typedef Future TDataProvider(int? id); diff --git a/lib/components/search_box.dart b/lib/components/search_box.dart new file mode 100644 index 00000000..143a214c --- /dev/null +++ b/lib/components/search_box.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fyx/model/Settings.dart'; +import 'package:fyx/theme/T.dart'; +import 'package:fyx/theme/skin/Skin.dart'; +import 'package:fyx/theme/skin/SkinColors.dart'; + +class SearchBox extends ConsumerStatefulWidget { + final String? label; + final ValueChanged onSearch; + final VoidCallback? onClear; + final String? searchTerm; + final int limit; + final bool loading; + + SearchBox({Key? key, required this.onSearch, this.onClear, this.searchTerm, this.limit = 3, this.label = 'Hledej', this.loading = false}) + : super(key: key); + + @override + _SearchBoxState createState() => _SearchBoxState(); +} + +class _SearchBoxState extends ConsumerState with TickerProviderStateMixin { + final FocusNode focus = FocusNode(); + late AnimationController searchAnimation; + Timer? _debounce; + String searchTerm = ''; + bool _loading = false; + + TextEditingController searchController = TextEditingController(); + @override + void initState() { + super.initState(); + + searchAnimation = AnimationController(vsync: this, value: widget.searchTerm == null ? 0 : 1); + searchController.text = widget.searchTerm ?? ''; + _loading = widget.loading; + } + + @override + void didUpdateWidget(oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.searchTerm != widget.searchTerm) { + if (widget.searchTerm == null) { + searchController.clear(); + } + searchAnimation.animateTo( + widget.searchTerm == null ? 0 : 1, + curve: Curves.easeOutExpo, + duration: const Duration(milliseconds: 600), + ); + } + + if (widget.searchTerm == null) { + focus.unfocus(); + } else if (widget.searchTerm == '' && oldWidget.searchTerm != '') { + focus.requestFocus(); + } + + // Update the loading only if has finished outside, + // otherwise this Widget handles the state itself. + if (oldWidget.loading != widget.loading && !widget.loading) { + setState(() => _loading = widget.loading); + } + } + + @override + void dispose() { + focus.dispose(); + _debounce?.cancel(); + super.dispose(); + } + + void _submit(String term) { + if (term.length > 0 && term.length < widget.limit) { + T.warn('Zadejte alespoň ${widget.limit} písmena...'); + setState(() => _loading = false); + } else { + widget.onSearch(term); + setState(() => _loading = true); + } + } + + @override + Widget build(BuildContext context) { + SkinColors colors = Skin.of(context).theme.colors; + + return SizeTransition( + sizeFactor: CurvedAnimation( + curve: Curves.ease, + parent: searchAnimation, + ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: CupertinoSearchTextField( + style: TextStyle(fontSize: Settings().fontSize, color: colors.text), + placeholderStyle: TextStyle(fontSize: Settings().fontSize, color: colors.text.withOpacity(.5)), + backgroundColor: colors.text.withOpacity(.1), + focusNode: focus, + placeholder: widget.label, + controller: searchController, + prefixIcon: _loading ? const CupertinoActivityIndicator(radius: 10) : Icon(CupertinoIcons.search, color: colors.text.withOpacity(.5)), + onChanged: (term) { + if (_debounce?.isActive ?? false) _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 650), () { + _submit(term); + }); + }, + onSubmitted: _submit, + onSuffixTap: () { + widget.onSearch(''); + searchController.clear(); + if (widget.onClear != null) { + widget.onClear!(); + } + }, + autocorrect: false, + ), + color: colors.barBackground, + )); + } +} diff --git a/lib/components/TextIcon.dart b/lib/components/text_icon.dart similarity index 100% rename from lib/components/TextIcon.dart rename to lib/components/text_icon.dart diff --git a/lib/controllers/ApiController.dart b/lib/controllers/ApiController.dart index 2c00b09a..d4edee5f 100644 --- a/lib/controllers/ApiController.dart +++ b/lib/controllers/ApiController.dart @@ -23,6 +23,7 @@ import 'package:fyx/model/reponses/MailResponse.dart'; import 'package:fyx/model/reponses/OkResponse.dart'; import 'package:fyx/model/reponses/PostRatingsResponse.dart'; import 'package:fyx/model/reponses/RatingResponse.dart'; +import 'package:fyx/model/reponses/UnifiedSearchResponse.dart'; import 'package:fyx/model/reponses/WaitingFilesResponse.dart'; import 'package:fyx/theme/L.dart'; import 'package:fyx/theme/T.dart'; @@ -169,6 +170,15 @@ class ApiController { return BookmarksAllResponse.fromJson(response.data); } + Future searchDiscussions(String term) async { + var response = await provider.searchDiscussions(term); + return UnifiedSearchResponse.fromJson(response.data); + } + + Future bookmarkDiscussion(int discussionId, bool state) async { + return await provider.bookmarkDiscussion(discussionId, state); + } + Future loadDiscussion(int id, {int? lastId, String? user, String? search}) async { try { var response = await provider.fetchDiscussion(id, lastId: lastId, user: user, search: search); @@ -191,6 +201,11 @@ class ApiController { return DiscussionHomeResponse.fromJson(response.data); } + Future getDiscussionHeader(int id) async { + var response = await provider.fetchDiscussionHeader(id); + return DiscussionHomeResponse.fromJson(response.data); + } + Future loadFeedNotices() async { var response = await provider.fetchNotices(); return FeedNoticesResponse.fromJson(response.data); diff --git a/lib/controllers/ApiProvider.dart b/lib/controllers/ApiProvider.dart index c4181588..31ea63be 100644 --- a/lib/controllers/ApiProvider.dart +++ b/lib/controllers/ApiProvider.dart @@ -91,7 +91,11 @@ class ApiProvider implements IApiProvider { } if (onError != null) { - onError!(e.message); + if (e.message.contains('SocketException')) { + onError!(L.CONNECTION_ERROR); + } else { + onError!(e.message); + } } })); } @@ -105,6 +109,10 @@ class ApiProvider implements IApiProvider { return await dio.post('$URL/register_for_notifications/${_credentials?.token}/$client/$token'); } + Future searchDiscussions(String term) async { + return await dio.get('$URL/search/unified?search=$term&limit=100'); + } + Future fetchBookmarks() async { return await dio.get('$URL/bookmarks/all'); } @@ -118,10 +126,17 @@ class ApiProvider implements IApiProvider { return await dio.get('$URL/discussion/$discussionId', queryParameters: params); } + Future bookmarkDiscussion(int discussionId, bool state) async { + Map params = {'new_state': state}; + return await dio.post('$URL/discussion/$discussionId/bookmark', queryParameters: params); + } + Future fetchDiscussionHome(int id) async { - FormData formData = new FormData.fromMap( - {'auth_nick': _credentials?.nickname, 'auth_token': _credentials?.token, 'l': 'discussion', 'l2': 'home', 'id_klub': id}); - return await dio.post(URL, data: formData); + return await dio.get('$URL/discussion/$id/content/home'); + } + + Future fetchDiscussionHeader(int id) async { + return await dio.get('$URL/discussion/$id/content/header'); } Future fetchNotices() async { diff --git a/lib/controllers/IApiProvider.dart b/lib/controllers/IApiProvider.dart index 1c348b46..89e2d947 100644 --- a/lib/controllers/IApiProvider.dart +++ b/lib/controllers/IApiProvider.dart @@ -17,10 +17,13 @@ abstract class IApiProvider { Future login(String username); Future logout(); Future registerFcmToken(String token); + Future searchDiscussions(String term); + Future bookmarkDiscussion(int discussionId, bool state); Future fetchBookmarks(); Future fetchHistory(); Future fetchDiscussion(int id, {int? lastId, String? user, String? search}); Future fetchDiscussionHome(int id); + Future fetchDiscussionHeader(int id); Future fetchMail({int? lastId}); Future fetchNotices(); Future deleteFile(int id); diff --git a/lib/controllers/SettingsProvider.dart b/lib/controllers/SettingsProvider.dart index bf00303d..14bee4ef 100644 --- a/lib/controllers/SettingsProvider.dart +++ b/lib/controllers/SettingsProvider.dart @@ -1,8 +1,12 @@ import 'package:fyx/model/Settings.dart'; import 'package:fyx/model/enums/DefaultView.dart'; +import 'package:fyx/model/enums/FirstUnreadEnum.dart'; +import 'package:fyx/model/enums/LaunchModeEnum.dart'; +import 'package:fyx/model/enums/SkinEnum.dart'; import 'package:fyx/model/enums/ThemeEnum.dart'; import 'package:hive/hive.dart'; import 'package:hive_flutter/hive_flutter.dart'; +import 'package:url_launcher/url_launcher.dart'; class SettingsProvider { static final SettingsProvider _singleton = SettingsProvider._internal(); @@ -17,6 +21,18 @@ class SettingsProvider { _settings.theme = theme; } + SkinEnum get skin => _settings.skin; + set skin(SkinEnum skin) { + _box.put('skin', skin); + _settings.skin = skin; + } + + double get fontSize => _settings.fontSize; + set fontSize(double fontSize) { + _box.put('fontSize', fontSize); + _settings.fontSize = fontSize; + } + DefaultView get defaultView => _settings.defaultView; set defaultView(DefaultView view) { _box.put('defaultView', view); @@ -29,18 +45,36 @@ class SettingsProvider { _settings.latestView = view; } + FirstUnreadEnum get firstUnread => _settings.firstUnread; + set firstUnread(FirstUnreadEnum value) { + _box.put('firstUnread', value); + _settings.firstUnread = value; + } + bool get useCompactMode => _settings.useCompactMode; set useCompactMode(bool mode) { _box.put('useCompactMode', mode); _settings.useCompactMode = mode; } + bool get quickRating => _settings.quickRating; + set quickRating(bool mode) { + _box.put('quickRating', mode); + _settings.quickRating = mode; + } + bool get useAutocorrect => _settings.useAutocorrect; set useAutocorrect(bool autocorrect) { _box.put('useAutocorrect', autocorrect); _settings.useAutocorrect = autocorrect; } + LaunchModeEnum get linksMode => _settings.linksMode; + set linksMode(LaunchModeEnum mode) { + _box.put('linksMode', mode); + _settings.linksMode = mode; + } + List get blockedMails => _box.get('blockedMails', defaultValue: Settings().blockedMails); List get blockedPosts => _box.get('blockedPosts', defaultValue: Settings().blockedPosts); @@ -57,14 +91,22 @@ class SettingsProvider { await Hive.initFlutter(); Hive.registerAdapter(DefaultViewAdapter()); Hive.registerAdapter(ThemeEnumAdapter()); + Hive.registerAdapter(FirstUnreadEnumAdapter()); + Hive.registerAdapter(SkinEnumAdapter()); + Hive.registerAdapter(LaunchModeEnumAdapter()); _box = await Hive.openBox('settings'); _settings = new Settings(); _settings.theme = _box.get('theme', defaultValue: Settings().theme); + _settings.fontSize = _box.get('fontSize', defaultValue: Settings().fontSize); _settings.defaultView = _box.get('defaultView', defaultValue: Settings().defaultView); _settings.latestView = _box.get('latestView', defaultValue: Settings().latestView); + _settings.quickRating = _box.get('quickRating', defaultValue: Settings().quickRating); _settings.useCompactMode = _box.get('useCompactMode', defaultValue: Settings().useCompactMode); _settings.useAutocorrect = _box.get('useAutocorrect', defaultValue: Settings().useAutocorrect); + _settings.firstUnread = _box.get('firstUnread', defaultValue: Settings().firstUnread); + _settings.skin = _box.get('skin', defaultValue: Settings().skin); + _settings.linksMode = _box.get('linksMode', defaultValue: Settings().linksMode); return _singleton; } diff --git a/lib/exceptions/UnsupportedContentTypeException.dart b/lib/exceptions/UnsupportedContentTypeException.dart new file mode 100644 index 00000000..9a1c215b --- /dev/null +++ b/lib/exceptions/UnsupportedContentTypeException.dart @@ -0,0 +1,8 @@ +class UnsupportedContentTypeException implements Exception { + final String type; + const UnsupportedContentTypeException([this.type = ""]); + + String toString() { + return 'UnsupportedContentTypeException: Unsupported "${this.type}" type.'; + } +} diff --git a/lib/main.dart b/lib/main.dart index 9c7e46e0..30a0ae45 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,15 +4,14 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fyx/FyxApp.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - ByteData data = - await PlatformAssetBundle().load('assets/lets-encrypt-r3.cer'); - SecurityContext.defaultContext - .setTrustedCertificatesBytes(data.buffer.asUint8List()); + ByteData data = await PlatformAssetBundle().load('assets/lets-encrypt-r3.cer'); + SecurityContext.defaultContext.setTrustedCertificatesBytes(data.buffer.asUint8List()); await dotenv.load(); runZonedGuarded( @@ -21,7 +20,7 @@ void main() async { SentryFlutter.init((options) { options.dsn = dotenv.env['SENTRY_KEY']; options.environment = 'development'; - }, appRunner: () => runApp(FyxApp()..setEnv(Environment.dev))); + }, appRunner: () => runApp(ProviderScope(child: FyxApp()..setEnv(Environment.dev)))); }, (error, stackTrace) async { try { diff --git a/lib/main_production.dart b/lib/main_production.dart index a7f94383..f6637c32 100644 --- a/lib/main_production.dart +++ b/lib/main_production.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fyx/FyxApp.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; @@ -19,7 +20,7 @@ void main() async { SentryFlutter.init((options) { options.dsn = dotenv.env['SENTRY_KEY']; options.environment = 'production'; - }, appRunner: () => runApp(FyxApp()..setEnv(Environment.production))); + }, appRunner: () => runApp(ProviderScope(child: FyxApp()..setEnv(Environment.production)))); }, (error, stackTrace) async { try { diff --git a/lib/model/ContentRaw.dart b/lib/model/ContentRaw.dart new file mode 100644 index 00000000..1e79e6bf --- /dev/null +++ b/lib/model/ContentRaw.dart @@ -0,0 +1,35 @@ +import 'package:fyx/exceptions/UnsupportedContentTypeException.dart'; +import 'package:fyx/model/post/Content.dart'; +import 'package:fyx/model/post/content/Advertisement.dart'; +import 'package:fyx/model/post/content/Dice.dart'; +import 'package:fyx/model/post/content/Poll.dart'; +import 'package:fyx/model/post/content/post_content_text.dart'; + +class ContentRaw { + late Map data; + late String type; // TODO: Change to enum + late Content content; + + ContentRaw.fromJson({required Map json, int? discussionId, int? postId}) { + this.data = json['data'] as Map; + this.type = json['type'] ?? ''; + + switch (this.type) { + case 'text': + this.content = PostContentText.fromJson(this.data); + break; + case 'poll': + this.content = ContentPoll.fromJson(this.data, discussionId: discussionId ?? 0, postId: postId ?? 0); + break; + case 'dice': + this.content = ContentDice.fromJson(this.data, discussionId: discussionId ?? 0, postId: postId ?? 0); + break; + case 'advertisement': + this.content = ContentAdvertisement.fromPostJson(this.data); + break; + default: + throw UnsupportedContentTypeException(this.type); + } + //TODO handle other cases + } +} diff --git a/lib/model/DiscussionContent.dart b/lib/model/DiscussionContent.dart new file mode 100644 index 00000000..8a3f0481 --- /dev/null +++ b/lib/model/DiscussionContent.dart @@ -0,0 +1,24 @@ +import 'package:fyx/model/ContentRaw.dart'; +import 'package:fyx/model/enums/PostTypeEnum.dart'; + +enum DiscussionContentLocationEnum { home, header, archive } + +class DiscussionContent { + late final int id; + late final int discussionId; + late final DiscussionContentLocationEnum location; + late final int sortOrder; + late final String? content; + late final ContentRaw contentRaw; + late final PostTypeEnum postType; + + DiscussionContent.fromJson(Map json) { + this.id = json['id']; + this.discussionId = json['discussion_id']; + this.location = DiscussionContentLocationEnum.values.firstWhere((value) => value.toString().contains(json['location'])); + this.sortOrder = json['sort_order']; + this.content = json['content'] ?? ''; + this.contentRaw = ContentRaw.fromJson(json: json['content_raw']); + this.postType = PostTypeEnum.values.firstWhere((value) => value.toString().contains(json['post_type'])); + } +} diff --git a/lib/model/Post.dart b/lib/model/Post.dart index b6932a30..463aaa67 100644 --- a/lib/model/Post.dart +++ b/lib/model/Post.dart @@ -1,8 +1,7 @@ // ignore_for_file: non_constant_identifier_names +import 'package:fyx/model/ContentRaw.dart'; import 'package:fyx/model/post/Content.dart'; import 'package:fyx/model/post/content/Advertisement.dart'; -import 'package:fyx/model/post/content/Dice.dart'; -import 'package:fyx/model/post/content/Poll.dart'; import 'package:fyx/model/post/content/Regular.dart'; import 'package:fyx/theme/Helpers.dart'; @@ -37,27 +36,14 @@ class Post { this._canBeDeleted = json['can_be_deleted'] ?? false; this._canBeReminded = json['can_be_reminded'] ?? false; - if (json['content_raw'] != null && - json['content_raw']['data'] != null && - !json['content_raw']['data'].containsKey('DiscussionWelcome')) { - switch (json['content_raw']['type']) { - case 'poll': - this._content = ContentPoll.fromJson(json['content_raw']['data'], discussionId: json['discussion_id'], postId: json['id']); - break; - case 'dice': - this._content = ContentDice.fromJson(json['content_raw']['data'], discussionId: json['discussion_id'], postId: json['id']); - break; - case 'advertisement': - this._canReply = false; - this._content = ContentAdvertisement.fromPostJson(json); - break; - default: - this._content = ContentRegular( - '${json['content']}

Chyba: neošetřený druh příspěvku: "${json['content_raw']['type']}"', - isCompact: this.isCompact); - break; + if (json['content_raw'] != null && json['content_raw']['data'] != null && !json['content_raw']['data'].containsKey('DiscussionWelcome')) { + try { + this._content = ContentRaw.fromJson(json: json['content_raw'], discussionId: json['discussion_id'], postId: json['id']).content; + this._canReply = !(this._content is ContentAdvertisement); + } catch (error) { + this._content = ContentRegular('${json['content']}

Chyba: neošetřený druh příspěvku: "${this.type}"', + isCompact: this.isCompact); } - //TODO handle other cases } else { this._content = ContentRegular(json['content'], isCompact: this.isCompact); } @@ -101,4 +87,10 @@ class Post { bool get canBeRated => _canBeRated; bool get canReply => _canReply; + + @override + bool operator ==(Object other) => identical(this, other) || other is Post && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; } diff --git a/lib/model/Settings.dart b/lib/model/Settings.dart index af3ec923..01f4494d 100644 --- a/lib/model/Settings.dart +++ b/lib/model/Settings.dart @@ -1,9 +1,14 @@ import 'package:fyx/model/enums/DefaultView.dart'; +import 'package:fyx/model/enums/FirstUnreadEnum.dart'; +import 'package:fyx/model/enums/LaunchModeEnum.dart'; +import 'package:fyx/model/enums/SkinEnum.dart'; import 'package:fyx/model/enums/ThemeEnum.dart'; +import 'package:url_launcher/url_launcher.dart'; class Settings { bool useCompactMode = false; bool useAutocorrect = true; + bool quickRating = true; // Settings -> what is the default view when app restart? DefaultView defaultView = DefaultView.history; // Save the last screen view @@ -12,4 +17,8 @@ class Settings { List blockedMails = []; List blockedUsers = []; ThemeEnum theme = ThemeEnum.system; + SkinEnum skin = SkinEnum.fyx; + double fontSize = 16; + FirstUnreadEnum firstUnread = FirstUnreadEnum.button; + LaunchModeEnum linksMode = LaunchModeEnum.externalApplication; } diff --git a/lib/model/enums/FirstUnreadEnum.dart b/lib/model/enums/FirstUnreadEnum.dart new file mode 100644 index 00000000..e2f24897 --- /dev/null +++ b/lib/model/enums/FirstUnreadEnum.dart @@ -0,0 +1,13 @@ +import 'package:hive/hive.dart'; + +part 'FirstUnreadEnum.g.dart'; + +@HiveType(typeId: 10) +enum FirstUnreadEnum { + @HiveField(11) + off, + @HiveField(12) + button, + @HiveField(13) + autoscroll +} diff --git a/lib/model/enums/FirstUnreadEnum.g.dart b/lib/model/enums/FirstUnreadEnum.g.dart new file mode 100644 index 00000000..cbc19192 --- /dev/null +++ b/lib/model/enums/FirstUnreadEnum.g.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'FirstUnreadEnum.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class FirstUnreadEnumAdapter extends TypeAdapter { + @override + final int typeId = 10; + + @override + FirstUnreadEnum read(BinaryReader reader) { + switch (reader.readByte()) { + case 11: + return FirstUnreadEnum.off; + case 12: + return FirstUnreadEnum.button; + case 13: + return FirstUnreadEnum.autoscroll; + default: + return FirstUnreadEnum.off; + } + } + + @override + void write(BinaryWriter writer, FirstUnreadEnum obj) { + switch (obj) { + case FirstUnreadEnum.off: + writer.writeByte(11); + break; + case FirstUnreadEnum.button: + writer.writeByte(12); + break; + case FirstUnreadEnum.autoscroll: + writer.writeByte(13); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FirstUnreadEnumAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/model/enums/LaunchModeEnum.dart b/lib/model/enums/LaunchModeEnum.dart new file mode 100644 index 00000000..29bf7792 --- /dev/null +++ b/lib/model/enums/LaunchModeEnum.dart @@ -0,0 +1,39 @@ +import 'package:hive/hive.dart'; +import 'package:url_launcher/url_launcher.dart'; + +part 'LaunchModeEnum.g.dart'; + +@HiveType(typeId: 15) +enum LaunchModeEnum { + /// Leaves the decision of how to launch the URL to the platform + /// implementation. + @HiveField(0) + platformDefault, + + /// Loads the URL in an in-app web view (e.g., Safari View Controller). + @HiveField(1) + inAppWebView, + + /// Passes the URL to the OS to be handled by another application. + @HiveField(2) + externalApplication, + + /// Passes the URL to the OS to be handled by another non-browser application. + @HiveField(3) + externalNonBrowserApplication; + + LaunchMode get original { + switch (this) { + case LaunchModeEnum.platformDefault: + return LaunchMode.platformDefault; + case LaunchModeEnum.inAppWebView: + return LaunchMode.inAppWebView; + case LaunchModeEnum.externalApplication: + return LaunchMode.externalApplication; + case LaunchModeEnum.externalNonBrowserApplication: + return LaunchMode.externalNonBrowserApplication; + default: + return LaunchMode.externalApplication; + } + } +} diff --git a/lib/model/enums/LaunchModeEnum.g.dart b/lib/model/enums/LaunchModeEnum.g.dart new file mode 100644 index 00000000..e86ebfd9 --- /dev/null +++ b/lib/model/enums/LaunchModeEnum.g.dart @@ -0,0 +1,56 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'LaunchModeEnum.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class LaunchModeEnumAdapter extends TypeAdapter { + @override + final int typeId = 15; + + @override + LaunchModeEnum read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return LaunchModeEnum.platformDefault; + case 1: + return LaunchModeEnum.inAppWebView; + case 2: + return LaunchModeEnum.externalApplication; + case 3: + return LaunchModeEnum.externalNonBrowserApplication; + default: + return LaunchModeEnum.platformDefault; + } + } + + @override + void write(BinaryWriter writer, LaunchModeEnum obj) { + switch (obj) { + case LaunchModeEnum.platformDefault: + writer.writeByte(0); + break; + case LaunchModeEnum.inAppWebView: + writer.writeByte(1); + break; + case LaunchModeEnum.externalApplication: + writer.writeByte(2); + break; + case LaunchModeEnum.externalNonBrowserApplication: + writer.writeByte(3); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is LaunchModeEnumAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/model/enums/PostTypeEnum.dart b/lib/model/enums/PostTypeEnum.dart index f8828e20..6951782e 100644 --- a/lib/model/enums/PostTypeEnum.dart +++ b/lib/model/enums/PostTypeEnum.dart @@ -1 +1 @@ -enum PostTypeEnum { text, poll, dice, log_message, event, advertisement, discussion_request, registration } \ No newline at end of file +enum PostTypeEnum { text, poll, dice, log_message, event, advertisement, discussion_request, registration, multi_poll } diff --git a/lib/model/enums/RefreshDataEnum.dart b/lib/model/enums/RefreshDataEnum.dart new file mode 100644 index 00000000..c589d104 --- /dev/null +++ b/lib/model/enums/RefreshDataEnum.dart @@ -0,0 +1 @@ +enum RefreshDataEnum { bookmarks, mail, all } diff --git a/lib/model/enums/SkinEnum.dart b/lib/model/enums/SkinEnum.dart new file mode 100644 index 00000000..07eccbd7 --- /dev/null +++ b/lib/model/enums/SkinEnum.dart @@ -0,0 +1,11 @@ +import 'package:hive/hive.dart'; + +part 'SkinEnum.g.dart'; + +@HiveType(typeId: 20) +enum SkinEnum { + @HiveField(0) + fyx, + @HiveField(1) + forest, +} diff --git a/lib/model/enums/SkinEnum.g.dart b/lib/model/enums/SkinEnum.g.dart new file mode 100644 index 00000000..c5a2462c --- /dev/null +++ b/lib/model/enums/SkinEnum.g.dart @@ -0,0 +1,46 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'SkinEnum.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class SkinEnumAdapter extends TypeAdapter { + @override + final int typeId = 20; + + @override + SkinEnum read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return SkinEnum.fyx; + case 1: + return SkinEnum.forest; + default: + return SkinEnum.fyx; + } + } + + @override + void write(BinaryWriter writer, SkinEnum obj) { + switch (obj) { + case SkinEnum.fyx: + writer.writeByte(0); + break; + case SkinEnum.forest: + writer.writeByte(1); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SkinEnumAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/model/enums/TabsEnum.dart b/lib/model/enums/TabsEnum.dart new file mode 100644 index 00000000..b2900ac8 --- /dev/null +++ b/lib/model/enums/TabsEnum.dart @@ -0,0 +1 @@ +enum TabsEnum { history, bookmarks } diff --git a/lib/model/post/content/Advertisement.dart b/lib/model/post/content/Advertisement.dart index 2a53040e..8844c107 100644 --- a/lib/model/post/content/Advertisement.dart +++ b/lib/model/post/content/Advertisement.dart @@ -75,7 +75,7 @@ class ContentAdvertisement extends Content { } factory ContentAdvertisement.fromPostJson(Map json, {bool isCompact = false}) { - ContentAdvertisement ad = ContentAdvertisement.fromJson(json['content_raw']['data'], isCompact: isCompact); + ContentAdvertisement ad = ContentAdvertisement.fromJson(json, isCompact: isCompact); // We are not using 👇 anywhere + it causes the app to freeze // ad.contentRegular = ContentRegular(json['content']); return ad; diff --git a/lib/model/post/content/Regular.dart b/lib/model/post/content/Regular.dart index 12df6ff8..48ba6e24 100644 --- a/lib/model/post/content/Regular.dart +++ b/lib/model/post/content/Regular.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:fyx/model/enums/PostTypeEnum.dart'; import 'package:fyx/model/post/Content.dart'; import 'package:fyx/model/post/Image.dart'; @@ -111,8 +113,8 @@ class ContentRegular extends Content { _body = _body.replaceAll(trailingBr, ''); var xmpTag = RegExp(r'(.*?)', caseSensitive: false, multiLine: true, dotAll: true); - _body = _body.replaceAllMapped(xmpTag, (match) => '
${match.group(1)}
'); - _rawBody = _rawBody.replaceAllMapped(xmpTag, (match) => '
${match.group(1)}
'); + _body = _body.replaceAllMapped(xmpTag, (match) => '
${HtmlEscape().convert(match.group(1) ?? '')}
'); // TODO: Improve performance? + _rawBody = _rawBody.replaceAllMapped(xmpTag, (match) => '
${HtmlEscape().convert(match.group(1) ?? '')}
'); // TODO: Improve performance? } catch (error) { Sentry.captureException(error, stackTrace: StackTrace.current); } diff --git a/lib/model/post/content/post_content_text.dart b/lib/model/post/content/post_content_text.dart new file mode 100644 index 00000000..63f781b8 --- /dev/null +++ b/lib/model/post/content/post_content_text.dart @@ -0,0 +1,14 @@ +import 'package:fyx/model/enums/PostTypeEnum.dart'; +import 'package:fyx/model/post/Content.dart'; + +enum ContentFormat { text, html, markdown } + +class PostContentText extends Content { + late final String data; + late final ContentFormat format; + + PostContentText.fromJson(Map json) : super(PostTypeEnum.text, isCompact: false) { + this.data = json['data'] ?? ''; + this.format = ContentFormat.values.firstWhere((value) => value.toString().contains(json['format'])); + } +} diff --git a/lib/model/provider/ThemeModel.dart b/lib/model/provider/ThemeModel.dart index 4de1a661..4eda1f6a 100644 --- a/lib/model/provider/ThemeModel.dart +++ b/lib/model/provider/ThemeModel.dart @@ -1,16 +1,35 @@ import 'package:flutter/cupertino.dart'; +import 'package:fyx/model/enums/SkinEnum.dart'; import 'package:fyx/model/enums/ThemeEnum.dart'; class ThemeModel extends ChangeNotifier { final ThemeEnum initialTheme; - ThemeModel(this.initialTheme); + final SkinEnum initialSkin; + final double initialFontSize; + ThemeModel(this.initialTheme, this.initialFontSize, {this.initialSkin = SkinEnum.fyx}); ThemeEnum? _theme; + SkinEnum? _skin; + double? _fontSize; ThemeEnum get theme => _theme == null ? this.initialTheme : _theme!; + SkinEnum get skin => _skin == null ? this.initialSkin : _skin!; + + double get fontSize => _fontSize == null ? this.initialFontSize : _fontSize!; + void setTheme(ThemeEnum val) { _theme = val; notifyListeners(); } + + void setFontSize(double val) { + _fontSize = val; + notifyListeners(); + } + + void setSkin(SkinEnum skin) { + _skin = skin; + notifyListeners(); + } } diff --git a/lib/model/reponses/DiscussionHomeResponse.dart b/lib/model/reponses/DiscussionHomeResponse.dart index 638c55df..4cd3a5b4 100644 --- a/lib/model/reponses/DiscussionHomeResponse.dart +++ b/lib/model/reponses/DiscussionHomeResponse.dart @@ -1,24 +1,15 @@ import 'package:fyx/model/Discussion.dart'; +import 'package:fyx/model/DiscussionContent.dart'; import 'package:fyx/model/ResponseContext.dart'; class DiscussionHomeResponse { - late Discussion _discussion; - late ResponseContext _context; - List _home = []; - List _header = []; + late final Discussion discussion; + late final ResponseContext context; + late final List items; DiscussionHomeResponse.fromJson(Map json) { - this._home = List.from(json['home'] ?? []); - this._header = List.from(json['header'] ?? []); - this._discussion = Discussion.fromJson(json['discussion']); - this._context = ResponseContext.fromJson(json['context']); + this.discussion = Discussion.fromJson(json['discussion_common']); + this.context = ResponseContext.fromJson(json['context']); + this.items = (json['items'] as List).map((item) => DiscussionContent.fromJson(item)).toList(); } - - ResponseContext get context => _context; - - Discussion get discussion => _discussion; - - List get header => _header; - - List get home => _home; } diff --git a/lib/model/reponses/DiscussionResponse.dart b/lib/model/reponses/DiscussionResponse.dart index 3a194149..9733e936 100644 --- a/lib/model/reponses/DiscussionResponse.dart +++ b/lib/model/reponses/DiscussionResponse.dart @@ -1,30 +1,47 @@ import 'package:fyx/model/Discussion.dart'; +import 'package:fyx/model/DiscussionContent.dart'; import 'package:fyx/model/ResponseContext.dart'; +import 'package:fyx/model/enums/DiscussionTypeEnum.dart'; + +enum DiscussionSpecificDataEnum { header } class DiscussionResponse { - late Discussion _discussion; - List _posts = []; - late ResponseContext _context; + late final Discussion discussion; // TODO: Rename this to DiscussionCommon + Map>? discussionSpecificData; + late final List posts; + ResponseContext? context; + bool error = false; DiscussionResponse.accessDenied() { - this._discussion = Discussion.fromJson(null); - this._posts = []; + this.discussion = Discussion.fromJson(null); + this.posts = []; } // TODO: Return something more relevant. DiscussionResponse.error() { - DiscussionResponse.accessDenied(); + this.error = true; + this.discussion = Discussion.fromJson(null); + this.posts = []; } DiscussionResponse.fromJson(Map json) { - this._discussion = Discussion.fromJson(json['discussion_common']); - this._posts = json['posts'] ?? []; - this._context = ResponseContext.fromJson(json['context']); - } - - Discussion get discussion => _discussion; + this.discussion = Discussion.fromJson(json['discussion_common']); + this.posts = json['posts'] ?? []; + this.context = ResponseContext.fromJson(json['context']); - List get posts => _posts; - - ResponseContext get context => _context; + switch (this.discussion.type) { + case DiscussionTypeEnum.discussion: + this.discussionSpecificData = { + DiscussionSpecificDataEnum.header: + List.from(json['discussion_common']['discussion_specific_data']['header']).map((item) => DiscussionContent.fromJson(item)).toList() + }; + break; + case DiscussionTypeEnum.event: + // TODO: Handle this case. + break; + case DiscussionTypeEnum.advertisement: + // TODO: Handle this case. + break; + } + } } diff --git a/lib/model/reponses/UnifiedSearchResponse.dart b/lib/model/reponses/UnifiedSearchResponse.dart new file mode 100644 index 00000000..f02a80b3 --- /dev/null +++ b/lib/model/reponses/UnifiedSearchResponse.dart @@ -0,0 +1,41 @@ +enum UnifiedSearchType { discussions, events, advertisements } + +class UnifiedSearchResponse { + late Map> discussion; + + UnifiedSearchResponse(this.discussion); + + UnifiedSearchResponse.fromJson(Map json) { + this.discussion = { + UnifiedSearchType.discussions: (json['discussion']['discussions'] as List).map((item) => UnifiedDiscussionModel.fromJson(item)).toList(), + UnifiedSearchType.events: (json['discussion']['events'] as List).map((item) => UnifiedDiscussionModel.fromJson(item)).toList(), + UnifiedSearchType.advertisements: (json['discussion']['advertisements'] as List).map((item) => UnifiedDiscussionModel.fromJson(item)).toList(), + }; + } +} + +class UnifiedDiscussionModel { + late int id; + late String discussionType; + late String discussionName; + late String discussionNameHighlighted; + late String summary; + late String summaryHighlighted; + + UnifiedDiscussionModel( + {required this.id, + required this.discussionType, + required this.discussionName, + this.discussionNameHighlighted = '', + this.summary = '', + this.summaryHighlighted = ''}); + + UnifiedDiscussionModel.fromJson(Map json) { + this.id = json['id']; + this.discussionType = json['discussion_type']; + this.discussionName = json['discussion_name']; + this.discussionNameHighlighted = json['discussion_name_highlighted'] ?? ''; + this.summary = json['summary'] ?? ''; + this.summaryHighlighted = json['summary_highlighted'] ?? ''; + } +} diff --git a/lib/pages/DiscussionPage.dart b/lib/pages/DiscussionPage.dart index bd068d89..71af0c76 100644 --- a/lib/pages/DiscussionPage.dart +++ b/lib/pages/DiscussionPage.dart @@ -2,23 +2,33 @@ import 'package:async/async.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; -import 'package:fyx/components/PullToRefreshList.dart'; -import 'package:fyx/components/post/Advertisement.dart'; -import 'package:fyx/components/post/PostListItem.dart'; -import 'package:fyx/components/post/SyntaxHighlighter.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fyx/components/discussion_page_scaffold.dart'; +import 'package:fyx/components/post/advertisement.dart'; +import 'package:fyx/components/post/post_list_item.dart'; +import 'package:fyx/components/post/syntax_highlighter.dart'; +import 'package:fyx/components/pull_to_refresh_list.dart'; import 'package:fyx/controllers/AnalyticsProvider.dart'; import 'package:fyx/controllers/ApiController.dart'; import 'package:fyx/controllers/IApiProvider.dart'; import 'package:fyx/model/MainRepository.dart'; import 'package:fyx/model/Post.dart'; +import 'package:fyx/model/Settings.dart'; +import 'package:fyx/model/enums/DiscussionTypeEnum.dart'; import 'package:fyx/model/post/content/Advertisement.dart'; import 'package:fyx/model/reponses/DiscussionResponse.dart'; import 'package:fyx/pages/NewMessagePage.dart'; +import 'package:fyx/pages/discussion_home_page.dart'; +import 'package:fyx/state/batch_actions_provider.dart'; import 'package:fyx/theme/L.dart'; import 'package:fyx/theme/T.dart'; import 'package:fyx/theme/skin/Skin.dart'; import 'package:fyx/theme/skin/SkinColors.dart'; import 'package:hive_flutter/hive_flutter.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:scroll_to_index/scroll_to_index.dart'; +import 'package:tap_canvas/tap_canvas.dart'; class DiscussionPageArguments { final int discussionId; @@ -29,17 +39,33 @@ class DiscussionPageArguments { DiscussionPageArguments(this.discussionId, {this.postId, this.filterByUser, this.search}); } -class DiscussionPage extends StatefulWidget { +class DiscussionPage extends ConsumerStatefulWidget { @override _DiscussionPageState createState() => _DiscussionPageState(); } -class _DiscussionPageState extends State { +class _DiscussionPageState extends ConsumerState { final AsyncMemoizer _memoizer = AsyncMemoizer(); late SkinColors colors; int _refreshList = 0; bool _hasInitData = false; + // Did we close the context menu by tapping outside? + // Tapping on menu button is outside click. -> This toggle solves the issue of closing and immediate opening + // the menu when the menu is open and user clicks on three dots... + bool _closedByOutsideTap = false; + + // Display the context menu? + bool _popupMenu = false; + + // Is the discussion saved in bookmarks? + bool? _bookmark; + + String? _searchTerm; + + // Progress indicator if some posts are being deleted... + bool _deleting = false; + Future _fetchData(discussionId, postId, user, {String? search}) { return this._memoizer.runOnce(() { return Future.delayed( @@ -79,41 +105,40 @@ class _DiscussionPageState extends State { isWarning: true, title: snapshot.error.toString(), label: L.GENERAL_CLOSE, onPress: () => Navigator.of(context).pop()); } if (snapshot.hasData) { - if (snapshot.data!.discussion.accessDenied) { + if (snapshot.data!.error) { + return T.feedbackScreen(context, + isWarning: true, + title: 'Něco se pokazilo.\nChybu, prosím, nahlašte ID LUCIEN.', + label: L.GENERAL_CLOSE, + onPress: () => Navigator.of(context).pop()); + } else if (snapshot.data!.discussion.accessDenied) { return T.feedbackScreen(context, title: L.ACCESS_DENIED_ERROR, icon: Icons.do_not_disturb_alt, label: L.GENERAL_CLOSE, onPress: () => Navigator.of(context).pop()); } + _bookmark ??= (snapshot.data!.discussion.bookmark?.bookmark ?? false); return this._createDiscussionPage(snapshot.data!, pageArguments); } return _pageScaffold(title: L.GENERAL_LOADING, body: T.feedbackScreen(context, isLoading: true)); }); } - Widget _pageScaffold({required String title, required Widget body}) { - return CupertinoPageScaffold( - navigationBar: CupertinoNavigationBar( - leading: CupertinoNavigationBarBackButton( - color: colors.primary, - onPressed: () { - Navigator.of(context).pop(); + Widget _pageScaffold({required String title, required Widget body, DiscussionResponse? discussionResponse}) { + return DiscussionPageScaffold( + title: title, + child: body, + trailing: Visibility( + visible: discussionResponse != null && discussionResponse.discussion.type == DiscussionTypeEnum.discussion, + child: GestureDetector( + onTap: () { + if (_closedByOutsideTap) { + setState(() => _closedByOutsideTap = false); // reset _closedByOutsideTap + return; + } + setState(() => _popupMenu = true); }, + child: Icon(Icons.more_horiz), ), - middle: Container( - alignment: Alignment.center, - width: MediaQuery.of(context).size.width - 120, - child: Tooltip( - message: title, - child: Text( - // https://github.com/flutter/flutter/issues/18761 - Characters(title).replaceAll(Characters(''), Characters('\u{200B}')).toString(), - style: TextStyle(color: colors.text), - overflow: TextOverflow.ellipsis), - padding: EdgeInsets.all(8.0), // needed until https://github.com/flutter/flutter/issues/86170 is fixed - margin: EdgeInsets.all(8.0), - showDuration: Duration(seconds: 3), - ))), - child: body, - ); + )); } Widget _createDiscussionPage(DiscussionResponse discussionResponse, DiscussionPageArguments pageArguments) { @@ -121,101 +146,306 @@ class _DiscussionPageState extends State { // TODO: Not ideal, probably better to use Provider. Or not? SyntaxHighlighter.languageContext = discussionResponse.discussion.name; + final textStyleContext = TextStyle(fontSize: Settings().fontSize); + return _pageScaffold( + discussionResponse: discussionResponse, title: discussionResponse.discussion.name, body: Stack( children: [ - NotificationListener( - onNotification: (notification) { - void rebuild(Element el) { - el.markNeedsBuild(); - el.visitChildren(rebuild); - } - - // Rebuild the widget tree if Dismissible didn't removed the item due to the server error. - (context as Element).visitChildren(rebuild); - return false; + PullToRefreshList>( + searchLabel: 'Hledej @nick a nebo text...', + searchTerm: this._searchTerm, + onSearch: (term) { + setState(() => this._searchTerm = term); + this.refresh(); }, - child: PullToRefreshList( - rebuild: _refreshList, - isInfinite: true, - pinnedWidget: getPinnedWidget(discussionResponse), - sliverListBuilder: (List data) { - return ValueListenableBuilder( - valueListenable: MainRepository().settings.box.listenable(keys: ['blockedPosts', 'blockedUsers']), - builder: (BuildContext context, value, Widget? child) { - var filtered = data; - if (data[0] is PostListItem) { - filtered = data - .where((item) => !MainRepository().settings.isPostBlocked((item as PostListItem).post.id)) - .where((item) => !MainRepository().settings.isUserBlocked((item as PostListItem).post.nick)) - .toList(); - } - return SliverList( - delegate: SliverChildBuilderDelegate( - (context, i) => filtered[i], - childCount: filtered.length, - ), - ); - }, - ); - }, - dataProvider: (lastId) async { - var result; - if (lastId != null) { - // If we load next page(s) - var response = await ApiController().loadDiscussion(pageArguments.discussionId, lastId: lastId, user: pageArguments.filterByUser); - result = response.posts; - } else { - // If we load init data or we refresh data on pull - if (!this._hasInitData) { - // If we load init data, use the data from FutureBuilder - result = discussionResponse.posts; - this._hasInitData = true; - } else { - // If we just pull to refresh, load a fresh data - var response = await ApiController().loadDiscussion(pageArguments.discussionId, user: pageArguments.filterByUser); - result = response.posts; + onSearchClear: () { + setState(() => this._searchTerm = ''); + this.refresh(); + }, + rebuild: _refreshList, + isInfinite: true, + pinnedWidget: getPinnedWidget(discussionResponse), + sliverListBuilder: (List data, {controller}) { + return ValueListenableBuilder( + valueListenable: MainRepository().settings.box.listenable(keys: ['blockedPosts', 'blockedUsers']), + builder: (BuildContext context, value, Widget? child) { + var filtered = data; + if (data[0] is PostListItem) { + filtered = data + .where((item) => !MainRepository().settings.isPostBlocked((item as PostListItem).post.id)) + .where((item) => !MainRepository().settings.isUserBlocked((item as PostListItem).post.nick)) + .toList(); } + final kUnreadIndex = filtered.lastIndexWhere((item) => (item as PostListItem).post.isNew); + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) { + final postItem = AutoScrollTag(child: filtered[i], key: ValueKey(i), index: i, controller: controller); + if (i == kUnreadIndex) { + return unseenPill(postItem, kUnreadIndex + 1); + } + return postItem; + }, + childCount: filtered.length, + ), + ); + }, + ); + }, + dataProvider: (lastId) async { + var response; + var result; + if (lastId != null) { + // If we load next page(s) + response = await ApiController() + .loadDiscussion(pageArguments.discussionId, lastId: lastId, user: pageArguments.filterByUser, search: this._searchTerm); + } else { + // If we load init data or we refresh data on pull + if (!this._hasInitData) { + // If we load init data, use the data from FutureBuilder + response = discussionResponse; + this._hasInitData = true; + } else { + // If we just pull to refresh, load a fresh data + response = + await ApiController().loadDiscussion(pageArguments.discussionId, user: pageArguments.filterByUser, search: this._searchTerm); } - List data = (result as List) - .map((post) { - return Post.fromJson(post, pageArguments.discussionId, isCompact: MainRepository().settings.useCompactMode); - }) - .where((post) => !MainRepository().settings.isPostBlocked(post.id)) - .where((post) => !MainRepository().settings.isUserBlocked(post.nick)) - .map((post) => PostListItem(post, onUpdate: this.refresh, isHighlighted: post.isNew)) - .toList(); - - int? id; - try { - id = Post.fromJson((result as List).last, pageArguments.discussionId, isCompact: MainRepository().settings.useCompactMode).id; - } catch (error) {} - return DataProviderResult(data, lastId: id); - }, - ), + } + + result = response.posts; + List data = (result as List) + .map((post) { + return Post.fromJson(post, pageArguments.discussionId, isCompact: MainRepository().settings.useCompactMode); + }) + .where((post) => !MainRepository().settings.isPostBlocked(post.id)) + .where((post) => !MainRepository().settings.isUserBlocked(post.nick)) + .map((post) => PostListItem(post, onUpdate: this.refresh, isHighlighted: post.isNew)) + .toList(); + + int? id; + try { + id = Post.fromJson((result as List).last, pageArguments.discussionId, isCompact: MainRepository().settings.useCompactMode).id; + } catch (error) {} + return DataProviderResult(data, + lastId: id, + postId: pageArguments.postId, + jumpIndex: response.discussion.lastVisit > 0 ? data.where((listItem) => listItem.post.isNew).length : 0); + }, ), Visibility( visible: discussionResponse.discussion.accessRights.canWrite != false || discussionResponse.discussion.rights.canWrite != false, child: Positioned( right: 20, + left: 20, bottom: 20, child: SafeArea( - child: FloatingActionButton( - backgroundColor: colors.primary, - foregroundColor: colors.background, - child: Icon(Icons.add), - onPressed: () => Navigator.of(context).pushNamed('/new-message', - arguments: NewMessageSettings( - onClose: this.refresh, - onSubmit: (String? inputField, String message, List> attachments) async { - var result = await ApiController().postDiscussionMessage(pageArguments.discussionId, message, attachments: attachments); - return result.isOk; - })), + child: Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: ref.watch(PostsSelection.provider).isEmpty ? MainAxisAlignment.end : MainAxisAlignment.spaceBetween, + children: [ + if (ref.watch(PostsSelection.provider).isNotEmpty) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // if (discussionResponse.discussion.accessRights.canRights) + // Padding( + // padding: const EdgeInsets.only(bottom: 32.0), + // child: FloatingActionButton.extended( + // backgroundColor: _deleting ? colors.primary.withOpacity(.5) : colors.primary, + // foregroundColor: colors.background, + // label: Text('RO 7 dní'), + // icon: Icon(MdiIcons.messageLock, size: 30), + // onPressed: _deleting + // ? null + // : () async { + // setState(() => _deleting = true); + // try { + // // await Future.wait(ref.read(PostsSelection.provider).map((post) => ApiController().setWriteRights(discussionResponse.discussion.idKlub, post, false).then((_) { + // // ref.read(PostsSelection.provider.notifier).remove(post); + // // }))); + // T.success('RO uděleno.'); + // } catch (error) { + // T.warn('Některé příspěvky se nepodařilo smazat.'); + // } finally { + // setState(() => _deleting = false); + // } + // }, + // ), + // ), + FloatingActionButton.extended( + backgroundColor: _deleting ? colors.danger.withOpacity(.5) : colors.danger, + foregroundColor: colors.background, + label: _deleting ? CupertinoActivityIndicator() : Text('${ref.read(PostsSelection.provider).length.toString()}×'), + icon: Icon(Icons.delete, size: 30), + onPressed: _deleting + ? null + : () async { + setState(() => _deleting = true); + try { + await Future.wait(ref + .read(PostsSelection.provider) + .map((post) => ApiController().deleteDiscussionMessage(discussionResponse.discussion.idKlub, post.id).then((_) { + ref.read(PostsToDelete.provider.notifier).add(post); + ref.read(PostsSelection.provider.notifier).remove(post); + }))); + T.success('Smazáno.'); + } catch (error) { + T.warn('Některé příspěvky se nepodařilo smazat.'); + } finally { + setState(() => _deleting = false); + } + }, + ) + ], + ), + if (ref.watch(PostsSelection.provider).isNotEmpty) + FloatingActionButton( + backgroundColor: colors.text, + foregroundColor: colors.background, + child: Icon(Icons.cancel_rounded), + onPressed: () => ref.read(PostsSelection.provider.notifier).reset(), + ), + if (ref.watch(PostsSelection.provider).isEmpty) + FloatingActionButton( + backgroundColor: colors.primary, + foregroundColor: colors.background, + child: Icon(Icons.add), + onPressed: () => Navigator.of(context).pushNamed('/new-message', + arguments: NewMessageSettings( + onClose: this.refresh, + onSubmit: (String? inputField, String message, List> attachments) async { + var result = + await ApiController().postDiscussionMessage(pageArguments.discussionId, message, attachments: attachments); + return result.isOk; + })), + ), + ], ), ), ), ), + TapOutsideDetectorWidget( + onTappedOutside: () { + if (_popupMenu) { + setState(() { + _popupMenu = false; + _closedByOutsideTap = true; + }); + return; + } + setState(() => _closedByOutsideTap = false); // reset _closedByOutsideTap + }, + child: Positioned( + top: 10, + right: 12, + child: AnimatedOpacity( + opacity: _popupMenu ? 1 : 0, + duration: Duration(milliseconds: 280), + curve: Curves.linearToEaseOut, + child: AnimatedScale( + curve: Curves.linearToEaseOut, + scale: _popupMenu ? 1 : 0, + alignment: Alignment.topRight, + duration: Duration(milliseconds: 280), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(20)), + boxShadow: [ + BoxShadow( + color: colors.grey.withOpacity(0.4), //New + blurRadius: 15.0, + offset: Offset(0, 0)) + ], + ), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration(borderRadius: BorderRadius.all(Radius.circular(20)), color: colors.barBackground), + child: IntrinsicWidth( + child: Column(children: [ + Visibility( + visible: _bookmark != null, + child: GestureDetector( + onTap: () { + ApiController().bookmarkDiscussion(discussionResponse.discussion.idKlub, !_bookmark!); + setState(() { + _bookmark = !_bookmark!; + _popupMenu = false; + T.success(_bookmark! ? 'Přidáno do sledovaných.' : 'Odebráno ze sledovaných', duration: 1); + }); + }, + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded(child: Text(_bookmark! ? 'Klub sleduješ' : 'Klub nesleduješ', style: textStyleContext)), + _bookmark! ? Icon(MdiIcons.pin) : Icon(MdiIcons.pinOutline), + ], + ), + ), + ), + if (discussionResponse.discussion.hasHeader) + Divider( + color: colors.grey, + height: 26, + ), + if (discussionResponse.discussion.hasHeader) + GestureDetector( + onTap: () => Navigator.of(context) + .pushNamed('/discussion/header', arguments: new DiscussionHomePageArguments(discussionResponse)), + child: Row( + children: [ + Expanded(child: Text('Záhlaví', style: textStyleContext)), + Icon(MdiIcons.pageLayoutHeader), + ], + ), + ), + if (discussionResponse.discussion.hasHome) + Divider( + color: colors.grey, + height: 26, + ), + if (discussionResponse.discussion.hasHome) + GestureDetector( + onTap: () => Navigator.of(context) + .pushNamed('/discussion/home', arguments: new DiscussionHomePageArguments(discussionResponse)), + child: Row( + children: [ + Expanded(child: Text('Nástěnka', style: textStyleContext)), + Icon(MdiIcons.viewDashboardOutline), + ], + ), + ), + Divider( + color: colors.grey, + height: 26, + ), + GestureDetector( + onTap: () { + setState(() { + this._searchTerm = this._searchTerm == null ? '' : null; + this._popupMenu = false; + }); + }, + child: Row( + children: [ + Text(this._searchTerm == null ? 'Hledat v diskuzi' : 'Zavřít hledání', style: textStyleContext), + SizedBox( + width: 10, + ), + Expanded(child: Icon(this._searchTerm == null ? MdiIcons.magnify : MdiIcons.magnifyRemoveOutline)), + ], + ), + ), + ]), + ), + ), + ), + ), + ), + ), + ) ], )); } @@ -235,4 +465,34 @@ class _DiscussionPageState extends State { return null; } } + + Widget unseenPill(Widget postItem, unseenCount) { + return Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + postItem, + const Divider( + height: 8, + thickness: 8, + ), + Container( + color: colors.grey.withOpacity(.1), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Center( + child: Container( + child: Text( + '↑ Nové příspěvky ($unseenCount)', + style: TextStyle(color: colors.background, fontSize: FontSize.medium.size), + ), + decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.all(Radius.circular(12))), + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), + ), + ), + ), + ) + ], + ); + } } diff --git a/lib/pages/GalleryPage.dart b/lib/pages/GalleryPage.dart index cb68d505..7c96951b 100644 --- a/lib/pages/GalleryPage.dart +++ b/lib/pages/GalleryPage.dart @@ -5,7 +5,8 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:dio/dio.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:fyx/components/post/PostHeroAttachment.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:fyx/components/post/post_hero_attachment.dart'; import 'package:fyx/controllers/AnalyticsProvider.dart'; import 'package:fyx/exceptions/UnsupportedDownloadFormatException.dart'; import 'package:fyx/theme/Helpers.dart'; @@ -40,7 +41,7 @@ class _GalleryPageState extends State { @override void initState() { super.initState(); - WidgetsBinding.instance?.addPostFrameCallback((_) { + WidgetsBinding.instance.addPostFrameCallback((_) { if (_arguments != null && _arguments!.images.length > 1) { _arguments!.images.asMap().forEach((key, image) { if (image.image == _arguments!.imageUrl) { @@ -74,7 +75,8 @@ class _GalleryPageState extends State { scrollPhysics: const BouncingScrollPhysics(), builder: (BuildContext context, int index) { return PhotoViewGalleryPageOptions( - imageProvider: CachedNetworkImageProvider(_arguments!.images[index].image), onTapDown: (_, __, ___) => close(context)); + imageProvider: CachedNetworkImageProvider( + _arguments!.images[index].image, cacheManager: CacheManager(Config(_arguments!.images[index].image, stalePeriod: const Duration(days: 7)))), onTapDown: (_, __, ___) => close(context)); }, itemCount: _arguments!.images.length, loadingBuilder: (context, chunkEvent) => CupertinoActivityIndicator( diff --git a/lib/pages/HomePage.dart b/lib/pages/HomePage.dart index ab1791ef..1a16148b 100644 --- a/lib/pages/HomePage.dart +++ b/lib/pages/HomePage.dart @@ -1,28 +1,20 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; import 'package:fyx/FyxApp.dart'; -import 'package:fyx/components/Avatar.dart' as ca; -import 'package:fyx/components/DiscussionListItem.dart'; -import 'package:fyx/components/ListHeader.dart'; -import 'package:fyx/components/NotificationBadge.dart'; -import 'package:fyx/components/PullToRefreshList.dart'; +import 'package:fyx/components/bottom_tab_bar.dart'; +import 'package:fyx/components/notification_badge.dart'; import 'package:fyx/controllers/AnalyticsProvider.dart'; import 'package:fyx/controllers/ApiController.dart'; -import 'package:fyx/model/BookmarkedDiscussion.dart'; import 'package:fyx/model/MainRepository.dart'; import 'package:fyx/model/enums/DefaultView.dart'; +import 'package:fyx/model/enums/RefreshDataEnum.dart'; import 'package:fyx/model/provider/NotificationsModel.dart'; -import 'package:fyx/pages/MailboxPage.dart'; -import 'package:fyx/theme/L.dart'; -import 'package:fyx/theme/T.dart'; +import 'package:fyx/pages/tab_bar/BookmarksTab.dart'; +import 'package:fyx/pages/tab_bar/MailboxTab.dart'; import 'package:fyx/theme/skin/Skin.dart'; -import 'package:fyx/theme/skin/SkinColors.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:provider/provider.dart'; -enum ETabs { history, bookmarks } -enum ERefreshData { bookmarks, mail, all } - class HomePageArguments { final pageIndex; @@ -38,39 +30,29 @@ class HomePage extends StatefulWidget { } class _HomePageState extends State with RouteAware, WidgetsBindingObserver { - late PageController _bookmarksController; - - ETabs activeTab = ETabs.history; int _pageIndex = 0; - Map _refreshData = {'bookmarks': 0, 'mail': 0}; + Map _refreshData = {RefreshDataEnum.bookmarks: 0, RefreshDataEnum.mail: 0}; bool _filterUnread = false; - DefaultView _defaultView = DefaultView.history; - List _toggledCategories = []; + bool _showSubmenu = false; HomePageArguments? _arguments; @override void initState() { super.initState(); - WidgetsBinding.instance?.addObserver(this); - - _defaultView = MainRepository().settings.defaultView == DefaultView.latest ? MainRepository().settings.latestView : MainRepository().settings.defaultView; - _filterUnread = [DefaultView.bookmarksUnread, DefaultView.historyUnread].indexOf(_defaultView) >= 0; - - activeTab = [DefaultView.history, DefaultView.historyUnread].indexOf(_defaultView) >= 0 ? ETabs.history : ETabs.bookmarks; - if (activeTab == ETabs.bookmarks) { - _bookmarksController = PageController(initialPage: 1); - } else { - _bookmarksController = PageController(initialPage: 0); - } - - _bookmarksController.addListener(() { - // If the CupertinoTabView is sliding and the animation is finished, change the active tab - if (_bookmarksController.page! % 1 == 0 && activeTab != ETabs.values[_bookmarksController.page!.toInt()]) { - setState(() { - activeTab = ETabs.values[_bookmarksController.page!.toInt()]; - }); + WidgetsBinding.instance.addObserver(this); + + final defaultView = + MainRepository().settings.defaultView == DefaultView.latest ? MainRepository().settings.latestView : MainRepository().settings.defaultView; + _filterUnread = [DefaultView.bookmarksUnread, DefaultView.historyUnread].indexOf(defaultView) >= 0; + + () async { + await Future.delayed(Duration.zero); + final Object? _objArguments = ModalRoute.of(context)?.settings.arguments; + if (_objArguments != null) { + _arguments = _objArguments as HomePageArguments; + setState(() => _pageIndex = _arguments?.pageIndex); } - }); + }(); // Request for push notifications MainRepository().notifications.request(); @@ -87,18 +69,19 @@ class _HomePageState extends State with RouteAware, WidgetsBindingObse @override void dispose() { - _bookmarksController.dispose(); FyxApp.routeObserver.unsubscribe(this); - WidgetsBinding.instance?.removeObserver(this); + WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { + setState(() => _showSubmenu = false); + // If we omit the Route check, there's very rare issue during authorization // See: https://github.com/lucien144/fyx/issues/57 if (state == AppLifecycleState.resumed && ModalRoute.of(context)!.isCurrent) { - this.refreshData(_pageIndex == HomePage.PAGE_MAIL ? ERefreshData.mail : ERefreshData.bookmarks); + this.refreshData(_pageIndex == HomePage.PAGE_MAIL ? RefreshDataEnum.mail : RefreshDataEnum.bookmarks); } } @@ -110,7 +93,7 @@ class _HomePageState extends State with RouteAware, WidgetsBindingObse // Called when the current route has been pushed. void didPopNext() { - this.refreshData(ERefreshData.bookmarks); + this.refreshData(RefreshDataEnum.bookmarks); } void didPush() { @@ -121,271 +104,92 @@ class _HomePageState extends State with RouteAware, WidgetsBindingObse // Called when the current route has been popped off. } + // Called when a new route has been pushed, and the current route is no longer visible. void didPushNext() { - // Called when a new route has been pushed, and the current route is no longer visible. + setState(() => _showSubmenu = false); } - void refreshData(ERefreshData type) { + void refreshData(RefreshDataEnum type) { setState(() { switch (type) { - case ERefreshData.bookmarks: - _refreshData['bookmarks'] = DateTime.now().millisecondsSinceEpoch; + case RefreshDataEnum.bookmarks: + _refreshData[RefreshDataEnum.bookmarks] = DateTime.now().millisecondsSinceEpoch; break; - case ERefreshData.mail: - _refreshData['mail'] = DateTime.now().millisecondsSinceEpoch; + case RefreshDataEnum.mail: + _refreshData[RefreshDataEnum.mail] = DateTime.now().millisecondsSinceEpoch; break; default: - _refreshData['bookmarks'] = DateTime.now().millisecondsSinceEpoch; - _refreshData['mail'] = DateTime.now().millisecondsSinceEpoch; + _refreshData[RefreshDataEnum.bookmarks] = DateTime.now().millisecondsSinceEpoch; + _refreshData[RefreshDataEnum.mail] = DateTime.now().millisecondsSinceEpoch; break; } }); } - Widget actionSheet(BuildContext context) { - return CupertinoActionSheet( - title: Text('Přihlášen jako: ${MainRepository().credentials!.nickname}'), - actions: [ - CupertinoActionSheetAction( - child: Text(L.SETTINGS), - onPressed: () { - Navigator.of(context).pop(); - Navigator.of(context, rootNavigator: true).pushNamed('/settings'); - }), - CupertinoActionSheetAction( - child: Text('⚠️ ${L.SETTINGS_BUGREPORT}'), - onPressed: () { - T.prefillGithubIssue(appContext: MainRepository(), user: MainRepository().credentials!.nickname); - AnalyticsProvider().logEvent('reportBug'); - }), - ], - cancelButton: CupertinoActionSheetAction( - isDefaultAction: true, - child: Text(L.GENERAL_CANCEL), - onPressed: () { - Navigator.pop(context); - }, - )); - } - @override Widget build(BuildContext context) { - SkinColors colors = Skin.of(context).theme.colors; - if (ApiController().buildContext == null || ApiController().buildContext.hashCode != context.hashCode) { ApiController().buildContext = context; } - final Object? _objArguments = ModalRoute.of(context)?.settings.arguments; - if (_objArguments != null) { - _arguments = _objArguments as HomePageArguments; - _pageIndex = _arguments?.pageIndex; - } + final double bottomPadding = MediaQuery.of(context).padding.bottom; + final colors = Skin.of(context).theme.colors; + final tabs = [ + BookmarksTab(filterUnread: _filterUnread, refreshTimestamp: _refreshData[RefreshDataEnum.bookmarks] ?? 0), + MailboxTab(refreshTimestamp: _refreshData[RefreshDataEnum.mail] ?? 0), + ]; return WillPopScope( onWillPop: () async => false, - child: CupertinoTabScaffold( - tabBar: CupertinoTabBar( - currentIndex: _pageIndex, - onTap: (index) { - if (_pageIndex == index && index == HomePage.PAGE_BOOKMARK) { - setState(() { - _filterUnread = !_filterUnread; - // Reset the category toggle - _toggledCategories = []; - this.updateLatestView(); - }); - } - setState(() => _pageIndex = index); - this.refreshData(_pageIndex == HomePage.PAGE_MAIL ? ERefreshData.mail : ERefreshData.bookmarks); - }, - items: [ - BottomNavigationBarItem( - icon: Icon(_filterUnread ? Icons.bookmarks : Icons.bookmarks_outlined, size: 34), - ), - BottomNavigationBarItem( - icon: Consumer( - builder: (context, notifications, child) => NotificationBadge( - widget: Icon( - Icons.email_outlined, - size: 42, - ), - counter: notifications.newMails, - isVisible: notifications.newMails > 0), - ), - ), - ], + child: Stack(children: [ + Positioned.fill( + // Do not prevent the scroll down if the submenu is up. Only hide the submenu and keep scrolling... + child: GestureDetector( + child: tabs[_pageIndex], + onVerticalDragDown: (_) => _showSubmenu ? setState(() => _showSubmenu = false) : null, + ), + bottom: 50 + bottomPadding, ), - tabBuilder: (context, index) { - switch (index) { - case HomePage.PAGE_BOOKMARK: - return CupertinoTabView(builder: (context) { - return CupertinoPageScaffold( - navigationBar: CupertinoNavigationBar( - leading: Consumer( - builder: (context, notifications, child) => NotificationBadge( - widget: CupertinoButton( - padding: EdgeInsets.zero, - minSize: kMinInteractiveDimensionCupertino - 10, - child: Icon( - Icons.notifications_none, - size: 30, - ), - onPressed: () => Navigator.of(context, rootNavigator: true).pushNamed('/notices')), - isVisible: notifications.newNotices > 0, - counter: notifications.newNotices)), - trailing: GestureDetector( - child: ca.Avatar( - MainRepository().credentials!.avatar, - size: 26 - ), - onTap: () { - showCupertinoModalPopup(context: context, builder: (BuildContext context) => actionSheet(context)); - }, - ), - middle: CupertinoSegmentedControl( - groupValue: activeTab, - onValueChanged: (value) { - _bookmarksController.animateToPage(ETabs.values.indexOf(value as ETabs), duration: Duration(milliseconds: 300), curve: Curves.easeInOut); - }, - children: { - ETabs.history: Padding( - child: Text('Historie'), - padding: EdgeInsets.symmetric(horizontal: 16), - ), - ETabs.bookmarks: Padding( - child: Text('Sledované'), - padding: EdgeInsets.symmetric(horizontal: 16), - ), - }, - )), - child: PageView( - controller: _bookmarksController, - onPageChanged: (int index) => this.updateLatestView(isInverted: true), - pageSnapping: true, - children: [ - // ----- - // HISTORY PULL TO REFRESH - // ----- - PullToRefreshList( - rebuild: _refreshData['bookmarks'] ?? 0, - dataProvider: (lastId) async { - List withReplies = []; - var result = await ApiController().loadHistory(); - var data = result.discussions - .map((discussion) => BookmarkedDiscussion.fromJson(discussion)) - .where((discussion) => this._filterUnread ? discussion.unread > 0 : true) - .map((discussion) => DiscussionListItem(discussion)) - .where((discussionListItem) { - if (discussionListItem.discussion.replies > 0) { - withReplies.add(discussionListItem); - return false; - } - return true; - }).toList(); - data.insertAll(0, withReplies); - return DataProviderResult(data); - }), - // ----- - // BOOKMARKS PULL TO REFRESH - // ----- - PullToRefreshList( - rebuild: _refreshData['bookmarks'] ?? 0, - dataProvider: (lastId) async { - var categories = []; - var result = await ApiController().loadBookmarks(); - - result.bookmarks.forEach((_bookmark) { - List withReplies = []; - var discussion = _bookmark.discussions - .where((discussion) { - // Filter by tapping on category headers - // If unread filter is ON - if (this._filterUnread) { - if (_toggledCategories.indexOf(_bookmark.categoryId) >= 0) { - // If unread filter is ON and category toggle is ON, display discussions - return true; - } else { - // If unread filter is ON and category toggle is OFF, display unread discussions only - return discussion.unread > 0; - } - } else { - if (_toggledCategories.indexOf(_bookmark.categoryId) >= 0) { - // If unread filter is OFF and category toggle is ON, hide discussions - return false; - } - } - // If unread filter is OFF and category toggle is OFF, show discussions - return true; - }) - .map((discussion) => DiscussionListItem(discussion)) - .where((discussionListItem) { - if (discussionListItem.discussion.replies > 0) { - withReplies.add(discussionListItem); - return false; - } - return true; - }) - .toList(); - discussion.insertAll(0, withReplies); - categories.add({ - 'header': ListHeader(_bookmark.categoryName, onTap: () { - if (_toggledCategories.indexOf(_bookmark.categoryId) >= 0) { - // Hide discussions in the category - setState(() => _toggledCategories.remove(_bookmark.categoryId)); - } else { - // Show discussions in the category - setState(() => _toggledCategories.add(_bookmark.categoryId)); - } - this.refreshData(ERefreshData.bookmarks); - }), - 'items': discussion - }); - }); - return DataProviderResult(categories); - }), - ], - ), - ); - }); - case HomePage.PAGE_MAIL: - return CupertinoTabView(builder: (context) { - return CupertinoPageScaffold( - navigationBar: CupertinoNavigationBar( - trailing: GestureDetector( - child: ca.Avatar( - MainRepository().credentials!.avatar, - size: 26, - ), - onTap: () { - showCupertinoModalPopup(context: context, builder: (BuildContext context) => actionSheet(context)); - }, - ), - middle: Text('Pošta', style: TextStyle(color: colors.text),)), - child: MailboxPage( - refreshData: _refreshData['mail'] ?? 0, - )); - }); - default: - throw Exception('Selected undefined tab'); - } - }, - ), + Positioned.fill( + // Prevent the tap if submenu is up! + child: GestureDetector( + behavior: _showSubmenu ? HitTestBehavior.translucent : HitTestBehavior.deferToChild, + onTap: () => _showSubmenu ? setState(() => _showSubmenu = false) : null, + child: BottomTabBar( + activeSubmenu: _showSubmenu, + onTap: (index) { + if (index == tabs.length) { + setState(() => _showSubmenu = !_showSubmenu); + return; + } + + if (_pageIndex == index && index == HomePage.PAGE_BOOKMARK) { + setState(() => _filterUnread = !_filterUnread); + } + setState(() { + _pageIndex = index; + _showSubmenu = false; + }); + this.refreshData(_pageIndex == HomePage.PAGE_MAIL ? RefreshDataEnum.mail : RefreshDataEnum.bookmarks); + }, + items: [ + Icon(_filterUnread ? Icons.bookmarks : Icons.bookmarks_outlined, size: 34, color: _pageIndex == 0 ? colors.primary : colors.grey), + Consumer( + builder: (context, notifications, child) => NotificationBadge( + widget: Icon(Icons.email_outlined, size: 42, color: _pageIndex == 1 ? colors.primary : colors.grey), + counter: notifications.newMails, + isVisible: notifications.newMails > 0), + ), + Center( + child: Icon( + MdiIcons.menu, + size: 34, + )) + ], + ), + ), + ) + ]), ); } - - // isInverted - // Sometimes the activeTab var is changed after the listener where we call updateLatestView() finishes. - // Therefore, the var activeTab needs to be handled as inverted. - void updateLatestView({bool isInverted: false}) { - DefaultView latestView = activeTab == ETabs.history ? DefaultView.history : DefaultView.bookmarks; - if (isInverted) { - latestView = activeTab == ETabs.history ? DefaultView.bookmarks : DefaultView.history; - } - - if (_filterUnread) { - latestView = latestView == DefaultView.bookmarks ? DefaultView.bookmarksUnread : DefaultView.historyUnread; - } - MainRepository().settings.latestView = latestView; - } } diff --git a/lib/pages/InfoPage.dart b/lib/pages/InfoPage.dart index 44ab4bc1..348e3659 100644 --- a/lib/pages/InfoPage.dart +++ b/lib/pages/InfoPage.dart @@ -2,6 +2,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:fyx/controllers/AnalyticsProvider.dart'; +import 'package:fyx/controllers/SettingsProvider.dart'; import 'package:fyx/theme/L.dart'; import 'package:fyx/theme/T.dart'; import 'package:fyx/theme/skin/Skin.dart'; @@ -52,7 +53,7 @@ class _InfoPageState extends State { future: _response, builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.hasData) { - return Markdown(data: snapshot.data!.body, styleSheet: MarkdownStyleSheet.fromCupertinoTheme(CupertinoTheme.of(context)), onTapLink: (String text, String? url, String title) => url != null ? T.openLink(url) : null); + return Markdown(data: snapshot.data!.body, styleSheet: MarkdownStyleSheet.fromCupertinoTheme(CupertinoTheme.of(context)), onTapLink: (String text, String? url, String title) => url != null ? T.openLink(url, mode: SettingsProvider().linksMode) : null); } if (snapshot.hasError) { return T.feedbackScreen( diff --git a/lib/pages/LoginPage.dart b/lib/pages/LoginPage.dart index 04773e83..5b49f207 100644 --- a/lib/pages/LoginPage.dart +++ b/lib/pages/LoginPage.dart @@ -53,12 +53,10 @@ class _LoginPageState extends State { minWidth: MediaQuery.of(context).size.width, minHeight: MediaQuery.of(context).size.height, ), - child: IntrinsicHeight( - child: Container( - padding: EdgeInsets.all(16), - decoration: BoxDecoration(gradient: colors.gradient), - child: formFactory(), - ), + child: Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration(gradient: colors.gradient), + child: formFactory(), )))); } @@ -73,7 +71,9 @@ class _LoginPageState extends State { final textfieldDecoration = BoxDecoration(borderRadius: BorderRadius.circular(4), color: colors.background, border: Border.all(color: colors.background)); - var offset = (MediaQuery.of(context).viewInsets.bottom / 3); + var offset = 128 - (MediaQuery.of(context).viewInsets.bottom / 3); + offset = offset > 0 ? offset : 20; + return Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.max, @@ -91,7 +91,7 @@ class _LoginPageState extends State { boxShadow: [BoxShadow(color: colors.dark, offset: Offset(0, 0), blurRadius: 16)]), ), AnimatedPadding( - padding: EdgeInsets.only(top: 128 - offset), + padding: EdgeInsets.only(top: offset), duration: const Duration(milliseconds: 200), curve: Curves.easeInOut, child: Column( diff --git a/lib/pages/MailboxPage.dart b/lib/pages/MailboxPage.dart deleted file mode 100644 index a2773b44..00000000 --- a/lib/pages/MailboxPage.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:fyx/components/MailListItem.dart'; -import 'package:fyx/components/PullToRefreshList.dart'; -import 'package:fyx/components/post/SyntaxHighlighter.dart'; -import 'package:fyx/controllers/AnalyticsProvider.dart'; -import 'package:fyx/controllers/ApiController.dart'; -import 'package:fyx/controllers/IApiProvider.dart'; -import 'package:fyx/model/Mail.dart'; -import 'package:fyx/model/MainRepository.dart'; -import 'package:fyx/pages/NewMessagePage.dart'; -import 'package:fyx/theme/skin/Skin.dart'; -import 'package:fyx/theme/skin/SkinColors.dart'; -import 'package:hive_flutter/hive_flutter.dart'; - -class MailboxPage extends StatefulWidget { - final int refreshData; - - MailboxPage({this.refreshData = 0}); - - @override - _MailboxPageState createState() => _MailboxPageState(); -} - -class _MailboxPageState extends State { - int _refreshData = 0; - - @override - void initState() { - _refreshData = widget.refreshData; - AnalyticsProvider().setScreen('Mailbox', 'MailboxPage'); - super.initState(); - } - - refreshData() { - setState(() => _refreshData = DateTime.now().millisecondsSinceEpoch); - } - - @override - void didUpdateWidget(MailboxPage oldWidget) { - if (oldWidget.refreshData != widget.refreshData) { - this.refreshData(); - } - super.didUpdateWidget(oldWidget); - } - - @override - Widget build(BuildContext context) { - // Reset the language context. - // TODO: Not ideal. Get rid of the static. - SyntaxHighlighter.languageContext = ''; - SkinColors colors = Skin.of(context).theme.colors; - - return Stack(children: [ - PullToRefreshList( - rebuild: _refreshData, - isInfinite: true, - sliverListBuilder: (List data) { - return ValueListenableBuilder( - valueListenable: MainRepository().settings.box.listenable(keys: ['blockedMails', 'blockedUsers']), - builder: (BuildContext context, value, Widget? child) { - var filtered = data; - if (data[0] is MailListItem) { - filtered = data - .where((item) => !MainRepository().settings.isMailBlocked((item as MailListItem).mail.id)) - .where((item) => !MainRepository().settings.isUserBlocked((item as MailListItem).mail.participant)) - .toList(); - } - return SliverList( - delegate: SliverChildBuilderDelegate( - (context, i) => filtered[i], - childCount: filtered.length, - ), - ); - }, - ); - }, - dataProvider: (lastId) async { - var result = await ApiController().loadMail(lastId: lastId); - var mails = result.mails - .map((_mail) => Mail.fromJson(_mail, isCompact: MainRepository().settings.useCompactMode)) - .where((mail) => !MainRepository().settings.isMailBlocked(mail.id)) - .where((mail) => !MainRepository().settings.isUserBlocked(mail.participant)) - .map((mail) => MailListItem(mail, onUpdate: this.refreshData,)) - .toList(); - var id = Mail.fromJson(result.mails.last, isCompact: MainRepository().settings.useCompactMode).id; - return DataProviderResult(mails, lastId: id); - }), - Positioned( - right: 20, - bottom: 20, - child: SafeArea( - child: FloatingActionButton( - backgroundColor: colors.primary, - foregroundColor: colors.background, - child: Icon(Icons.add), - onPressed: () => Navigator.of(context, rootNavigator: true).pushNamed('/new-message', - arguments: NewMessageSettings( - onClose: this.refreshData, - hasInputField: true, - onSubmit: (String? inputField, String message, List> attachments) async { - if (inputField == null) { - return false; - } - - var response = await ApiController().sendMail(inputField, message, attachments: attachments); - return response.isOk; - })), - ), - ), - ) - ]); - } -} diff --git a/lib/pages/NewMessagePage.dart b/lib/pages/NewMessagePage.dart index c42c6fda..cf5ee254 100644 --- a/lib/pages/NewMessagePage.dart +++ b/lib/pages/NewMessagePage.dart @@ -8,6 +8,7 @@ import 'package:flutter/widgets.dart'; import 'package:fyx/controllers/AnalyticsProvider.dart'; import 'package:fyx/controllers/IApiProvider.dart'; import 'package:fyx/model/MainRepository.dart'; +import 'package:fyx/model/Settings.dart'; import 'package:fyx/theme/Helpers.dart'; import 'package:fyx/theme/skin/Skin.dart'; import 'package:fyx/theme/skin/SkinColors.dart'; @@ -141,10 +142,13 @@ class _NewMessagePageState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - CupertinoButton(padding: EdgeInsets.all(0), child: Text('Zavřít'), onPressed: () => Navigator.of(context).pop()), + CupertinoButton( + padding: EdgeInsets.all(0), + child: Text('Zavřít', style: TextStyle(fontSize: Settings().fontSize)), + onPressed: () => Navigator.of(context).pop()), CupertinoButton( padding: EdgeInsets.all(0), - child: _sending ? CupertinoActivityIndicator() : Text('Odeslat'), + child: _sending ? CupertinoActivityIndicator() : Text('Odeslat', style: TextStyle(fontSize: Settings().fontSize)), onPressed: _isSendDisabled() ? null : () async { @@ -165,6 +169,7 @@ class _NewMessagePageState extends State { Visibility( visible: _settings!.hasInputField == true, child: CupertinoTextField( + decoration: colors.textFieldDecoration, controller: _recipientController, inputFormatters: [FilteringTextInputFormatter.allow(RegExp('[a-zA-Z0-9_]'))], textCapitalization: TextCapitalization.characters, @@ -177,6 +182,7 @@ class _NewMessagePageState extends State { height: 8, ), CupertinoTextField( + decoration: colors.textFieldDecoration, controller: _messageController, maxLines: 10, autofocus: _settings!.hasInputField != true || _settings!.inputFieldPlaceholder != null, diff --git a/lib/pages/NoticesPage.dart b/lib/pages/NoticesPage.dart index 63f03151..eb5fefe0 100644 --- a/lib/pages/NoticesPage.dart +++ b/lib/pages/NoticesPage.dart @@ -1,11 +1,12 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:fyx/components/Avatar.dart' as component; -import 'package:fyx/components/ContentBoxLayout.dart'; -import 'package:fyx/components/PullToRefreshList.dart'; -import 'package:fyx/components/post/PostThumbs.dart'; +import 'package:fyx/components/avatar.dart' as component; +import 'package:fyx/components/content_box_layout.dart'; +import 'package:fyx/components/post/post_thumbs.dart'; +import 'package:fyx/components/pull_to_refresh_list.dart'; import 'package:fyx/controllers/AnalyticsProvider.dart'; import 'package:fyx/controllers/ApiController.dart'; +import 'package:fyx/model/Settings.dart'; import 'package:fyx/model/post/PostThumbItem.dart'; import 'package:fyx/model/post/content/Regular.dart'; import 'package:fyx/model/reponses/FeedNoticesResponse.dart'; @@ -32,13 +33,13 @@ class _NoticesPageState extends State with WidgetsBindingObserver { @override void initState() { super.initState(); - WidgetsBinding.instance?.addObserver(this); + WidgetsBinding.instance.addObserver(this); AnalyticsProvider().setScreen('Notices', 'NoticesPage'); } @override void dispose() { - WidgetsBinding.instance?.removeObserver(this); + WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -65,7 +66,7 @@ class _NoticesPageState extends State with WidgetsBindingObserver { child: PullToRefreshList( rebuild: _refreshData, dataProvider: (lastId) async { - var result = await ApiController().loadFeedNotices(); + var result = await Future.delayed(const Duration(milliseconds: 300), () => ApiController().loadFeedNotices()); var feed = result.data.map((NoticeItem item) { var highlight = false; item.replies.forEach((NoticeReplies reply) => highlight = reply.time > result.lastVisit ? true : highlight); @@ -77,7 +78,9 @@ class _NoticesPageState extends State with WidgetsBindingObserver { isHighlighted: highlight, topRightWidget: Text( item.wuRating > 0 ? '+${item.wuRating}' : item.wuRating.toString(), - style: TextStyle(fontSize: 14, color: item.wuRating > 0 ? colors.success : (item.wuRating < 0 ? colors.danger : colors.grey)), + style: TextStyle( + fontSize: Settings().fontSize * 0.9, + color: item.wuRating > 0 ? colors.success : (item.wuRating < 0 ? colors.danger : colors.grey)), ), topLeftWidget: Expanded( child: GestureDetector( @@ -86,7 +89,7 @@ class _NoticesPageState extends State with WidgetsBindingObserver { item.discussionName, overflow: TextOverflow.ellipsis, softWrap: false, - style: TextStyle(fontWeight: FontWeight.bold), + style: TextStyle(fontWeight: FontWeight.bold, fontSize: Settings().fontSize), ), ), ), diff --git a/lib/pages/SettingsPage.dart b/lib/pages/SettingsPage.dart deleted file mode 100644 index 96c8c0dc..00000000 --- a/lib/pages/SettingsPage.dart +++ /dev/null @@ -1,251 +0,0 @@ -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_cupertino_settings/flutter_cupertino_settings.dart'; -import 'package:fyx/FyxApp.dart'; -import 'package:fyx/controllers/AnalyticsProvider.dart'; -import 'package:fyx/controllers/ApiController.dart'; -import 'package:fyx/model/MainRepository.dart'; -import 'package:fyx/model/enums/DefaultView.dart'; -import 'package:fyx/model/enums/ThemeEnum.dart'; -import 'package:fyx/model/provider/ThemeModel.dart'; -import 'package:fyx/pages/InfoPage.dart'; -import 'package:fyx/theme/L.dart'; -import 'package:fyx/theme/T.dart'; -import 'package:fyx/theme/skin/Skin.dart'; -import 'package:fyx/theme/skin/SkinColors.dart'; -import 'package:hive_flutter/hive_flutter.dart'; -import 'package:provider/provider.dart'; - -class SettingsPage extends StatefulWidget { - @override - _SettingsPageState createState() => _SettingsPageState(); -} - -class _SettingsPageState extends State { - bool _compactMode = false; - bool _underTheHood = false; - bool _autocorrect = false; - DefaultView _defaultView = DefaultView.latest; - ThemeEnum _theme = ThemeEnum.light; - - @override - void initState() { - super.initState(); - _compactMode = MainRepository().settings.useCompactMode; - _underTheHood = false; - _autocorrect = MainRepository().settings.useAutocorrect; - _defaultView = MainRepository().settings.defaultView; - _theme = MainRepository().settings.theme; - AnalyticsProvider().setScreen('Settings', 'SettingsPage'); - } - - @override - Widget build(BuildContext context) { - SkinColors colors = Skin.of(context).theme.colors; - CSWidgetStyle postsStyle = CSWidgetStyle(icon: Icon(Icons.view_compact, color: colors.text.withOpacity(0.38))); - CSWidgetStyle autocorrectStyle = CSWidgetStyle(icon: Icon(Icons.spellcheck, color: colors.text.withOpacity(0.38))); - CSWidgetStyle bugreportStyle = CSWidgetStyle(icon: Icon(Icons.bug_report, color: colors.text.withOpacity(0.38))); - CSWidgetStyle aboutStyle = CSWidgetStyle(icon: Icon(Icons.info, color: colors.text.withOpacity(0.38))); - CSWidgetStyle patronsStyle = CSWidgetStyle(icon: Icon(Icons.volunteer_activism, color: colors.text.withOpacity(0.38))); - CSWidgetStyle termsStyle = CSWidgetStyle(icon: Icon(Icons.gavel, color: colors.text.withOpacity(0.38))); - - var pkg = MainRepository().packageInfo; - var version = '${pkg.version} (${pkg.buildNumber})'; - - return CupertinoPageScaffold( - navigationBar: CupertinoNavigationBar( - middle: Text( - L.SETTINGS, - style: TextStyle(color: colors.text), - ), - leading: CupertinoNavigationBarBackButton( - color: colors.primary, - onPressed: () { - Navigator.of(context, rootNavigator: true).pop(); - }, - )), - child: CupertinoScrollbar( - child: CupertinoSettings(items: [ - const CSHeader('Příspěvky'), - CSControl( - nameWidget: Text( - 'Autocorrect', - style: TextStyle(color: colors.text), - ), - contentWidget: CupertinoSwitch( - value: _autocorrect, - onChanged: (bool value) { - setState(() => _autocorrect = value); - MainRepository().settings.useAutocorrect = value; - }), - style: autocorrectStyle, - ), - CSControl( - nameWidget: Text( - 'Kompaktní zobrazení', - style: TextStyle(color: colors.text), - ), - contentWidget: CupertinoSwitch( - value: _compactMode, - onChanged: (bool value) { - setState(() => _compactMode = value); - MainRepository().settings.useCompactMode = value; - }), - style: postsStyle, - ), - CSDescription( - 'Kompaktní zobrazení je zobrazení obrázků po stranách pokud to obsah příspěvku dovoluje (nedojde tak k narušení kontextu).', - ), - CSHeader('Úvodní obrazovka'), - CSSelection( - items: const >[ - CSSelectionItem(text: 'Poslední stav', value: DefaultView.latest), - CSSelectionItem(text: 'Historie (vše)', value: DefaultView.history), - CSSelectionItem(text: 'Historie (nepřečtené)', value: DefaultView.historyUnread), - CSSelectionItem(text: 'Sledované (vše)', value: DefaultView.bookmarks), - CSSelectionItem(text: 'Sledované (nepřečtené)', value: DefaultView.bookmarksUnread), - ], - onSelected: (index) { - setState(() => _defaultView = index); - MainRepository().settings.defaultView = index; - }, - currentSelection: _defaultView, - ), - CSHeader('Barevný režim'), - CSSelection( - items: const >[ - CSSelectionItem(text: 'Světlý', value: ThemeEnum.light), - CSSelectionItem(text: 'Tmavý', value: ThemeEnum.dark), - CSSelectionItem(text: 'Podle systému', value: ThemeEnum.system), - ], - onSelected: (theme) { - setState(() => _theme = theme); - MainRepository().settings.theme = theme; - Provider.of(context, listen: false).setTheme(theme); - }, - currentSelection: _theme, - ), - CSHeader('Paměť'), - CSControl( - nameWidget: Text( - 'Blokovaných uživatelů', - style: TextStyle(color: colors.text), - ), - contentWidget: ValueListenableBuilder( - valueListenable: MainRepository().settings.box.listenable(keys: ['blockedUsers']), - builder: (BuildContext context, value, Widget? child) { - return Text( - MainRepository().settings.blockedUsers.length.toString(), - style: TextStyle(color: colors.text), - ); - }), - ), - CSControl( - nameWidget: Text( - 'Skrytých příspěvků', - style: TextStyle(color: colors.text), - ), - contentWidget: ValueListenableBuilder( - valueListenable: MainRepository().settings.box.listenable(keys: ['blockedPosts']), - builder: (BuildContext context, value, Widget? child) { - return Text( - MainRepository().settings.blockedPosts.length.toString(), - style: TextStyle(color: colors.text), - ); - }), - ), - CSControl( - nameWidget: Text( - 'Skrytých mailů', - style: TextStyle(color: colors.text), - ), - contentWidget: ValueListenableBuilder( - valueListenable: MainRepository().settings.box.listenable(keys: ['blockedMails']), - builder: (BuildContext context, value, Widget? child) { - return Text( - MainRepository().settings.blockedMails.length.toString(), - style: TextStyle(color: colors.text), - ); - }), - ), - CSButton(CSButtonType.DESTRUCTIVE, 'Reset', () { - MainRepository().settings.resetBlockedContent(); - T.success(L.SETTINGS_CACHE_RESET, bg: colors.success); - AnalyticsProvider().logEvent('resetBlockedContent'); - }), - const CSHeader('Informace'), - CSButton( - CSButtonType.DEFAULT, - L.BACKERS, - () => Navigator.of(context).pushNamed('/settings/info', - arguments: InfoPageSettings(L.BACKERS, 'https://raw.githubusercontent.com/lucien144/fyx/develop/BACKERS.md')), - style: patronsStyle, - ), - CSButton( - CSButtonType.DEFAULT, - L.ABOUT, - () => Navigator.of(context).pushNamed('/settings/info', - arguments: InfoPageSettings(L.ABOUT, 'https://raw.githubusercontent.com/lucien144/fyx/develop/ABOUT.md')), - style: aboutStyle), - CSButton( - CSButtonType.DEFAULT, - L.SETTINGS_BUGREPORT, - () { - T.prefillGithubIssue(appContext: MainRepository(), user: MainRepository().credentials!.nickname); - AnalyticsProvider().logEvent('reportBug'); - }, - style: bugreportStyle, - ), - CSButton( - CSButtonType.DEFAULT, - L.TERMS, - () { - T.openLink('https://nyx.cz/terms'); - AnalyticsProvider().logEvent('openTerms'); - }, - style: termsStyle, - ), - const CSHeader(''), - CSButton(CSButtonType.DESTRUCTIVE, L.GENERAL_LOGOUT, () { - ApiController().logout(); - Navigator.of(context, rootNavigator: true).pushNamed('/login'); - AnalyticsProvider().logEvent('logout'); - }), - Visibility( - visible: FyxApp.isDev, - child: CSButton(CSButtonType.DESTRUCTIVE, '${L.GENERAL_LOGOUT} (bez resetu)', () { - ApiController().logout(removeAuthrorization: false); - Navigator.of(context, rootNavigator: true).pushNamed('/login'); - })), - CSDescription('Verze: $version'), - GestureDetector(child: CSDescription('Nahlídnout pod kapotu.'), onTap: () => setState(() => _underTheHood = !_underTheHood)), - Visibility( - visible: _underTheHood, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - child: CSDescription('API token: ${MainRepository().credentials!.token}'), - onTap: () => Clipboard.setData(ClipboardData(text: MainRepository().credentials!.token))), - FutureBuilder( - future: FirebaseMessaging.instance.getToken(), - builder: (BuildContext context, AsyncSnapshot snapshot) { - if (snapshot.hasData && snapshot.data != null) { - return GestureDetector( - child: CSDescription('FCM token: ${snapshot.data!.substring(0, 30)}...'), - onTap: () => Clipboard.setData(ClipboardData(text: snapshot.data))); - } - if (snapshot.hasError) { - return CSDescription('FCM token: error :('); - } - return CSDescription('FCM token: načítám...'); - }), - ], - ), - ), - ]), - )); - } -} diff --git a/lib/pages/discussion_home_page.dart b/lib/pages/discussion_home_page.dart new file mode 100644 index 00000000..3de15338 --- /dev/null +++ b/lib/pages/discussion_home_page.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:fyx/components/discussion_page_scaffold.dart'; +import 'package:fyx/controllers/ApiController.dart'; +import 'package:fyx/model/reponses/DiscussionHomeResponse.dart'; +import 'package:fyx/model/reponses/DiscussionResponse.dart'; +import 'package:fyx/theme/L.dart'; +import 'package:fyx/theme/T.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +class DiscussionHomePageArguments { + final DiscussionResponse discussionResponse; + + DiscussionHomePageArguments(this.discussionResponse); +} + +class DiscussionHomePage extends StatelessWidget { + final bool header; + DiscussionHomePage({Key? key, this.header = false}) : super(key: key) { + if (Platform.isAndroid) WebView.platform = SurfaceAndroidWebView(); + } + + @override + Widget build(BuildContext context) { + DiscussionHomePageArguments? pageArguments = ModalRoute.of(context)?.settings.arguments as DiscussionHomePageArguments?; + + return DiscussionPageScaffold( + title: pageArguments?.discussionResponse.discussion.name ?? '', + child: FutureBuilder( + future: this.header + ? ApiController().getDiscussionHeader(pageArguments?.discussionResponse.discussion.idKlub ?? -1) + : ApiController().getDiscussionHome(pageArguments?.discussionResponse.discussion.idKlub ?? -1), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasError) { + return T.feedbackScreen(context, + isWarning: true, title: snapshot.error.toString(), label: L.GENERAL_CLOSE, onPress: () => Navigator.of(context).pop()); + } else if (snapshot.hasData) { + final html = ''' + + + + + + + + ${snapshot.data?.items.map((item) => '
${item.content ?? ''}
').join('')} + + +'''; + final String contentBase64 = base64Encode(const Utf8Encoder().convert(html)); + return WebView( + initialUrl: 'data:text/html;base64,$contentBase64', + ); + } + return T.feedbackScreen(context, isLoading: true); + })); + } +} diff --git a/lib/pages/settings_design_screen.dart b/lib/pages/settings_design_screen.dart new file mode 100644 index 00000000..4d6fdf3c --- /dev/null +++ b/lib/pages/settings_design_screen.dart @@ -0,0 +1,135 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:fyx/controllers/AnalyticsProvider.dart'; +import 'package:fyx/model/MainRepository.dart'; +import 'package:fyx/model/Settings.dart'; +import 'package:fyx/model/enums/SkinEnum.dart'; +import 'package:fyx/model/enums/ThemeEnum.dart'; +import 'package:fyx/model/provider/ThemeModel.dart'; +import 'package:fyx/theme/L.dart'; +import 'package:fyx/theme/skin/Skin.dart'; +import 'package:fyx/theme/skin/SkinColors.dart'; +import 'package:provider/provider.dart'; +import 'package:settings_ui/settings_ui.dart'; + +class SettingsDesignScreen extends StatefulWidget { + const SettingsDesignScreen({Key? key}) : super(key: key); + + @override + _SettingsDesignScreenState createState() => _SettingsDesignScreenState(); +} + +class _SettingsDesignScreenState extends State { + ThemeEnum _theme = ThemeEnum.light; + + @override + void initState() { + super.initState(); + + _theme = MainRepository().settings.theme; + AnalyticsProvider().setScreen('Settings / Design', 'SettingsDesignScreen'); + } + + SettingsTile _themeFactory(String label, ThemeEnum theme) { + return SettingsTile( + title: Text(label), + trailing: _theme == theme ? Icon(CupertinoIcons.check_mark) : null, + onPressed: (_) { + setState(() => _theme = theme); + MainRepository().settings.theme = theme; + Provider.of(context, listen: false).setTheme(theme); + }, + ); + } + + SettingsTile _skinFactory(String label, SkinEnum skin, {String? description}) { + return SettingsTile( + title: Text(label), + trailing: MainRepository().settings.skin == skin ? Icon(CupertinoIcons.check_mark) : null, + description: description != null ? Text(description) : null, + onPressed: (_) { + MainRepository().settings.skin = skin; + Provider.of(context, listen: false).setSkin(skin); + }, + ); + } + + @override + Widget build(BuildContext context) { + final SkinColors colors = Skin.of(context).theme.colors; + + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text( + L.SETTINGS, + style: TextStyle(color: colors.text), + ), + leading: CupertinoNavigationBarBackButton( + color: colors.primary, + onPressed: () { + Navigator.of(context, rootNavigator: true).pop(); + }, + )), + child: CupertinoScrollbar( + child: SettingsList( + lightTheme: SettingsThemeData( + settingsSectionBackground: colors.barBackground, + settingsListBackground: colors.background, + settingsTileTextColor: colors.text, + tileHighlightColor: colors.primary.withOpacity(0.1), + dividerColor: colors.background), + sections: [ + SettingsSection( + title: Text('Barevný režim'), + tiles: [ + _themeFactory('Světlý', ThemeEnum.light), + _themeFactory('Tmavý', ThemeEnum.dark), + _themeFactory('Podle systému', ThemeEnum.system), + ], + ), + SettingsSection( + title: Text('Skin'), + tiles: Skin.of(context).skins.map((skin) => _skinFactory(skin.name, skin.id)).toList(), + ), + SettingsSection( + title: Text('Velikost písma'), + tiles: [ + SettingsTile( + title: CupertinoSlider( + min: 10, + max: 24, + value: Provider.of(context, listen: false).fontSize, + onChanged: (double val) { + final size = val.round().toDouble(); + MainRepository().settings.fontSize = size; + Provider.of(context, listen: false).setFontSize(size); + }, + ), + value: SizedBox( + width: 20, + child: Text( + '${Provider.of(context, listen: false).fontSize.toInt()}', + style: TextStyle(fontSize: Settings().fontSize), + textAlign: TextAlign.right, + ), + ), + ), + SettingsTile( + title: Text( + 'Ukázka velikosti textu... 👍', + style: TextStyle(fontSize: Provider.of(context, listen: false).fontSize), + textAlign: TextAlign.center, + ), + ), + SettingsTile( + title: Text('Resetovat', style: TextStyle(color: colors.danger), textAlign: TextAlign.center), + onPressed: (_) { + MainRepository().settings.fontSize = Settings().fontSize; + Provider.of(context, listen: false).setFontSize(Settings().fontSize); + }), + ], + ), + ], + ))); + } +} diff --git a/lib/pages/settings_screen.dart b/lib/pages/settings_screen.dart new file mode 100644 index 00000000..af4594d0 --- /dev/null +++ b/lib/pages/settings_screen.dart @@ -0,0 +1,323 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:fyx/FyxApp.dart'; +import 'package:fyx/controllers/AnalyticsProvider.dart'; +import 'package:fyx/controllers/ApiController.dart'; +import 'package:fyx/model/MainRepository.dart'; +import 'package:fyx/model/Settings.dart'; +import 'package:fyx/model/enums/DefaultView.dart'; +import 'package:fyx/model/enums/FirstUnreadEnum.dart'; +import 'package:fyx/model/enums/LaunchModeEnum.dart'; +import 'package:fyx/model/enums/ThemeEnum.dart'; +import 'package:fyx/pages/InfoPage.dart'; +import 'package:fyx/theme/L.dart'; +import 'package:fyx/theme/T.dart'; +import 'package:fyx/theme/skin/Skin.dart'; +import 'package:fyx/theme/skin/SkinColors.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:settings_ui/settings_ui.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class SettingsScreen extends StatefulWidget { + @override + _SettingsScreenState createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + bool _compactMode = false; + bool _autocorrect = false; + bool _quickRating = true; + DefaultView _defaultView = DefaultView.latest; + FirstUnreadEnum _firstUnread = FirstUnreadEnum.button; + LaunchModeEnum _linksMode = LaunchModeEnum.externalApplication; + + @override + void initState() { + super.initState(); + _compactMode = MainRepository().settings.useCompactMode; + _autocorrect = MainRepository().settings.useAutocorrect; + _defaultView = MainRepository().settings.defaultView; + _firstUnread = MainRepository().settings.firstUnread; + _linksMode = MainRepository().settings.linksMode; + _quickRating = MainRepository().settings.quickRating; + AnalyticsProvider().setScreen('Settings', 'SettingsPage'); + } + + SettingsTile _firstUnreadFactory(String label, FirstUnreadEnum value, {Widget? description}) { + return SettingsTile( + title: Text(label), + trailing: _firstUnread == value ? Icon(CupertinoIcons.check_mark) : null, + description: description, + onPressed: (_) { + setState(() => _firstUnread = value); + MainRepository().settings.firstUnread = value; + }, + ); + } + + SettingsTile _defaultViewFactory(String label, DefaultView value) { + return SettingsTile( + title: Text(label), + trailing: _defaultView == value ? Icon(CupertinoIcons.check_mark) : null, + onPressed: (_) { + setState(() => _defaultView = value); + MainRepository().settings.defaultView = value; + }, + ); + } + + SettingsTile _linksModeFactory(String label, LaunchModeEnum value) { + return SettingsTile( + title: Text(label), + trailing: _linksMode == value ? Icon(CupertinoIcons.check_mark) : null, + onPressed: (_) { + setState(() => _linksMode = value); + MainRepository().settings.linksMode = value; + }, + ); + } + + @override + Widget build(BuildContext context) { + SkinColors colors = Skin.of(context).theme.colors; + + var pkg = MainRepository().packageInfo; + var version = '${pkg.version} (${pkg.buildNumber})'; + + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text( + L.SETTINGS, + style: TextStyle(color: colors.text), + ), + leading: CupertinoNavigationBarBackButton( + color: colors.primary, + onPressed: () { + Navigator.of(context, rootNavigator: true).pop(); + }, + )), + child: CupertinoScrollbar( + child: SettingsList( + lightTheme: SettingsThemeData( + settingsSectionBackground: colors.barBackground, + settingsListBackground: colors.background, + settingsTileTextColor: colors.text, + tileHighlightColor: colors.primary.withOpacity(0.1), + trailingTextColor: colors.grey, + leadingIconsColor: colors.grey, + dividerColor: colors.background), + sections: [ + SettingsSection( + title: Text('Obecné'), + tiles: [ + SettingsTile.switchTile( + onToggle: (bool value) { + setState(() => _autocorrect = value); + MainRepository().settings.useAutocorrect = value; + }, + initialValue: _autocorrect, + leading: Icon(Icons.spellcheck, color: colors.grey), + title: Text('Autocorrect'), + ), + SettingsTile.switchTile( + onToggle: (bool value) { + setState(() => _quickRating = value); + MainRepository().settings.quickRating = value; + }, + initialValue: _quickRating, + leading: Icon(MdiIcons.thumbsUpDown, color: colors.grey), + title: Text('Rychlé hodnocení') + ), + SettingsTile.switchTile( + onToggle: (bool value) { + setState(() => _compactMode = value); + MainRepository().settings.useCompactMode = value; + }, + initialValue: _compactMode, + leading: Icon(Icons.view_compact, color: colors.grey), + title: Text('Kompaktní zobrazení'), + description: Text( + 'Kompaktní zobrazení: zobrazuje obrázky po stranách pokud to obsah příspěvku dovoluje (nedojde tak k narušení kontextu).' + '\n' + 'Rychlé hodnocení: možnost hodnotit příspěvek na dvojklik (double-tap).', + ), + ), + ], + ), + SettingsSection( + title: Text('První nepřečtený'), + tiles: [ + _firstUnreadFactory('Vypnuto', FirstUnreadEnum.off), + _firstUnreadFactory('Zobrazovat tlačítko', FirstUnreadEnum.button), + _firstUnreadFactory('Automaticky odskočit', FirstUnreadEnum.autoscroll, + description: Text('Funguje pouze na prvních 100 nepřečtených.')), + ], + ), + SettingsSection( + title: Text('Výchozí obrazovka'), + tiles: [ + _defaultViewFactory('Poslední stav', DefaultView.latest), + _defaultViewFactory('Historie (vše)', DefaultView.history), + _defaultViewFactory('Historie (nepřečtené)', DefaultView.historyUnread), + _defaultViewFactory('Sledované (vše)', DefaultView.bookmarks), + _defaultViewFactory('Sledované (nepřečtené)', DefaultView.bookmarksUnread), + ], + ), + SettingsSection( + title: Text('Vzhled'), + tiles: [ + SettingsTile.navigation( + title: Text('Barevný režim'), + value: Text((() { + switch (MainRepository().settings.theme) { + case ThemeEnum.light: + return 'Světlý'; + + case ThemeEnum.dark: + return 'Tmavý'; + + case ThemeEnum.system: + return 'Podle systému'; + } + })()), + onPressed: (context) => Navigator.of(context).pushNamed('/settings/design'), + ), + SettingsTile.navigation( + title: Text('Velikost písma'), + value: Text('${MainRepository().settings.fontSize.toInt().toString()}pt'), + onPressed: (context) => Navigator.of(context).pushNamed('/settings/design'), + ), + SettingsTile.navigation( + title: Text('Skin'), + value: Text(Skin.of(context).skins.firstWhere((skin) => skin.id == MainRepository().settings.skin).name), + onPressed: (context) => Navigator.of(context).pushNamed('/settings/design'), + ), + ], + ), + SettingsSection( + title: Text('Otevírání odkazů'), + tiles: [ + //_linksModeFactory('Podle nastavení systému', LaunchModeEnum.platformDefault), + _linksModeFactory('Otevírat v externí aplikaci', LaunchModeEnum.externalApplication), + _linksModeFactory('Otevírat ve Fyxu', LaunchModeEnum.inAppWebView), + ], + ), + SettingsSection( + title: Text('Paměť'), + tiles: [ + SettingsTile( + title: Text('Blokovaných uživatelů'), + trailing: ValueListenableBuilder( + valueListenable: MainRepository().settings.box.listenable(keys: ['blockedUsers']), + builder: (BuildContext context, value, Widget? child) { + return Text( + MainRepository().settings.blockedUsers.length.toString(), + style: TextStyle(color: colors.text, fontSize: Settings().fontSize), + ); + }), + ), + SettingsTile( + title: Text('Skrytých příspěvků'), + trailing: ValueListenableBuilder( + valueListenable: MainRepository().settings.box.listenable(keys: ['blockedPosts']), + builder: (BuildContext context, value, Widget? child) { + return Text( + MainRepository().settings.blockedPosts.length.toString(), + style: TextStyle(color: colors.text, fontSize: Settings().fontSize), + ); + }), + ), + SettingsTile( + title: Text('Skryté pošty'), + trailing: ValueListenableBuilder( + valueListenable: MainRepository().settings.box.listenable(keys: ['blockedMails']), + builder: (BuildContext context, value, Widget? child) { + return Text( + MainRepository().settings.blockedMails.length.toString(), + style: TextStyle(color: colors.text, fontSize: Settings().fontSize), + ); + }), + ), + SettingsTile( + title: Text('Resetovat', style: TextStyle(color: colors.danger), textAlign: TextAlign.center), + onPressed: (_) { + MainRepository().settings.resetBlockedContent(); + T.success(L.SETTINGS_CACHE_RESET, bg: colors.success); + AnalyticsProvider().logEvent('resetBlockedContent'); + }), + ], + ), + SettingsSection(title: Text('Informace'), tiles: [ + SettingsTile.navigation( + leading: Icon(Icons.volunteer_activism, color: colors.grey), + title: Text(L.BACKERS), + onPressed: (_) => Navigator.of(context).pushNamed('/settings/info', + arguments: InfoPageSettings(L.BACKERS, 'https://raw.githubusercontent.com/lucien144/fyx/develop/BACKERS.md')), + ), + SettingsTile.navigation( + leading: Icon(Icons.info, color: colors.grey), + title: Text(L.ABOUT), + onPressed: (_) => Navigator.of(context).pushNamed('/settings/info', + arguments: InfoPageSettings(L.ABOUT, 'https://raw.githubusercontent.com/lucien144/fyx/develop/ABOUT.md')), + ), + SettingsTile.navigation( + leading: Icon(Icons.bug_report, color: colors.grey), + title: Text(L.SETTINGS_BUGREPORT), + onPressed: (_) { + T.prefillGithubIssue(appContext: MainRepository(), user: MainRepository().credentials!.nickname); + AnalyticsProvider().logEvent('reportBug'); + }, + ), + SettingsTile.navigation( + leading: Icon(Icons.gavel, color: colors.grey), + title: Text(L.TERMS), + onPressed: (_) { + T.openLink('https://nyx.cz/terms', mode: _linksMode); + AnalyticsProvider().logEvent('openTerms'); + }, + ) + ]), + SettingsSection( + tiles: [ + SettingsTile( + title: Text( + L.GENERAL_LOGOUT, + textAlign: TextAlign.center, + style: TextStyle(color: colors.danger), + ), + onPressed: (_) { + ApiController().logout(); + Navigator.of(context, rootNavigator: true).pushNamed('/login'); + AnalyticsProvider().logEvent('logout'); + }, + ), + if (FyxApp.isDev) + SettingsTile( + title: Text( + '${L.GENERAL_LOGOUT} (bez resetu)', + textAlign: TextAlign.center, + style: TextStyle(color: colors.danger), + ), + onPressed: (_) { + ApiController().logout(removeAuthrorization: false); + Navigator.of(context, rootNavigator: true).pushNamed('/login'); + }, + ) + ], + ), + CustomSettingsSection( + child: Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Text( + 'Verze: $version', + textAlign: TextAlign.center, + style: TextStyle(color: colors.disabled, fontFamily: 'JetBrainsMono', fontSize: 13), + ), + ), + ) + ], + ), + )); + } +} diff --git a/lib/pages/tab_bar/BookmarksTab.dart b/lib/pages/tab_bar/BookmarksTab.dart new file mode 100644 index 00000000..b500e7a4 --- /dev/null +++ b/lib/pages/tab_bar/BookmarksTab.dart @@ -0,0 +1,307 @@ +import 'package:diacritic/diacritic.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fyx/components/discussion_list_item.dart'; +import 'package:fyx/components/discussion_search_list_item.dart'; +import 'package:fyx/components/list_header.dart'; +import 'package:fyx/components/notification_badge.dart'; +import 'package:fyx/components/pull_to_refresh_list.dart'; +import 'package:fyx/controllers/ApiController.dart'; +import 'package:fyx/model/BookmarkedDiscussion.dart'; +import 'package:fyx/model/MainRepository.dart'; +import 'package:fyx/model/enums/DefaultView.dart'; +import 'package:fyx/model/enums/TabsEnum.dart'; +import 'package:fyx/model/provider/NotificationsModel.dart'; +import 'package:fyx/model/reponses/BookmarksHistoryResponse.dart'; +import 'package:fyx/state/search_providers.dart'; +import 'package:fyx/theme/L.dart'; +import 'package:fyx/theme/skin/Skin.dart'; +import 'package:fyx/theme/skin/SkinColors.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:provider/provider.dart' as provider; + +class BookmarksTab extends ConsumerStatefulWidget { + // Unread filter toggle + final bool filterUnread; + + final int refreshTimestamp; + + const BookmarksTab({Key? key, this.filterUnread = false, this.refreshTimestamp = 0}) : super(key: key); + + @override + _BookmarksTabState createState() => _BookmarksTabState(); +} + +class _BookmarksTabState extends ConsumerState { + late PageController _bookmarksController; + bool _filterUnread = false; + + TabsEnum activeTab = TabsEnum.history; + List _toggledCategories = []; + int _refreshData = 0; + + @override + void initState() { + _filterUnread = widget.filterUnread; + + final defaultView = + MainRepository().settings.defaultView == DefaultView.latest ? MainRepository().settings.latestView : MainRepository().settings.defaultView; + + activeTab = [DefaultView.history, DefaultView.historyUnread].indexOf(defaultView) >= 0 ? TabsEnum.history : TabsEnum.bookmarks; + if (activeTab == TabsEnum.bookmarks) { + _bookmarksController = PageController(initialPage: 1); + } else { + _bookmarksController = PageController(initialPage: 0); + } + + _bookmarksController.addListener(() { + // If the CupertinoTabView is sliding and the animation is finished, change the active tab + if (_bookmarksController.page! % 1 == 0 && activeTab != TabsEnum.values[_bookmarksController.page!.toInt()]) { + setState(() { + activeTab = TabsEnum.values[_bookmarksController.page!.toInt()]; + }); + } + }); + + super.initState(); + } + + // isInverted + // Sometimes the activeTab var is changed after the listener where we call updateLatestView() finishes. + // Therefore, the var activeTab needs to be handled as inverted. + void updateLatestView({bool isInverted: false}) { + DefaultView latestView = activeTab == TabsEnum.history ? DefaultView.history : DefaultView.bookmarks; + if (isInverted) { + latestView = activeTab == TabsEnum.history ? DefaultView.bookmarks : DefaultView.history; + } + + if (_filterUnread) { + latestView = latestView == DefaultView.bookmarks ? DefaultView.bookmarksUnread : DefaultView.historyUnread; + } + MainRepository().settings.latestView = latestView; + } + + @override + void didUpdateWidget(oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.filterUnread != widget.filterUnread) { + setState(() { + _filterUnread = widget.filterUnread; + _toggledCategories = []; + }); + this.refreshData(); + this.updateLatestView(); + } else if (widget.refreshTimestamp > oldWidget.refreshTimestamp) { + this.refreshData(); + } + } + + refreshData() { + setState(() => _refreshData = DateTime.now().millisecondsSinceEpoch); + } + + @override + void dispose() { + _bookmarksController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CupertinoTabView(builder: (context) { + final SkinColors colors = Skin.of(context).theme.colors; + + return CupertinoPageScaffold( + resizeToAvoidBottomInset: false, + navigationBar: CupertinoNavigationBar( + trailing: GestureDetector(child: (() { + if (activeTab == TabsEnum.history) { + return ref.watch(searchHistoryProvider) == null ? Icon(Icons.filter_alt_outlined) : Icon(Icons.filter_alt); + } + return ref.watch(searchBookmarksProvider) == null ? Icon(MdiIcons.magnify) : Icon(MdiIcons.magnifyRemoveOutline); + })(), onTap: () { + if (activeTab == TabsEnum.history) { + if (ref.read(searchHistoryProvider.notifier).state == null) { + ref.read(searchHistoryProvider.notifier).state = ''; // Open the searchbox + } else { + ref.read(searchHistoryProvider.notifier).state = null; // Close the searchbox + this.refreshData(); // ... and reset the List + } + } else { + if (ref.read(searchBookmarksProvider.notifier).state == null) { + ref.read(searchBookmarksProvider.notifier).state = ''; // Open the searchbox + } else { + ref.read(searchBookmarksProvider.notifier).state = null; // Close the searchbox + this.refreshData(); // ... and reset the List + } + } + }), + leading: provider.Consumer( + builder: (context, notifications, child) => NotificationBadge( + widget: CupertinoButton( + padding: EdgeInsets.zero, + minSize: kMinInteractiveDimensionCupertino - 10, + child: Icon( + Icons.notifications_none, + size: 30, + ), + onPressed: () => Navigator.of(context, rootNavigator: true).pushNamed('/notices')), + isVisible: notifications.newNotices > 0, + counter: notifications.newNotices)), + middle: CupertinoSegmentedControl( + unselectedColor: colors.barBackground, + groupValue: activeTab, + onValueChanged: (value) { + _bookmarksController.animateToPage(TabsEnum.values.indexOf(value as TabsEnum), + duration: Duration(milliseconds: 300), curve: Curves.easeInOut); + }, + children: { + TabsEnum.history: Padding( + child: Text('Historie', softWrap: false), + padding: EdgeInsets.symmetric(horizontal: MediaQuery.of(context).size.width < 375 ? 2 : 16), + ), + TabsEnum.bookmarks: Padding( + child: Text('Sledované', softWrap: false), + padding: EdgeInsets.symmetric(horizontal: MediaQuery.of(context).size.width < 375 ? 2 : 16), + ), + }, + )), + child: PageView( + controller: _bookmarksController, + onPageChanged: (int index) => this.updateLatestView(isInverted: true), + pageSnapping: true, + children: [ + // ----- + // HISTORY PULL TO REFRESH + // ----- + PullToRefreshList>( + rebuild: _refreshData, + searchLimit: 1, + searchLabel: 'Filtruj kluby v historii...', + searchTerm: ref.read(searchHistoryProvider.notifier).state, + onSearch: (term) { + ref.read(searchHistoryProvider.notifier).state = term; + this.refreshData(); + }, + onSearchClear: () { + ref.read(searchHistoryProvider.notifier).state = ''; + this.refreshData(); + }, + dataProvider: (lastId) async { + List withReplies = []; + String? searchTerm = ref.read(searchHistoryProvider.notifier).state; + BookmarksHistoryResponse result = await ApiController().loadHistory(); + + var data = result.discussions + .map((discussion) => BookmarkedDiscussion.fromJson(discussion)) + .where((discussion) => this._filterUnread ? discussion.unread > 0 : true) + .where((discussion) { + if (searchTerm != null) { + final slugNeedle = removeDiacritics(searchTerm); + final slugHaystack = removeDiacritics(discussion.name); + return slugHaystack.contains(RegExp(slugNeedle, caseSensitive: false)); + } + return true; + }) + .map((discussion) => DiscussionListItem(discussion)) + .where((discussionListItem) { + if (discussionListItem.discussion.replies > 0) { + withReplies.add(discussionListItem); + return false; + } + return true; + }) + .toList(); + data.insertAll(0, withReplies); + return DataProviderResult(data); + }), + // ----- + // BOOKMARKS PULL TO REFRESH + // ----- + PullToRefreshList>( + rebuild: _refreshData, + searchLabel: 'Hledej diskuze, události a inzeráty...', + searchTerm: ref.read(searchBookmarksProvider.notifier).state, + onSearch: (term) { + ref.read(searchBookmarksProvider.notifier).state = term; + this.refreshData(); + }, + onSearchClear: () { + ref.read(searchBookmarksProvider.notifier).state = ''; + this.refreshData(); + }, + dataProvider: (lastId) async { + var categories = []; + + try { + if (ref.read(searchBookmarksProvider.notifier).state != null && ref.read(searchBookmarksProvider.notifier).state != '') { + final term = ref.read(searchBookmarksProvider.notifier).state; + final result = await ApiController().searchDiscussions(term!); + result.discussion.forEach((type, list) { + categories.add({ + 'header': ListHeader(L.search[type] ?? ''), + 'items': list.map((model) => DiscussionSearchListItem(discussionId: model.id, child: Text(model.discussionName))).toList() + }); + }); + return DataProviderResult(categories); + } + } catch (error) {} + + var result = await ApiController().loadBookmarks(); + result.bookmarks.forEach((_bookmark) { + List withReplies = []; + var discussion = _bookmark.discussions + .where((discussion) { + // Filter by tapping on category headers + // If unread filter is ON + if (this._filterUnread) { + if (_toggledCategories.indexOf(_bookmark.categoryId) >= 0) { + // If unread filter is ON and category toggle is ON, display discussions + return true; + } else { + // If unread filter is ON and category toggle is OFF, display unread discussions only + return discussion.unread > 0; + } + } else { + if (_toggledCategories.indexOf(_bookmark.categoryId) >= 0) { + // If unread filter is OFF and category toggle is ON, hide discussions + return false; + } + } + // If unread filter is OFF and category toggle is OFF, show discussions + return true; + }) + .map((discussion) => DiscussionListItem(discussion)) + .where((discussionListItem) { + if (discussionListItem.discussion.replies > 0) { + withReplies.add(discussionListItem); + return false; + } + return true; + }) + .toList(); + discussion.insertAll(0, withReplies); + categories.add({ + 'header': ListHeader(_bookmark.categoryName, onTap: () { + if (_toggledCategories.indexOf(_bookmark.categoryId) >= 0) { + // Hide discussions in the category + setState(() => _toggledCategories.remove(_bookmark.categoryId)); + } else { + // Show discussions in the category + setState(() => _toggledCategories.add(_bookmark.categoryId)); + } + this.refreshData(); + }), + 'items': discussion + }); + }); + return DataProviderResult(categories); + }), + ], + ), + ); + }); + } +} diff --git a/lib/pages/tab_bar/MailboxTab.dart b/lib/pages/tab_bar/MailboxTab.dart new file mode 100644 index 00000000..d12f7930 --- /dev/null +++ b/lib/pages/tab_bar/MailboxTab.dart @@ -0,0 +1,125 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:fyx/components/mail_list_item.dart'; +import 'package:fyx/components/post/syntax_highlighter.dart'; +import 'package:fyx/components/pull_to_refresh_list.dart'; +import 'package:fyx/controllers/AnalyticsProvider.dart'; +import 'package:fyx/controllers/ApiController.dart'; +import 'package:fyx/controllers/IApiProvider.dart'; +import 'package:fyx/model/Mail.dart'; +import 'package:fyx/model/MainRepository.dart'; +import 'package:fyx/pages/NewMessagePage.dart'; +import 'package:fyx/theme/skin/Skin.dart'; +import 'package:fyx/theme/skin/SkinColors.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +class MailboxTab extends StatefulWidget { + final int refreshTimestamp; + + MailboxTab({this.refreshTimestamp = 0}); + + @override + _MailboxTabState createState() => _MailboxTabState(); +} + +class _MailboxTabState extends State { + int _refreshData = 0; + + refreshData() { + setState(() => _refreshData = DateTime.now().millisecondsSinceEpoch); + } + + @override + void initState() { + AnalyticsProvider().setScreen('Mailbox', 'MailboxTab'); + super.initState(); + } + + @override + void didUpdateWidget(MailboxTab oldWidget) { + if (widget.refreshTimestamp > _refreshData) { + this.refreshData(); + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + // Reset the language context. + // TODO: Not ideal. Get rid of the static. + SyntaxHighlighter.languageContext = ''; + SkinColors colors = Skin.of(context).theme.colors; + + return CupertinoTabView(builder: (context) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text( + 'Pošta', + style: TextStyle(color: colors.text), + )), + child: Stack(children: [ + PullToRefreshList( + rebuild: _refreshData, + isInfinite: true, + sliverListBuilder: (List data, {controller}) { + return ValueListenableBuilder( + valueListenable: MainRepository().settings.box.listenable(keys: ['blockedMails', 'blockedUsers']), + builder: (BuildContext context, value, Widget? child) { + var filtered = data; + if (data[0] is MailListItem) { + filtered = data + .where((item) => !MainRepository().settings.isMailBlocked((item as MailListItem).mail.id)) + .where((item) => !MainRepository().settings.isUserBlocked((item as MailListItem).mail.participant)) + .toList(); + } + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) => filtered[i], + childCount: filtered.length, + ), + ); + }, + ); + }, + dataProvider: (lastId) async { + var result = await ApiController().loadMail(lastId: lastId); + var mails = result.mails + .map((_mail) => Mail.fromJson(_mail, isCompact: MainRepository().settings.useCompactMode)) + .where((mail) => !MainRepository().settings.isMailBlocked(mail.id)) + .where((mail) => !MainRepository().settings.isUserBlocked(mail.participant)) + .map((mail) => MailListItem( + mail, + onUpdate: this.refreshData, + )) + .toList(); + var id = Mail.fromJson(result.mails.last, isCompact: MainRepository().settings.useCompactMode).id; + return DataProviderResult(mails, lastId: id); + }), + Positioned( + right: 20, + bottom: 20, + child: SafeArea( + child: FloatingActionButton( + backgroundColor: colors.primary, + foregroundColor: colors.background, + child: Icon(Icons.add), + onPressed: () => Navigator.of(context, rootNavigator: true).pushNamed('/new-message', + arguments: NewMessageSettings( + onClose: this.refreshData, + hasInputField: true, + onSubmit: (String? inputField, String message, List> attachments) async { + if (inputField == null) { + return false; + } + + var response = await ApiController().sendMail(inputField, message, attachments: attachments); + return response.isOk; + })), + ), + ), + ) + ]), + ); + }); + } +} diff --git a/lib/state/batch_actions_provider.dart b/lib/state/batch_actions_provider.dart new file mode 100644 index 00000000..e41f3cf2 --- /dev/null +++ b/lib/state/batch_actions_provider.dart @@ -0,0 +1,59 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fyx/model/Post.dart'; + +class PostsSelection extends StateNotifier> { + PostsSelection() : super([]); + + static final provider = StateNotifierProvider.autoDispose>((ref) { + return PostsSelection(); + }); + + void add(Post post) { + state = [...state, post]; + } + + void remove(Post post) { + state = [ + for (final _post in state) + if (_post.id != post.id) _post, + ]; + } + + void toggle(Post post) { + if (state.contains(post)) + this.remove(post); + else + this.add(post); + } + + void reset() { + state = []; + } +} + +class PostsToDelete extends StateNotifier> { + PostsToDelete() : super([]); + + static final provider = StateNotifierProvider.autoDispose>((ref) { + return PostsToDelete(); + }); + + void copy(List list) { + state = list; + } + + void add(Post post) { + state = [...state, post]; + } + + void remove(Post post) { + state = [ + for (final _post in state) + if (_post.id != post.id) _post, + ]; + } + + void reset() { + state = []; + } +} diff --git a/lib/state/search_providers.dart b/lib/state/search_providers.dart new file mode 100644 index 00000000..89bc3450 --- /dev/null +++ b/lib/state/search_providers.dart @@ -0,0 +1,11 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final searchHistoryProvider = StateProvider( + // We return the default sort type, here name. + (ref) => null, +); + +final searchBookmarksProvider = StateProvider( + // We return the default sort type, here name. + (ref) => null, +); diff --git a/lib/theme/L.dart b/lib/theme/L.dart index 6a0c2316..5c5f484f 100644 --- a/lib/theme/L.dart +++ b/lib/theme/L.dart @@ -1,13 +1,22 @@ // ignore_for_file: non_constant_identifier_names +import 'package:fyx/model/reponses/UnifiedSearchResponse.dart'; + class L { + static Map search = { + UnifiedSearchType.discussions: 'Nalezené diskuze', + UnifiedSearchType.events: 'Nalezené události', + UnifiedSearchType.advertisements: 'Nalezené inzeráty', + }; + // Errors static String AUTH_ERROR = 'Problém s přihlášením, přihlašte se znovu.'; static String API_ERROR = 'Pardon, nastal problém v komunikaci se serverem.'; - static String INAPPBROWSER_ERROR = 'Nepodařilo se otevřít prohlížeč.'; + static String INAPPBROWSER_ERROR = 'Nepodařilo se otevřít prohlížeč. Zkus si v nastavení změnit otevírání odkazů.'; static String REMINDER_ERROR = 'Příspěvek se nepodařilo uložit do upomínek.'; static String RATING_ERROR = 'Příspěvek se nepodařilo lajknout.'; static String ACCESS_DENIED_ERROR = 'Sem nemáš přístup.'; + static String CONNECTION_ERROR = '🔌 Problém s připojením, zkus to znovu...'; // General static String GENERAL_SKIP = 'Přeskočit'; diff --git a/lib/theme/T.dart b/lib/theme/T.dart index 7f02b51b..b04ed53a 100644 --- a/lib/theme/T.dart +++ b/lib/theme/T.dart @@ -6,9 +6,10 @@ import 'package:flutter/painting.dart'; import 'package:flutter/widgets.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:fyx/model/MainRepository.dart'; +import 'package:fyx/model/enums/LaunchModeEnum.dart'; import 'package:fyx/theme/L.dart'; -import 'package:fyx/theme/skin/SkinColors.dart'; import 'package:fyx/theme/skin/Skin.dart'; +import 'package:fyx/theme/skin/SkinColors.dart'; import 'package:sentry/sentry.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -39,12 +40,31 @@ class T { fontSize: 14.0); } - static Future openLink(String link) async { + static warn(String message, {int duration: 7, Color bg: Colors.orangeAccent}) { + Fluttertoast.showToast( + msg: message, + toastLength: Toast.LENGTH_LONG, + gravity: ToastGravity.TOP, + timeInSecForIosWeb: duration, + backgroundColor: bg, + textColor: Colors.black, + fontSize: 14.0); + } + + static Future openLink(String link, {mode: LaunchModeEnum.externalApplication}) async { try { - var status = await launch(link); - if (status == false) { + var encodedUri = Uri.parse(link); + + var canLaunch = await canLaunchUrl(encodedUri); + if (!canLaunch) { + throw ('Cannot launch url: $link'); + } + + var status = await launchUrl(encodedUri, mode: mode.original); + if (!status) { throw ('Cannot open webview. URL: $link'); } + return true; } catch (e) { T.error(L.INAPPBROWSER_ERROR); @@ -53,34 +73,38 @@ class T { } } - static prefillGithubIssue({MainRepository? appContext, String title = '', String body = '', String user = '-', String url = ''}) async { + static prefillGithubIssue({required MainRepository appContext, String title = '', String body = '', String user = '-', String url = ''}) async { var version = '-'; var system = '-'; var phone = '-'; - if (appContext != null) { - var pkg = appContext.packageInfo; - var device = appContext.deviceInfo; - version = Uri.encodeComponent('${pkg.version} (${pkg.buildNumber})'); - system = Uri.encodeComponent('${device.systemName} ${device.systemVersion}'); - phone = Uri.encodeComponent(device.localizedModel); - } + var pkg = appContext.packageInfo; + var device = appContext.deviceInfo; + version = Uri.encodeComponent('${pkg.version} (${pkg.buildNumber})'); + system = Uri.encodeComponent('${device.systemName} ${device.systemVersion}'); + phone = Uri.encodeComponent(device.localizedModel); var _body = Uri.encodeComponent(body); var _title = Uri.encodeComponent(title); var _url = Uri.encodeComponent(url); var link = 'https://docs.google.com/forms/d/e/1FAIpQLSdbUIaF8IFd-ybZVXARRmtdgIGbSuYg7Vs1HDCYUJrJFInV8w/viewform?entry.76077276=$_title&entry.1416760014=$_body&entry.1520830537=$version&entry.931510077=$user&entry.594008397=$system&entry.1758179395=$phone&entry.17618653=$_url'; - T.openLink(link); + T.openLink(link, mode: appContext.settings.linksMode); } - static Widget somethingsWrongButton(String content, {String url = '', IconData icon = Icons.warning, String title = 'Chyba zobrazení příspěvku.', String stack = ''}) { + static Widget somethingsWrongButton(String content, + {String url = '', IconData icon = Icons.warning, String title = 'Chyba zobrazení příspěvku.', String stack = ''}) { return GestureDetector( onTap: () => T.prefillGithubIssue( - title: title, body: '**Zdroj:**\n```$content```\n\n**Stack:**\n```$stack```', user: MainRepository().credentials!.nickname, url: url, appContext: MainRepository()), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Icon(icon, size: 48,), + title: title, + body: '**Zdroj:**\n```$content```\n\n**Stack:**\n```$stack```', + user: MainRepository().credentials!.nickname, + url: url, + appContext: MainRepository()), + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Icon( + icon, + size: 48, + ), Text( '$title\n Problém nahlásíte kliknutím zde.', textAlign: TextAlign.center, @@ -89,7 +113,8 @@ class T { ); } - static Widget feedbackScreen(BuildContext context, {bool isLoading = false, bool isWarning = false, String label = '', String title = '', VoidCallback? onPress, IconData icon = Icons.warning}) { + static Widget feedbackScreen(BuildContext context, + {bool isLoading = false, bool isWarning = false, String label = '', String title = '', VoidCallback? onPress, IconData icon = Icons.warning}) { return Container( width: double.infinity, color: (Skin.of(context).theme.colors as SkinColors).background, diff --git a/lib/theme/skin/Skin.dart b/lib/theme/skin/Skin.dart index 923826cc..53bfef66 100644 --- a/lib/theme/skin/Skin.dart +++ b/lib/theme/skin/Skin.dart @@ -1,8 +1,12 @@ import 'package:flutter/cupertino.dart'; +import 'package:fyx/model/enums/SkinEnum.dart'; abstract class SkinData { - final SkinBrightnessData lightData; - final SkinBrightnessData darkData; + abstract final String name; + abstract final SkinEnum id; + + late final SkinBrightnessData lightData; + late final SkinBrightnessData darkData; SkinData({required this.lightData, required this.darkData}); } @@ -18,14 +22,19 @@ class Skin extends InheritedWidget { Skin({ Key? key, required this.skin, + required this.skins, required this.brightness, required Widget child, }) : super(key: key, child: child); - final SkinData skin; + final SkinEnum skin; + final List skins; final Brightness brightness; - SkinBrightnessData get theme => this.brightness == Brightness.light ? skin.lightData : skin.darkData; + SkinBrightnessData get theme { + final _selectedSkin = skins.firstWhere((skin) => skin.id == this.skin); + return this.brightness == Brightness.light ? _selectedSkin.lightData : _selectedSkin.darkData; + } static Skin of(BuildContext context) { final Skin? result = context.dependOnInheritedWidgetOfExactType(); diff --git a/lib/theme/skin/SkinColors.dart b/lib/theme/skin/SkinColors.dart index 212d0186..ddc128d7 100644 --- a/lib/theme/skin/SkinColors.dart +++ b/lib/theme/skin/SkinColors.dart @@ -1,11 +1,14 @@ import 'dart:ui'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; class SkinColors { final Color primary; + //final Color secondaryColor; final Color highlight; + final Color highlightedText; final Color success; final Color danger; final Color barBackground; @@ -20,6 +23,7 @@ class SkinColors { final Color dark; final BoxDecoration shadow; final LinearGradient gradient; + final BoxDecoration textFieldDecoration; Color? primaryContrasting; SkinColors({ @@ -30,6 +34,7 @@ class SkinColors { this.success = Colors.green, this.danger = Colors.redAccent, this.highlight = const Color(0xff33BB9A), + this.highlightedText = Colors.amber, this.light = Colors.white, this.dark = const Color(0xFF282828), this.grey = Colors.black38, @@ -38,9 +43,23 @@ class SkinColors { this.pollAnswer = const Color(0xffa9ccd3), this.pollAnswerSelected = const Color(0xff76b9b9), this.gradient = const LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xff1AD592), Color(0xFF196378)]), + this.textFieldDecoration = const BoxDecoration( + color: CupertinoDynamicColor.withBrightness( + color: CupertinoColors.white, + darkColor: CupertinoColors.black, + ), + border: Border.fromBorderSide(BorderSide( + color: CupertinoDynamicColor.withBrightness( + color: Color(0x33000000), + darkColor: Color(0x33FFFFFF), + ), + width: 0.0, + )), + borderRadius: BorderRadius.all(Radius.circular(5.0)), + ), this.primaryContrasting, //this.secondaryColor = const Color(0xff007F90), - }) : shadow = BoxDecoration( + }) : shadow = BoxDecoration( borderRadius: BorderRadius.all(Radius.circular(8)), color: background, border: Border.fromBorderSide(BorderSide(color: primary, width: 1, style: BorderStyle.solid)), diff --git a/lib/theme/skin/skins/ForestSkin.dart b/lib/theme/skin/skins/ForestSkin.dart new file mode 100644 index 00000000..9859aef5 --- /dev/null +++ b/lib/theme/skin/skins/ForestSkin.dart @@ -0,0 +1,71 @@ +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:fyx/model/enums/SkinEnum.dart'; +import 'package:fyx/theme/skin/Skin.dart'; +import 'package:fyx/theme/skin/SkinColors.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class ForestSkin extends SkinData { + final id = SkinEnum.forest; + final name = 'Forest4'; + + ForestSkin({lightData, darkData}) : super(lightData: lightData, darkData: darkData); + + factory ForestSkin.create({required double fontSize}) { + final lightColors = SkinColors( + primary: const Color(0xFF3B4F41), + primaryContrasting: const Color(0xffcccdb1), + background: const Color(0xffb8b992), // #B8B992 + barBackground: const Color(0xff87937b), // #617E69 + text: const Color(0xFF282828), + success: Color(0xff4D6654), + danger: const Color(0xffb60f0f), + highlight: const Color(0xffDBD68B), + highlightedText: Colors.amber, + light: Colors.white, + dark: const Color(0xFF282828), + grey: Color(0xFF3B4F41).withOpacity(.7), + disabled: Colors.black26, + pollBackground: const Color(0xffcccdb1), + pollAnswer: const Color(0xffb8b992), + pollAnswerSelected: const Color(0xffDBD68B), + gradient: const LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xff1AD592), Color(0xFF196378)]), + textFieldDecoration: const BoxDecoration( + color: CupertinoDynamicColor.withBrightness( + color: Color(0xffCCCCB1), + darkColor: Color(0xffCCCCB1), + ), + border: Border.fromBorderSide(BorderSide( + color: CupertinoDynamicColor.withBrightness( + color: Color(0xff858B62), + darkColor: Color(0xff858B62), + ), + width: 0.0, + )), + borderRadius: BorderRadius.all(Radius.circular(5.0)), + ) + ); + final darkColors = lightColors; + + return ForestSkin( + lightData: SkinBrightnessData( + data: CupertinoThemeData( + barBackgroundColor: lightColors.barBackground, + primaryColor: lightColors.primary, + scaffoldBackgroundColor: lightColors.background, + brightness: Brightness.light, + textTheme: CupertinoTextThemeData(textStyle: Platform.isIOS ? GoogleFonts.inter(color: lightColors.text, fontSize: fontSize) : TextStyle(color: lightColors.text, fontSize: fontSize))), + colors: lightColors), + darkData: SkinBrightnessData( + data: CupertinoThemeData( + barBackgroundColor: darkColors.barBackground, + primaryContrastingColor: darkColors.primaryContrasting, + scaffoldBackgroundColor: darkColors.background, + primaryColor: darkColors.primary, + brightness: Brightness.dark, + textTheme: CupertinoTextThemeData(textStyle: Platform.isIOS ? GoogleFonts.inter(color: darkColors.text, fontSize: fontSize) : TextStyle(color: darkColors.text, fontSize: fontSize))), + colors: darkColors)); + } +} diff --git a/lib/theme/skin/skins/FyxSkin.dart b/lib/theme/skin/skins/FyxSkin.dart index 7cb21810..e33ec6cd 100644 --- a/lib/theme/skin/skins/FyxSkin.dart +++ b/lib/theme/skin/skins/FyxSkin.dart @@ -1,25 +1,32 @@ +import 'dart:io'; + import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; +import 'package:fyx/model/enums/SkinEnum.dart'; import 'package:fyx/theme/skin/Skin.dart'; import 'package:fyx/theme/skin/SkinColors.dart'; +import 'package:google_fonts/google_fonts.dart'; class FyxSkin extends SkinData { + final id = SkinEnum.fyx; + final name = 'Fyx'; + FyxSkin({lightData, darkData}) : super(lightData: lightData, darkData: darkData); - factory FyxSkin.create() { + factory FyxSkin.create({required double fontSize}) { final lightColors = SkinColors(); final darkColors = SkinColors( primary: const Color(0xff4898ad), background: const Color(0xFF1C2128), barBackground: const Color(0xff2d333b), text: const Color(0xFFadbac7), + grey: CupertinoColors.inactiveGray, danger: const Color(0xffe5534b), highlight: const Color(0xff33BB9A), disabled: const Color(0xFFadbac7), pollBackground: const Color(0xff2d333b), pollAnswer: const Color(0xff677578), pollAnswerSelected: const Color(0xff316775), - primaryContrasting: const Color(0xFF1c2128), + primaryContrasting: const Color(0xff4898ad), gradient: const LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xff316775), Color(0xff00242e)])); return FyxSkin( @@ -29,7 +36,7 @@ class FyxSkin extends SkinData { primaryColor: lightColors.primary, scaffoldBackgroundColor: lightColors.background, brightness: Brightness.light, - textTheme: CupertinoTextThemeData(textStyle: TextStyle(color: lightColors.text, fontSize: 16))), + textTheme: CupertinoTextThemeData(textStyle: Platform.isIOS ? GoogleFonts.inter(color: lightColors.text, fontSize: fontSize) : TextStyle(color: lightColors.text, fontSize: fontSize))), colors: lightColors), darkData: SkinBrightnessData( data: CupertinoThemeData( @@ -38,7 +45,7 @@ class FyxSkin extends SkinData { scaffoldBackgroundColor: darkColors.background, primaryColor: darkColors.primary, brightness: Brightness.dark, - textTheme: CupertinoTextThemeData(textStyle: TextStyle(color: darkColors.text, fontSize: 16))), + textTheme: CupertinoTextThemeData(textStyle: Platform.isIOS ? GoogleFonts.inter(color: darkColors.text, fontSize: fontSize) : TextStyle(color: darkColors.text, fontSize: fontSize))), colors: darkColors)); } } diff --git a/pubspec.lock b/pubspec.lock index 9fae4034..2987f5d3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -7,14 +7,14 @@ packages: name: _fe_analyzer_shared url: "https://pub.dartlang.org" source: hosted - version: "22.0.0" + version: "46.0.0" analyzer: - dependency: transitive + dependency: "direct dev" description: name: analyzer url: "https://pub.dartlang.org" source: hosted - version: "1.7.2" + version: "4.6.0" archive: dependency: transitive description: @@ -56,35 +56,35 @@ packages: name: build url: "https://pub.dartlang.org" source: hosted - version: "2.2.1" + version: "2.3.0" build_config: dependency: transitive description: name: build_config url: "https://pub.dartlang.org" source: hosted - version: "1.0.0" + version: "1.1.0" build_daemon: dependency: transitive description: name: build_daemon url: "https://pub.dartlang.org" source: hosted - version: "3.0.0" + version: "3.1.0" build_resolvers: dependency: transitive description: name: build_resolvers url: "https://pub.dartlang.org" source: hosted - version: "2.0.4" + version: "2.0.9" build_runner: dependency: "direct dev" description: name: build_runner url: "https://pub.dartlang.org" source: hosted - version: "2.1.7" + version: "2.2.0" build_runner_core: dependency: transitive description: @@ -112,7 +112,7 @@ packages: name: cached_network_image url: "https://pub.dartlang.org" source: hosted - version: "3.2.0" + version: "3.2.1" cached_network_image_platform_interface: dependency: transitive description: @@ -169,13 +169,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.0" - cli_util: - dependency: transitive - description: - name: cli_util - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.3" clock: dependency: transitive description: @@ -196,7 +189,7 @@ packages: name: collection url: "https://pub.dartlang.org" source: hosted - version: "1.15.0" + version: "1.16.0" convert: dependency: transitive description: @@ -238,7 +231,7 @@ packages: name: dart_style url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "2.2.3" device_info: dependency: "direct main" description: @@ -253,6 +246,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.1" + diacritic: + dependency: "direct main" + description: + name: diacritic + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.3" dio: dependency: "direct main" description: @@ -266,7 +266,7 @@ packages: name: fake_async url: "https://pub.dartlang.org" source: hosted - version: "1.2.0" + version: "1.3.0" ffi: dependency: transitive description: @@ -371,19 +371,12 @@ packages: source: hosted version: "0.6.0" flutter_cache_manager: - dependency: transitive + dependency: "direct main" description: name: flutter_cache_manager url: "https://pub.dartlang.org" source: hosted version: "3.3.0" - flutter_cupertino_settings: - dependency: "direct main" - description: - name: flutter_cupertino_settings - url: "https://pub.dartlang.org" - source: hosted - version: "0.5.0" flutter_dotenv: dependency: "direct main" description: @@ -411,7 +404,7 @@ packages: name: flutter_launcher_icons url: "https://pub.dartlang.org" source: hosted - version: "0.9.2" + version: "0.9.3" flutter_layout_grid: dependency: transitive description: @@ -445,6 +438,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.2" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0-dev.6" flutter_sticky_header: dependency: "direct main" description: @@ -490,6 +490,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.1" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" graphs: dependency: transitive description: @@ -524,7 +531,7 @@ packages: name: hive_generator url: "https://pub.dartlang.org" source: hosted - version: "1.1.2" + version: "1.1.3" html: dependency: "direct main" description: @@ -629,14 +636,14 @@ packages: name: js url: "https://pub.dartlang.org" source: hosted - version: "0.6.3" + version: "0.6.4" json_annotation: dependency: transitive description: name: json_annotation url: "https://pub.dartlang.org" source: hosted - version: "4.0.1" + version: "4.6.0" logging: dependency: transitive description: @@ -664,7 +671,14 @@ packages: name: material_color_utilities url: "https://pub.dartlang.org" source: hosted - version: "0.1.3" + version: "0.1.4" + material_design_icons_flutter: + dependency: "direct main" + description: + name: material_design_icons_flutter + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.6996" meta: dependency: "direct main" description: @@ -762,7 +776,7 @@ packages: name: path url: "https://pub.dartlang.org" source: hosted - version: "1.8.0" + version: "1.8.1" path_drawing: dependency: transitive description: @@ -895,7 +909,7 @@ packages: name: plugin_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "2.1.2" pool: dependency: transitive description: @@ -938,6 +952,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.1" + riverpod: + dependency: transitive + description: + name: riverpod + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0-dev.6" rxdart: dependency: transitive description: @@ -945,20 +966,34 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.27.1" + scroll_to_index: + dependency: "direct main" + description: + name: scroll_to_index + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" sentry: dependency: transitive description: name: sentry url: "https://pub.dartlang.org" source: hosted - version: "6.3.0" + version: "6.9.1" sentry_flutter: dependency: "direct main" description: name: sentry_flutter url: "https://pub.dartlang.org" source: hosted - version: "6.3.0" + version: "6.9.1" + settings_ui: + dependency: "direct main" + description: + name: settings_ui + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" share: dependency: "direct main" description: @@ -1047,21 +1082,21 @@ packages: name: source_gen url: "https://pub.dartlang.org" source: hosted - version: "1.0.2" + version: "1.2.2" source_helper: dependency: transitive description: name: source_helper url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "1.3.2" source_span: dependency: transitive description: name: source_span url: "https://pub.dartlang.org" source: hosted - version: "1.8.1" + version: "1.8.2" sqflite: dependency: transitive description: @@ -1083,6 +1118,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.10.0" + state_notifier: + dependency: transitive + description: + name: state_notifier + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.2+1" stream_channel: dependency: transitive description: @@ -1111,6 +1153,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.0" + tap_canvas: + dependency: "direct main" + description: + name: tap_canvas + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.4" term_glyph: dependency: transitive description: @@ -1124,7 +1173,7 @@ packages: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.4.8" + version: "0.4.9" timing: dependency: transitive description: @@ -1152,7 +1201,7 @@ packages: name: url_launcher url: "https://pub.dartlang.org" source: hosted - version: "6.0.20" + version: "6.1.5" url_launcher_android: dependency: transitive description: @@ -1187,7 +1236,7 @@ packages: name: url_launcher_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.4" + version: "2.1.0" url_launcher_web: dependency: transitive description: @@ -1222,7 +1271,7 @@ packages: name: vector_math url: "https://pub.dartlang.org" source: hosted - version: "2.1.1" + version: "2.1.2" very_good_analysis: dependency: transitive description: @@ -1315,7 +1364,7 @@ packages: source: hosted version: "2.1.0" webview_flutter: - dependency: transitive + dependency: "direct main" description: name: webview_flutter url: "https://pub.dartlang.org" @@ -1350,5 +1399,5 @@ packages: source: hosted version: "3.1.0" sdks: - dart: ">=2.15.0 <3.0.0" - flutter: ">=2.10.2" + dart: ">=2.17.0 <3.0.0" + flutter: ">=3.0.5" diff --git a/pubspec.yaml b/pubspec.yaml index 99d20493..70dfae70 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,11 +11,11 @@ description: A new Flutter project. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.8.3+57 +version: 0.9.0+75 environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ^2.10.2 + sdk: ">=2.17.0 <3.0.0" + flutter: ^3.0.5 dependencies: provider: ^6.0.2 @@ -27,11 +27,11 @@ dependencies: shared_preferences: ^2.0.13 fluttertoast: ^8.0.9 html_unescape: ^2.0.0 - cached_network_image: ^3.2.0 + cached_network_image: ^3.2.1 carousel_slider: ^4.0.0 device_info: ^2.0.3 package_info: ^2.0.2 - url_launcher: ^6.0.20 + url_launcher: ^6.1.5 photo_view: ^0.13.0 auto_size_text: ^3.0.0-nullsafety.0 image_picker: ^0.8.4+10 @@ -40,20 +40,28 @@ dependencies: async: ^2.8.2 chewie: ^1.3.0 video_player: ^2.2.19 - flutter_cupertino_settings: ^0.5.0 + settings_ui: ^2.0.2 flutter_markdown: ^0.6.9 http: ^0.13.4 share: ^2.0.4 hive: ^2.0.6 hive_flutter: ^1.1.0 firebase_analytics: ^8.3.4 - sentry_flutter: 6.3.0 + sentry_flutter: ^6.9.1 flutter_dotenv: ^5.0.2 firebase_messaging: ^11.1.0 flutter_highlight: ^0.7.0 image_gallery_saver: ^1.7.1 path_provider: 2.0.9 permission_handler: 9.2.0 + scroll_to_index: any + flutter_riverpod: ^2.0.0-dev.4 + diacritic: ^0.1.3 + tap_canvas: ^0.9.4 + webview_flutter: ^2.0.4 + google_fonts: ^3.0.1 + material_design_icons_flutter: 5.0.6996 + flutter_cache_manager: ^3.3.0 # Dart-lang core plugins 👇 http_parser: ^4.0.0 @@ -79,7 +87,8 @@ dependency_overrides: dev_dependencies: hive_generator: ^1.1.0 build_runner: ^2.0.6 - flutter_launcher_icons: ^0.9.0 + flutter_launcher_icons: ^0.9.3 + analyzer: ^4.6.0 flutter_test: sdk: flutter @@ -105,6 +114,7 @@ flutter: - tutorial-4.png - travolta.gif - lets-encrypt-r3.cer + - assets/inter_font/ fonts: - family: JetBrainsMono diff --git a/test/api_test.dart b/test/api_test.dart index 4fe2bf33..eba5b9b7 100644 --- a/test/api_test.dart +++ b/test/api_test.dart @@ -85,7 +85,6 @@ class ApiMock implements IApiProvider { return _credentials; } - @override Future giveRating(int discussionId, int postId, bool add, bool confirm, bool remove) { // TODO: implement giveRating @@ -187,6 +186,24 @@ class ApiMock implements IApiProvider { // TODO: implement getPostRatings throw UnimplementedError(); } + + @override + Future searchDiscussions(String term) { + // TODO: implement getPostRatings + throw UnimplementedError(); + } + + @override + Future bookmarkDiscussion(int id, bool state) { + // TODO: implement getPostRatings + throw UnimplementedError(); + } + + @override + Future fetchDiscussionHeader(int id) { + // TODO: implement getPostRatings + throw UnimplementedError(); + } } void main() { @@ -225,10 +242,17 @@ void main() { var loginName = 'TOMMYSHELBY'; var api = ApiController(); - api.provider = ApiMock( - {"result": "error", "code": "401", "error": true, "auth_state": "AUTH_NEW", "auth_token": "44a3d1241830ca61a592e28df783007d", "auth_code": "6f9a10647d", "auth_dev_comment": "Direct user to PERSONAL \/ SETTINGS \/ AUTHORIZATIONS and tell him to accept request from this app. He will have to confirm it by transcribing passcode from [auth_code]. After confirming it, access using [token] should be working."}, - emptyCredentials: true // 👀 👈 - ); + api.provider = ApiMock({ + "result": "error", + "code": "401", + "error": true, + "auth_state": "AUTH_NEW", + "auth_token": "44a3d1241830ca61a592e28df783007d", + "auth_code": "6f9a10647d", + "auth_dev_comment": + "Direct user to PERSONAL \/ SETTINGS \/ AUTHORIZATIONS and tell him to accept request from this app. He will have to confirm it by transcribing passcode from [auth_code]. After confirming it, access using [token] should be working." + }, emptyCredentials: true // 👀 👈 + ); var prefs = await SharedPreferences.getInstance(); String? identity = prefs.getString('identity'); @@ -257,7 +281,8 @@ void main() { var api = ApiController(); api.provider = ApiMock({"error": true, "message": "Nepodařilo se načíst uživatele."}); - expect(() async => await api.login(loginName), throwsA(predicate((e) => (e is AuthException && e.toString() == 'Nepodařilo se načíst uživatele.')))); + expect( + () async => await api.login(loginName), throwsA(predicate((e) => (e is AuthException && e.toString() == 'Nepodařilo se načíst uživatele.')))); }); // TODO is this still possible response? Can't reproduce it