diff --git a/.DS_Store b/.DS_Store index fbe691ba..269af6f0 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.flutter-plugins b/.flutter-plugins deleted file mode 100644 index 5ee678fe..00000000 --- a/.flutter-plugins +++ /dev/null @@ -1,6 +0,0 @@ -# This is a generated file; do not edit or check into version control. -firebase_core=/Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/firebase_core-1.12.0/ -firebase_core_web=/Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/firebase_core_web-1.5.4/ -video_player=/Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/video_player-2.2.15/ -video_player_web=/Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/video_player_web-2.0.6/ -video_player_web_hls=/Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/video_player_web_hls-0.1.11+3/ diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies deleted file mode 100644 index dcc97096..00000000 --- a/.flutter-plugins-dependencies +++ /dev/null @@ -1 +0,0 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"firebase_core","path":"/Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/firebase_core-1.12.0/","dependencies":[]},{"name":"video_player","path":"/Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/video_player-2.2.15/","dependencies":[]}],"android":[{"name":"firebase_core","path":"/Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/firebase_core-1.12.0/","dependencies":[]},{"name":"video_player","path":"/Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/video_player-2.2.15/","dependencies":[]}],"macos":[{"name":"firebase_core","path":"/Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/firebase_core-1.12.0/","dependencies":[]}],"linux":[],"windows":[],"web":[{"name":"firebase_core_web","path":"/Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/firebase_core_web-1.5.4/","dependencies":[]},{"name":"video_player_web","path":"/Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/video_player_web-2.0.6/","dependencies":[]},{"name":"video_player_web_hls","path":"/Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/video_player_web_hls-0.1.11+3/","dependencies":[]}]},"dependencyGraph":[{"name":"firebase_core","dependencies":["firebase_core_web"]},{"name":"firebase_core_web","dependencies":[]},{"name":"video_player","dependencies":["video_player_web"]},{"name":"video_player_web","dependencies":[]},{"name":"video_player_web_hls","dependencies":[]}],"date_created":"2022-01-30 16:27:43.690884","version":"2.8.1"} \ No newline at end of file diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml new file mode 100644 index 00000000..de19edbb --- /dev/null +++ b/.github/workflows/android-build.yml @@ -0,0 +1,30 @@ +name: Android Build + +on: + workflow_dispatch: # Allow manual triggering of the workflow + +jobs: + build: + name: Build Android APK + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: stable + + - name: Install dependencies + run: flutter pub get + + - name: Build APK + run: flutter build apk --release + + - name: Upload APK + uses: actions/upload-artifact@v3 + with: + name: android-apk + path: build/app/outputs/flutter-apk/app-release.apk diff --git a/.gitignore b/.gitignore index 54f88372..ca093c16 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,14 @@ +android/private_key.pepk +android/signing.properties +android/app/store +android/app/release/app-release.aab +android/.idea/** +android/.idea +.env +.flutter-plugins-dependencies +.flutter-plugins + + # Xcode # # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore @@ -22,6 +33,8 @@ DerivedData/ *.perspectivev3 !default.perspectivev3 +ios/vendor + ## Obj-C/Swift specific *.hmap @@ -89,4 +102,9 @@ fastlane/test_output iOSInjectionProject/ -.dart_tool \ No newline at end of file +.dart_tool +.env +dotenv +*.env + +.idea \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d33521..00000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index 1b2101d7..00000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -acela \ No newline at end of file diff --git a/.idea/Android-App.iml b/.idea/Android-App.iml new file mode 100644 index 00000000..d97ec2ad --- /dev/null +++ b/.idea/Android-App.iml @@ -0,0 +1,210 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml deleted file mode 100644 index 4bec4ea8..00000000 --- a/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index a55e7a17..00000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/libraries/Dart_Packages.xml b/.idea/libraries/Dart_Packages.xml deleted file mode 100644 index 6a2abd22..00000000 --- a/.idea/libraries/Dart_Packages.xml +++ /dev/null @@ -1,348 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/libraries/Dart_SDK.xml b/.idea/libraries/Dart_SDK.xml index 6cba42fc..d4bb4d42 100644 --- a/.idea/libraries/Dart_SDK.xml +++ b/.idea/libraries/Dart_SDK.xml @@ -1,25 +1,27 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/.idea/libraries/Flutter_Plugins.xml b/.idea/libraries/Flutter_Plugins.xml index 2d99f4f8..a35cb6dc 100644 --- a/.idea/libraries/Flutter_Plugins.xml +++ b/.idea/libraries/Flutter_Plugins.xml @@ -1,11 +1,74 @@ - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.idea/libraries/KotlinJavaRuntime.xml b/.idea/libraries/KotlinJavaRuntime.xml deleted file mode 100644 index 2b96ac4b..00000000 --- a/.idea/libraries/KotlinJavaRuntime.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index f4f6d798..00000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml index bc717bbb..b8bc9936 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -2,7 +2,7 @@ - + \ No newline at end of file diff --git a/.idea/runConfigurations/main_dart.xml b/.idea/runConfigurations/main_dart.xml deleted file mode 100644 index aab7b5cd..00000000 --- a/.idea/runConfigurations/main_dart.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.metadata b/.metadata deleted file mode 100644 index fd70cabc..00000000 --- a/.metadata +++ /dev/null @@ -1,10 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: 77d935af4db863f6abd0b9c31c7e6df2a13de57b - channel: stable - -project_type: app diff --git a/.packages b/.packages deleted file mode 100644 index 3dcda11b..00000000 --- a/.packages +++ /dev/null @@ -1,49 +0,0 @@ -# This file is deprecated. Tools should instead consume -# `.dart_tool/package_config.json`. -# -# For more info see: https://dart.dev/go/dot-packages-deprecation -# -# Generated by pub on 2022-01-30 16:15:49.484679. -args:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/args-2.3.0/lib/ -async:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/async-2.8.2/lib/ -boolean_selector:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/boolean_selector-2.1.0/lib/ -characters:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/characters-1.2.0/lib/ -charcode:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/charcode-1.3.1/lib/ -clock:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/clock-1.1.0/lib/ -collection:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/collection-1.15.0/lib/ -csslib:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/csslib-0.17.1/lib/ -fake_async:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/fake_async-1.2.0/lib/ -firebase_core:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/firebase_core-1.12.0/lib/ -firebase_core_platform_interface:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/firebase_core_platform_interface-4.2.4/lib/ -firebase_core_web:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/firebase_core_web-1.5.4/lib/ -flutter:file:///Applications/flutter/flutter/packages/flutter/lib/ -flutter_lints:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/flutter_lints-1.0.4/lib/ -flutter_markdown:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/flutter_markdown-0.6.9/lib/ -flutter_test:file:///Applications/flutter/flutter/packages/flutter_test/lib/ -flutter_web_plugins:file:///Applications/flutter/flutter/packages/flutter_web_plugins/lib/ -html:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/html-0.15.0/lib/ -http:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/http-0.13.4/lib/ -http_parser:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/http_parser-4.0.0/lib/ -js:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/js-0.6.3/lib/ -lints:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/lints-1.0.1/lib/ -markdown:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/markdown-4.0.1/lib/ -matcher:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/matcher-0.12.11/lib/ -meta:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/meta-1.7.0/lib/ -path:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/path-1.8.0/lib/ -pedantic:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/pedantic-1.11.1/lib/ -plugin_platform_interface:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/plugin_platform_interface-2.1.2/lib/ -sky_engine:file:///Applications/flutter/flutter/bin/cache/pkg/sky_engine/lib/ -source_span:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/source_span-1.8.1/lib/ -stack_trace:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/stack_trace-1.10.0/lib/ -stream_channel:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/stream_channel-2.1.0/lib/ -string_scanner:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/string_scanner-1.1.0/lib/ -term_glyph:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/term_glyph-1.2.0/lib/ -test_api:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/test_api-0.4.3/lib/ -timeago:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/timeago-3.1.0/lib/ -typed_data:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/typed_data-1.3.0/lib/ -vector_math:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/vector_math-2.1.1/lib/ -video_player:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/video_player-2.2.15/lib/ -video_player_platform_interface:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/video_player_platform_interface-4.2.0/lib/ -video_player_web:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/video_player_web-2.0.6/lib/ -video_player_web_hls:file:///Applications/flutter/flutter/.pub-cache/hosted/pub.dartlang.org/video_player_web_hls-0.1.11+3/lib/ -acela:lib/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..ec5f3c57 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +FROM mingc/android-build-box:latest + +RUN apt-get update +RUN apt-get install -y curl git wget unzip libgconf-2-4 gdb libstdc++6 libglu1-mesa fonts-droid-fallback lib32stdc++6 python3 +RUN apt-get clean + +# download Flutter SDK from Flutter Github repo +RUN git clone https://github.com/flutter/flutter.git /usr/local/flutter + +# Set flutter environment path +ENV PATH="/usr/local/flutter/bin:/usr/local/flutter/bin/cache/dart-sdk/bin:${PATH}" + +# Run flutter doctor +RUN flutter doctor + +# Install Android SDK dependencies +RUN apt-get update && apt-get install -y \ + openjdk-11-jdk \ + && apt-get clean + + + +RUN eval "$(jenv init -)" + +# Create and set the working directory +WORKDIR /app + +# Copy the Flutter project into the container +COPY . . + +# Check Flutter +RUN flutter doctor + +# Get Flutter dependencies +RUN flutter pub get + +# Build the Flutter project for Android +CMD ["flutter", "build", "aab", "--release"] diff --git a/acela.iml b/acela.iml index dec007d1..e6299203 100644 --- a/acela.iml +++ b/acela.iml @@ -54,6 +54,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/analysis_options.yaml b/analysis_options.yaml deleted file mode 100644 index 61b6c4de..00000000 --- a/analysis_options.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at - # https://dart-lang.github.io/linter/lints/index.html. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore index 6f568019..fd140eb1 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -11,3 +11,5 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks +/.idea +.idea \ No newline at end of file diff --git a/android/.idea/.gitignore b/android/.idea/.gitignore deleted file mode 100644 index 26d33521..00000000 --- a/android/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/android/.idea/assetWizardSettings.xml b/android/.idea/assetWizardSettings.xml deleted file mode 100644 index ba5438e8..00000000 --- a/android/.idea/assetWizardSettings.xml +++ /dev/null @@ -1,292 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/android/.idea/compiler.xml b/android/.idea/compiler.xml deleted file mode 100644 index fb7f4a8a..00000000 --- a/android/.idea/compiler.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/android/.idea/gradle.xml b/android/.idea/gradle.xml index 1c4cf4dc..bb7ef750 100644 --- a/android/.idea/gradle.xml +++ b/android/.idea/gradle.xml @@ -4,17 +4,57 @@ diff --git a/android/.idea/jarRepositories.xml b/android/.idea/jarRepositories.xml deleted file mode 100644 index 75fe4bc9..00000000 --- a/android/.idea/jarRepositories.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle_______build_app_intermediates_flutter_debug_libs_jar.xml b/android/.idea/libraries/Gradle_______build_app_intermediates_flutter_debug_libs_jar.xml deleted file mode 100644 index 106c5b36..00000000 --- a/android/.idea/libraries/Gradle_______build_app_intermediates_flutter_debug_libs_jar.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__androidx_activity_activity_1_0_0_aar.xml b/android/.idea/libraries/Gradle__androidx_activity_activity_1_0_0_aar.xml deleted file mode 100644 index 06d1f863..00000000 --- a/android/.idea/libraries/Gradle__androidx_activity_activity_1_0_0_aar.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__androidx_annotation_annotation_1_2_0.xml b/android/.idea/libraries/Gradle__androidx_annotation_annotation_1_2_0.xml deleted file mode 100644 index 74437d76..00000000 --- a/android/.idea/libraries/Gradle__androidx_annotation_annotation_1_2_0.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__androidx_annotation_annotation_experimental_1_1_0_aar.xml b/android/.idea/libraries/Gradle__androidx_annotation_annotation_experimental_1_1_0_aar.xml deleted file mode 100644 index 69540c2d..00000000 --- a/android/.idea/libraries/Gradle__androidx_annotation_annotation_experimental_1_1_0_aar.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__androidx_arch_core_core_common_2_1_0.xml b/android/.idea/libraries/Gradle__androidx_arch_core_core_common_2_1_0.xml deleted file mode 100644 index 22084152..00000000 --- a/android/.idea/libraries/Gradle__androidx_arch_core_core_common_2_1_0.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__androidx_arch_core_core_runtime_2_0_0_aar.xml b/android/.idea/libraries/Gradle__androidx_arch_core_core_runtime_2_0_0_aar.xml deleted file mode 100644 index 28098333..00000000 --- a/android/.idea/libraries/Gradle__androidx_arch_core_core_runtime_2_0_0_aar.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__androidx_collection_collection_1_1_0.xml b/android/.idea/libraries/Gradle__androidx_collection_collection_1_1_0.xml deleted file mode 100644 index eafc05e9..00000000 --- a/android/.idea/libraries/Gradle__androidx_collection_collection_1_1_0.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__androidx_core_core_1_6_0_aar.xml b/android/.idea/libraries/Gradle__androidx_core_core_1_6_0_aar.xml deleted file mode 100644 index f171ace3..00000000 --- a/android/.idea/libraries/Gradle__androidx_core_core_1_6_0_aar.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__androidx_customview_customview_1_0_0_aar.xml b/android/.idea/libraries/Gradle__androidx_customview_customview_1_0_0_aar.xml deleted file mode 100644 index cbcb46a4..00000000 --- a/android/.idea/libraries/Gradle__androidx_customview_customview_1_0_0_aar.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__androidx_fragment_fragment_1_1_0_aar.xml b/android/.idea/libraries/Gradle__androidx_fragment_fragment_1_1_0_aar.xml deleted file mode 100644 index c1e43822..00000000 --- a/android/.idea/libraries/Gradle__androidx_fragment_fragment_1_1_0_aar.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__androidx_lifecycle_lifecycle_common_2_2_0.xml b/android/.idea/libraries/Gradle__androidx_lifecycle_lifecycle_common_2_2_0.xml deleted file mode 100644 index f7d64790..00000000 --- a/android/.idea/libraries/Gradle__androidx_lifecycle_lifecycle_common_2_2_0.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__androidx_lifecycle_lifecycle_common_java8_2_2_0.xml b/android/.idea/libraries/Gradle__androidx_lifecycle_lifecycle_common_java8_2_2_0.xml deleted file mode 100644 index fc8d6778..00000000 --- a/android/.idea/libraries/Gradle__androidx_lifecycle_lifecycle_common_java8_2_2_0.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__androidx_lifecycle_lifecycle_livedata_2_0_0_aar.xml b/android/.idea/libraries/Gradle__androidx_lifecycle_lifecycle_livedata_2_0_0_aar.xml deleted file mode 100644 index 15e56ddf..00000000 --- a/android/.idea/libraries/Gradle__androidx_lifecycle_lifecycle_livedata_2_0_0_aar.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__androidx_lifecycle_lifecycle_livedata_core_2_0_0_aar.xml b/android/.idea/libraries/Gradle__androidx_lifecycle_lifecycle_livedata_core_2_0_0_aar.xml deleted file mode 100644 index 73bb1d5e..00000000 --- a/android/.idea/libraries/Gradle__androidx_lifecycle_lifecycle_livedata_core_2_0_0_aar.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__androidx_lifecycle_lifecycle_runtime_2_2_0_aar.xml b/android/.idea/libraries/Gradle__androidx_lifecycle_lifecycle_runtime_2_2_0_aar.xml deleted file mode 100644 index a05f2022..00000000 --- a/android/.idea/libraries/Gradle__androidx_lifecycle_lifecycle_runtime_2_2_0_aar.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__androidx_lifecycle_lifecycle_viewmodel_2_1_0_aar.xml b/android/.idea/libraries/Gradle__androidx_lifecycle_lifecycle_viewmodel_2_1_0_aar.xml deleted file mode 100644 index bcfcd11e..00000000 --- a/android/.idea/libraries/Gradle__androidx_lifecycle_lifecycle_viewmodel_2_1_0_aar.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__androidx_loader_loader_1_0_0_aar.xml b/android/.idea/libraries/Gradle__androidx_loader_loader_1_0_0_aar.xml deleted file mode 100644 index 2f72bddb..00000000 --- a/android/.idea/libraries/Gradle__androidx_loader_loader_1_0_0_aar.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__androidx_savedstate_savedstate_1_0_0_aar.xml b/android/.idea/libraries/Gradle__androidx_savedstate_savedstate_1_0_0_aar.xml deleted file mode 100644 index 811dc169..00000000 --- a/android/.idea/libraries/Gradle__androidx_savedstate_savedstate_1_0_0_aar.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__androidx_tracing_tracing_1_0_0_aar.xml b/android/.idea/libraries/Gradle__androidx_tracing_tracing_1_0_0_aar.xml deleted file mode 100644 index aa35bb79..00000000 --- a/android/.idea/libraries/Gradle__androidx_tracing_tracing_1_0_0_aar.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__androidx_versionedparcelable_versionedparcelable_1_1_1_aar.xml b/android/.idea/libraries/Gradle__androidx_versionedparcelable_versionedparcelable_1_1_1_aar.xml deleted file mode 100644 index 611a088b..00000000 --- a/android/.idea/libraries/Gradle__androidx_versionedparcelable_versionedparcelable_1_1_1_aar.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__androidx_viewpager_viewpager_1_0_0_aar.xml b/android/.idea/libraries/Gradle__androidx_viewpager_viewpager_1_0_0_aar.xml deleted file mode 100644 index 4f214927..00000000 --- a/android/.idea/libraries/Gradle__androidx_viewpager_viewpager_1_0_0_aar.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__com_google_android_exoplayer_exoplayer_common_2_14_1_aar.xml b/android/.idea/libraries/Gradle__com_google_android_exoplayer_exoplayer_common_2_14_1_aar.xml deleted file mode 100644 index 9062fc06..00000000 --- a/android/.idea/libraries/Gradle__com_google_android_exoplayer_exoplayer_common_2_14_1_aar.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__com_google_android_exoplayer_exoplayer_core_2_14_1_aar.xml b/android/.idea/libraries/Gradle__com_google_android_exoplayer_exoplayer_core_2_14_1_aar.xml deleted file mode 100644 index 5b786594..00000000 --- a/android/.idea/libraries/Gradle__com_google_android_exoplayer_exoplayer_core_2_14_1_aar.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__com_google_android_exoplayer_exoplayer_dash_2_14_1_aar.xml b/android/.idea/libraries/Gradle__com_google_android_exoplayer_exoplayer_dash_2_14_1_aar.xml deleted file mode 100644 index 4d305f4d..00000000 --- a/android/.idea/libraries/Gradle__com_google_android_exoplayer_exoplayer_dash_2_14_1_aar.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__com_google_android_exoplayer_exoplayer_extractor_2_14_1_aar.xml b/android/.idea/libraries/Gradle__com_google_android_exoplayer_exoplayer_extractor_2_14_1_aar.xml deleted file mode 100644 index dc32ca16..00000000 --- a/android/.idea/libraries/Gradle__com_google_android_exoplayer_exoplayer_extractor_2_14_1_aar.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__com_google_android_exoplayer_exoplayer_hls_2_14_1_aar.xml b/android/.idea/libraries/Gradle__com_google_android_exoplayer_exoplayer_hls_2_14_1_aar.xml deleted file mode 100644 index b7002dbf..00000000 --- a/android/.idea/libraries/Gradle__com_google_android_exoplayer_exoplayer_hls_2_14_1_aar.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__com_google_android_exoplayer_exoplayer_smoothstreaming_2_14_1_aar.xml b/android/.idea/libraries/Gradle__com_google_android_exoplayer_exoplayer_smoothstreaming_2_14_1_aar.xml deleted file mode 100644 index 3a95a7fa..00000000 --- a/android/.idea/libraries/Gradle__com_google_android_exoplayer_exoplayer_smoothstreaming_2_14_1_aar.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__com_google_guava_failureaccess_1_0_1.xml b/android/.idea/libraries/Gradle__com_google_guava_failureaccess_1_0_1.xml deleted file mode 100644 index aeb2fc76..00000000 --- a/android/.idea/libraries/Gradle__com_google_guava_failureaccess_1_0_1.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__com_google_guava_guava_27_1_android.xml b/android/.idea/libraries/Gradle__com_google_guava_guava_27_1_android.xml deleted file mode 100644 index 62babcfd..00000000 --- a/android/.idea/libraries/Gradle__com_google_guava_guava_27_1_android.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__com_google_guava_listenablefuture_9999_0_empty_to_avoid_conflict_with_guava.xml b/android/.idea/libraries/Gradle__com_google_guava_listenablefuture_9999_0_empty_to_avoid_conflict_with_guava.xml deleted file mode 100644 index 11f8cce0..00000000 --- a/android/.idea/libraries/Gradle__com_google_guava_listenablefuture_9999_0_empty_to_avoid_conflict_with_guava.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__io_flutter_arm64_v8a_debug_1_0_0_890a5fca2e34db413be624fc83aeea8e61d42ce6.xml b/android/.idea/libraries/Gradle__io_flutter_arm64_v8a_debug_1_0_0_890a5fca2e34db413be624fc83aeea8e61d42ce6.xml deleted file mode 100644 index f8a806a2..00000000 --- a/android/.idea/libraries/Gradle__io_flutter_arm64_v8a_debug_1_0_0_890a5fca2e34db413be624fc83aeea8e61d42ce6.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__io_flutter_armeabi_v7a_debug_1_0_0_890a5fca2e34db413be624fc83aeea8e61d42ce6.xml b/android/.idea/libraries/Gradle__io_flutter_armeabi_v7a_debug_1_0_0_890a5fca2e34db413be624fc83aeea8e61d42ce6.xml deleted file mode 100644 index 067ef82c..00000000 --- a/android/.idea/libraries/Gradle__io_flutter_armeabi_v7a_debug_1_0_0_890a5fca2e34db413be624fc83aeea8e61d42ce6.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__io_flutter_flutter_embedding_debug_1_0_0_890a5fca2e34db413be624fc83aeea8e61d42ce6.xml b/android/.idea/libraries/Gradle__io_flutter_flutter_embedding_debug_1_0_0_890a5fca2e34db413be624fc83aeea8e61d42ce6.xml deleted file mode 100644 index f7ecc2e8..00000000 --- a/android/.idea/libraries/Gradle__io_flutter_flutter_embedding_debug_1_0_0_890a5fca2e34db413be624fc83aeea8e61d42ce6.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__io_flutter_x86_64_debug_1_0_0_890a5fca2e34db413be624fc83aeea8e61d42ce6.xml b/android/.idea/libraries/Gradle__io_flutter_x86_64_debug_1_0_0_890a5fca2e34db413be624fc83aeea8e61d42ce6.xml deleted file mode 100644 index 5b11ae43..00000000 --- a/android/.idea/libraries/Gradle__io_flutter_x86_64_debug_1_0_0_890a5fca2e34db413be624fc83aeea8e61d42ce6.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__io_flutter_x86_debug_1_0_0_890a5fca2e34db413be624fc83aeea8e61d42ce6.xml b/android/.idea/libraries/Gradle__io_flutter_x86_debug_1_0_0_890a5fca2e34db413be624fc83aeea8e61d42ce6.xml deleted file mode 100644 index 2b9a9dfc..00000000 --- a/android/.idea/libraries/Gradle__io_flutter_x86_debug_1_0_0_890a5fca2e34db413be624fc83aeea8e61d42ce6.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__junit_junit_4_12.xml b/android/.idea/libraries/Gradle__junit_junit_4_12.xml deleted file mode 100644 index 6c078d62..00000000 --- a/android/.idea/libraries/Gradle__junit_junit_4_12.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__net_bytebuddy_byte_buddy_1_10_20.xml b/android/.idea/libraries/Gradle__net_bytebuddy_byte_buddy_1_10_20.xml deleted file mode 100644 index 3a6cfb29..00000000 --- a/android/.idea/libraries/Gradle__net_bytebuddy_byte_buddy_1_10_20.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__net_bytebuddy_byte_buddy_agent_1_10_20.xml b/android/.idea/libraries/Gradle__net_bytebuddy_byte_buddy_agent_1_10_20.xml deleted file mode 100644 index ccb3d374..00000000 --- a/android/.idea/libraries/Gradle__net_bytebuddy_byte_buddy_agent_1_10_20.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__org_hamcrest_hamcrest_core_1_3.xml b/android/.idea/libraries/Gradle__org_hamcrest_hamcrest_core_1_3.xml deleted file mode 100644 index 09cf23d1..00000000 --- a/android/.idea/libraries/Gradle__org_hamcrest_hamcrest_core_1_3.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__org_jetbrains_annotations_13_0.xml b/android/.idea/libraries/Gradle__org_jetbrains_annotations_13_0.xml deleted file mode 100644 index 1fa0fa9f..00000000 --- a/android/.idea/libraries/Gradle__org_jetbrains_annotations_13_0.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__org_jetbrains_kotlin_kotlin_stdlib_1_3_50.xml b/android/.idea/libraries/Gradle__org_jetbrains_kotlin_kotlin_stdlib_1_3_50.xml deleted file mode 100644 index e03b2f64..00000000 --- a/android/.idea/libraries/Gradle__org_jetbrains_kotlin_kotlin_stdlib_1_3_50.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__org_jetbrains_kotlin_kotlin_stdlib_common_1_3_50.xml b/android/.idea/libraries/Gradle__org_jetbrains_kotlin_kotlin_stdlib_common_1_3_50.xml deleted file mode 100644 index 861597d5..00000000 --- a/android/.idea/libraries/Gradle__org_jetbrains_kotlin_kotlin_stdlib_common_1_3_50.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__org_jetbrains_kotlin_kotlin_stdlib_jdk7_1_3_50.xml b/android/.idea/libraries/Gradle__org_jetbrains_kotlin_kotlin_stdlib_jdk7_1_3_50.xml deleted file mode 100644 index 8dfcb211..00000000 --- a/android/.idea/libraries/Gradle__org_jetbrains_kotlin_kotlin_stdlib_jdk7_1_3_50.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__org_mockito_mockito_core_3_9_0.xml b/android/.idea/libraries/Gradle__org_mockito_mockito_core_3_9_0.xml deleted file mode 100644 index 31d2f832..00000000 --- a/android/.idea/libraries/Gradle__org_mockito_mockito_core_3_9_0.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__org_mockito_mockito_inline_3_9_0.xml b/android/.idea/libraries/Gradle__org_mockito_mockito_inline_3_9_0.xml deleted file mode 100644 index 448d71fc..00000000 --- a/android/.idea/libraries/Gradle__org_mockito_mockito_inline_3_9_0.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/libraries/Gradle__org_objenesis_objenesis_3_2.xml b/android/.idea/libraries/Gradle__org_objenesis_objenesis_3_2.xml deleted file mode 100644 index 755fe5c6..00000000 --- a/android/.idea/libraries/Gradle__org_objenesis_objenesis_3_2.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/misc.xml b/android/.idea/misc.xml index 2a4d5b52..8978d23d 100644 --- a/android/.idea/misc.xml +++ b/android/.idea/misc.xml @@ -1,6 +1,6 @@ - - + + diff --git a/android/.idea/modules.xml b/android/.idea/modules.xml deleted file mode 100644 index d647f933..00000000 --- a/android/.idea/modules.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/modules/-1065946935/android.video_player.iml b/android/.idea/modules/-1065946935/android.video_player.iml deleted file mode 100644 index 6a5712d2..00000000 --- a/android/.idea/modules/-1065946935/android.video_player.iml +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/modules/android.iml b/android/.idea/modules/android.iml deleted file mode 100644 index 4e86d157..00000000 --- a/android/.idea/modules/android.iml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/.idea/modules/app/android.app.iml b/android/.idea/modules/app/android.app.iml deleted file mode 100644 index 5d0b5c76..00000000 --- a/android/.idea/modules/app/android.app.iml +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/android/acela_android.iml b/android/acela_android.iml index 18999696..3e44773e 100644 --- a/android/acela_android.iml +++ b/android/acela_android.iml @@ -26,4 +26,4 @@ - + \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index 0500500d..45aa690c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,3 +1,9 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { @@ -6,11 +12,6 @@ if (localPropertiesFile.exists()) { } } -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' @@ -21,13 +22,16 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'com.google.gms.google-services' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('signing.properties') +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} android { - compileSdkVersion flutter.compileSdkVersion + ndkVersion "23.1.7779620" + compileSdkVersion 34 compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 @@ -43,28 +47,52 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId "com.example.acela" - minSdkVersion flutter.minSdkVersion - targetSdkVersion flutter.targetSdkVersion + applicationId "tv.threespeak.app" + minSdkVersion 24 + targetSdkVersion 34 versionCode flutterVersionCode.toInteger() versionName flutterVersionName + multiDexEnabled true + } + + signingConfigs { + release { + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile keystoreProperties['storeFilePath'] ? file(keystoreProperties['storeFilePath']) : null + storePassword keystoreProperties['storePassword'] + } } buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig signingConfigs.debug + minifyEnabled false + shrinkResources false + // signingConfig signingConfigs.release } } + buildFeatures { + viewBinding true + } + namespace 'tv.threespeak.app' } flutter { source '../..' } +def mediaVersion = "1.0.2" + dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation platform('com.google.firebase:firebase-bom:29.0.4') + implementation 'androidx.webkit:webkit:1.6.1' + implementation 'com.google.code.gson:gson:2.9.0' + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'com.google.android.material:material:1.4.+' + + implementation 'com.google.android.exoplayer:exoplayer:2.18.7' + + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.navigation:navigation-fragment-ktx:2.5.3' + implementation 'androidx.navigation:navigation-ui-ktx:2.5.3' + implementation "androidx.multidex:multidex:2.0.1" } diff --git a/android/app/google-services.json b/android/app/google-services.json index 0ef1234b..5bc6a5a7 100644 --- a/android/app/google-services.json +++ b/android/app/google-services.json @@ -7,9 +7,9 @@ "client": [ { "client_info": { - "mobilesdk_app_id": "1:252548198943:android:ea163470c7c668c9355b2a", + "mobilesdk_app_id": "1:252548198943:android:3af79775d4815fab355b2a", "android_client_info": { - "package_name": "com.example.acela" + "package_name": "tv.threespeak.app" } }, "oauth_client": [ @@ -29,6 +29,13 @@ { "client_id": "252548198943-gu14ujl4f9b45042d76b4tq6h8n1aa4a.apps.googleusercontent.com", "client_type": 3 + }, + { + "client_id": "252548198943-ec3gnethnkvoudaat0lrobmq2t7mve8g.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "com.example.acela" + } } ] } diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 0820d81a..f880684a 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -1,5 +1,4 @@ - + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ca3fe0cc..d2096b0f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,35 +1,92 @@ + - - + + + + + + + + + + + + + android:icon="@mipmap/ic_launcher" + android:label="3Speak" + android:usesCleartextTraffic="true" + android:requestLegacyExternalStorage="true" + > + + + + + - + to determine the Window background behind the Flutter UI. + + android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" + --> + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" /> + - - + + - + + + + + + + + + + + + + - + + \ No newline at end of file diff --git a/android/app/src/main/assets/index.html b/android/app/src/main/assets/index.html new file mode 100644 index 00000000..0f716c74 --- /dev/null +++ b/android/app/src/main/assets/index.html @@ -0,0 +1,921 @@ + + + + + + + + 3Speak + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/java/tv/threespeak/app/MediaPlayerService.kt b/android/app/src/main/java/tv/threespeak/app/MediaPlayerService.kt new file mode 100644 index 00000000..08e75ee0 --- /dev/null +++ b/android/app/src/main/java/tv/threespeak/app/MediaPlayerService.kt @@ -0,0 +1,58 @@ +package tv.threespeak.app + +import android.app.Service +import android.content.Intent +import android.media.AudioManager +import android.media.MediaPlayer +import android.os.Binder +import android.os.IBinder + + +class MediaPlayerService : Service(), MediaPlayer.OnCompletionListener, + MediaPlayer.OnPreparedListener, MediaPlayer.OnErrorListener, + MediaPlayer.OnSeekCompleteListener, MediaPlayer.OnInfoListener, + MediaPlayer.OnBufferingUpdateListener, AudioManager.OnAudioFocusChangeListener { + // Binder given to clients + private val iBinder: IBinder = LocalBinder() + + override fun onBind(intent: Intent?): IBinder { + return iBinder + } + + override fun onBufferingUpdate(mp: MediaPlayer, percent: Int) { + //Invoked indicating buffering status of + //a media resource being streamed over the network. + } + + override fun onCompletion(mp: MediaPlayer) { + //Invoked when playback of a media source has completed. + } + + //Handle errors + override fun onError(mp: MediaPlayer, what: Int, extra: Int): Boolean { + //Invoked when there has been an error during an asynchronous operation. + return false + } + + override fun onInfo(mp: MediaPlayer, what: Int, extra: Int): Boolean { + //Invoked to communicate some info. + return false + } + + override fun onPrepared(mp: MediaPlayer) { + //Invoked when the media source is ready for playback. + } + + override fun onSeekComplete(mp: MediaPlayer) { + //Invoked indicating the completion of a seek operation. + } + + override fun onAudioFocusChange(focusChange: Int) { + //Invoked when the audio focus of the system is updated. + } + + inner class LocalBinder : Binder() { + val service: MediaPlayerService + get() = this@MediaPlayerService + } +} \ No newline at end of file diff --git a/android/app/src/main/java/tv/threespeak/app/VideoPlayerActivity.kt b/android/app/src/main/java/tv/threespeak/app/VideoPlayerActivity.kt new file mode 100644 index 00000000..67bd2c9c --- /dev/null +++ b/android/app/src/main/java/tv/threespeak/app/VideoPlayerActivity.kt @@ -0,0 +1,103 @@ +package tv.threespeak.app + +import android.media.AudioManager +import android.media.MediaPlayer +import android.net.Uri +import android.os.Bundle +import android.os.PersistableBundle +import androidx.appcompat.app.AppCompatActivity +import com.google.android.exoplayer2.ExoPlayer +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.util.MimeTypes +import tv.threespeak.app.databinding.ActivityVideoPlayerBinding +import java.io.IOException + + +/** + * An example full-screen activity that shows and hides the system UI (i.e. + * status bar and navigation/system bar) with user interaction. + */ +class VideoPlayerActivity : AppCompatActivity() { + private lateinit var binding: ActivityVideoPlayerBinding + private var seconds = 0 + private var url = "https://ipfs-3speak.b-cdn.net/ipfs/QmYS9k6LTkbix77XPitT5sGqLKmzfQ9h2qg1ucSwwTFSxx/480p/index.m3u8" + + override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) { + super.onCreate(savedInstanceState, persistentState) + initializePlayer() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityVideoPlayerBinding.inflate(layoutInflater) + setContentView(binding.root) + intent.extras?.getInt("seconds")?.let { + this.seconds = it + } + intent.extras?.getString("url")?.let { + this.url = it + } + + // to go complete full screen +// WindowCompat.setDecorFitsSystemWindows(window, false) +// WindowInsetsControllerCompat(window, binding.videoView).let { controller -> +// controller.hide(WindowInsetsCompat.Type.systemBars()) +// controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE +// } + } + + private var mediaPlayer: MediaPlayer? = null + + private fun initMediaPlayer() { + mediaPlayer = MediaPlayer() + mediaPlayer!!.setOnPreparedListener { + it.start() + } + mediaPlayer!!.reset() + // mediaPlayer!!.setAudioStreamType(AudioManager.STREAM_MUSIC) + try { + // Set the data source to the mediaFile location + mediaPlayer!!.setDataSource(this, Uri.parse(url)) + } catch (e: IOException) { + e.printStackTrace() + // stopSelf() + } + mediaPlayer!!.prepareAsync() + } + + private fun initializePlayer() { + binding.videoView.player = ExoPlayer.Builder(this) + .build() + .also { exoPlayer -> + + val mediaItem = MediaItem + .Builder() + .setUri(url) + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build() + + exoPlayer.setMediaItem(mediaItem) + exoPlayer.seekTo((1000 * seconds).toLong()) + exoPlayer.playWhenReady = true + exoPlayer.prepare() + } + } + + public override fun onStart() { + super.onStart() + initializePlayer() +// initMediaPlayer() + } + + public override fun onStop() { + super.onStop() + releasePlayer() + } + + private fun releasePlayer() { + binding.videoView.player?.let { exoPlayer -> + exoPlayer.release() + } + binding.videoView.player = null + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/example/acela/MainActivity.kt b/android/app/src/main/kotlin/com/example/acela/MainActivity.kt deleted file mode 100644 index 61f7ab10..00000000 --- a/android/app/src/main/kotlin/com/example/acela/MainActivity.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.example.acela - -import io.flutter.embedding.android.FlutterActivity - -class MainActivity: FlutterActivity() { -} diff --git a/android/app/src/main/kotlin/tv/threespeak/app/MainActivity.kt b/android/app/src/main/kotlin/tv/threespeak/app/MainActivity.kt new file mode 100644 index 00000000..ce41f81e --- /dev/null +++ b/android/app/src/main/kotlin/tv/threespeak/app/MainActivity.kt @@ -0,0 +1,266 @@ +package tv.threespeak.app + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.view.View +import android.webkit.JavascriptInterface +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.FrameLayout +import androidx.annotation.NonNull +import androidx.annotation.RequiresApi +import androidx.webkit.WebViewAssetLoader +import com.google.gson.Gson +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import com.ryanheise.audioservice.AudioServiceActivity; + + +class MainActivity : AudioServiceActivity() { + var webView: WebView? = null + var result: MethodChannel.Result? = null + + override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + if (webView == null) { + setupView() + } + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, "blog.hive.auth/bridge" + ).setMethodCallHandler { call, result -> + this.result = result + val username = call.argument("username") + val authKey = call.argument("authKey") + val data = call.argument("data") + if (call.method == "getRedirectUriData" && username != null) { + webView?.evaluateJavascript("getRedirectUriData('$username');", null) + } else if (call.method == "getDecryptedHASToken" && username != null && authKey != null && data != null) { + webView?.evaluateJavascript("getDecryptedHASToken('$username','$authKey','$data');", null) + } else if (call.method == "getEncryptedChallenge" && username != null && authKey != null) { + webView?.evaluateJavascript("getEncryptedChallenge('$username','$authKey');", null) + } else if (call.method == "getDecryptedChallenge" && username != null && authKey != null && data != null) { + webView?.evaluateJavascript("getDecryptedChallenge('$username','$authKey', '$data');", null) + } else if (call.method == "doWeHavePostingAuth" && username != null) { + webView?.evaluateJavascript("doWeHavePostingAuth('$username');", null) + } + } + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "com.example.acela/auth" + ).setMethodCallHandler { call, result -> + this.result = result + val username = call.argument("username") + val postingKey = call.argument("postingKey") + val params = call.argument("params") + val encryptedToken = call.argument("encryptedToken") + + val thumbnail = call.argument("thumbnail") + val video_v2 = call.argument("video_v2") + val description = call.argument("description") + val title = call.argument("title") + val tags = call.argument("tags") + val permlink = call.argument("permlink") + val duration = call.argument("duration") + val size = call.argument("size") + val originalFilename = call.argument("originalFilename") + val firstUpload = call.argument("firstUpload") + val bene = call.argument("bene") + val beneW = call.argument("beneW") + val community = call.argument("community") + val ipfsHash = call.argument("ipfsHash") + val hasKey = call.argument("hasKey") + val hasAuthkey = + call.argument("hasAuthkey") ?: call.argument("hasAuthKey") + val user = call.argument("user") + val author = call.argument("author") + val weight = call.argument("weight") + val comment = call.argument("comment") + val seconds = call.argument("seconds") + val url = call.argument("url") + val newBene = call.argument("newBene") + val language = call.argument("language") + val powerUp = call.argument("powerUp") + val enclosureUrl = call.argument("enclosureUrl") + val string = call.argument("string") + val proof = call.argument("proof") + + val data = call.argument("data") + if (call.method == "playFullscreen" && url != null && seconds != null) { + val intent = Intent(this, VideoPlayerActivity::class.java) + val bundle = Bundle() + bundle.putString("url", url) + bundle.putInt("seconds", seconds) + intent.putExtras(bundle); + startActivity(intent) + } else if (call.method == "validateHiveKey" && username != null && postingKey != null) { + webView?.evaluateJavascript("validateHiveKey('$username','$postingKey');", null) + } else if (call.method == "getProofOfPayload" && username != null && postingKey != null && proof != null) { + webView?.evaluateJavascript("getProofOfPayload('$username','$postingKey','$proof');", null) + } else if (call.method == "getHTMLStringForContent" && string != null) { + webView?.evaluateJavascript("getHTMLStringForContent('$string');", null) + } else if (call.method == "encryptedToken" && username != null + && postingKey != null && encryptedToken != null + ) { + webView?.evaluateJavascript( + "decryptMemo('$username','$postingKey', '$encryptedToken');", + null + ) + } else if (call.method == "postVideo" && data != null && postingKey != null) { + webView?.evaluateJavascript("postVideo('$data','$postingKey');", null) + } else if (call.method == "newPostVideo" && thumbnail != null && video_v2 != null + && description != null && title != null && tags != null && username != null + && permlink != null && duration != null && size != null && originalFilename != null + && firstUpload != null && bene != null && beneW != null && community != null + && ipfsHash != null && newBene != null && language != null && powerUp != null + ) { + webView?.evaluateJavascript( + "newPostVideo('$thumbnail','$video_v2', '$description', '$title', '$tags', '$username', '$permlink', $duration, $size, '$originalFilename', '$language', $firstUpload, '$bene', '$beneW', '$postingKey', '$community', '$ipfsHash', '$hasKey', '$hasAuthkey', '$newBene', $powerUp);", + null + ) + } + else if (call.method == "newPostPodcast" && thumbnail != null && enclosureUrl != null + && description != null && title != null && tags != null && username != null + && permlink != null && duration != null && size != null && originalFilename != null + && firstUpload != null && bene != null && beneW != null && community != null + && ipfsHash != null && newBene != null && language != null && powerUp != null + ) { + webView?.evaluateJavascript( + "newPostPodcast('$thumbnail','$enclosureUrl', '$description', '$title', '$tags', '$username', '$permlink', $duration, $size, '$originalFilename', '$language', $firstUpload, '$bene', '$beneW', '$postingKey', '$community', '$ipfsHash', '$hasKey', '$hasAuthkey', '$newBene', $powerUp);", + null + ) + } + else if (call.method == "voteContent" && user != null && author != null + && permlink != null && weight != null && postingKey != null && hasKey != null + && hasAuthkey != null + ) { + webView?.evaluateJavascript("voteContent('$user', '$author', '$permlink', $weight, '$postingKey', '$hasKey', '$hasAuthkey');", null) + } else if (call.method == "commentOnContent" && user != null && author != null + && permlink != null && comment != null && postingKey != null && hasKey != null + && hasAuthkey != null + ) { + webView?.evaluateJavascript("commentOnContent('$user', '$author', '$permlink', '$comment', '$postingKey', '$hasKey', '$hasAuthkey');", null) + } else if (call.method == "getAccountInfo" && username != null ) { + webView?.evaluateJavascript( + "getAccountInfo('$username');", + null + ) + } + } + } + + @SuppressLint("SetJavaScriptEnabled") + private fun setupView() { + val params = FrameLayout.LayoutParams(0, 0) + webView = WebView(activity) + val decorView = activity.window.decorView as FrameLayout + decorView.addView(webView, params) + webView?.visibility = View.GONE + webView?.settings?.javaScriptEnabled = true + webView?.settings?.domStorageEnabled = true +// webView?.webChromeClient = WebChromeClient() + WebView.setWebContentsDebuggingEnabled(true) + val assetLoader = WebViewAssetLoader.Builder() + .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(this)) + .build() + val client: WebViewClient = object : WebViewClient() { + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest + ): WebResourceResponse? { + return assetLoader.shouldInterceptRequest(request.url) + } + + override fun shouldInterceptRequest( + view: WebView, + url: String + ): WebResourceResponse? { + return assetLoader.shouldInterceptRequest(Uri.parse(url)) + } + } + webView?.webViewClient = client + webView?.addJavascriptInterface(WebAppInterface(this), "Android") + webView?.loadUrl("https://appassets.androidplatform.net/assets/index.html") + } +} + +class WebAppInterface(private val mContext: Context) { + @JavascriptInterface + fun postMessage(message: String) { + val main = mContext as? MainActivity ?: return + val gson = Gson() + val dataObject = gson.fromJson(message, JSEvent::class.java) + when (dataObject.type) { + JSBridgeAction.VALIDATE_HIVE_KEY.value -> { + main.result?.success(message) + } + JSBridgeAction.GET_REDIRECT_URI_DATA.value -> { + main.result?.success(message) + } + JSBridgeAction.DECRYPTED_MEMO.value -> { + main.result?.success(message) + } + JSBridgeAction.GET_DECRYPTED_HAS_TOKEN.value -> { + main.result?.success(message) + } + JSBridgeAction.POST_VIDEO.value -> { + main.result?.success(message) + } + JSBridgeAction.COMMENT_ON_CONTENT.value -> { + main.result?.success(message) + } + JSBridgeAction.VOTE_CONTENT.value -> { + main.result?.success(message) + } + JSBridgeAction.GET_HTML.value -> { + main.result?.success(message) + } + JSBridgeAction.POST_AUDIO.value -> { + main.result?.success(message) + } + JSBridgeAction.GET_PROOF_PAYLOAD.value -> { + main.result?.success(message) + } + JSBridgeAction.GET_ENCRYPTED_CHALLENGE.value -> { + main.result?.success(message) + } + JSBridgeAction.GET_DECRYPTED_CHALLENGE.value -> { + main.result?.success(message) + } + JSBridgeAction.DO_WE_HAVE_POSTING_AUTH.value -> { + main.result?.success(message) + } + JSBridgeAction.GET_ACCOUNT_INFO.value -> { + main.result?.success(message) + } + } + } +} + +data class JSEvent( + val type: String, +) + +enum class JSBridgeAction(val value: String) { + VALIDATE_HIVE_KEY("validateHiveKey"), + DECRYPTED_MEMO("decryptedMemo"), + POST_VIDEO("postVideo"), + GET_REDIRECT_URI_DATA("getRedirectUriData"), + GET_DECRYPTED_HAS_TOKEN("getDecryptedHASToken"), + COMMENT_ON_CONTENT("commentOnContent"), + VOTE_CONTENT("voteContent"), + GET_HTML("getHTMLStringForContent"), + POST_AUDIO("postAudio"), + GET_PROOF_PAYLOAD("getProofOfPayload"), + GET_ENCRYPTED_CHALLENGE("getEncryptedChallenge"), + GET_DECRYPTED_CHALLENGE("getDecryptedChallenge"), + DO_WE_HAVE_POSTING_AUTH("doWeHavePostingAuth"), + GET_ACCOUNT_INFO("getAccountInfo"), +} diff --git a/android/app/src/main/res/layout/activity_video_player.xml b/android/app/src/main/res/layout/activity_video_player.xml new file mode 100644 index 00000000..53c8f1d3 --- /dev/null +++ b/android/app/src/main/res/layout/activity_video_player.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/navigation/nav_graph.xml b/android/app/src/main/res/navigation/nav_graph.xml new file mode 100644 index 00000000..a5346788 --- /dev/null +++ b/android/app/src/main/res/navigation/nav_graph.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values-land/dimens.xml b/android/app/src/main/res/values-land/dimens.xml new file mode 100644 index 00000000..22d7f004 --- /dev/null +++ b/android/app/src/main/res/values-land/dimens.xml @@ -0,0 +1,3 @@ + + 48dp + \ No newline at end of file diff --git a/android/app/src/main/res/values-night/themes.xml b/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 00000000..d46cc9f2 --- /dev/null +++ b/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values-w1240dp/dimens.xml b/android/app/src/main/res/values-w1240dp/dimens.xml new file mode 100644 index 00000000..d73f4a35 --- /dev/null +++ b/android/app/src/main/res/values-w1240dp/dimens.xml @@ -0,0 +1,3 @@ + + 200dp + \ No newline at end of file diff --git a/android/app/src/main/res/values-w600dp/dimens.xml b/android/app/src/main/res/values-w600dp/dimens.xml new file mode 100644 index 00000000..22d7f004 --- /dev/null +++ b/android/app/src/main/res/values-w600dp/dimens.xml @@ -0,0 +1,3 @@ + + 48dp + \ No newline at end of file diff --git a/android/app/src/main/res/values/attrs.xml b/android/app/src/main/res/values/attrs.xml new file mode 100644 index 00000000..e52391d2 --- /dev/null +++ b/android/app/src/main/res/values/attrs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..89cad66c --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ + + #FF039BE5 + #FF01579B + #FF40C4FF + #FF00B0FF + #66000000 + \ No newline at end of file diff --git a/android/app/src/main/res/values/dimens.xml b/android/app/src/main/res/values/dimens.xml new file mode 100644 index 00000000..125df871 --- /dev/null +++ b/android/app/src/main/res/values/dimens.xml @@ -0,0 +1,3 @@ + + 16dp + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..a913c2fc --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,49 @@ + + VideoPlayerActivity + Dummy Button + DUMMY\nCONTENT + MyTestActivity + + First Fragment + Second Fragment + Next + Previous + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam in scelerisque sem. Mauris + volutpat, dolor id interdum ullamcorper, risus dolor egestas lectus, sit amet mattis purus + dui nec risus. Maecenas non sodales nisi, vel dictum dolor. Class aptent taciti sociosqu ad + litora torquent per conubia nostra, per inceptos himenaeos. Suspendisse blandit eleifend + diam, vel rutrum tellus vulputate quis. Aliquam eget libero aliquet, imperdiet nisl a, + ornare ex. Sed rhoncus est ut libero porta lobortis. Fusce in dictum tellus.\n\n + Suspendisse interdum ornare ante. Aliquam nec cursus lorem. Morbi id magna felis. Vivamus + egestas, est a condimentum egestas, turpis nisl iaculis ipsum, in dictum tellus dolor sed + neque. Morbi tellus erat, dapibus ut sem a, iaculis tincidunt dui. Interdum et malesuada + fames ac ante ipsum primis in faucibus. Curabitur et eros porttitor, ultricies urna vitae, + molestie nibh. Phasellus at commodo eros, non aliquet metus. Sed maximus nisl nec dolor + bibendum, vel congue leo egestas.\n\n + Sed interdum tortor nibh, in sagittis risus mollis quis. Curabitur mi odio, condimentum sit + amet auctor at, mollis non turpis. Nullam pretium libero vestibulum, finibus orci vel, + molestie quam. Fusce blandit tincidunt nulla, quis sollicitudin libero facilisis et. Integer + interdum nunc ligula, et fermentum metus hendrerit id. Vestibulum lectus felis, dictum at + lacinia sit amet, tristique id quam. Cras eu consequat dui. Suspendisse sodales nunc ligula, + in lobortis sem porta sed. Integer id ultrices magna, in luctus elit. Sed a pellentesque + est.\n\n + Aenean nunc velit, lacinia sed dolor sed, ultrices viverra nulla. Etiam a venenatis nibh. + Morbi laoreet, tortor sed facilisis varius, nibh orci rhoncus nulla, id elementum leo dui + non lorem. Nam mollis ipsum quis auctor varius. Quisque elementum eu libero sed commodo. In + eros nisl, imperdiet vel imperdiet et, scelerisque a mauris. Pellentesque varius ex nunc, + quis imperdiet eros placerat ac. Duis finibus orci et est auctor tincidunt. Sed non viverra + ipsum. Nunc quis augue egestas, cursus lorem at, molestie sem. Morbi a consectetur ipsum, a + placerat diam. Etiam vulputate dignissim convallis. Integer faucibus mauris sit amet finibus + convallis.\n\n + Phasellus in aliquet mi. Pellentesque habitant morbi tristique senectus et netus et + malesuada fames ac turpis egestas. In volutpat arcu ut felis sagittis, in finibus massa + gravida. Pellentesque id tellus orci. Integer dictum, lorem sed efficitur ullamcorper, + libero justo consectetur ipsum, in mollis nisl ex sed nisl. Donec maximus ullamcorper + sodales. Praesent bibendum rhoncus tellus nec feugiat. In a ornare nulla. Donec rhoncus + libero vel nunc consequat, quis tincidunt nisl eleifend. Cras bibendum enim a justo luctus + vestibulum. Fusce dictum libero quis erat maximus, vitae volutpat diam dignissim. + + + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index d460d1e9..11ea92e2 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -15,4 +15,12 @@ + + + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..225bece0 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml index 0820d81a..f880684a 100644 --- a/android/app/src/profile/AndroidManifest.xml +++ b/android/app/src/profile/AndroidManifest.xml @@ -1,5 +1,4 @@ - + diff --git a/android/build.gradle b/android/build.gradle index d302341a..5c031b14 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,17 +1,3 @@ -buildscript { - ext.kotlin_version = '1.3.50' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:4.1.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath 'com.google.gms:google-services:4.3.10' - } -} - allprojects { repositories { google() @@ -27,6 +13,18 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { +tasks.register("clean", Delete) { delete rootProject.buildDir } + +//rootProject.allprojects { +// subprojects { +// project.configurations.all { +// resolutionStrategy.eachDependency { details -> +// if (details.requested.group == 'androidx.core' && !details.requested.name.contains('androidx')) { +// details.useVersion "1.0.1" +// } +// } +// } +// } +//} diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index bc6a58af..c4623b06 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip \ No newline at end of file diff --git a/android/settings.gradle b/android/settings.gradle index 44e62bcf..8b3ec872 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,11 +1,25 @@ -include ':app' +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "7.2.0" apply false // Replace with your AGP version if needed + id "org.jetbrains.kotlin.android" version "1.9.23" apply false // Replace with your Kotlin version if needed +} + +include ":app" \ No newline at end of file diff --git a/assets/branding/three_shorts_icon.png b/assets/branding/three_shorts_icon.png new file mode 100644 index 00000000..685c5c29 Binary files /dev/null and b/assets/branding/three_shorts_icon.png differ diff --git a/assets/ctt-logo.png b/assets/ctt-logo.png new file mode 100644 index 00000000..1740e5c7 Binary files /dev/null and b/assets/ctt-logo.png differ diff --git a/assets/hive-keychain-image.png b/assets/hive-keychain-image.png new file mode 100644 index 00000000..c5bfe873 Binary files /dev/null and b/assets/hive-keychain-image.png differ diff --git a/assets/hive_auth_button.png b/assets/hive_auth_button.png new file mode 100644 index 00000000..a6d8330e Binary files /dev/null and b/assets/hive_auth_button.png differ diff --git a/assets/hiveauth_icon.png b/assets/hiveauth_icon.png new file mode 100644 index 00000000..54e9bb9e Binary files /dev/null and b/assets/hiveauth_icon.png differ diff --git a/assets/ipfs-logo.png b/assets/ipfs-logo.png new file mode 100644 index 00000000..5d26eb26 Binary files /dev/null and b/assets/ipfs-logo.png differ diff --git a/assets/pod-cast-logo-round.png b/assets/pod-cast-logo-round.png new file mode 100644 index 00000000..c48aaea2 Binary files /dev/null and b/assets/pod-cast-logo-round.png differ diff --git a/assets/podcast-index.jpg b/assets/podcast-index.jpg new file mode 100644 index 00000000..47472c97 Binary files /dev/null and b/assets/podcast-index.jpg differ diff --git a/assets/ps_guide_1.png b/assets/ps_guide_1.png new file mode 100644 index 00000000..1d99c6a0 Binary files /dev/null and b/assets/ps_guide_1.png differ diff --git a/assets/ps_guide_2.png b/assets/ps_guide_2.png new file mode 100644 index 00000000..bf3461ad Binary files /dev/null and b/assets/ps_guide_2.png differ diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 00000000..fa0b357c --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/flutter_01.log b/flutter_01.log new file mode 100644 index 00000000..4ae32a64 --- /dev/null +++ b/flutter_01.log @@ -0,0 +1,118 @@ +Flutter crash report. +Please report a bug at https://github.com/flutter/flutter/issues. + +## command + +flutter pub get + +## exception + +PathNotFoundException: PathNotFoundException: Cannot open file, path = '/Applications/flutter/flutter/version' (OS Error: No such file or directory, errno = 2) + +``` +#0 _File.throwIfError (dart:io/file_impl.dart:629:7) +#1 _File.openSync (dart:io/file_impl.dart:473:5) +#2 _File.readAsBytesSync (dart:io/file_impl.dart:533:18) +#3 _File.readAsStringSync (dart:io/file_impl.dart:578:18) +#4 ForwardingFile.readAsStringSync (package:file/src/forwarding/forwarding_file.dart:99:16) +#5 ErrorHandlingFile.readAsStringSync. (package:flutter_tools/src/base/error_handling_io.dart:222:22) +#6 _runSync (package:flutter_tools/src/base/error_handling_io.dart:600:14) +#7 ErrorHandlingFile.readAsStringSync (package:flutter_tools/src/base/error_handling_io.dart:221:12) +#8 _DefaultPub.get (package:flutter_tools/src/dart/pub.dart:360:50) + +#9 PackagesGetCommand._runPubGet (package:flutter_tools/src/commands/packages.dart:140:7) + +#10 PackagesGetCommand.runCommand (package:flutter_tools/src/commands/packages.dart:175:5) + +#11 FlutterCommand.run. (package:flutter_tools/src/runner/flutter_command.dart:1257:27) + +#12 AppContext.run. (package:flutter_tools/src/base/context.dart:150:19) + +#13 CommandRunner.runCommand (package:args/command_runner.dart:209:13) + +#14 FlutterCommandRunner.runCommand. (package:flutter_tools/src/runner/flutter_command_runner.dart:283:9) + +#15 AppContext.run. (package:flutter_tools/src/base/context.dart:150:19) + +#16 FlutterCommandRunner.runCommand (package:flutter_tools/src/runner/flutter_command_runner.dart:229:5) + +#17 run.. (package:flutter_tools/runner.dart:64:9) + +#18 AppContext.run. (package:flutter_tools/src/base/context.dart:150:19) + +#19 main (package:flutter_tools/executable.dart:91:3) + +``` + +## flutter doctor + +``` +[☠] Flutter (the doctor check crashed) + ✗ Due to an error, the doctor check did not complete. If the error message below is not helpful, please let us know about this issue at https://github.com/flutter/flutter/issues. + ✗ Exception: Could not find directory at /Applications/flutter/flutter/bin/cache/dart-sdk/bin/resources/devtools + • #0 Cache.devToolsVersion (package:flutter_tools/src/cache.dart:384:9) + #1 _DefaultDoctorValidatorsProvider.validators. (package:flutter_tools/src/doctor.dart:126:46) + #2 FlutterValidator.validate (package:flutter_tools/src/doctor.dart:530:84) + #3 Doctor.startValidatorTasks. (package:flutter_tools/src/doctor.dart:250:72) + #4 asyncGuard. (package:flutter_tools/src/base/async_guard.dart:111:32) + #5 _rootRun (dart:async/zone.dart:1398:13) + #6 _CustomZone.run (dart:async/zone.dart:1300:19) + #7 _runZoned (dart:async/zone.dart:1803:10) + #8 runZonedGuarded (dart:async/zone.dart:1791:12) + #9 runZoned (dart:async/zone.dart:1743:12) + #10 asyncGuard (package:flutter_tools/src/base/async_guard.dart:109:3) + #11 Doctor.startValidatorTasks (package:flutter_tools/src/doctor.dart:242:9) + #12 DoctorText._validatorTasks (package:flutter_tools/src/doctor.dart:739:60) + #13 DoctorText._validatorTasks (package:flutter_tools/src/doctor.dart) + #14 DoctorText._runDiagnosis (package:flutter_tools/src/doctor.dart:743:53) + #15 DoctorText.text (package:flutter_tools/src/doctor.dart:735:36) + #16 DoctorText.text (package:flutter_tools/src/doctor.dart) + #17 _createLocalCrashReport (package:flutter_tools/runner.dart:206:51) + #18 _handleToolError (package:flutter_tools/runner.dart:168:31) + + #19 AppContext.run. (package:flutter_tools/src/base/context.dart:150:19) + + #20 main (package:flutter_tools/executable.dart:91:3) + + + +[!] Android toolchain - develop for Android devices (Android SDK version 33.0.0) + • Android SDK at /Users/sagar/Library/Android/sdk + • Platform android-33, build-tools 33.0.0 + • Java binary at: /Library/Java/JavaVirtualMachines/temurin-8.jdk/Contents/Home/bin/java + • Java version OpenJDK Runtime Environment (Temurin)(build 1.8.0_322-b06) + ✗ Android license status unknown. + Run `flutter doctor --android-licenses` to accept the SDK licenses. + See https://flutter.dev/docs/get-started/install/macos#android-setup for more details. + +[✓] Xcode - develop for iOS and macOS (Xcode 14.2) + • Xcode at /Applications/Xcode.app/Contents/Developer + • Build 14C18 + • CocoaPods version 1.11.3 + +[✓] Chrome - develop for the web + • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome + +[!] Android Studio (version 2022.1) + • Android Studio at /Applications/Android Studio.app/Contents + • Flutter plugin can be installed from: + 🔨 https://plugins.jetbrains.com/plugin/9212-flutter + • Dart plugin can be installed from: + 🔨 https://plugins.jetbrains.com/plugin/6351-dart + ✗ Unable to find bundled Java version. + • Try updating or re-installing Android Studio. + +[✓] VS Code (version 1.74.3) + • VS Code at /Applications/Visual Studio Code.app/Contents + • Flutter extension version 3.58.0 + +[✓] Connected device (3 available) + • iPhone 14 Pro (mobile) • 01D7AC4F-11B8-456A-8FD4-8275C0814F45 • ios • com.apple.CoreSimulator.SimRuntime.iOS-16-2 (simulator) + • macOS (desktop) • macos • darwin-arm64 • macOS 13.1 22C65 darwin-arm64 + • Chrome (web) • chrome • web-javascript • Google Chrome 109.0.5414.119 + +[✓] HTTP Host Availability + • All required HTTP hosts are available + +! Doctor found issues in 3 categories. +``` diff --git a/ios/.DS_Store b/ios/.DS_Store index cc251821..da90ef3e 100644 Binary files a/ios/.DS_Store and b/ios/.DS_Store differ diff --git a/ios/.bundle/config b/ios/.bundle/config new file mode 100644 index 00000000..23692288 --- /dev/null +++ b/ios/.bundle/config @@ -0,0 +1,2 @@ +--- +BUNDLE_PATH: "vendor/bundle" diff --git a/ios/.gitignore b/ios/.gitignore index 7a7f9873..c65d6368 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -1,3 +1,7 @@ +fastlane +*.ipa +*.app.dSYM.zip + **/dgph *.mode1v3 *.mode2v3 @@ -31,4 +35,4 @@ Runner/GeneratedPluginRegistrant.* !default.mode1v3 !default.mode2v3 !default.pbxuser -!default.perspectivev3 +!default.perspectivev3 \ No newline at end of file diff --git a/ios/Development_com.example.acela.mobileprovision b/ios/Development_com.example.acela.mobileprovision new file mode 100644 index 00000000..22952a57 Binary files /dev/null and b/ios/Development_com.example.acela.mobileprovision differ diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 8d4492f9..7c569640 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 9.0 + 12.0 diff --git a/ios/Gemfile b/ios/Gemfile new file mode 100644 index 00000000..d554c27a --- /dev/null +++ b/ios/Gemfile @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gem "fastlane" +# gem "rails" diff --git a/ios/Gemfile.lock b/ios/Gemfile.lock new file mode 100644 index 00000000..6421b260 --- /dev/null +++ b/ios/Gemfile.lock @@ -0,0 +1,218 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.6) + rexml + addressable (2.8.5) + public_suffix (>= 2.0.2, < 6.0) + artifactory (3.0.15) + atomos (0.1.3) + aws-eventstream (1.2.0) + aws-partitions (1.805.0) + aws-sdk-core (3.180.3) + aws-eventstream (~> 1, >= 1.0.2) + aws-partitions (~> 1, >= 1.651.0) + aws-sigv4 (~> 1.5) + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.71.0) + aws-sdk-core (~> 3, >= 3.177.0) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.132.1) + aws-sdk-core (~> 3, >= 3.179.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.6) + aws-sigv4 (1.6.0) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + declarative (0.0.20) + digest-crc (0.6.5) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.5.20190701) + unf (>= 0.0.5, < 1.0.0) + dotenv (2.8.1) + emoji_regex (3.2.3) + excon (0.100.0) + faraday (1.10.3) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.7) + faraday (>= 0.8.0) + http-cookie (~> 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.0.4) + multipart-post (~> 2) + faraday-net_http (1.0.1) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.0) + faraday (~> 1.0) + fastimage (2.2.7) + fastlane (2.214.0) + CFPropertyList (>= 2.3, < 4.0.0) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + bundler (>= 1.12.0, < 3.0.0) + colored + commander (~> 4.6) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + naturally (~> 2.2) + optparse (~> 0.1.1) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.3) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (>= 1.4.5, < 2.0.0) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.3.0) + xcpretty-travis-formatter (>= 0.0.3) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.48.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.1) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + webrick + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.19.0) + google-apis-core (>= 0.9.0, < 2.a) + google-cloud-core (1.6.0) + google-cloud-env (~> 1.0) + google-cloud-errors (~> 1.0) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.3.1) + google-cloud-storage (1.44.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.19.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.7.0) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + memoist (~> 0.16) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.5) + domain_name (~> 0.5) + httpclient (2.8.3) + jmespath (1.6.2) + json (2.6.3) + jwt (2.7.1) + memoist (0.16.2) + mini_magick (4.12.0) + mini_mime (1.1.5) + multi_json (1.15.0) + multipart-post (2.3.0) + nanaimo (0.3.0) + naturally (2.2.1) + optparse (0.1.1) + os (1.1.4) + plist (3.7.0) + public_suffix (5.0.3) + rake (13.0.6) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.1.2) + rexml (3.2.6) + rouge (2.0.7) + ruby2_keywords (0.0.5) + rubyzip (2.3.2) + security (0.1.3) + signet (0.17.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + terminal-notifier (2.0.0) + terminal-table (1.8.0) + unicode-display_width (~> 1.1, >= 1.1.1) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.1) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unf (0.1.4) + unf_ext + unf_ext (0.0.8.2) + unicode-display_width (1.8.0) + webrick (1.8.1) + word_wrap (1.0.0) + xcodeproj (1.22.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.3.0) + rexml (~> 3.2.4) + xcpretty (0.3.0) + rouge (~> 2.0.7) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + arm64-darwin-21 + +DEPENDENCIES + fastlane + +BUNDLED WITH + 2.3.14 diff --git a/ios/Podfile b/ios/Podfile index 6b7880c5..ea9d9b81 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -30,6 +30,7 @@ flutter_ios_podfile_setup target 'Runner' do use_frameworks! use_modular_headers! + pod 'FYVideoCompressor' flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 0c649f2f..f1339ccf 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,22 +1,450 @@ PODS: + - assets_audio_player (0.0.1): + - Flutter + - audio_service (0.0.1): + - Flutter + - audio_session (0.0.1): + - Flutter + - better_player (0.0.1): + - Cache (~> 6.0.0) + - Flutter + - GCDWebServer + - HLSCachingReverseProxyServer + - PINCache + - Cache (6.0.0) + - croppy (0.0.1): + - Flutter + - device_info_plus (0.0.1): + - Flutter + - DKImagePickerController/Core (4.3.9): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.9) + - DKImagePickerController/PhotoGallery (4.3.9): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.9) + - DKPhotoGallery (0.0.19): + - DKPhotoGallery/Core (= 0.0.19) + - DKPhotoGallery/Model (= 0.0.19) + - DKPhotoGallery/Preview (= 0.0.19) + - DKPhotoGallery/Resource (= 0.0.19) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.19): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.19): + - SDWebImage + - SwiftyGif + - ffmpeg-kit-ios-https-gpl (6.0) + - ffmpeg_kit_flutter_https_gpl (6.0.3): + - ffmpeg_kit_flutter_https_gpl/https-gpl (= 6.0.3) + - Flutter + - ffmpeg_kit_flutter_https_gpl/https-gpl (6.0.3): + - ffmpeg-kit-ios-https-gpl (= 6.0) + - Flutter + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter + - Firebase/Analytics (11.6.0): + - Firebase/Core + - Firebase/Core (11.6.0): + - Firebase/CoreOnly + - FirebaseAnalytics (~> 11.6.0) + - Firebase/CoreOnly (11.6.0): + - FirebaseCore (~> 11.6.0) + - Firebase/Crashlytics (11.6.0): + - Firebase/CoreOnly + - FirebaseCrashlytics (~> 11.6.0) + - firebase_analytics (11.4.0): + - Firebase/Analytics (= 11.6.0) + - firebase_core + - Flutter + - firebase_core (3.10.0): + - Firebase/CoreOnly (= 11.6.0) + - Flutter + - firebase_crashlytics (4.3.0): + - Firebase/Crashlytics (= 11.6.0) + - firebase_core + - Flutter + - FirebaseAnalytics (11.6.0): + - FirebaseAnalytics/AdIdSupport (= 11.6.0) + - FirebaseCore (~> 11.6.0) + - FirebaseInstallations (~> 11.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.0) + - GoogleUtilities/MethodSwizzler (~> 8.0) + - GoogleUtilities/Network (~> 8.0) + - "GoogleUtilities/NSData+zlib (~> 8.0)" + - nanopb (~> 3.30910.0) + - FirebaseAnalytics/AdIdSupport (11.6.0): + - FirebaseCore (~> 11.6.0) + - FirebaseInstallations (~> 11.0) + - GoogleAppMeasurement (= 11.6.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.0) + - GoogleUtilities/MethodSwizzler (~> 8.0) + - GoogleUtilities/Network (~> 8.0) + - "GoogleUtilities/NSData+zlib (~> 8.0)" + - nanopb (~> 3.30910.0) + - FirebaseCore (11.6.0): + - FirebaseCoreInternal (~> 11.6.0) + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/Logger (~> 8.0) + - FirebaseCoreExtension (11.6.0): + - FirebaseCore (~> 11.6.0) + - FirebaseCoreInternal (11.6.0): + - "GoogleUtilities/NSData+zlib (~> 8.0)" + - FirebaseCrashlytics (11.6.0): + - FirebaseCore (~> 11.6.0) + - FirebaseInstallations (~> 11.0) + - FirebaseRemoteConfigInterop (~> 11.0) + - FirebaseSessions (~> 11.0) + - GoogleDataTransport (~> 10.0) + - GoogleUtilities/Environment (~> 8.0) + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - FirebaseInstallations (11.6.0): + - FirebaseCore (~> 11.6.0) + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - PromisesObjC (~> 2.4) + - FirebaseRemoteConfigInterop (11.6.0) + - FirebaseSessions (11.6.0): + - FirebaseCore (~> 11.6.0) + - FirebaseCoreExtension (~> 11.6.0) + - FirebaseInstallations (~> 11.0) + - GoogleDataTransport (~> 10.0) + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - nanopb (~> 3.30910.0) + - PromisesSwift (~> 2.1) - Flutter (1.0.0) - - video_player (0.0.1): + - flutter_downloader (0.0.1): + - Flutter + - flutter_secure_storage (6.0.0): + - Flutter + - FYVideoCompressor (0.0.9) + - GCDWebServer (3.5.4): + - GCDWebServer/Core (= 3.5.4) + - GCDWebServer/Core (3.5.4) + - GoogleAppMeasurement (11.6.0): + - GoogleAppMeasurement/AdIdSupport (= 11.6.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.0) + - GoogleUtilities/MethodSwizzler (~> 8.0) + - GoogleUtilities/Network (~> 8.0) + - "GoogleUtilities/NSData+zlib (~> 8.0)" + - nanopb (~> 3.30910.0) + - GoogleAppMeasurement/AdIdSupport (11.6.0): + - GoogleAppMeasurement/WithoutAdIdSupport (= 11.6.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.0) + - GoogleUtilities/MethodSwizzler (~> 8.0) + - GoogleUtilities/Network (~> 8.0) + - "GoogleUtilities/NSData+zlib (~> 8.0)" + - nanopb (~> 3.30910.0) + - GoogleAppMeasurement/WithoutAdIdSupport (11.6.0): + - GoogleUtilities/AppDelegateSwizzler (~> 8.0) + - GoogleUtilities/MethodSwizzler (~> 8.0) + - GoogleUtilities/Network (~> 8.0) + - "GoogleUtilities/NSData+zlib (~> 8.0)" + - nanopb (~> 3.30910.0) + - GoogleDataTransport (10.1.0): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - GoogleUtilities/AppDelegateSwizzler (8.0.2): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (8.0.2): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.0.2): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/MethodSwizzler (8.0.2): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/Network (8.0.2): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (8.0.2)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.0.2) + - GoogleUtilities/Reachability (8.0.2): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/UserDefaults (8.0.2): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - HLSCachingReverseProxyServer (0.1.0): + - GCDWebServer (~> 3.5) + - PINCache (>= 3.0.1-beta.3) + - image_picker_ios (0.0.1): + - Flutter + - images_picker (0.0.1): - Flutter + - ZLPhotoBrowser (= 4.2.5) + - just_audio (0.0.1): + - Flutter + - libwebp (1.3.2): + - libwebp/demux (= 1.3.2) + - libwebp/mux (= 1.3.2) + - libwebp/sharpyuv (= 1.3.2) + - libwebp/webp (= 1.3.2) + - libwebp/demux (1.3.2): + - libwebp/webp + - libwebp/mux (1.3.2): + - libwebp/demux + - libwebp/sharpyuv (1.3.2) + - libwebp/webp (1.3.2): + - libwebp/sharpyuv + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) + - package_info_plus (0.4.5): + - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - permission_handler_apple (9.3.0): + - Flutter + - PINCache (3.0.4): + - PINCache/Arc-exception-safe (= 3.0.4) + - PINCache/Core (= 3.0.4) + - PINCache/Arc-exception-safe (3.0.4): + - PINCache/Core + - PINCache/Core (3.0.4): + - PINOperation (~> 1.2.3) + - PINOperation (1.2.3) + - PromisesObjC (2.4.0) + - PromisesSwift (2.4.0): + - PromisesObjC (= 2.4.0) + - SDWebImage (5.20.0): + - SDWebImage/Core (= 5.20.0) + - SDWebImage/Core (5.20.0) + - share_plus (0.0.1): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - SwiftyGif (5.4.5) + - url_launcher_ios (0.0.1): + - Flutter + - video_compress (0.3.0): + - Flutter + - video_player_avfoundation (0.0.1): + - Flutter + - FlutterMacOS + - video_thumbnail (0.0.1): + - Flutter + - libwebp + - wakelock_plus (0.0.1): + - Flutter + - webview_flutter_wkwebview (0.0.1): + - Flutter + - FlutterMacOS + - ZLPhotoBrowser (4.2.5): + - ZLPhotoBrowser/Core (= 4.2.5) + - ZLPhotoBrowser/Core (4.2.5) DEPENDENCIES: + - assets_audio_player (from `.symlinks/plugins/assets_audio_player/ios`) + - audio_service (from `.symlinks/plugins/audio_service/ios`) + - audio_session (from `.symlinks/plugins/audio_session/ios`) + - better_player (from `.symlinks/plugins/better_player/ios`) + - croppy (from `.symlinks/plugins/croppy/ios`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - ffmpeg_kit_flutter_https_gpl (from `.symlinks/plugins/ffmpeg_kit_flutter_https_gpl/ios`) + - file_picker (from `.symlinks/plugins/file_picker/ios`) + - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) + - firebase_core (from `.symlinks/plugins/firebase_core/ios`) + - firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`) - Flutter (from `Flutter`) - - video_player (from `.symlinks/plugins/video_player/ios`) + - flutter_downloader (from `.symlinks/plugins/flutter_downloader/ios`) + - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) + - FYVideoCompressor + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - images_picker (from `.symlinks/plugins/images_picker/ios`) + - just_audio (from `.symlinks/plugins/just_audio/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - video_compress (from `.symlinks/plugins/video_compress/ios`) + - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) + - video_thumbnail (from `.symlinks/plugins/video_thumbnail/ios`) + - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) + - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) + +SPEC REPOS: + trunk: + - Cache + - DKImagePickerController + - DKPhotoGallery + - ffmpeg-kit-ios-https-gpl + - Firebase + - FirebaseAnalytics + - FirebaseCore + - FirebaseCoreExtension + - FirebaseCoreInternal + - FirebaseCrashlytics + - FirebaseInstallations + - FirebaseRemoteConfigInterop + - FirebaseSessions + - FYVideoCompressor + - GCDWebServer + - GoogleAppMeasurement + - GoogleDataTransport + - GoogleUtilities + - HLSCachingReverseProxyServer + - libwebp + - nanopb + - PINCache + - PINOperation + - PromisesObjC + - PromisesSwift + - SDWebImage + - SwiftyGif + - ZLPhotoBrowser EXTERNAL SOURCES: + assets_audio_player: + :path: ".symlinks/plugins/assets_audio_player/ios" + audio_service: + :path: ".symlinks/plugins/audio_service/ios" + audio_session: + :path: ".symlinks/plugins/audio_session/ios" + better_player: + :path: ".symlinks/plugins/better_player/ios" + croppy: + :path: ".symlinks/plugins/croppy/ios" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" + ffmpeg_kit_flutter_https_gpl: + :path: ".symlinks/plugins/ffmpeg_kit_flutter_https_gpl/ios" + file_picker: + :path: ".symlinks/plugins/file_picker/ios" + firebase_analytics: + :path: ".symlinks/plugins/firebase_analytics/ios" + firebase_core: + :path: ".symlinks/plugins/firebase_core/ios" + firebase_crashlytics: + :path: ".symlinks/plugins/firebase_crashlytics/ios" Flutter: :path: Flutter - video_player: - :path: ".symlinks/plugins/video_player/ios" + flutter_downloader: + :path: ".symlinks/plugins/flutter_downloader/ios" + flutter_secure_storage: + :path: ".symlinks/plugins/flutter_secure_storage/ios" + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" + images_picker: + :path: ".symlinks/plugins/images_picker/ios" + just_audio: + :path: ".symlinks/plugins/just_audio/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + video_compress: + :path: ".symlinks/plugins/video_compress/ios" + video_player_avfoundation: + :path: ".symlinks/plugins/video_player_avfoundation/darwin" + video_thumbnail: + :path: ".symlinks/plugins/video_thumbnail/ios" + wakelock_plus: + :path: ".symlinks/plugins/wakelock_plus/ios" + webview_flutter_wkwebview: + :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" SPEC CHECKSUMS: - Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a - video_player: ecd305f42e9044793efd34846e1ce64c31ea6fcb + assets_audio_player: ae418728ee1b31f11556504fda8034639e20ce44 + audio_service: 2023a4a1bdb2fd1443e7b00bdbdb1baa321525db + audio_session: f08db0697111ac84ba46191b55488c0563bb29c6 + better_player: 472a1f3471b8991bde82327c91498b0f7934245d + Cache: 4ca7e00363fca5455f26534e5607634c820ffc2d + croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30 + device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896 + DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c + DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 + ffmpeg-kit-ios-https-gpl: 0caef23dca1bdb18fe6f2e1cd43d02d5978c4c2e + ffmpeg_kit_flutter_https_gpl: 9ac316d69f8c5e30aa1ff6b9f76c4eb284b385f9 + file_picker: 5f42b9d5580e30b57b4863f9d94b448016b702e5 + Firebase: 374a441a91ead896215703a674d58cdb3e9d772b + firebase_analytics: a5c6ef5a435d22870fe3cfdcb424f390f56ff752 + firebase_core: 2337982fb78ee4d8d91e608b0a3d4f44346a93c8 + firebase_crashlytics: 3b6a9a9cbdc5ab92afaf9b206e52c79c2321a0d4 + FirebaseAnalytics: 7114c698cac995602e3b1b96663473e50d54d6e7 + FirebaseCore: 48b0dd707581cf9c1a1220da68223fb0a562afaa + FirebaseCoreExtension: 2d77d6430c16cf43ca2b04608302ed02b3598361 + FirebaseCoreInternal: d98ab91e2d80a56d7b246856a8885443b302c0c2 + FirebaseCrashlytics: b21c665fb50138766480bce73ebdb1aa30f7f300 + FirebaseInstallations: efc0946fc756e4d22d8113f7c761948120322e8c + FirebaseRemoteConfigInterop: e75e348953352a000331eb77caf01e424248e176 + FirebaseSessions: 9529d14180868e29a8da164b3a729c036204918b + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + flutter_downloader: 78da0da1084e709cbfd3b723c7ea349c71681f09 + flutter_secure_storage: 2c2ff13db9e0a5647389bff88b0ecac56e3f3418 + FYVideoCompressor: 80e2a90bbc118044038b37b8442f23084c5698bf + GCDWebServer: 2c156a56c8226e2d5c0c3f208a3621ccffbe3ce4 + GoogleAppMeasurement: 6a9e6317b6a6d810ad03d4a66564ca6c4c5818a3 + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d + HLSCachingReverseProxyServer: 59935e1e0244ad7f3375d75b5ef46e8eb26ab181 + image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a + images_picker: f01be5684149ad15ee37b5f270a3d50fdb33afa5 + just_audio: 6c031bb61297cf218b4462be616638e81c058e97 + libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009 + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + PINCache: d9a87a0ff397acffe9e2f0db972ac14680441158 + PINOperation: fb563bcc9c32c26d6c78aaff967d405aa2ee74a7 + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 + SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8 + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + video_compress: f2133a07762889d67f0711ac831faa26f956980e + video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b + video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140 + wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49 + webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c + ZLPhotoBrowser: 4bfab86b851042e18d7f413284472aa68759626a -PODFILE CHECKSUM: 56099e53bf102df458f588dbe78632cd13d5357b +PODFILE CHECKSUM: c47b194cb97680e602c9d704f278cff245139eb7 -COCOAPODS: 1.11.2 +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 65bc1088..2ba6889e 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,12 +3,21 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 1B77A5C027A5C39000510271 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 1B77A5BF27A5C39000510271 /* GoogleService-Info.plist */; }; + 1B07DB412ABAD91A004AFB4E /* libsqlite3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 1B07DB402ABAD90F004AFB4E /* libsqlite3.tbd */; }; + 1B07DB422ABAD91A004AFB4E /* libsqlite3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 1B07DB402ABAD90F004AFB4E /* libsqlite3.tbd */; }; + 1B24F3CC282EC1F600256625 /* VideoEncoderSizes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B24F3CB282EC1F600256625 /* VideoEncoderSizes.swift */; }; + 1B24F3CF282EC27300256625 /* EncoderBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B24F3CE282EC27300256625 /* EncoderBridge.swift */; }; + 1B6A5097281E206800480755 /* AcelaWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B6A5096281E206800480755 /* AcelaWebViewController.swift */; }; + 1B6A509E281E219E00480755 /* public in Resources */ = {isa = PBXBuildFile; fileRef = 1B6A509D281E219E00480755 /* public */; }; + 1B6A50A1281E290300480755 /* AuthBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B6A50A0281E290300480755 /* AuthBridge.swift */; }; + 1B6A50A4281F5AEF00480755 /* ValidateHiveKeyResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B6A50A3281F5AEF00480755 /* ValidateHiveKeyResponse.swift */; }; + 1B951A4D2954716D00BBA4CF /* HASBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B951A4C2954716D00BBA4CF /* HASBridge.swift */; }; + 1BBABA1B27E2F977000ABB58 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 1BBABA1A27E2F977000ABB58 /* GoogleService-Info.plist */; }; 23F3017383FF2D45A9FFE1B2 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 900CC1210BBABA2901A394FB /* Pods_Runner.framework */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; @@ -33,7 +42,16 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 1B77A5BF27A5C39000510271 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; + 1B07DB402ABAD90F004AFB4E /* libsqlite3.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libsqlite3.tbd; path = usr/lib/libsqlite3.tbd; sourceTree = SDKROOT; }; + 1B24F3CB282EC1F600256625 /* VideoEncoderSizes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoEncoderSizes.swift; sourceTree = ""; }; + 1B24F3CE282EC27300256625 /* EncoderBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncoderBridge.swift; sourceTree = ""; }; + 1B6A5096281E206800480755 /* AcelaWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcelaWebViewController.swift; sourceTree = ""; }; + 1B6A509D281E219E00480755 /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; + 1B6A50A0281E290300480755 /* AuthBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthBridge.swift; sourceTree = ""; }; + 1B6A50A3281F5AEF00480755 /* ValidateHiveKeyResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateHiveKeyResponse.swift; sourceTree = ""; }; + 1B951A4C2954716D00BBA4CF /* HASBridge.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HASBridge.swift; sourceTree = ""; }; + 1BBABA1A27E2F977000ABB58 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; + 1BFFAA0B285824800093D01C /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; 3655DA9EF6F21F87FCBD0D8C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 571EA836831FEA283EA2C84D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; @@ -56,6 +74,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 1B07DB422ABAD91A004AFB4E /* libsqlite3.tbd in Frameworks */, + 1B07DB412ABAD91A004AFB4E /* libsqlite3.tbd in Frameworks */, 23F3017383FF2D45A9FFE1B2 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -63,6 +83,41 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 1B24F3CA282E8AF900256625 /* Encoder */ = { + isa = PBXGroup; + children = ( + 1B24F3CB282EC1F600256625 /* VideoEncoderSizes.swift */, + ); + path = Encoder; + sourceTree = ""; + }; + 1B24F3CD282EC26500256625 /* Encoder */ = { + isa = PBXGroup; + children = ( + 1B24F3CE282EC27300256625 /* EncoderBridge.swift */, + ); + path = Encoder; + sourceTree = ""; + }; + 1B6A509F281E28F600480755 /* Bridges */ = { + isa = PBXGroup; + children = ( + 1B24F3CD282EC26500256625 /* Encoder */, + 1B6A50A2281F5AE500480755 /* Auth */, + ); + path = Bridges; + sourceTree = ""; + }; + 1B6A50A2281F5AE500480755 /* Auth */ = { + isa = PBXGroup; + children = ( + 1B951A4C2954716D00BBA4CF /* HASBridge.swift */, + 1B6A50A0281E290300480755 /* AuthBridge.swift */, + 1B6A50A3281F5AEF00480755 /* ValidateHiveKeyResponse.swift */, + ); + path = Auth; + sourceTree = ""; + }; 2BD77FF19862F46541915B75 /* Pods */ = { isa = PBXGroup; children = ( @@ -76,6 +131,7 @@ 5EF66C9F588A8934CC38BF1D /* Frameworks */ = { isa = PBXGroup; children = ( + 1B07DB402ABAD90F004AFB4E /* libsqlite3.tbd */, 900CC1210BBABA2901A394FB /* Pods_Runner.framework */, ); name = Frameworks; @@ -114,6 +170,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + 1BFFAA0B285824800093D01C /* Runner.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, @@ -121,8 +178,12 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 1B24F3CA282E8AF900256625 /* Encoder */, + 1B6A509F281E28F600480755 /* Bridges */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, - 1B77A5BF27A5C39000510271 /* GoogleService-Info.plist */, + 1BBABA1A27E2F977000ABB58 /* GoogleService-Info.plist */, + 1B6A5096281E206800480755 /* AcelaWebViewController.swift */, + 1B6A509D281E219E00480755 /* public */, ); path = Runner; sourceTree = ""; @@ -142,6 +203,8 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 214AAB41E8D0416E47EF2909 /* [CP] Embed Pods Frameworks */, + E16D86D96B9AC4CC871B1E93 /* [firebase_crashlytics] Crashlytics Upload Symbols */, + DDDD97A17EC4BBAA45BD69B0 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -158,7 +221,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -192,7 +255,8 @@ files = ( 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, - 1B77A5C027A5C39000510271 /* GoogleService-Info.plist in Resources */, + 1BBABA1B27E2F977000ABB58 /* GoogleService-Info.plist in Resources */, + 1B6A509E281E219E00480755 /* public in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, ); @@ -242,10 +306,12 @@ }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( @@ -256,6 +322,7 @@ }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -268,6 +335,46 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + DDDD97A17EC4BBAA45BD69B0 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + E16D86D96B9AC4CC871B1E93 /* [firebase_crashlytics] Crashlytics Upload Symbols */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}\"", + "\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/\"", + "\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"", + "\"$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)\"", + "\"$(PROJECT_DIR)/firebase_app_id_file.json\"", + ); + name = "[firebase_crashlytics] Crashlytics Upload Symbols"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$PODS_ROOT/FirebaseCrashlytics/upload-symbols\" --flutter-project \"$PROJECT_DIR/firebase_app_id_file.json\" "; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -276,7 +383,13 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1B24F3CC282EC1F600256625 /* VideoEncoderSizes.swift in Sources */, + 1B6A5097281E206800480755 /* AcelaWebViewController.swift in Sources */, + 1B951A4D2954716D00BBA4CF /* HASBridge.swift in Sources */, + 1B6A50A4281F5AEF00480755 /* ValidateHiveKeyResponse.swift in Sources */, + 1B6A50A1281E290300480755 /* AuthBridge.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 1B24F3CF282EC27300256625 /* EncoderBridge.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -332,6 +445,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = "${FLUTTER_BUILD_NUMBER}"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -343,12 +457,15 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = "${FLUTTER_BUILD_NAME}"; MTL_ENABLE_DEBUG_INFO = NO; + PRODUCT_BUNDLE_IDENTIFIER = com.example.acela; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; }; name = Profile; }; @@ -357,17 +474,26 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "${FLUTTER_BUILD_NUMBER}"; DEVELOPMENT_TEAM = 58LRY57FMK; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = 3Speak.tv; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video"; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.3speak.Acela; + MARKETING_VERSION = "${FLUTTER_BUILD_NAME}"; + PRODUCT_BUNDLE_IDENTIFIER = com.example.acela; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; @@ -404,6 +530,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = "${FLUTTER_BUILD_NUMBER}"; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -421,11 +548,14 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = "${FLUTTER_BUILD_NAME}"; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.example.acela; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; }; @@ -459,6 +589,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = "${FLUTTER_BUILD_NUMBER}"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -470,14 +601,17 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = "${FLUTTER_BUILD_NAME}"; MTL_ENABLE_DEBUG_INFO = NO; + PRODUCT_BUNDLE_IDENTIFIER = com.example.acela; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; }; name = Release; }; @@ -486,17 +620,26 @@ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "${FLUTTER_BUILD_NUMBER}"; DEVELOPMENT_TEAM = 58LRY57FMK; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = 3Speak.tv; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video"; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.3speak.Acela; + MARKETING_VERSION = "${FLUTTER_BUILD_NAME}"; + PRODUCT_BUNDLE_IDENTIFIER = com.example.acela; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -509,17 +652,26 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "${FLUTTER_BUILD_NUMBER}"; DEVELOPMENT_TEAM = 58LRY57FMK; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = 3Speak.tv; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video"; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.3speak.Acela; + MARKETING_VERSION = "${FLUTTER_BUILD_NAME}"; + PRODUCT_BUNDLE_IDENTIFIER = com.example.acela; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index c87d15a3..c53e2b31 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ diff --git a/ios/Runner/AcelaWebViewController.swift b/ios/Runner/AcelaWebViewController.swift new file mode 100644 index 00000000..fc1a8217 --- /dev/null +++ b/ios/Runner/AcelaWebViewController.swift @@ -0,0 +1,344 @@ +// +// AcelaWebViewController.swift +// Runner +// +// Created by Sagar on 01/05/22. +// + +import UIKit +import WebKit + +class AcelaWebViewController: UIViewController { + let acela = "acela" + let config = WKWebViewConfiguration() + let rect = CGRect(x: 0, y: 0, width: 10, height: 10) + var webView: WKWebView? + var didFinish = false + var postingKeyValidationHandler: ((String) -> Void)? + var decryptTokenHandler: ((String) -> Void)? + var postVideoHandler: ((String) -> Void)? + var postPodcastHandler: ((String) -> Void)? + + var getRedirectUriHandler: ((String) -> Void)? = nil + var getRedirectUriDataHandler: ((String) -> Void)? = nil + var hiveUserInfoHandler: ((String) -> Void)? = nil + var getDecryptedHASTokenHandler: ((String) -> Void)? = nil + var voteContentHandler: ((String) -> Void)? = nil + var commentOnContentHandler: ((String) -> Void)? = nil + var getHtmlHandler: ((String) -> Void)? = nil + var getProofOfPayloadHandler: ((String) -> Void)? = nil + var getEncryptedChallengeHandler: ((String) -> Void)? = nil + var getDecryptedChallengeHandler: ((String) -> Void)? = nil + var doWeHavePostingAuthHandler: ((String) -> Void)? = nil + var getAccountInfoHandler: ((String) -> Void)? = nil + + override func viewDidLoad() { + super.viewDidLoad() + config.userContentController.add(self, name: acela) + webView = WKWebView(frame: rect, configuration: config) + webView?.navigationDelegate = self + guard + let url = Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "public") + else { return } + let dir = url.deletingLastPathComponent() + webView?.loadFileURL(url, allowingReadAccessTo: dir) +//#if DEBUG + if #available(iOS 16.4, *) { + self.webView?.isInspectable = true + } +//#endif + } + + func getHtml(string: String, handler: @escaping (String) -> Void) { + getHtmlHandler = handler + OperationQueue.main.addOperation { + self.webView?.evaluateJavaScript("getHTMLStringForContent('\(string)');") + } + } + + func validatePostingKey( + username: String, + postingKey: String, + handler: @escaping (String) -> Void + ) { + postingKeyValidationHandler = handler + OperationQueue.main.addOperation { + self.webView?.evaluateJavaScript("validateHiveKey('\(username)', '\(postingKey)');") + } + } + + func decryptMemo( + username: String, + postingKey: String, + encryptedMemo: String, + handler: @escaping (String) -> Void + ) { + decryptTokenHandler = handler + OperationQueue.main.addOperation { + self.webView?.evaluateJavaScript("decryptMemo('\(username)', '\(postingKey)', '\(encryptedMemo)');") + } + } + + func voteContent( + user: String, + author: String, + permlink: String, + weight: Double, + postingKey: String, + hasKey: String, + hasAuthKey: String, + handler: @escaping (String) -> Void + ) { + voteContentHandler = handler + OperationQueue.main.addOperation { + self.webView?.evaluateJavaScript("voteContent('\(user)', '\(author)', '\(permlink)', \(weight), '\(postingKey)', '\(hasKey)', '\(hasAuthKey)');") + } + } + + func commentOnContent( + user: String, + author: String, + permlink: String, + comment: String, + postingKey: String, + hasKey: String, + hasAuthKey: String, + handler: @escaping (String) -> Void + ) { + commentOnContentHandler = handler + OperationQueue.main.addOperation { + self.webView?.evaluateJavaScript("commentOnContent('\(user)', '\(author)', '\(permlink)', '\(comment)', '\(postingKey)', '\(hasKey)', '\(hasAuthKey)');") + } + } + + func postVideo( + thumbnail: String, + video_v2: String, + description: String, + title: String, + tags: String, + username: String, + permlink: String, + duration: Double, + size: Double, + originalFilename: String, + firstUpload: Bool, + bene: String, + beneW: String, + postingKey: String, + community: String, + ipfsHash: String, + hasKey: String, + hasAuthkey: String, + newBene: String, + language: String, + powerUp: Bool, + handler: @escaping (String) -> Void + ) { + postVideoHandler = handler + OperationQueue.main.addOperation { + self.webView?.evaluateJavaScript("newPostVideo('\(thumbnail)','\(video_v2)', '\(description)', '\(title)', '\(tags)', '\(username)', '\(permlink)', \(duration), \(size), '\(originalFilename)', '\(language)', \(firstUpload ? "true" : "false"), '\(bene)', '\(beneW)', '\(postingKey)', '\(community)', '\(ipfsHash)', '\(hasKey)', '\(hasAuthkey)', '\(newBene)', \(powerUp ? "true" : "false"));") + } + } + + func getProofOfPayload(username: String, password: String, proof: String, handler: @escaping (String) -> Void) { + getProofOfPayloadHandler = handler + OperationQueue.main.addOperation { + self.webView?.evaluateJavaScript("getProofOfPayload('\(username)','\(password)', '\(proof)');") + } + } + + func getEncryptedChallenge(username: String, authKey: String, handler: @escaping (String) -> Void) { + getEncryptedChallengeHandler = handler + OperationQueue.main.addOperation { + self.webView?.evaluateJavaScript("getEncryptedChallenge('\(username)','\(authKey)');") + } + } + + func getDecryptedChallenge(username: String, authKey: String, data: String, handler: @escaping (String) -> Void) { + getDecryptedChallengeHandler = handler + OperationQueue.main.addOperation { + self.webView?.evaluateJavaScript("getDecryptedChallenge('\(username)','\(authKey)', '\(data)');") + } + } + + func doWeHavePostingAuth(username: String, handler: @escaping (String) -> Void) { + doWeHavePostingAuthHandler = handler + OperationQueue.main.addOperation { + self.webView?.evaluateJavaScript("doWeHavePostingAuth('\(username)');") + } + } + + func getAccountInfo(username: String, handler: @escaping (String) -> Void) { + getAccountInfoHandler = handler + OperationQueue.main.addOperation { + self.webView?.evaluateJavaScript("getAccountInfo('\(username)');") + } + } + + func postPodcast( + thumbnail: String, + enclosureUrl: String, + description: String, + title: String, + tags: String, + username: String, + permlink: String, + duration: Double, + size: Double, + originalFilename: String, + firstUpload: Bool, + bene: String, + beneW: String, + postingKey: String, + community: String, + ipfsHash: String, + hasKey: String, + hasAuthkey: String, + newBene: String, + language: String, + powerUp: Bool, + handler: @escaping (String) -> Void + ) { + postPodcastHandler = handler + OperationQueue.main.addOperation { + self.webView?.evaluateJavaScript("newPostPodcast('\(thumbnail)','\(enclosureUrl)', '\(description)', '\(title)', '\(tags)', '\(username)', '\(permlink)', \(duration), \(size), '\(originalFilename)', '\(language)', \(firstUpload ? "true" : "false"), '\(bene)', '\(beneW)', '\(postingKey)', '\(community)', '\(ipfsHash)', '\(hasKey)', '\(hasAuthkey)', '\(newBene)', \(powerUp ? "true" : "false"));") + } + } + + func getRedirectUri(_ username: String, handler: @escaping (String) -> Void) { + getRedirectUriHandler = handler + webView?.evaluateJavaScript("getRedirectUri('\(username)');") + } + + func getRedirectUriData(_ username: String, handler: @escaping (String) -> Void) { + getRedirectUriDataHandler = handler + webView?.evaluateJavaScript("getRedirectUriData('\(username)');") + } + + func getDecryptedHASToken( + username: String, + authKey: String, + data: String, + handler: @escaping (String) -> Void + ) { + getDecryptedHASTokenHandler = handler + webView?.evaluateJavaScript("getDecryptedHASToken('\(username)','\(authKey)','\(data)');") + } + + func getUserInfo(_ handler: @escaping (String) -> Void) { + hiveUserInfoHandler = handler + webView?.evaluateJavaScript("getUserInfo();") + } +} + +extension AcelaWebViewController: WKNavigationDelegate { + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + didFinish = true + } +} + +extension AcelaWebViewController: WKScriptMessageHandler { + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + guard message.name == acela else { return } + guard let dict = message.body as? [String: AnyObject] else { return } + guard let type = dict["type"] as? String else { return } + switch type { + case "validateHiveKey": + guard + let response = ValidateHiveKeyResponse.jsonStringFrom(dict: dict) + else { return } + postingKeyValidationHandler?(response) + case "decryptedMemo": + guard + let accountName = dict["accountName"] as? String, + let error = dict["error"] as? String, + let decrypted = dict["decrypted"] as? String, + let response = DecryptMemoResponse.jsonStringFrom(dict: dict) + else { return } + debugPrint("account name is \(accountName)") + debugPrint("Error is \(error)") + debugPrint("decrypted is \(decrypted)") + decryptTokenHandler?(response) + case "postVideo": + guard + let isValid = dict["valid"] as? Bool, + let error = dict["error"] as? String, + let response = ValidateHiveKeyResponse.jsonStringFrom(dict: dict) + else { return } + debugPrint("Is it valid? \(isValid ? "TRUE" : "FALSE")") + debugPrint("Error is \(error)") + postVideoHandler?(response) + case "postAudio": + guard + let response = ValidateHiveKeyResponse.jsonStringFrom(dict: dict) + else { return } + postPodcastHandler?(response) + case "hiveAuthUserInfo": + guard + let response = ValidateHiveKeyResponse.jsonStringFrom(dict: dict) + else { return } + hiveUserInfoHandler?(response) + case "getRedirectUri": + guard + let response = ValidateHiveKeyResponse.jsonStringFrom(dict: dict) + else { return } + getRedirectUriHandler?(response) + case "getRedirectUriData": + guard + let response = ValidateHiveKeyResponse.jsonStringFrom(dict: dict) + else { return } + getRedirectUriDataHandler?(response) + case "getDecryptedHASToken": + guard + let response = ValidateHiveKeyResponse.jsonStringFrom(dict: dict) + else { return } + getDecryptedHASTokenHandler?(response) + case "voteContent": + guard + let response = ValidateHiveKeyResponse.jsonStringFrom(dict: dict) + else { return } + voteContentHandler?(response) + case "commentOnContent": + guard + let response = ValidateHiveKeyResponse.jsonStringFrom(dict: dict) + else { return } + commentOnContentHandler?(response) + case "getHTMLStringForContent": + guard + let response = ValidateHiveKeyResponse.jsonStringFrom(dict: dict) + else { return } + getHtmlHandler?(response) + case "getProofOfPayload": + guard + let response = ValidateHiveKeyResponse.jsonStringFrom(dict: dict) + else { return } + getProofOfPayloadHandler?(response) + case "getEncryptedChallenge": + guard + let response = ValidateHiveKeyResponse.jsonStringFrom(dict: dict) + else { return } + getEncryptedChallengeHandler?(response) + case "getDecryptedChallenge": + guard + let response = ValidateHiveKeyResponse.jsonStringFrom(dict: dict) + else { return } + getDecryptedChallengeHandler?(response) + case "getAccountInfo": + guard + let data = try? JSONSerialization.data(withJSONObject: dict), + let string = String(data: data, encoding: .utf8) + else { return } + getAccountInfoHandler?(string) + case "doWeHavePostingAuth": + guard + let response = ValidateHiveKeyResponse.jsonStringFrom(dict: dict) + else { return } + doWeHavePostingAuthHandler?(response) + default: debugPrint("Do nothing here.") + } + } +} diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 70693e4a..1d990d06 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,13 +1,41 @@ import UIKit import Flutter +import AVKit +import flutter_downloader -@UIApplicationMain +@main @objc class AppDelegate: FlutterAppDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } + var acela: AcelaWebViewController? + let authBridge = AuthBridge() + let encoderBridge = EncoderBridge() + let hasBridge = HASBridge() + + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + acela = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "AcelaWebViewController") as? AcelaWebViewController + + acela?.viewDidLoad() + + let controller : FlutterViewController = window?.rootViewController as! FlutterViewController + authBridge.initiate(controller: controller, window: window, acela: acela) + encoderBridge.initiate(controller: controller, window: window, acela: acela) + hasBridge.initiate(controller: controller, window: window, acela: acela) + + GeneratedPluginRegistrant.register(with: self) + FlutterDownloaderPlugin.setPluginRegistrantCallback({ registry in + if (!registry.hasPlugin("FlutterDownloaderPlugin")) { + FlutterDownloaderPlugin.register(with: registry.registrar(forPlugin: "FlutterDownloaderPlugin")!) + } + }) + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + +// override func applicationDidBecomeActive(_ application: UIApplication) { +// if (TSSocket.shared.tusClient == nil) { +// TSSocket.shared.connect() +// } +// } } diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard index f3c28516..0f0a8a10 100644 --- a/ios/Runner/Base.lproj/Main.storyboard +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -1,8 +1,11 @@ - - + + + - + + + @@ -14,13 +17,37 @@ - + - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Bridges/Auth/AuthBridge.swift b/ios/Runner/Bridges/Auth/AuthBridge.swift new file mode 100644 index 00000000..6e071e18 --- /dev/null +++ b/ios/Runner/Bridges/Auth/AuthBridge.swift @@ -0,0 +1,291 @@ +// +// AuthBridge.swift +// Runner +// +// Created by Sagar on 01/05/22. +// + +import UIKit +import Flutter +import AVFoundation +import AVKit + +class AuthBridge { + var window: UIWindow? + var acela: AcelaWebViewController? + + func initiate(controller: FlutterViewController, window: UIWindow?, acela: AcelaWebViewController?) { + self.window = window + self.acela = acela + let authChannel = FlutterMethodChannel( + name: "com.example.acela/auth", + binaryMessenger: controller.binaryMessenger + ) + authChannel.setMethodCallHandler({ + [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in + // Note: this method is invoked on the UI thread. + switch (call.method) { + case "playFullscreen": + guard + let arguments = call.arguments as? NSDictionary, + let stringUrl = arguments ["url"] as? String, + let seconds = arguments ["seconds"] as? Int, + let url = URL(string: stringUrl) + else { + result(FlutterMethodNotImplemented) + return + } + let myTime = CMTime(seconds: Double(seconds), preferredTimescale: 60000) + let controller = AVPlayerViewController() + let player = AVPlayer(url: url) + controller.player = player + player.seek(to: myTime, toleranceBefore: .zero, toleranceAfter: .zero) + player.play() + window?.rootViewController?.present(controller, animated: true) + result("done") + case "getHTMLStringForContent": + guard + let arguments = call.arguments as? NSDictionary, + let string = arguments ["string"] as? String + else { + result(FlutterMethodNotImplemented) + return + } + self?.getHtml(string: string, result: result) + case "validateHiveKey": + guard + let arguments = call.arguments as? NSDictionary, + let username = arguments ["username"] as? String, + let password = arguments["postingKey"] as? String + else { + result(FlutterMethodNotImplemented) + return + } + self?.authenticate(username: username, postingKey: password, result: result) + case "encryptedToken": + guard + let arguments = call.arguments as? NSDictionary, + let username = arguments ["username"] as? String, + let password = arguments["postingKey"] as? String, + let encryptedToken = arguments["encryptedToken"] as? String + else { + result(FlutterMethodNotImplemented) + return + } + self?.decryptMemo(username: username, postingKey: password, encryptedMemo: encryptedToken, result: result) + case "getProofOfPayload": + guard + let arguments = call.arguments as? NSDictionary, + let username = arguments ["username"] as? String, + let password = arguments["postingKey"] as? String, + let proof = arguments["proof"] as? String, + let acela = acela + else { + result(FlutterMethodNotImplemented) + return + } + acela.getProofOfPayload(username: username, password: password, proof: proof) { data in + result(data) + } + case "newPostPodcast": + guard + let arguments = call.arguments as? NSDictionary, + let thumbnail = arguments["thumbnail"] as? String, + let enclosureUrl = arguments["enclosureUrl"] as? String, + let description = arguments["description"] as? String, + let title = arguments["title"] as? String, + let tags = arguments["tags"] as? String, + let username = arguments["username"] as? String, + let permlink = arguments["permlink"] as? String, + let duration = arguments["duration"] as? Double, + let size = arguments["size"] as? Double, + let originalFilename = arguments["originalFilename"] as? String, + let firstUpload = arguments["firstUpload"] as? Bool, + let bene = arguments["bene"] as? String, + let beneW = arguments["beneW"] as? String, + let postingKey = arguments["postingKey"] as? String, + let community = arguments["community"] as? String, + let ipfsHash = arguments["ipfsHash"] as? String, + let hasKey = arguments["hasKey"] as? String, + let hasAuthKey = arguments["hasAuthKey"] as? String, + let newBene = arguments["newBene"] as? String, + let language = arguments["language"] as? String, + let powerUp = arguments["powerUp"] as? Bool, + let acela = acela + else { + result(FlutterMethodNotImplemented) + return + } + acela.postPodcast( + thumbnail: thumbnail, + enclosureUrl: enclosureUrl, + description: description, + title: title, + tags: tags, + username: username, + permlink: permlink, + duration: duration, + size: size, + originalFilename: originalFilename, + firstUpload: firstUpload, + bene: bene, + beneW: beneW, + postingKey: postingKey, + community: community, + ipfsHash: ipfsHash, + hasKey: hasKey, + hasAuthkey: hasAuthKey, + newBene: newBene, + language: language, + powerUp: powerUp + ) { response in + result(response) + } + case "newPostVideo": + guard + let arguments = call.arguments as? NSDictionary, + let thumbnail = arguments["thumbnail"] as? String, + let video_v2 = arguments["video_v2"] as? String, + let description = arguments["description"] as? String, + let title = arguments["title"] as? String, + let tags = arguments["tags"] as? String, + let username = arguments["username"] as? String, + let permlink = arguments["permlink"] as? String, + let duration = arguments["duration"] as? Double, + let size = arguments["size"] as? Double, + let originalFilename = arguments["originalFilename"] as? String, + let firstUpload = arguments["firstUpload"] as? Bool, + let bene = arguments["bene"] as? String, + let beneW = arguments["beneW"] as? String, + let postingKey = arguments["postingKey"] as? String, + let community = arguments["community"] as? String, + let ipfsHash = arguments["ipfsHash"] as? String, + let hasKey = arguments["hasKey"] as? String, + let hasAuthKey = arguments["hasAuthKey"] as? String, + let newBene = arguments["newBene"] as? String, + let language = arguments["language"] as? String, + let powerUp = arguments["powerUp"] as? Bool, + let acela = acela + else { + result(FlutterMethodNotImplemented) + return + } + acela.postVideo( + thumbnail: thumbnail, + video_v2: video_v2, + description: description, + title: title, + tags: tags, + username: username, + permlink: permlink, + duration: duration, + size: size, + originalFilename: originalFilename, + firstUpload: firstUpload, + bene: bene, + beneW: beneW, + postingKey: postingKey, + community: community, + ipfsHash: ipfsHash, + hasKey: hasKey, + hasAuthkey: hasAuthKey, + newBene: newBene, + language: language, + powerUp: powerUp + ) { response in + result(response) + } + case "voteContent": + guard + let arguments = call.arguments as? NSDictionary, + let user = arguments["user"] as? String, + let author = arguments["author"] as? String, + let permlink = arguments["permlink"] as? String, + let weight = arguments["weight"] as? Double, + let postingKey = arguments["postingKey"] as? String, + let hasKey = arguments["hasKey"] as? String, + let hasAuthKey = arguments["hasAuthKey"] as? String, + let acela = acela + else { + result(FlutterMethodNotImplemented) + return + } + acela.voteContent(user: user, author: author, permlink: permlink, weight: weight, postingKey: postingKey, hasKey: hasKey, hasAuthKey: hasAuthKey) { response in + result(response) + } + case "commentOnContent": + guard + let arguments = call.arguments as? NSDictionary, + let user = arguments["user"] as? String, + let author = arguments["author"] as? String, + let permlink = arguments["permlink"] as? String, + let comment = arguments["comment"] as? String, + let postingKey = arguments["postingKey"] as? String, + let hasKey = arguments["hasKey"] as? String, + let hasAuthKey = arguments["hasAuthKey"] as? String, + let acela = acela + else { + result(FlutterMethodNotImplemented) + return + } + acela.commentOnContent(user: user, author: author, permlink: permlink, comment: comment, postingKey: postingKey, hasKey: hasKey, hasAuthKey: hasAuthKey) { response in + result(response) + } + case "getAccountInfo": + guard + let arguments = call.arguments as? NSDictionary, + let username = arguments["username"] as? String, + let acela = acela + else { + result(FlutterMethodNotImplemented) + return + } + acela.getAccountInfo(username: username) { response in + result(response) + } + default: debugPrint("do nothing") + } + }) + } + + private func getHtml(string: String, result: @escaping FlutterResult) { + guard let acela = acela else { + result(FlutterError(code: "ERROR", + message: "Error setting up Hive", + details: nil)) + return + } + acela.getHtml(string: string) { response in + result(response) + } + } + + private func authenticate(username: String, postingKey: String, result: @escaping FlutterResult) { + guard let acela = acela else { + result(FlutterError(code: "ERROR", + message: "Error setting up Hive", + details: nil)) + return + } + acela.validatePostingKey(username: username, postingKey: postingKey) { response in + result(response) + } + } + + private func decryptMemo( + username: String, + postingKey: String, + encryptedMemo: String, + result: @escaping FlutterResult + ) { + guard let acela = acela else { + result(FlutterError(code: "ERROR", + message: "Error setting up Hive", + details: nil)) + return + } + acela.decryptMemo(username: username, postingKey: postingKey, encryptedMemo: encryptedMemo) { response in + result(response) + } + } +} diff --git a/ios/Runner/Bridges/Auth/HASBridge.swift b/ios/Runner/Bridges/Auth/HASBridge.swift new file mode 100644 index 00000000..fb1db024 --- /dev/null +++ b/ios/Runner/Bridges/Auth/HASBridge.swift @@ -0,0 +1,156 @@ +// +// Bridge.swift +// Runner +// +// Created by Sagar on 24/11/22. +// + +import Foundation +import UIKit +import Flutter + +class HASBridge { + var window: UIWindow? + var acela: AcelaWebViewController? + + func initiate( + controller: FlutterViewController, + window: UIWindow?, + acela: AcelaWebViewController? + ) { + self.window = window + self.acela = acela + let authChannel = FlutterMethodChannel( + name: "blog.hive.auth/bridge", + binaryMessenger: controller.binaryMessenger + ) + + authChannel.setMethodCallHandler({ + [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in + // Note: this method is invoked on the UI thread. + switch (call.method) { + case "getRedirectUri": + guard + let arguments = call.arguments as? NSDictionary, + let username = arguments ["username"] as? String + else { + result(FlutterMethodNotImplemented) + return + } + self?.getRedirectUri(username: username, result: result) + case "getRedirectUriData": + guard + let arguments = call.arguments as? NSDictionary, + let username = arguments ["username"] as? String + else { + result(FlutterMethodNotImplemented) + return + } + self?.getRedirectUriData(username: username, result: result) + case "getDecryptedHASToken": + guard + let arguments = call.arguments as? NSDictionary, + let username = arguments ["username"] as? String, + let authKey = arguments ["authKey"] as? String, + let data = arguments ["data"] as? String + else { + result(FlutterMethodNotImplemented) + return + } + self?.getDecryptedHASToken(username: username, authKey: authKey, data: data, result: result) + case "getUserInfo": + self?.getUserInfo(result: result) + case "getEncryptedChallenge": + guard + let arguments = call.arguments as? NSDictionary, + let username = arguments ["username"] as? String, + let authKey = arguments["authKey"] as? String, + let acela = acela + else { + result(FlutterMethodNotImplemented) + return + } + acela.getEncryptedChallenge(username: username, authKey: authKey) { data in + result(data) + } + case "getDecryptedChallenge": + guard + let arguments = call.arguments as? NSDictionary, + let username = arguments ["username"] as? String, + let authKey = arguments["authKey"] as? String, + let data = arguments["data"] as? String, + let acela = acela + else { + result(FlutterMethodNotImplemented) + return + } + acela.getDecryptedChallenge(username: username, authKey: authKey, data: data) { data in + result(data) + } + case "doWeHavePostingAuth": + guard + let arguments = call.arguments as? NSDictionary, + let username = arguments ["username"] as? String, + let acela = acela + else { + result(FlutterMethodNotImplemented) + return + } + acela.doWeHavePostingAuth(username: username) { data in + result(data) + } + default: + result(FlutterMethodNotImplemented) + } + }) + } + + private func getDecryptedHASToken(username: String, authKey: String, data: String, result: @escaping FlutterResult) { + guard let acela = acela else { + result(FlutterError(code: "ERROR", + message: "Error setting up Hive", + details: nil)) + return + } + acela.getDecryptedHASToken(username: username, authKey: authKey, data: data) { string in + result(string) + } + } + + private func getUserInfo(result: @escaping FlutterResult) { + guard let acela = acela else { + result(FlutterError(code: "ERROR", + message: "Error setting up Hive", + details: nil)) + return + } + acela.getUserInfo { string in + result(string) + } + } + + private func getRedirectUri(username: String, result: @escaping FlutterResult) { + guard let acela = acela else { + result(FlutterError(code: "ERROR", + message: "Error setting up Hive", + details: nil)) + return + } + acela.getRedirectUri(username) { string in + result(string) + } + } + + private func getRedirectUriData(username: String, result: @escaping FlutterResult) { + guard let acela = acela else { + result(FlutterError(code: "ERROR", + message: "Error setting up Hive", + details: nil)) + return + } + acela.getRedirectUriData(username) { string in + debugPrint("Sending string back - \(string)") + result(string) + } + } +} diff --git a/ios/Runner/Bridges/Auth/ValidateHiveKeyResponse.swift b/ios/Runner/Bridges/Auth/ValidateHiveKeyResponse.swift new file mode 100644 index 00000000..bc4364fc --- /dev/null +++ b/ios/Runner/Bridges/Auth/ValidateHiveKeyResponse.swift @@ -0,0 +1,76 @@ +// +// ValidateHiveKeyResponse.swift +// Runner +// +// Created by Sagar on 02/05/22. +// + +import Foundation + +struct ValidateHiveKeyResponse: Codable { + let valid: Bool + let accountName: String? + let error: String + let data: String? + + static func jsonStringFrom(dict: [String: AnyObject]) -> String? { + guard + let isValid = dict["valid"] as? Bool, + let error = dict["error"] as? String + else { return nil } + let response = ValidateHiveKeyResponse( + valid: isValid, + accountName: dict["accountName"] as? String, + error: error, + data: dict["data"] as? String + ) + guard + let data = try? JSONEncoder().encode(response) + else { return nil } + guard + let dataString = String(data: data, encoding: .utf8) + else { return nil } + return dataString + } +} + +struct DecryptMemoResponse: Codable { + let accountName: String + let decrypted: String + let error: String + + static func jsonStringFrom(dict: [String: AnyObject]) -> String? { + guard + let accountName = dict["accountName"] as? String, + let error = dict["error"] as? String, + let decrypted = dict["decrypted"] as? String + else { return nil } + let response = DecryptMemoResponse( + accountName: accountName, + decrypted: decrypted, + error: error + ) + guard let data = try? JSONEncoder().encode(response) else { return nil } + guard let dataString = String(data: data, encoding: .utf8) else { return nil } + return dataString + } +} + +struct PostVideoResponse: Codable { + let valid: Bool + let error: String + + static func jsonStringFrom(dict: [String: AnyObject]) -> String? { + guard + let valid = dict["valid"] as? Bool, + let error = dict["error"] as? String + else { return nil } + let response = PostVideoResponse( + valid: valid, + error: error + ) + guard let data = try? JSONEncoder().encode(response) else { return nil } + guard let dataString = String(data: data, encoding: .utf8) else { return nil } + return dataString + } +} diff --git a/ios/Runner/Bridges/Encoder/EncoderBridge.swift b/ios/Runner/Bridges/Encoder/EncoderBridge.swift new file mode 100644 index 00000000..36fa29e4 --- /dev/null +++ b/ios/Runner/Bridges/Encoder/EncoderBridge.swift @@ -0,0 +1,192 @@ +// +// EncoderBridge.swift +// Runner +// +// Created by Sagar on 13/05/22. +// + +import Foundation +import UIKit +import Flutter +import PhotosUI +import FYVideoCompressor +import MobileCoreServices + +class EncoderBridge: NSObject { + var window: UIWindow? + var acela: AcelaWebViewController? + var controller: FlutterViewController? + let picker = UIImagePickerController() + var result: FlutterResult? + + func initiate(controller: FlutterViewController, window: UIWindow?, acela: AcelaWebViewController?) { + self.window = window + self.acela = acela + self.controller = controller + let encoderChannel = FlutterMethodChannel( + name: "com.example.acela/encoder", + binaryMessenger: controller.binaryMessenger + ) + encoderChannel.setMethodCallHandler({ + [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in + // Note: this method is invoked on the UI thread. + guard + call.method == "video", + let arguments = call.arguments as? NSDictionary, + let username = arguments ["username"] as? String, + let password = arguments["postingKey"] as? String + else { + result(FlutterMethodNotImplemented) + return + } + self?.video(username: username, postingKey: password, result: result) + }) + } + + private func video(username: String, postingKey: String, result: @escaping FlutterResult) { + guard let acela = acela else { + result(FlutterError(code: "ERROR", + message: "Error setting up Hive", + details: nil)) + return + } + acela.validatePostingKey(username: username, postingKey: postingKey) { [weak self] response in + guard + let data = response.data(using: .utf8), + let object = try? JSONDecoder().decode(ValidateHiveKeyResponse.self, from: data), + object.valid == true + else { + result(response) + return + } + self?.showPicker(result: result) + } + } + + func showPicker(result: @escaping FlutterResult) { + if #available(iOS 14, *) { + PHPhotoLibrary.requestAuthorization(for: .readWrite) { (status) in + DispatchQueue.main.async { + self.showUI(for: status, result: result) + } + } + } else { + showVideoPicker(result: result) + } + } + + func showUI(for status: PHAuthorizationStatus, result: @escaping FlutterResult) { + switch status { + case .authorized: + showVideoPicker(result: result) + case .limited: + showVideoPicker(result: result) + case .restricted: + showVideoPicker(result: result) + case .denied: + result(FlutterError(code: "ERROR", + message: "Please provide access. Go to Settings > Acela > Photos > Selected Photos / All Photos", + details: nil)) + case .notDetermined: + result(FlutterError(code: "ERROR", + message: "Please provide access. Go to Settings > Acela > Photos > Selected Photos / All Photos.", + details: nil)) + break + @unknown default: + break + } + } + + func showVideoPicker(result: @escaping FlutterResult) { + if UIImagePickerController.isSourceTypeAvailable(.photoLibrary) { + picker.sourceType = .photoLibrary + picker.mediaTypes = ["public.movie"] + picker.allowsEditing = false + picker.delegate = self + controller?.present(picker, animated: true, completion: nil) + self.result = result + } else { + result(FlutterError(code: "ERROR", + message: "Please provide access. Go to Settings > Acela > Photos > Selected Photos / All Photos.", + details: nil)) + } + } + + func convertToMP4(url: URL) { + let fm = FileManager.default + var comps = url.lastPathComponent.components(separatedBy: CharacterSet(charactersIn: ".")) + comps.removeLast() + let fileName = comps.joined(separator: ".") + let docDir = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] + let docDirFilePath = "\(docDir)/\(fileName).mp4" + try? fm.removeItem(atPath: docDirFilePath) + let docDirFileUrl = URL(fileURLWithPath: docDirFilePath) + let asset = AVURLAsset(url: url) + let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough) + export?.outputURL = docDirFileUrl + export?.outputFileType = AVFileType.mp4 + export?.exportAsynchronously(completionHandler: { + debugPrint("DocDir url - \(docDirFileUrl.absoluteString)") + let asset = AVAsset(url: docDirFileUrl) + let duration = asset.duration + let durationTime = CMTimeGetSeconds(duration) + debugPrint("Video duration is in seconds - \(durationTime) seconds") + do { + let attr = try FileManager.default.attributesOfItem(atPath: docDirFilePath) + let fileSize = attr[FileAttributeKey.size] as! UInt64 + debugPrint("Video file size is - \(fileSize)") + let responseString = VideoDataResponse.jsonStringFrom(size: Int(fileSize), duration: Int(durationTime), oFilename: "\(fileName).mp4", path: docDirFileUrl.absoluteString) + self.result?(responseString) + } catch { + print("Error: \(error)") + } + }) + } +} + +struct VideoDataResponse: Codable { + let size: Int + let duration: Int + let oFilename: String + let path: String + + static func jsonStringFrom(size: Int, duration: Int, oFilename: String, path: String) -> String? { + let response = VideoDataResponse( + size: size, + duration: duration, + oFilename: oFilename, + path: path + ) + guard let data = try? JSONEncoder().encode(response) else { return nil } + guard let dataString = String(data: data, encoding: .utf8) else { return nil } + return dataString + } +} + +extension EncoderBridge: UIImagePickerControllerDelegate, UINavigationControllerDelegate { + func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any] + ) { + if let type = info[UIImagePickerController.InfoKey.mediaType] as? String, + type == kUTTypeMovie as String, + let url = info[UIImagePickerController.InfoKey.mediaURL] as? URL { + picker.dismiss(animated: true) { + debugPrint("URL of media is \(url.debugDescription)") + self.convertToMP4(url: url) + } + } else { + picker.dismiss(animated: true, completion: nil) + result?(FlutterError(code: "ERROR", + message: "Selection of media is not a video.", + details: nil)) + } + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true, completion: nil) + result?(FlutterError(code: "ERROR", + message: "You cancelled selection of videos.", + details: nil)) + } +} diff --git a/ios/Runner/Encoder/VideoEncoderSizes.swift b/ios/Runner/Encoder/VideoEncoderSizes.swift new file mode 100644 index 00000000..ab66c696 --- /dev/null +++ b/ios/Runner/Encoder/VideoEncoderSizes.swift @@ -0,0 +1,123 @@ +// +// VideoEncoderSizes.swift +// Runner +// +// Created by Sagar on 13/05/22. +// + +import UIKit +import MobileCoreServices +import AVFoundation +import FYVideoCompressor + +// 240p, 480p, 720p, 1080p, 1440p, 2160p +enum VideoQuality { + case veryLow + case low + case medium + case high + case twoK + case fourK + + var size: CGSize { + switch self { + case .veryLow: return CGSize(width: 240, height: 0) + case .low: return CGSize(width: 480, height: 0) + case .medium: return CGSize(width: 720, height: 0) + case .high: return CGSize(width: 1080, height: 0) + case .twoK: return CGSize(width: 1440, height: 0) + case .fourK: return CGSize(width: 2160, height: 0) + } + } + + var stringValue: String { + switch self { + case .veryLow: return "veryLow" + case .low: return "low" + case .medium: return "medium" + case .high: return "high" + case .twoK: return "2K" + case .fourK: return "4K" + } + } + + var intValue: Int { + switch self { + case .veryLow: return 0 + case .low: return 1 + case .medium: return 2 + case .high: return 3 + case .twoK: return 4 + case .fourK: return 5 + } + } + + static func from(_ intValue: Int) -> VideoQuality { + switch intValue { + case 0: return .veryLow + case 1: return .low + case 2: return .medium + case 3: return .high + case 4: return .twoK + default: return .low + } + } + + var bitrate: Int { + switch self { + case .veryLow: return 1000_000 // 1 mbps + case .low: return 1250_000 // 1.25 mbps + case .medium: return 5000_000 // 5 mbps + case .high: return 8000_000 // 8 mbps + case .twoK: return 16000_000 // 16 mbps + case .fourK: return 35000_000 // 35 mbps + } + } + + var framerate: Float { + switch self { + case .veryLow: return 15 + case .low: return 17 + case .medium: return 22 + case .high: return 24 + case .twoK: return 25 + case .fourK: return 26 + } + } + + var config: FYVideoCompressor.CompressionConfig { + FYVideoCompressor.CompressionConfig( + videoBitrate: bitrate, + videomaxKeyFrameInterval: 10, + fps: framerate, + audioSampleRate: 44100, + audioBitrate: 128_000, + fileType: .mp4, + scale: size) + } +} + +public extension URL { + var fileSize: Int? { + let value = try? resourceValues(forKeys: [.fileSizeKey]) + return value?.fileSize + } + + var getThumbnail: UIImage? { + do { + let asset = AVURLAsset(url: self) + let imageGenerator = AVAssetImageGenerator(asset: asset) + imageGenerator.appliesPreferredTrackTransform = true + + // Swift 5.3 + let cgImage = try imageGenerator.copyCGImage(at: .zero, + actualTime: nil) + + return UIImage(cgImage: cgImage) + } catch { + print(error.localizedDescription) + + return nil + } + } +} diff --git a/ios/Runner/GoogleService-Info.plist b/ios/Runner/GoogleService-Info.plist index c0e7a352..c35d3307 100644 --- a/ios/Runner/GoogleService-Info.plist +++ b/ios/Runner/GoogleService-Info.plist @@ -3,9 +3,9 @@ CLIENT_ID - 252548198943-m20qel1pq01m3p8ivhfg9ac54600j8cg.apps.googleusercontent.com + 252548198943-ec3gnethnkvoudaat0lrobmq2t7mve8g.apps.googleusercontent.com REVERSED_CLIENT_ID - com.googleusercontent.apps.252548198943-m20qel1pq01m3p8ivhfg9ac54600j8cg + com.googleusercontent.apps.252548198943-ec3gnethnkvoudaat0lrobmq2t7mve8g API_KEY AIzaSyBhRRZgrQb8WfCKVMjOwC4jnK0XiMuK4YM GCM_SENDER_ID @@ -13,7 +13,7 @@ PLIST_VERSION 1 BUNDLE_ID - com.3speak.Acela + com.example.acela PROJECT_ID acela-9c624 STORAGE_BUCKET @@ -29,6 +29,6 @@ IS_SIGNIN_ENABLED GOOGLE_APP_ID - 1:252548198943:ios:de180e80d89dc46e355b2a + 1:252548198943:ios:2fcda030e33a7164355b2a \ No newline at end of file diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index cffa1ab8..f107805f 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -1,52 +1,78 @@ - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Acela - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - acela - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + 3Speak + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + 3Speak + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + ITSAppUsesNonExemptEncryption + + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSAppleMusicUsageDescription + Allow access to music + NSCameraUsageDescription + Allow access to camera to capture the video + NSMicrophoneUsageDescription + Allow access to microphone while capturing the video + NSPhotoLibraryUsageDescription + Allow access to videos + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + audio + fetch + remote-notification + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UISupportsDocumentBrowser + + UIViewControllerBasedStatusBarAppearance + + FDAllFilesDownloadedMessage + All files have been downloaded + FDMaximumConcurrentTasks + 5 + diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 00000000..903def2a --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/ios/Runner/public/index.html b/ios/Runner/public/index.html new file mode 100644 index 00000000..eec94027 --- /dev/null +++ b/ios/Runner/public/index.html @@ -0,0 +1,924 @@ + + + + + + + + 3Speak + + + + + + + + + + + + + diff --git a/ios/app.json b/ios/app.json new file mode 100644 index 00000000..f6026b31 --- /dev/null +++ b/ios/app.json @@ -0,0 +1,44 @@ +{ + "name": "3Speak - Acela Source", + "identifier": "com.example.acela", + "sourceURL": "https://github.com/spknetwork/Android-App/raw/development/ios/app.json", + "apps": [ + { + "name": "3Speak.tv - Acela", + "bundleIdentifier": "com.example.acela", + "developerName": "sagarkothari88", + "subtitle": "Tokenized Video Communities", + "version": "1.0.3", + "versionDate": "2022-12-08", + "versionDescription": "https://ecency.com/hive-181335/@sagarkothari88/3speak-development-update-from-sagarkothari88-7cd51a3b88448", + "downloadURL": "https://github.com/spknetwork/Android-App/releases/download/v1.0.3_48/Runner.ipa", + "localizedDescription": "The build which was also submitted to the Apple for TestFlight review", + "iconURL": "https://github.com/spknetwork/Android-App/releases/download/v1.0.3_48/AppIcon-512.png", + "tintColor": "AFEEEE", + "size": 857669, + "permissions": [ + ], + "screenshotURLs": [ + "https://github.com/spknetwork/Android-App/releases/download/v1.0.3_48/IMG_5651.PNG", + "https://github.com/spknetwork/Android-App/releases/download/v1.0.3_48/IMG_5652.PNG", + "https://github.com/spknetwork/Android-App/releases/download/v1.0.3_48/IMG_5653.PNG", + "https://github.com/spknetwork/Android-App/releases/download/v1.0.3_48/IMG_5654.PNG", + "https://github.com/spknetwork/Android-App/releases/download/v1.0.3_48/IMG_5655.PNG" + ], + "beta": false + } + ], + "news": [ + { + "title": "3Speak.tv Acela - Download now - via AltStore", + "identifier": "3speak-tv-acela-download-2022-12-08", + "caption": "3Speak.tv Acela release via AltStore", + "date": "2022-12-08", + "tintColor": "afeeee", + "imageURL": "https://github.com/spknetwork/Android-App/releases/download/v1.0.3_48/AppIcon-512.png", + "url": "https://ecency.com/hive-181335/@sagarkothari88/3speak-development-update-from-sagarkothari88-7cd51a3b88448", + "appID": "com.example.acela", + "notify": true + } + ] +} diff --git a/lib/generated_plugin_registrant.dart b/lib/generated_plugin_registrant.dart index 7552fd6b..88732597 100644 --- a/lib/generated_plugin_registrant.dart +++ b/lib/generated_plugin_registrant.dart @@ -4,17 +4,26 @@ // ignore_for_file: directives_ordering // ignore_for_file: lines_longer_than_80_chars +// ignore_for_file: depend_on_referenced_packages -import 'package:firebase_core_web/firebase_core_web.dart'; +import 'package:file_picker/_internal/file_picker_web.dart'; +import 'package:flutter_secure_storage_web/flutter_secure_storage_web.dart'; +import 'package:image_picker_for_web/image_picker_for_web.dart'; +import 'package:url_launcher_web/url_launcher_web.dart'; import 'package:video_player_web/video_player_web.dart'; import 'package:video_player_web_hls/video_player_web_hls.dart'; +import 'package:wakelock_web/wakelock_web.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; // ignore: public_member_api_docs void registerPlugins(Registrar registrar) { - FirebaseCoreWeb.registerWith(registrar); + FilePickerWeb.registerWith(registrar); + FlutterSecureStorageWeb.registerWith(registrar); + ImagePickerPlugin.registerWith(registrar); + UrlLauncherPlugin.registerWith(registrar); VideoPlayerPlugin.registerWith(registrar); VideoPlayerPluginHls.registerWith(registrar); + WakelockWeb.registerWith(registrar); registrar.registerMessageHandler(); } diff --git a/lib/main.dart b/lib/main.dart index 77a47a90..e4dcd0fc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,42 +1,226 @@ -import 'package:acela/src/screens/home_screen/home_screen.dart'; -import 'package:acela/src/screens/video_details_screen/video_details_screen.dart'; -import 'package:firebase_core/firebase_core.dart'; +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/global_provider/image_resolution_provider.dart'; +import 'package:acela/src/global_provider/video_setting_provider.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/podcast/controller/podcast_controller.dart'; +import 'package:acela/src/screens/podcast/controller/podcast_player_controller.dart'; +import 'package:acela/src/screens/report/controller/report_controller.dart'; +import 'package:acela/src/screens/upload/video/controller/video_upload_controller.dart'; +import 'package:acela/src/utils/graphql/gql_communicator.dart'; +import 'package:acela/src/utils/routes/app_router.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:overlay_support/overlay_support.dart'; +import 'package:provider/provider.dart'; +import 'dart:async'; +import 'package:flutter_downloader/flutter_downloader.dart'; +import 'package:audio_service/audio_service.dart'; +import 'package:upgrader/upgrader.dart'; +import 'src/screens/podcast/widgets/audio_player/audio_player_core_controls.dart'; Future main() async { + await dotenv.load(fileName: "dotenv"); WidgetsFlutterBinding.ensureInitialized(); - runApp(MyApp()); + await GetStorage.init(); + await FlutterDownloader.initialize( + debug: true, + ignoreSsl: true, + ); + GetAudioPlayer getAudioPlayer = GetAudioPlayer(); + getAudioPlayer.audioHandler = await AudioService.init( + builder: () => AudioPlayerHandlerImpl(), + config: const AudioServiceConfig( + androidNotificationChannelId: 'com.ryanheise.myapp.channel.audio', + androidNotificationChannelName: 'Audio playback', + androidNotificationOngoing: true, + ), + ); + // await Upgrader.clearSavedSettings(); // for debugging + await Upgrader.sharedInstance.initialize(); + runApp(const MyApp()); + +} + +class MyApp extends StatefulWidget { + const MyApp({Key? key}) : super(key: key); + + @override + _MyAppState createState() => _MyAppState(); } -class MyApp extends StatelessWidget { - final Future _fbApp = Firebase.initializeApp(); +class _MyAppState extends State { + late final Future _futureToLoadData; Widget futureBuilder(Widget withWidget) { return FutureBuilder( - future: _fbApp, - builder: (context, snapshot) { - if (snapshot.hasError) { - return const Text('Firebase not initialized'); - } else if (snapshot.hasData) { - return withWidget; - } else { - return const CircularProgressIndicator(); - } - }); + future: _futureToLoadData, + builder: (context, snapshot) { + if (snapshot.hasError) { + return const Text('Firebase not initialized'); + } else if (snapshot.connectionState == ConnectionState.done) { + return withWidget; + } else { + return MaterialApp( + title: '3Speak', + home: Scaffold( + appBar: AppBar(title: const Text('3Speak')), + body: const Center( + child: CircularProgressIndicator(), + ), + ), + ); + } + }, + ); } // This widget is the root of your application. @override Widget build(BuildContext context) { - return MaterialApp( - title: 'Acela - 3Speak App', - theme: ThemeData.dark(), - routes: { - VideoDetailsScreen.routeName: (context) => - futureBuilder(const VideoDetailsScreen()), - '/': (context) => futureBuilder(const HomeScreen()), + return MultiProvider( + providers: [ + StreamProvider.value( + value: server.hiveUserData, + initialData: HiveUserData( + resolution: '480p', + keychainData: null, + accessToken: null, + postingKey: null, + username: null, + cookie: null, + postingAuthority: null, + rpc: 'api.hive.blog', + union: GQLCommunicator.defaultGQLServer, + loaded: false, + language: null, + ),), + ChangeNotifierProxyProvider( + lazy: false, + create: (_) => ReportController(), + update: (_, hiveUserData, reportController) { + reportController?.updateHiveUserData(hiveUserData); + return reportController!; }, - // home: const HomeScreen(), + ), + + ChangeNotifierProvider( + lazy: false, + create: (context) => PodcastController(), + ), + ChangeNotifierProvider( + lazy: false, create: (context) => VideoSettingProvider()), + ChangeNotifierProvider( + lazy: false, + create: (context) => SettingsProvider(), + ), + ChangeNotifierProvider( + create: (context) => PodcastPlayerController(), + ), + ChangeNotifierProxyProvider( + create: (_) => VideoUploadController(null), + update: (_, hiveUserData, videoUploadController)=> VideoUploadController(hiveUserData.username), + ), + ], + child: OverlaySupport.global( + child: futureBuilder( + StreamProvider.value( + value: server.theme, + initialData: true, + child: const AcelaApp(), + ), + ), + + ), + ); + } + + @override + void initState() { + super.initState(); + _futureToLoadData = loadData(); + } + + Future loadData() async { + const storage = FlutterSecureStorage(); + String? username = await storage.read(key: 'username'); + String? postingKey = await storage.read(key: 'postingKey'); + String? cookie = await storage.read(key: 'cookie'); + String? accessToken = await storage.read(key: 'accessToken'); + String? hasId = await storage.read(key: 'hasId'); + String? hasExpiry = await storage.read(key: 'hasExpiry'); + String? hasAuthKey = await storage.read(key: 'hasAuthKey'); + String resolution = await storage.read(key: 'resolution') ?? '480p'; + String rpc = await storage.read(key: 'rpc') ?? 'api.hive.blog'; + String? postingAuth = await storage.read(key: 'postingAuth'); + String union = + await storage.read(key: 'union') ?? GQLCommunicator.defaultGQLServer; + if (union == 'threespeak-union-graph-ql.sagarkothari88.one') { + await storage.write( + key: 'union', value: GQLCommunicator.defaultGQLServer); + union = GQLCommunicator.defaultGQLServer; + } + String? lang = await storage.read(key: 'lang'); + server.updateHiveUserData( + HiveUserData( + username: username, + postingKey: postingKey, + keychainData: hasId != null && + hasId.isNotEmpty && + hasExpiry != null && + hasExpiry.isNotEmpty && + hasAuthKey != null && + hasAuthKey.isNotEmpty + ? HiveKeychainData( + hasAuthKey: hasAuthKey, + hasExpiry: hasExpiry, + hasId: hasId, + ) + : null, + cookie: cookie, + accessToken: accessToken, + postingAuthority: postingAuth, + resolution: resolution, + rpc: rpc, + union: union, + loaded: true, + language: lang, + ), + ); + } +} + +class AcelaApp extends StatelessWidget { + const AcelaApp({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + var isDarkMode = Provider.of(context); + return MaterialApp.router( + title: 'Acela - 3Speak App', + routerConfig: AppRouter.router, + theme: isDarkMode + ? ThemeData.dark().copyWith( + primaryColor: Colors.deepPurple, + primaryColorLight: Colors.white, + primaryColorDark: Colors.black, + scaffoldBackgroundColor: Colors.black, + cardTheme: CardTheme( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4))), + color: Colors.grey.shade900), + ) + : ThemeData.light().copyWith( + primaryColor: Colors.deepPurple, + primaryColorLight: Colors.black, + primaryColorDark: Colors.white, + cardTheme: CardTheme( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4))), + color: Colors.grey.shade200), + ), + debugShowCheckedModeBanner: false, ); } } diff --git a/lib/src/bloc/server.dart b/lib/src/bloc/server.dart index 3658dbe0..765728ec 100644 --- a/lib/src/bloc/server.dart +++ b/lib/src/bloc/server.dart @@ -1,11 +1,44 @@ +import 'dart:async'; + +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; class Server { final String domain = "https://3speak.tv"; + String userOwnerThumb(String value) { return "https://images.hive.blog/u/$value/avatar"; } - final String hiveDomain = "https://api.hive.blog"; + String userChannelCover(String value) { + return "https://img.3speakcontent.co/user/$value/cover.png"; + } + + String communityIcon(String value) { + return "https://images.hive.blog/u/$value/avatar?size=icon"; + } + + String resizedImage(String value) { + return "https://images.hive.blog/640x0/$value"; + } + + final _controller = StreamController(); + final _hiveUserDataController = StreamController(); + + Stream get theme { + return _controller.stream; + } + + Stream get hiveUserData { + return _hiveUserDataController.stream; + } + + void changeTheme(bool value) async { + _controller.sink.add(!value); + } + + void updateHiveUserData(HiveUserData data) { + _hiveUserDataController.sink.add(data); + } } -Server server = Server(); \ No newline at end of file +Server server = Server(); diff --git a/lib/src/extensions/ui.dart b/lib/src/extensions/ui.dart new file mode 100644 index 00000000..624c9f05 --- /dev/null +++ b/lib/src/extensions/ui.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +extension UI on BuildContext { + PageRouteBuilder fadePageRoute(Widget screen) { + return PageRouteBuilder( + fullscreenDialog: false, + opaque: false, + barrierColor: Colors.black.withOpacity(0.9), + barrierDismissible: true, + transitionDuration: const Duration(milliseconds: 200), + reverseTransitionDuration: const Duration(milliseconds: 200), + pageBuilder: (BuildContext context, _, __) { + return screen; + }, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: animation, + child: child, + ); + }, + ); + } + + void showSnackBar(String message) { + ScaffoldMessenger.of(this).hideCurrentSnackBar(); + ScaffoldMessenger.of(this).showSnackBar(SnackBar( + content: Text( + message, + textAlign: TextAlign.center, + ), + duration: const Duration(seconds: 3), + )); + } + + PopScope _loader(BuildContext context, bool canPop) { + return PopScope( + canPop: canPop, + child: MediaQuery.removeViewInsets( + removeLeft: true, + removeTop: true, + removeRight: true, + removeBottom: true, + context: context, + child: Container( + color: Colors.transparent, + alignment: Alignment.center, + child: ConstrainedBox( + constraints: BoxConstraints.tight( + const Size.fromRadius(60), + ), + child: Center( + child: CircularProgressIndicator( + color: Theme.of(context).primaryColor, + strokeWidth: 4, + ), + ), + ), + ), + ), + ); + } + + void showLoader({bool canPop = false}) { + showDialog( + context: this, + useRootNavigator: true, + barrierDismissible: false, + builder: (context) => _loader(context, canPop), + ); + } + + void hideLoader() { + Navigator.of(this).pop(); + } + + void copyToClipbaord(text, {String? successMessage}) { + Clipboard.setData(ClipboardData(text: text)).then((_) { + if (successMessage != null) { + showSnackBar(successMessage); + } + }); + } +} diff --git a/lib/src/global_provider/image_resolution_provider.dart b/lib/src/global_provider/image_resolution_provider.dart new file mode 100644 index 00000000..50379a20 --- /dev/null +++ b/lib/src/global_provider/image_resolution_provider.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:get_storage/get_storage.dart'; + +class SettingsProvider extends ChangeNotifier { + late String _resolution; + late bool _autoPlayVideo; + String _resolutionKey = 'videoImageResolution'; + String _autoPlayVideoKey = 'autoPlayVideo'; + GetStorage _storage = GetStorage(); + + SettingsProvider() { + _init(); + } + + void _init() { + _resolution = _storage.read(_resolutionKey) ?? Resolution.r480; + _autoPlayVideo = _storage.read(_autoPlayVideoKey) ?? true; + } + + set resolution(String newResolution) { + if (newResolution != _resolution) { + _resolution = newResolution; + _storage.write(_resolutionKey, newResolution); + notifyListeners(); + } + } + + String get resolution { + String resolutionString = _resolution.toString().replaceAll('r', ''); + return resolutionString; + } + + set autoPlayVideo(bool status) { + if (status != _autoPlayVideo) { + _autoPlayVideo = status; + _storage.write(_autoPlayVideoKey, status); + notifyListeners(); + } + } + + bool get autoPlayVideo { + return _autoPlayVideo; + } +} + +class Resolution { + static String r360 = '360p'; + static String r480 = '480p'; + static String r720 = '720p'; + static String r1080 = '1080p'; + + static removePFromResolution(String resolution) { + String actualResolution = resolution.replaceAll('p', ''); + return actualResolution; + } +} diff --git a/lib/src/global_provider/ipfs_node_provider.dart b/lib/src/global_provider/ipfs_node_provider.dart new file mode 100644 index 00000000..228b2475 --- /dev/null +++ b/lib/src/global_provider/ipfs_node_provider.dart @@ -0,0 +1,31 @@ +import 'package:get_storage/get_storage.dart'; + +class IpfsNodeProvider{ + String storageKey = 'ipfsNode'; + late String nodeUrl; + GetStorage _storage = GetStorage(); + String defaultIpfsNode = 'https://ipfs-3speak.b-cdn.net/ipfs/'; + +static IpfsNodeProvider? _instance; + IpfsNodeProvider._(); + + static IpfsNodeProvider get instance { + _instance ??= IpfsNodeProvider._(); + return _instance!; + } + IpfsNodeProvider() { + _init(); + } + + void _init() { + nodeUrl = _storage.read(storageKey) ?? defaultIpfsNode; + } + + void changeIpfsNode(String newUrl) { + if (newUrl != nodeUrl) { + nodeUrl = newUrl; + _storage.write(storageKey, newUrl); + } + } +} + diff --git a/lib/src/global_provider/video_setting_provider.dart b/lib/src/global_provider/video_setting_provider.dart new file mode 100644 index 00000000..87f68de2 --- /dev/null +++ b/lib/src/global_provider/video_setting_provider.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class VideoSettingProvider extends ChangeNotifier { + bool isMuted = true; + + void changeMuteStatus(bool value) { + isMuted = value; + notifyListeners(); + } +} diff --git a/lib/src/models/action_response.dart b/lib/src/models/action_response.dart new file mode 100644 index 00000000..b19286b1 --- /dev/null +++ b/lib/src/models/action_response.dart @@ -0,0 +1,28 @@ +import 'dart:convert'; + +class ActionResponse { + final String? type; + final String data; + final bool valid; + final String error; + + ActionResponse({ + this.type, + required this.data, + required this.valid, + required this.error, + }); + + factory ActionResponse.fromJsonString(String string,) => + ActionResponse.fromJson(json.decode(string),); + + factory ActionResponse.fromJson( + Map json,) { + return ActionResponse( + type: json['type'] as String, + data: (json['data'] ), + valid: json['valid'] as bool, + error: json['error'] as String, + ); + } +} diff --git a/lib/src/models/communities_models/request/communities_request_model.dart b/lib/src/models/communities_models/request/communities_request_model.dart new file mode 100644 index 00000000..56bd9bf9 --- /dev/null +++ b/lib/src/models/communities_models/request/communities_request_model.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; + +import 'package:acela/src/utils/safe_convert.dart'; + +class CommunitiesRequestModel { + final CommunitiesRequestParams params; + + // 2.0 + final String jsonrpc; + + // bridge.list_communities + final String method; + + // 1 + final int id; + + CommunitiesRequestModel({ + required this.params, + this.jsonrpc = "2.0", + this.method = "bridge.list_communities", + this.id = 1, + }); + + factory CommunitiesRequestModel.fromJson(Map? json) => + CommunitiesRequestModel( + params: CommunitiesRequestParams.fromJson(asMap(json, 'params')), + jsonrpc: asString(json, 'jsonrpc'), + method: asString(json, 'method'), + id: asInt(json, 'id'), + ); + + Map toJson() => { + 'params': params.toJson(), + 'jsonrpc': jsonrpc, + 'method': method, + 'id': id, + }; + + String toJsonString() => json.encode(toJson()); +} + +class CommunitiesRequestParams { + // 100 + final int limit; + final String? query; + + CommunitiesRequestParams({ + this.limit = 100, + this.query, + }); + + factory CommunitiesRequestParams.fromJson(Map? json) => + CommunitiesRequestParams( + limit: asInt(json, 'limit'), + ); + + Map toJson() { + Map map = { + 'limit': limit, + }; + if (query != null && query!.isNotEmpty) { + map['query'] = query; + } + return map; + } +} diff --git a/lib/src/models/communities_models/request/community_details_request.dart b/lib/src/models/communities_models/request/community_details_request.dart new file mode 100644 index 00000000..6a04b784 --- /dev/null +++ b/lib/src/models/communities_models/request/community_details_request.dart @@ -0,0 +1,75 @@ +import 'package:acela/src/utils/safe_convert.dart'; +import 'dart:convert'; + +class CommunityDetailsRequest { + final CommunityDetailsRequestParams params; + + // 2.0 + final String jsonrpc; + + // bridge.get_community + final String method; + + // 1 + final int id; + + CommunityDetailsRequest({ + required this.params, + this.jsonrpc = "", + this.method = "", + this.id = 0, + }); + + factory CommunityDetailsRequest.fromJson(Map? json) => + CommunityDetailsRequest( + params: CommunityDetailsRequestParams.fromJson(asMap(json, 'params')), + jsonrpc: asString(json, 'jsonrpc'), + method: asString(json, 'method'), + id: asInt(json, 'id'), + ); + + factory CommunityDetailsRequest.forName(String name) { + return CommunityDetailsRequest( + params: CommunityDetailsRequestParams.forName(name), + jsonrpc: "2.0", + method: "bridge.get_community", + id: 1, + ); + } + + Map toJson() => { + 'params': params.toJson(), + 'jsonrpc': jsonrpc, + 'method': method, + 'id': id, + }; + + String toJsonString() => json.encode(toJson()); +} + +class CommunityDetailsRequestParams { + // hive-167922 + final String name; + + // sagarkothari88 + final String observer; + + CommunityDetailsRequestParams({ + this.name = "", + this.observer = "", + }); + + factory CommunityDetailsRequestParams.fromJson(Map? json) => + CommunityDetailsRequestParams( + name: asString(json, 'name'), + observer: asString(json, 'observer'), + ); + + factory CommunityDetailsRequestParams.forName(String name) => + CommunityDetailsRequestParams(name: name, observer: 'sagarkothari88'); + + Map toJson() => { + 'name': name, + 'observer': observer, + }; +} diff --git a/lib/src/models/communities_models/response/communities_response_models.dart b/lib/src/models/communities_models/response/communities_response_models.dart new file mode 100644 index 00000000..02a506f2 --- /dev/null +++ b/lib/src/models/communities_models/response/communities_response_models.dart @@ -0,0 +1,96 @@ +import 'dart:convert'; + +import 'package:acela/src/utils/safe_convert.dart'; + +CommunitiesResponseModel communitiesResponseModelFromString(String string) { + return CommunitiesResponseModel.fromJson(json.decode(string)); +} + +class CommunitiesResponseModel { + // 2.0 + final String jsonrpc; + final List result; + // 1 + final int id; + + CommunitiesResponseModel({ + this.jsonrpc = "", + required this.result, + this.id = 0, + }); + + factory CommunitiesResponseModel.fromJson(Map? json) => + CommunitiesResponseModel( + jsonrpc: asString(json, 'jsonrpc'), + result: asList(json, 'result') + .map((e) => CommunityItem.fromJson(e)) + .toList(), + id: asInt(json, 'id'), + ); + + Map toJson() => { + 'jsonrpc': jsonrpc, + 'result': result.map((e) => e.toJson()), + 'id': id, + }; +} + +class CommunityItem { + // 1341662 + final int id; + // hive-167922 + final String name; + // LeoFinance + final String title; + // LeoFinance is a community for crypto & finance. Powered by Hive and the LEO token economy. + final String about; + // 1 + final int typeId; + // false + final bool isNsfw; + // 11475 + final int subscribers; + // 29860 + final int sumPending; + final int numAuthors; + final List admins; + + CommunityItem({ + this.id = 0, + this.name = "", + this.title = "", + this.about = "", + this.typeId = 0, + this.isNsfw = false, + this.subscribers = 0, + this.sumPending = 0, + this.numAuthors = 0, + required this.admins, + }); + + factory CommunityItem.fromJson(Map? json) => CommunityItem( + id: asInt(json, 'id'), + name: asString(json, 'name'), + title: asString(json, 'title'), + about: asString(json, 'about'), + typeId: asInt(json, 'type_id'), + isNsfw: asBool(json, 'is_nsfw'), + subscribers: asInt(json, 'subscribers'), + sumPending: asInt(json, 'sum_pending'), + numAuthors: asInt(json, 'num_authors'), + admins: asList(json, 'admins').map((e) => e.toString()).toList(), + ); + + Map toJson() => { + 'id': id, + 'name': name, + 'title': title, + 'about': about, + 'type_id': typeId, + 'is_nsfw': isNsfw, + 'subscribers': subscribers, + 'sum_pending': sumPending, + 'num_authors': numAuthors, + 'admins': admins.map((e) => e), + }; +} diff --git a/lib/src/models/communities_models/response/community_details_response_models.dart b/lib/src/models/communities_models/response/community_details_response_models.dart new file mode 100644 index 00000000..1fcddcdd --- /dev/null +++ b/lib/src/models/communities_models/response/community_details_response_models.dart @@ -0,0 +1,142 @@ +import 'dart:developer'; + +import 'package:acela/src/utils/safe_convert.dart'; +import 'dart:convert'; + +class CommunityDetailsResponse { + // 2.0 + final String jsonrpc; + final CommunityDetailsResponseResult result; + + // 1 + final int id; + + CommunityDetailsResponse({ + this.jsonrpc = "", + required this.result, + this.id = 0, + }); + + factory CommunityDetailsResponse.fromJson(Map? json) => + CommunityDetailsResponse( + jsonrpc: asString(json, 'jsonrpc'), + result: CommunityDetailsResponseResult.fromJson(asMap(json, 'result')), + id: asInt(json, 'id'), + ); + + factory CommunityDetailsResponse.fromString(String string) => + CommunityDetailsResponse.fromJson(json.decode(string)); + + Map toJson() => { + 'jsonrpc': jsonrpc, + 'result': result.toJson(), + 'id': id, + }; +} + +class CommunityDetailsResponseResult { + // 1341662 + final int id; + + // hive-167922 + final String name; + + // LeoFinance + final String title; + + // LeoFinance is a community for crypto & finance. Powered by Hive and the LEO token economy. + final String about; + + // en + final String lang; + + // 1 + final int typeId; + + // false + final bool isNsfw; + + // 12009 + final int subscribers; + + // 2019-11-26 17:25:27 + final String createdAt; + + // 22177 + final int sumPending; + + // 12112 + final int numPending; + + // 1435 + final int numAuthors; + final String avatarUrl; + + // Using our Hive-based token (LEO) we reward content creators and users for engaging on our platform at https://leofinance.io and within our community on the Hive blockchain. Blogging is just the beginning of what's possible in the LeoFinance community and with the LEO token:1). Trade LEO and other Hive-based tokens on our exchange: https://leodex.io2). Track your Hive account statistics at https://hivestats.io3). Opt-in to ads on LEO Apps which drives value back into the LEO token economy from ad buybacks.4). Learn & contribute to our crypto-educational resource at https://leopedia.io5). Wrap LEO onto the Ethereum blockchain with our cross-chain token bridge: https://wleo.io (coming soon)Learn more about us at https://leopedia.io/faq + final String description; + + // Content should be related to the financial space (i.e. crypto, equities, etc. etc.)Posts created from our interface (https://leofinance.io) are eligible for upvotes from @leo.voter and will automatically be posted to our Hive community, our front end and other Hive front ends as wellPosts in our community are also eligible to earn our native token (LEO) in conjunction with HIVE post rewardsIf you have any questions or need help with anything, feel free to reach out to us on twitter (@financeleo) or head over to our discord server (https://discord.gg/KgcVDKQ) + final String flagText; + final List> team; + + CommunityDetailsResponseResult({ + this.id = 0, + this.name = "", + this.title = "", + this.about = "", + this.lang = "", + this.typeId = 0, + this.isNsfw = false, + this.subscribers = 0, + this.createdAt = "", + this.sumPending = 0, + this.numPending = 0, + this.numAuthors = 0, + this.avatarUrl = "", + this.description = "", + this.flagText = "", + required this.team, + }); + + factory CommunityDetailsResponseResult.fromJson(Map? json) => + CommunityDetailsResponseResult( + id: asInt(json, 'id'), + name: asString(json, 'name'), + title: asString(json, 'title'), + about: asString(json, 'about'), + lang: asString(json, 'lang'), + typeId: asInt(json, 'type_id'), + isNsfw: asBool(json, 'is_nsfw'), + subscribers: asInt(json, 'subscribers'), + createdAt: asString(json, 'created_at'), + sumPending: asInt(json, 'sum_pending'), + numPending: asInt(json, 'num_pending'), + numAuthors: asInt(json, 'num_authors'), + avatarUrl: asString(json, 'avatar_url'), + description: asString(json, 'description'), + flagText: asString(json, 'flag_text'), + team: asList(json, 'team').map((e) { + log("Console message goes here"); + return (e as List).map((s) => asDynamicString(s)).toList(); + }).toList(), + ); + + Map toJson() => { + 'id': id, + 'name': name, + 'title': title, + 'about': about, + 'lang': lang, + 'type_id': typeId, + 'is_nsfw': isNsfw, + 'subscribers': subscribers, + 'created_at': createdAt, + 'sum_pending': sumPending, + 'num_pending': numPending, + 'num_authors': numAuthors, + 'avatar_url': avatarUrl, + 'description': description, + 'flag_text': flagText, + 'team': team.map((e) => e), + }; +} diff --git a/lib/src/models/hive_comments/new_hive_comment/new_hive_comment.dart b/lib/src/models/hive_comments/new_hive_comment/new_hive_comment.dart new file mode 100644 index 00000000..4d7c292f --- /dev/null +++ b/lib/src/models/hive_comments/new_hive_comment/new_hive_comment.dart @@ -0,0 +1,136 @@ +import 'dart:convert'; + +import 'package:equatable/equatable.dart'; + +class GQLHiveCommentReponse { + final GQLHiveCommentReponseData data; + + GQLHiveCommentReponse({ + required this.data, + }); + + factory GQLHiveCommentReponse.fromRawJson(String str) => + GQLHiveCommentReponse.fromJson(json.decode(str)); + + factory GQLHiveCommentReponse.fromJson(Map json) => + GQLHiveCommentReponse( + data: GQLHiveCommentReponseData.fromJson(json["data"]), + ); +} + +class GQLHiveCommentReponseData { + final CommentSocialPostModel socialPost; + + GQLHiveCommentReponseData({ + required this.socialPost, + }); + + factory GQLHiveCommentReponseData.fromJson(Map json) => GQLHiveCommentReponseData( + socialPost: CommentSocialPostModel.fromJson(json["socialPost"]), + ); +} + +class CommentSocialPostModel { + final List? children; + final String? body; + + CommentSocialPostModel({ + required this.children, + required this.body, + }); + + factory CommentSocialPostModel.fromJson(Map json) => CommentSocialPostModel( + children:json["children"]!=null ? List.from( + json["children"].map((x) { + if(x!=null){ + return VideoCommentModel.fromJson(x); + } + })) : [], + body: json["body"], + ); +} + +class VideoCommentModel extends Equatable{ + final String? body; + final String permlink; + final DateTime? createdAt; + final VideoCommentAuthorModel author; + final VideoCommentStatsModel? stats; + final List? children; + + VideoCommentModel({ + required this.body, + required this.permlink, + required this.createdAt, + required this.author, + required this.stats, + required this.children, + }); + + VideoCommentModel copyWith({ + int? numVotes, + + }) { + return VideoCommentModel( + body: body, + permlink: permlink, + author: author, + children: children, + createdAt: createdAt, + stats: stats!.copyWith(numVotes : numVotes), + ); + } + + factory VideoCommentModel.fromJson(Map json) => VideoCommentModel( + body: json["body"], + permlink: json["permlink"], + createdAt: DateTime.parse(json["created_at"]), + author: VideoCommentAuthorModel.fromJson(json["author"]), + stats: VideoCommentStatsModel.fromJson(json["stats"]), + children:json["children"]!=null ? List.from(json["children"].map((x) { + if (x != null) { + return VideoCommentModel.fromJson(x); + } + })) : [], + ); + + @override + List get props => [body,permlink,createdAt,author,stats,children]; +} + +class VideoCommentAuthorModel { + final String username; + + VideoCommentAuthorModel({ + required this.username, + }); + + factory VideoCommentAuthorModel.fromJson(Map json) => VideoCommentAuthorModel( + username: json["username"], + ); + +} + +class VideoCommentStatsModel extends Equatable{ + final int? numVotes; + + VideoCommentStatsModel({ + required this.numVotes, + }); + + factory VideoCommentStatsModel.fromJson(Map? json) => VideoCommentStatsModel( + numVotes: json!=null ? json["num_votes"] ?? 0 : 0, + ); + + VideoCommentStatsModel copyWith({ + int? numVotes, + + }) { + return VideoCommentStatsModel( + numVotes: numVotes ?? this.numVotes + ); + } + + @override + List get props => [numVotes]; +} diff --git a/lib/src/models/hive_comments/new_hive_comment/newest_comment_model.dart b/lib/src/models/hive_comments/new_hive_comment/newest_comment_model.dart new file mode 100644 index 00000000..0b6664b3 --- /dev/null +++ b/lib/src/models/hive_comments/new_hive_comment/newest_comment_model.dart @@ -0,0 +1,400 @@ +import 'dart:convert'; + +import 'package:equatable/equatable.dart'; + +class CommentResponseModel { + final String? jsonrpc; + final List comments; + final int? id; + + CommentResponseModel({ + this.jsonrpc, + this.comments = const [], + this.id, + }); + + factory CommentResponseModel.fromRawJson(String str) => + CommentResponseModel.fromJson(json.decode(str)); + + factory CommentResponseModel.fromJson(Map json) => + CommentResponseModel( + jsonrpc: json["jsonrpc"], + comments: _parseComments(json["result"]), + id: json["id"], + ); + + static List _parseComments(Map? json) { + List items = []; + if (json != null) { + int count = 0; + json.forEach((key, value) { + if (count != 0) { + items.add(CommentItemModel.fromJson(value)); + } + count++; + }); + ; + } + return items; + } +} + +// ignore: must_be_immutable +class CommentItemModel extends Equatable { + final int? postId; + final String author; + final String permlink; + final String? category; + final String? title; + final String body; + final CommentMetaDataModel? jsonMetadata; + final DateTime created; + final DateTime? updated; + final int depth; + final int children; + final int? netRshares; + final bool? isPaidout; + final DateTime? payoutAt; + final double? payout; + final String? pendingPayoutValue; + final String? authorPayoutValue; + final String? curatorPayoutValue; + final String? promoted; + final List replies; + final int? reblogs; + final double? authorReputation; + final Stats? stats; + final String? url; + final List? beneficiaries; + final String? maxAcceptedPayout; + final int? percentHbd; + final String? parentAuthor; + final String? parentPermlink; + final List activeVotes; + final List blacklists; + final String? community; + final String? communityTitle; + final String? authorRole; + final String? authorTitle; + var visited = false; + final bool isLocallyAdded; + + CommentItemModel( + {this.postId, + required this.author, + required this.permlink, + this.category, + this.title, + required this.body, + this.jsonMetadata, + required this.created, + this.updated, + required this.depth, + required this.children, + this.netRshares, + this.isPaidout, + this.payoutAt, + this.payout, + this.pendingPayoutValue, + this.authorPayoutValue, + this.curatorPayoutValue, + this.promoted, + this.replies = const [], + this.reblogs, + this.authorReputation, + this.stats, + this.url, + this.beneficiaries, + this.maxAcceptedPayout, + this.percentHbd, + this.parentAuthor, + this.parentPermlink, + this.activeVotes = const [], + this.blacklists = const [], + this.community, + this.communityTitle, + this.authorRole, + this.authorTitle, + this.isLocallyAdded = false}); + + CommentItemModel copyWith({ + int? postId, + String? author, + String? permlink, + String? category, + String? title, + String? body, + CommentMetaDataModel? jsonMetadata, + DateTime? created, + DateTime? updated, + int? depth, + int? children, + int? netRshares, + bool? isPaidout, + DateTime? payoutAt, + double? payout, + String? pendingPayoutValue, + String? authorPayoutValue, + String? curatorPayoutValue, + String? promoted, + List? replies, + int? reblogs, + double? authorReputation, + Stats? stats, + String? url, + List? beneficiaries, + String? maxAcceptedPayout, + int? percentHbd, + String? parentAuthor, + String? parentPermlink, + List? activeVotes, + List? blacklists, + String? community, + String? communityTitle, + String? authorRole, + String? authorTitle, + }) { + return CommentItemModel( + postId: postId ?? this.postId, + author: author ?? this.author, + permlink: permlink ?? this.permlink, + category: category ?? this.category, + title: title ?? this.title, + body: body ?? this.body, + jsonMetadata: jsonMetadata ?? this.jsonMetadata, + created: created ?? this.created, + updated: updated ?? this.updated, + depth: depth ?? this.depth, + children: children ?? this.children, + netRshares: netRshares ?? this.netRshares, + isPaidout: isPaidout ?? this.isPaidout, + payoutAt: payoutAt ?? this.payoutAt, + payout: payout ?? this.payout, + pendingPayoutValue: pendingPayoutValue ?? this.pendingPayoutValue, + authorPayoutValue: authorPayoutValue ?? this.authorPayoutValue, + curatorPayoutValue: curatorPayoutValue ?? this.curatorPayoutValue, + promoted: promoted ?? this.promoted, + replies: replies ?? this.replies, + reblogs: reblogs ?? this.reblogs, + authorReputation: authorReputation ?? this.authorReputation, + stats: stats ?? this.stats, + url: url ?? this.url, + beneficiaries: beneficiaries ?? this.beneficiaries, + maxAcceptedPayout: maxAcceptedPayout ?? this.maxAcceptedPayout, + percentHbd: percentHbd ?? this.percentHbd, + parentAuthor: parentAuthor ?? this.parentAuthor, + parentPermlink: parentPermlink ?? this.parentPermlink, + activeVotes: activeVotes ?? this.activeVotes, + blacklists: blacklists ?? this.blacklists, + community: community ?? this.community, + communityTitle: communityTitle ?? this.communityTitle, + authorRole: authorRole ?? this.authorRole, + authorTitle: authorTitle ?? this.authorTitle, + ); + } + + factory CommentItemModel.fromRawJson(String str) => + CommentItemModel.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory CommentItemModel.fromJson(Map json) => + CommentItemModel( + postId: json["post_id"], + author: json["author"], + permlink: json["permlink"], + category: json["category"], + title: json["title"], + body: json["body"], + jsonMetadata: json["json_metadata"] == null + ? null + : CommentMetaDataModel.fromJson(json["json_metadata"]), + created: DateTime.parse(json["created"]), + updated: + json["updated"] == null ? null : DateTime.parse(json["updated"]), + depth: json["depth"], + children: json["children"], + netRshares: json["net_rshares"], + isPaidout: json["is_paidout"], + payoutAt: json["payout_at"] == null + ? null + : DateTime.parse(json["payout_at"]), + payout: json["payout"]?.toDouble(), + pendingPayoutValue: json["pending_payout_value"], + authorPayoutValue: json["author_payout_value"], + curatorPayoutValue: json["curator_payout_value"], + promoted: json["promoted"], + replies: json["replies"] == null + ? [] + : List.from(json["replies"]!.map((x) => x)), + reblogs: json["reblogs"], + authorReputation: json["author_reputation"]?.toDouble(), + stats: json["stats"] == null ? null : Stats.fromJson(json["stats"]), + url: json["url"], + beneficiaries: json["beneficiaries"] == null + ? [] + : List.from(json["beneficiaries"]!.map((x) => x)), + maxAcceptedPayout: json["max_accepted_payout"], + percentHbd: json["percent_hbd"], + parentAuthor: json["parent_author"], + parentPermlink: json["parent_permlink"], + activeVotes: json["active_votes"] == null + ? [] + : List.from(json["active_votes"]! + .map((x) => CommentActiveVote.fromJson(x))), + blacklists: json["blacklists"] == null + ? [] + : List.from(json["blacklists"]!.map((x) => x)), + community: json["community"], + communityTitle: json["community_title"], + authorRole: json["author_role"], + authorTitle: json["author_title"], + ); + + Map toJson() => { + "post_id": postId, + "author": author, + "permlink": permlink, + "category": category, + "title": title, + "body": body, + "json_metadata": jsonMetadata?.toJson(), + "created": created.toIso8601String(), + "updated": updated?.toIso8601String(), + "depth": depth, + "children": children, + "net_rshares": netRshares, + "is_paidout": isPaidout, + "payout_at": payoutAt?.toIso8601String(), + "payout": payout, + "pending_payout_value": pendingPayoutValue, + "author_payout_value": authorPayoutValue, + "curator_payout_value": curatorPayoutValue, + "promoted": promoted, + "replies": List.from(replies.map((x) => x)), + "reblogs": reblogs, + "author_reputation": authorReputation, + "stats": stats?.toJson(), + "url": url, + "beneficiaries": beneficiaries == null + ? [] + : List.from(beneficiaries!.map((x) => x)), + "max_accepted_payout": maxAcceptedPayout, + "percent_hbd": percentHbd, + "parent_author": parentAuthor, + "parent_permlink": parentPermlink, + "active_votes": List.from(activeVotes!.map((x) => x.toJson())), + "blacklists": List.from(blacklists!.map((x) => x)), + "community": community, + "community_title": communityTitle, + "author_role": authorRole, + "author_title": authorTitle, + }; + + @override + List get props => + [postId, permlink, author, parentPermlink, parentAuthor, created]; +} + +class CommentActiveVote extends Equatable { + final int? rshares; + final String? voter; + + CommentActiveVote({ + this.rshares, + this.voter, + }); + + factory CommentActiveVote.fromRawJson(String str) => + CommentActiveVote.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory CommentActiveVote.fromJson(Map json) => + CommentActiveVote( + rshares: json["rshares"], + voter: json["voter"], + ); + + Map toJson() => { + "rshares": rshares, + "voter": voter, + }; + + @override + List get props => [voter]; +} + +class CommentMetaDataModel { + final List? tags; + final String? app; + + CommentMetaDataModel({ + this.tags, + this.app, + }); + + factory CommentMetaDataModel.fromRawJson(String str) => + CommentMetaDataModel.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory CommentMetaDataModel.fromJson(Map json) => + CommentMetaDataModel( + tags: json["tags"] == null + ? [] + : json['tags'] is String ? [json['tags']] : List.from(json["tags"]!.map((x) => x)), + app: json["app"], + ); + + Map toJson() => { + "tags": tags == null ? [] : List.from(tags!.map((x) => x)), + "app": app, + }; +} + +class Stats { + final bool? hide; + final bool? gray; + final int? totalVotes; + final double? flagWeight; + + Stats({ + this.hide, + this.gray, + this.totalVotes, + this.flagWeight, + }); + + Stats copyWith({ + bool? hide, + bool? gray, + int? totalVotes, + double? flagWeight, + }) { + return Stats( + hide: hide ?? this.hide, + gray: gray ?? this.gray, + totalVotes: totalVotes ?? this.totalVotes, + flagWeight: flagWeight ?? this.flagWeight, + ); + } + + factory Stats.fromRawJson(String str) => Stats.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory Stats.fromJson(Map json) => Stats( + hide: json["hide"], + gray: json["gray"], + totalVotes: json["total_votes"], + flagWeight: json["flag_weight"], + ); + + Map toJson() => { + "hide": hide, + "gray": gray, + "total_votes": totalVotes, + "flag_weight": flagWeight, + }; +} diff --git a/lib/src/models/hive_comments/request/hive_comment_request.dart b/lib/src/models/hive_comments/request/hive_comment_request.dart new file mode 100644 index 00000000..5c4fe0d0 --- /dev/null +++ b/lib/src/models/hive_comments/request/hive_comment_request.dart @@ -0,0 +1,49 @@ +import 'package:acela/src/utils/safe_convert.dart'; +import 'dart:convert'; + +String hiveCommentRequestToJson(HiveCommentRequest data) => + json.encode(data.toJson()); + +class HiveCommentRequest { + final List params; + + // 2.0 + final String jsonrpc; + + // condenser_api.get_content_replies + final String method; + + // 1 + final int id; + + HiveCommentRequest({ + required this.params, + this.jsonrpc = "", + this.method = "", + this.id = 0, + }); + + factory HiveCommentRequest.from(List params) { + return HiveCommentRequest( + params: params, + method: "condenser_api.get_content_replies", + jsonrpc: "2.0", + id: 1, + ); + } + + factory HiveCommentRequest.fromJson(Map? json) => + HiveCommentRequest( + params: asList(json, 'params').map((e) => e.toString()).toList(), + jsonrpc: asString(json, 'jsonrpc'), + method: asString(json, 'method'), + id: asInt(json, 'id'), + ); + + Map toJson() => { + 'params': params.map((e) => e).toList(), + 'jsonrpc': jsonrpc, + 'method': method, + 'id': id, + }; +} diff --git a/lib/src/models/hive_comments/request/hive_comments_request.dart b/lib/src/models/hive_comments/request/hive_comments_request.dart deleted file mode 100644 index 2244bcf4..00000000 --- a/lib/src/models/hive_comments/request/hive_comments_request.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'dart:convert'; - -String hiveCommentsRequestToJson(HiveCommentsRequest data) => json.encode(data.toJson()); - -class HiveCommentsRequest { - HiveCommentsRequest({required this.params}); - List params; - var jsonrpc = "2.0"; - var method = "condenser_api.get_content_replies"; - var id = 1; - - factory HiveCommentsRequest.from(String author, String permlink) { - return HiveCommentsRequest(params: [author, permlink]); - } - - Map toJson() => { - "jsonrpc": jsonrpc, - "method": method, - "id": id, - "params": List.from(params.map((x) => x)), - }; -} - -// {"jsonrpc":"2.0", "method":"condenser_api.get_content_replies", "params":["hiveio", "firstpost"], "id":1} \ No newline at end of file diff --git a/lib/src/models/hive_comments/response/active_vote.dart b/lib/src/models/hive_comments/response/active_vote.dart new file mode 100644 index 00000000..7b5122ae --- /dev/null +++ b/lib/src/models/hive_comments/response/active_vote.dart @@ -0,0 +1,44 @@ +import 'package:acela/src/utils/safe_convert.dart'; + +class ActiveVote { + // 1000 + final int percent; + // 784865040553638 + final int reputation; + // 179995483613 + final int rshares; + // 2022-02-06T04:25:57 + final String time; + // jongolson + final String voter; + // 179995483613 + final int weight; + + ActiveVote({ + this.percent = 0, + this.reputation = 0, + this.rshares = 0, + this.time = "", + this.voter = "", + this.weight = 0, + }); + + factory ActiveVote.fromJson(Map? json) => ActiveVote( + percent: asInt(json, 'percent'), + reputation: asInt(json, 'reputation'), + rshares: asInt(json, 'rshares'), + time: asString(json, 'time'), + voter: asString(json, 'voter'), + weight: asInt(json, 'weight'), + ); + + Map toJson() => { + 'percent': percent, + 'reputation': reputation, + 'rshares': rshares, + 'time': time, + 'voter': voter, + 'weight': weight, + }; +} + diff --git a/lib/src/models/hive_comments/response/hive_comments.dart b/lib/src/models/hive_comments/response/hive_comments.dart index aacadbb0..9259af62 100644 --- a/lib/src/models/hive_comments/response/hive_comments.dart +++ b/lib/src/models/hive_comments/response/hive_comments.dart @@ -1,92 +1,117 @@ -// To parse this JSON data, do -// -// final hiveComments = hiveCommentsFromJson(jsonString); - import 'dart:convert'; -HiveComments hiveCommentsFromJson(String str) => - HiveComments.fromJson(json.decode(str)); +import 'package:acela/src/utils/safe_convert.dart'; + +import 'active_vote.dart'; -String hiveCommentsToJson(HiveComments data) => json.encode(data.toJson()); +HiveComments hiveCommentsFromString(String string) { + return HiveComments.fromJson(json.decode(string)); +} class HiveComments { + final String jsonrpc; + final List result; + final int id; + HiveComments({ + this.jsonrpc = "", required this.result, + this.id = 0, }); - List result; - - factory HiveComments.fromJson(Map json) => HiveComments( + factory HiveComments.fromJson(Map? json) => HiveComments( + jsonrpc: asString(json, 'jsonrpc'), result: - List.from(json["result"].map((x) => HiveComment.fromJson(x))), + asList(json, 'result').map((e) => HiveComment.fromJson(e)).toList(), + id: asInt(json, 'id'), ); Map toJson() => { - "result": List.from(result.map((x) => x.toJson())), + 'jsonrpc': jsonrpc, + 'result': result.map((e) => e.toJson()), + 'id': id, }; } class HiveComment { + final String author; + final String permlink; + final String category; + final String body; + final String created; + final int depth; + final int children; + final String lastPayout; + final String cashoutTime; + final String totalPayoutValue; + final String curatorPayoutValue; + final String pendingPayoutValue; + final String parentAuthor; + final String parentPermlink; + final String url; + final List activeVotes; + final int? authorReputation; + final int? netRshares; + HiveComment({ - required this.author, - required this.permlink, - required this.body, - required this.created, - required this.depth, - required this.children, - required this.pendingPayoutValue, - required this.parentPermlink, + this.author = "", + this.permlink = "", + this.category = "", + this.body = "", + this.created = "", + this.depth = 0, + this.children = 0, + this.lastPayout = "", + this.cashoutTime = "", + this.totalPayoutValue = "", + this.curatorPayoutValue = "", + this.pendingPayoutValue = "", + this.parentAuthor = "", + this.parentPermlink = "", + this.url = "", required this.activeVotes, + required this.authorReputation, + required this.netRshares, }); - String author; - String permlink; - String body; - DateTime created; - int depth; - int children; - String pendingPayoutValue; - String parentPermlink; - List activeVotes; - - factory HiveComment.fromJson(Map json) => HiveComment( - author: json["author"], - permlink: json["permlink"], - body: json["body"], - created: DateTime.parse(json["created"]), - depth: json["depth"], - children: json["children"], - pendingPayoutValue: json["pending_payout_value"], - parentPermlink: json["parent_permlink"], - activeVotes: List.from( - json["active_votes"].map((x) => ActiveVote.fromJson(x))), - ); - - Map toJson() => { - "author": author, - "permlink": permlink, - "body": body, - "created": created.toIso8601String(), - "depth": depth, - "children": children, - "pending_payout_value": pendingPayoutValue, - "parent_permlink": parentPermlink, - "active_votes": List.from(activeVotes.map((x) => x.toJson())), - }; -} - -class ActiveVote { - ActiveVote({ - required this.percent, - }); - - int percent; + DateTime? get createdAt { + return DateTime.tryParse(created); + } - factory ActiveVote.fromJson(Map json) => ActiveVote( - percent: json["percent"], + factory HiveComment.fromJson(Map? json) => HiveComment( + author: asString(json, 'author'), + permlink: asString(json, 'permlink'), + category: asString(json, 'category'), + body: asString(json, 'body'), + created: asString(json, 'created'), + depth: asInt(json, 'depth'), + children: asInt(json, 'children'), + lastPayout: asString(json, 'last_payout'), + totalPayoutValue: asString(json, 'total_payout_value'), + pendingPayoutValue: asString(json, 'pending_payout_value'), + parentAuthor: asString(json, 'parent_author'), + parentPermlink: asString(json, 'parent_permlink'), + url: asString(json, 'url'), + authorReputation: asInt(json, 'author_reputation'), + netRshares: asInt(json, 'net_rshares'), + activeVotes: asList(json, 'active_votes') + .map((e) => ActiveVote.fromJson(json)) + .toList(), ); Map toJson() => { - "percent": percent, + 'author': author, + 'permlink': permlink, + 'category': category, + 'body': body, + 'created': created, + 'depth': depth, + 'children': children, + 'last_payout': lastPayout, + 'total_payout_value': totalPayoutValue, + 'pending_payout_value': pendingPayoutValue, + 'parent_author': parentAuthor, + 'url': url, + 'active_votes': activeVotes.map((e) => e), }; } diff --git a/lib/src/models/hive_post_info/hive_post_info.dart b/lib/src/models/hive_post_info/hive_post_info.dart new file mode 100644 index 00000000..456b1f59 --- /dev/null +++ b/lib/src/models/hive_post_info/hive_post_info.dart @@ -0,0 +1,103 @@ +import 'dart:convert'; + +import 'package:acela/src/utils/safe_convert.dart'; +import 'package:equatable/equatable.dart'; + +class HivePostInfo { + final String jsonrpc; + final HivePostInfoResult result; + final int id; + + HivePostInfo({ + this.jsonrpc = "", + required this.result, + this.id = 0, + }); + + factory HivePostInfo.fromJson(Map? json) => HivePostInfo( + jsonrpc: asString(json, 'jsonrpc'), + result: HivePostInfoResult.fromJson(asMap(json, 'result')), + id: asInt(json, 'id'), + ); + + factory HivePostInfo.fromJsonString(String jsonString) => + HivePostInfo.fromJson(json.decode(jsonString)); +} + +class HivePostInfoResult { + final List resultData; + + HivePostInfoResult({ + required this.resultData, + }); + + factory HivePostInfoResult.fromJson(Map? json) { + var result = json?.keys.map((e) => HivePostInfoPostResultBody.fromJson( + json[e] as Map)) ?? + []; + return HivePostInfoResult(resultData: result.toList()); + } +} + +class HivePostInfoPostResultBody { + final double payout; + final List activeVotes; + final String permlink; + + HivePostInfoPostResultBody({ + required this.payout, + required this.activeVotes, + required this.permlink, + }); + + HivePostInfoPostResultBody copyWith({ + double? payout, + List? activeVotes, + String? permlink, + }) { + return HivePostInfoPostResultBody( + payout: payout ?? this.payout, + activeVotes: activeVotes ?? this.activeVotes, + permlink: permlink ?? this.permlink, + ); + } + + factory HivePostInfoPostResultBody.fromJson(Map? json) => + HivePostInfoPostResultBody( + payout: asDouble(json, 'payout'), + permlink: asString(json, 'permlink'), + activeVotes: asList(json, 'active_votes') + .map((e) => ActiveVotesItem.fromJson(e)) + .toList(), + ); + + Map toJson() => { + 'payout': payout, + 'permlink': permlink, + 'active_votes': activeVotes.map((e) => e.toJson()), + }; +} + +class ActiveVotesItem extends Equatable { + final int rshares; + final String voter; + + const ActiveVotesItem({ + this.rshares = 0, + this.voter = "", + }); + + factory ActiveVotesItem.fromJson(Map? json) => + ActiveVotesItem( + rshares: asInt(json, 'rshares'), + voter: asString(json, 'voter'), + ); + + Map toJson() => { + 'rshares': rshares, + 'voter': voter, + }; + + @override + List get props => [voter]; +} diff --git a/lib/src/models/hive_post_info/hive_user_posting_key.dart b/lib/src/models/hive_post_info/hive_user_posting_key.dart new file mode 100644 index 00000000..c60c3b5a --- /dev/null +++ b/lib/src/models/hive_post_info/hive_user_posting_key.dart @@ -0,0 +1,26 @@ +import 'dart:convert'; + +import 'package:acela/src/utils/safe_convert.dart'; + +class HiveUserPostingKey { + final String publicPostingKey; + + HiveUserPostingKey({ + required this.publicPostingKey, + }); + + factory HiveUserPostingKey.fromJson(Map? json) { + var resultMap = asMap(json, 'result'); + var accounts = asList(resultMap, 'accounts'); + if (accounts.isEmpty) throw 'accounts is empty'; + var postingMap = asMap(accounts[0], 'posting'); + var keyAuthsTopLevel = asList(postingMap, 'key_auths'); + if (keyAuthsTopLevel.isEmpty) throw 'Posting Key auths top level empty'; + var firstKeyAuth = keyAuthsTopLevel[0] as List; + var postingPublicKey = firstKeyAuth[0] as String; + return HiveUserPostingKey(publicPostingKey: postingPublicKey); + } + + factory HiveUserPostingKey.fromString(String string) => + HiveUserPostingKey.fromJson(json.decode(string)); +} diff --git a/lib/src/models/home_screen_feed_models/hive_payout_response.dart b/lib/src/models/home_screen_feed_models/hive_payout_response.dart new file mode 100644 index 00000000..cff10fba --- /dev/null +++ b/lib/src/models/home_screen_feed_models/hive_payout_response.dart @@ -0,0 +1,49 @@ +import 'dart:convert'; +import 'package:acela/src/utils/safe_convert.dart'; + +class HivePayoutResponse { + final String jsonrpc; + final HivePayoutResponseResult result; + + HivePayoutResponse({ + this.jsonrpc = "", + required this.result, + }); + + factory HivePayoutResponse.fromJson(Map? json) => HivePayoutResponse( + jsonrpc: asString(json, 'jsonrpc'), + result: HivePayoutResponseResult.fromJson(asMap(json, 'result')), + ); + + factory HivePayoutResponse.fromJsonString(String jsonString) => HivePayoutResponse.fromJson(json.decode(jsonString)); + + Map toJson() => { + 'jsonrpc': jsonrpc, + 'result': result.toJson(), + }; +} + +class HivePayoutResponseResult { + final String totalPayoutValue; + final String curatorPayoutValue; + final String pendingPayoutValue; + + HivePayoutResponseResult({ + this.totalPayoutValue = "", + this.curatorPayoutValue = "", + this.pendingPayoutValue = "", + }); + + factory HivePayoutResponseResult.fromJson(Map? json) => HivePayoutResponseResult( + totalPayoutValue: asString(json, 'total_payout_value'), + curatorPayoutValue: asString(json, 'curator_payout_value'), + pendingPayoutValue: asString(json, 'pending_payout_value'), + ); + + Map toJson() => { + 'total_payout_value': totalPayoutValue, + 'curator_payout_value': curatorPayoutValue, + 'pending_payout_value': pendingPayoutValue, + }; +} + diff --git a/lib/src/models/home_screen_feed_models/home_feed.dart b/lib/src/models/home_screen_feed_models/home_feed.dart new file mode 100644 index 00000000..fb8da12f --- /dev/null +++ b/lib/src/models/home_screen_feed_models/home_feed.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; + +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/utils/safe_convert.dart'; + +// final jsonList = json.decode(jsonStr) as List; +// final list = jsonList.map((e) => HomeFeedItem.fromJson(e)).toList(); + +List homeFeedItemFromString(String string) { + final jsonList = json.decode(string) as List; + final list = jsonList.map((e) => HomeFeedItem.fromJson(e)).toList(); + return list; +} + +class PayoutInfo { + double? payout; + int? upVotes; + int? downVotes; + PayoutInfo({ + required this.payout, + required this.upVotes, + required this.downVotes, + }); +} + +class HomeFeedItem { + final String created; + final String language; + final int views; + final String author; + final String permlink; + final String title; + final double duration; + final bool isNsfw; + final List tags; + final bool isIpfs; + final String playUrl; + final String ipfs; + final HomeFeedItemImage images; + final bool isShorts; + + HomeFeedItem({ + this.created = "", + this.language = "", + this.views = 0, + this.author = "", + this.permlink = "", + this.title = "", + this.duration = 0.0, + this.isNsfw = false, + required this.tags, + this.isIpfs = false, + this.playUrl = "", + this.ipfs = "", + required this.images, + required this.isShorts, + }); + + String getVideoUrl(HiveUserData data) { + // return playUrl; + if (playUrl.contains('ipfs')) { + // example + // https://ipfs-3speak.b-cdn.net/ipfs/QmTRDJcgtt66pxs3ZnQCdRw57b69NS2TQvF4yHwaux5grT/manifest.m3u8 + // https://ipfs-3speak.b-cdn.net/ipfs/QmTRDJcgtt66pxs3ZnQCdRw57b69NS2TQvF4yHwaux5grT/480p/index.m3u8 + return playUrl.replaceAll('manifest', '${data.resolution}/index'); + } else { + // example + // https://threespeakvideo.b-cdn.net/chjwguvd/default.m3u8 + // https://threespeakvideo.b-cdn.net/chjwguvd/480p.m3u8 + return playUrl.replaceAll('default', '${data.resolution}'); + } + } + + DateTime? get createdAt { + return DateTime.tryParse(created); + } + + factory HomeFeedItem.fromJson(Map? json) => HomeFeedItem( + created: asString(json, 'created'), + language: asString(json, 'language'), + views: asInt(json, 'views'), + author: asString(json, 'author'), + permlink: asString(json, 'permlink'), + title: asString(json, 'title'), + duration: asDouble(json, 'duration'), + isNsfw: asBool(json, 'isNsfw'), + tags: asList(json, 'tags').map((e) => e.toString()).toList(), + isIpfs: asBool(json, 'isIpfs'), + playUrl: asString(json, 'playUrl'), + ipfs: asString(json, 'ipfs'), + isShorts: asBool(json, 'isShorts'), + images: HomeFeedItemImage.fromJson(asMap(json, 'images')), + ); + + Map toJson() => { + 'created': created, + 'language': language, + 'views': views, + 'author': author, + 'permlink': permlink, + 'title': title, + 'duration': duration, + 'isNsfw': isNsfw, + 'tags': tags.map((e) => e), + 'isIpfs': isIpfs, + 'playUrl': playUrl, + 'ipfs': ipfs, + 'isShorts': isShorts, + 'images': images.toJson(), + }; +} + +class HomeFeedItemImage { + final String ipfsThumbnail; + final String thumbnail; + + HomeFeedItemImage({ + this.ipfsThumbnail = "", + this.thumbnail = "", + }); + + factory HomeFeedItemImage.fromJson(Map? json) => + HomeFeedItemImage( + ipfsThumbnail: asString(json, 'ipfs_thumbnail'), + thumbnail: asString(json, 'thumbnail'), + ); + + Map toJson() => { + 'ipfs_thumbnail': ipfsThumbnail, + 'thumbnail': thumbnail, + }; +} diff --git a/lib/src/models/home_screen_feed_models/home_feed_models.dart b/lib/src/models/home_screen_feed_models/home_feed_models.dart deleted file mode 100644 index 21e02279..00000000 --- a/lib/src/models/home_screen_feed_models/home_feed_models.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'dart:convert'; - -List homeFeedFromJson(String str) => List.from(json.decode(str).map((x) => HomeFeed.fromJson(x))); - -String homeFeedToJson(List data) => json.encode(List.from(data.map((x) => x.toJson()))); - -class HomeFeed { - HomeFeed({ - required this.created, - required this.views, - required this.owner, - required this.permlink, - required this.title, - required this.duration, - required this.isNsfwContent, - required this.tags_v2, - required this.thumbUrl, - required this.baseThumbUrl, - this.ipfs, - }); - - DateTime created; - int views; - String owner; - String permlink; - String title; - double duration; - bool isNsfwContent; - List tags_v2; - String? ipfs; - String thumbUrl; - String baseThumbUrl; - - factory HomeFeed.fromJson(Map json) { - final created = json['created'] as String?; - if (created == null) { - throw UnsupportedError('Invalid data: $json -> "created" is missing'); - } - final int? views = json['views'] as int?; - if (views == null) { - throw UnsupportedError('Invalid data: $json -> "views" is missing'); - } - final owner = json['owner'] as String?; - if (owner == null) { - throw UnsupportedError('Invalid data: $json -> "owner" is missing'); - } - final permlink = json['permlink'] as String?; - if (permlink == null) { - throw UnsupportedError('Invalid data: $json -> "permlink" is missing'); - } - final title = json['title'] as String?; - if (title == null) { - throw UnsupportedError('Invalid data: $json -> "title" is missing'); - } - final double? duration = double.parse(json['duration'].toString()); - if (duration == null) { - throw UnsupportedError('Invalid data: $json -> "duration" is missing'); - } - final bool? isNsfwContent = json['isNsfwContent'] as bool?; - if (isNsfwContent == null) { - throw UnsupportedError('Invalid data: $json -> "isNsfwContent" is missing'); - } - final thumbUrl = json['thumbUrl'] as String?; - if (thumbUrl == null) { - throw UnsupportedError('Invalid data: $json -> "thumbUrl" is missing'); - } - final baseThumbUrl = json['baseThumbUrl'] as String?; - if (baseThumbUrl == null) { - throw UnsupportedError('Invalid data: $json -> "baseThumbUrl" is missing'); - } - final ipfs = json['ipfs'] as String?; - return HomeFeed( - created: DateTime.parse(created), - views: views, - owner: owner, - permlink: permlink, - title: title, - duration: duration, - isNsfwContent: isNsfwContent, - tags_v2: json["tags_v2"] == null ? [] : List.from(json["tags_v2"].map((x) => x)), - ipfs: ipfs, - thumbUrl: thumbUrl, - baseThumbUrl: baseThumbUrl, - ); - } - - Map toJson() => { - "created": created.toIso8601String(), - "views": views, - "owner": owner, - "permlink": permlink, - "title": title, - "duration": duration, - "isNsfwContent": isNsfwContent, - "tags_v2": List.from(tags_v2.map((x) => x)), - "thumbUrl": thumbUrl, - "baseThumbUrl": baseThumbUrl, - "ipfs": ipfs, - }; -} \ No newline at end of file diff --git a/lib/src/models/leaderboard_models/leaderboard_model.dart b/lib/src/models/leaderboard_models/leaderboard_model.dart new file mode 100644 index 00000000..52b80b92 --- /dev/null +++ b/lib/src/models/leaderboard_models/leaderboard_model.dart @@ -0,0 +1,39 @@ +import 'dart:convert'; +import 'package:acela/src/utils/safe_convert.dart'; + +List leaderboardResponseItemFromString(String string) { + final jsonList = json.decode(string) as List; + final list = + jsonList.map((e) => LeaderboardResponseItem.fromJson(e)).toList(); + return list; +} + +class LeaderboardResponseItem { + // 735 + final int rank; + + // 667.6 + final double score; + + // mrosenquist1 + final String username; + + LeaderboardResponseItem({ + this.rank = 0, + this.score = 0.0, + this.username = "", + }); + + factory LeaderboardResponseItem.fromJson(Map? json) => + LeaderboardResponseItem( + rank: asInt(json, 'rank'), + score: asDouble(json, 'score'), + username: asString(json, 'username'), + ); + + Map toJson() => { + 'rank': rank, + 'score': score, + 'username': username, + }; +} diff --git a/lib/src/models/login/login_bridge_response.dart b/lib/src/models/login/login_bridge_response.dart new file mode 100644 index 00000000..2c96b27c --- /dev/null +++ b/lib/src/models/login/login_bridge_response.dart @@ -0,0 +1,35 @@ +import 'dart:convert'; + +import 'package:acela/src/utils/safe_convert.dart'; + +class LoginBridgeResponse { + final bool valid; + final String? accountName; + final String error; + final String? data; + + LoginBridgeResponse({ + required this.valid, + required this.accountName, + required this.error, + required this.data, + }); + + factory LoginBridgeResponse.fromJson(Map? json) => + LoginBridgeResponse( + valid: asBool(json, 'valid'), + accountName: asString(json, 'accountName'), + error: asString(json, 'error'), + data: asString(json, 'data'), + ); + + factory LoginBridgeResponse.fromJsonString(String jsonString) => + LoginBridgeResponse.fromJson(json.decode(jsonString)); + + Map toJson() => { + 'valid': valid, + 'accountName': accountName, + 'error': error, + 'data': data, + }; +} diff --git a/lib/src/models/login/memo_response.dart b/lib/src/models/login/memo_response.dart new file mode 100644 index 00000000..31201a05 --- /dev/null +++ b/lib/src/models/login/memo_response.dart @@ -0,0 +1,30 @@ +import 'dart:convert'; + +import 'package:acela/src/utils/safe_convert.dart'; + +class MemoResponse { + final String error; + final String accountName; + final String decrypted; + + MemoResponse({ + this.error = "", + this.accountName = "", + this.decrypted = "", + }); + + factory MemoResponse.fromJson(Map? json) => MemoResponse( + error: asString(json, 'error'), + accountName: asString(json, 'accountName'), + decrypted: asString(json, 'decrypted'), + ); + + factory MemoResponse.fromJsonString(String jsonString) => + MemoResponse.fromJson(json.decode(jsonString)); + + Map toJson() => { + 'error': error, + 'accountName': accountName, + 'decrypted': decrypted, + }; +} diff --git a/lib/src/models/my_account/my_devices.dart b/lib/src/models/my_account/my_devices.dart new file mode 100644 index 00000000..e94904b5 --- /dev/null +++ b/lib/src/models/my_account/my_devices.dart @@ -0,0 +1,49 @@ +import 'dart:convert'; + +import 'package:acela/src/utils/safe_convert.dart'; + +class MyDevices { + final bool success; + final List data; + + MyDevices({ + this.success = false, + required this.data, + }); + + factory MyDevices.fromJson(Map? json) => MyDevices( + success: asBool(json, 'success'), + data: asList(json, 'data') + .map((e) => MyDevicesDataItem.fromJson(e)) + .toList(), + ); + + factory MyDevices.fromString(String string) => + MyDevices.fromJson(json.decode(string)); + + Map toJson() => { + 'success': success, + 'data': data.map((e) => e.toJson()), + }; +} + +class MyDevicesDataItem { + final String token; + final String deviceName; + + MyDevicesDataItem({ + this.token = "", + this.deviceName = "", + }); + + factory MyDevicesDataItem.fromJson(Map? json) => + MyDevicesDataItem( + token: asString(json, 'token'), + deviceName: asString(json, 'deviceName'), + ); + + Map toJson() => { + 'token': token, + 'deviceName': deviceName, + }; +} diff --git a/lib/src/models/my_account/video_ops.dart b/lib/src/models/my_account/video_ops.dart new file mode 100644 index 00000000..b893343a --- /dev/null +++ b/lib/src/models/my_account/video_ops.dart @@ -0,0 +1,88 @@ +import 'dart:convert'; + +import 'package:acela/src/utils/safe_convert.dart'; +import 'package:equatable/equatable.dart'; + +class VideoOpsResponse { + final bool success; + VideoOpsResponse({ + required this.success, + }); + + factory VideoOpsResponse.fromJson(Map? json) => + VideoOpsResponse( + success: asBool(json, 'success'), + ); + + factory VideoOpsResponse.fromJsonString(String jsonString) => + VideoOpsResponse.fromJson(json.decode(jsonString)); +} + +class ErrorResponse { + final String? error; + ErrorResponse({ + required this.error, + }); + + factory ErrorResponse.fromJson(Map? json) => ErrorResponse( + error: asString(json, 'error'), + ); + + factory ErrorResponse.fromJsonString(String jsonString) => + ErrorResponse.fromJson(json.decode(jsonString)); +} + +class BeneficiariesJson extends Equatable { + final String account; + int weight; + final String src; + final bool isDefault; + + BeneficiariesJson( + {required this.account, + required this.weight, + required this.src, + this.isDefault = false}); + + factory BeneficiariesJson.fromJson(Map? json) => + BeneficiariesJson( + account: asString(json, 'account'), + weight: asInt(json, 'weight'), + src: asString(json, 'src'), + ); + + static List fromJsonString(String jsonString) { + var list = json.decode(jsonString) as List; + var listNew = list.map((e) => BeneficiariesJson.fromJson(e)).toList(); + return listNew; + } + + static String toJsonString(List data) { + return json.encode(data); + } + + BeneficiariesJson copyWith({ + String? account, + int? weight, + String? src, + bool? isDefault, + }) { + return BeneficiariesJson( + account: account ?? this.account, + weight: weight ?? this.weight, + src: src ?? this.src, + isDefault: isDefault ?? this.isDefault, + ); + } + + Map toJson() { + return { + 'account': account, + 'weight': weight * 100, + 'src': src, + }; + } + + @override + List get props => [account, src]; +} diff --git a/lib/src/models/navigation_models/new_video_detail_screen_navigation_model.dart b/lib/src/models/navigation_models/new_video_detail_screen_navigation_model.dart new file mode 100644 index 00000000..602845d9 --- /dev/null +++ b/lib/src/models/navigation_models/new_video_detail_screen_navigation_model.dart @@ -0,0 +1,12 @@ +import 'package:acela/src/utils/graphql/models/trending_feed_response.dart'; +import 'package:better_player/better_player.dart'; +import 'package:flutter/material.dart'; + +class NewVideoDetailScreenNavigationParameter { + final BetterPlayerController? betterPlayerController; + final GQLFeedItem? item; + final VoidCallback? onPop; + + NewVideoDetailScreenNavigationParameter( + {this.betterPlayerController, this.item,this.onPop}); +} diff --git a/lib/src/models/podcast/podcast_categories_response.dart b/lib/src/models/podcast/podcast_categories_response.dart new file mode 100644 index 00000000..18c98f99 --- /dev/null +++ b/lib/src/models/podcast/podcast_categories_response.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; + +class PodcastCategoriesResponse { + String? status; + List? feeds; + int? count; + String? description; + + PodcastCategoriesResponse({ + this.status, + this.feeds, + this.count, + this.description, + }); + + factory PodcastCategoriesResponse.fromRawJson(String str) => + PodcastCategoriesResponse.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory PodcastCategoriesResponse.fromJson(Map json) => + PodcastCategoriesResponse( + status: json["status"], + feeds: json["feeds"] == null + ? [] + : List.from( + json["feeds"]!.map((x) => PodcastCategory.fromJson(x))), + count: json["count"], + description: json["description"], + ); + + Map toJson() => { + "status": status, + "feeds": feeds == null + ? [] + : List.from(feeds!.map((x) => x.toJson())), + "count": count, + "description": description, + }; +} + +class PodcastCategory { + int? id; + String? name; + + PodcastCategory({ + this.id, + this.name, + }); + + factory PodcastCategory.fromRawJson(String str) => + PodcastCategory.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory PodcastCategory.fromJson(Map json) => + PodcastCategory( + id: json["id"], + name: json["name"], + ); + + Map toJson() => { + "id": id, + "name": name, + }; +} diff --git a/lib/src/models/podcast/podcast_episode_chapters.dart b/lib/src/models/podcast/podcast_episode_chapters.dart new file mode 100644 index 00000000..8ce48207 --- /dev/null +++ b/lib/src/models/podcast/podcast_episode_chapters.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; + +import 'package:equatable/equatable.dart'; + +class PodcastEpisodeChapterResponse { + final String? version; + final List chapters; + + PodcastEpisodeChapterResponse({ + this.version, + required this.chapters, + }); + + factory PodcastEpisodeChapterResponse.fromRawJson(String str) => + PodcastEpisodeChapterResponse.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory PodcastEpisodeChapterResponse.fromJson(Map json) => + PodcastEpisodeChapterResponse( + version: json["version"], + chapters: json["chapters"] == null + ? [] + : List.from(json["chapters"]! + .map((x) => PodcastEpisodeChapter.fromJson(x))), + ); + + Map toJson() => { + "version": version, + "chapters": List.from( + chapters.map( + (x) => x.toJson(), + ), + ), + }; +} + +class PodcastEpisodeChapter extends Equatable { + final int? startTime; + final String? title; + final String? image; + final String? url; + final int? endTime; + final bool? toc; + + PodcastEpisodeChapter({ + this.startTime, + this.title, + this.image, + this.url, + this.endTime, + this.toc, + }); + + factory PodcastEpisodeChapter.fromRawJson(String str) => + PodcastEpisodeChapter.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory PodcastEpisodeChapter.fromJson(Map json) => + PodcastEpisodeChapter( + startTime: json["startTime"], + title: json["title"], + image: json["img"], + url: json["url"], + endTime: json["endTime"], + toc: json["toc"], + ); + + Map toJson() => { + "startTime": startTime, + "title": title, + "img": image, + "url": url, + "endTime": endTime, + "toc": toc, + }; + + @override + List get props => [title, startTime, image]; +} diff --git a/lib/src/models/podcast/podcast_episodes.dart b/lib/src/models/podcast/podcast_episodes.dart new file mode 100644 index 00000000..cebd2afe --- /dev/null +++ b/lib/src/models/podcast/podcast_episodes.dart @@ -0,0 +1,181 @@ +import 'dart:convert'; + +import 'package:dart_rss/dart_rss.dart'; + +class PodcastEpisodesByFeedResponse { + String? status; + List? liveItems; + List? items; + int? count; + String? query; + String? description; + + PodcastEpisodesByFeedResponse({ + this.status, + this.liveItems, + this.items, + this.count, + this.query, + this.description, + }); + + factory PodcastEpisodesByFeedResponse.fromRawJson(String str) => + PodcastEpisodesByFeedResponse.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory PodcastEpisodesByFeedResponse.fromJson(Map json) => + PodcastEpisodesByFeedResponse( + status: json["status"], + liveItems: json["liveItems"] == null + ? [] + : List.from(json["liveItems"]!.map((x) => x)), + items: json["items"] == null + ? [] + : List.from( + json["items"]!.map((x) => PodcastEpisode.fromJson(x))), + count: json["count"], + query: json["query"], + description: json["description"], + ); + + Map toJson() => { + "status": status, + "liveItems": liveItems == null + ? [] + : List.from(liveItems!.map((x) => x)), + "items": items == null + ? [] + : List.from(items!.map((x) => x.toJson())), + "count": count, + "query": query, + "description": description, + }; +} + +class PodcastEpisode { + String? id; + String? title; + String? link; + String? description; + int? datePublished; + String? datePublishedPretty; + String? enclosureUrl; + int? duration; + int? episode; + String? image; + String? feedImage; + String? guid; + String? chaptersUrl; + bool isAudio; + + PodcastEpisode( + {this.id, + this.title, + this.link, + this.description, + this.datePublished, + this.datePublishedPretty, + this.enclosureUrl, + this.duration, + this.episode, + this.image, + this.feedImage, + this.guid, + this.chaptersUrl, + required this.isAudio}); + + factory PodcastEpisode.fromRawJson(String str) => + PodcastEpisode.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + String? get networkImage { + if (image != null && image!.isNotEmpty) { + return image; + } + if (feedImage != null && feedImage!.isNotEmpty) { + return feedImage; + } + return null; + } + + factory PodcastEpisode.fromJson(Map json) => PodcastEpisode( + id: json["id"].toString(), + isAudio: + json['isAudio'] ?? json['enclosureType']?.contains('audio') ?? true, + title: json["title"], + link: json["link"], + description: json["description"], + datePublished: json["datePublished"], + datePublishedPretty: json["datePublishedPretty"], + enclosureUrl: json["enclosureUrl"], + duration: json["duration"], + episode: json["episode"], + image: json["image"], + feedImage : json["feedImage"], + guid: json["guid"], + chaptersUrl: json['chaptersUrl']); + + factory PodcastEpisode.fromRss(RssItem rssItem) => PodcastEpisode( + id: rssItem.guid, + isAudio: rssItem.enclosure?.type?.contains('audio') ?? true, + title: rssItem.title, + link: rssItem.link, + description: rssItem.description, + datePublished: null, + datePublishedPretty: rssItem.pubDate, + enclosureUrl: rssItem.enclosure?.url, + duration: rssItem.itunes?.duration?.inSeconds, + episode: rssItem.itunes?.episode, + image: rssItem.itunes?.image?.href, + guid: rssItem.guid, + ); + + Map toJson() => { + "id": id, + "title": title, + "link": link, + "description": description, + "datePublished": datePublished, + "datePublishedPretty": datePublishedPretty, + "enclosureUrl": enclosureUrl, + "duration": duration, + "episode": episode, + "image": image, + 'chaptersUrl': chaptersUrl, + 'isAudio': isAudio + }; + + PodcastEpisode copyWith({ + String? id, + String? title, + String? link, + String? description, + int? datePublished, + String? datePublishedPretty, + String? enclosureUrl, + int? duration, + int? episode, + String? image, + String? guid, + String? chaptersUrl, + bool? isAudio, + }) { + return PodcastEpisode( + id: id ?? this.id, + title: title ?? this.title, + link: link ?? this.link, + description: description ?? this.description, + datePublished: datePublished ?? this.datePublished, + datePublishedPretty: datePublishedPretty ?? this.datePublishedPretty, + enclosureUrl: enclosureUrl ?? this.enclosureUrl, + duration: duration ?? this.duration, + episode: episode ?? this.episode, + image: image ?? this.image, + guid: guid ?? this.guid, + chaptersUrl: chaptersUrl ?? this.chaptersUrl, + isAudio: isAudio ?? this.isAudio, + ); + } +} diff --git a/lib/src/models/podcast/trending_podcast_response.dart b/lib/src/models/podcast/trending_podcast_response.dart new file mode 100644 index 00000000..59728869 --- /dev/null +++ b/lib/src/models/podcast/trending_podcast_response.dart @@ -0,0 +1,149 @@ +import 'dart:convert'; + +import 'package:dart_rss/dart_rss.dart'; + +class TrendingPodCastResponse { + String? status; + List? feeds; + List? items; + int? count; + dynamic max; + int? since; + String? description; + + TrendingPodCastResponse({ + this.status, + this.feeds, + this.items, + this.count, + this.max, + this.since, + this.description, + }); + + factory TrendingPodCastResponse.fromRawJson(String str) => + TrendingPodCastResponse.fromJson(json.decode(str)); + + factory TrendingPodCastResponse.fromJson(Map json) => + TrendingPodCastResponse( + status: json["status"], + feeds: json["feeds"] == null + ? [] + : List.from( + json["feeds"]!.map((x) => PodCastFeedItem.fromJson(x))), + items: json["items"] == null + ? [] + : List.from( + json["items"]!.map((x) => PodCastFeedItem.fromJson(x))), + count: json["count"], + max: json["max"], + since: json["since"], + description: json["description"], + ); +} + +class PodCastFeedItem { + String? id; + String? url; + String? rssUrl; + String? title; + String? description; + String? author; + String? image; + String? feedImage; + String? feedTitle; + String? artwork; + int? newestItemPublishTime; + int? itunesId; + int? trendScore; + String? language; + String? feedLanguage; + String? datePublishedPretty; + + String? get networkImage { + if (image != null && image!.isNotEmpty) { + return image; + } + if (feedImage != null && feedImage!.isNotEmpty) { + return feedImage; + } + if (artwork != null && artwork!.isNotEmpty) { + return artwork; + } + return null; + } + + PodCastFeedItem( + {this.id, + this.url, + this.rssUrl, + this.title, + this.description, + this.author, + this.image, + this.feedImage, + this.artwork, + this.newestItemPublishTime, + this.itunesId, + this.trendScore, + this.language, + this.feedTitle, + this.feedLanguage, + this.datePublishedPretty + // this.categories, + }); + + factory PodCastFeedItem.fromRawJson(String str) => + PodCastFeedItem.fromJson(json.decode(str)); + + // String toRawJson() => json.encode(toJson()); + + factory PodCastFeedItem.fromJson(Map json) => + PodCastFeedItem( + id: json["id"].toString(), + url: json["url"], + title: json["title"], + rssUrl: json['rssUrl'], + description: json["description"], + author: json["author"], + image: json["image"], + feedImage: json["feedImage"], + feedTitle: json["feedTitle"], + feedLanguage: json["feedLanguage"], + artwork: json["artwork"], + newestItemPublishTime: json["newestItemPublishTime"], + itunesId: json["itunesId"], + trendScore: json["trendScore"], + language: json["language"], + datePublishedPretty: json["datePublishedPretty"], + // categories: json["categories"] != null ? Map.from(json["categories"]!).map((k, v) => MapEntry(k, v)) : null, + ); + + factory PodCastFeedItem.fromRss(RssFeed rssFeed, String rssUrl) => + PodCastFeedItem( + id: rssUrl, + rssUrl: rssUrl, + title: rssFeed.title, + description: rssFeed.description, + author: rssFeed.author, + image: rssFeed.image?.url, + feedImage: rssFeed.image?.url, + language: rssFeed.language); + + Map toJson() => { + "id": id, + "url": url, + 'rssUrl': rssUrl, + "title": title, + "description": description, + "author": author, + "image": image, + "feedImage": feedImage, + "artwork": artwork, + "newestItemPublishTime": newestItemPublishTime, + "itunesId": itunesId, + "trendScore": trendScore, + "language": language, + // "categories": Map.from(categories!).map((k, v) => MapEntry(k, v)), + }; +} diff --git a/lib/src/models/podcast/upload/podcast_episode_upload_response.dart b/lib/src/models/podcast/upload/podcast_episode_upload_response.dart new file mode 100644 index 00000000..61357602 --- /dev/null +++ b/lib/src/models/podcast/upload/podcast_episode_upload_response.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; + +class PodcastEpisodeUploadResponse { + String id; + String permlink; + String title; + String description; + String community; + String thumbnail; + String enclosureUrl; + bool firstUpload; + + PodcastEpisodeUploadResponse({ + required this.id, + required this.permlink, + required this.title, + required this.description, + required this.community, + required this.thumbnail, + required this.enclosureUrl, + required this.firstUpload, + }); + + static PodcastEpisodeUploadResponse podcastEpisodeUploadResponseFromJson( + String str) => + PodcastEpisodeUploadResponse.fromJson(json.decode(str)); + + static String podcastEpisodeUploadResponseToJson( + PodcastEpisodeUploadResponse data) => + json.encode(data.toJson()); + + factory PodcastEpisodeUploadResponse.fromJson(Map json) => + PodcastEpisodeUploadResponse( + id: json["id"], + permlink: json["permlink"], + title: json["title"], + description: json["description"], + community: json["community"], + thumbnail: json["thumbnail"], + enclosureUrl: json["enclosureUrl"], + firstUpload: json["firstUpload"] == 1 , + ); + + Map toJson() => { + "id": id, + "permlink": permlink, + "title": title, + "description": description, + "community": community, + "thumbnail": thumbnail, + "enclosureUrl": enclosureUrl, + "firstUpload": firstUpload, + }; +} diff --git a/lib/src/models/search/search_response_models.dart b/lib/src/models/search/search_response_models.dart new file mode 100644 index 00000000..3c706d07 --- /dev/null +++ b/lib/src/models/search/search_response_models.dart @@ -0,0 +1,116 @@ +import 'dart:convert'; + +import 'package:acela/src/utils/safe_convert.dart'; + +class SearchResponseModels { + final double took; + final int hits; + final List results; + + SearchResponseModels({ + this.took = 0.0, + this.hits = 0, + required this.results, + }); + + factory SearchResponseModels.fromJson(Map? json) => + SearchResponseModels( + took: asDouble(json, 'took'), + hits: asInt(json, 'hits'), + results: asList(json, 'results') + .map((e) => SearchResponseResultsItem.fromJson(e)) + .toList(), + ); + + factory SearchResponseModels.fromJsonString(String jsonString) => + SearchResponseModels.fromJson(json.decode(jsonString)); + + Map toJson() => { + 'took': took, + 'hits': hits, + 'results': results.map((e) => e.toJson()), + }; +} + +class SearchResponseResultsItem { + final int id; + final String author; + final String permlink; + final String category; + final int children; + final String authorRep; + final String title; + final String titleMarked; + final String body; + final String bodyMarked; + final String imgUrl; + final double payout; + final int totalVotes; + final int upVotes; + final String createdAt; + final List tags; + final String app; + final int depth; + + SearchResponseResultsItem( + {this.id = 0, + this.author = "", + this.permlink = "", + this.category = "", + this.children = 0, + this.authorRep = "", + this.title = "", + this.titleMarked = "", + this.body = "", + this.bodyMarked = "", + this.imgUrl = "", + this.payout = 0.0, + this.totalVotes = 0, + this.upVotes = 0, + this.createdAt = "", + required this.tags, + this.app = "", + this.depth = 0}); + + factory SearchResponseResultsItem.fromJson(Map? json) => + SearchResponseResultsItem( + id: asInt(json, 'id'), + author: asString(json, 'author'), + permlink: asString(json, 'permlink'), + category: asString(json, 'category'), + children: asInt(json, 'children'), + authorRep: asString(json, 'author_rep'), + title: asString(json, 'title'), + titleMarked: asString(json, 'title_marked'), + body: asString(json, 'body'), + bodyMarked: asString(json, 'body_marked'), + imgUrl: asString(json, 'img_url'), + payout: asDouble(json, 'payout'), + totalVotes: asInt(json, 'total_votes'), + upVotes: asInt(json, 'up_votes'), + createdAt: asString(json, 'created_at'), + tags: asList(json, 'tags').map((e) => e.toString()).toList(), + app: asString(json, 'app'), + depth: asInt(json, 'depth')); + + Map toJson() => { + 'id': id, + 'author': author, + 'permlink': permlink, + 'category': category, + 'children': children, + 'author_rep': authorRep, + 'title': title, + 'title_marked': titleMarked, + 'body': body, + 'body_marked': bodyMarked, + 'img_url': imgUrl, + 'payout': payout, + 'total_votes': totalVotes, + 'up_votes': upVotes, + 'created_at': createdAt, + 'tags': tags.map((e) => e), + 'app': app, + 'depth': depth + }; +} diff --git a/lib/src/models/stories/stories_feed_response.dart b/lib/src/models/stories/stories_feed_response.dart new file mode 100644 index 00000000..e8384eab --- /dev/null +++ b/lib/src/models/stories/stories_feed_response.dart @@ -0,0 +1,120 @@ +import 'dart:convert'; + +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/utils/communicator.dart'; +import 'package:acela/src/utils/safe_convert.dart'; + +class StoriesFeedResponseItem { + final bool fromMobile; + final bool isReel; + final String id; + final String filename; + final String originalFilename; + final String permlink; + final double duration; + final int size; + final String owner; + final String uploadType; + final int v; + final String description; + final String tags; + final String thumbnail; + final String title; + final String thumbUrl; + final String baseThumbUrl; + final String playUrl; + final String publishData; + final String localFilename; + final String jobId; + final String created; + final int views; + + String getVideoUrl(HiveUserData data) { + if (playUrl.contains('ipfs')) { + // example + // https://ipfs-3speak.b-cdn.net/ipfs/QmTRDJcgtt66pxs3ZnQCdRw57b69NS2TQvF4yHwaux5grT/manifest.m3u8 + // https://ipfs-3speak.b-cdn.net/ipfs/QmTRDJcgtt66pxs3ZnQCdRw57b69NS2TQvF4yHwaux5grT/480p/index.m3u8 + return playUrl.replaceAll('manifest', '${data.resolution}/index'); + } else { + // example + // https://threespeakvideo.b-cdn.net/chjwguvd/default.m3u8 + // https://threespeakvideo.b-cdn.net/chjwguvd/480p.m3u8 + return playUrl.replaceAll('default', '${data.resolution}'); + } + } + + String get thumbnailValue { + if (thumbnail.startsWith("http")) { + return thumbnail; + } + return '${Communicator.threeSpeakCDN}/ipfs/${thumbnail.replaceAll("ipfs://", '')}'; + } + + StoriesFeedResponseItem({ + this.fromMobile = false, + this.isReel = false, + this.id = "", + this.filename = "", + this.originalFilename = "", + this.permlink = "", + this.duration = 0.0, + this.size = 0, + this.owner = "", + this.uploadType = "", + this.v = 0, + this.description = "", + this.tags = "", + this.thumbnail = "", + this.title = "", + this.thumbUrl = "", + this.baseThumbUrl = "", + this.playUrl = "", + this.publishData = "", + this.localFilename = "", + this.jobId = "", + this.created = "", + this.views = 0, + }); + + List fromJsonString(String jsonString, String type) { + if (type == 'feed') { + final jsonList = json.decode(jsonString) as List; + return jsonList.map((e) => StoriesFeedResponseItem.fromJson(e)).toList(); + } else if (type == 'trends') { + final jsonObj = json.decode(jsonString) as Map; + final jsonList = jsonObj['trends'] as List; + return jsonList.map((e) => StoriesFeedResponseItem.fromJson(e)).toList(); + } else { + final jsonObj = json.decode(jsonString) as Map; + final jsonList = jsonObj['recommended'] as List; + return jsonList.map((e) => StoriesFeedResponseItem.fromJson(e)).toList(); + } + } + + factory StoriesFeedResponseItem.fromJson(Map? json) => + StoriesFeedResponseItem( + fromMobile: asBool(json, 'fromMobile'), + isReel: asBool(json, 'isReel'), + id: asString(json, '_id'), + filename: asString(json, 'filename'), + originalFilename: asString(json, 'originalFilename'), + permlink: asString(json, 'permlink'), + duration: asDouble(json, 'duration'), + size: asInt(json, 'size'), + owner: asString(json, 'owner'), + uploadType: asString(json, 'upload_type'), + v: asInt(json, '__v'), + description: asString(json, 'description'), + tags: asString(json, 'tags'), + thumbnail: asString(json, 'thumbnail'), + title: asString(json, 'title'), + thumbUrl: asString(json, 'thumbUrl'), + baseThumbUrl: asString(json, 'baseThumbUrl'), + playUrl: asString(json, 'playUrl'), + publishData: asString(json, 'publish_data'), + localFilename: asString(json, 'local_filename'), + jobId: asString(json, 'job_id'), + created: asString(json, 'created'), + views: asInt(json, 'views'), + ); +} diff --git a/lib/src/models/trending_tags/trending_tags_response.dart b/lib/src/models/trending_tags/trending_tags_response.dart new file mode 100644 index 00000000..1104c627 --- /dev/null +++ b/lib/src/models/trending_tags/trending_tags_response.dart @@ -0,0 +1,85 @@ +import 'dart:convert'; + +class TrendingTagResponse { + TrendingTagResponseData? data; + + TrendingTagResponse({ + this.data, + }); + + factory TrendingTagResponse.fromRawJson(String str) => TrendingTagResponse.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory TrendingTagResponse.fromJson(Map json) => TrendingTagResponse( + data: json["data"] == null ? null : TrendingTagResponseData.fromJson(json["data"]), + ); + + Map toJson() => { + "data": data?.toJson(), + }; +} + +class TrendingTagResponseData { + TrendingTagResponseDataTrendingTags? trendingTags; + + TrendingTagResponseData({ + this.trendingTags, + }); + + factory TrendingTagResponseData.fromRawJson(String str) => TrendingTagResponseData.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory TrendingTagResponseData.fromJson(Map json) => TrendingTagResponseData( + trendingTags: json["trendingTags"] == null ? null : TrendingTagResponseDataTrendingTags.fromJson(json["trendingTags"]), + ); + + Map toJson() => { + "trendingTags": trendingTags?.toJson(), + }; +} + +class TrendingTagResponseDataTrendingTags { + List? tags; + + TrendingTagResponseDataTrendingTags({ + this.tags, + }); + + factory TrendingTagResponseDataTrendingTags.fromRawJson(String str) => TrendingTagResponseDataTrendingTags.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory TrendingTagResponseDataTrendingTags.fromJson(Map json) => TrendingTagResponseDataTrendingTags( + tags: json["tags"] == null ? [] : List.from(json["tags"]!.map((x) => TrendingTagResponseDataTrendingTag.fromJson(x))), + ); + + Map toJson() => { + "tags": tags == null ? [] : List.from(tags!.map((x) => x.toJson())), + }; +} + +class TrendingTagResponseDataTrendingTag { + int score; + String tag; + + TrendingTagResponseDataTrendingTag({ + required this.score, + required this.tag, + }); + + factory TrendingTagResponseDataTrendingTag.fromRawJson(String str) => TrendingTagResponseDataTrendingTag.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory TrendingTagResponseDataTrendingTag.fromJson(Map json) => TrendingTagResponseDataTrendingTag( + score: json["score"], + tag: json["tag"], + ); + + Map toJson() => { + "score": score, + "tag": tag, + }; +} diff --git a/lib/src/models/user_account/action_response.dart b/lib/src/models/user_account/action_response.dart new file mode 100644 index 00000000..48e676b8 --- /dev/null +++ b/lib/src/models/user_account/action_response.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +enum ResponseStatus { success, failed, unknown } + +class ActionSingleDataResponse { + final String? id; + final String? type; + final T? data; + final bool valid; + final String errorMessage; + final ResponseStatus status; + final bool isSuccess; + + ActionSingleDataResponse( + {this.id, + this.type, + this.data, + this.isSuccess = false, + this.valid = false, + required this.errorMessage, + required this.status}); + + factory ActionSingleDataResponse.fromJsonString( + String string, T Function(Map) fromJson) => + ActionSingleDataResponse.fromJson(json.decode(string), fromJson); + + factory ActionSingleDataResponse.fromJson( + Map json, T Function(Map) fromJson) { + return ActionSingleDataResponse( + id: json['id'] as String, + type: json['type'] as String, + data: fromJson(json['data']), + valid: json['valid'] as bool, + status: json['valid'] && json['error'].isEmpty + ? ResponseStatus.success + : ResponseStatus.failed, + isSuccess: json['valid'] && json['error'].isEmpty, + errorMessage: json['error'] as String, + ); + } +} + +class ActionListDataResponse { + final List? data; + final bool valid; + final String errorMessage; + final ResponseStatus status; + final bool isSuccess; + + ActionListDataResponse({ + this.data, + this.isSuccess = false, + this.valid = false, + required this.status, + required this.errorMessage, + }); + + factory ActionListDataResponse.fromJsonString( + String string, T Function(dynamic) fromJson) => + ActionListDataResponse.fromJson(json.decode(string), fromJson); + + factory ActionListDataResponse.fromJson( + Map json, T Function(dynamic) fromJson) { + return ActionListDataResponse( + data: (json['data'] as List?) + ?.map((dynamic item) => fromJson(item)) + .toList() ?? + [], + status: json['valid'] && json['error'].isEmpty + ? ResponseStatus.success + : ResponseStatus.failed, + isSuccess: json['valid'] && json['error'].isEmpty && json['data'] != null, + valid: json['valid'] as bool, + errorMessage: json['error'] as String, + ); + } +} diff --git a/lib/src/models/user_account/active_model.dart b/lib/src/models/user_account/active_model.dart new file mode 100644 index 00000000..4fbe7c38 --- /dev/null +++ b/lib/src/models/user_account/active_model.dart @@ -0,0 +1,34 @@ +class ActiveModel { + final List>? accountAuths; + final List>? keyAuths; + final int? weightThreshold; + + ActiveModel({ + this.accountAuths, + this.keyAuths, + this.weightThreshold, + }); + + ActiveModel copyWith({ + List>? accountAuths, + List>? keyAuths, + int? weightThreshold, + }) => + ActiveModel( + accountAuths: accountAuths ?? this.accountAuths, + keyAuths: keyAuths ?? this.keyAuths, + weightThreshold: weightThreshold ?? this.weightThreshold, + ); + + factory ActiveModel.fromJson(Map json) => ActiveModel( + accountAuths: json["account_auths"] == null ? [] : List>.from(json["account_auths"]!.map((x) => List.from(x.map((x) => x)))), + keyAuths: json["key_auths"] == null ? [] : List>.from(json["key_auths"]!.map((x) => List.from(x.map((x) => x)))), + weightThreshold: json["weight_threshold"], + ); + + Map toJson() => { + "account_auths": accountAuths == null ? [] : List.from(accountAuths!.map((x) => List.from(x.map((x) => x)))), + "key_auths": keyAuths == null ? [] : List.from(keyAuths!.map((x) => List.from(x.map((x) => x)))), + "weight_threshold": weightThreshold, + }; +} diff --git a/lib/src/models/user_account/delayed_vote_model.dart b/lib/src/models/user_account/delayed_vote_model.dart new file mode 100644 index 00000000..2b74f559 --- /dev/null +++ b/lib/src/models/user_account/delayed_vote_model.dart @@ -0,0 +1,28 @@ +class DelayedVoteModel { + final DateTime? time; + final int? val; + + DelayedVoteModel({ + this.time, + this.val, + }); + + DelayedVoteModel copyWith({ + DateTime? time, + int? val, + }) => + DelayedVoteModel( + time: time ?? this.time, + val: val ?? this.val, + ); + + factory DelayedVoteModel.fromJson(Map json) => DelayedVoteModel( + time: json["time"] == null ? null : DateTime.parse(json["time"]), + val: json["val"], + ); + + Map toJson() => { + "time": time?.toIso8601String(), + "val": val, + }; +} diff --git a/lib/src/models/user_account/downvote_manarbar_model.dart b/lib/src/models/user_account/downvote_manarbar_model.dart new file mode 100644 index 00000000..4edbd000 --- /dev/null +++ b/lib/src/models/user_account/downvote_manarbar_model.dart @@ -0,0 +1,28 @@ +class DownvoteManabarModel { + final int? currentMana; + final int? lastUpdateTime; + + DownvoteManabarModel({ + this.currentMana, + this.lastUpdateTime, + }); + + DownvoteManabarModel copyWith({ + int? currentMana, + int? lastUpdateTime, + }) => + DownvoteManabarModel( + currentMana: currentMana ?? this.currentMana, + lastUpdateTime: lastUpdateTime ?? this.lastUpdateTime, + ); + + factory DownvoteManabarModel.fromJson(Map json) => DownvoteManabarModel( + currentMana: json["current_mana"], + lastUpdateTime: json["last_update_time"], + ); + + Map toJson() => { + "current_mana": currentMana, + "last_update_time": lastUpdateTime, + }; +} diff --git a/lib/src/models/user_account/posting_json_meta_data.dart b/lib/src/models/user_account/posting_json_meta_data.dart new file mode 100644 index 00000000..9be207cf --- /dev/null +++ b/lib/src/models/user_account/posting_json_meta_data.dart @@ -0,0 +1,117 @@ +import 'dart:convert'; + +import 'package:acela/src/utils/safe_convert.dart'; + +class UserPostingJsonMetadata { + final UserPostingJsonMetadataProfile? profile; + + UserPostingJsonMetadata({ + this.profile, + }); + + UserPostingJsonMetadata copyWith({ + UserPostingJsonMetadataProfile? profile, + }) => + UserPostingJsonMetadata( + profile: profile ?? this.profile, + ); + + factory UserPostingJsonMetadata.fromJson(Map json) => + UserPostingJsonMetadata( + profile: json["profile"] == null + ? null + : UserPostingJsonMetadataProfile.fromJson(json["profile"]), + ); + + factory UserPostingJsonMetadata.fromRawJson(String str) => + UserPostingJsonMetadata.fromJson(json.decode(str)); + + Map toJson() => { + "profile": profile?.toJson(), + }; +} + +class UserPostingJsonMetadataProfile { + final String? name; + final String? about; + final String? coverImage; + final String? profileImage; + final String? website; + final String? location; + final String? pinned; + final int? version; + final bool? trail; + final String? witnessDescription; + final List? tokens; + + UserPostingJsonMetadataProfile({ + this.name, + this.about, + this.coverImage, + this.profileImage, + this.website, + this.location, + this.pinned, + this.version, + this.trail, + this.witnessDescription, + this.tokens, + }); + + UserPostingJsonMetadataProfile copyWith({ + String? name, + String? about, + String? coverImage, + String? profileImage, + String? website, + String? location, + String? pinned, + int? version, + bool? trail, + String? witnessDescription, + List? tokens, + }) => + UserPostingJsonMetadataProfile( + name: name ?? this.name, + about: about ?? this.about, + coverImage: coverImage ?? this.coverImage, + profileImage: profileImage ?? this.profileImage, + website: website ?? this.website, + location: location ?? this.location, + pinned: pinned ?? this.pinned, + version: version ?? this.version, + trail: trail ?? this.trail, + witnessDescription: witnessDescription ?? this.witnessDescription, + tokens: tokens ?? this.tokens, + ); + + factory UserPostingJsonMetadataProfile.fromJson(Map json) => + UserPostingJsonMetadataProfile( + name: json["name"], + about: json["about"], + coverImage: json["cover_image"], + profileImage: json["profile_image"], + website: json["website"], + location: json["location"], + pinned: json["pinned"], + version: json["version"], + trail: json["trail"], + witnessDescription: json["witness_description"], + tokens: asList(json, "tokens") + ); + + Map toJson() => { + "name": name, + "about": about, + "cover_image": coverImage, + "profile_image": profileImage, + "website": website, + "location": location, + "pinned": pinned, + "version": version, + "trail": trail, + "witness_description": witnessDescription, + "tokens": + tokens == null ? [] : List.from(tokens!.map((x) => x)), + }; +} diff --git a/lib/src/models/user_account/user_model.dart b/lib/src/models/user_account/user_model.dart new file mode 100644 index 00000000..04c73346 --- /dev/null +++ b/lib/src/models/user_account/user_model.dart @@ -0,0 +1,596 @@ +import 'dart:convert'; + +import 'package:acela/src/models/user_account/active_model.dart'; +import 'package:acela/src/models/user_account/delayed_vote_model.dart'; +import 'package:acela/src/models/user_account/downvote_manarbar_model.dart'; +import 'package:acela/src/models/user_account/posting_json_meta_data.dart'; +import 'package:acela/src/models/user_account/voting_manabar_model.dart'; +import 'package:acela/src/utils/safe_convert.dart'; + +List userModelFromJson(String str) => + List.from(json.decode(str).map((x) => UserModel.fromJson(x))); + +String userModelToJson(List data) => + json.encode(List.from(data.map((x) => x.toJson()))); + +class UserModel { + final ActiveModel? active; + final String balance; + final bool? canVote; + final int? commentCount; + final DateTime created; + final int? curationRewards; + final List? delayedVotes; + final String? delegatedVestingShares; + final DownvoteManabarModel? downvoteManabar; + final DateTime? governanceVoteExpirationTs; + final List? guestBloggers; + final String hbdBalance; + final DateTime? hbdLastInterestPayment; + final String? hbdSeconds; + final DateTime? hbdSecondsLastUpdate; + final int id; + final String? jsonMetadata; + final DateTime? lastAccountRecovery; + final DateTime? lastAccountUpdate; + final DateTime? lastOwnerUpdate; + final DateTime? lastPost; + final DateTime? lastRootPost; + final DateTime? lastVoteTime; + final int? lifetimeVoteCount; + final List? marketHistory; + final String? memoKey; + final bool? mined; + final String name; + final DateTime? nextVestingWithdrawal; + final int? openRecurrentTransfers; + final List? otherHistory; + final ActiveModel? owner; + final int? pendingClaimedAccounts; + final int? pendingTransfers; + final int? postBandwidth; + final int? postCount; + final List? postHistory; + final String? postVotingPower; + final ActiveModel? posting; + final UserPostingJsonMetadata? postingJsonMetadata; + final int? postingRewards; + final DateTime? previousOwnerUpdate; + final List? proxiedVsfVotes; + final String? proxy; + final String? receivedVestingShares; + final String? recoveryAccount; + final int? reputation; + final String? resetAccount; + final String? rewardHbdBalance; + final String? rewardHiveBalance; + final String? rewardVestingBalance; + final String? rewardVestingHive; + final String? savingsBalance; + final String? savingsHbdBalance; + final DateTime? savingsHbdLastInterestPayment; + final String? savingsHbdSeconds; + final DateTime? savingsHbdSecondsLastUpdate; + final int? savingsWithdrawRequests; + final List? tagsUsage; + final int? toWithdraw; + final List? transferHistory; + final String? vestingBalance; + final String? vestingShares; + final String? vestingWithdrawRate; + final List? voteHistory; + final VotingManabarModel? votingManabar; + final int? votingPower; + final int? withdrawRoutes; + final int? withdrawn; + final List? witnessVotes; + final int? witnessesVotedFor; + + UserModel({ + this.active, + required this.balance, + this.canVote, + this.commentCount, + required this.created, + this.curationRewards, + this.delayedVotes, + this.delegatedVestingShares, + this.downvoteManabar, + this.governanceVoteExpirationTs, + this.guestBloggers, + required this.hbdBalance, + this.hbdLastInterestPayment, + this.hbdSeconds, + this.hbdSecondsLastUpdate, + required this.id, + this.jsonMetadata, + this.lastAccountRecovery, + this.lastAccountUpdate, + this.lastOwnerUpdate, + this.lastPost, + this.lastRootPost, + this.lastVoteTime, + this.lifetimeVoteCount, + this.marketHistory, + this.memoKey, + this.mined, + required this.name, + this.nextVestingWithdrawal, + this.openRecurrentTransfers, + this.otherHistory, + this.owner, + this.pendingClaimedAccounts, + this.pendingTransfers, + this.postBandwidth, + this.postCount, + this.postHistory, + this.postVotingPower, + this.posting, + this.postingJsonMetadata, + this.postingRewards, + this.previousOwnerUpdate, + this.proxiedVsfVotes, + this.proxy, + this.receivedVestingShares, + this.recoveryAccount, + this.reputation, + this.resetAccount, + this.rewardHbdBalance, + this.rewardHiveBalance, + this.rewardVestingBalance, + this.rewardVestingHive, + this.savingsBalance, + this.savingsHbdBalance, + this.savingsHbdLastInterestPayment, + this.savingsHbdSeconds, + this.savingsHbdSecondsLastUpdate, + this.savingsWithdrawRequests, + this.tagsUsage, + this.toWithdraw, + this.transferHistory, + this.vestingBalance, + this.vestingShares, + this.vestingWithdrawRate, + this.voteHistory, + this.votingManabar, + this.votingPower, + this.withdrawRoutes, + this.withdrawn, + this.witnessVotes, + this.witnessesVotedFor, + }); + + bool hasThreeSpeakPostingAuthority() { + if (posting != null && + posting!.accountAuths != null && + posting!.accountAuths!.isNotEmpty) { + for (List? item in posting!.accountAuths!) { + if (item != null && item.isNotEmpty && item.first == "threespeak") { + return true; + } + } + } + return false; + } + + double _vestingToDouble(String? arg) { + double? value = 0; + String result = arg ?? ""; + if (result.isNotEmpty) { + int index = result.indexOf(" "); + value = double.tryParse(result.substring(0, index)); + } + return value ?? 0; + } + + double get delegatedVestingSharesValue => + _vestingToDouble(delegatedVestingShares); + + double get receivedVestingSharesValue => + _vestingToDouble(receivedVestingShares); + + double get vestingSharesValue => _vestingToDouble(vestingShares); + + UserModel copyWith({ + ActiveModel? active, + String? balance, + bool? canVote, + int? commentCount, + DateTime? created, + int? curationRewards, + List? delayedVotes, + String? delegatedVestingShares, + DownvoteManabarModel? downvoteManabar, + DateTime? governanceVoteExpirationTs, + List? guestBloggers, + String? hbdBalance, + DateTime? hbdLastInterestPayment, + String? hbdSeconds, + DateTime? hbdSecondsLastUpdate, + int? id, + String? jsonMetadata, + DateTime? lastAccountRecovery, + DateTime? lastAccountUpdate, + DateTime? lastOwnerUpdate, + DateTime? lastPost, + DateTime? lastRootPost, + DateTime? lastVoteTime, + int? lifetimeVoteCount, + List? marketHistory, + String? memoKey, + bool? mined, + String? name, + DateTime? nextVestingWithdrawal, + int? openRecurrentTransfers, + List? otherHistory, + ActiveModel? owner, + int? pendingClaimedAccounts, + int? pendingTransfers, + int? postBandwidth, + int? postCount, + List? postHistory, + String? postVotingPower, + ActiveModel? posting, + UserPostingJsonMetadata? postingJsonMetadata, + int? postingRewards, + DateTime? previousOwnerUpdate, + List? proxiedVsfVotes, + String? proxy, + String? receivedVestingShares, + String? recoveryAccount, + int? reputation, + String? resetAccount, + String? rewardHbdBalance, + String? rewardHiveBalance, + String? rewardVestingBalance, + String? rewardVestingHive, + String? savingsBalance, + String? savingsHbdBalance, + DateTime? savingsHbdLastInterestPayment, + String? savingsHbdSeconds, + DateTime? savingsHbdSecondsLastUpdate, + int? savingsWithdrawRequests, + List? tagsUsage, + int? toWithdraw, + List? transferHistory, + String? vestingBalance, + String? vestingShares, + String? vestingWithdrawRate, + List? voteHistory, + VotingManabarModel? votingManabar, + int? votingPower, + int? withdrawRoutes, + int? withdrawn, + List? witnessVotes, + int? witnessesVotedFor, + }) => + UserModel( + active: active ?? this.active, + balance: balance ?? this.balance, + canVote: canVote ?? this.canVote, + commentCount: commentCount ?? this.commentCount, + created: created ?? this.created, + curationRewards: curationRewards ?? this.curationRewards, + delayedVotes: delayedVotes ?? this.delayedVotes, + delegatedVestingShares: + delegatedVestingShares ?? this.delegatedVestingShares, + downvoteManabar: downvoteManabar ?? this.downvoteManabar, + governanceVoteExpirationTs: + governanceVoteExpirationTs ?? this.governanceVoteExpirationTs, + guestBloggers: guestBloggers ?? this.guestBloggers, + hbdBalance: hbdBalance ?? this.hbdBalance, + hbdLastInterestPayment: + hbdLastInterestPayment ?? this.hbdLastInterestPayment, + hbdSeconds: hbdSeconds ?? this.hbdSeconds, + hbdSecondsLastUpdate: hbdSecondsLastUpdate ?? this.hbdSecondsLastUpdate, + id: id ?? this.id, + jsonMetadata: jsonMetadata ?? this.jsonMetadata, + lastAccountRecovery: lastAccountRecovery ?? this.lastAccountRecovery, + lastAccountUpdate: lastAccountUpdate ?? this.lastAccountUpdate, + lastOwnerUpdate: lastOwnerUpdate ?? this.lastOwnerUpdate, + lastPost: lastPost ?? this.lastPost, + lastRootPost: lastRootPost ?? this.lastRootPost, + lastVoteTime: lastVoteTime ?? this.lastVoteTime, + lifetimeVoteCount: lifetimeVoteCount ?? this.lifetimeVoteCount, + marketHistory: marketHistory ?? this.marketHistory, + memoKey: memoKey ?? this.memoKey, + mined: mined ?? this.mined, + name: name ?? this.name, + nextVestingWithdrawal: + nextVestingWithdrawal ?? this.nextVestingWithdrawal, + openRecurrentTransfers: + openRecurrentTransfers ?? this.openRecurrentTransfers, + otherHistory: otherHistory ?? this.otherHistory, + owner: owner ?? this.owner, + pendingClaimedAccounts: + pendingClaimedAccounts ?? this.pendingClaimedAccounts, + pendingTransfers: pendingTransfers ?? this.pendingTransfers, + postBandwidth: postBandwidth ?? this.postBandwidth, + postCount: postCount ?? this.postCount, + postHistory: postHistory ?? this.postHistory, + postVotingPower: postVotingPower ?? this.postVotingPower, + posting: posting ?? this.posting, + postingJsonMetadata: postingJsonMetadata ?? this.postingJsonMetadata, + postingRewards: postingRewards ?? this.postingRewards, + previousOwnerUpdate: previousOwnerUpdate ?? this.previousOwnerUpdate, + proxiedVsfVotes: proxiedVsfVotes ?? this.proxiedVsfVotes, + proxy: proxy ?? this.proxy, + receivedVestingShares: + receivedVestingShares ?? this.receivedVestingShares, + recoveryAccount: recoveryAccount ?? this.recoveryAccount, + reputation: reputation ?? this.reputation, + resetAccount: resetAccount ?? this.resetAccount, + rewardHbdBalance: rewardHbdBalance ?? this.rewardHbdBalance, + rewardHiveBalance: rewardHiveBalance ?? this.rewardHiveBalance, + rewardVestingBalance: rewardVestingBalance ?? this.rewardVestingBalance, + rewardVestingHive: rewardVestingHive ?? this.rewardVestingHive, + savingsBalance: savingsBalance ?? this.savingsBalance, + savingsHbdBalance: savingsHbdBalance ?? this.savingsHbdBalance, + savingsHbdLastInterestPayment: + savingsHbdLastInterestPayment ?? this.savingsHbdLastInterestPayment, + savingsHbdSeconds: savingsHbdSeconds ?? this.savingsHbdSeconds, + savingsHbdSecondsLastUpdate: + savingsHbdSecondsLastUpdate ?? this.savingsHbdSecondsLastUpdate, + savingsWithdrawRequests: + savingsWithdrawRequests ?? this.savingsWithdrawRequests, + tagsUsage: tagsUsage ?? this.tagsUsage, + toWithdraw: toWithdraw ?? this.toWithdraw, + transferHistory: transferHistory ?? this.transferHistory, + vestingBalance: vestingBalance ?? this.vestingBalance, + vestingShares: vestingShares ?? this.vestingShares, + vestingWithdrawRate: vestingWithdrawRate ?? this.vestingWithdrawRate, + voteHistory: voteHistory ?? this.voteHistory, + votingManabar: votingManabar ?? this.votingManabar, + votingPower: votingPower ?? this.votingPower, + withdrawRoutes: withdrawRoutes ?? this.withdrawRoutes, + withdrawn: withdrawn ?? this.withdrawn, + witnessVotes: witnessVotes ?? this.witnessVotes, + witnessesVotedFor: witnessesVotedFor ?? this.witnessesVotedFor, + ); + + String? get location { + return postingJsonMetadata?.profile?.location; + } + + String? get website { + return postingJsonMetadata?.profile?.website; + } + + factory UserModel.fromJson(Map json) => UserModel( + active: json["active"] == null + ? null + : ActiveModel.fromJson(json["active"]), + balance: asString(json, "balance"), + canVote: json["can_vote"], + commentCount: json["comment_count"], + created: DateTime.parse(json["created"]), + curationRewards: json["curation_rewards"], + delayedVotes: json["delayed_votes"] == null + ? [] + : List.from(json["delayed_votes"]! + .map((x) => DelayedVoteModel.fromJson(x))), + delegatedVestingShares: json["delegated_vesting_shares"], + downvoteManabar: json["downvote_manabar"] == null + ? null + : DownvoteManabarModel.fromJson(json["downvote_manabar"]), + governanceVoteExpirationTs: + json["governance_vote_expiration_ts"] == null + ? null + : DateTime.parse(json["governance_vote_expiration_ts"]), + guestBloggers: json["guest_bloggers"] == null + ? [] + : List.from(json["guest_bloggers"]!.map((x) => x)), + hbdBalance: asString(json, "hbd_balance"), + hbdLastInterestPayment: json["hbd_last_interest_payment"] == null + ? null + : DateTime.parse(json["hbd_last_interest_payment"]), + hbdSeconds: json["hbd_seconds"], + hbdSecondsLastUpdate: json["hbd_seconds_last_update"] == null + ? null + : DateTime.parse(json["hbd_seconds_last_update"]), + id: json["id"], + jsonMetadata: json["json_metadata"], + lastAccountRecovery: json["last_account_recovery"] == null + ? null + : DateTime.parse(json["last_account_recovery"]), + lastAccountUpdate: json["last_account_update"] == null + ? null + : DateTime.parse(json["last_account_update"]), + lastOwnerUpdate: json["last_owner_update"] == null + ? null + : DateTime.parse(json["last_owner_update"]), + lastPost: json["last_post"] == null + ? null + : DateTime.parse(json["last_post"]), + lastRootPost: json["last_root_post"] == null + ? null + : DateTime.parse(json["last_root_post"]), + lastVoteTime: json["last_vote_time"] == null + ? null + : DateTime.parse(json["last_vote_time"]), + lifetimeVoteCount: json["lifetime_vote_count"], + marketHistory: json["market_history"] == null + ? [] + : List.from(json["market_history"]!.map((x) => x)), + memoKey: json["memo_key"], + mined: json["mined"], + name: json["name"], + nextVestingWithdrawal: json["next_vesting_withdrawal"] == null + ? null + : DateTime.parse(json["next_vesting_withdrawal"]), + openRecurrentTransfers: json["open_recurrent_transfers"], + otherHistory: json["other_history"] == null + ? [] + : List.from(json["other_history"]!.map((x) => x)), + owner: + json["owner"] == null ? null : ActiveModel.fromJson(json["owner"]), + pendingClaimedAccounts: json["pending_claimed_accounts"], + pendingTransfers: json["pending_transfers"], + postBandwidth: json["post_bandwidth"], + postCount: json["post_count"], + postHistory: json["post_history"] == null + ? [] + : List.from(json["post_history"]!.map((x) => x)), + postVotingPower: json["post_voting_power"], + posting: json["posting"] == null + ? null + : ActiveModel.fromJson(json["posting"]), + postingJsonMetadata: json["posting_json_metadata"].isNotEmpty + ? UserPostingJsonMetadata.fromRawJson(json["posting_json_metadata"]) + : null, + postingRewards: json["posting_rewards"], + previousOwnerUpdate: json["previous_owner_update"] == null + ? null + : DateTime.parse(json["previous_owner_update"]), + proxiedVsfVotes: json["proxied_vsf_votes"] == null + ? [] + : List.from(json["proxied_vsf_votes"]!.map((x) { + if (x is String) { + return int.parse(x); + } else { + return x; + } + })), + proxy: json["proxy"], + receivedVestingShares: json["received_vesting_shares"], + recoveryAccount: json["recovery_account"], + reputation: json["reputation"], + resetAccount: json["reset_account"], + rewardHbdBalance: json["reward_hbd_balance"], + rewardHiveBalance: json["reward_hive_balance"], + rewardVestingBalance: json["reward_vesting_balance"], + rewardVestingHive: json["reward_vesting_hive"], + savingsBalance: json["savings_balance"], + savingsHbdBalance: json["savings_hbd_balance"], + savingsHbdLastInterestPayment: + json["savings_hbd_last_interest_payment"] == null + ? null + : DateTime.parse(json["savings_hbd_last_interest_payment"]), + savingsHbdSeconds: json["savings_hbd_seconds"], + savingsHbdSecondsLastUpdate: + json["savings_hbd_seconds_last_update"] == null + ? null + : DateTime.parse(json["savings_hbd_seconds_last_update"]), + savingsWithdrawRequests: json["savings_withdraw_requests"], + tagsUsage: json["tags_usage"] == null + ? [] + : List.from(json["tags_usage"]!.map((x) => x)), + toWithdraw: json["to_withdraw"], + transferHistory: json["transfer_history"] == null + ? [] + : List.from(json["transfer_history"]!.map((x) => x)), + vestingBalance: json["vesting_balance"], + vestingShares: json["vesting_shares"], + vestingWithdrawRate: json["vesting_withdraw_rate"], + voteHistory: json["vote_history"] == null + ? [] + : List.from(json["vote_history"]!.map((x) => x)), + votingManabar: json["voting_manabar"] == null + ? null + : VotingManabarModel.fromJson(json["voting_manabar"]), + votingPower: json["voting_power"], + withdrawRoutes: json["withdraw_routes"], + withdrawn: json["withdrawn"], + witnessVotes: json["witness_votes"] == null + ? [] + : List.from(json["witness_votes"]!.map((x) => x)), + witnessesVotedFor: json["witnesses_voted_for"], + ); + + Map toJson() => { + "active": active?.toJson(), + "balance": balance, + "can_vote": canVote, + "comment_count": commentCount, + "created": created.toIso8601String(), + "curation_rewards": curationRewards, + "delayed_votes": delayedVotes == null + ? [] + : List.from(delayedVotes!.map((x) => x.toJson())), + "delegated_vesting_shares": delegatedVestingShares, + "downvote_manabar": downvoteManabar?.toJson(), + "governance_vote_expiration_ts": + governanceVoteExpirationTs?.toIso8601String(), + "guest_bloggers": guestBloggers == null + ? [] + : List.from(guestBloggers!.map((x) => x)), + "hbd_balance": hbdBalance, + "hbd_last_interest_payment": hbdLastInterestPayment?.toIso8601String(), + "hbd_seconds": hbdSeconds, + "hbd_seconds_last_update": hbdSecondsLastUpdate?.toIso8601String(), + "id": id, + "json_metadata": jsonMetadata, + "last_account_recovery": lastAccountRecovery?.toIso8601String(), + "last_account_update": lastAccountUpdate?.toIso8601String(), + "last_owner_update": lastOwnerUpdate?.toIso8601String(), + "last_post": lastPost?.toIso8601String(), + "last_root_post": lastRootPost?.toIso8601String(), + "last_vote_time": lastVoteTime?.toIso8601String(), + "lifetime_vote_count": lifetimeVoteCount, + "market_history": marketHistory == null + ? [] + : List.from(marketHistory!.map((x) => x)), + "memo_key": memoKey, + "mined": mined, + "name": name, + "next_vesting_withdrawal": nextVestingWithdrawal?.toIso8601String(), + "open_recurrent_transfers": openRecurrentTransfers, + "other_history": otherHistory == null + ? [] + : List.from(otherHistory!.map((x) => x)), + "owner": owner?.toJson(), + "pending_claimed_accounts": pendingClaimedAccounts, + "pending_transfers": pendingTransfers, + "post_bandwidth": postBandwidth, + "post_count": postCount, + "post_history": postHistory == null + ? [] + : List.from(postHistory!.map((x) => x)), + "post_voting_power": postVotingPower, + "posting": posting?.toJson(), + "posting_json_metadata": postingJsonMetadata?.toJson(), + "posting_rewards": postingRewards, + "previous_owner_update": previousOwnerUpdate?.toIso8601String(), + "proxied_vsf_votes": proxiedVsfVotes == null + ? [] + : List.from(proxiedVsfVotes!.map((x) => x)), + "proxy": proxy, + "received_vesting_shares": receivedVestingShares, + "recovery_account": recoveryAccount, + "reputation": reputation, + "reset_account": resetAccount, + "reward_hbd_balance": rewardHbdBalance, + "reward_hive_balance": rewardHiveBalance, + "reward_vesting_balance": rewardVestingBalance, + "reward_vesting_hive": rewardVestingHive, + "savings_balance": savingsBalance, + "savings_hbd_balance": savingsHbdBalance, + "savings_hbd_last_interest_payment": + savingsHbdLastInterestPayment?.toIso8601String(), + "savings_hbd_seconds": savingsHbdSeconds, + "savings_hbd_seconds_last_update": + savingsHbdSecondsLastUpdate?.toIso8601String(), + "savings_withdraw_requests": savingsWithdrawRequests, + "tags_usage": tagsUsage == null + ? [] + : List.from(tagsUsage!.map((x) => x)), + "to_withdraw": toWithdraw, + "transfer_history": transferHistory == null + ? [] + : List.from(transferHistory!.map((x) => x)), + "vesting_balance": vestingBalance, + "vesting_shares": vestingShares, + "vesting_withdraw_rate": vestingWithdrawRate, + "vote_history": voteHistory == null + ? [] + : List.from(voteHistory!.map((x) => x)), + "voting_manabar": votingManabar?.toJson(), + "voting_power": votingPower, + "withdraw_routes": withdrawRoutes, + "withdrawn": withdrawn, + "witness_votes": witnessVotes == null + ? [] + : List.from(witnessVotes!.map((x) => x)), + "witnesses_voted_for": witnessesVotedFor, + }; +} diff --git a/lib/src/models/user_account/voting_manabar_model.dart b/lib/src/models/user_account/voting_manabar_model.dart new file mode 100644 index 00000000..c73905d2 --- /dev/null +++ b/lib/src/models/user_account/voting_manabar_model.dart @@ -0,0 +1,28 @@ +class VotingManabarModel { + final int? currentMana; + final int? lastUpdateTime; + + VotingManabarModel({ + this.currentMana, + this.lastUpdateTime, + }); + + VotingManabarModel copyWith({ + int? currentMana, + int? lastUpdateTime, + }) => + VotingManabarModel( + currentMana: currentMana ?? this.currentMana, + lastUpdateTime: lastUpdateTime ?? this.lastUpdateTime, + ); + + factory VotingManabarModel.fromJson(Map json) => VotingManabarModel( + currentMana: json["current_mana"], + lastUpdateTime: json["\"last_update_time"], + ); + + Map toJson() => { + "current_mana": currentMana, + "\"last_update_time": lastUpdateTime, + }; +} diff --git a/lib/src/models/user_profile/request/user_followers_request.dart b/lib/src/models/user_profile/request/user_followers_request.dart new file mode 100644 index 00000000..69797c5a --- /dev/null +++ b/lib/src/models/user_profile/request/user_followers_request.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; +import 'package:acela/src/utils/safe_convert.dart'; + +class UserFollowerRequest { + // 2.0 + final String jsonrpc; + + // bridge.get_profile + final String method; + final List params; + + // 1 + final int id; + + UserFollowerRequest({ + this.jsonrpc = "", + this.method = "", + required this.params, + this.id = 0, + }); + + factory UserFollowerRequest.fromJson(Map? json) => + UserFollowerRequest( + jsonrpc: asString(json, 'jsonrpc'), + method: asString(json, 'method'), + params: asList(json, 'params').map((e) => e.toString()).toList(), + id: asInt(json, 'id'), + ); + + factory UserFollowerRequest.followers(String owner) => UserFollowerRequest( + jsonrpc: "2.0", + method: "condenser_api.get_followers", + params: [owner, null, "blog"], + id: 1); + + factory UserFollowerRequest.following(String owner) => UserFollowerRequest( + jsonrpc: "2.0", + method: "condenser_api.get_following", + params: [owner, null, "blog"], + id: 1); + + Map toJson() => { + 'jsonrpc': jsonrpc, + 'method': method, + 'params': params.map((e) => e).toList(), + 'id': id, + }; + + String toJsonString() => json.encode(toJson()); +} \ No newline at end of file diff --git a/lib/src/models/user_profile/request/user_profile_request.dart b/lib/src/models/user_profile/request/user_profile_request.dart new file mode 100644 index 00000000..39e270ff --- /dev/null +++ b/lib/src/models/user_profile/request/user_profile_request.dart @@ -0,0 +1,62 @@ +import 'dart:convert'; +import 'package:acela/src/utils/safe_convert.dart'; + +class UserProfileRequest { + // 2.0 + final String jsonrpc; + + // bridge.get_profile + final String method; + final UserProfileRequestParams params; + + // 1 + final int id; + + UserProfileRequest({ + this.jsonrpc = "", + this.method = "", + required this.params, + this.id = 0, + }); + + factory UserProfileRequest.fromJson(Map? json) => + UserProfileRequest( + jsonrpc: asString(json, 'jsonrpc'), + method: asString(json, 'method'), + params: UserProfileRequestParams.fromJson(asMap(json, 'params')), + id: asInt(json, 'id'), + ); + + factory UserProfileRequest.forOwner(String owner) => UserProfileRequest( + jsonrpc: "2.0", + method: "bridge.get_profile", + params: UserProfileRequestParams(account: owner), + id: 1); + + Map toJson() => { + 'jsonrpc': jsonrpc, + 'method': method, + 'params': params.toJson(), + 'id': id, + }; + + String toJsonString() => json.encode(toJson()); +} + +class UserProfileRequestParams { + // sagarkothari88 + final String account; + + UserProfileRequestParams({ + this.account = "", + }); + + factory UserProfileRequestParams.fromJson(Map? json) => + UserProfileRequestParams( + account: asString(json, 'account'), + ); + + Map toJson() => { + 'account': account, + }; +} diff --git a/lib/src/models/user_profile/response/followers_and_following.dart b/lib/src/models/user_profile/response/followers_and_following.dart new file mode 100644 index 00000000..e40bf944 --- /dev/null +++ b/lib/src/models/user_profile/response/followers_and_following.dart @@ -0,0 +1,63 @@ +import 'dart:convert'; +import 'package:acela/src/utils/safe_convert.dart'; + +class Followers { + // 2.0 + final String jsonrpc; + final List result; + // 1 + final int id; + + Followers({ + this.jsonrpc = "", + required this.result, + this.id = 0, + }); + + factory Followers.fromJson(Map? json) => Followers( + jsonrpc: asString(json, 'jsonrpc'), + result: asList(json, 'result').map((e) => FollowerItem.fromJson(e)).toList(), + id: asInt(json, 'id'), + ); + + factory Followers.fromJsonString(String jsonString) => Followers.fromJson( + json.decode(jsonString), + ); + + Map toJson() => { + 'jsonrpc': jsonrpc, + 'result': result.map((e) => e.toJson()), + 'id': id, + }; +} + +class FollowerItem { + // lepe + final String follower; + // madefrance + final String following; + final List what; + + FollowerItem({ + this.follower = "", + this.following = "", + required this.what, + }); + + factory FollowerItem.fromJson(Map? json) => FollowerItem( + follower: asString(json, 'follower'), + following: asString(json, 'following'), + what: asList(json, 'what').map((e) => e.toString()).toList(), + ); + + factory FollowerItem.fromJsonString(String jsonString) => FollowerItem.fromJson( + json.decode(jsonString), + ); + + Map toJson() => { + 'follower': follower, + 'following': following, + 'what': what.map((e) => e), + }; +} + diff --git a/lib/src/models/user_profile/response/user_profile.dart b/lib/src/models/user_profile/response/user_profile.dart new file mode 100644 index 00000000..6148a52d --- /dev/null +++ b/lib/src/models/user_profile/response/user_profile.dart @@ -0,0 +1,176 @@ +import 'dart:convert'; +import 'package:acela/src/utils/safe_convert.dart'; + +class UserProfileResponse { + // 2.0 + final String jsonrpc; + final UserProfile result; + // 1 + final int id; + + UserProfileResponse({ + this.jsonrpc = "", + required this.result, + this.id = 0, + }); + + factory UserProfileResponse.fromJson(Map? json) => UserProfileResponse( + jsonrpc: asString(json, 'jsonrpc'), + result: UserProfile.fromJson(asMap(json, 'result')), + id: asInt(json, 'id'), + ); + + factory UserProfileResponse.fromString(String string) { + return UserProfileResponse.fromJson(json.decode(string)); + } + + Map toJson() => { + 'jsonrpc': jsonrpc, + 'result': result.toJson(), + 'id': id, + }; +} + +class UserProfile { + // 2267004 + final int id; + // sagarkothari88 + final String name; + // 2021-11-18T17:43:36 + final String created; + // 2022-02-23T00:27:15 + final String active; + // 82 + final int postCount; + // 61.36 + final double reputation; + final UserProfileStats stats; + final UserProfileMetadata metadata; + + UserProfile({ + this.id = 0, + this.name = "", + this.created = "", + this.active = "", + this.postCount = 0, + this.reputation = 0.0, + required this.stats, + required this.metadata, + }); + + factory UserProfile.fromJson(Map? json) => UserProfile( + id: asInt(json, 'id'), + name: asString(json, 'name'), + created: asString(json, 'created'), + active: asString(json, 'active'), + postCount: asInt(json, 'post_count'), + reputation: asDouble(json, 'reputation'), + stats: UserProfileStats.fromJson(asMap(json, 'stats')), + metadata: UserProfileMetadata.fromJson(asMap(json, 'metadata')), + ); + + Map toJson() => { + 'id': id, + 'name': name, + 'created': created, + 'active': active, + 'post_count': postCount, + 'reputation': reputation, + 'stats': stats.toJson(), + 'metadata': metadata.toJson(), + }; +} + +class UserProfileStats { + // 0 + final int rank; + // 9 + final int following; + // 18 + final int followers; + + UserProfileStats({ + this.rank = 0, + this.following = 0, + this.followers = 0, + }); + + factory UserProfileStats.fromJson(Map? json) => UserProfileStats( + rank: asInt(json, 'rank'), + following: asInt(json, 'following'), + followers: asInt(json, 'followers'), + ); + + Map toJson() => { + 'rank': rank, + 'following': following, + 'followers': followers, + }; +} + + +class UserProfileMetadata { + final UserProfileMetadataProfile profile; + + UserProfileMetadata({ + required this.profile, + }); + + factory UserProfileMetadata.fromJson(Map? json) => UserProfileMetadata( + profile: UserProfileMetadataProfile.fromJson(asMap(json, 'profile')), + ); + + Map toJson() => { + 'profile': profile.toJson(), + }; +} + +class UserProfileMetadataProfile { + // sagar.kothari.88 + final String name; + // Block chain based Social Media - Mobile App Developer + final String about; + final String website; + // On Internet + final String location; + // https://files.peakd.com/file/peakd-hive/sagarkothari88/sagar.kothari.png + final String coverImage; + // https://files.peakd.com/file/peakd-hive/sagarkothari88/None_32e767de-b206-4f60-a4ca-b22f51f29d8c.jpg + final String profileImage; + final String blacklistDescription; + final String mutedListDescription; + + UserProfileMetadataProfile({ + this.name = "", + this.about = "", + this.website = "", + this.location = "", + this.coverImage = "", + this.profileImage = "", + this.blacklistDescription = "", + this.mutedListDescription = "", + }); + + factory UserProfileMetadataProfile.fromJson(Map? json) => UserProfileMetadataProfile( + name: asString(json, 'name'), + about: asString(json, 'about'), + website: asString(json, 'website'), + location: asString(json, 'location'), + coverImage: asString(json, 'cover_image'), + profileImage: asString(json, 'profile_image'), + blacklistDescription: asString(json, 'blacklist_description'), + mutedListDescription: asString(json, 'muted_list_description'), + ); + + Map toJson() => { + 'name': name, + 'about': about, + 'website': website, + 'location': location, + 'cover_image': coverImage, + 'profile_image': profileImage, + 'blacklist_description': blacklistDescription, + 'muted_list_description': mutedListDescription, + }; +} + diff --git a/lib/src/models/user_stream/hive_user_stream.dart b/lib/src/models/user_stream/hive_user_stream.dart new file mode 100644 index 00000000..ddcf53e5 --- /dev/null +++ b/lib/src/models/user_stream/hive_user_stream.dart @@ -0,0 +1,53 @@ +class HiveKeychainData { + String hasId; + String hasExpiry; + String hasAuthKey; + HiveKeychainData({ + required this.hasId, + required this.hasExpiry, + required this.hasAuthKey, + }); +} + +class HiveSocketData { + String authKey; + String encryptedData; + + HiveSocketData({ + required this.authKey, + required this.encryptedData, + }); +} + +class HiveUserData { + String? username; + String? postingKey; + String? cookie; + String? language; + HiveKeychainData? keychainData; + String resolution; + String rpc; + String union; + bool loaded; + String? accessToken; + late bool postingAuthority; + + HiveUserData( + {required this.username, + required this.postingKey, + required this.keychainData, + required this.accessToken, + required this.cookie, + required this.resolution, + required this.rpc, + required this.union, + required this.loaded, + required this.language, + required String? postingAuthority,}) { + if (postingAuthority != null) { + this.postingAuthority = postingAuthority == 'true'; + } else { + this.postingAuthority = false; + } + } +} diff --git a/lib/src/models/video_details_model/video_details.dart b/lib/src/models/video_details_model/video_details.dart new file mode 100644 index 00000000..e74c516b --- /dev/null +++ b/lib/src/models/video_details_model/video_details.dart @@ -0,0 +1,221 @@ +import 'dart:convert'; +import 'dart:developer'; +import 'package:acela/src/global_provider/ipfs_node_provider.dart'; +import 'package:acela/src/models/my_account/video_ops.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/utils/communicator.dart'; +import 'package:acela/src/utils/safe_convert.dart'; +import 'package:collection/collection.dart'; + +List videoItemsFromString(String string) { + final jsonList = json.decode(string) as List; + final list = jsonList.map((e) => VideoDetails.fromJson(e)).toList(); + return list; +} + +class VideoDetails { + final String created; + final bool paid; + final int views; + final List tagsV2; + final String id; + final String community; + final String owner; + final bool steemPosted; + final String status; + final String playUrl; + final String language; + final int? encodingProgress; + final String thumbnail; + + final String video_v2; + final String description; + final String title; + final String tags; + final String permlink; + final double duration; + final int size; + final String originalFilename; + final bool firstUpload; + final String beneficiaries; + final bool isPowerUp; + final String visible_status; + + String getThumbnail() { + return thumbnail.replaceAll('ipfs://', IpfsNodeProvider().nodeUrl); + } + + String getVideoUrl(HiveUserData data) { + if (playUrl.contains('ipfs')) { + // example + // https://ipfs-3speak.b-cdn.net/ipfs/QmTRDJcgtt66pxs3ZnQCdRw57b69NS2TQvF4yHwaux5grT/manifest.m3u8 + // https://ipfs-3speak.b-cdn.net/ipfs/QmTRDJcgtt66pxs3ZnQCdRw57b69NS2TQvF4yHwaux5grT/480p/index.m3u8 + return playUrl.replaceAll('manifest', '${data.resolution}/index'); + } else { + // example + // https://threespeakvideo.b-cdn.net/chjwguvd/default.m3u8 + // https://threespeakvideo.b-cdn.net/chjwguvd/480p.m3u8 + return playUrl.replaceAll('default', '${data.resolution}'); + } + } + + String rootVideoV2M3U8() { + if (video_v2.contains('ipfs')) { + // example + // https://ipfs-3speak.b-cdn.net/ipfs/QmTRDJcgtt66pxs3ZnQCdRw57b69NS2TQvF4yHwaux5grT/manifest.m3u8 + // https://ipfs-3speak.b-cdn.net/ipfs/QmTRDJcgtt66pxs3ZnQCdRw57b69NS2TQvF4yHwaux5grT/480p/index.m3u8 + // https://ipfs-3speak.b-cdn.net/ipfs/QmWADpD1PWPnmYVkSZvgokU5vcN2qZqvsHCA985GZ5Jf4r/manifest.m3u8 + var url = video_v2.replaceAll('ipfs://', IpfsNodeProvider().nodeUrl); + log('Root Play url is - $url'); + return url; + } + return video_v2; + } + + String videoV2M3U8(HiveUserData data) { + if (video_v2.contains('ipfs')) { + // example + // https://ipfs-3speak.b-cdn.net/ipfs/QmTRDJcgtt66pxs3ZnQCdRw57b69NS2TQvF4yHwaux5grT/manifest.m3u8 + // https://ipfs-3speak.b-cdn.net/ipfs/QmTRDJcgtt66pxs3ZnQCdRw57b69NS2TQvF4yHwaux5grT/480p/index.m3u8 + // https://ipfs-3speak.b-cdn.net/ipfs/QmWADpD1PWPnmYVkSZvgokU5vcN2qZqvsHCA985GZ5Jf4r/manifest.m3u8 + var url = video_v2 + .replaceAll('ipfs://', IpfsNodeProvider().nodeUrl) + .replaceAll('manifest', '${data.resolution}/index'); + log('Play url is - $url'); + return url; + } + return video_v2; + } + + String get thumbnailValue { + if (thumbnail.startsWith("http")) { + return thumbnail; + } + return '${Communicator.threeSpeakCDN}/ipfs/${thumbnail.replaceAll("ipfs://", '')}'; + } + + String get videoValue { + if (video_v2.startsWith("http")) { + return thumbnail; + } + return '${Communicator.threeSpeakCDN}/ipfs/${video_v2.replaceAll("ipfs://", '')}'; + } + + VideoDetails({ + this.created = "", + this.paid = false, + this.views = 0, + required this.tagsV2, + this.id = "", + this.community = "", + this.permlink = "", + this.duration = 0.0, + this.size = 0, + this.owner = "", + this.description = "", + this.thumbnail = "", + this.title = "", + this.language = "", + this.playUrl = "", + this.steemPosted = false, + this.status = "", + this.isPowerUp = false, + required this.encodingProgress, + required this.video_v2, + required this.tags, + required this.originalFilename, + required this.firstUpload, + required this.beneficiaries, + required this.visible_status, + }); + + factory VideoDetails.fromJsonString(String jsonString) => + VideoDetails.fromJson(json.decode(jsonString)); + + List get benes { + if (beneficiaries == "[]") { + return [ + BeneficiariesJson(account: 'sagarkothari88', src: 'mobile', weight: 1), + BeneficiariesJson( + account: 'spk.beneficiary', src: 'threespeak', weight: 10), + ]; + } else { + try { + var array = BeneficiariesJson.fromJsonString(beneficiaries); + List beneficiariesToSet = []; + for (var item in array) { + var name = item.account; + var weight = item.weight; + if ((weight / 100) >= 1) { + beneficiariesToSet.add( + BeneficiariesJson( + account: name, + weight: weight ~/ 100, + src: item.src, + ), + ); + } + } + return beneficiariesToSet; + } catch (e) { + return [ + BeneficiariesJson( + account: 'sagarkothari88', src: 'mobile', weight: 1), + BeneficiariesJson( + account: 'spk.beneficiary', src: 'threespeak', weight: 10), + ]; + } + } + } + + factory VideoDetails.fromJson(Map? json) => VideoDetails( + created: asString(json, 'created'), + paid: asBool(json, 'paid'), + views: asInt(json, 'views'), + tagsV2: asList(json, 'tags_v2').map((e) => e.toString()).toList(), + id: asString(json, '_id'), + encodingProgress: json != null ? asInt(json, 'encodingProgress') : null, + community: asString(json, 'community'), + permlink: asString(json, 'permlink'), + duration: asDouble(json, 'duration'), + size: asInt(json, 'size'), + owner: asString(json, 'owner'), + description: asString(json, 'description'), + thumbnail: asString(json, 'thumbnail'), + title: asString(json, 'title'), + language: asString(json, 'language'), + playUrl: asString(json, 'playUrl'), + isPowerUp: asBool(json, 'rewardPowerup'), + steemPosted: asBool(json, 'steemPosted'), + status: asString(json, 'status'), + tags: asString(json, 'tags'), + video_v2: asString(json, 'video_v2'), + originalFilename: asString(json, 'originalFilename'), + firstUpload: asBool(json, 'firstUpload'), + beneficiaries: asString(json, 'beneficiaries'), + visible_status: asString(json, 'visible_status'), + ); + + String toJsonString() => json.encode(toJson()); + + Map toJson() => { + 'created': created, + 'paid': paid, + 'views': views, + 'tags_v2': tagsV2.map((e) => e), + '_id': id, + 'community': community, + 'permlink': permlink, + 'duration': duration, + 'size': size, + 'owner': owner, + 'rewardPowerup' : isPowerUp, + 'description': description, + 'thumbnail': thumbnail, + 'title': title, + 'language': language, + 'playUrl': playUrl, + 'steemPosted': steemPosted, + 'status': status, + }; +} diff --git a/lib/src/models/video_details_model/video_details_description.dart b/lib/src/models/video_details_model/video_details_description.dart index f6bfaf6c..0983456d 100644 --- a/lib/src/models/video_details_model/video_details_description.dart +++ b/lib/src/models/video_details_model/video_details_description.dart @@ -1,8 +1,11 @@ import 'dart:convert'; +import 'package:acela/src/utils/safe_convert.dart'; -VideoDetailsDescription videoDetailsDescriptionFromJson(String str) => VideoDetailsDescription.fromJson(json.decode(str)); +VideoDetailsDescription videoDetailsDescriptionFromJson(String str) => + VideoDetailsDescription.fromJson(json.decode(str)); -String videoDetailsDescriptionToJson(VideoDetailsDescription data) => json.encode(data.toJson()); +String videoDetailsDescriptionToJson(VideoDetailsDescription data) => + json.encode(data.toJson()); class VideoDetailsDescription { VideoDetailsDescription({ @@ -11,11 +14,12 @@ class VideoDetailsDescription { String description; - factory VideoDetailsDescription.fromJson(Map json) => VideoDetailsDescription( - description: json["description"] ?? "", - ); + factory VideoDetailsDescription.fromJson(Map? json) => + VideoDetailsDescription( + description: asString(json, 'description'), + ); Map toJson() => { - "description": description, + 'description': description }; -} \ No newline at end of file +} diff --git a/lib/src/models/video_recommendation_models/video_recommendation.dart b/lib/src/models/video_recommendation_models/video_recommendation.dart new file mode 100644 index 00000000..824f107a --- /dev/null +++ b/lib/src/models/video_recommendation_models/video_recommendation.dart @@ -0,0 +1,41 @@ +import 'dart:convert'; + +import 'package:acela/src/utils/safe_convert.dart'; + +List videoRecommendationItemsFromJson(String str) { + final jsonList = json.decode(str) as List; + return jsonList.map((e) => VideoRecommendationItem.fromJson(e)).toList(); +} + +class VideoRecommendationItem { + // https://img.3speakcontent.co/asxmrbot/poster.png + final String image; + // I am alive challenge day 131 // crossing the bridge + final String title; + // asxmrbot + final String mediaid; + // dobro2020 + final String owner; + + VideoRecommendationItem({ + this.image = "", + this.title = "", + this.mediaid = "", + this.owner = "", + }); + + factory VideoRecommendationItem.fromJson(Map? json) => + VideoRecommendationItem( + image: asString(json, 'image'), + title: asString(json, 'title'), + mediaid: asString(json, 'mediaid'), + owner: asString(json, 'owner'), + ); + + Map toJson() => { + 'image': image, + 'title': title, + 'mediaid': mediaid, + 'owner': owner, + }; +} diff --git a/lib/src/models/video_upload/does_post_exists.dart b/lib/src/models/video_upload/does_post_exists.dart new file mode 100644 index 00000000..54f058f4 --- /dev/null +++ b/lib/src/models/video_upload/does_post_exists.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; + +import 'package:acela/src/utils/safe_convert.dart'; + +class DoesPostExistsResponse { + final DoesPostExistsResponseError? error; + + DoesPostExistsResponse({ + required this.error, + }); + + factory DoesPostExistsResponse.fromJson(Map? json) => + DoesPostExistsResponse( + error: DoesPostExistsResponseError.fromJson(asMap(json, 'error')), + ); + + factory DoesPostExistsResponse.fromJsonString(String jsonString) => + DoesPostExistsResponse.fromJson(json.decode(jsonString)); +} + +class DoesPostExistsResponseError { + final String data; + + DoesPostExistsResponseError({ + this.data = "", + }); + + factory DoesPostExistsResponseError.fromJson(Map? json) => + DoesPostExistsResponseError( + data: asString(json, 'data'), + ); +} diff --git a/lib/src/models/video_upload/platform_video_info.dart b/lib/src/models/video_upload/platform_video_info.dart new file mode 100644 index 00000000..ecbcfbae --- /dev/null +++ b/lib/src/models/video_upload/platform_video_info.dart @@ -0,0 +1,35 @@ +import 'dart:convert'; + +import 'package:acela/src/utils/safe_convert.dart'; + +class PlatformVideoInfo { + final int? size; + final String path; + final String? oFilename; + final int? duration; + + PlatformVideoInfo({ + required this.size, + required this.path, + required this.oFilename, + required this.duration, + }); + + factory PlatformVideoInfo.fromJson(Map? json) => + PlatformVideoInfo( + size: asInt(json, 'size'), + path: asString(json, 'path'), + oFilename: asString(json, 'oFilename'), + duration: asInt(json, 'duration'), + ); + + factory PlatformVideoInfo.fromJsonString(String jsonString) => + PlatformVideoInfo.fromJson(json.decode(jsonString)); + + Map toJson() => { + 'size': size, + 'path': path, + 'oFilename': oFilename, + 'duration': duration, + }; +} diff --git a/lib/src/models/video_upload/upload_response.dart b/lib/src/models/video_upload/upload_response.dart new file mode 100644 index 00000000..f8e8a3bd --- /dev/null +++ b/lib/src/models/video_upload/upload_response.dart @@ -0,0 +1,11 @@ +import 'package:equatable/equatable.dart'; + +class UploadResponse extends Equatable{ + final String name; + final String url; + + UploadResponse({required this.name, required this.url}); + + @override + List get props => [name,url]; +} diff --git a/lib/src/models/video_upload/video_device_encode_upload_model.dart b/lib/src/models/video_upload/video_device_encode_upload_model.dart new file mode 100644 index 00000000..f5b6578c --- /dev/null +++ b/lib/src/models/video_upload/video_device_encode_upload_model.dart @@ -0,0 +1,122 @@ +import 'dart:convert'; + +import 'package:acela/src/models/my_account/video_ops.dart'; + +class VideoDeviceEncodeUploadModel { + final String originalFilename; + final int duration; + final int size; + final int width; + final int height; + final String owner; + final String title; + final String description; + final bool isReel; + final bool isNsfwContent; + final String tags; + final String communityID; + final List beneficiaries; + final bool rewardPowerup; + final String tusId; + final bool publishLater; + final bool scheduled; + final DateTime? publishData; + + VideoDeviceEncodeUploadModel( + {required this.originalFilename, + required this.duration, + required this.size, + required this.width, + required this.height, + required this.owner, + required this.title, + required this.description, + required this.isReel, + required this.isNsfwContent, + required this.tags, + required this.communityID, + required this.beneficiaries, + required this.rewardPowerup, + required this.tusId, + required this.publishLater, + required this.scheduled, + required this.publishData + }); + + VideoDeviceEncodeUploadModel copyWith( + {String? originalFilename, + int? duration, + int? size, + int? width, + int? height, + String? owner, + String? title, + String? description, + bool? isReel, + bool? isNsfwContent, + String? tags, + String? communityID, + List? beneficiaries, + bool? rewardPowerup, + String? tusId, + bool? publishLater, + bool? scheduled, + DateTime? publishData + }) { + return VideoDeviceEncodeUploadModel( + originalFilename: originalFilename ?? this.originalFilename, + duration: duration ?? this.duration, + size: size ?? this.size, + width: width ?? this.width, + height: height ?? this.height, + owner: owner ?? this.owner, + title: title ?? this.title, + description: description ?? this.description, + isReel: isReel ?? this.isReel, + isNsfwContent: isNsfwContent ?? this.isNsfwContent, + tags: tags ?? this.tags, + communityID: communityID ?? this.communityID, + beneficiaries: beneficiaries ?? this.beneficiaries, + rewardPowerup: rewardPowerup ?? this.rewardPowerup, + tusId: tusId ?? this.tusId, + publishLater: publishLater ?? this.publishLater, + scheduled: scheduled ?? this.scheduled, + publishData: publishData ?? this.publishData, + ); + } + + Map toJson() { + var bene = beneficiaries + .map((e) => e.copyWith(account: e.account.toLowerCase())) + .toList() + ..sort( + (a, b) => a.account.toLowerCase().compareTo(b.account.toLowerCase())); + Map map = { + 'originalFilename': originalFilename, + 'duration': duration, + 'size': size, + 'width': width, + 'height': height, + 'owner': owner, + 'title': title, + 'description': description, + 'isReel': isReel, + 'isNsfwContent': isNsfwContent, + 'tags': tags, + 'communityID': communityID, + 'beneficiaries': json.encode(bene.map((e) => e.toJson()).toList()), + 'rewardPowerup': rewardPowerup, + 'tusId': tusId, + 'publishLater': publishLater, + 'scheduled' : scheduled + }; + if(publishData != null){ + map['publishData'] = publishData!.toUtc().toIso8601String().replaceAll("Z", "+00:00");; + } + return map; + } + + String toJsonString() { + return jsonEncode(toJson()); + } +} diff --git a/lib/src/models/video_upload/video_info.dart b/lib/src/models/video_upload/video_info.dart new file mode 100644 index 00000000..b3c7574b --- /dev/null +++ b/lib/src/models/video_upload/video_info.dart @@ -0,0 +1,39 @@ +class VideoInfo { + final String? originalFilename; + final int? duration; + final int? size; + final int? width; + final int? height; + final String? tusId; + final bool? isLandscape; + + VideoInfo({ + this.originalFilename, + this.duration, + this.size, + this.width, + this.height, + this.tusId, + this.isLandscape + }); + + VideoInfo copyWith({ + String? originalFilename, + int? duration, + int? size, + int? width, + int? height, + String? tusId, + bool? isLandscape + }) { + return VideoInfo( + originalFilename: originalFilename ?? this.originalFilename, + duration: duration ?? this.duration, + size: size ?? this.size, + width: width ?? this.width, + height: height ?? this.height, + tusId: tusId ?? this.tusId, + isLandscape: isLandscape ?? this.isLandscape + ); + } +} diff --git a/lib/src/models/video_upload/video_upload_complete_request.dart b/lib/src/models/video_upload/video_upload_complete_request.dart new file mode 100644 index 00000000..fb94d022 --- /dev/null +++ b/lib/src/models/video_upload/video_upload_complete_request.dart @@ -0,0 +1,85 @@ +import 'dart:convert'; + +class VideoUploadCompleteRequest { + final String videoId; + final String title; + final String description; + final bool isNsfwContent; + final String tags; + final String? thumbnail; + final String communityID; + final String? beneficiaries; + + VideoUploadCompleteRequest( + {required this.videoId, + required this.title, + required this.description, + required this.isNsfwContent, + required this.tags, + required this.thumbnail, + required this.communityID, + required this.beneficiaries}); + + Map toJson() { + var map = { + 'videoId': videoId, + 'title': title, + 'description': description, + 'isNsfwContent': isNsfwContent, + 'tags': tags, + 'communityID': communityID, + 'beneficiaries': beneficiaries + }; + if (thumbnail != null && thumbnail!.isNotEmpty) { + map['thumbnail'] = thumbnail!; + } + return map; + } + + String toJsonString() => json.encode(toJson()); +} + +class VideoThumbUpdateRequest { + final String videoId; + final String thumbnail; + + VideoThumbUpdateRequest({ + required this.videoId, + required this.thumbnail, + }); + + Map toJson() { + return {'videoId': videoId, 'thumbnail': thumbnail}; + } + + String toJsonString() => json.encode(toJson()); +} + +class NewVideoUploadCompleteRequest { + final String oFilename; + final int duration; + final double size; + final String filename; + final String thumbnail; + final String owner; + + NewVideoUploadCompleteRequest({ + required this.oFilename, + required this.duration, + required this.size, + required this.filename, + required this.thumbnail, + required this.owner, + }); + + Map toJson() => { + 'filename': filename, + 'oFilename': oFilename, + 'size': size, + 'duration': duration, + 'thumbnail': thumbnail, + 'owner': owner, + }; + + String toJsonString() => json.encode(toJson()); +} diff --git a/lib/src/models/video_upload/video_upload_login_response.dart b/lib/src/models/video_upload/video_upload_login_response.dart new file mode 100644 index 00000000..312be9cb --- /dev/null +++ b/lib/src/models/video_upload/video_upload_login_response.dart @@ -0,0 +1,31 @@ +import 'dart:convert'; + +import 'package:acela/src/utils/safe_convert.dart'; + +class VideoUploadLoginResponse { + final bool? banned; + final String? memo; + final String? userId; + final String? network; + final String? error; + + VideoUploadLoginResponse({ + required this.banned, + required this.memo, + required this.userId, + required this.network, + required this.error, + }); + + factory VideoUploadLoginResponse.fromJson(Map? json) => + VideoUploadLoginResponse( + banned: asBool(json, 'banned'), + memo: asString(json, 'memo'), + userId: asString(json, 'user_id'), + network: asString(json, 'network'), + error: asString(json, 'error'), + ); + + factory VideoUploadLoginResponse.fromJsonString(String jsonString) => + VideoUploadLoginResponse.fromJson(json.decode(jsonString)); +} diff --git a/lib/src/models/video_upload/video_upload_prepare_response.dart b/lib/src/models/video_upload/video_upload_prepare_response.dart new file mode 100644 index 00000000..de4c5b38 --- /dev/null +++ b/lib/src/models/video_upload/video_upload_prepare_response.dart @@ -0,0 +1,201 @@ +import 'dart:convert'; + +import 'package:acela/src/utils/safe_convert.dart'; + +class VideoUploadPrepareResponse { + final String signedUrl; + final String filename; + final double duration; + final String originalFilename; + final String status; + final VideoUploadInfo video; + final String uploadType; + + VideoUploadPrepareResponse({ + this.signedUrl = "", + this.filename = "", + this.duration = 0.0, + this.originalFilename = "", + this.status = "", + required this.video, + this.uploadType = "", + }); + + factory VideoUploadPrepareResponse.fromJson(Map? json) => + VideoUploadPrepareResponse( + signedUrl: asString(json, 'signed_url'), + filename: asString(json, 'filename'), + duration: asDouble(json, 'duration'), + originalFilename: asString(json, 'original_filename'), + status: asString(json, 'status'), + video: VideoUploadInfo.fromJson(asMap(json, 'video')), + uploadType: asString(json, 'upload_type'), + ); + + factory VideoUploadPrepareResponse.fromJsonString(String jsonString) => + VideoUploadPrepareResponse.fromJson(json.decode(jsonString)); + + Map toJson() => { + 'signed_url': signedUrl, + 'filename': filename, + 'duration': duration, + 'original_filename': originalFilename, + 'status': status, + 'video': video.toJson(), + 'upload_type': uploadType, + }; +} + +class VideoUploadInfo { + final bool updateSteem; + final bool lowRc; + final bool needsBlockchainUpdate; + final String status; + final String encodingPriceSteem; + final bool paid; + final int encodingProgress; + final String created; + final bool is3CJContent; + final bool isVOD; + final bool isNsfwContent; + final bool declineRewards; + final bool rewardPowerup; + final String language; + final String category; + final bool firstUpload; + final bool indexed; + final int views; + final String hive; + final bool upvoteEligible; + final String publishType; + final String beneficiaries; + final int votePercent; + final bool reducedUpvote; + final bool donations; + final bool postToHiveBlog; + final String id; + final String originalFilename; + final String permlink; + final double duration; + final int size; + final String owner; + final String uploadType; + final int v; + final String cdn; + + VideoUploadInfo({ + this.updateSteem = false, + this.lowRc = false, + this.needsBlockchainUpdate = false, + this.status = "", + this.encodingPriceSteem = "", + this.paid = false, + this.encodingProgress = 0, + this.created = "", + this.is3CJContent = false, + this.isVOD = false, + this.isNsfwContent = false, + this.declineRewards = false, + this.rewardPowerup = false, + this.language = "", + this.category = "", + this.firstUpload = false, + this.indexed = false, + this.views = 0, + this.hive = "", + this.upvoteEligible = false, + this.publishType = "", + this.beneficiaries = "", + this.votePercent = 0, + this.reducedUpvote = false, + this.donations = false, + this.postToHiveBlog = false, + this.id = "", + this.originalFilename = "", + this.permlink = "", + this.duration = 0.0, + this.size = 0, + this.owner = "", + this.uploadType = "", + this.v = 0, + this.cdn = "https://ipfs-3speak.b-cdn.net", + }); + + factory VideoUploadInfo.fromJsonString(String jsonString) => + VideoUploadInfo.fromJson(json.decode(jsonString)); + + factory VideoUploadInfo.fromJson(Map? json) => + VideoUploadInfo( + updateSteem: asBool(json, 'updateSteem'), + lowRc: asBool(json, 'lowRc'), + needsBlockchainUpdate: asBool(json, 'needsBlockchainUpdate'), + status: asString(json, 'status'), + encodingPriceSteem: asString(json, 'encoding_price_steem'), + paid: asBool(json, 'paid'), + encodingProgress: asInt(json, 'encodingProgress'), + created: asString(json, 'created'), + is3CJContent: asBool(json, 'is3CJContent'), + isVOD: asBool(json, 'isVOD'), + isNsfwContent: asBool(json, 'isNsfwContent'), + declineRewards: asBool(json, 'declineRewards'), + rewardPowerup: asBool(json, 'rewardPowerup'), + language: asString(json, 'language'), + category: asString(json, 'category'), + firstUpload: asBool(json, 'firstUpload'), + indexed: asBool(json, 'indexed'), + views: asInt(json, 'views'), + hive: asString(json, 'hive'), + upvoteEligible: asBool(json, 'upvoteEligible'), + publishType: asString(json, 'publish_type'), + beneficiaries: asString(json, 'beneficiaries'), + votePercent: asInt(json, 'votePercent'), + reducedUpvote: asBool(json, 'reducedUpvote'), + donations: asBool(json, 'donations'), + postToHiveBlog: asBool(json, 'postToHiveBlog'), + id: asString(json, '_id'), + originalFilename: asString(json, 'originalFilename'), + permlink: asString(json, 'permlink'), + duration: asDouble(json, 'duration'), + size: asInt(json, 'size'), + owner: asString(json, 'owner'), + uploadType: asString(json, 'upload_type'), + v: asInt(json, '__v'), + ); + + Map toJson() => { + 'updateSteem': updateSteem, + 'lowRc': lowRc, + 'needsBlockchainUpdate': needsBlockchainUpdate, + 'status': status, + 'encoding_price_steem': encodingPriceSteem, + 'paid': paid, + 'encodingProgress': encodingProgress, + 'created': created, + 'is3CJContent': is3CJContent, + 'isVOD': isVOD, + 'isNsfwContent': isNsfwContent, + 'declineRewards': declineRewards, + 'rewardPowerup': rewardPowerup, + 'language': language, + 'category': category, + 'firstUpload': firstUpload, + 'indexed': indexed, + 'views': views, + 'hive': hive, + 'upvoteEligible': upvoteEligible, + 'publish_type': publishType, + 'beneficiaries': beneficiaries, + 'votePercent': votePercent, + 'reducedUpvote': reducedUpvote, + 'donations': donations, + 'postToHiveBlog': postToHiveBlog, + '_id': id, + 'originalFilename': originalFilename, + 'permlink': permlink, + 'duration': duration, + 'size': size, + 'owner': owner, + 'upload_type': uploadType, + '__v': v, + }; +} diff --git a/lib/src/screens/about/about_faq.dart b/lib/src/screens/about/about_faq.dart new file mode 100644 index 00000000..3bf03266 --- /dev/null +++ b/lib/src/screens/about/about_faq.dart @@ -0,0 +1,50 @@ +import 'package:acela/src/screens/about/about_us.dart'; +import 'package:flutter/material.dart'; + +class AboutFaqScreen extends StatelessWidget { + const AboutFaqScreen({Key? key}) : super(key: key); + static List elements = [ + AboutUsElement( + title: "What is 3Speak?", + subtitle: + "3Speak is a place where content creators directly own their onsite assets and their communities. Using blockchain technology, the ownership of these assets and communities are intrinsic to the creator and the user, not 3 Speak. They are therefore transferable to other apps that use blockchain technology. This means that if we do not serve the community and creators in the best possible way, they can take the assets they have generated and move them to another app. The result is that 3Speak is censorship resistant, cannot take your assets away or delete your communities.\n\nOur policy is that the ability to be offensive is the bedrock of Freedom of Speech, and in turn Freedom of Speech protects societies from descending into chaos and civil war. Everyone has the right to their opinions, no matter how offensive some other people may find it (as long as it's not inciting violence or illegal of course). We especially welcome those talking about cryptocurrency and other emerging technologies which are threats to the establishment. Many of these content creators are being silenced because rich and powerful organisations do not want to be challenged. But we believe in Freedom of choice too!"), + AboutUsElement( + title: "Why am I not upvoted by 3Speak?", + subtitle: + "3Speak will vote at our own discretion and do not follow any specific criteria. The best way to attract our attention is to upload high-quality content and draw audiences and communities to 3speak."), + AboutUsElement( + title: "Why are some of my videos missing from the new feed?", + subtitle: + "We allow you to upload as many videos as you want! This means that sometimes one user could fill up feeds with just their content, to combat this, we limit the videos by any one creator that can be displayed per load."), + AboutUsElement( + title: "What are the guidelines?", + subtitle: + "We believe Freedom of Speech is absolute. As outlined above, there are some instances where we would have to restrict content, but for clarity here are 3speak's policies on various subjects:\n\nWe fully support your right to be offensive as long as it does not violate any of our terms (see below).\n\nCRITICISING RELIGION, BELIEFS, GROUPS, PEOPLE:\n\nSWEARING AND PROFANITY: (slander not allowed)\n\nOFFENSIVE JOKES: (If you are making a joke which could be construed as something illegal or slanderous, it might be a good idea to make it clear. As long as you aren't calling for people to be killed or harmed in any way.)\n\nALTERNATIVE POLITICS / CONSPIRACIES / CRITICIZING GOVERNMENTS & WORLD LEADERS:\n\nPSEUDONYMS: \n\nCALLING FOR OR INCITEMENT TO VIOLENCE: \n\nSHOWING OF EXCESSIVE GORE OR PORN:"), + AboutUsElement( + title: "How do I become a content creator?", + subtitle: + "The quickest way to get a hive account is to press the \"Sign up\" button in the navigation panel and follow the instructions. (dont loose your keys!).\nNext you will need to log in with your hive account and click on the \"creator studio\" / upload.\nYou're all set up and ready to go!"), + AboutUsElement( + title: "How do I earn rewards for commenting?", + subtitle: + "In order to earn rewards, you need a Hive blockchain account. There are a few ways to get a Hive account:\n\n1. Get one for free from Hive (https://signup.hive.io). This can take some time to get approved however.\n2. Purchase a Hive user guide, which comes with a free, instant Hive account here. However, with value adding, useful or articulate commenting you should be able to earn this back in a few days, or after uploading a couple of videos.\n3. Get another Hive user who can claim accounts to give one to you\n\nOnce you have an account, you need to login with Hivesigner. You will need to provide your ACTIVE key ONLY on the first login. Then you can post comments which can earn.\n\nIf you want to comment without earning cryptocurrency rewards, you can simply login with email or via your facebook, google, twitter or instagram accounts."), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('FREQUENTLY ASKED QUESTIONS'), + ), + body: ListView.separated( + itemBuilder: (c, i) { + return ListTile( + title: Text(elements[i].title), + subtitle: Text(elements[i].subtitle), + ); + }, + separatorBuilder: (c, i) => const Divider(), + itemCount: elements.length), + ); + } +} diff --git a/lib/src/screens/about/about_home_screen.dart b/lib/src/screens/about/about_home_screen.dart new file mode 100644 index 00000000..79f1a318 --- /dev/null +++ b/lib/src/screens/about/about_home_screen.dart @@ -0,0 +1,128 @@ +import 'package:acela/src/screens/about/about_faq.dart'; +import 'package:acela/src/screens/about/about_us.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class AboutHomeScreen extends StatelessWidget { + const AboutHomeScreen({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('3Speak.tv'), + ), + body: ListView( + children: [ + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('About us'), + onTap: () { + var screen = const AboutUsScreen(); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + }, + ), + ListTile( + leading: const FaIcon(FontAwesomeIcons.question), + title: const Text('FAQ'), + onTap: () { + var screen = const AboutFaqScreen(); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + }, + ), + ListTile( + leading: const FaIcon(FontAwesomeIcons.twitter), + title: const Text('Follow us on Twitter'), + onTap: () { + launchUrl(Uri.parse( + 'https://twitter.com/3speakonline??utm_source=3speak.tv.acela')); + }, + ), + ListTile( + leading: const FaIcon(FontAwesomeIcons.telegram), + title: const Text('Join us on Telegram'), + onTap: () { + launchUrl(Uri.parse( + 'https://t.me/threespeak?utm_source=3speak.tv.acela')); + }, + ), + ListTile( + leading: const FaIcon(FontAwesomeIcons.discord), + title: const Text('Join us on Discord'), + onTap: () { + launchUrl(Uri.parse( + 'https://discord.gg/NSFS2VGj83?utm_source=3speak.tv.acela')); + }, + ), + ListTile( + leading: const FaIcon(FontAwesomeIcons.blog), + title: const Text('Visit blog - hive.blog/@threespeak'), + onTap: () { + launchUrl(Uri.parse('https://hive.blog/@threespeak')); + }, + ), + ListTile( + leading: const Icon(Icons.picture_as_pdf), + title: const Text('Terms of service'), + onTap: () { + launchUrl(Uri.parse( + 'https://threespeakvideo.b-cdn.net/static/terms_of_service.pdf')); + }, + ), + ListTile( + leading: const Icon(Icons.how_to_vote), + title: const Text('Vote for 3Speak proposal'), + onTap: () { + launchUrl(Uri.parse( + 'https://peakd.com/hive-112019/@spknetwork/spk-network-funding-proposal-rhnv7e')); + }, + ), + ListTile( + leading: const Icon(Icons.phone_iphone), + title: const Text('Share 3Speak iOS App with others'), + onTap: () { + Share.share( + 'https://apps.apple.com/us/app/3speak/id1614771373'); + }, + ), + Visibility( + visible: defaultTargetPlatform == TargetPlatform.android, + child: ListTile( + leading: const Icon(Icons.android), + title: const Text('Share 3Speak Android App with others'), + onTap: () { + Share.share( + 'https://play.google.com/store/apps/details?id=tv.threespeak.app'); + }, + ), + ), + Visibility( + visible: defaultTargetPlatform == TargetPlatform.android, + child: ListTile( + leading: const Icon(Icons.check_box_outline_blank), + title: const Text( + 'Share 3Speak Android App dropbox-download-link with others'), + onTap: () { + Share.share( + 'https://www.dropbox.com/sh/a0q5u7l3j9ygzty/AABAqtxnLrPBYbk4q5H9BBWja?dl=0'); + }, + ), + ), + ListTile( + leading: const Icon(Icons.developer_mode), + title: const Text('Contact Dev - @sagarkothari88'), + onTap: () { + launchUrl(Uri.parse( + 'https://hivesigner.com/sign/account-witness-vote?witness=sagarkothari88&approve=1')); + }, + ), + ], + ), + ); + } +} diff --git a/lib/src/screens/about/about_us.dart b/lib/src/screens/about/about_us.dart new file mode 100644 index 00000000..b8ee3070 --- /dev/null +++ b/lib/src/screens/about/about_us.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; + +class AboutUsElement { + String title; + String subtitle; + + AboutUsElement({required this.title, required this.subtitle}); +} + +class AboutUsScreen extends StatelessWidget { + const AboutUsScreen({Key? key}) : super(key: key); + static List elements = [ + AboutUsElement( + title: "3SPEAK, PROTECT YOUR CONTENT, TOKENISE YOUR COMMUNITY", + subtitle: + "3Speak is a place where content creators directly own their onsite assets and their communities. Using blockchain technology, the ownership of these assets and communities are intrinsic to the creator and the user, not 3 Speak. They are therefore transferable to other apps that use blockchain technology. This means that if we do not serve the community and creators in the best possible way, they can take the assets they have generated and move them to another app. The result is that 3Speak is censorship resistant, cannot take your assets away or delete your communities."), + AboutUsElement( + title: "REWARDS", + subtitle: + "By using the platform, users get rewarded in Hive tokens and can receive donations in our proprietary Speak token. The more of these tokens you hold, the more privileges you have in the eco system. Additionally, the more tokens you hold, the more say you have over the governance of the platform and where it goes in future."), + AboutUsElement( + title: "P2P", + subtitle: + "The blockchain technology that the site uses ensures that content creators have true P2P connections to their user base, without any middle parties."), + AboutUsElement( + title: "TOKENISATION", + subtitle: + "Content creators can also easily create their own tokens, market places, stake driven rewards and economies to back their communities"), + AboutUsElement( + title: "FREE SPEECH", + subtitle: + "Our policy is that the ability to be offensive is the bedrock of Freedom of Speech, and in turn Freedom of Speech protects societies from descending into chaos and civil war. Everyone has the right to their opinions, no matter how offensive some other people may find it (as long as it's not inciting violence or illegal of course). We especially welcome those talking about cryptocurrency and other emerging technologies which are threats to the establishment. Many of these content creators are being silenced because rich and powerful organisations do not want to be challenged. But we believe in Freedom of choice too!"), + AboutUsElement( + title: "CITIZEN JOURNALISM", + subtitle: + "We also encourage citizen journalists to join us too, and post the kind of content which is often ignored. We believe that citizen journalists are the future, and we invite them to come and join our Citizen Journalist Tag and Community"), + AboutUsElement( + title: "George Orwell", + subtitle: + "If liberty means anything at all, it means the right to tell people what they do not want to hear."), + AboutUsElement( + title: "Voltaire", + subtitle: + "I disapprove of what you say, but I will defend to the death your right to say it."), + AboutUsElement( + title: "Philip Sharp", + subtitle: + "The right to free speech and the unrealistic expectation to never be offended can not coexist."), + AboutUsElement( + title: "Marshall Lumsden", + subtitle: + "At no time is freedom of speech more precious than when a man hits his thumb with a hammer."), + AboutUsElement( + title: "Alan Dershowitz", + subtitle: + "Freedom of speech means freedom for those who you despise, and freedom to express the most despicable views. It also means that the government cannot pick and choose which expressions to authorize and which to prevent."), + AboutUsElement( + title: "Anna Quindlen", + subtitle: + "Ignorant free speech often works against the speaker. That is one of several reasons why it must be given rein instead of suppressed."), + AboutUsElement( + title: "Brad Thor", + subtitle: "Freedom of speech includes the freedom to offend people."), + AboutUsElement( + title: "Newton Lee", + subtitle: + "There is a fine line between free speech and hate speech. Free speech encourages debate whereas hate speech incites violence."), + AboutUsElement( + title: "Hugo L. Black", + subtitle: + "Freedom of speech means that you shall not do something to people either for the views they have, or the views they express, or the words they speak or write."), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('ABOUT 3SPEAK'), + ), + body: ListView.separated( + itemBuilder: (c, i) { + return ListTile( + title: Text(elements[i].title), + subtitle: Text(elements[i].subtitle), + ); + }, + separatorBuilder: (c, i) => const Divider(), + itemCount: elements.length), + ); + } +} diff --git a/lib/src/screens/communities_screen/communities_screen.dart b/lib/src/screens/communities_screen/communities_screen.dart new file mode 100644 index 00000000..4dc1bbe7 --- /dev/null +++ b/lib/src/screens/communities_screen/communities_screen.dart @@ -0,0 +1,154 @@ +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/models/communities_models/response/communities_response_models.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/communities_screen/community_details/community_details_screen.dart'; +import 'package:acela/src/utils/communicator.dart'; +import 'package:acela/src/widgets/custom_circle_avatar.dart'; +import 'package:acela/src/widgets/loading_screen.dart'; +import 'package:acela/src/widgets/retry.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +class CommunitiesScreen extends StatefulWidget { + const CommunitiesScreen({ + Key? key, + required this.didSelectCommunity, + required this.withoutScaffold, + }) : super(key: key); + final Function(String, String)? didSelectCommunity; + final bool withoutScaffold; + + @override + _CommunitiesScreenState createState() => _CommunitiesScreenState(); +} + +class _CommunitiesScreenState extends State { + Future>? _future; + + TextEditingController searchController = TextEditingController(); + Widget _listTile(CommunityItem item) { + var formatter = NumberFormat(); + var extra = + "${item.about}\n\n${formatter.format(item.subscribers)} subscribers\n${formatter.format(item.numAuthors)} active posters"; + return ListTile( + leading: CustomCircleAvatar( + width: 60, + height: 60, + url: server.communityIcon(item.name), + ), + title: Text(item.title), + subtitle: Text(extra), + onTap: () { + if (widget.didSelectCommunity != null) { + widget.didSelectCommunity!(item.title, item.name); + Navigator.of(context).pop(); + } else { + var screen = + CommunityDetailScreen(name: item.name, title: item.title); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + } + }, + ); + } + + Widget _list(List data) { + return Stack( + children: [ + Container( + margin: EdgeInsets.only(top: 80), + child: ListView.separated( + itemBuilder: (context, index) { + return _listTile(data[index]); + }, + separatorBuilder: (context, index) => const Divider(), + itemCount: data.length, + ), + ), + Container( + margin: EdgeInsets.all(10), + child: TextFormField( + controller: searchController, + decoration: InputDecoration( + // icon: const Icon(Icons.search), + label: const Text('Search'), + hintText: 'Search community', + suffixIcon: ElevatedButton( + child: const Text('Search'), + onPressed: () { + setState(() { + _future = null; + }); + }, + ), + ), + autocorrect: false, + enabled: true, + ), + ), + ], + ); + } + + Future> getListOfCommunities(String hiveApiUrl) { + var value = searchController.value.text; + return Communicator() + .getListOfCommunities(value.isEmpty ? null : value, hiveApiUrl); + } + + Widget _body(HiveUserData appData) { + return FutureBuilder>( + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.hasError) { + return RetryScreen( + error: snapshot.error?.toString() ?? "Something went wrong", + onRetry: () { + getListOfCommunities(appData.rpc); + }, + ); + } else if (snapshot.hasData) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + child: _list(snapshot.data!.take(100).toList()), + ); + } else { + return RetryScreen( + error: "Something went wrong", + onRetry: () { + getListOfCommunities(appData.rpc); + }, + ); + } + } else { + return const LoadingScreen( + title: 'Loading Data', + subtitle: 'Please wait', + ); + } + }, + future: _future, + ); + } + + @override + Widget build(BuildContext context) { + var appData = Provider.of(context); + if (_future == null) { + setState(() { + _future = getListOfCommunities(appData.rpc); + }); + } + if (widget.withoutScaffold) { + return _body(appData); + } else { + return Scaffold( + appBar: AppBar( + title: const Text("Communities"), + ), + body: _body(appData), + ); + } + } +} diff --git a/lib/src/screens/communities_screen/community_details/community_details_screen.dart b/lib/src/screens/communities_screen/community_details/community_details_screen.dart new file mode 100644 index 00000000..43552d09 --- /dev/null +++ b/lib/src/screens/communities_screen/community_details/community_details_screen.dart @@ -0,0 +1,244 @@ +import 'dart:async'; + +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/models/communities_models/request/community_details_request.dart'; +import 'package:acela/src/models/communities_models/response/community_details_response_models.dart'; +import 'package:acela/src/models/home_screen_feed_models/home_feed.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/home_screen/home_screen_feed_list.dart'; +import 'package:acela/src/screens/stories/story_feed_list.dart'; +import 'package:acela/src/screens/video_details_screen/video_details_screen.dart'; +import 'package:acela/src/screens/video_details_screen/video_details_view_model.dart'; +import 'package:acela/src/utils/routes/routes.dart'; +import 'package:acela/src/utils/seconds_to_duration.dart'; +import 'package:acela/src/widgets/custom_circle_avatar.dart'; +import 'package:acela/src/widgets/loading_screen.dart'; +import 'package:acela/src/widgets/retry.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart'; +import 'package:http/http.dart' as http; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class CommunityDetailScreen extends StatefulWidget { + const CommunityDetailScreen( + {Key? key, required this.name, required this.title}) + : super(key: key); + final String name; + final String title; + + @override + _CommunityDetailScreenState createState() => _CommunityDetailScreenState(); +} + +class _CommunityDetailScreenState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + Map payout = {}; + static List tabs = [ + Tab(text: 'Videos'), + Tab( + icon: Image.asset( + 'assets/branding/three_shorts_icon.png', + width: 30, + height: 30, + ), + ), + Tab(text: 'About'), + Tab(text: 'Team') + ]; + + Future>? _loadingFeed; + Future? _details; + + Future> _loadHomeFeed() async { + var uri = + Uri.parse('${server.domain}/apiv2/feeds/community/${widget.name}/new'); + var response = await get(uri); + if (response.statusCode == 200) { + List list = homeFeedItemFromString(response.body); + return list; + } else { + throw 'Status code ${response.statusCode}'; + } + } + + Future _loadDetails(String hiveApiUrl) async { + var client = http.Client(); + var body = CommunityDetailsRequest.forName(widget.name).toJsonString(); + var response = + await client.post(Uri.parse('https://$hiveApiUrl'), body: body); + if (response.statusCode == 200) { + return CommunityDetailsResponse.fromString(response.body); + } else { + throw "Status code is ${response.statusCode}"; + } + } + + @override + void initState() { + super.initState(); + _tabController = TabController(length: tabs.length, vsync: this); + } + + @override + void dispose() { + super.dispose(); + _tabController.dispose(); + } + + void onTap(HomeFeedItem item) { + var viewModel = + VideoDetailsViewModel(author: item.author, permlink: item.permlink); + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => VideoDetailsScreen(vm: viewModel))); + } + + void onUserTap(HomeFeedItem item) { + context.pushNamed(Routes.userView, pathParameters: {'author': item.author}); + } + + Widget _screen(HiveUserData appData) { + return HomeScreenFeedList( + feedType: HomeScreenFeedType.community, + appData: appData, + community: widget.name, + ); + } + + Widget _shortsScreen(HiveUserData appData) { + return StoryFeedList( + appData: appData, + feedType: StoryFeedType.community, + community: widget.name, + ); + } + + String _generateMarkDown(CommunityDetailsResponse data) { + return "## About:\n${data.result.about}\n\n## Information:\n${data.result.description}\n\n## Flags:\n${data.result.flagText}\n\n## Total Authors:\n${data.result.numAuthors}\n\n## Subscribers:\n${data.result.subscribers}\n\n## Created At:\n${Utilities.parseAndFormatDateTime(data.result.createdAt)}"; + } + + Widget _descriptionMarkDown(String markDown) { + return Markdown( + data: Utilities.removeAllHtmlTags(markDown), + onTapLink: (text, url, title) { + launchUrl(Uri.parse(url ?? 'https://google.com')); + }, + ); + } + + Widget _about(HiveUserData appData) { + return FutureBuilder( + future: _details, + builder: (builder, snapshot) { + if (snapshot.hasError) { + return RetryScreen( + error: snapshot.error?.toString() ?? 'Something went wrong', + onRetry: () { + setState(() { + _details = null; + }); + }); + } else if (snapshot.hasData && + snapshot.connectionState == ConnectionState.done) { + CommunityDetailsResponse data = + snapshot.data! as CommunityDetailsResponse; + return _descriptionMarkDown(_generateMarkDown(data)); + } else { + return const LoadingScreen( + title: 'Loading Data', + subtitle: 'Please wait', + ); + } + }, + ); + } + + Widget _team(HiveUserData appData) { + return FutureBuilder( + future: _details, + builder: (builder, snapshot) { + if (snapshot.hasError) { + return RetryScreen( + error: snapshot.error?.toString() ?? 'Something went wrong', + onRetry: () { + setState(() { + _details = null; + }); + }, + ); + } else if (snapshot.hasData && + snapshot.connectionState == ConnectionState.done) { + CommunityDetailsResponse data = + snapshot.data! as CommunityDetailsResponse; + return ListView.separated( + itemBuilder: (context, index) { + return ListTile( + leading: CustomCircleAvatar( + height: 40, + width: 40, + url: server.userOwnerThumb(data.result.team[index][0]), + ), + title: Text(data.result.team[index][0]), + subtitle: Text(data.result.team[index][1]), + ); + }, + separatorBuilder: (context, index) => const Divider(), + itemCount: data.result.team.length, + ); + } else { + return const LoadingScreen( + title: 'Loading Data', + subtitle: 'Please wait', + ); + } + }, + ); + } + + @override + Widget build(BuildContext context) { + var appData = Provider.of(context); + if (_loadingFeed == null) { + setState(() { + _loadingFeed = _loadHomeFeed(); + }); + } + if (_details == null) { + setState(() { + _details = _loadDetails(appData.rpc); + }); + } + return Scaffold( + appBar: AppBar( + title: Row( + children: [ + CustomCircleAvatar( + height: 40, + width: 40, + url: server.communityIcon(widget.name), + ), + const SizedBox(width: 10), + Text(widget.title) + ], + ), + bottom: TabBar( + controller: _tabController, + tabs: tabs, + isScrollable: true, + ), + ), + body: TabBarView( + controller: _tabController, + children: [ + _screen(appData), + _shortsScreen(appData), + _about(appData), + _team(appData), + ], + ), + ); + } +} diff --git a/lib/src/screens/drawer_screen/drawer_screen.dart b/lib/src/screens/drawer_screen/drawer_screen.dart deleted file mode 100644 index 163f9ab1..00000000 --- a/lib/src/screens/drawer_screen/drawer_screen.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'package:flutter/material.dart'; - -class DrawerScreen extends StatelessWidget { - const DrawerScreen({Key? key}) : super(key: key); - - Widget _homeMenu() { - return ListTile( - leading: const Icon(Icons.home), - title: const Text("Home"), - onTap: () { - - }, - ); - } - - Widget _firstUploads() { - return ListTile( - leading: const Icon(Icons.emoji_emotions_outlined), - title: const Text("First Uploads"), - onTap: () { - - }, - ); - } - - Widget _trendingContent() { - return ListTile( - leading: const Icon(Icons.local_fire_department), - title: const Text("Trending Content"), - onTap: () { - - }, - ); - } - - Widget _newContent() { - return ListTile( - leading: const Icon(Icons.play_arrow), - title: const Text("New Content"), - onTap: () { - - }, - ); - } - - Widget _communities() { - return ListTile( - leading: const Icon(Icons.people_sharp), - title: const Text("Communities"), - onTap: () { - - }, - ); - } - - Widget _leaderBoard() { - return ListTile( - leading: const Icon(Icons.leaderboard), - title: const Text("Leaderboard"), - onTap: () { - - }, - ); - } - - Widget _drawerHeader(BuildContext context) { - return DrawerHeader( - child: Column( - children: [ - Image.asset( - "assets/branding/three_speak_icon.png", - width: 60, - height: 60, - ), - const SizedBox(height: 5), - Text( - "Acela", - style: Theme.of(context).textTheme.headline5, - ), - const SizedBox(height: 5), - Text( - "3Speak.tv", - style: Theme.of(context).textTheme.headline6, - ), - ], - ), - ); - } - - Widget _drawerMenu(BuildContext context) { - return ListView( - children: [ - _drawerHeader(context), - _homeMenu(), - const Divider(height: 1, color: Colors.blueGrey,), - _firstUploads(), - const Divider(height: 1, color: Colors.blueGrey,), - _trendingContent(), - const Divider(height: 1, color: Colors.blueGrey,), - _newContent(), - const Divider(height: 1, color: Colors.blueGrey,), - _communities(), - const Divider(height: 1, color: Colors.blueGrey,), - _leaderBoard(), - const Divider(height: 1, color: Colors.blueGrey,), - ], - ); - } - - @override - Widget build(BuildContext context) { - return Drawer( - child: _drawerMenu(context) - ); - } -} diff --git a/lib/src/screens/favourites/favourite_shorts_body.dart b/lib/src/screens/favourites/favourite_shorts_body.dart new file mode 100644 index 00000000..650a64d7 --- /dev/null +++ b/lib/src/screens/favourites/favourite_shorts_body.dart @@ -0,0 +1,38 @@ +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/stories/story_feed_body.dart'; +import 'package:acela/src/screens/video_details_screen/new_video_details/video_detail_favourite_provider.dart'; +import 'package:acela/src/utils/graphql/models/trending_feed_response.dart'; +import 'package:carousel_slider/carousel_slider.dart'; +import 'package:flutter/material.dart'; + +class FavouriteShortsBody extends StatefulWidget { + const FavouriteShortsBody({Key? key, required this.appData}) + : super(key: key); + + final HiveUserData appData; + + @override + State createState() => _FavouriteShortsBodyState(); +} + +class _FavouriteShortsBodyState extends State { + final CarouselSliderController controller = CarouselSliderController(); + final VideoFavoriteProvider dataProvider = VideoFavoriteProvider(); + + @override + Widget build(BuildContext context) { + final List shorts = + dataProvider.getLikedVideos(isShorts: true); + return shorts.isNotEmpty + ? StoryFeedDataBody( + onRemoveFavouriteCallback: () { + setState(() {}); + }, + items: shorts, + appData: widget.appData, + controller: controller) + : Center( + child: Text("No Bookmarked shorts found"), + ); + } +} diff --git a/lib/src/screens/favourites/favourite_tags_body.dart b/lib/src/screens/favourites/favourite_tags_body.dart new file mode 100644 index 00000000..2d2d71c0 --- /dev/null +++ b/lib/src/screens/favourites/favourite_tags_body.dart @@ -0,0 +1,38 @@ +import 'package:acela/src/screens/trending_tags/tag_favourite_provider.dart'; +import 'package:acela/src/screens/trending_tags/trending_tag_videos.dart'; +import 'package:flutter/material.dart'; + +class FavouriteTagsBody extends StatelessWidget { + const FavouriteTagsBody({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final TagFavoriteProvider dataProvider = TagFavoriteProvider(); + List items = dataProvider.getLikedTags(); + return items.isNotEmpty + ? ListView.builder( + itemCount: items.length, + itemBuilder: (context, index) { + String tag = items[index]; + return Dismissible( + key: Key(tag), + background: Center(child: Text("Delete")), + onDismissed: (direction) { + dataProvider.storeLikedTagLocally(tag, forceRemove: true); + }, + child: ListTile( + onTap: () { + var screen = TrendingTagVideos(tag: tag); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + }, + title: Text(tag), + ), + ); + }, + ) + : const Center( + child: Text("No Bookmarked tags found"), + ); + } +} diff --git a/lib/src/screens/favourites/favourite_users_body.dart b/lib/src/screens/favourites/favourite_users_body.dart new file mode 100644 index 00000000..2659c9a4 --- /dev/null +++ b/lib/src/screens/favourites/favourite_users_body.dart @@ -0,0 +1,45 @@ +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/screens/user_channel_screen/user_channel_screen.dart'; +import 'package:acela/src/screens/user_channel_screen/user_favourite_provider.dart'; +import 'package:acela/src/widgets/custom_circle_avatar.dart'; +import 'package:flutter/material.dart'; + +class FavouriteUsersBody extends StatelessWidget { + const FavouriteUsersBody({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final UserFavoriteProvider dataProvider = UserFavoriteProvider(); + List items = dataProvider.getBookmarkedUsers(); + return items.isNotEmpty + ? ListView.builder( + itemCount: items.length, + itemBuilder: (context, index) { + String user = items[index]; + return Dismissible( + key: Key(user), + background: Center(child: Text("Delete")), + onDismissed: (direction) { + dataProvider.storeLikedUserLocally(user, forceRemove: true); + }, + child: ListTile( + onTap: () { + var screen = UserChannelScreen(owner: user); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + }, + leading: CustomCircleAvatar( + height: 36, + width: 36, + url: server.userOwnerThumb(user), + ), + title: Text(user), + ), + ); + }, + ) + : const Center( + child: Text("No Bookmarked users found"), + ); + } +} diff --git a/lib/src/screens/favourites/favourite_video_body.dart b/lib/src/screens/favourites/favourite_video_body.dart new file mode 100644 index 00000000..f12e4875 --- /dev/null +++ b/lib/src/screens/favourites/favourite_video_body.dart @@ -0,0 +1,51 @@ +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/video_details_screen/new_video_details/video_detail_favourite_provider.dart'; +import 'package:acela/src/utils/graphql/models/trending_feed_response.dart'; +import 'package:acela/src/screens/home_screen/home_screen_feed_item/widgets/new_feed_list_item.dart'; +import 'package:flutter/material.dart'; + +class FavouriteVideoBody extends StatefulWidget { + const FavouriteVideoBody({Key? key, required this.appData}) : super(key: key); + + final HiveUserData appData; + + @override + State createState() => _FavouriteVideoBodyState(); +} + +class _FavouriteVideoBodyState extends State { + final VideoFavoriteProvider dataProvider = VideoFavoriteProvider(); + + @override + Widget build(BuildContext context) { + List items = dataProvider.getLikedVideos(); + return items.isNotEmpty + ? ListView.builder( + itemBuilder: (c, i) { + var item = items[i]; + return NewFeedListItem( + onFavouriteRemoved: () { + setState(() {}); + }, + thumbUrl: item.spkvideo?.thumbnailUrl ?? '', + author: item.author?.username ?? '', + title: item.title ?? '', + createdAt: item.createdAt ?? DateTime.now(), + duration: item.spkvideo?.duration ?? 0.0, + comments: item.stats?.numComments, + hiveRewards: item.stats?.totalHiveReward, + votes: item.stats?.numVotes, + views: 0, + permlink: item.permlink ?? '', + onTap: () {}, + onUserTap: () {}, + item: item, + appData: widget.appData); + }, + itemCount: items.length % 50 == 0 ? items.length + 1 : items.length, + ) + : Center( + child: Text("No Bookmarked vidoes found"), + ); + } +} diff --git a/lib/src/screens/favourites/user_favourites.dart b/lib/src/screens/favourites/user_favourites.dart new file mode 100644 index 00000000..9f75846e --- /dev/null +++ b/lib/src/screens/favourites/user_favourites.dart @@ -0,0 +1,108 @@ +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/favourites/favourite_shorts_body.dart'; +import 'package:acela/src/screens/favourites/favourite_tags_body.dart'; +import 'package:acela/src/screens/favourites/favourite_users_body.dart'; +import 'package:acela/src/screens/favourites/favourite_video_body.dart'; +import 'package:acela/src/screens/podcast/view/liked_podcasts.dart'; +import 'package:acela/src/screens/podcast/view/local_podcast_episode.dart'; +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:provider/provider.dart'; + +class UserFavourites extends StatefulWidget { + const UserFavourites({Key? key}) : super(key: key); + + @override + State createState() => _UserFavouritesState(); +} + +class _UserFavouritesState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + int currentIndex = 0; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 7, vsync: this); + _tabController.addListener(() { + setState(() { + currentIndex = _tabController.index; + }); + }); + } + + @override + void dispose() { + super.dispose(); + _tabController.dispose(); + } + + @override + Widget build(BuildContext context) { + var appData = Provider.of(context); + return Scaffold( + appBar: AppBar( + title: ListTile( + title: Text('Bookmarks'), + subtitle: Text(appBarSubtitle()), + ), + bottom: TabBar( + controller: _tabController, + tabs: [ + Tab(icon: const Icon(Icons.play_arrow)), + Tab(icon: const Icon(Icons.video_library_rounded)), + Tab(icon: const Icon(Icons.tag)), + Tab(icon: const Icon(Icons.person)), + Tab(icon: const Icon(Icons.podcasts)), + Tab(icon: const Icon(Icons.queue_music_rounded)), + Tab(icon: const Icon(FontAwesomeIcons.download)), + ], + ), + ), + body: TabBarView(controller: _tabController, children: [ + FavouriteVideoBody(appData: appData), + FavouriteShortsBody( + appData: appData, + ), + FavouriteTagsBody(), + FavouriteUsersBody(), + LikedPodcasts( + playOnMiniPlayer: false, + appData: appData, + showAppBar: false, + ), + LocalEpisodeListView( + isOffline: false, + playOnMiniPlayer: false, + ), + LocalEpisodeListView( + isOffline: true, + playOnMiniPlayer: false, + ), + ]), + ); + } + + String appBarSubtitle() { + switch (currentIndex) { + case 0: + return "Videos"; + case 1: + return "Shorts"; + case 2: + return "Tags"; + case 3: + return "Users"; + case 4: + return "Podcasts"; + case 5: + return "Podcast Episode"; + case 6: + return "Offline Podcast Episode"; + default: + return ""; + } + } +} diff --git a/lib/src/screens/home_screen/default_screen.dart b/lib/src/screens/home_screen/default_screen.dart new file mode 100644 index 00000000..3de6045c --- /dev/null +++ b/lib/src/screens/home_screen/default_screen.dart @@ -0,0 +1,25 @@ +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/home_screen/new_home_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class DefaultView extends StatelessWidget { + const DefaultView({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final userData = Provider.of(context); + return userData.loaded + ? userData.username != null + ? GQLFeedScreen(appData: userData, username: userData.username!) + : GQLFeedScreen(appData: userData, username: null) + : Scaffold( + appBar: AppBar(title: const Text('3Speak')), + body: const Center( + child: CircularProgressIndicator(), + ), + ); + } +} diff --git a/lib/src/screens/home_screen/home_screen.dart b/lib/src/screens/home_screen/home_screen.dart deleted file mode 100644 index d58f99d3..00000000 --- a/lib/src/screens/home_screen/home_screen.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:acela/src/models/home_screen_feed_models/home_feed_models.dart'; -import 'package:acela/src/screens/drawer_screen/drawer_screen.dart'; -import 'package:acela/src/screens/home_screen/home_screen_view_model.dart'; -import 'package:acela/src/screens/video_details_screen/video_details_screen.dart'; -import 'package:acela/src/widgets/retry.dart'; -import 'package:flutter/material.dart'; -import 'package:acela/src/screens/home_screen/home_screen_widgets.dart'; - -class HomeScreen extends StatefulWidget { - const HomeScreen({Key? key}) : super(key: key); - - @override - _HomeScreenState createState() => _HomeScreenState(); -} - -class _HomeScreenState extends State { - final widgets = HomeScreenWidgets(); - late HomeScreenViewModel vm; - - @override - void initState() { - super.initState(); - vm = HomeScreenViewModel(stateUpdated: () { - setState(() {}); - }); - vm.loadHomeFeed(); - } - - void onTap(HomeFeed item) { - Navigator.of(context).pushNamed(VideoDetailsScreen.routeName, - arguments: VideoDetailsScreenArguments(item)); - } - - Widget _screen() { - return FutureBuilder( - future: vm.getHomeFeed(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - if (snapshot.hasError) { - return RetryScreen( - error: snapshot.error as String, onRetry: vm.loadHomeFeed); - } else if (snapshot.hasData) { - return widgets.list( - snapshot.data as List, vm.loadHomeFeed, onTap); - } else { - return widgets.loadingData(); - } - } else { - return widgets.loadingData(); - } - }, - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Home'), - ), - body: _screen(), - drawer: const DrawerScreen(), - ); - } -} diff --git a/lib/src/screens/home_screen/home_screen_feed_item/controller/home_feed_video_controller.dart b/lib/src/screens/home_screen/home_screen_feed_item/controller/home_feed_video_controller.dart new file mode 100644 index 00000000..3ec40e0b --- /dev/null +++ b/lib/src/screens/home_screen/home_screen_feed_item/controller/home_feed_video_controller.dart @@ -0,0 +1,96 @@ +import 'package:acela/src/global_provider/video_setting_provider.dart'; +import 'package:better_player/better_player.dart'; +import 'package:flutter/material.dart'; + +class HomeFeedVideoController extends ChangeNotifier { + Duration? currentDuration; + Duration? totalDuration; + bool skippedToInitialDuartion = false; + bool isInitialized = false; + bool isUserOnAnotherScreen = false; + bool isPaused = false; + + void videoEventListener( + BetterPlayerController? betterPlayerController, BetterPlayerEvent event) { + + if (event.betterPlayerEventType == BetterPlayerEventType.hideFullscreen && + !betterPlayerController!.videoPlayerController!.value.isPlaying && + !isUserOnAnotherScreen) { + changeControlsVisibility(betterPlayerController, false); + } + } + + void didPopFullScreen(BetterPlayerController betterPlayerController) { + if (!betterPlayerController.videoPlayerController!.value.isPlaying && + !isUserOnAnotherScreen) { + changeControlsVisibility(betterPlayerController, false); + } + } + + void videoPlayerListener(BetterPlayerController? betterPlayerController, + VideoSettingProvider videoSettingProvider) { + if (betterPlayerController != null && + betterPlayerController.videoPlayerController != null && + betterPlayerController.videoPlayerController!.value.initialized) { + if (!isInitialized) { + isInitialized = true; + } + if (!betterPlayerController.isFullScreen) { + if (betterPlayerController.controlsEnabled && !isUserOnAnotherScreen) { + changeControlsVisibility(betterPlayerController, false); + } + } + if (betterPlayerController.videoPlayerController!.value.isPlaying && + isPaused) { + isPaused = false; + } else if (!betterPlayerController + .videoPlayerController!.value.isPlaying && + !isPaused) { + isPaused = true; + } + if (totalDuration == null) { + totalDuration = + betterPlayerController.videoPlayerController!.value.duration!; + } + if (!skippedToInitialDuartion) { + skippedToInitialDuartion = true; + if (currentDuration != null) { + Duration totalDuration = + betterPlayerController.videoPlayerController!.value.duration!; + if (totalDuration != currentDuration) { + betterPlayerController.seekTo(currentDuration!).then((value) => + betterPlayerController.videoPlayerController!.play()); + } + } + } + currentDuration = + betterPlayerController.videoPlayerController!.value.position; + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + notifyListeners(); + }); + if (betterPlayerController.videoPlayerController!.value.volume == 0.0 && + !videoSettingProvider.isMuted) { + videoSettingProvider.changeMuteStatus(true); + } else if (betterPlayerController.videoPlayerController!.value.volume != + 0.0 && + videoSettingProvider.isMuted) { + videoSettingProvider.changeMuteStatus(false); + } + } + } + + void changeControlsVisibility( + BetterPlayerController betterPlayerController, bool showControls) { + betterPlayerController.setControlsAlwaysVisible(showControls); + betterPlayerController.setControlsEnabled(showControls); + betterPlayerController.setControlsVisibility(showControls); + } + + void reset() { + isInitialized = false; + if (isUserOnAnotherScreen) { + isUserOnAnotherScreen = false; + } + notifyListeners(); + } +} diff --git a/lib/src/screens/home_screen/home_screen_feed_item/widgets/bottom_nav_bar.dart b/lib/src/screens/home_screen/home_screen_feed_item/widgets/bottom_nav_bar.dart new file mode 100644 index 00000000..7ce2ea06 --- /dev/null +++ b/lib/src/screens/home_screen/home_screen_feed_item/widgets/bottom_nav_bar.dart @@ -0,0 +1,274 @@ +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/home_screen/video_upload_sheet.dart'; +import 'package:acela/src/screens/login/ha_login_screen.dart'; +import 'package:acela/src/screens/my_account/my_account_screen.dart'; +import 'package:acela/src/screens/podcast/view/podcast_trending.dart'; +import 'package:acela/src/screens/search/search_screen.dart'; +import 'package:acela/src/screens/stories/new_tab_based_stories.dart'; +import 'package:acela/src/screens/upload/podcast/podcast_upload_screen.dart'; +import 'package:acela/src/screens/upload/video/controller/video_upload_controller.dart'; +import 'package:acela/src/screens/upload/video/video_upload_screen.dart'; +import 'package:acela/src/widgets/custom_circle_avatar.dart'; +import 'package:adaptive_action_sheet/adaptive_action_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class BottomNavBar extends StatefulWidget { + const BottomNavBar({required this.appData, this.username}); + + final HiveUserData appData; + final String? username; + + @override + State createState() => _BottomNavBarState(); +} + +class _BottomNavBarState extends State { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SafeArea( + child: SizedBox( + height: 65, + child: BottomNavigationBar( + showUnselectedLabels: true, + selectedFontSize: 11, + unselectedFontSize: 11, + type: BottomNavigationBarType.fixed, + items: navItems, + onTap: (index) => navigate(index, context), + selectedItemColor: theme.primaryColorLight, + unselectedItemColor: theme.primaryColorLight, + backgroundColor: theme.primaryColorDark, + ), + ), + ); + } + + List get navItems { + List items = []; + items.add( + BottomNavigationBarItem( + icon: SizedBox(height: 25, child: Icon(Icons.search)), + label: 'Search', + ), + ); + + items.add( + BottomNavigationBarItem( + icon: SizedBox( + height: 25, + child: Padding( + padding: const EdgeInsets.only(bottom: 0.0), + child: Image.asset( + 'assets/branding/three_shorts_icon.png', + height: 23, + width: 23, + ), + ), + ), + label: '3Shorts', + ), + ); + + if (widget.username != null) { + items.add( + BottomNavigationBarItem( + icon: SizedBox(height: 25, child: Icon(Icons.add)), + label: 'Upload', + ), + ); + } + + items.add( + BottomNavigationBarItem( + icon: SizedBox( + height: 25, + child: Image.asset( + 'assets/pod-cast-logo-round.png', + height: 23, + width: 23, + ), + ), + label: 'Podcast', + ), + ); + if (widget.username != null) { + items.add( + BottomNavigationBarItem( + icon: SizedBox( + height: 25, + child: widget.username == null + ? Icon(Icons.person) + : CustomCircleAvatar( + height: 23, + width: 23, + color: Theme.of(context).primaryColorLight == Colors.black + ? Colors.grey.shade400 + : Colors.grey.shade900, + url: + 'https://images.hive.blog/u/${widget.username ?? ''}/avatar', + )), + label: 'You', + ), + ); + } + return items; + } + + void navigate(int index, BuildContext context) { + if (widget.username != null) { + switch (index) { + case 0: + _onTapSearch(); + break; + case 1: + _onTapThreeShorts(); + break; + case 2: + _uploadBottomSheet(); + break; + case 3: + _onTapPodcast(); + break; + case 4: + _onAccountTap(); + break; + } + } else { + switch (index) { + case 0: + _onTapSearch(); + break; + case 1: + _onTapThreeShorts(); + break; + case 2: + _onTapPodcast(); + break; + } + } + } + + void _onTapThreeShorts() { + var screen = GQLStoriesScreen(appData: widget.appData); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + } + + Widget addPostButton(HiveUserData? userData) { + return Visibility( + visible: widget.username != null, + child: SizedBox( + width: 40, + child: IconButton( + color: Theme.of(context).primaryColorLight, + onPressed: () { + _uploadBottomSheet(); + }, + icon: Icon(Icons.add_circle), + )), + ); + } + + void _onTapPodcast() { + var screen = PodCastTrendingScreen(appData: widget.appData); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context, rootNavigator: true).push(route); + } + + void _onTapSearch() { + var route = MaterialPageRoute( + builder: (context) => const SearchScreen(), + ); + Navigator.of(context).push(route); + } + + void _onAccountTap() { + if (widget.username == null) { + _loginBottomSheet(); + return; + } else { + var screen = MyAccountScreen(data: widget.appData); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + } + } + + void _loginBottomSheet() { + showAdaptiveActionSheet( + context: context, + title: const Text('You are not logged in. Please log in.'), + androidBorderRadius: 30, + actions: [ + BottomSheetAction( + title: Text('Log in'), + leading: Icon(Icons.login), + onPressed: (c) { + Navigator.of(c).pop(); + var screen = HiveAuthLoginScreen(appData: widget.appData); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(c).push(route); + }), + ], + cancelAction: CancelAction(title: const Text('Cancel')), + ); + } + + void _uploadBottomSheet() { + if (widget.username == null) { + _loginBottomSheet(); + } else { + showAdaptiveActionSheet( + context: context, + title: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.upload), + const SizedBox( + width: 5, + ), + const Text( + 'Upload', + style: TextStyle(fontSize: 20), + ), + ], + ), + androidBorderRadius: 30, + actions: [ + BottomSheetAction( + title: const Text('Video'), + leading: const Icon(Icons.video_call), + onPressed: (c) { + Navigator.pop(context); + if (!context.read().isFreshUpload()) { + var screen = VideoUploadScreen( + isCamera: true, + appData: widget.appData, + isDeviceEncode: + context.read().isDeviceEncoding, + ); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + } else { + VideoUploadSheet.show(widget.appData, context); + } + }, + ), + BottomSheetAction( + title: const Text('Podcast'), + leading: const Icon(Icons.podcasts), + onPressed: (c) { + var route = MaterialPageRoute( + builder: (c) => PodcastUploadScreen(data: widget.appData)); + Navigator.of(context).pop(); + Navigator.of(context).push(route); + }), + ], + cancelAction: CancelAction( + title: const Text('Cancel'), + ), + ); + } + } +} diff --git a/lib/src/screens/home_screen/home_screen_feed_item/widgets/feed_item_grid_view.dart b/lib/src/screens/home_screen/home_screen_feed_item/widgets/feed_item_grid_view.dart new file mode 100644 index 00000000..be6ae309 --- /dev/null +++ b/lib/src/screens/home_screen/home_screen_feed_item/widgets/feed_item_grid_view.dart @@ -0,0 +1,101 @@ +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/home_screen/home_screen_feed_item/widgets/new_feed_list_item.dart'; +import 'package:acela/src/utils/graphql/models/trending_feed_response.dart'; +import 'package:flutter/material.dart'; + +class FeedItemGridView extends StatelessWidget { + const FeedItemGridView({ + Key? key, + required this.screenWidth, + required this.items, + required this.appData, + this.scrollController, + this.nextPageLoader, + }) : super(key: key); + + final double screenWidth; + final List items; + final HiveUserData appData; + final ScrollController? scrollController; + final Widget? nextPageLoader; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 15, horizontal: 20), + child: CustomScrollView( + controller: scrollController, + slivers: [ + FeedItemGridWidget(items: items,appData: appData), + SliverToBoxAdapter( + child: nextPageLoader, + ), + ], + ), + ); + } + + +} + +class FeedItemGridWidget extends StatelessWidget { + const FeedItemGridWidget({ + Key? key, + required this.items, + required this.appData, + }) : super(key: key); + + final List items; + final HiveUserData appData; + + @override + Widget build(BuildContext context) { + var width = MediaQuery.of(context).size.width ; + return SliverGrid.builder( + itemCount: items.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: getCrossAxisCount(width), + childAspectRatio: + MediaQuery.of(context).orientation == Orientation.landscape + ? 1.25 + : 1.4, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemBuilder: (context, index) { + var item = items[index]; + return NewFeedListItem( + isGridView: true, + key: ValueKey(index), + showVideo: false, + thumbUrl: item.spkvideo?.thumbnailUrl ?? '', + author: item.author?.username ?? '', + title: item.title ?? '', + createdAt: item.createdAt ?? DateTime.now(), + duration: item.spkvideo?.duration ?? 0.0, + comments: item.stats?.numComments ?? 0, + hiveRewards: item.stats?.totalHiveReward, + votes: item.stats?.numVotes, + views: 0, + permlink: item.permlink ?? '', + onTap: () {}, + onUserTap: () {}, + item: item, + appData: appData, + ); + }, + ); + } + + int getCrossAxisCount(double width) { + if (width > 1300) { + return 4; + } else if (width > 974 && width < 1300) { + return 3; + } else if (width > 650 && width < 974) { + return 2; + } else { + return 2; + } + } +} diff --git a/lib/src/screens/home_screen/home_screen_feed_item/widgets/home_feed_video_full_screen_button.dart b/lib/src/screens/home_screen/home_screen_feed_item/widgets/home_feed_video_full_screen_button.dart new file mode 100644 index 00000000..c17e7f50 --- /dev/null +++ b/lib/src/screens/home_screen/home_screen_feed_item/widgets/home_feed_video_full_screen_button.dart @@ -0,0 +1,60 @@ +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/home_screen/home_screen_feed_item/controller/home_feed_video_controller.dart'; +import 'package:acela/src/utils/graphql/models/trending_feed_response.dart'; +import 'package:better_player/better_player.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +class HomeFeedVideoFullScreenButton extends StatelessWidget { + const HomeFeedVideoFullScreenButton( + {Key? key, + required this.betterPlayerController, + required this.item, + required this.appData}) + : super(key: key); + + final BetterPlayerController betterPlayerController; + final GQLFeedItem item; + final HiveUserData appData; + + @override + Widget build(BuildContext context) { + bool isInitialized = context + .select((value) => value.isInitialized); + return Visibility( + visible: isInitialized, + child: CircleAvatar( + backgroundColor: Theme.of(context).primaryColorDark.withOpacity(0.5), + child: IconButton( + onPressed: () { + if (defaultTargetPlatform == TargetPlatform.android) { + context + .read() + .changeControlsVisibility(betterPlayerController, true); + betterPlayerController.enterFullScreen(); + } else { + fullscreenTapped(); + } + }, + icon: Icon( + Icons.fullscreen, + color: Colors.white, + )), + ), + ); + } + + void fullscreenTapped() async { + var position = await betterPlayerController.videoPlayerController?.position; + var seconds = position?.inSeconds; + if (seconds == null) return; + debugPrint('position is $position'); + const platform = MethodChannel('com.example.acela/auth'); + await platform.invokeMethod('playFullscreen', { + 'url': item.videoV2M3U8(appData), + 'seconds': seconds, + }); + } +} diff --git a/lib/src/screens/home_screen/home_screen_feed_item/widgets/home_feed_video_slider.dart b/lib/src/screens/home_screen/home_screen_feed_item/widgets/home_feed_video_slider.dart new file mode 100644 index 00000000..9ecc1253 --- /dev/null +++ b/lib/src/screens/home_screen/home_screen_feed_item/widgets/home_feed_video_slider.dart @@ -0,0 +1,52 @@ +import 'package:acela/src/screens/home_screen/home_screen_feed_item/controller/home_feed_video_controller.dart'; +import 'package:acela/src/utils/seconds_to_duration.dart'; +import 'package:better_player/better_player.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class HomeFeedVideoSlider extends StatelessWidget { + const HomeFeedVideoSlider({Key? key, required this.betterPlayerController}) + : super(key: key); + final BetterPlayerController? betterPlayerController; + + @override + Widget build(BuildContext context) { + double min = 0; + bool isInitialized = context + .select((value) => value.isInitialized); + Duration? currentDuration = + context.select( + (value) => value.currentDuration); + Duration? totalDuration = + context.select( + (value) => value.totalDuration); + return isInitialized && totalDuration != null && currentDuration != null + ? SliderTheme( + data: SliderThemeData( + trackHeight: 2.0, + thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7.0), + overlayShape: RoundSliderOverlayShape(overlayRadius: 0.0), + trackShape: RectangularSliderTrackShape(), + ), + child: Slider( + activeColor: Theme.of(context).primaryColorLight == Colors.black + ? Theme.of(context).primaryColor + : Theme.of(context).primaryColorLight, + inactiveColor: Theme.of(context).primaryColorLight == Colors.black + ? Theme.of(context).primaryColor.withOpacity(0.5) + : Theme.of(context).primaryColorLight.withOpacity(0.38), + min: min, + max: Utilities.durationToDouble(totalDuration), + value: (Utilities.durationToDouble(currentDuration) + .clamp(min, Utilities.durationToDouble(totalDuration))), + onChanged: (newValue) { + betterPlayerController! + .seekTo(Utilities.doubleToDuration(newValue)) + .then((value) => + betterPlayerController!.videoPlayerController!.play()); + }, + ), + ) + : const SizedBox.shrink(); + } +} diff --git a/lib/src/screens/home_screen/home_screen_feed_item/widgets/home_feed_video_timer.dart b/lib/src/screens/home_screen/home_screen_feed_item/widgets/home_feed_video_timer.dart new file mode 100644 index 00000000..1c0b9628 --- /dev/null +++ b/lib/src/screens/home_screen/home_screen_feed_item/widgets/home_feed_video_timer.dart @@ -0,0 +1,64 @@ +import 'package:acela/src/screens/home_screen/home_screen_feed_item/controller/home_feed_video_controller.dart'; +import 'package:acela/src/utils/seconds_to_duration.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class HomeFeedVideoTimer extends StatelessWidget { + const HomeFeedVideoTimer({Key? key, required this.totalDuration}) + : super(key: key); + + final double totalDuration; + + @override + Widget build(BuildContext context) { + bool isInitialized = context + .select((value) => value.isInitialized); + Duration? currentDuration = + context.select( + (value) => value.currentDuration); + Duration? totalDuration = + context.select( + (value) => value.totalDuration); + Duration? defaultDuration = Utilities.doubleToDuration(this.totalDuration); + return AnimatedPadding( + duration: const Duration(milliseconds: 150), + padding: EdgeInsets.only(bottom: isInitialized ? 8 : 0), + child: Container( + padding: EdgeInsets.symmetric( + vertical: 2, + horizontal: 4, + ), + decoration: BoxDecoration( + color: Theme.of(context).primaryColorDark.withOpacity(0.8), + borderRadius: BorderRadius.all( + Radius.circular(4), + ), + ), + child: Text( + isInitialized + ? remainingDuration( + totalDuration ?? defaultDuration, currentDuration) + : remainingDuration(defaultDuration, null), + style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold), + )), + ); + } + + String _formatDuration(Duration duration) { + int hours = duration.inHours; + int minutes = (duration.inMinutes % 60); + int seconds = (duration.inSeconds % 60); + + String formattedDuration = + '${hours > 0 ? hours.toString().padLeft(2, '0') + ':' : ''}${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + + return formattedDuration; + } + + String remainingDuration(Duration totalDuration, Duration? currentDuration) { + Duration remainingTime = + totalDuration - (currentDuration ?? Duration(milliseconds: 0)); + String formattedRemainingTime = _formatDuration(remainingTime); + return formattedRemainingTime; + } +} diff --git a/lib/src/screens/home_screen/home_screen_feed_item/widgets/mute_unmute_button.dart b/lib/src/screens/home_screen/home_screen_feed_item/widgets/mute_unmute_button.dart new file mode 100644 index 00000000..b55c9694 --- /dev/null +++ b/lib/src/screens/home_screen/home_screen_feed_item/widgets/mute_unmute_button.dart @@ -0,0 +1,56 @@ +import 'package:acela/src/global_provider/video_setting_provider.dart'; +import 'package:acela/src/screens/home_screen/home_screen_feed_item/controller/home_feed_video_controller.dart'; +import 'package:better_player/better_player.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class MuteUnmuteButton extends StatefulWidget { + const MuteUnmuteButton({required this.betterPlayerController}); + + final BetterPlayerController betterPlayerController; + + @override + State createState() => _MuteUnmuteButtonState(); +} + +class _MuteUnmuteButtonState extends State { + @override + Widget build(BuildContext context) { + bool isInitialized = context + .select((value) => value.isInitialized); + bool isMuted = + context.select((value) => value.isMuted); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + if (!isMuted && + widget.betterPlayerController.videoPlayerController!.value.volume == + 0.0) { + widget.betterPlayerController.setVolume(1); + } else if (isMuted && + widget.betterPlayerController.videoPlayerController!.value.volume != + 0.0) { + widget.betterPlayerController.setVolume(0); + } + }); + + return Visibility( + visible: isInitialized, + child: CircleAvatar( + backgroundColor: Theme.of(context).primaryColorDark.withOpacity(0.5), + child: IconButton( + icon: Icon( + isMuted ? Icons.volume_off : Icons.volume_up, + color: Colors.white, + ), + onPressed: () { + if (!isMuted) { + widget.betterPlayerController.setVolume(0); + } else { + widget.betterPlayerController.setVolume(1); + } + context.read().changeMuteStatus(!isMuted); + }, + ), + ), + ); + } +} diff --git a/lib/src/screens/home_screen/home_screen_feed_item/widgets/new_feed_list_item.dart b/lib/src/screens/home_screen/home_screen_feed_item/widgets/new_feed_list_item.dart new file mode 100644 index 00000000..c47ce1a9 --- /dev/null +++ b/lib/src/screens/home_screen/home_screen_feed_item/widgets/new_feed_list_item.dart @@ -0,0 +1,581 @@ +import 'dart:io'; + +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/global_provider/image_resolution_provider.dart'; +import 'package:acela/src/global_provider/video_setting_provider.dart'; +import 'package:acela/src/models/navigation_models/new_video_detail_screen_navigation_model.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/home_screen/home_screen_feed_item/controller/home_feed_video_controller.dart'; +import 'package:acela/src/screens/home_screen/home_screen_feed_item/widgets/home_feed_video_full_screen_button.dart'; +import 'package:acela/src/screens/home_screen/home_screen_feed_item/widgets/home_feed_video_slider.dart'; +import 'package:acela/src/screens/home_screen/home_screen_feed_item/widgets/home_feed_video_timer.dart'; +import 'package:acela/src/screens/home_screen/home_screen_feed_item/widgets/mute_unmute_button.dart'; +import 'package:acela/src/screens/home_screen/home_screen_feed_item/widgets/play_pause_button.dart'; +import 'package:acela/src/screens/home_screen/home_screen_feed_item/widgets/thumbnail_widget.dart'; +import 'package:acela/src/screens/report/widgets/report_pop_up_menu.dart'; +import 'package:acela/src/screens/video_details_screen/new_video_details/video_detail_favourite_provider.dart'; +import 'package:acela/src/screens/video_details_screen/video_details_screen.dart'; +import 'package:acela/src/screens/video_details_screen/video_details_view_model.dart'; +import 'package:acela/src/utils/enum.dart'; +import 'package:acela/src/utils/graphql/models/trending_feed_response.dart'; +import 'package:acela/src/utils/routes/routes.dart'; +import 'package:acela/src/utils/seconds_to_duration.dart'; +import 'package:acela/src/widgets/cached_image.dart'; +import 'package:acela/src/widgets/upvote_button.dart'; +import 'package:better_player/better_player.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart' as http; +import 'package:provider/provider.dart'; +import 'package:timeago/timeago.dart' as timeago; + +class NewFeedListItem extends StatefulWidget { + const NewFeedListItem( + {Key? key, + required this.createdAt, + required this.duration, + required this.views, + required this.thumbUrl, + required this.author, + required this.title, + required this.permlink, + required this.onTap, + required this.onUserTap, + required this.comments, + required this.votes, + required this.hiveRewards, + this.item, + required this.appData, + this.showVideo = false, + this.onFavouriteRemoved, + this.isGridView = false}) + : super(key: key); + + final DateTime? createdAt; + final double? duration; + final int? views; + final String thumbUrl; + final String author; + final String title; + final String permlink; + final int? votes; + final int? comments; + final double? hiveRewards; + final Function onTap; + final Function onUserTap; + final GQLFeedItem? item; + final HiveUserData appData; + final bool showVideo; + final VoidCallback? onFavouriteRemoved; + final bool isGridView; + + @override + State createState() => _NewFeedListItemState(); +} + +class _NewFeedListItemState extends State + with AutomaticKeepAliveClientMixin { + BetterPlayerController? _betterPlayerController; + late final VideoSettingProvider videoSettingProvider; + HomeFeedVideoController homeFeedVideoController = HomeFeedVideoController(); + final VideoFavoriteProvider favoriteProvider = VideoFavoriteProvider(); + + @override + void initState() { + videoSettingProvider = context.read(); + if (widget.showVideo) { + _initVideo(); + } + super.initState(); + } + + @override + void dispose() { + homeFeedVideoController.dispose(); + if (_betterPlayerController != null) { + _betterPlayerController!.videoPlayerController! + .removeListener(videoPlayerListener); + _betterPlayerController!.removeEventsListener(videoEventListener); + _betterPlayerController!.videoPlayerController?.dispose(); + _betterPlayerController!.dispose(); + } + super.dispose(); + } + + @override + void didUpdateWidget(covariant NewFeedListItem oldWidget) { + if (widget.showVideo && + _betterPlayerController == null && + !homeFeedVideoController.isUserOnAnotherScreen) { + _initVideo(); + } else if (oldWidget.showVideo && !widget.showVideo) { + if (_betterPlayerController != null) { + homeFeedVideoController.skippedToInitialDuartion = false; + _betterPlayerController!.videoPlayerController! + .removeListener(videoPlayerListener); + _betterPlayerController!.removeEventsListener(videoEventListener); + homeFeedVideoController.reset(); + _betterPlayerController!.videoPlayerController?.dispose(); + _betterPlayerController!.dispose(); + _betterPlayerController = null; + } + } + super.didUpdateWidget(oldWidget); + } + + void setupVideo( + String url, + ) { + BetterPlayerConfiguration betterPlayerConfiguration = + BetterPlayerConfiguration( + routePageBuilder: + (context, animation, secondaryAnimation, controllerProvider) => + PopScope( + onPopInvoked: (didPop) { + if (didPop) { + homeFeedVideoController.didPopFullScreen(_betterPlayerController!); + } + }, + child: BetterPlayer( + controller: _betterPlayerController!, + ), + ), + fit: BoxFit.contain, + autoPlay: true, + fullScreenByDefault: false, + controlsConfiguration: BetterPlayerControlsConfiguration( + enablePip: false, + enableFullscreen: defaultTargetPlatform == TargetPlatform.android, + enableSkips: true, + enableMute: true), + autoDetectFullscreenAspectRatio: false, + placeholder: + !widget.item!.isVideo ? videoThumbnail() : const SizedBox.shrink(), + deviceOrientationsOnFullScreen: const [ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + DeviceOrientation.portraitDown, + DeviceOrientation.portraitUp + ], + deviceOrientationsAfterFullScreen: const [ + DeviceOrientation.portraitDown, + DeviceOrientation.portraitUp + ], + autoDispose: false, + expandToFill: true, + allowedScreenSleep: false, + ); + BetterPlayerDataSource dataSource = BetterPlayerDataSource( + BetterPlayerDataSourceType.network, + (widget.item!.isVideo) + ? Platform.isAndroid + ? url.replaceAll("/manifest.m3u8", "/480p/index.m3u8") + : url + : widget.item!.playUrl!, + videoFormat: widget.item!.isVideo + ? BetterPlayerVideoFormat.hls + : BetterPlayerVideoFormat.other, + ); + setState(() { + _betterPlayerController = + BetterPlayerController(betterPlayerConfiguration); + }); + _betterPlayerController!.setupDataSource(dataSource); + homeFeedVideoController.changeControlsVisibility( + _betterPlayerController!, false); + } + + void _initVideo() async { + if (widget.item!.isVideo) { + var url = widget.item!.videoV2M3U8(widget.appData); + try { + var data = await http.get(Uri.parse(url)); + if (data.body.contains('failed to resolve /ipfs')) { + debugPrint('Invalid url. let\'s update it ${url}'); + url = widget.item!.mobileEncodedVideoUrl(); + } else { + debugPrint('Valid URL. lets not update it. - ${data.body}'); + } + } catch (e) { + debugPrint('Invalid url. let\'s update it ${url}'); + url = widget.item!.mobileEncodedVideoUrl(); + } + setupVideo(url); + } else { + setupVideo(widget.item!.playUrl!); + } + if (videoSettingProvider.isMuted) { + _betterPlayerController!.setVolume(0.0); + } + _betterPlayerController!.videoPlayerController! + .addListener(videoPlayerListener); + _betterPlayerController!.addEventsListener(videoEventListener); + } + + void videoPlayerListener() { + homeFeedVideoController.videoPlayerListener( + _betterPlayerController, videoSettingProvider); + } + + void videoEventListener(BetterPlayerEvent event) { + homeFeedVideoController.videoEventListener(_betterPlayerController, event); + } + + Widget videoThumbnail() { + return Selector( + selector: (context, myType) => myType.resolution, + builder: (context, value, child) { + return ThumbnailWidget( + image: Utilities.getProxyImage(value, widget.thumbUrl), + height: !widget.isGridView ? 230 : null, + width: double.infinity, + ); + }); + } + + Widget listTile() { + TextStyle titleStyle = + TextStyle(color: Theme.of(context).primaryColorLight, fontSize: 13); + Widget thumbnail = videoThumbnail(); + String timeInString = + widget.createdAt != null ? "${timeago.format(widget.createdAt!)}" : ""; + return InkWell( + onTap: () { + widget.onTap(); + if (widget.item == null) { + var viewModel = VideoDetailsViewModel( + author: widget.author, + permlink: widget.permlink, + ); + var screen = VideoDetailsScreen(vm: viewModel); + var route = MaterialPageRoute(builder: (context) => screen); + Navigator.of(context).push(route); + } else { + _pushToVideoDetailScreen(); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + children: [ + widget.isGridView + ? Expanded( + child: _videoStack(thumbnail), + ) + : _videoStack(thumbnail), + SizedBox( + height: widget.isGridView ? 75 : null, + child: Padding( + padding: const EdgeInsets.only( + top: 10.0, bottom: 5, left: 13, right: 13), + child: Row( + crossAxisAlignment: + !widget.isGridView && isTitleOneLine(titleStyle) + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + InkWell( + child: ClipOval( + child: CachedImage( + imageHeight: 40, + imageWidth: 40, + loadingIndicatorSize: 25, + imageUrl: server.userOwnerThumb(widget.author), + ), + ), + onTap: () { + widget.onUserTap(); + _pushToUserScreen(); + }, + ), + const SizedBox( + width: 10, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 2.0), + child: Row( + children: [ + Expanded( + child: Text( + widget.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: titleStyle, + ), + ), + Gap(10), + ReportPopUpMenu( + iconSize: 20, + type: Report.post, + author: widget.author, + permlink: widget.permlink, + ) + ], + ), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + child: Row( + children: [ + Text( + '${widget.author}', + style: TextStyle( + color: Theme.of(context) + .primaryColorLight + .withOpacity(0.7), + fontSize: 12), + ), + ], + ), + onTap: () { + widget.onUserTap(); + _pushToUserScreen(); + }, + ), + Expanded( + child: Text( + ' • $timeInString', + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + color: Theme.of(context) + .primaryColorLight + .withOpacity(0.7), + fontSize: 12), + )), + const SizedBox( + width: 15, + ), + UpvoteButton( + appData: widget.appData, + item: widget.item!, + votes: widget.votes, + ), + Padding( + padding: + const EdgeInsets.only(top: 2.5, left: 15), + child: Icon( + Icons.comment, + size: 14, + ), + ), + Padding( + padding: const EdgeInsets.only( + top: 1.0, + ), + child: Text( + ' ${widget.comments}', + style: TextStyle( + color: Theme.of(context) + .primaryColorLight + .withOpacity(0.7), + fontSize: 12), + ), + ), + // Padding( + // padding: + // const EdgeInsets.only(left: 10, top: 2.0, right: 5), + // child: SizedBox( + // height: 15, + // width: 25, + // child: FavouriteWidget( + // alignment: Alignment.topCenter, + // disablePadding: true, + // iconSize: 15, + // isLiked: favoriteProvider + // .isLikedVideoPresentLocally(widget.item!), + // onAdd: () { + // favoriteProvider + // .storeLikedVideoLocally(widget.item!); + // }, + // onRemove: () { + // favoriteProvider.storeLikedVideoLocally( + // widget.item!, + // forceRemove: true); + // if (widget.onFavouriteRemoved != null) + // widget.onFavouriteRemoved!(); + // }, + // toastType: 'Video'), + // ), + // ) + ], + ), + ], + )) + ], + ), + ), + ), + ], + ), + ), + ); + } + + Stack _videoStack(Widget thumbnail) { + return Stack( + children: [ + widget.showVideo && _betterPlayerController != null + ? Stack( + clipBehavior: Clip.none, + children: [ + _videoPlayer(), + _thumbNailAndLoader(thumbnail), + _nextScreenGestureDetector(), + _videoSlider(), + _interactionTools() + ], + ) + : widget.isGridView + ? Positioned.fill(child: thumbnail) + : thumbnail, + _timer(), + ], + ); + } + + bool isTitleOneLine( + TextStyle titleStyle, + ) { + return Utilities.textLines(widget.title, titleStyle, + MediaQuery.of(context).size.width * 0.78, 2) == + 1; + } + + Positioned _nextScreenGestureDetector() { + return Positioned.fill( + child: GestureDetector( + onTap: () { + _pushToVideoDetailScreen(); + }, + child: Container( + color: Colors.transparent, + ), + ), + ); + } + + void _pushToVideoDetailScreen() async { + homeFeedVideoController.isUserOnAnotherScreen = true; + context.pushNamed(Routes.videoDetailsView, + extra: NewVideoDetailScreenNavigationParameter( + betterPlayerController: _betterPlayerController, + item: widget.item, + onPop: onPopFromUserViewOrVideoDetailsView), + pathParameters: {'author': widget.author, 'permlink': widget.permlink}); + } + + void _pushToUserScreen() async { + context.pushNamed(Routes.userView, + pathParameters: {'author': widget.author}, + extra: onPopFromUserViewOrVideoDetailsView); + } + + void onPopFromUserViewOrVideoDetailsView() { + homeFeedVideoController.isUserOnAnotherScreen = false; + if (widget.showVideo && + _betterPlayerController == null && + !homeFeedVideoController.isUserOnAnotherScreen) { + setState(() { + _initVideo(); + }); + } + } + + Positioned _timer() { + return Positioned( + bottom: 10, + right: 10, + child: HomeFeedVideoTimer(totalDuration: widget.duration ?? 0), + ); + } + + Positioned _interactionTools() { + return Positioned( + top: 5, + right: 5, + child: Column( + children: [ + HomeFeedVideoFullScreenButton( + appData: widget.appData, + item: widget.item!, + betterPlayerController: _betterPlayerController!), + Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: MuteUnmuteButton( + betterPlayerController: _betterPlayerController!), + ), + PlayPauseButton(betterPlayerController: _betterPlayerController!) + ], + ), + ); + } + + Positioned _videoSlider() { + return Positioned( + left: -3, + right: -3, + bottom: 0, + child: HomeFeedVideoSlider( + betterPlayerController: _betterPlayerController, + ), + ); + } + + Positioned _thumbNailAndLoader(Widget thumbnail) { + return Positioned.fill( + child: Selector( + selector: (_, myType) => myType.isInitialized, + builder: (context, value, child) { + return Visibility(visible: !value, child: child!); + }, + child: Stack( + children: [ + thumbnail, + Positioned( + bottom: 10, + left: 10, + child: SizedBox( + height: 13, + width: 13, + child: CircularProgressIndicator( + strokeWidth: 1.8, color: Colors.white), + ), + ) + ], + ), + ), + ); + } + + Hero _videoPlayer() { + return Hero( + tag: '${widget.item?.author}/${widget.item?.permlink}', + child: SizedBox( + height: !widget.isGridView ? 230 : null, + child: BetterPlayer( + controller: _betterPlayerController!, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return ChangeNotifierProvider.value( + value: homeFeedVideoController, child: listTile()); + } + + @override + bool get wantKeepAlive => homeFeedVideoController.currentDuration != null; +} diff --git a/lib/src/screens/home_screen/home_screen_feed_item/widgets/play_pause_button.dart b/lib/src/screens/home_screen/home_screen_feed_item/widgets/play_pause_button.dart new file mode 100644 index 00000000..6eff5bb9 --- /dev/null +++ b/lib/src/screens/home_screen/home_screen_feed_item/widgets/play_pause_button.dart @@ -0,0 +1,37 @@ +import 'package:acela/src/screens/home_screen/home_screen_feed_item/controller/home_feed_video_controller.dart'; +import 'package:better_player/better_player.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class PlayPauseButton extends StatelessWidget { + const PlayPauseButton({Key? key, required this.betterPlayerController}) : super(key: key); + + final BetterPlayerController betterPlayerController; + + @override + Widget build(BuildContext context) { + bool isInitialized = context + .select((value) => value.isInitialized); + bool isPaused = context + .select((value) => value.isPaused); + return Visibility( + visible: isInitialized, + child: CircleAvatar( + backgroundColor: Theme.of(context).primaryColorDark.withOpacity(0.5), + child: IconButton( + icon: Icon( + !isPaused ? Icons.pause : Icons.play_arrow, + color: Colors.white, + ), + onPressed: () { + if (isPaused) { + betterPlayerController.play(); + } else { + betterPlayerController.pause(); + } + }, + ), + ), + ); + } +} diff --git a/lib/src/screens/home_screen/home_screen_feed_item/widgets/tab_title_toast.dart b/lib/src/screens/home_screen/home_screen_feed_item/widgets/tab_title_toast.dart new file mode 100644 index 00000000..26f537dc --- /dev/null +++ b/lib/src/screens/home_screen/home_screen_feed_item/widgets/tab_title_toast.dart @@ -0,0 +1,78 @@ + +import 'package:flutter/material.dart'; + +class HomeScreenTabTitleToast extends StatefulWidget { + const HomeScreenTabTitleToast( + {Key? key, required this.tabIndex, required this.subtitle}) + : super(key: key); + + final int tabIndex; + final String subtitle; + + @override + State createState() => + _HomeScreenTabTitleToastState(); +} + +class _HomeScreenTabTitleToastState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _animationController; + late final Animation _animation; + ValueNotifier hideMenu = ValueNotifier(false); + + @override + void initState() { + _animationController = AnimationController( + vsync: this, duration: const Duration(milliseconds: 300)); + _animation = Tween(begin: 0.0, end: 1.0).animate(_animationController); + animate(); + super.initState(); + } + + @override + void didUpdateWidget(covariant HomeScreenTabTitleToast oldWidget) { + if (oldWidget.tabIndex != widget.tabIndex) { + animate(); + } + super.didUpdateWidget(oldWidget); + } + + void animate() async { + _animationController.reset(); + _animationController.forward(); + await Future.delayed(const Duration(seconds: 3)); + if (_animation.status == AnimationStatus.completed) { + _animationController.reverse(); + } + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _animation, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 10), + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + blurRadius: 2, + spreadRadius: 2, + color: Colors.purple.shade800.withOpacity(0.2), + ) + ], + borderRadius: BorderRadius.all( + Radius.circular(4), + ), + color: Colors.deepPurple.withOpacity(0.8), + ), + child: Text(widget.subtitle,style: TextStyle(color: Colors.white),), + ), + ); + } +} diff --git a/lib/src/screens/home_screen/home_screen_feed_item/widgets/thumbnail_widget.dart b/lib/src/screens/home_screen/home_screen_feed_item/widgets/thumbnail_widget.dart new file mode 100644 index 00000000..324abeed --- /dev/null +++ b/lib/src/screens/home_screen/home_screen_feed_item/widgets/thumbnail_widget.dart @@ -0,0 +1,86 @@ +import 'dart:ui'; +import 'package:acela/src/bloc/server.dart'; +import 'package:flutter/material.dart'; + +class ThumbnailWidget extends StatelessWidget { + const ThumbnailWidget( + {super.key, + required this.image, + required this.height, + required this.width, + this.verticalPadding = 8}); + + final String image; + final double? height; + final double width; + final double verticalPadding; + + @override + Widget build(BuildContext context) { + return Container( + color: Theme.of(context).primaryColorLight == Colors.black + ? Colors.grey.shade400 + : Colors.grey.shade900, + height: height, + width: width, + child: Stack( + children: [ + if (image.isNotEmpty) + Image.network( + Server().resizedImage( + image, + ), + height: height, + width: width, + fit: BoxFit.cover, + ), + if (image.isNotEmpty) + Positioned.fill( + top: -2, + bottom: -2, + left: -2, + right: -2, + child: ClipRRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 25, sigmaY: 25), + child: const SizedBox.shrink(), + ), + ), + ), + Container( + margin: EdgeInsets.symmetric(vertical: verticalPadding), + height: height, + width: width, + child: _imageThumb(image, width, context)), + ], + ), + ); + } + + Widget _imageThumb(String url, double width, BuildContext context) { + return Padding( + padding: EdgeInsets.zero, + child: FadeInImage.assetNetwork( + fit: BoxFit.contain, + placeholder: "", + image: Server().resizedImage(url), + placeholderErrorBuilder: + (BuildContext context, Object error, StackTrace? stackTrace) { + return const SizedBox.shrink(); + }, + imageErrorBuilder: + (BuildContext context, Object error, StackTrace? stackTrace) { + return _errorIndicator(width, Theme.of(context)); + }, + ), + ); + } + + Widget _errorIndicator(double width, ThemeData theme) { + return Image.asset( + 'assets/ctt-logo.png', + height: height, + width: width, + ); + } +} diff --git a/lib/src/screens/home_screen/home_screen_feed_item/widgets/video_encoder_switch.dart b/lib/src/screens/home_screen/home_screen_feed_item/widgets/video_encoder_switch.dart new file mode 100644 index 00000000..2b0c6a43 --- /dev/null +++ b/lib/src/screens/home_screen/home_screen_feed_item/widgets/video_encoder_switch.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class VideoEncoderSwitch extends StatelessWidget { + final ValueNotifier valueNotifier; + + const VideoEncoderSwitch({ + Key? key, + required this.valueNotifier, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: ValueListenableBuilder( + valueListenable: valueNotifier, + builder: (context, value, child) { + return Switch( + value: value, + onChanged: (newValue) { + valueNotifier.value = newValue; + }, + activeColor: Colors.blue, + inactiveThumbColor: Colors.grey, + ); + }, + ), + ); + } +} diff --git a/lib/src/screens/home_screen/home_screen_feed_list.dart b/lib/src/screens/home_screen/home_screen_feed_list.dart new file mode 100644 index 00000000..99216fd7 --- /dev/null +++ b/lib/src/screens/home_screen/home_screen_feed_list.dart @@ -0,0 +1,424 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:acela/src/global_provider/image_resolution_provider.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/home_screen/home_screen_feed_item/widgets/feed_item_grid_view.dart'; +import 'package:acela/src/screens/home_screen/home_screen_feed_item/widgets/new_feed_list_item.dart'; +import 'package:acela/src/screens/report/controller/report_controller.dart'; +import 'package:acela/src/utils/graphql/gql_communicator.dart'; +import 'package:acela/src/utils/graphql/models/trending_feed_response.dart'; +import 'package:acela/src/widgets/box_loading/video_feed_loader.dart'; +import 'package:acela/src/widgets/retry.dart'; +import 'package:flutter/material.dart'; +import 'package:inview_notifier_list/inview_notifier_list.dart'; +import 'package:provider/provider.dart'; + +enum HomeScreenFeedType { + userFeed, + trendingFeed, + newUploads, + firstUploads, + userChannelFeed, + userChannelShorts, + community, + trendingTag, +} + +class HomeScreenFeedList extends StatefulWidget { + const HomeScreenFeedList( + {Key? key, + required this.appData, + required this.feedType, + this.owner, + this.community, + this.showVideo = true, + this.onEmptyDataCallback}); + + final HiveUserData appData; + final HomeScreenFeedType feedType; + final String? owner; + final String? community; + final bool showVideo; + final VoidCallback? onEmptyDataCallback; + + @override + State createState() => _HomeScreenFeedListState(); +} + +class _HomeScreenFeedListState extends State + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + List originalItems = []; + List items = []; + int pageLimit = 50; + var firstPageLoaded = false; + var isPageEnded = false; + var isLoading = false; + var hasFailed = false; + final _scrollController = ScrollController(); + int inViewIndex = 0; + bool viewOnStart = true; + bool viewOnEnd = false; + bool isUserScrolling = false; + Timer loadVideoOnStoppedScrolling = + Timer(const Duration(milliseconds: 1), () {}); + + @override + void initState() { + super.initState(); + loadFeed(false); + _scrollController.addListener(() { + if (_scrollController.offset == 0) { + if (!viewOnStart) { + setState(() { + viewOnStart = true; + _setInViewIndex(0); + }); + } + } else { + if (viewOnStart) { + setState(() { + viewOnStart = false; + }); + } + } + if (_scrollController.offset != + _scrollController.position.maxScrollExtent) {} + if (viewOnEnd) { + setState(() { + viewOnEnd = false; + }); + } + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent && + !isPageEnded) { + loadFeed(false); + } + }); + } + + Future> loadWith(bool firstPage) { + try { + switch (widget.feedType) { + case HomeScreenFeedType.trendingTag: + return GQLCommunicator().getTrendingTagFeed( + widget.owner ?? 'threespeak', + false, + firstPage ? 0 : originalItems.length, + widget.appData.language, + ); + case HomeScreenFeedType.trendingFeed: + return GQLCommunicator().getTrendingFeed(false, + firstPage ? 0 : originalItems.length, widget.appData.language); + case HomeScreenFeedType.newUploads: + return GQLCommunicator().getNewUploadsFeed(false, + firstPage ? 0 : originalItems.length, widget.appData.language); + case HomeScreenFeedType.firstUploads: + return GQLCommunicator().getFirstUploadsFeed(false, + firstPage ? 0 : originalItems.length, widget.appData.language); + case HomeScreenFeedType.userFeed: + return GQLCommunicator().getMyFeed( + widget.appData.username ?? 'sagarkothari88', + false, + firstPage ? 0 : originalItems.length, + widget.appData.language); + case HomeScreenFeedType.userChannelFeed: + return GQLCommunicator().getUserFeed( + [widget.owner ?? 'sagarkothari88'], + false, + firstPage ? 0 : originalItems.length, + widget.appData.language); + case HomeScreenFeedType.userChannelShorts: + return GQLCommunicator().getUserFeed( + [widget.owner ?? 'sagarkothari88'], + true, + firstPage ? 0 : originalItems.length, + widget.appData.language); + case HomeScreenFeedType.community: + return GQLCommunicator().getCommunity( + widget.community ?? 'hive-181335', + true, + firstPage ? 0 : originalItems.length, + widget.appData.language); + } + } catch (e) { + hasFailed = true; + throw e; + } + } + + void loadFeed(bool reset) async { + try { + log('loading'); + if (isLoading) return; + if (!firstPageLoaded) { + setState(() { + isLoading = true; + firstPageLoaded = false; + }); + var newItems = await loadWith(true); + setState(() { + if (newItems.length < pageLimit - 1) { + isPageEnded = true; + } + originalItems = newItems; + if (originalItems.isEmpty && widget.onEmptyDataCallback != null) { + widget.onEmptyDataCallback!(); + } + + _filterFromReports(); + + isLoading = false; + firstPageLoaded = true; + }); + } else { + setState(() { + isLoading = true; + if (reset) { + firstPageLoaded = false; + isPageEnded = false; + } + }); + var newItems = await loadWith(reset); + setState(() { + if (newItems.length < pageLimit - 1) { + isPageEnded = true; + } + if (newItems.isNotEmpty) { + newItems.removeAt(0); + } + originalItems = originalItems + newItems; + _filterFromReports(); + isLoading = false; + firstPageLoaded = true; + }); + } + } catch (e) { + setState(() { + isLoading = false; + hasFailed = true; + }); + } + } + + List _filterFromReports() { + final reportController = context.read(); + return items = [...originalItems]..removeWhere((item) { + final isReportedPost = reportController.reportedPosts.any((report) => + report.username == item.author?.username || + report.permlink == item.permlink); + + final isReportedUser = reportController.reportedUsers + .any((user) => user.username == item.author?.username); + + return isReportedPost || isReportedUser; + }); + } + + @override + Widget build(BuildContext context) { + final isGridView = MediaQuery.of(context).size.shortestSide > 600; + super.build(context); + context.select((e) { + if (e.shouldRefresh) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _filterFromReports(); + }); + } + context.read().turnRefreshOff(); + }); + } + return false; + }); + var screenWidth = MediaQuery.of(context).size.width; + if (isLoading && !firstPageLoaded) { + return VideoFeedLoader( + isGridView: isGridView, + ); + } else if (hasFailed) { + return RetryScreen( + onRetry: () async { + loadFeed(true); + }, + error: 'Something went wrong. Try again.', + ); + } else { + if (items.isEmpty) { + return Center( + child: Column( + children: [ + Spacer(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: Text( + widget.feedType == HomeScreenFeedType.userFeed + ? 'Please follow more people to see videos they publish.' + : 'We did not find anything to show.\nTap on Reload button to try again.', + textAlign: TextAlign.center, + ), + ), + const SizedBox( + height: 5, + ), + ElevatedButton( + onPressed: () { + loadFeed(true); + }, + child: Text('Reload'), + ), + Spacer(), + ], + ), + ); + } else { + return LayoutBuilder( + builder: (context, constraints) { + if (isGridView) { + return FeedItemGridView( + scrollController: _scrollController, + nextPageLoader: _loadNextPageWidget(), + screenWidth: screenWidth, + items: items, + appData: widget.appData); + } else { + return _listView(); + } + }, + ); + } + } + } + + NotificationListener _listView() { + return NotificationListener( + onNotification: _onScrollStartStopNotification, + child: RefreshIndicator( + onRefresh: () async { + loadFeed(true); + }, + child: InViewNotifierList( + scrollDirection: Axis.vertical, + controller: _scrollController, + initialInViewIds: ['0'], + isInViewPortCondition: + (double deltaTop, double deltaBottom, double viewPortDimension) { + return deltaTop < (0.5 * viewPortDimension) && + deltaBottom > (0.5 * viewPortDimension); + }, + itemCount: items.length, + onListEndReached: () { + if (!viewOnEnd) { + setState(() { + viewOnEnd = true; + }); + } + + _setInViewIndex(items.length - 1); + }, + builder: (context, index) { + GQLFeedItem item = items[index]; + return LayoutBuilder( + key: ValueKey('${item.author}/${item.permlink}'), + builder: (context, constraints) { + return InViewNotifierWidget( + id: '$index', + builder: (context, isInView, child) { + if (isInView && !viewOnStart && !viewOnEnd) { + _setInViewIndex(index); + } + var item = items[index]; + return Selector( + selector: (_, myType) => myType.autoPlayVideo, + builder: (context, autoPlay, child) { + return Column( + children: [ + NewFeedListItem( + key: ValueKey(index), + showVideo: (index == inViewIndex && + !isUserScrolling && + widget.showVideo) && + autoPlay, + thumbUrl: item.spkvideo?.thumbnailUrl ?? '', + author: item.author?.username ?? '', + title: item.title ?? '', + createdAt: item.createdAt ?? DateTime.now(), + duration: item.spkvideo?.duration ?? 0.0, + comments: item.stats?.numComments ?? 0, + hiveRewards: item.stats?.totalHiveReward, + votes: item.stats?.numVotes, + views: 0, + permlink: item.permlink ?? '', + onTap: () {}, + onUserTap: () {}, + item: item, + appData: widget.appData, + ), + Visibility( + visible: + index == items.length - 1 && !isPageEnded, + child: _loadNextPageWidget()) + ], + ); + }, + ); + }, + ); + }, + ); + }, + ), + ), + ); + } + + Visibility _loadNextPageWidget() { + return Visibility( + visible: !isPageEnded, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Center( + child: CircularProgressIndicator(), + ), + ), + ); + } + + bool _onScrollStartStopNotification(ScrollNotification scrollNotification) { + if (scrollNotification is ScrollStartNotification) { + if (!isUserScrolling) { + setState(() { + isUserScrolling = true; + }); + } + return true; + } else if (scrollNotification is ScrollEndNotification) { + if (isUserScrolling) { + loadVideoOnStoppedScrolling.cancel(); + const duration = Duration(seconds: 1); + loadVideoOnStoppedScrolling = Timer(duration, () { + setState(() { + isUserScrolling = false; + }); + }); + } + return true; + } else { + return true; + } + } + + void _setInViewIndex(int index) { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + if (inViewIndex != index) { + setState(() { + inViewIndex = index; + }); + } + }); + } +} diff --git a/lib/src/screens/home_screen/home_screen_view_model.dart b/lib/src/screens/home_screen/home_screen_view_model.dart deleted file mode 100644 index 87bdedaa..00000000 --- a/lib/src/screens/home_screen/home_screen_view_model.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:http/http.dart' show get; -import 'package:acela/src/bloc/server.dart'; -import 'package:acela/src/models/home_screen_feed_models/home_feed_models.dart'; - -enum LoadState { - notStarted, - loading, - succeeded, - failed, -} - -class HomeScreenViewModel { - LoadState state = LoadState.notStarted; - List list = []; - String error = 'Something went wrong'; - Function() stateUpdated; - - HomeScreenViewModel({required this.stateUpdated}); - - Future loadHomeFeed() async { - state = LoadState.loading; - stateUpdated(); - final endPoint = "${server.domain}/api/feed/more"; - var response = await get(Uri.parse(endPoint)); - if (response.statusCode == 200) { - List list = homeFeedFromJson(response.body); - state = LoadState.succeeded; - this.list = list; - stateUpdated(); - } else { - error = - 'Something went wrong.\nStatus code is ${response.statusCode} for $endPoint'; - state = LoadState.failed; - stateUpdated(); - } - } - - Future> getHomeFeed() async { - final endPoint = "${server.domain}/api/feed/more"; - var response = await get(Uri.parse(endPoint)); - if (response.statusCode == 200) { - List list = homeFeedFromJson(response.body); - return list; - } else { - error = - 'Something went wrong.\nStatus code is ${response.statusCode} for $endPoint'; - throw error; - } - } -} \ No newline at end of file diff --git a/lib/src/screens/home_screen/home_screen_widgets.dart b/lib/src/screens/home_screen/home_screen_widgets.dart deleted file mode 100644 index 1da9e148..00000000 --- a/lib/src/screens/home_screen/home_screen_widgets.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:acela/src/bloc/server.dart'; -import 'package:acela/src/models/home_screen_feed_models/home_feed_models.dart'; -import 'package:acela/src/utils/seconds_to_duration.dart'; -import 'package:acela/src/widgets/list_tile_video.dart'; -import 'package:acela/src/widgets/loading_screen.dart'; -import 'package:flutter/material.dart'; -import 'package:timeago/timeago.dart' as timeago; - -class HomeScreenWidgets { - Widget loadingData() { - return const LoadingScreen(); - } - - Widget _tileTitle(HomeFeed item, BuildContext context) { - String timeInString = "📆 ${timeago.format(item.created)}"; - String owner = "👤 ${item.owner}"; - String duration = "🕚 ${Utilities.formatTime(item.duration.toInt())}"; - return ListTileVideo( - placeholder: 'assets/branding/three_speak_logo.png', - url: item.thumbUrl, - userThumbUrl: server.userOwnerThumb(item.owner), - title: item.title, - subtitle: "$timeInString $owner $duration ▶ ${item.views}", - ); - } - - Widget _listTile( - HomeFeed item, BuildContext context, Function(HomeFeed) onTap) { - return ListTile( - title: _tileTitle(item, context), - onTap: () { - onTap(item); - }, - ); - } - - Widget list(List list, Future Function() onRefresh, - Function(HomeFeed) onTap) { - return Container( - padding: const EdgeInsets.only(top: 10, bottom: 10), - child: RefreshIndicator( - onRefresh: onRefresh, - child: ListView.separated( - physics: const AlwaysScrollableScrollPhysics(), - itemBuilder: (context, index) { - return _listTile(list[index], context, onTap); - }, - separatorBuilder: (context, index) => const Divider( - thickness: 0, - height: 10, - color: Colors.transparent, - ), - itemCount: list.length), - )); - } -} diff --git a/lib/src/screens/home_screen/new_home_screen.dart b/lib/src/screens/home_screen/new_home_screen.dart new file mode 100644 index 00000000..9ce6084e --- /dev/null +++ b/lib/src/screens/home_screen/new_home_screen.dart @@ -0,0 +1,274 @@ +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/about/about_home_screen.dart'; +import 'package:acela/src/screens/communities_screen/communities_screen.dart'; +import 'package:acela/src/screens/home_screen/home_screen_feed_item/widgets/bottom_nav_bar.dart'; +import 'package:acela/src/screens/home_screen/home_screen_feed_item/widgets/tab_title_toast.dart'; +import 'package:acela/src/screens/home_screen/home_screen_feed_list.dart'; +import 'package:acela/src/screens/login/ha_login_screen.dart'; +import 'package:acela/src/screens/trending_tags/trending_tags.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:upgrader/upgrader.dart'; + +class GQLFeedScreen extends StatefulWidget { + const GQLFeedScreen({ + Key? key, + required this.appData, + required this.username, + }); + + final HiveUserData appData; + final String? username; + + @override + State createState() => _GQLFeedScreenState(); +} + +class _GQLFeedScreenState extends State + with TickerProviderStateMixin { + var isMenuOpen = false; + + List myTabs() { + return widget.username != null + ? [ + Tab(icon: Icon(Icons.person)), + // Tab(icon: Icon(Icons.home)), + Tab(icon: Icon(Icons.local_fire_department)), + Tab(icon: Icon(Icons.play_arrow)), + Tab(icon: Icon(Icons.looks_one)), + Tab(icon: Icon(Icons.handshake)), + Tab(icon: Icon(Icons.tag)), + ] + : [ + // Tab(icon: Icon(Icons.home)), + Tab(icon: Icon(Icons.local_fire_department)), + Tab(icon: Icon(Icons.play_arrow)), + Tab(icon: Icon(Icons.looks_one)), + Tab(icon: Icon(Icons.handshake)), + Tab(icon: Icon(Icons.tag)), + ]; + } + + late TabController _tabController; + var currentIndex = 0; + + @override + void initState() { + super.initState(); + _tabController = + TabController(vsync: this, length: widget.username != null ? 6 : 5); + _tabController.addListener(tabBarListener); + } + + @override + void didUpdateWidget(covariant GQLFeedScreen oldWidget) { + if (widget.username != oldWidget.username) { + _tabController.removeListener(tabBarListener); + _tabController.dispose(); + _tabController = + TabController(vsync: this, length: widget.username != null ? 6 : 5); + _tabController.addListener(tabBarListener); + } + super.didUpdateWidget(oldWidget); + } + + void tabBarListener() { + setState(() { + currentIndex = _tabController.index; + }); + } + + @override + void dispose() { + _tabController.removeListener(tabBarListener); + _tabController.dispose(); + super.dispose(); + } + + String getSubtitle() { + if (widget.username != null) { + switch (currentIndex) { + case 0: + return '@${widget.username ?? 'User'}\'s feed'; + case 1: + return 'Trending feed'; + case 2: + return 'New feed'; + case 3: + return 'First uploads'; + case 4: + return 'Communities'; + case 5: + return 'Trending Tags'; + default: + return 'User\'s feed'; + } + } else { + switch (currentIndex) { + case 0: + return 'Trending feed'; + case 1: + return 'New feed'; + case 2: + return 'First uploads'; + case 3: + return 'Communities'; + case 4: + return 'Trending Tags'; + default: + return 'User\'s feed'; + } + } + } + + Widget appBarHeader() { + return ListTile( + contentPadding: EdgeInsets.zero, + leading: InkWell( + child: ClipOval( + child: Image.asset('assets/branding/three_speak_icon.png', + height: 33, width: 33)), + onTap: () { + var screen = const AboutHomeScreen(); + var route = MaterialPageRoute(builder: (_) => screen); + Navigator.of(context).push(route); + }, + ), + title: Text('3Speak.tv'), + subtitle: Text('Powered by Hive'), + ); + } + + @override + Widget build(BuildContext context) { + var appData = Provider.of(context); + return UpgradeAlert( + showIgnore: true, + showReleaseNotes: true, + child: Scaffold( + bottomNavigationBar: BottomNavBar( + appData: widget.appData, + username: widget.username, + ), + appBar: AppBar( + title: appBarHeader(), + bottom: TabBar( + controller: _tabController, + onTap: (value) { + setState(() { + currentIndex = value; + }); + }, + tabs: myTabs(), + ), + actions: [ + if (widget.username == null) + Padding( + padding: const EdgeInsets.only(right: 15.0), + child: SizedBox( + height: 25, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4))), + padding: + EdgeInsets.symmetric(horizontal: 2, vertical: 0)), + onPressed: () { + var screen = HiveAuthLoginScreen(appData: widget.appData); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + }, + child: Text('Log In'), + ), + ), + ), + ], + ), + body: SafeArea( + child: Stack( + children: [ + TabBarView( + key: ValueKey('${widget.username}'), + controller: _tabController, + children: widget.username != null + ? [ + HomeScreenFeedList( + key: ValueKey("${widget.username} 0"), + showVideo: currentIndex == 0, + feedType: HomeScreenFeedType.userFeed, + appData: appData, + onEmptyDataCallback: () { + _tabController.animateTo(1); + }, + ), + HomeScreenFeedList( + key: ValueKey("${widget.username} 1"), + showVideo: currentIndex == 1, + feedType: HomeScreenFeedType.trendingFeed, + appData: appData), + HomeScreenFeedList( + key: ValueKey("${widget.username} 2"), + showVideo: currentIndex == 2, + feedType: HomeScreenFeedType.newUploads, + appData: appData), + HomeScreenFeedList( + key: ValueKey("${widget.username} 3"), + showVideo: currentIndex == 3, + feedType: HomeScreenFeedType.firstUploads, + appData: appData), + CommunitiesScreen( + key: ValueKey("${widget.username} 4"), + didSelectCommunity: null, + withoutScaffold: true, + ), + TrendingTagsWidget( + key: ValueKey("${widget.username} 5"), + ), + ] + : [ + HomeScreenFeedList( + key: ValueKey("${widget.username} 0"), + showVideo: currentIndex == 0, + feedType: HomeScreenFeedType.trendingFeed, + appData: appData), + HomeScreenFeedList( + key: ValueKey("${widget.username} 1"), + showVideo: currentIndex == 1, + feedType: HomeScreenFeedType.newUploads, + appData: appData), + HomeScreenFeedList( + key: ValueKey("${widget.username} 2"), + showVideo: currentIndex == 2, + feedType: HomeScreenFeedType.firstUploads, + appData: appData), + CommunitiesScreen( + key: ValueKey("${widget.username} 3"), + didSelectCommunity: null, + withoutScaffold: true, + ), + TrendingTagsWidget( + key: ValueKey("${widget.username} 4"), + ), + ]), + Padding( + padding: const EdgeInsets.only(top: 15.0), + child: Align( + alignment: Alignment.topCenter, + child: HomeScreenTabTitleToast( + subtitle: getSubtitle(), + tabIndex: currentIndex, + ), + ), + ), + ], + ), + ), + ), + ); + } + + void showError(String string) { + var snackBar = SnackBar(content: Text('Error: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } +} diff --git a/lib/src/screens/home_screen/video_upload_sheet.dart b/lib/src/screens/home_screen/video_upload_sheet.dart new file mode 100644 index 00000000..d2a654d7 --- /dev/null +++ b/lib/src/screens/home_screen/video_upload_sheet.dart @@ -0,0 +1,77 @@ +import 'dart:developer'; + +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/upload/video/video_editor/video_picker_screen.dart'; +import 'package:acela/src/utils/graphql/gql_communicator.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class VideoUploadSheet { + static void show(HiveUserData data, BuildContext context) { + void pushToVideoEditorScreen(){ + Navigator.of(context).push(MaterialPageRoute(builder: (c) => VideoPickerScreen())); + } + if (data.username != null && data.postingKey != null) { + pushToVideoEditorScreen(); + } else if (data.keychainData != null) { + var expiry = data.keychainData!.hasExpiry; + log('Expiry is $expiry'); + try { + var longValue = int.tryParse(expiry) ?? 0; + var expiryDate = DateTime.fromMillisecondsSinceEpoch(longValue); + var nowDate = DateTime.now(); + log('Expiry Date is $expiryDate, now date is $nowDate'); + var compareResult = nowDate.compareTo(expiryDate); + log('compare result - $compareResult'); + if (compareResult == -1) { + pushToVideoEditorScreen(); + } else { + _showError('Invalid Session. Please login again.', context); + _logout(data); + } + } catch (e) { + _showError('Invalid Session. Please login again.', context); + _logout(data); + } + } else { + _showError('Invalid Session. Please login again.', context); + _logout(data); + } + } + + static void _logout(HiveUserData data) async { + const storage = FlutterSecureStorage(); + await storage.delete(key: 'username'); + await storage.delete(key: 'postingKey'); + await storage.delete(key: 'cookie'); + await storage.delete(key: 'hasId'); + await storage.delete(key: 'hasExpiry'); + await storage.delete(key: 'hasAuthKey'); + String resolution = await storage.read(key: 'resolution') ?? '480p'; + String rpc = await storage.read(key: 'rpc') ?? 'api.hive.blog'; + String union = + await storage.read(key: 'union') ?? GQLCommunicator.defaultGQLServer; + String? lang = await storage.read(key: 'lang'); + server.updateHiveUserData( + HiveUserData( + username: null, + postingKey: null, + keychainData: null, + cookie: null, + accessToken: null, + postingAuthority: null, + resolution: resolution, + rpc: rpc, + union: union, + loaded: true, + language: lang, + ), + ); + } + + static void _showError(String string, BuildContext context) { + var snackBar = SnackBar(content: Text('Error: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } +} diff --git a/lib/src/screens/leaderboard_screen/leaderboard_screen.dart b/lib/src/screens/leaderboard_screen/leaderboard_screen.dart new file mode 100644 index 00000000..000d0028 --- /dev/null +++ b/lib/src/screens/leaderboard_screen/leaderboard_screen.dart @@ -0,0 +1,150 @@ +import 'dart:core'; +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/models/leaderboard_models/leaderboard_model.dart'; +import 'package:acela/src/screens/user_channel_screen/user_channel_screen.dart'; +import 'package:acela/src/utils/routes/routes.dart'; +import 'package:acela/src/widgets/custom_circle_avatar.dart'; +import 'package:acela/src/widgets/loading_screen.dart'; +import 'package:acela/src/widgets/retry.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart' show get; + +class LeaderboardScreen extends StatefulWidget { + const LeaderboardScreen({ + Key? key, + required this.withoutScaffold, + }) : super(key: key); + final bool withoutScaffold; + + @override + _LeaderboardScreenState createState() => _LeaderboardScreenState(); +} + +class _LeaderboardScreenState extends State { + Future> getData() async { + var response = await get(Uri.parse("${server.domain}/apiv2/leaderboard")); + if (response.statusCode == 200) { + return leaderboardResponseItemFromString(response.body); + } else { + throw "Status code not 200"; + } + } + + Widget _listTileSubtitle(LeaderboardResponseItem item, double max) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Rank: ${item.rank}\nScore: ${item.score}"), + Container( + height: 5, + ), + LinearProgressIndicator( + value: item.score / max, + ) + ], + ); + } + + void onUserTap(String author) { + context.pushNamed(Routes.userView, pathParameters: {'author': author}); + } + + Widget _medalTile(LeaderboardResponseItem item, String medal, double max) { + return ListTile( + leading: CustomCircleAvatar( + width: 60, + height: 60, + url: server.userOwnerThumb(item.username), + ), + title: Row( + children: [ + CircleAvatar( + child: Text(medal), + backgroundColor: Colors.transparent, + ), + Text(item.username), + ], + ), + subtitle: _listTileSubtitle(item, max), + onTap: () { + onUserTap(item.username); + }, + ); + } + + Widget _listTile(LeaderboardResponseItem item, double max) { + return ListTile( + leading: CustomCircleAvatar( + width: 60, + height: 60, + url: server.userOwnerThumb(item.username), + ), + title: Text(item.username), + subtitle: _listTileSubtitle(item, max), + onTap: () { + onUserTap(item.username); + }, + ); + } + + Widget _list(List data) { + return ListView.separated( + itemBuilder: (context, index) { + return index == 0 + ? _medalTile(data[index], '🥇', data[0].score) + : index == 1 + ? _medalTile(data[index], '🥈', data[0].score) + : index == 2 + ? _medalTile(data[index], '🥉', data[0].score) + : _listTile(data[index], data[0].score); + }, + separatorBuilder: (context, index) => const Divider(), + itemCount: data.length); + } + + Widget _body() { + return FutureBuilder>( + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.hasError) { + return RetryScreen( + error: snapshot.error?.toString() ?? "Something went wrong", + onRetry: getData, + ); + } else if (snapshot.hasData && + snapshot.connectionState == ConnectionState.done) { + return Container( + margin: const EdgeInsets.only(top: 10, bottom: 10), + child: _list(snapshot.data!.take(100).toList()), + ); + } else { + return RetryScreen( + error: "Something went wrong", + onRetry: getData, + ); + } + } else { + return const LoadingScreen( + title: 'Loading Data', + subtitle: 'Please wait', + ); + } + }, + future: getData()); + } + + @override + Widget build(BuildContext context) { + if (widget.withoutScaffold) { + return _body(); + } else { + return Scaffold( + appBar: AppBar( + title: const Text("Leaderboard"), + ), + body: _body(), + ); + } + } +} diff --git a/lib/src/screens/login/ha_login_screen.dart b/lib/src/screens/login/ha_login_screen.dart new file mode 100644 index 00000000..a290d463 --- /dev/null +++ b/lib/src/screens/login/ha_login_screen.dart @@ -0,0 +1,481 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; + +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/models/login/login_bridge_response.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/login/sign_up_screen.dart'; +import 'package:acela/src/utils/communicator.dart'; +import 'package:acela/src/utils/graphql/gql_communicator.dart'; +import 'package:acela/src/utils/safe_convert.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:provider/provider.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +class HiveAuthLoginScreen extends StatefulWidget { + const HiveAuthLoginScreen({ + Key? key, + required this.appData, + }) : super(key: key); + final HiveUserData appData; + + @override + State createState() => _HiveAuthLoginScreenState(); +} + +class _HiveAuthLoginScreenState extends State + with TickerProviderStateMixin { + static const platform = MethodChannel('blog.hive.auth/bridge'); + var usernameController = TextEditingController(); + late WebSocketChannel socket; + String authKey = ''; + String? qrCode; + var loadingQR = false; + var timer = 0; + var timeoutValue = 0; + Timer? ticker; + var didTapKeychainButton = false; + + var isLoading = false; + var postingKey = ''; + static const storage = FlutterSecureStorage(); + + @override + void initState() { + super.initState(); + socket = WebSocketChannel.connect( + Uri.parse(Communicator.hiveAuthServer), + ); + socket.stream.listen((message) { + var map = json.decode(message) as Map; + var cmd = asString(map, 'cmd'); + if (cmd.isNotEmpty) { + switch (cmd) { + case "connected": + setState(() { + timeoutValue = asInt(map, 'timeout'); + }); + break; + case "auth_wait": + var uuid = asString(map, 'uuid'); + var jsonData = { + "account": usernameController.text, + "uuid": uuid, + "key": authKey, + "host": Communicator.hiveAuthServer + }; + var jsonString = json.encode(jsonData); + var utf8Data = utf8.encode(jsonString); + var qr = base64.encode(utf8Data); + qr = "has://auth_req/$qr"; + setState(() { + qrCode = qr; + if (didTapKeychainButton) { + var uri = Uri.tryParse(qr); + if (uri != null) { + launchUrl(uri); + } + } + timer = timeoutValue; + ticker = Timer.periodic(Duration(seconds: 1), (tickrr) { + if (timer == 0) { + setState(() { + tickrr.cancel(); + qrCode = null; + }); + } else { + setState(() { + timer--; + }); + } + }); + loadingQR = false; + }); + break; + case "auth_ack": + var messageData = asString(map, 'data'); + decryptData(widget.appData, messageData); + break; + case "auth_nack": + showError("Auth was not acknowledged"); + setState(() { + qrCode = null; + timer = 0; + loadingQR = false; + }); + break; + default: + log('Default case here'); + } + } + }); + } + + void showError(String string) { + var snackBar = SnackBar(content: Text('Error: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + void showMessage(String string) { + var snackBar = SnackBar(content: Text(string)); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + Widget _hiveUserName() { + return TextField( + decoration: InputDecoration( + icon: const Icon(Icons.alternate_email_outlined), + label: const Text('Hive Username'), + hintText: 'Enter Hive username here', + ), + autocorrect: false, + controller: usernameController, + ); + } + + void _hasButtonTapped(bool keychainTapped) async { + if (usernameController.text.isEmpty) { + showError('Please enter hive username'); + return; + } + setState(() { + loadingQR = true; + }); + final String response = await platform.invokeMethod('getRedirectUriData', { + 'username': usernameController.text, + }); + var bridgeResponse = LoginBridgeResponse.fromJsonString(response); + if (bridgeResponse.data != null) { + var data = json.decode(bridgeResponse.data!) as Map; + var dataForSocket = asString(data, 'data'); + var key = asString(data, 'authKey'); + var socketData = { + "cmd": "auth_req", + "account": usernameController.text, + "data": dataForSocket, + }; + var jsonEncodedData = json.encode(socketData); + socket.sink.add(jsonEncodedData); + setState(() { + didTapKeychainButton = keychainTapped; + authKey = key; + }); + } + } + + Widget _hasButton(HiveUserData data) { + return Row( + children: [ + const Spacer(), + ElevatedButton( + onPressed: () { + _hasButtonTapped(true); + }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.black), + child: Image.asset('assets/hive-keychain-image.png', width: 120), + ), + const SizedBox(width: 10), + ElevatedButton( + onPressed: () { + _hasButtonTapped(false); + }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.black), + child: Image.asset('assets/hive_auth_button.png', width: 120), + ), + const Spacer(), + ], + ); + } + + Widget _hivePostingKey() { + return TextField( + decoration: InputDecoration( + icon: const Icon(Icons.key), + label: const Text('Hive Private Posting Key'), + hintText: 'Copy & paste Private posting key here', + ), + obscureText: true, + onChanged: (value) { + setState(() { + postingKey = value; + }); + }, + enabled: isLoading ? false : true, + ); + } + + Widget _showQRCodeAndKeychainButton(String qr) { + return Center( + child: Column( + children: [ + didTapKeychainButton + ? Container() + : Column( + children: [ + const SizedBox(height: 10), + Image.asset('assets/hive_auth_button.png'), + const SizedBox(height: 10), + Text('Scan QR Code\n- OR - \nTap on QR Code'), + SizedBox(height: 10), + InkWell( + child: Container( + decoration: BoxDecoration(color: Colors.white), + child: QrImageView( + data: qr, + size: 200, + gapless: true, + ), + ), + onTap: () { + var url = Uri.parse(qr); + launchUrl(url); + }, + ), + SizedBox(height: 10), + SizedBox( + width: 200, + child: LinearProgressIndicator( + value: timer.toDouble() / timeoutValue.toDouble(), + semanticsLabel: 'Timeout Timer for HiveAuth QR', + ), + ), + ], + ), + didTapKeychainButton + ? Column( + children: [ + const SizedBox(height: 10), + const Text( + 'Authorize this request with "Keychain for Hive" app.'), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () { + var url = Uri.parse(qr); + launchUrl(url); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.black), + child: Image.asset('assets/hive-keychain-image.png', + width: 220), + ), + const SizedBox(height: 20), + SizedBox(height: 10), + SizedBox( + width: 200, + child: LinearProgressIndicator( + value: timer.toDouble() / timeoutValue.toDouble(), + semanticsLabel: 'Timeout Timer for HiveAuth QR', + ), + ), + ], + ) + : Container() + ], + ), + ); + } + + Widget _loginForm(HiveUserData appData) { + return loadingQR || isLoading + ? const Center(child: CircularProgressIndicator()) + : qrCode == null + ? Container( + margin: EdgeInsets.all(10), + child: Column( + children: [ + _hiveUserName(), + const SizedBox(height: 10), + _hasButton(appData), + const SizedBox(height: 10), + const Text('- OR -'), + const SizedBox(height: 10), + _hivePostingKey(), + const SizedBox(height: 10), + ElevatedButton( + onPressed: () { + onLoginTapped(appData); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.black), + child: const Text('Login with Posting Key'), + ), + const SizedBox(height: 10), + const Text('- OR -'), + const SizedBox(height: 10), + ElevatedButton( + onPressed: () { + const screen = SignUpScreen(); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + }, + child: Text('Sign up'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.black), + ), + ], + ), + ) + : _showQRCodeAndKeychainButton(qrCode!); + } + + void onLoginTapped(HiveUserData appData) async { + if (usernameController.text.isEmpty) { + showError('Please enter hive username'); + return; + } + setState(() { + isLoading = true; + }); + try { + var platform = MethodChannel('com.example.acela/auth'); + final String response = await platform.invokeMethod('validateHiveKey', { + 'username': usernameController.text, + 'postingKey': postingKey, + }); + var bridgeResponse = LoginBridgeResponse.fromJsonString(response); + if (bridgeResponse.valid) { + debugPrint("Successful login"); + String resolution = await storage.read(key: 'resolution') ?? '480p'; + String rpc = await storage.read(key: 'rpc') ?? 'api.hive.blog'; + String union = await storage.read(key: 'union') ?? + GQLCommunicator.defaultGQLServer; + String? lang = await storage.read(key: 'lang'); + await storage.write(key: 'username', value: usernameController.text); + await storage.write(key: 'postingKey', value: postingKey); + await storage.delete(key: 'hasId'); + await storage.delete(key: 'hasExpiry'); + await storage.delete(key: 'hasAuthKey'); + await storage.delete(key: 'cookie'); + var data = HiveUserData( + accessToken: null, + postingAuthority: null, + username: usernameController.text, + postingKey: postingKey, + keychainData: null, + cookie: null, + resolution: resolution, + rpc: rpc, + union: union, + loaded: true, + language: lang, + ); + server.updateHiveUserData(data); + var cookie = await Communicator().getValidCookie(data); + log(cookie); + Navigator.of(context).pop(); + showMessage( + 'You have successfully logged in as - ${usernameController.text}'); + setState(() { + isLoading = false; + }); + } else { + // it is NO valid key + showError('Not valid key.'); + setState(() { + isLoading = false; + }); + } + } catch (e) { + setState(() { + isLoading = false; + }); + log(e.toString()); + if(e == 'No 3Speak Account found with name - ${usernameController.text}'){ + await storage.delete(key: 'username'); + await storage.delete(key: 'postingKey'); + await storage.delete(key: 'hasId'); + await storage.delete(key: 'hasExpiry'); + await storage.delete(key: 'hasAuthKey'); + await storage.delete(key: 'cookie'); + var data = HiveUserData( + username: null, + postingKey: null, + keychainData: null, + cookie: null, + accessToken: null, + postingAuthority: null, + resolution: '480p', + rpc: 'api.hive.blog', + union: GQLCommunicator.defaultGQLServer, + loaded: true, + language: null, + ); + server.updateHiveUserData(data); + } + showError('Error occurred - ${e.toString()}'); + } + } + + @override + void dispose() { + super.dispose(); + socket.sink.close(); + } + + void decryptData(HiveUserData data, String encryptedData) async { + final String response = + await platform.invokeMethod('getDecryptedHASToken', { + 'username': usernameController.text, + 'authKey': authKey, + 'data': encryptedData, + }); + var bridgeResponse = LoginBridgeResponse.fromJsonString(response); + if (bridgeResponse.valid && + bridgeResponse.data != null && + bridgeResponse.data!.isNotEmpty) { + var tokenData = bridgeResponse.data!.split(","); + if (tokenData.isEmpty || tokenData.length != 2) { + showMessage( + 'Did not find token & expiry details from HiveAuth. Please go back & try again.'); + } else { + const storage = FlutterSecureStorage(); + await storage.write(key: 'username', value: usernameController.text); + await storage.delete(key: 'postingKey'); + await storage.delete(key: 'cookie'); + await storage.write(key: 'hasId', value: tokenData[0]); + await storage.write(key: 'hasExpiry', value: tokenData[1]); + await storage.write(key: 'hasAuthKey', value: authKey); + var newData = HiveUserData( + username: usernameController.text, + postingKey: null, + keychainData: HiveKeychainData( + hasAuthKey: authKey, + hasExpiry: tokenData[1], + hasId: tokenData[0], + ), + cookie: null, + accessToken: null, + postingAuthority: null, + resolution: data.resolution, + rpc: data.rpc, + union: data.union, + loaded: true, + language: data.language, + ); + server.updateHiveUserData(newData); + showMessage( + 'You have successfully logged in with Hive Auth with user - ${usernameController.text}'); + Navigator.of(context).pop(); + } + } else { + showMessage( + 'Something went wrong - ${bridgeResponse.error}. Please go back & try again.'); + } + } + + @override + Widget build(BuildContext context) { + var data = Provider.of(context); + return Scaffold( + appBar: AppBar( + title: const Text('Sign in with your account'), + ), + body: _loginForm(data), + ); + } +} diff --git a/lib/src/screens/login/provider/logout_provider.dart b/lib/src/screens/login/provider/logout_provider.dart new file mode 100644 index 00000000..8504d718 --- /dev/null +++ b/lib/src/screens/login/provider/logout_provider.dart @@ -0,0 +1,37 @@ +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/utils/graphql/gql_communicator.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class LogoutProvider { + Future call() async { + const storage = FlutterSecureStorage(); + await storage.delete(key: 'username'); + await storage.delete(key: 'postingKey'); + await storage.delete(key: 'accessToken'); + await storage.delete(key: 'postingAuth'); + await storage.delete(key: 'cookie'); + await storage.delete(key: 'hasId'); + await storage.delete(key: 'hasExpiry'); + await storage.delete(key: 'hasAuthKey'); + String resolution = await storage.read(key: 'resolution') ?? '480p'; + String rpc = await storage.read(key: 'rpc') ?? 'api.hive.blog'; + String union = + await storage.read(key: 'union') ?? GQLCommunicator.defaultGQLServer; + String? lang = await storage.read(key: 'lang'); + var newUserData = HiveUserData( + username: null, + postingKey: null, + keychainData: null, + cookie: null, + accessToken: null, + postingAuthority: null, + resolution: resolution, + rpc: rpc, + union: union, + loaded: true, + language: lang, + ); + server.updateHiveUserData(newUserData); + } +} diff --git a/lib/src/screens/login/sign_up_screen.dart b/lib/src/screens/login/sign_up_screen.dart new file mode 100644 index 00000000..063b813e --- /dev/null +++ b/lib/src/screens/login/sign_up_screen.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class SignUpScreen extends StatefulWidget { + const SignUpScreen({Key? key}) : super(key: key); + + @override + State createState() => _SignUpScreenState(); +} + +class _SignUpScreenState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('How to Sign up?'), + ), + body: SafeArea( + child: Container( + margin: EdgeInsets.all(10), + child: Center( + child: Column( + children: [ + Spacer(), + InkWell( + child: Text( + 'Step 1. Click here 3Speak Sign up', + style: TextStyle(color: Colors.blue), + textAlign: TextAlign.center, + ), + onTap: () { + var uri = Uri.parse('https://auth.3speak.tv/3/signupHive'); + launchUrl(uri); + }, + ), + const SizedBox(height: 35), + Text( + 'Step 2. Upload Human Verification Video from 3speak.tv website', + textAlign: TextAlign.center, + ), + const SizedBox(height: 35), + InkWell( + child: Text( + 'Step 3. Join Discord & ask for email for keys', + style: TextStyle(color: Colors.blue), + textAlign: TextAlign.center, + ), + onTap: () { + var uri = Uri.parse( + 'https://discord.gg/NSFS2VGj83?utm_source=3speak.tv.acela'); + launchUrl(uri); + }, + ), + const SizedBox(height: 35), + Text( + 'Step 4. Login with Posting key\nUse HiveKeychain App', + textAlign: TextAlign.center, + ), + Spacer(), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/screens/login/web_view_screen.dart b/lib/src/screens/login/web_view_screen.dart new file mode 100644 index 00000000..817e893f --- /dev/null +++ b/lib/src/screens/login/web_view_screen.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +class WebViewScreen extends StatefulWidget { + const WebViewScreen({ + Key? key, + required this.title, + required this.url, + }) : super(key: key); + final String title; + final String url; + + @override + State createState() => _WebViewScreenState(); +} + +class _WebViewScreenState extends State { + late WebViewController controller; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: WebViewWidget(controller: controller), + ); + } + + @override + void initState() { + super.initState(); + controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setBackgroundColor(const Color(0x00000000)) + ..setNavigationDelegate( + NavigationDelegate( + onProgress: (int progress) { + // Update loading bar. + }, + onPageStarted: (String url) {}, + onPageFinished: (String url) {}, + onWebResourceError: (WebResourceError error) {}, + onNavigationRequest: (NavigationRequest request) { + return NavigationDecision.navigate; + }, + ), + ) + ..loadRequest(Uri.parse(widget.url)); + } +} diff --git a/lib/src/screens/my_account/account_settings/account_settings_screen.dart b/lib/src/screens/my_account/account_settings/account_settings_screen.dart new file mode 100644 index 00000000..75b2dddb --- /dev/null +++ b/lib/src/screens/my_account/account_settings/account_settings_screen.dart @@ -0,0 +1,137 @@ + +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/home_screen/new_home_screen.dart'; +import 'package:acela/src/screens/my_account/account_settings/widgets/delete_dialog.dart'; +import 'package:acela/src/utils/communicator.dart'; +import 'package:acela/src/utils/graphql/gql_communicator.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:provider/provider.dart'; + +class AccountSettingsScreen extends StatefulWidget { + const AccountSettingsScreen({Key? key}) : super(key: key); + + @override + State createState() => _AccountSettingsScreenState(); +} + +class _AccountSettingsScreenState extends State { + bool isLoading = false; + Future logout(HiveUserData data) async { + // Create storage + const storage = FlutterSecureStorage(); + await storage.delete(key: 'username'); + await storage.delete(key: 'postingKey'); + await storage.delete(key: 'cookie'); + await storage.delete(key: 'hasId'); + await storage.delete(key: 'hasExpiry'); + await storage.delete(key: 'hasAuthKey'); + String resolution = await storage.read(key: 'resolution') ?? '480p'; + String rpc = await storage.read(key: 'rpc') ?? 'api.hive.blog'; + String union = + await storage.read(key: 'union') ?? GQLCommunicator.defaultGQLServer; + String? lang = await storage.read(key: 'lang'); + var newUserData = HiveUserData( + username: null, + postingKey: null, + keychainData: null, + cookie: null, + accessToken: null, + postingAuthority: null, + resolution: resolution, + rpc: rpc, + union: union, + loaded: true, + language: lang, + ); + server.updateHiveUserData(newUserData); + Navigator.of(context).pop(); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + var data = Provider.of(context); + return Scaffold( + appBar: AppBar( + title: const Text('Settings'), + ), + body: SafeArea( + child: isLoading + ? Center( + child: CircularProgressIndicator(), + ) + : ListView( + children: [ + ListTile( + leading: const Icon(Icons.logout), + title: const Text('Log Out'), + onTap: () { + logout(data); + }, + ), + ListTile( + leading: const Icon( + Icons.delete, + color: Colors.red, + ), + title: const Text( + 'Delete Account', + style: TextStyle(color: Colors.red), + ), + onTap: () { + deleteDialog(data); + }, + ), + ], + ), + ), + ); + } + + void deleteDialog(HiveUserData data) { + showDialog( + barrierDismissible: true, + useRootNavigator: true, + context: context, + builder: (context) { + return DeleteDialog( + onDelete: () async { + Navigator.pop(context); + try { + setState(() { + isLoading = true; + }); + bool status = await Communicator().deleteAccount(data); + if (status) { + await logout(data); + showMessage('Account Deleted Successfully'); + } else { + showError("Sorry, Something went wrong."); + } + setState(() { + isLoading = false; + }); + } catch (e) { + setState(() { + isLoading = false; + }); + showError("Sorry, Something went wrong."); + } + }, + ); + }, + ); + } + + void showMessage(String string) { + var snackBar = SnackBar(content: Text(string)); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + void showError(String string) { + var snackBar = SnackBar(content: Text('Error: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } +} diff --git a/lib/src/screens/my_account/account_settings/widgets/delete_dialog.dart b/lib/src/screens/my_account/account_settings/widgets/delete_dialog.dart new file mode 100644 index 00000000..ac3b21d7 --- /dev/null +++ b/lib/src/screens/my_account/account_settings/widgets/delete_dialog.dart @@ -0,0 +1,69 @@ +import 'package:acela/src/screens/my_account/account_settings/widgets/dialog_button.dart'; +import 'package:flutter/material.dart'; + +class DeleteDialog extends StatefulWidget { + const DeleteDialog({Key? key, required this.onDelete}) : super(key: key); + + final VoidCallback onDelete; + + @override + State createState() => _DeleteDialogState(); +} + +class _DeleteDialogState extends State { + bool enableDeleteButton = false; + + @override + void initState() { + super.initState(); + _init(); + } + + void _init() async { + await Future.delayed(const Duration(seconds: 5)); + if (mounted) { + setState(() { + enableDeleteButton = true; + }); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + actionsPadding: const EdgeInsets.only(bottom: 20, right: 20), + title: Text( + "Delete Account", + ), + content: Text( + "Your account will be deleted permanently", + ), + actions: [ + DialogButton( + text: "Cancel", + onPressed: () { + Navigator.pop(context); + }, + ), + Stack( + children: [ + DialogButton( + text: "Delete", + color: + enableDeleteButton ? Colors.red : Colors.red.withOpacity(0.5), + onPressed: widget.onDelete, + ), + Positioned.fill( + child: Visibility( + visible: !enableDeleteButton, + child: Container( + color: Colors.transparent, + ), + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/src/screens/my_account/account_settings/widgets/dialog_button.dart b/lib/src/screens/my_account/account_settings/widgets/dialog_button.dart new file mode 100644 index 00000000..0165bc99 --- /dev/null +++ b/lib/src/screens/my_account/account_settings/widgets/dialog_button.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +class DialogButton extends StatelessWidget { + const DialogButton({ + required this.text, + required this.onPressed, + this.color, + }); + + final String text; + final Function() onPressed; + final Color? color; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 25, + child: TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + ), + onPressed: onPressed, + child: Text( + text, + style: TextStyle(color: color), + ), + ), + ); + } +} diff --git a/lib/src/screens/my_account/my_account_screen.dart b/lib/src/screens/my_account/my_account_screen.dart new file mode 100644 index 00000000..f6b5634b --- /dev/null +++ b/lib/src/screens/my_account/my_account_screen.dart @@ -0,0 +1,494 @@ +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/models/video_details_model/video_details.dart'; +import 'package:acela/src/screens/favourites/user_favourites.dart'; +import 'package:acela/src/screens/my_account/update_video/publish_video_screen.dart'; +import 'package:acela/src/screens/my_account/video_preview.dart'; +import 'package:acela/src/screens/settings/settings_screen.dart'; +import 'package:acela/src/utils/communicator.dart'; +import 'package:acela/src/utils/graphql/gql_communicator.dart'; +import 'package:acela/src/utils/routes/routes.dart'; +import 'package:acela/src/widgets/confirmation_dialog.dart'; +import 'package:acela/src/widgets/custom_circle_avatar.dart'; +import 'package:acela/src/widgets/loading_screen.dart'; +import 'package:adaptive_action_sheet/adaptive_action_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:go_router/go_router.dart'; + +class MyAccountScreen extends StatefulWidget { + const MyAccountScreen({ + Key? key, + required this.data, + this.initialTabIndex, + }) : super(key: key); + final HiveUserData data; + final int? initialTabIndex; + + @override + State createState() => _MyAccountScreenState(); +} + +class _MyAccountScreenState extends State + with SingleTickerProviderStateMixin { + Future>? loadVideos; + late TabController _tabController; + var currentIndex = 0; + + @override + void initState() { + super.initState(); + setState(() { + loadVideos = Communicator().loadVideos(widget.data); + }); + _tabController = TabController( + length: 3, vsync: this, initialIndex: widget.initialTabIndex ?? 0); + _tabController.addListener(() { + setState(() { + currentIndex = _tabController.index; + }); + }); + } + + @override + void dispose() { + super.dispose(); + _tabController.dispose(); + } + + void showError(String string) { + var snackBar = SnackBar(content: Text('Error: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + AppBar _appBar(String username) { + return AppBar( + leadingWidth: 30, + title: ListTile( + splashColor: Colors.transparent, + contentPadding: EdgeInsets.zero, + onTap: () { + context + .pushNamed(Routes.userView, pathParameters: {'author': username}); + }, + leading: CustomCircleAvatar( + height: 36, + width: 36, + url: 'https://images.hive.blog/u/$username/avatar', + ), + title: Text( + username, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + bottom: TabBar( + controller: _tabController, + tabs: [ + Tab(icon: Text('Publish Now')), + Tab(icon: Text('My Videos')), + Tab(icon: Text('Encoding')), + // Tab(icon: Text('Deleted')), + ], + ), + actions: [ + IconButton( + icon: Icon(Icons.bookmarks), + onPressed: () { + var screen = const UserFavourites(); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + }, + ), + IconButton( + onPressed: () { + var screen = const SettingsScreen( + isUserFromUserSettings: true, + ); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + }, + icon: const Icon(Icons.settings), + ), + IconButton( + onPressed: () { + showDialog( + context: context, + builder: (BuildContext context) { + return ConfirmationDialog( + title: 'Log Out', + content: 'Are you sure you want to log out?', + onConfirm: () => logout(widget.data), + ); + }, + ); + }, + icon: const Icon(Icons.logout_outlined), + ) + ], + ); + } + + Widget _trailingActionOnVideoListItem(VideoDetails item, HiveUserData user) { + if (item.status == 'published') { + return const Icon( + Icons.more_vert, + ); + } else if (item.status == "encoding_failed" || + item.status.toLowerCase() == "deleted") { + return const Icon(Icons.cancel_outlined, color: Colors.red); + } else if (item.status == 'publish_manual' || + item.status == 'publish_later' || + item.status == 'scheduled') { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 25, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4))), + padding: EdgeInsets.symmetric(horizontal: 2, vertical: 0)), + onPressed: () { + // var screen = + // VideoPrimaryInfo(item: item, justForEditing: false); + var screen = PublishVideoScreen( + item: item, + hasKey: widget.data.keychainData?.hasId ?? "", + hasAuthKey: widget.data.keychainData?.hasAuthKey ?? "", + appData: widget.data, + ); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + }, + child: Text('Publish'), + ), + ), + const SizedBox( + width: 5, + ), + SizedBox( + height: 25, + width: 25, + child: Center( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4))), + padding: EdgeInsets.zero), + onPressed: () { + _showBottomSheet(item); + }, + child: Center( + child: Icon(Icons.more_vert), + ), + ), + ), + ), + ], + ); + } else { + bool isEncodingFailed = isEncodedFailed(item.created); + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + isEncodingFailed + ? Icon( + Icons.block, + color: Colors.red, + ) + : Icon( + Icons.hourglass_top, + color: Colors.yellowAccent, + ), + if (item.encodingProgress != null) + isEncodingFailed + ? Text( + "Failed", + style: TextStyle(color: Colors.red), + ) + : Text("${item.encodingProgress.toString()}%") + ], + ); + } + } + + bool isEncodedFailed(String dateString) { + DateTime parsedDate = DateTime.parse(dateString); + DateTime currentDate = DateTime.now(); + Duration difference = currentDate.difference(parsedDate); + if (difference.inDays > 30) { + return true; + } else { + return false; + } + } + + void _showBottomSheet(VideoDetails item) { + List actions = []; + if (currentIndex == 0) { + actions.add(BottomSheetAction( + title: const Text("Publish"), + onPressed: (context) { + // var screen = VideoPrimaryInfo(item: item, justForEditing: false); + var screen = PublishVideoScreen( + item: item, + hasKey: widget.data.keychainData?.hasId ?? "", + hasAuthKey: widget.data.keychainData?.hasAuthKey ?? "", + appData: widget.data, + ); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + })); + } + + if (item.status == 'publish_manual') { + actions.add(BottomSheetAction( + title: Text('Preview'), + onPressed: (context) { + Navigator.of(context).pop(); + var screen = VideoPreviewScreen(data: widget.data, item: item); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + }, + )); + } + actions.add( + BottomSheetAction( + title: Text( + 'Delete Video', + style: TextStyle(color: Colors.red), + ), + onPressed: (context) async { + Navigator.of(context).pop(); + showSnackBar('Deleting...', seconds: 60); + bool result = + await Communicator().deleteVideo(item.permlink, widget.data); + hideSnackBar(); + if (result) { + setState(() { + loadVideos = Communicator().loadVideos(widget.data); + }); + } else { + showSnackBar("Something went wrong"); + } + }, + ), + ); + showAdaptiveActionSheet( + context: context, + title: const Text('Options'), + androidBorderRadius: 30, + actions: actions, + cancelAction: CancelAction( + title: const Text('Cancel'), + ), + ); + } + + void showSnackBar(String message, {int seconds = 3}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Deleting..."), + duration: Duration(seconds: seconds), + ), + ); + } + + void hideSnackBar() { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + } + + Widget _videoListItem(VideoDetails item, HiveUserData user) { + var desc = item.description.length > 30 + ? item.description.substring(0, 30) + : item.description; + // desc = "\n${item.visible_status}"; + return ListTile( + contentPadding: EdgeInsets.only(right: 10, left: 10), + leading: Image.network( + item.getThumbnail(), + ), + title: Text( + item.title.length > 30 ? item.title.substring(0, 30) : item.title), + subtitle: Text(desc), + trailing: _trailingActionOnVideoListItem(item, user), + onTap: () { + if (currentIndex == 0) { + _showBottomSheet(item); + } else if (item.status != 'publish_manual' && + item.status != 'encoding_failed' && + item.status.toLowerCase() != 'deleted') { + _showBottomSheet(item); + } + }, + ); + } + + Widget _listViewForItems(List items, HiveUserData user) { + if (items.isEmpty) { + return const Center( + child: Text('No videos found.'), + ); + } + return RefreshIndicator( + onRefresh: () async { + setState(() { + loadVideos = Communicator().loadVideos(widget.data); + }); + }, + child: ListView.separated( + itemBuilder: (context, index) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.only( + top: 15.0, left: 15, right: 15, bottom: 20), + child: Text( + headerText, + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).primaryColorLight), + ), + ); + } + return _videoListItem(items[index - 1], user); + }, + separatorBuilder: (context, index) => const Divider( + height: 0, + color: Colors.transparent, + ), + itemCount: items.length + 1, + ), + ); + } + + String get headerText { + if (currentIndex == 0) { + return 'Videos NOT YET posted\nTap on a video to edit details & publish'; + } else if (currentIndex == 1) { + return 'Following videos are already posted\nTap on a video to change thumbnail'; + } else { + return "Videos which are under processing"; + } + } + + Widget _videosList(List items, HiveUserData user) { + var published = items.where((item) => item.status == 'published').toList(); + var ready = items + .where((item) => + item.status == 'publish_manual' || + item.status == 'publish_later' || + item.status == 'scheduled') + .toList(); + // items.forEach((element) { + // log(element.status); + // }); + var failed = items + .where((item) => + item.status == 'encoding_failed' || + item.status.toLowerCase() == 'Deleted') + .toList(); + var process = items + .where((item) => + item.status != 'published' && + item.status != 'publish_manual' && + item.status != 'encoding_failed' && + item.status != 'publish_later' && + item.status != 'scheduled' && + item.status.toLowerCase() != 'deleted') + .toList(); + var delted = + items.where((item) => item.status.toLowerCase() == 'deleted').toList(); + var processAndFailed = process + failed; + processAndFailed.sort((a, b) { + DateTime dateA = DateTime.parse(a.created); + DateTime dateB = DateTime.parse(b.created); + return dateB.compareTo(dateA); // Compare in descending order + }); + return TabBarView( + controller: _tabController, + children: [ + // SafeArea( + // child: _listViewForItems(process, user), + // ), + SafeArea( + child: _listViewForItems(ready, user), + ), + SafeArea( + child: _listViewForItems(published, user), + ), + SafeArea( + child: _listViewForItems(processAndFailed, user), + ), + // SafeArea( + // child: _listViewForItems(delted, user), + // ), + ], + ); + } + + Widget _videoFuture() { + return FutureBuilder( + future: loadVideos, + builder: (context, snapshot) { + if (snapshot.hasError) { + return const Center(child: Text('Something went wrong')); + } else if (snapshot.hasData && + snapshot.connectionState == ConnectionState.done) { + return _videosList(snapshot.data as List, widget.data); + } else { + return const LoadingScreen( + title: 'Getting your videos', + subtitle: 'Please wait', + ); + } + }, + ); + } + + Future logout(HiveUserData data) async { + // Create storage + const storage = FlutterSecureStorage(); + await storage.delete(key: 'username'); + await storage.delete(key: 'postingKey'); + await storage.delete(key: 'accessToken'); + await storage.delete(key: 'postingAuth'); + await storage.delete(key: 'cookie'); + await storage.delete(key: 'hasId'); + await storage.delete(key: 'hasExpiry'); + await storage.delete(key: 'hasAuthKey'); + String resolution = await storage.read(key: 'resolution') ?? '480p'; + String rpc = await storage.read(key: 'rpc') ?? 'api.hive.blog'; + String union = + await storage.read(key: 'union') ?? GQLCommunicator.defaultGQLServer; + String? lang = await storage.read(key: 'lang'); + var newUserData = HiveUserData( + username: null, + postingKey: null, + keychainData: null, + cookie: null, + accessToken: null, + postingAuthority: null, + resolution: resolution, + rpc: rpc, + union: union, + loaded: true, + language: lang, + ); + server.updateHiveUserData(newUserData); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 3, + child: Scaffold( + appBar: _appBar(widget.data.username ?? 'sagarkothari88'), + body: SafeArea( + child: _videoFuture(), + ), + ), + ); + } +} diff --git a/lib/src/screens/my_account/update_thumb/update_thumb_screen.dart b/lib/src/screens/my_account/update_thumb/update_thumb_screen.dart new file mode 100644 index 00000000..1eee5761 --- /dev/null +++ b/lib/src/screens/my_account/update_thumb/update_thumb_screen.dart @@ -0,0 +1,203 @@ +import 'dart:developer'; + +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/models/video_details_model/video_details.dart'; +import 'package:acela/src/utils/communicator.dart'; +import 'package:acela/src/widgets/loading_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:provider/provider.dart'; +import 'package:tus_client/tus_client.dart'; + +class UpdateThumbScreen extends StatefulWidget { + const UpdateThumbScreen({ + Key? key, + required this.item, + }) : super(key: key); + final VideoDetails item; + + @override + State createState() => _UpdateThumbScreenState(); +} + +class _UpdateThumbScreenState extends State { + var isCompleting = false; + var isPickingImage = false; + var uploadStarted = false; + var uploadComplete = false; + var thumbIpfs = ''; + var thumbUrl = ''; + var progress = 0.0; + var processText = ''; + final ImagePicker _picker = ImagePicker(); + + void initiateUpload( + HiveUserData data, + XFile xFile, + ) async { + if (uploadStarted) return; + setState(() { + uploadStarted = true; + }); + final client = TusClient( + Uri.parse(Communicator.fsServer), + xFile, + store: TusMemoryStore(), + ); + await client.upload( + onComplete: () async { + print("Complete!"); + print(client.uploadUrl.toString()); + var url = client.uploadUrl.toString(); + var ipfsName = url.replaceAll("${Communicator.fsServer}/", ""); + setState(() { + thumbUrl = url; + thumbIpfs = ipfsName; + uploadComplete = true; + uploadStarted = false; + }); + }, + onProgress: (progress) { + log("Progress: $progress"); + setState(() { + this.progress = progress; + }); + }, + ); + } + + void completeVideo(HiveUserData user) async { + setState(() { + isCompleting = true; + processText = 'Updating video info'; + }); + try { + await Communicator().updateThumb( + user: user, + videoId: widget.item.id, + thumbnail: thumbIpfs, + ); + Navigator.of(context).pop(); + Navigator.of(context).pop(); + } catch (e) { + showError(e.toString()); + setState(() { + isCompleting = false; + processText = ''; + }); + } + } + + Widget _thumbnailPicker(HiveUserData user) { + return Center( + child: Container( + width: 320, + height: 160, + margin: const EdgeInsets.all(10), + decoration: const BoxDecoration( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(24.0), + topRight: Radius.circular(24.0), + ), + boxShadow: [ + BoxShadow( + color: Colors.black12, + spreadRadius: 3, + blurRadius: 3, + ) + ]), + child: InkWell( + child: Center( + child: isPickingImage + ? const CircularProgressIndicator() + : progress > 0.0 && progress < 100.0 + ? CircularProgressIndicator(value: progress) + : thumbUrl.isNotEmpty + ? Image.network( + thumbUrl, + width: 320, + height: 160, + ) + : widget.item.getThumbnail().isNotEmpty + ? Image.network( + widget.item.getThumbnail(), + width: 320, + height: 160, + ) + : const Text( + 'Tap here to add thumbnail for your video\n\nThumbnail is MANDATORY to set.', + textAlign: TextAlign.center), + ), + onTap: () async { + try { + setState(() { + isPickingImage = true; + }); + final XFile? file = + await _picker.pickImage(source: ImageSource.gallery); + if (file != null) { + setState(() { + isPickingImage = false; + }); + initiateUpload(user, file); + } else { + throw 'User cancelled image picker'; + } + } catch (e) { + showError(e.toString()); + setState(() { + isPickingImage = false; + }); + } + }, + ), + ), + ); + } + + void showError(String string) { + var snackBar = SnackBar(content: Text('Error: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + void showMessage(String string) { + var snackBar = SnackBar(content: Text('Message: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + @override + Widget build(BuildContext context) { + var user = Provider.of(context); + return Scaffold( + appBar: AppBar( + title: const Text('Provide more info'), + ), + body: isCompleting + ? Center( + child: LoadingScreen( + title: 'Please wait', + subtitle: processText, + ), + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _thumbnailPicker(user), + const Text('Tap to change video thumbnail'), + ], + ), + floatingActionButton: isCompleting + ? null + : thumbIpfs.isNotEmpty || widget.item.getThumbnail().isNotEmpty + ? FloatingActionButton( + onPressed: () { + if (user.username != null) { + completeVideo(user); + } + }, + child: const Icon(Icons.save), + ) + : null, + ); + } +} diff --git a/lib/src/screens/my_account/update_video/add_bene_sheet.dart b/lib/src/screens/my_account/update_video/add_bene_sheet.dart new file mode 100644 index 00000000..62d6a8f6 --- /dev/null +++ b/lib/src/screens/my_account/update_video/add_bene_sheet.dart @@ -0,0 +1,163 @@ +import 'dart:developer'; + +import 'package:acela/src/models/my_account/video_ops.dart'; +import 'package:acela/src/widgets/user_profile_image.dart'; +import 'package:flutter/material.dart'; + +class AddBeneSheet extends StatefulWidget { + const AddBeneSheet({ + Key? key, + required this.benes, + required this.onSave, + }) : super(key: key); + final List benes; + final Function(List newBenes) onSave; + + @override + State createState() => _AddBeneSheetState(); +} + +class _AddBeneSheetState extends State { + var newBeneValue = 1; + var name = ''; + var _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Widget _beneNameField() { + return Container( + margin: const EdgeInsets.only(left: 10, right: 10), + child: Row( + children: [ + ValueListenableBuilder( + valueListenable: _controller, + builder: (context, value, child) { + return UserProfileImage(userName: _controller.text); + }), + const SizedBox( + width: 10, + ), + Expanded( + child: TextField( + controller: _controller, + decoration: InputDecoration( + hintText: 'Video Participant Hive Account Name', + labelText: 'Account Name', + ), + onChanged: (text) { + setState(() { + name = text; + }); + }, + maxLines: 1, + minLines: 1, + maxLength: 150, + ), + ), + ], + ), + ); + } + + void showError(String string) { + var snackBar = SnackBar(content: Text('Error: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + void showMessage(String string) { + var snackBar = SnackBar(content: Text(string)); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + @override + Widget build(BuildContext context) { + int totalWeight = 0; + widget.benes.forEach((element) { + totalWeight += element.weight; + }); + log(MediaQuery.of(context).viewInsets.bottom.toString()); + var max = (99 - totalWeight); + return SafeArea( + child: Container( + color: Theme.of(context).scaffoldBackgroundColor, + height: 400, + margin: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: Column( + children: [ + AppBar( + title: Text('Add Participant'), + actions: [ + IconButton( + onPressed: () { + if (name.isEmpty) return; + var names = + widget.benes.map((e) => e.account.toLowerCase()).toList(); + var participant = name.toLowerCase().trim(); + if (names.contains(participant)) { + showError('Video Participant already added'); + } else { + var percentValue = newBeneValue; + var newList = widget.benes; + newList.add( + BeneficiariesJson( + account: participant, + weight: percentValue, + src: 'participant', + ), + ); + // newList = newList.where((e) => e.src != 'author').toList(); + // var sum = newList.map((e) => e.weight).toList().sum; + // var newWeight = 99 - sum; + // newList.add( + // BeneficiariesJson( + // account: author.account, + // weight: newWeight, + // src: 'author'), + // ); + widget.onSave(newList); + Navigator.of(context).pop(); + } + }, + icon: Icon(Icons.add), + ) + ], + ), + if (1 <= max && + newBeneValue.toDouble() >= 1 && + newBeneValue.toDouble() <= max) + Expanded( + child: ListView( + // mainAxisSize: MainAxisSize.min, + children: [ + _beneNameField(), + const SizedBox( + height: 15, + ), + Slider( + value: newBeneValue.toDouble(), + min: 1.0, + max: max.toDouble(), + activeColor: Theme.of(context).colorScheme.secondary, + onChanged: (val) { + setState(() { + newBeneValue = val.toInt(); + }); + }, + ), + const SizedBox(height: 5), + Text( + '${(newBeneValue).toStringAsFixed(0)} %', + textAlign: TextAlign.center, + ), + ], + ), + ), + ], + ), + )); + } +} diff --git a/lib/src/screens/my_account/update_video/publish_video_screen.dart b/lib/src/screens/my_account/update_video/publish_video_screen.dart new file mode 100644 index 00000000..e99f07b6 --- /dev/null +++ b/lib/src/screens/my_account/update_video/publish_video_screen.dart @@ -0,0 +1,546 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; + +import 'package:acela/src/global_provider/ipfs_node_provider.dart'; +import 'package:acela/src/models/login/login_bridge_response.dart'; +import 'package:acela/src/models/my_account/video_ops.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/models/video_details_model/video_details.dart'; +import 'package:acela/src/utils/communicator.dart'; +import 'package:acela/src/utils/safe_convert.dart'; +import 'package:acela/src/widgets/loading_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:tus_client/tus_client.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +class PublishVideoScreen extends StatefulWidget { + const PublishVideoScreen({ + Key? key, + required this.item, + required this.hasKey, + required this.hasAuthKey, + required this.appData, + }) : super(key: key); + final String hasKey; + final String hasAuthKey; + final VideoDetails item; + + final HiveUserData appData; + + @override + State createState() => _PublishVideoScreenState(); +} + +class _PublishVideoScreenState extends State { + var isCompleting = false; + var uploadStarted = false; + var uploadComplete = false; + var thumbIpfs = ''; + var thumbUrl = ''; + var progress = 0.0; + var processText = ''; + String? hiveKeychainTransactionId; + late WebSocketChannel socket; + var socketClosed = true; + String? qrCode; + var timer = 0; + var timeoutValue = 0; + Timer? ticker; + var loadingQR = false; + var shouldShowHiveAuth = false; + late List beneficiaries; + + + + void showError(String string) { + var snackBar = SnackBar(content: Text('Error: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + void showMessage(String string) { + var snackBar = SnackBar(content: Text('Message: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + @override + void initState() { + super.initState(); + beneficiaries = widget.item.benes; + setBeneficiares(); + socket = WebSocketChannel.connect( + Uri.parse(Communicator.hiveAuthServer), + ); + socket.stream.listen((message) { + var map = json.decode(message) as Map; + var cmd = asString(map, 'cmd'); + if (cmd.isNotEmpty) { + switch (cmd) { + case "connected": + setState(() { + timeoutValue = asInt(map, 'timeout'); + }); + break; + case "auth_wait": + log('You are not logged in.'); + break; + case "auth_ack": + log('You are not logged in.'); + break; + case "auth_nack": + log('You are not logged in.'); + break; + case "sign_wait": + var uuid = asString(map, 'uuid'); + var jsonData = { + "account": widget.item.owner, + "uuid": uuid, + "key": widget.hasKey, + "host": Communicator.hiveAuthServer + }; + var jsonString = json.encode(jsonData); + var utf8Data = utf8.encode(jsonString); + var qr = base64.encode(utf8Data); + qr = "has://sign_req/$qr"; + setState(() { + loadingQR = false; + qrCode = qr; + var uri = Uri.tryParse(qr); + if (uri != null) { + launchUrl(uri); + } + timer = timeoutValue; + ticker = Timer.periodic(Duration(seconds: 1), (tickrr) { + if (timer == 0) { + setState(() { + tickrr.cancel(); + qrCode = null; + }); + } else { + setState(() { + timer--; + }); + } + }); + }); + break; + case "sign_ack": + setState(() { + qrCode = null; + }); + showMessage( + 'Please wait. Video is posted on Hive but needs to be marked as published.'); + Future.delayed(const Duration(seconds: 6), () async { + if (mounted) { + try { + await Communicator() + .updatePublishState(widget.appData, widget.item.id); + setState(() { + isCompleting = false; + processText = ''; + qrCode = null; + // showMessage('Congratulations. Your video is published.'); + showMyDialog(); + }); + } catch (e) { + setState(() { + qrCode = null; + isCompleting = false; + processText = ''; + // showMessage( + // 'Video is posted on Hive but needs to be marked as published. Please try again.'); + }); + Navigator.pop(context); + } + } + }); + break; + case "sign_nack": + setState(() { + isCompleting = false; + processText = ''; + qrCode = null; + }); + var uuid = asString(map, 'uuid'); + showError( + "Transaction - $uuid was declined. Please hit save button again to try again."); + break; + case "sign_err": + setState(() { + qrCode = null; + isCompleting = false; + processText = ''; + }); + var uuid = asString(map, 'uuid'); + showError("Transaction - $uuid failed."); + break; + default: + log('Default case here'); + } + } + }, onError: (e) async { + await Future.delayed(Duration(seconds: 2)); + socket = WebSocketChannel.connect( + Uri.parse(Communicator.hiveAuthServer), + ); + }, onDone: () async { + await Future.delayed(Duration(seconds: 2)); + socket = WebSocketChannel.connect( + Uri.parse(Communicator.hiveAuthServer), + ); + }, cancelOnError: true); + WidgetsBinding.instance.addPostFrameCallback((_) { + completeVideo(widget.appData); + }); + } + + void initiateUpload( + HiveUserData data, + XFile xFile, + ) async { + if (uploadStarted) return; + setState(() { + uploadStarted = true; + }); + final client = TusClient( + Uri.parse(Communicator.fsServer), + xFile, + store: TusMemoryStore(), + ); + await client.upload( + onComplete: () async { + print("Complete!"); + print(client.uploadUrl.toString()); + var url = client.uploadUrl.toString(); + var ipfsName = url.replaceAll("${Communicator.fsServer}/", ""); + setState(() { + thumbUrl = url; + thumbIpfs = ipfsName; + uploadComplete = true; + uploadStarted = false; + }); + }, + onProgress: (progress) { + log("Progress: $progress"); + setState(() { + this.progress = progress; + }); + }, + ); + } + + void completeVideo(HiveUserData user) async { + const platform = MethodChannel('com.example.acela/auth'); + setState(() { + isCompleting = true; + processText = 'Updating video info'; + }); + try { + var doesPostNotExist = await Communicator() + .doesPostNotExist(widget.item.owner, widget.item.permlink, user.rpc); + if (doesPostNotExist != true) { + await Communicator().updatePublishState(user, widget.item.id); + setState(() { + isCompleting = false; + processText = ''; + showMessage('Your video was already published.'); + showMyDialog(); + }); + } else { + var title = base64.encode(utf8.encode(widget.item.title)); + var description = widget.item.description; + + description = base64.encode(utf8.encode(description)); + var ipfsHash = ""; + if (widget.item.video_v2.isNotEmpty) { + ipfsHash = widget.item.video_v2 + .replaceAll(IpfsNodeProvider().nodeUrl, "") + .replaceAll("ipfs://", "") + .replaceAll("/manifest.m3u8", ""); + } + List newBene = beneficiaries + .toSet() + .map((e) => e.copyWith(account: e.account.toLowerCase())) + .toList() + ..sort((a, b) => + a.account.toLowerCase().compareTo(b.account.toLowerCase())); + final String response = await platform.invokeMethod('newPostVideo', { + 'thumbnail': widget.item.thumbnailValue, + 'video_v2': widget.item.videoValue, + 'description': description, + 'title': title, + 'tags': widget.item.tags, + 'username': user.username, + 'permlink': widget.item.permlink, + 'duration': widget.item.duration, + 'size': widget.item.size, + 'originalFilename': widget.item.originalFilename, + 'firstUpload': widget.item.firstUpload, + 'bene': '', + 'beneW': '', + 'postingKey': user.postingKey ?? '', + 'community': widget.item.community, + 'ipfsHash': ipfsHash, + 'hasKey': user.keychainData?.hasId ?? '', + 'hasAuthKey': user.keychainData?.hasAuthKey ?? '', + 'newBene': base64 + .encode(utf8.encode(BeneficiariesJson.toJsonString(newBene))), + 'language': widget.item.language, + 'powerUp': widget.item.isPowerUp, + }); + log('Response from platform $response'); + var bridgeResponse = LoginBridgeResponse.fromJsonString(response); + if ((bridgeResponse.error == "success" || + bridgeResponse.error.isEmpty) && + user.keychainData?.hasAuthKey == null) { + // showMessage( + // 'Please wait. Video is posted on Hive but needs to be marked as published.'); + Future.delayed(const Duration(seconds: 6), () async { + if (mounted) { + try { + await Communicator().updatePublishState(user, widget.item.id); + setState(() { + isCompleting = false; + processText = ''; + // showMessage('Congratulations. Your video is published.'); + showMyDialog(); + }); + } catch (e) { + setState(() { + isCompleting = false; + processText = ''; + // showMessage( + // 'Video is posted on Hive but needs to be marked as published. Please try again.'); + }); + Navigator.pop(context); + } + } + }); + } else if (bridgeResponse.error == "" && + bridgeResponse.data != null && + user.keychainData?.hasAuthKey != null) { + var socketData = { + "cmd": "sign_req", + "account": user.username!, + "token": user.keychainData!.hasId, + "data": bridgeResponse.data!, + }; + var jsonData = json.encode(socketData); + socket.sink.add(jsonData); + } else { + throw bridgeResponse.error; + } + } + } catch (e) { + setState(() { + showError(e.toString()); + isCompleting = false; + processText = ''; + }); + Navigator.pop(context); + } + } + + void showDialogForAfter10Seconds(String message) { + Widget okButton = TextButton( + child: Text("Okay"), + onPressed: () { + Navigator.of(context).pop(); + }, + ); + AlertDialog alert = AlertDialog( + title: Text("🎉 Congratulations 🎉"), + content: Text(message), + actions: [ + okButton, + ], + ); + showDialog(context: context, builder: (c) => alert); + } + + void showMyDialog() { + Widget okButton = TextButton( + child: Text("Okay"), + onPressed: () { + Navigator.of(context).pop(); + Navigator.of(context).pop(); + Navigator.of(context).pop(); + }, + ); + AlertDialog alert = AlertDialog( + title: Text("🎉 Congratulations 🎉"), + content: Text( + "Your Video is published on Hive & video is marked as published."), + actions: [ + okButton, + ], + ); + showDialog(context: context, builder: (c) => alert); + } + + void setBeneficiares({String? userName, bool resetBeneficiares = false}) { + int mobileAppPayIndex = beneficiaries.indexWhere((element) => + (element.account == 'sagarkothari88' && + element.src == "MOBILE_APP_PAY")); + int mobileAppPayAndEncoderPayIndex = beneficiaries.indexWhere((element) => + (element.account == 'sagarkothari88' && + (element.src == "MOBILE_APP_PAY_AND_ENCODER_PAY" || + element.src == "ENCODER_PAY_AND_MOBILE_APP_PAY"))); + + if (mobileAppPayIndex != -1 && mobileAppPayAndEncoderPayIndex != -1) { + beneficiaries.removeAt(mobileAppPayIndex); + } + for (int i = 0; i < beneficiaries.length; i++) { + if (widget.appData.username! != 'sagarkothari88' && + beneficiaries[i].account == 'sagarkothari88') { + beneficiaries[i] = beneficiaries[i].copyWith(isDefault: true); + } else if (widget.appData.username! != 'spk.beneficiary' && + beneficiaries[i].account == 'spk.beneficiary') { + beneficiaries[i] = beneficiaries[i].copyWith(isDefault: true); + } else if (beneficiaries[i].src == 'ENCODER_PAY') { + beneficiaries[i] = beneficiaries[i].copyWith(isDefault: true); + } else if (beneficiaries[i].src == 'MOBILE_APP_PAY_AND_ENCODER_PAY') { + beneficiaries[i] = beneficiaries[i].copyWith(isDefault: true); + } else if (beneficiaries[i].src == 'ENCODER_PAY_AND_MOBILE_APP_PAY') { + beneficiaries[i] = beneficiaries[i].copyWith(isDefault: true); + } else if (beneficiaries[i].src == 'MOBILE_APP_PAY') { + beneficiaries[i] = beneficiaries[i].copyWith(isDefault: true); + } + } + if (beneficiaries.indexWhere((element) => + (element.account == 'sagarkothari88' && + element.src == "MOBILE_APP_PAY") || + (element.account == 'sagarkothari88' && + (element.src == "MOBILE_APP_PAY_AND_ENCODER_PAY" || + element.src == "ENCODER_PAY_AND_MOBILE_APP_PAY"))) == + -1) { + beneficiaries.add( + BeneficiariesJson( + account: 'sagarkothari88', + src: 'MOBILE_APP_PAY', + weight: 1, + isDefault: true), + ); + } + if (beneficiaries + .indexWhere((element) => element.account == 'spk.beneficiary') == + -1) { + beneficiaries.add(BeneficiariesJson( + account: 'spk.beneficiary', + src: 'threespeak', + weight: 10, + isDefault: true)); + } + + beneficiaries = + beneficiaries.where((element) => element.src != 'author').toList(); + } + + Widget _showQRCodeAndKeychainButton(String qr) { + Widget hkButton = ElevatedButton( + onPressed: () { + var uri = Uri.tryParse(qr); + if (uri != null) { + launchUrl(uri); + } + }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.black), + child: Image.asset('assets/hive-keychain-image.png', width: 100), + ); + Widget haButton = ElevatedButton( + onPressed: () { + setState(() { + shouldShowHiveAuth = true; + }); + }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.black), + child: Image.asset('assets/hive_auth_button.png', width: 120), + ); + Widget qrCode = InkWell( + child: Container( + decoration: BoxDecoration(color: Colors.white), + child: QrImageView( + data: qr, + size: 150.0, + gapless: true, + ), + ), + onTap: () { + var uri = Uri.tryParse(qr); + if (uri != null) { + launchUrl(uri); + } + }, + ); + var backButton = ElevatedButton.icon( + onPressed: () { + setState(() { + shouldShowHiveAuth = false; + }); + }, + icon: Icon(Icons.arrow_back), + label: Text("Back"), + ); + List array = []; + if (shouldShowHiveAuth) { + array = [ + backButton, + const SizedBox(width: 10), + qrCode, + ]; + } else { + array = [ + haButton, + const SizedBox(width: 10), + hkButton, + ]; + } + return Center( + child: Column( + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: array, + ), + SizedBox(height: 10), + SizedBox( + width: 200, + child: LinearProgressIndicator( + value: timer.toDouble() / timeoutValue.toDouble(), + semanticsLabel: 'Timeout Timer for HiveAuth QR', + ), + ), + ], + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Publishing")), + body: isCompleting + ? (qrCode == null) + ? Center( + child: LoadingScreen( + title: 'Please wait', + subtitle: processText, + ), + ) + : _showQRCodeAndKeychainButton(qrCode!) + : Center( + child: Text("Video getting ready to get published"), + ), + ); + } +} diff --git a/lib/src/screens/my_account/update_video/video_details_info.dart b/lib/src/screens/my_account/update_video/video_details_info.dart new file mode 100644 index 00000000..b186ed89 --- /dev/null +++ b/lib/src/screens/my_account/update_video/video_details_info.dart @@ -0,0 +1,812 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; + +import 'package:acela/src/global_provider/ipfs_node_provider.dart'; +import 'package:acela/src/models/login/login_bridge_response.dart'; +import 'package:acela/src/models/my_account/video_ops.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/models/video_details_model/video_details.dart'; +import 'package:acela/src/screens/my_account/my_account_screen.dart'; +import 'package:acela/src/screens/settings/settings_screen.dart'; +import 'package:acela/src/screens/upload/video/widgets/beneficaries_tile.dart'; +import 'package:acela/src/utils/communicator.dart'; +import 'package:acela/src/utils/safe_convert.dart'; +import 'package:acela/src/widgets/custom_circle_avatar.dart'; +import 'package:acela/src/widgets/loading_screen.dart'; +import 'package:adaptive_action_sheet/adaptive_action_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:provider/provider.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:tus_client/tus_client.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +class VideoDetailsInfo extends StatefulWidget { + const VideoDetailsInfo({ + Key? key, + required this.item, + required this.title, + required this.subtitle, + required this.selectedCommunity, + required this.justForEditing, + required this.hasKey, + required this.hasAuthKey, + required this.appData, + required this.isNsfwContent, + }) : super(key: key); + final String hasKey; + final String hasAuthKey; + final VideoDetails item; + final String title; + final String subtitle; + final bool justForEditing; + final String selectedCommunity; + final HiveUserData appData; + final bool isNsfwContent; + + @override + State createState() => _VideoDetailsInfoState(); +} + +class _VideoDetailsInfoState extends State { + var isCompleting = false; + var isPickingImage = false; + var uploadStarted = false; + var uploadComplete = false; + var thumbIpfs = ''; + var thumbUrl = ''; + var tags = 'threespeak,mobile'; + var progress = 0.0; + var processText = ''; + TextEditingController tagsController = TextEditingController(); + final ImagePicker _picker = ImagePicker(); + String? hiveKeychainTransactionId; + late WebSocketChannel socket; + var socketClosed = true; + String? qrCode; + var timer = 0; + var timeoutValue = 0; + Timer? ticker; + var loadingQR = false; + var shouldShowHiveAuth = false; + var powerUp100 = false; + late List beneficiaries; + + var languages = [ + VideoLanguage(code: "en", name: "English"), + VideoLanguage(code: "de", name: "Deutsch"), + VideoLanguage(code: "pt", name: "Portuguese"), + VideoLanguage(code: "fr", name: "Français"), + VideoLanguage(code: "es", name: "Español"), + VideoLanguage(code: "nl", name: "Nederlands"), + VideoLanguage(code: "ko", name: "한국어"), + VideoLanguage(code: "ru", name: "русский"), + VideoLanguage(code: "hu", name: "Magyar"), + VideoLanguage(code: "ro", name: "Română"), + VideoLanguage(code: "cs", name: "čeština"), + VideoLanguage(code: "pl", name: "Polskie"), + VideoLanguage(code: "in", name: "bahasa Indonesia"), + VideoLanguage(code: "bn", name: "বাংলা"), + VideoLanguage(code: "it", name: "Italian"), + VideoLanguage(code: "he", name: "עִברִית"), + ]; + var selectedLanguage = VideoLanguage(code: "en", name: "English"); + + void showError(String string) { + var snackBar = SnackBar(content: Text('Error: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + void showMessage(String string) { + var snackBar = SnackBar(content: Text('Message: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + @override + void initState() { + super.initState(); + beneficiaries = widget.item.benes; + setBeneficiares(); + tagsController.text = + widget.item.tags.isEmpty ? "threespeak,mobile" : widget.item.tags; + tags = widget.item.tags.isEmpty ? "threespeak,mobile" : widget.item.tags; + socket = WebSocketChannel.connect( + Uri.parse(Communicator.hiveAuthServer), + ); + socket.stream.listen((message) { + var map = json.decode(message) as Map; + var cmd = asString(map, 'cmd'); + if (cmd.isNotEmpty) { + switch (cmd) { + case "connected": + setState(() { + timeoutValue = asInt(map, 'timeout'); + }); + break; + case "auth_wait": + log('You are not logged in.'); + break; + case "auth_ack": + log('You are not logged in.'); + break; + case "auth_nack": + log('You are not logged in.'); + break; + case "sign_wait": + var uuid = asString(map, 'uuid'); + var jsonData = { + "account": widget.item.owner, + "uuid": uuid, + "key": widget.hasKey, + "host": Communicator.hiveAuthServer + }; + var jsonString = json.encode(jsonData); + var utf8Data = utf8.encode(jsonString); + var qr = base64.encode(utf8Data); + qr = "has://sign_req/$qr"; + setState(() { + loadingQR = false; + qrCode = qr; + var uri = Uri.tryParse(qr); + if (uri != null) { + launchUrl(uri); + } + timer = timeoutValue; + ticker = Timer.periodic(Duration(seconds: 1), (tickrr) { + if (timer == 0) { + setState(() { + tickrr.cancel(); + qrCode = null; + }); + } else { + setState(() { + timer--; + }); + } + }); + }); + break; + case "sign_ack": + setState(() { + qrCode = null; + }); + showMessage( + 'Please wait. Video is posted on Hive but needs to be marked as published.'); + Future.delayed(const Duration(seconds: 6), () async { + if (mounted) { + try { + await Communicator() + .updatePublishState(widget.appData, widget.item.id); + setState(() { + isCompleting = false; + processText = ''; + qrCode = null; + showMessage('Congratulations. Your video is published.'); + showMyDialog(); + }); + } catch (e) { + setState(() { + qrCode = null; + isCompleting = false; + processText = ''; + showMessage( + 'Video is posted on Hive but needs to be marked as published. Please hit Save button again after few seconds.'); + }); + } + } + }); + break; + case "sign_nack": + setState(() { + isCompleting = false; + processText = ''; + qrCode = null; + }); + var uuid = asString(map, 'uuid'); + showError( + "Transaction - $uuid was declined. Please hit save button again to try again."); + break; + case "sign_err": + setState(() { + qrCode = null; + isCompleting = false; + processText = ''; + }); + var uuid = asString(map, 'uuid'); + showError("Transaction - $uuid failed."); + break; + default: + log('Default case here'); + } + } + }, onError: (e) async { + await Future.delayed(Duration(seconds: 2)); + socket = WebSocketChannel.connect( + Uri.parse(Communicator.hiveAuthServer), + ); + }, onDone: () async { + await Future.delayed(Duration(seconds: 2)); + socket = WebSocketChannel.connect( + Uri.parse(Communicator.hiveAuthServer), + ); + }, cancelOnError: true); + } + + void initiateUpload( + HiveUserData data, + XFile xFile, + ) async { + if (uploadStarted) return; + setState(() { + uploadStarted = true; + }); + final client = TusClient( + Uri.parse(Communicator.fsServer), + xFile, + store: TusMemoryStore(), + ); + await client.upload( + onComplete: () async { + print("Complete!"); + print(client.uploadUrl.toString()); + var url = client.uploadUrl.toString(); + var ipfsName = url.replaceAll("${Communicator.fsServer}/", ""); + setState(() { + thumbUrl = url; + thumbIpfs = ipfsName; + uploadComplete = true; + uploadStarted = false; + }); + }, + onProgress: (progress) { + log("Progress: $progress"); + setState(() { + this.progress = progress; + }); + }, + ); + } + + void completeVideo(HiveUserData user) async { + const platform = MethodChannel('com.example.acela/auth'); + setState(() { + isCompleting = true; + processText = 'Updating video info'; + }); + try { + var doesPostNotExist = await Communicator() + .doesPostNotExist(widget.item.owner, widget.item.permlink, user.rpc); + if (doesPostNotExist != true) { + await Communicator().updatePublishState(user, widget.item.id); + setState(() { + isCompleting = false; + processText = ''; + showMessage('Your video was already published.'); + showMyDialog(); + }); + } else { + var v = await Communicator().updateInfo( + user: user, + videoId: widget.item.id, + title: widget.title, + description: widget.subtitle, + isNsfwContent: widget.isNsfwContent, + tags: tags, + thumbnail: thumbIpfs.isEmpty ? null : thumbIpfs, + beneficiaries: beneficiaries, + communityID: widget.selectedCommunity); + if (widget.justForEditing) { + setState(() { + showMessage('Video details are saved.'); + var screen = MyAccountScreen(data: user); + Navigator.of(context).pop(); + Navigator.of(context).pop(); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + }); + return; + } + await Future.delayed(const Duration(seconds: 1), () {}); + var title = base64.encode(utf8.encode(widget.title)); + var description = widget.subtitle; + // if (!(description.contains(Communicator.suffixText))) { + // description = "$description\n${Communicator.suffixText}"; + // } + description = base64.encode(utf8.encode(description)); + var ipfsHash = ""; + if (widget.item.video_v2.isNotEmpty) { + ipfsHash = widget.item.video_v2 + .replaceAll(IpfsNodeProvider().nodeUrl, "") + .replaceAll("ipfs://", "") + .replaceAll("/manifest.m3u8", ""); + } + List newBene = beneficiaries.toSet() + .map((e) => e.copyWith(account: e.account.toLowerCase())) + .toList() + ..sort((a, b) => + a.account.toLowerCase().compareTo(b.account.toLowerCase())); + final String response = await platform.invokeMethod('newPostVideo', { + 'thumbnail': v.thumbnailValue, + 'video_v2': v.videoValue, + 'description': description, + 'title': title, + 'tags': v.tags, + 'username': user.username, + 'permlink': v.permlink, + 'duration': v.duration, + 'size': v.size, + 'originalFilename': v.originalFilename, + 'firstUpload': v.firstUpload, + 'bene': '', + 'beneW': '', + 'postingKey': user.postingKey ?? '', + 'community': widget.selectedCommunity, + 'ipfsHash': ipfsHash, + 'hasKey': user.keychainData?.hasId ?? '', + 'hasAuthKey': user.keychainData?.hasAuthKey ?? '', + 'newBene': base64 + .encode(utf8.encode(BeneficiariesJson.toJsonString(newBene))), + 'language': selectedLanguage.code, + 'powerUp': powerUp100, + }); + log('Response from platform $response'); + var bridgeResponse = LoginBridgeResponse.fromJsonString(response); + if ((bridgeResponse.error == "success" || bridgeResponse.error.isEmpty) && user.keychainData?.hasAuthKey == null) { + showMessage( + 'Please wait. Video is posted on Hive but needs to be marked as published.'); + Future.delayed(const Duration(seconds: 6), () async { + if (mounted) { + try { + await Communicator().updatePublishState(user, v.id); + setState(() { + isCompleting = false; + processText = ''; + showMessage('Congratulations. Your video is published.'); + showMyDialog(); + }); + } catch (e) { + setState(() { + isCompleting = false; + processText = ''; + showMessage( + 'Video is posted on Hive but needs to be marked as published. Please hit Save button again after few seconds.'); + }); + } + } + }); + } else if (bridgeResponse.error == "" && + bridgeResponse.data != null && + user.keychainData?.hasAuthKey != null) { + var socketData = { + "cmd": "sign_req", + "account": user.username!, + "token": user.keychainData!.hasId, + "data": bridgeResponse.data!, + }; + var jsonData = json.encode(socketData); + socket.sink.add(jsonData); + } else { + throw bridgeResponse.error; + } + } + } catch (e) { + showError(e.toString()); + setState(() { + isCompleting = false; + processText = ''; + }); + } + } + + void showDialogForAfter10Seconds(String message) { + Widget okButton = TextButton( + child: Text("Okay"), + onPressed: () { + Navigator.of(context).pop(); + }, + ); + AlertDialog alert = AlertDialog( + title: Text("🎉 Congratulations 🎉"), + content: Text(message), + actions: [ + okButton, + ], + ); + showDialog(context: context, builder: (c) => alert); + } + + // void showDialogForHASTransaction(String uuid) { + // Widget okButton = TextButton( + // child: Text("Okay"), + // onPressed: () { + // Navigator.of(context).pop(); + // }, + // ); + // AlertDialog alert = AlertDialog( + // title: Text("Launch HiveAuth / HiveKeychain"), + // content: Text("Transaction - $uuid is waiting for approval. Please launch \"Keychain for Hive\" and approve to publish on Hive."), + // actions: [ + // okButton, + // ], + // ); + // showDialog(context: context, builder: (c) => alert); + // } + + void showMyDialog() { + Widget okButton = TextButton( + child: Text("Okay"), + onPressed: () { + Navigator.of(context).pop(); + Navigator.of(context).pop(); + Navigator.of(context).pop(); + Navigator.of(context).pop(); + }, + ); + AlertDialog alert = AlertDialog( + title: Text("🎉 Congratulations 🎉"), + content: Text( + "Your Video is published on Hive & video is marked as published."), + actions: [ + okButton, + ], + ); + showDialog(context: context, builder: (c) => alert); + } + + Widget _thumbnailPicker(HiveUserData user) { + return Center( + child: Container( + width: 320, + height: 160, + margin: const EdgeInsets.all(10), + decoration: const BoxDecoration( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(24.0), + topRight: Radius.circular(24.0), + ), + boxShadow: [ + BoxShadow( + color: Colors.black12, + spreadRadius: 3, + blurRadius: 3, + ) + ]), + child: InkWell( + child: Center( + child: isPickingImage + ? const CircularProgressIndicator() + : progress > 0.0 && progress < 100.0 + ? CircularProgressIndicator(value: progress) + : thumbUrl.isNotEmpty + ? Image.network( + thumbUrl, + width: 320, + height: 160, + ) + : widget.item.getThumbnail().isNotEmpty + ? Image.network( + widget.item.getThumbnail(), + width: 320, + height: 160, + ) + : const Text( + 'Tap here to add thumbnail for your video\n\nThumbnail is MANDATORY to set.', + textAlign: TextAlign.center), + ), + onTap: () async { + try { + setState(() { + isPickingImage = true; + }); + final XFile? file = + await _picker.pickImage(source: ImageSource.gallery); + if (file != null) { + setState(() { + isPickingImage = false; + }); + initiateUpload(user, file); + } else { + throw 'User cancelled image picker'; + } + } catch (e) { + showError(e.toString()); + setState(() { + isPickingImage = false; + }); + } + }, + ), + ), + ); + } + + Widget _tagField() { + return Container( + margin: const EdgeInsets.only(left: 10, right: 10), + child: TextField( + controller: tagsController, + decoration: const InputDecoration( + hintText: 'Comma separated tags', + labelText: 'Tags', + ), + onChanged: (text) { + setState(() { + tags = text; + }); + }, + maxLines: 1, + minLines: 1, + maxLength: 150, + ), + ); + } + + Widget _rewardType() { + return Padding( + padding: const EdgeInsets.only(left: 15, right: 15, top: 20, bottom: 20), + child: Row( + children: [ + Text(powerUp100 ? '100% power' : '50% power'), + const Spacer(), + Switch( + value: powerUp100, + onChanged: (newVal) { + setState(() { + powerUp100 = newVal; + }); + }, + ) + ], + ), + ); + } + + Widget _beneficiaryTile() { + return BeneficiariesTile( + userName: context.read().username!, + beneficiaries: beneficiaries, + onChanged: (beneficaries) => this.beneficiaries = beneficaries, + ); + } + + void setBeneficiares({String? userName, bool resetBeneficiares = false}) { + int mobileAppPayIndex = beneficiaries.indexWhere((element) => + (element.account == 'sagarkothari88' && + element.src == "MOBILE_APP_PAY")); + int mobileAppPayAndEncoderPayIndex = beneficiaries.indexWhere((element) => + (element.account == 'sagarkothari88' && + (element.src == "MOBILE_APP_PAY_AND_ENCODER_PAY" || + element.src == "ENCODER_PAY_AND_MOBILE_APP_PAY"))); + + if (mobileAppPayIndex != -1 && mobileAppPayAndEncoderPayIndex != -1) { + beneficiaries.removeAt(mobileAppPayIndex); + } + for (int i = 0; i < beneficiaries.length; i++) { + if (widget.appData.username! != 'sagarkothari88' && + beneficiaries[i].account == 'sagarkothari88') { + beneficiaries[i] = beneficiaries[i].copyWith(isDefault: true); + } else if (widget.appData.username! != 'spk.beneficiary' && + beneficiaries[i].account == 'spk.beneficiary') { + beneficiaries[i] = beneficiaries[i].copyWith(isDefault: true); + } else if (beneficiaries[i].src == 'ENCODER_PAY') { + beneficiaries[i] = beneficiaries[i].copyWith(isDefault: true); + } else if (beneficiaries[i].src == 'MOBILE_APP_PAY_AND_ENCODER_PAY') { + beneficiaries[i] = beneficiaries[i].copyWith(isDefault: true); + } else if (beneficiaries[i].src == 'ENCODER_PAY_AND_MOBILE_APP_PAY') { + beneficiaries[i] = beneficiaries[i].copyWith(isDefault: true); + } else if (beneficiaries[i].src == 'MOBILE_APP_PAY') { + beneficiaries[i] = beneficiaries[i].copyWith(isDefault: true); + } + } + if (beneficiaries.indexWhere((element) => + (element.account == 'sagarkothari88' && + element.src == "MOBILE_APP_PAY") || + (element.account == 'sagarkothari88' && + (element.src == "MOBILE_APP_PAY_AND_ENCODER_PAY" || + element.src == "ENCODER_PAY_AND_MOBILE_APP_PAY"))) == + -1) { + beneficiaries.add( + BeneficiariesJson( + account: 'sagarkothari88', + src: 'MOBILE_APP_PAY', + weight: 1, + isDefault: true), + ); + } + if (beneficiaries + .indexWhere((element) => element.account == 'spk.beneficiary') == + -1) { + beneficiaries.add(BeneficiariesJson( + account: 'spk.beneficiary', + src: 'threespeak', + weight: 10, + isDefault: true)); + } + + beneficiaries = + beneficiaries.where((element) => element.src != 'author').toList(); + } + + Widget _showQRCodeAndKeychainButton(String qr) { + Widget hkButton = ElevatedButton( + onPressed: () { + var uri = Uri.tryParse(qr); + if (uri != null) { + launchUrl(uri); + } + }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.black), + child: Image.asset('assets/hive-keychain-image.png', width: 100), + ); + Widget haButton = ElevatedButton( + onPressed: () { + setState(() { + shouldShowHiveAuth = true; + }); + }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.black), + child: Image.asset('assets/hive_auth_button.png', width: 120), + ); + Widget qrCode = InkWell( + child: Container( + decoration: BoxDecoration(color: Colors.white), + child: QrImageView( + data: qr, + size: 150.0, + gapless: true, + ), + ), + onTap: () { + var uri = Uri.tryParse(qr); + if (uri != null) { + launchUrl(uri); + } + }, + ); + var backButton = ElevatedButton.icon( + onPressed: () { + setState(() { + shouldShowHiveAuth = false; + }); + }, + icon: Icon(Icons.arrow_back), + label: Text("Back"), + ); + List array = []; + if (shouldShowHiveAuth) { + array = [ + backButton, + const SizedBox(width: 10), + qrCode, + ]; + } else { + array = [ + haButton, + const SizedBox(width: 10), + hkButton, + ]; + } + return Center( + child: Column( + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: array, + ), + SizedBox(height: 10), + SizedBox( + width: 200, + child: LinearProgressIndicator( + value: timer.toDouble() / timeoutValue.toDouble(), + semanticsLabel: 'Timeout Timer for HiveAuth QR', + ), + ), + ], + ), + ], + ), + ); + } + + BottomSheetAction getLangAction(VideoLanguage language) { + return BottomSheetAction( + title: Text(language.name), + onPressed: (context) async { + setState(() { + selectedLanguage = language; + Navigator.of(context).pop(); + }); + }, + ); + } + + void tappedLanguage() { + showAdaptiveActionSheet( + context: context, + title: const Text('Set Default Language Filter'), + androidBorderRadius: 30, + actions: languages.map((e) => getLangAction(e)).toList(), + cancelAction: CancelAction(title: const Text('Cancel')), + ); + } + + Widget _changeLanguage() { + var display = selectedLanguage.name; + return ListTile( + leading: const Icon(Icons.language), + title: const Text("Set Language Filter"), + trailing: Text(display), + onTap: () { + tappedLanguage(); + }, + ); + } + + @override + Widget build(BuildContext context) { + var user = Provider.of(context); + return Scaffold( + appBar: AppBar( + title: ListTile( + leading: CustomCircleAvatar( + height: 36, + width: 36, + url: 'https://images.hive.blog/u/${user.username ?? ''}/avatar', + ), + title: Text(user.username ?? ''), + subtitle: Text('Provide more details to publish'), + ), + ), + body: isCompleting + ? (qrCode == null) + ? Center( + child: LoadingScreen( + title: 'Please wait', + subtitle: processText, + ), + ) + : _showQRCodeAndKeychainButton(qrCode!) + : Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _tagField(), + _thumbnailPicker(user), + const Text('Tap to change video thumbnail'), + if (!widget.justForEditing) _rewardType(), + if (!widget.justForEditing) _beneficiaryTile(), + if (!widget.justForEditing) _changeLanguage(), + ], + ), + floatingActionButton: isCompleting + ? null + : FloatingActionButton.extended( + label: Text(widget.justForEditing ? 'Save Details' : 'Publish'), + onPressed: () { + if (user.username != null) { + int weight = 0; + beneficiaries.forEach((element) { + weight += element.weight; + }); + if (beneficiaries.length > 8 || weight > 100) { + showError( + 'Beneficiaries exceeds the limit, please lower down'); + } else if (thumbIpfs.isNotEmpty || + widget.item.getThumbnail().isNotEmpty) { + completeVideo(user); + } else { + showError('Please set Thumbnail'); + } + } + }, + icon: Icon(widget.justForEditing ? Icons.save : Icons.post_add), + ), + ); + } +} diff --git a/lib/src/screens/my_account/update_video/video_primary_info.dart b/lib/src/screens/my_account/update_video/video_primary_info.dart new file mode 100644 index 00000000..6e9945bb --- /dev/null +++ b/lib/src/screens/my_account/update_video/video_primary_info.dart @@ -0,0 +1,214 @@ +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/models/video_details_model/video_details.dart'; +import 'package:acela/src/screens/communities_screen/communities_screen.dart'; +import 'package:acela/src/screens/my_account/update_video/video_details_info.dart'; +import 'package:acela/src/widgets/custom_circle_avatar.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class VideoPrimaryInfo extends StatefulWidget { + const VideoPrimaryInfo({ + Key? key, + required this.item, + required this.justForEditing, + }) : super(key: key); + final VideoDetails item; + final bool justForEditing; + + @override + State createState() => _VideoPrimaryInfoState(); +} + +class _VideoPrimaryInfoState extends State { + var title = ''; + var description = ''; + var titleController = TextEditingController(); + var descriptionController = TextEditingController(); + late String selectedCommunity; //= 'hive-181335'; + late String selectedCommunityVisibleName; //= 'Threespeak'; + var isNsfwContent = false; + + @override + void initState() { + super.initState(); + selectedCommunity = + widget.item.community.isEmpty ? 'hive-181335' : widget.item.community; + selectedCommunityVisibleName = widget.item.community.isEmpty + ? 'Three Speak' + : widget.item.community == 'hive-181335' + ? 'Three Speak' + : widget.item.community; + titleController.text = widget.item.title; + descriptionController.text = widget.item.description; + title = widget.item.title; + description = widget.item.description; + } + + Widget _notSafe() { + return Row( + children: [ + Text(isNsfwContent + ? 'Video is NOT SAFE for work.' + : 'Video is Safe for work.'), + const Spacer(), + Switch( + value: isNsfwContent, + onChanged: (newVal) { + setState(() { + isNsfwContent = newVal; + }); + }, + ) + ], + ); + } + + Widget _communityPicker() { + return Row( + children: [ + const Text('Select Community:'), + Spacer(), + InkWell( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (c) => CommunitiesScreen( + withoutScaffold: false, + didSelectCommunity: (name, id) { + setState(() { + selectedCommunity = id; + selectedCommunityVisibleName = name; + }); + }, + ), + ), + ); + }, + child: Row( + children: [ + Text(selectedCommunityVisibleName), + SizedBox(width: 10), + CustomCircleAvatar( + width: 44, + height: 44, + url: server.communityIcon(selectedCommunity), + ), + ], + ), + ), + ], + ); + } + + Widget _body() { + return SafeArea( + child: Container( + margin: const EdgeInsets.all(20), + child: Column( + children: [ + TextField( + decoration: InputDecoration( + hintText: 'Video title goes here', + labelText: 'Title', + suffixIcon: IconButton( + onPressed: () { + titleController.clear(); + setState(() { + title = ''; + }); + }, + icon: Icon(Icons.clear), + ), + ), + onChanged: (text) { + setState(() { + title = text; + }); + }, + controller: titleController, + maxLines: 1, + minLines: 1, + maxLength: 150, + ), + TextFormField( + decoration: InputDecoration( + hintText: 'Video description', + labelText: 'Description', + suffixIcon: IconButton( + onPressed: () { + descriptionController.clear(); + setState(() { + description = ''; + }); + }, + icon: Icon(Icons.clear), + ), + ), + onChanged: (text) { + setState(() { + description = text; + }); + }, + controller: descriptionController, + maxLines: 8, + minLines: 5, + ), + const SizedBox(height: 10), + _communityPicker(), + const SizedBox(height: 10), + _notSafe(), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + var appData = Provider.of(context); + return Scaffold( + appBar: AppBar( + title: ListTile( + leading: CustomCircleAvatar( + height: 36, + width: 36, + url: 'https://images.hive.blog/u/${appData.username ?? ''}/avatar', + ), + title: Text(appData.username ?? ''), + subtitle: Text('Edit Video Info'), + ), + ), + body: _body(), + floatingActionButton: FloatingActionButton( + onPressed: () { + if (title.isEmpty) { + showError('Title is required'); + } else if (description.isEmpty) { + showError('Description is required'); + } else { + var screen = VideoDetailsInfo( + item: widget.item, + title: titleController.text, + subtitle: descriptionController.text, + selectedCommunity: selectedCommunity, + isNsfwContent: isNsfwContent, + justForEditing: widget.justForEditing, + hasKey: appData.keychainData?.hasId ?? "", + hasAuthKey: appData.keychainData?.hasAuthKey ?? "", + appData: appData, + ); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + } + }, + child: const Text('Next'), + ), + ); + } + + void showError(String string) { + var snackBar = SnackBar(content: Text('Error: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } +} diff --git a/lib/src/screens/my_account/video_preview.dart b/lib/src/screens/my_account/video_preview.dart new file mode 100644 index 00000000..d66e5965 --- /dev/null +++ b/lib/src/screens/my_account/video_preview.dart @@ -0,0 +1,108 @@ + +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/models/video_details_model/video_details.dart'; +import 'package:acela/src/utils/communicator.dart'; +import 'package:acela/src/widgets/loading_screen.dart'; +import 'package:better_player/better_player.dart'; +import 'package:flutter/material.dart'; + +class VideoPreviewScreen extends StatefulWidget { + const VideoPreviewScreen({ + Key? key, + required this.item, + required this.data, + }) : super(key: key); + final VideoDetails item; + final HiveUserData data; + + @override + State createState() => _VideoPreviewScreenState(); +} + +class _VideoPreviewScreenState extends State { + late BetterPlayerController _betterPlayerController; + + void setupVideo(String url, double ratio) { + BetterPlayerConfiguration betterPlayerConfiguration = + BetterPlayerConfiguration( + aspectRatio: ratio, + fit: BoxFit.contain, + autoPlay: true, + fullScreenByDefault: false, + controlsConfiguration: BetterPlayerControlsConfiguration( + enablePip: false, + enableFullscreen: false, + enableSkips: true, + ), + autoDetectFullscreenAspectRatio: false, + autoDetectFullscreenDeviceOrientation: false, + autoDispose: true, + expandToFill: true, + allowedScreenSleep: false, + ); + BetterPlayerDataSource dataSource = BetterPlayerDataSource( + BetterPlayerDataSourceType.network, + widget.item.videoV2M3U8(widget.data), + videoFormat: BetterPlayerVideoFormat.hls, + ); + _betterPlayerController = BetterPlayerController(betterPlayerConfiguration); + _betterPlayerController.setupDataSource(dataSource); + } + + Widget container(String title, Widget body) { + return Scaffold( + body: body, + appBar: AppBar( + title: Text(title), + ), + ); + } + + Widget _futureForLoadingRatio() { + return FutureBuilder( + future: + Communicator().getAspectRatio(widget.item.videoV2M3U8(widget.data)), + builder: (builder, snapshot) { + if (snapshot.hasError) { + String text = + 'Something went wrong while loading video information - ${snapshot.error?.toString() ?? ""}'; + return container('Video Preview', Text(text)); + } else if (snapshot.hasData && + snapshot.connectionState == ConnectionState.done) { + var videoSize = snapshot.data as VideoSize; + setupVideo( + widget.item.getVideoUrl(widget.data), + videoSize.height / videoSize.width, + ); + return Scaffold( + appBar: AppBar( + title: Text('Video Preview'), + ), + body: SafeArea( + child: SizedBox( + height: double.infinity, + width: double.infinity, + child: BetterPlayer( + controller: _betterPlayerController, + ), + ), + ), + ); + } else { + return container( + 'Video Preview', + const LoadingScreen( + title: 'Loading Data', + subtitle: 'Please wait', + ), + ); + } + }, + ); + } + + @override + Widget build(BuildContext context) { + return _futureForLoadingRatio(); + } +} diff --git a/lib/src/screens/podcast/controller/podcast_chapters_controller.dart b/lib/src/screens/podcast/controller/podcast_chapters_controller.dart new file mode 100644 index 00000000..9fbd52a5 --- /dev/null +++ b/lib/src/screens/podcast/controller/podcast_chapters_controller.dart @@ -0,0 +1,167 @@ +import 'package:acela/src/models/podcast/podcast_episode_chapters.dart'; +import 'package:acela/src/screens/podcast/widgets/audio_player/action_tools.dart'; +import 'package:acela/src/screens/podcast/widgets/audio_player/audio_player_core_controls.dart'; +import 'package:acela/src/utils/podcast/podcast_communicator.dart'; +import 'package:flutter/material.dart'; + +class PodcastChapterController extends ChangeNotifier { + final AudioPlayerHandler audioPlayerHandler; + List? chapters; + final String? chapterUrl; + int totalDuration; + int currentDuration = 0; + + String? title; + String? image; + + PodcastChapterController({ + required this.chapterUrl, + required this.totalDuration, + required this.audioPlayerHandler, + }) { + _loadChapters(); + } + + void _loadChapters() async { + if (chapterUrl != null) { + var result = await PodCastCommunicator().getPodcastEpisodeChapters(chapterUrl!); + result.removeWhere((element) => element.toc != null); + chapters = result; + notifyListeners(); + } else { + chapters = null; + } + } + + void jumpToNextChapter(Function callback) { + if (chapters != null && chapters!.isNotEmpty) { + int index = chapters!.indexWhere((element) { + return element.startTime! > currentDuration; + }); + if (index != -1) { + _setChapterTitleAndImage(index); + int startTime = chapters![index].startTime!; + if (startTime > totalDuration) { + callback(); + } else { + audioPlayerHandler.seek(Duration(seconds: startTime)); + } + } else { + callback(); + } + } else { + callback(); + } + } + + void jumpToPreviousChapter(Function callback) { + if (chapters != null && chapters!.isNotEmpty) { + int? index = _findNearestLessThan(checkEqual: false); + if (index != null) { + _setChapterTitleAndImage(index); + int startTime = chapters![index].startTime!; + if (startTime < 0) { + callback(); + } else { + audioPlayerHandler.seek(Duration(seconds: startTime)); + } + } else { + callback(); + } + } else { + callback(); + } + } + + bool hasPreviousChapter() { + if (chapters != null && chapters!.isNotEmpty) { + int? index = _findNearestLessThan(checkEqual: false); + if (index == 0 && currentDuration == 0) { + return false; + } else { + return index != null; + } + } + return false; + } + + bool hasNextChapter() { + if (chapters != null && chapters!.isNotEmpty) { + int index = chapters!.indexWhere((element) { + return element.startTime! > currentDuration; + }); + return index != -1; + } + return false; + } + + void syncChapters({bool isInteracted = false, bool isReduced = false}) { + if (chapters != null && chapters!.isNotEmpty) { + if (!isInteracted) { + int index = chapters!.indexWhere((element) => element.startTime == currentDuration); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _setChapterTitleAndImage(index); + }); + } else { + if (!isReduced) { + int index = chapters!.indexWhere((element) => element.startTime == currentDuration); + if (index == -1) { + int newIndex = chapters!.indexWhere((element) { + return element.startTime! > currentDuration; + }); + if ((newIndex - 1 > 0)) { + _setChapterTitleAndImage(newIndex - 1); + } else { + _setChapterTitleAndImage(0); + } + } else { + _setChapterTitleAndImage(index); + } + } else { + if (isReduced) { + int? index = _findNearestLessThan(); + if (index != null) { + _setChapterTitleAndImage(index); + } + } + } + } + } + } + + int? _findNearestLessThan({bool checkEqual = true}) { + int? result; + for (int i = 0; i < chapters!.length; i++) { + if (checkEqual) { + if (chapters![i].startTime == currentDuration) { + return i; + } + } + if (chapters![i].startTime! < currentDuration) { + result = i; + } else { + return result; + } + } + return result; + } + + void _setChapterTitleAndImage(int index) { + if (index != -1 && index < chapters!.length) { + String? chapterTitle = chapters![index].title; + String? chapterImage = chapters![index].image; + if (chapterTitle != null) { + title = chapterTitle; + } + if (chapterImage != null) { + image = chapterImage; + } + notifyListeners(); + } + } + + void setDurationData(PositionData data) { + currentDuration = data.position.inSeconds; + totalDuration = data.duration.inSeconds; + } +} diff --git a/lib/src/screens/podcast/controller/podcast_controller.dart b/lib/src/screens/podcast/controller/podcast_controller.dart new file mode 100644 index 00000000..0cd1a405 --- /dev/null +++ b/lib/src/screens/podcast/controller/podcast_controller.dart @@ -0,0 +1,257 @@ +import 'dart:developer'; +import 'dart:io'; +import 'package:acela/src/models/podcast/podcast_episodes.dart'; +import 'package:acela/src/models/podcast/trending_podcast_response.dart'; +import 'package:flutter/material.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:http/http.dart'; +import 'package:path_provider/path_provider.dart'; + +class PodcastController extends ChangeNotifier { + final box = GetStorage(); + final durationStorage = GetStorage('duration_storage'); + final String _likedPodcastEpisodeLocalKey = 'liked_podcast_episode'; + final String _likedPodcastLocalKey = 'liked_podcast'; + final String _offlinePodcastLocalKey = 'offline_podcast'; + bool isDurationContinuing = true; + var externalDir; + + PodcastController() { + init(); + } + + void init() { + setupOfflinePath(); + } + + void setupOfflinePath() async { + if (Platform.isAndroid) { + externalDir = await getExternalStorageDirectory(); + } else { + externalDir = await getApplicationDocumentsDirectory(); + } + } + + bool isOffline(String name, String episodeId) { + if (externalDir != null) { + for (var item in externalDir.listSync()) { + if (decodeAudioName(item.path) == + decodeAudioName(name, + episodeId: name.startsWith('http') ? episodeId : null)) { + // print('offline'); + return true; + } + } + } + // print('online'); + return false; + } + + String getOfflineUrl(String url, String episodeId) { + for (var item in externalDir.listSync()) { + if (decodeAudioName(item.path) == + decodeAudioName(url, episodeId: episodeId)) { + return item.path.toString(); + } + } + return ""; + } + + String decodeAudioName(String name, + {String? episodeId, bool isAudio = true}) { + String decodedName = name.split('/').last; + String target = isAudio ? ".mp3" : ".mp4"; + int index = decodedName.indexOf(target); + String? id; + if (episodeId != null) { + id = removeUnwantedCharacters(episodeId); + } + if (index != -1) { + decodedName = decodedName.substring(0, index + target.length); + } + if (id == null) { + return decodedName; + } + return "$id$decodedName"; + } + + String removeUnwantedCharacters(String input) { + RegExp regex = RegExp( + r'[^a-zA-Z0-9\s]'); // Matches anything that is not a letter, digit, or whitespace + return input.replaceAll(regex, ''); + } + + //retrieve liked podcast from local + List getLikedPodcast({bool filterOnlyRssPodcasts = false}) { + final String key = _likedPodcastLocalKey; + if (box.read(key) != null) { + List json = box.read(key); + List items = []; + for (var item in json) { + if (!filterOnlyRssPodcasts) { + items.add(PodCastFeedItem.fromJson(item)); + } else if (item['rssUrl'] != null) { + items.add(PodCastFeedItem.fromJson( + item, + )); + } + } + return items; + } else { + return []; + } + } + + //check if the liked podcast is present in your local + bool isLikedPodcastPresentLocally(PodCastFeedItem item) { + final String key = _likedPodcastLocalKey; + if (box.read(key) != null) { + List json = box.read(key); + int index = json.indexWhere((element) => element['id'] == item.id); + return index != -1; + } else { + return false; + } + } + + //store the podcast locally if user likes it + void storeLikedPodcastLocally(PodCastFeedItem item) { + final String key = _likedPodcastLocalKey; + if (box.read(key) != null) { + List json = box.read(key); + int index = json.indexWhere((element) => element['id'] == item.id); + if (index == -1) { + json.add(item.toJson()); + box.write(key, json); + } else { + json.removeWhere((element) => element['id'] == item.id); + box.write(key, json); + } + } else { + box.write(key, [item.toJson()]); + } + notifyListeners(); + } + + //check if the liked podcast single episode is present locally + bool isLikedPodcastEpisodePresentLocally(PodcastEpisode item) { + final String key = _likedPodcastEpisodeLocalKey; + if (box.read(key) != null) { + List json = box.read(key); + int index = json.indexWhere((element) => element['id'] == item.id); + return index != -1; + } else { + return false; + } + } + + //sotre the single podcast episode locally if user likes it + void storeLikedPodcastEpisodeLocally(PodcastEpisode item, + {bool forceRemove = false}) { + final String key = _likedPodcastEpisodeLocalKey; + if (box.read(key) != null) { + List json = box.read(key); + int index = json.indexWhere((element) => element['id'] == item.id); + if (index == -1 && !forceRemove) { + json.add(item.toJson()); + box.write(key, json); + } else { + json.removeWhere((element) => element['id'] == item.id); + box.write(key, json); + } + } else { + box.write(key, [item.toJson()]); + } + } + + //after downloaing a podcast episode store it locally + Future storeOfflinePodcastLocally(PodcastEpisode episode) async { + log('saving'); + PodcastEpisode localEpisode = episode; + try { + localEpisode = episode.copyWith( + image: await _saveImage(episode.image!, episode.id!)); + } catch (e) {} + final String key = _offlinePodcastLocalKey; + if (box.read(key) != null) { + List json = box.read(key); + json.add(localEpisode.toJson()); + box.write(key, json); + } else { + box.write(key, [localEpisode.toJson()]); + } + } + + //retrieve the single podcast episodes for liked or offline + List likedOrOfflinepodcastEpisodes( + {required bool isOffline}) { + final box = GetStorage(); + final String key = + isOffline ? _offlinePodcastLocalKey : _likedPodcastEpisodeLocalKey; + if (box.read(key) != null) { + List json = box.read(key); + List items = + json.map((e) => PodcastEpisode.fromJson(e)).toList(); + if (isOffline) { + items = items.map((e) { + String url = e.enclosureUrl ?? ""; + url = Uri.parse(getOfflineUrl(e.enclosureUrl ?? "", e.id!)).path; + e.enclosureUrl = '$url'; + return e; + }).toList(); + } + return items; + } else { + return []; + } + } + + void deleteOfflinePodcastEpisode(PodcastEpisode episode) { + if (externalDir != null) { + String decodedId = removeUnwantedCharacters(episode.id!); + _deleteImage(externalDir.path + '/images/$decodedId.jpg'); + for (int i = 0; i < externalDir.listSync().length; i++) { + var item = externalDir.listSync()[i]; + if (decodeAudioName(item.path, episodeId: episode.id) == + decodeAudioName(episode.enclosureUrl ?? "", + episodeId: episode.id)) { + externalDir.listSync()[i].delete(); + final String key = _offlinePodcastLocalKey; + if (box.read(key) != null) { + List json = box.read(key); + json.removeWhere((element) => element['id'] == episode.id); + box.write(key, json); + } + } + } + } + } + + _saveImage(String imageUrl, String identifier) async { + String id = removeUnwantedCharacters(identifier); + var response = await get(Uri.parse(imageUrl)); + var firstPath = externalDir.path + "/images"; + var filePathAndName = externalDir.path + '/images/$id.jpg'; + await Directory(firstPath).create(recursive: true); + File file2 = new File(filePathAndName); + file2.writeAsBytesSync(response.bodyBytes); + return filePathAndName; + } + + void _deleteImage(String filePath) async { + try { + if (await File(filePath).exists()) { + await File(filePath).delete(); + print('File removed successfully: $filePath'); + } + } catch (e) {} + } + + int? readSavedDurationOfEpisode(String id, String url) { + return durationStorage.read('${id}_$url'); + } + + writeDurationOfEpisode(String id, String url, int value) { + durationStorage.write('${id}_$url', value); + } +} diff --git a/lib/src/screens/podcast/controller/podcast_episodes_controller.dart b/lib/src/screens/podcast/controller/podcast_episodes_controller.dart new file mode 100644 index 00000000..fd1a525f --- /dev/null +++ b/lib/src/screens/podcast/controller/podcast_episodes_controller.dart @@ -0,0 +1,47 @@ +import 'package:acela/src/models/podcast/podcast_episodes.dart'; +import 'package:acela/src/utils/enum.dart'; +import 'package:acela/src/utils/podcast/podcast_communicator.dart'; +import 'package:flutter/material.dart'; + +class PodcastEpisodesController extends ChangeNotifier { + List items = []; + ViewState viewState = ViewState.loading; + final bool isRss; + final String id; + + PodcastEpisodesController({required this.isRss, required this.id}) { + _init(); + } + + void _init() async { + try { + PodcastEpisodesByFeedResponse response = await fetchEpisodes(); + if (response.items != null && response.items!.isNotEmpty) { + items = response.items!; + viewState = ViewState.data; + } else { + viewState = ViewState.empty; + } + notifyListeners(); + } catch (e) { + viewState = ViewState.error; + notifyListeners(); + } + } + + Future fetchEpisodes() async { + if (isRss) { + return await PodCastCommunicator().getPodcastEpisodesByRss(id); + } else { + return await PodCastCommunicator().getPodcastEpisodesByFeedId(id); + } + } + + + + void refresh() { + viewState = ViewState.loading; + notifyListeners(); + _init(); + } +} diff --git a/lib/src/screens/podcast/controller/podcast_player_controller.dart b/lib/src/screens/podcast/controller/podcast_player_controller.dart new file mode 100644 index 00000000..5ef33b94 --- /dev/null +++ b/lib/src/screens/podcast/controller/podcast_player_controller.dart @@ -0,0 +1,71 @@ +import 'package:acela/src/models/podcast/podcast_episodes.dart'; +import 'package:acela/src/screens/podcast/widgets/audio_player/audio_player_core_controls.dart'; +import 'package:acela/src/screens/podcast/widgets/audio_player/new_pod_cast_epidose_player.dart'; +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:miniplayer/miniplayer.dart'; + +class PodcastPlayerController extends ChangeNotifier { + final MiniplayerController miniplayerController = MiniplayerController(); + List episodes = []; + int index = 0; + + void openPodcastPlayer(int index, {bool playOnMiniPlayer = true}) { + this.index = index; + notifyListeners(); + if (playOnMiniPlayer) { + miniplayerController.animateToHeight(state: PanelState.MAX); + } + } + + void setData(List episodes, int index, + {bool playOnMiniPlayer = true}) { + this.episodes = episodes; + openPodcastPlayer(index, playOnMiniPlayer: playOnMiniPlayer); + } + + Future _addEpisodesToQueue(List items) async { + GetAudioPlayer audioPlayer = GetAudioPlayer(); + await audioPlayer.audioHandler.updateQueue([]); + print(audioPlayer.audioHandler.queue.value.length); + await audioPlayer.audioHandler.addQueueItems(items + .map((e) => MediaItem( + id: e.enclosureUrl ?? "", + title: e.title ?? "", + artUri: Uri.parse(e.image ?? ""), + duration: Duration(seconds: e.duration ?? 0))) + .toList()); + } + + void _initiatePlay(BuildContext context, List episodes, + int index, bool playOnMiniPlayer) { + if (!GetAudioPlayer().audioHandler.isInitiated) { + GetAudioPlayer().audioHandler.isInitiated = true; + } + GetAudioPlayer().audioHandler.play(); + setData(episodes, index, playOnMiniPlayer: playOnMiniPlayer); + if (!playOnMiniPlayer) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => NewPodcastEpidosePlayer( + dragValue: 1, + currentPodcastIndex: index, + podcastEpisodes: episodes), + ), + ); + } + } + + void onTapEpisode(int index, BuildContext context, + List episodes, bool playOnMiniPlayer) async { + await _addEpisodesToQueue(episodes); + GetAudioPlayer().audioHandler.skipToQueueItem(index); + _initiatePlay(context, episodes, index, playOnMiniPlayer); + } + + void onDefaultPlay(BuildContext context,List episodes, bool playOnMiniPlayer) async { + await _addEpisodesToQueue(episodes); + _initiatePlay(context, episodes, 0, playOnMiniPlayer); + } +} diff --git a/lib/src/screens/podcast/view/add_rss_podcast.dart b/lib/src/screens/podcast/view/add_rss_podcast.dart new file mode 100644 index 00000000..d10a3780 --- /dev/null +++ b/lib/src/screens/podcast/view/add_rss_podcast.dart @@ -0,0 +1,142 @@ +import 'package:acela/src/models/podcast/trending_podcast_response.dart'; +import 'package:acela/src/screens/podcast/controller/podcast_controller.dart'; +import 'package:acela/src/utils/podcast/podcast_communicator.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class AddRssPodcast extends StatefulWidget { + const AddRssPodcast({Key? key}) : super(key: key); + + @override + State createState() => _AddRssPodcastState(); +} + +class _AddRssPodcastState extends State { + final TextEditingController textEditingController = TextEditingController(); + + bool isAdding = false; + + @override + void dispose() { + textEditingController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: ListTile( + title: const Text('Add a Podcast'), + subtitle: const Text('By entering RSS URL of a Podcast'), + ), + ), + body: Padding( + padding: const EdgeInsets.symmetric(vertical: 25, horizontal: 15.0), + child: isAdding ? _loadingBody() : _body(), + ), + ); + } + + Column _loadingBody() { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Center( + child: CircularProgressIndicator(), + ), + Padding( + padding: const EdgeInsets.only(top: 15.0), + child: Text("Adding please wait..."), + ) + ], + ); + } + + Column _body() { + return Column(mainAxisAlignment: MainAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.only(top: 60.0, bottom: 30), + child: Icon( + Icons.podcasts, + size: 60, + ), + ), + Text( + "Add podcast by RSS feed", + style: TextStyle(fontSize: 18), + textAlign: TextAlign.center, + ), + const SizedBox( + height: 8, + ), + Text( + "To add a podcast to your favourites by RSS feed, paste the full RSS URL in the field", + style: TextStyle(fontSize: 14, color: Colors.white54), + textAlign: TextAlign.center, + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 25), + child: TextField( + controller: textEditingController, + decoration: InputDecoration( + fillColor: Colors.grey.shade800, + filled: true, + hintText: "Enter URL", + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none), + ), + ), + SizedBox( + width: 125, + child: TextButton( + style: TextButton.styleFrom(backgroundColor: Colors.blue), + onPressed: onAdd, + child: Text( + "Add RSS feed", + style: const TextStyle(color: Colors.white), + )), + ) + ]); + } + + void onAdd() async { + if (textEditingController.text.trim().isNotEmpty) { + try { + setState(() { + isAdding = true; + }); + final controller = context.read(); + PodCastFeedItem item = await PodCastCommunicator() + .getPodcastFeedByRss(textEditingController.text.trim()); + if (!controller.isLikedPodcastPresentLocally(item)) { + controller.storeLikedPodcastLocally(item); + } + Navigator.pop(context); + showSnackBar("Podcast ${item.title} is Added"); + } catch (e) { + setState(() { + isAdding = false; + }); + showSnackBar(e.toString()); + print(e); + } + } + } + + void showSnackBar(String message) { + ScaffoldMessenger.of(context).removeCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Colors.black, + content: Text( + message, + style: TextStyle(color: Colors.white), + ), + duration: Duration(seconds: 3), + ), + ); + } +} diff --git a/lib/src/screens/podcast/view/liked_podcasts.dart b/lib/src/screens/podcast/view/liked_podcasts.dart new file mode 100644 index 00000000..ebecf29a --- /dev/null +++ b/lib/src/screens/podcast/view/liked_podcasts.dart @@ -0,0 +1,79 @@ +import 'package:acela/src/models/podcast/trending_podcast_response.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/podcast/controller/podcast_controller.dart'; +import 'package:acela/src/screens/podcast/widgets/podcast_feed_item.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class LikedPodcasts extends StatefulWidget { + const LikedPodcasts( + {Key? key, + required this.appData, + this.showAppBar = true, + this.playOnMiniPlayer = true, + this.filterOnlyRssPodcasts = false}) + : super(key: key); + + final HiveUserData appData; + final bool showAppBar; + final bool playOnMiniPlayer; + final bool filterOnlyRssPodcasts; + + @override + State createState() => _LikedPodcastsState(); +} + +class _LikedPodcastsState extends State { + @override + Widget build(BuildContext context) { + final List items = context + .read() + .getLikedPodcast(filterOnlyRssPodcasts: widget.filterOnlyRssPodcasts); + return Scaffold( + appBar: widget.showAppBar + ? AppBar( + title: Text("Liked Podcasts"), + ) + : null, + body: items.isEmpty + ? Center( + child: Text(widget.filterOnlyRssPodcasts + ? "" + : "Liked Podcasts is Empty")) + : ListView.separated( + itemBuilder: (c, i) { + return Dismissible( + key: Key(items[i].id!), + background: Center(child: Text("Delete")), + onDismissed: (direction) { + context + .read() + .storeLikedPodcastLocally(items[i]); + showSnackBar("Podcast ${items[i].title} is removed"); + }, + child: PodcastFeedItemWidget( + playOnMiniPlayer: widget.playOnMiniPlayer, + showLikeButton: false, + appData: widget.appData, + item: items[i], + ), + ); + }, + separatorBuilder: (c, i) => const Divider(height: 0), + itemCount: items.length, + ), + ); + } + + void showSnackBar(String message) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + backgroundColor: Colors.black, + content: Text( + message, + style: TextStyle(color: Colors.white), + ), + duration: Duration(seconds: 3), + )); + } +} diff --git a/lib/src/screens/podcast/view/local_podcast_episode.dart b/lib/src/screens/podcast/view/local_podcast_episode.dart new file mode 100644 index 00000000..8a55e005 --- /dev/null +++ b/lib/src/screens/podcast/view/local_podcast_episode.dart @@ -0,0 +1,160 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:acela/src/models/podcast/podcast_episodes.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/podcast/controller/podcast_controller.dart'; +import 'package:acela/src/screens/podcast/controller/podcast_player_controller.dart'; +import 'package:acela/src/widgets/confirmation_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class LocalPodcastEpisode extends StatelessWidget { + const LocalPodcastEpisode( + {Key? key, required this.appData, this.playOnMiniPlayer = true}) + : super(key: key); + final HiveUserData appData; + final bool playOnMiniPlayer; + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: Text('Podcast Episodes'), + bottom: TabBar( + tabs: [ + Tab(text: 'Offline Episode'), + Tab(text: 'Bookmarked Episode'), + ], + ), + ), + body: TabBarView( + children: [ + LocalEpisodeListView( + isOffline: true, + playOnMiniPlayer: playOnMiniPlayer, + ), + LocalEpisodeListView( + isOffline: false, + playOnMiniPlayer: playOnMiniPlayer, + ), + ], + ), + ), + ); + } +} + +class LocalEpisodeListView extends StatelessWidget { + const LocalEpisodeListView( + {Key? key, required this.isOffline, required this.playOnMiniPlayer}) + : super(key: key); + + final bool isOffline; + final bool playOnMiniPlayer; + + @override + Widget build(BuildContext context) { + final controller = context.read(); + List items = + controller.likedOrOfflinepodcastEpisodes(isOffline: isOffline); + if (items.isEmpty) + return Center( + child: Text( + "${isOffline ? "Offline" : "Liked"} Podcast Episode is Empty")); + else + return ListView.separated( + itemBuilder: (c, index) { + PodcastEpisode item = items[index]; + log(item.image!); + return Dismissible( + key: Key(item.id.toString()), + background: Center(child: Text("Delete")), + confirmDismiss: (direction) async { + if (isOffline) { + bool delete = false; + await showDialog( + context: context, + builder: (context) => ConfirmationDialog( + title: "Delete", + content: + "Are you sure you want to delete this episode ", + onConfirm: () { + delete = true; + }), + ).whenComplete(() => null); + return Future.value(delete); + } else { + return Future.value(true); + } + }, + onDismissed: (direction) { + if (isOffline) { + controller.deleteOfflinePodcastEpisode(item); + } else { + controller.storeLikedPodcastEpisodeLocally(item, + forceRemove: true); + } + }, + child: podcastEpisodeListItem(items, context, controller, index)); + }, + separatorBuilder: (c, i) => const Divider(height: 0), + itemCount: items.length, + ); + } + + ListTile podcastEpisodeListItem(List items, + BuildContext context, PodcastController controller, int index) { + PodcastEpisode item = items[index]; + return ListTile( + onTap: () { + final playerController = context.read(); + playerController.onTapEpisode( + index, context, items, playOnMiniPlayer); + }, + leading: Container( + height: 30, + width: 30, + decoration: BoxDecoration( + color: Colors.grey, + image: (isOffline && !item.image!.startsWith('http')) + ? DecorationImage( + image: FileImage(File(item.image!)), fit: BoxFit.cover) + : DecorationImage( + image: NetworkImage( + item.image ?? "", + ), + )), + ), + title: Text( + item.title ?? '', + maxLines: 2, + style: Theme.of(context).textTheme.titleSmall, + ), + trailing: fileSizeWidget(item.enclosureUrl)); + } + + Widget? fileSizeWidget(String? enclosureUrl) { + if (enclosureUrl == null) return null; + try { + return isOffline + ? Text(formatBytes(File(enclosureUrl).lengthSync())) + : null; + } catch (e) { + return null; + } + } + + String formatBytes(int bytes) { + const suffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + int i = 0; + double size = bytes.toDouble(); + while (size > 1024 && i < suffixes.length - 1) { + size /= 1024; + i++; + } + return "${size.toStringAsFixed(2)} ${suffixes[i]}"; + } +} diff --git a/lib/src/screens/podcast/view/podcast_category_view.dart b/lib/src/screens/podcast/view/podcast_category_view.dart new file mode 100644 index 00000000..2eb8b3e8 --- /dev/null +++ b/lib/src/screens/podcast/view/podcast_category_view.dart @@ -0,0 +1,29 @@ +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/podcast/widgets/podcast_feeds_body.dart'; +import 'package:acela/src/utils/podcast/podcast_communicator.dart'; +import 'package:flutter/material.dart'; + +class PodcastCategoryView extends StatelessWidget { + const PodcastCategoryView( + {Key? key, + required this.categoryId, + required this.categoryName, + required this.appData}) + : super(key: key); + + final int categoryId; + final String categoryName; + final HiveUserData appData; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: ListTile( + minLeadingWidth: 0, + title: Text(categoryName),subtitle: Text("Podcasts under $categoryName category"),)), + body: PodcastFeedsBody( + future: PodCastCommunicator().getFeedsByCategory(categoryId), + appData: appData), + ); + } +} diff --git a/lib/src/screens/podcast/view/podcast_episodes/podcast_episodes_appbar.dart b/lib/src/screens/podcast/view/podcast_episodes/podcast_episodes_appbar.dart new file mode 100644 index 00000000..18eae9ed --- /dev/null +++ b/lib/src/screens/podcast/view/podcast_episodes/podcast_episodes_appbar.dart @@ -0,0 +1,75 @@ +import 'package:acela/src/widgets/cached_image.dart'; +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/material.dart'; + +class PodcastEpisodesAppbar extends StatefulWidget + implements PreferredSizeWidget { + final ScrollController scrollController; + final String? image; + final String? title; + + PodcastEpisodesAppbar({ + required this.scrollController, + required this.image, + required this.title, + }); + + @override + Size get preferredSize => Size.fromHeight(kToolbarHeight); + + @override + State createState() => _PodcastEpisodesAppbarState(); +} + +class _PodcastEpisodesAppbarState extends State { + ValueNotifier offset = ValueNotifier(0); + + @override + void initState() { + widget.scrollController.addListener(scrollListener); + super.initState(); + } + + @override + void dispose() { + widget.scrollController.removeListener(scrollListener); + super.dispose(); + } + + void scrollListener() { + offset.value = widget.scrollController.offset; + offset.value = widget.scrollController.offset; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AppBar( + backgroundColor: theme.primaryColorDark, + leadingWidth: 30, + title: ValueListenableBuilder( + valueListenable: offset, + builder: (context, value, child) { + return value > 130 + ? ListTile( + leading: CachedImage( + imageUrl: widget.image ?? '', + imageHeight: 35, + imageWidth: 35, + ), + title: AutoSizeText( + widget.title ?? 'No Title', + maxLines: 1, + maxFontSize: 14, + minFontSize: 12, + overflow: TextOverflow.ellipsis, + ), + ) + : Text( + "Podcast Episodes", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ); + }), + ); + } +} diff --git a/lib/src/screens/podcast/view/podcast_episodes/podcast_episodes_view.dart b/lib/src/screens/podcast/view/podcast_episodes/podcast_episodes_view.dart new file mode 100644 index 00000000..56634f9d --- /dev/null +++ b/lib/src/screens/podcast/view/podcast_episodes/podcast_episodes_view.dart @@ -0,0 +1,228 @@ +import 'package:acela/src/models/podcast/podcast_episodes.dart'; +import 'package:acela/src/models/podcast/trending_podcast_response.dart'; +import 'package:acela/src/screens/podcast/controller/podcast_episodes_controller.dart'; +import 'package:acela/src/screens/podcast/controller/podcast_player_controller.dart'; +import 'package:acela/src/screens/podcast/view/podcast_episodes/podcast_episodes_appbar.dart'; +import 'package:acela/src/screens/podcast/widgets/audio_player/audio_player_core_controls.dart'; +import 'package:acela/src/utils/enum.dart'; +import 'package:acela/src/widgets/cached_image.dart'; +import 'package:acela/src/widgets/loading_screen.dart'; +import 'package:acela/src/widgets/retry.dart'; +import 'package:audio_service/audio_service.dart'; +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_spinkit/flutter_spinkit.dart'; +import 'package:provider/provider.dart'; + +class PodcastEpisodesView extends StatefulWidget { + const PodcastEpisodesView( + {Key? key, required this.feedItem, required this.playOnMiniPlayer}) + : super(key: key); + + final PodCastFeedItem feedItem; + final bool playOnMiniPlayer; + + @override + State createState() => _PodcastEpisodesViewState(); +} + +class _PodcastEpisodesViewState extends State { + final ScrollController scrollController = ScrollController(); + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return ChangeNotifierProvider( + create: (context) => PodcastEpisodesController( + isRss: widget.feedItem.rssUrl != null, + id: widget.feedItem.rssUrl != null + ? widget.feedItem.rssUrl! + : "${widget.feedItem.id ?? 227573}"), + builder: (context, child) { + final controller = context.read(); + return Scaffold( + appBar: PodcastEpisodesAppbar( + scrollController: scrollController, + title: widget.feedItem.title, + image: widget.feedItem.image, + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 15, + ), + child: Selector( + selector: (_, provider) => provider.viewState, + builder: (context, state, child) { + if (state == ViewState.data) { + return _data(controller.items, theme, context); + } else if (state == ViewState.empty) { + return RetryScreen( + error: "No episodes found", + onRetry: () => controller.refresh()); + } else if (state == ViewState.error) { + return RetryScreen( + error: "Something went wrong", + onRetry: () => controller.refresh()); + } else { + return LoadingScreen( + title: 'Loading', subtitle: 'Please wait..'); + } + }, + )), + ), + ); + }, + ); + } + + ListView _data( + List episodes, ThemeData theme, BuildContext context) { + final PodcastPlayerController playerController = + context.read(); + return ListView.builder( + controller: scrollController, + itemCount: episodes.length, + itemBuilder: (context, index) { + PodcastEpisode item = episodes[index]; + return Padding( + padding: + EdgeInsets.only(bottom: index == episodes.length - 1 ? 65.0 : 0), + child: Column( + children: [ + if (index == 0) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 15, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + CachedImage( + imageHeight: 125, + imageWidth: 125, + borderRadius: 18, + imageUrl: widget.feedItem.networkImage), + SizedBox( + width: 15, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AutoSizeText( + widget.feedItem.title ?? "", + maxLines: 5, + minFontSize: 13, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 20, fontWeight: FontWeight.bold), + ), + AutoSizeText( + widget.feedItem.author ?? "", + maxLines: 2, + maxFontSize: 13, + minFontSize: 11, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 13), + ), + ], + )) + ], + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 15), + child: FilledButton.icon( + onPressed: () => playerController.onDefaultPlay( + context,episodes, widget.playOnMiniPlayer), + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.all(Radius.circular(8))), + backgroundColor: theme.primaryColorLight), + label: Text( + "Play", + style: TextStyle( + fontWeight: FontWeight.bold, fontSize: 16), + ), + icon: Icon(Icons.play_arrow), + ), + ) + ], + ), + ), + ListTile( + onTap: () => playerController.onTapEpisode( + index, context, episodes, widget.playOnMiniPlayer), + trailing: StreamBuilder( + stream: GetAudioPlayer().audioHandler.mediaItem, + builder: (context, snapshot) { + MediaItem? mediaItem = snapshot.data; + return mediaItem != null && + mediaItem.id == item.enclosureUrl + ? SizedBox( + height: 30, + width: 30, + child: SpinKitWave( + itemCount: 4, + type: SpinKitWaveType.center, + size: 15, + color: theme.primaryColorLight, + ), + ) + : Icon(Icons.play_circle_outline_outlined); + }), + leading: CachedImage( + imageUrl: item.networkImage, + imageHeight: 48, + imageWidth: 48, + loadingIndicatorSize: 25, + ), + title: Text( + item.title!, + maxLines: 2, + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + ), + subtitle: item.duration != null || item.episode != null + ? Row( + children: [ + if (item.episode != null) + Text( + "#${item.episode} ${item.duration != null ? " • " : ""} ", + style: TextStyle(fontSize: 11), + ), + if (item.duration != null) + Text( + formatDuration(item.duration!), + style: TextStyle(fontSize: 11), + ), + ], + ) + : null, + ), + ], + ), + ); + }, + ); + } + + String formatDuration(int seconds) { + Duration duration = Duration(seconds: seconds); + + if (duration.inHours < 1) { + return '${(duration.inMinutes % 60).toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}'; + } else { + return '${duration.inHours}:${(duration.inMinutes % 60).toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}'; + } + } +} diff --git a/lib/src/screens/podcast/view/podcast_search.dart b/lib/src/screens/podcast/view/podcast_search.dart new file mode 100644 index 00000000..613aac92 --- /dev/null +++ b/lib/src/screens/podcast/view/podcast_search.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:acela/src/models/podcast/trending_podcast_response.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/podcast/widgets/podcast_feed_item.dart'; +import 'package:acela/src/utils/podcast/podcast_communicator.dart'; +import 'package:flutter/material.dart'; + +class PodCastSearch extends StatefulWidget { + const PodCastSearch({Key? key, + required this.appData, + }); + + final HiveUserData appData; + + @override + State createState() => _PodCastSearchState(); +} + +class _PodCastSearchState extends State { + var text = ''; + late TextEditingController _controller; + Timer? _timer; + var loading = false; + List items = []; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void search(String term) async { + setState(() { + loading = true; + items = []; + }); + var result = await PodCastCommunicator().getSearchResults(term); + setState(() { + loading = false; + items = result.feeds ?? []; + }); + } + + Widget getList() { + return ListView.separated( + itemBuilder: (c, i) { + var title = items[i].title ?? 'No title'; + // title = "$title by ${items[i].author ?? ''}"; + var desc = ''; // items[i].description ?? ''; + // desc = "$desc${(items[i].categories?.values ?? []).join(", ")}"; + return PodcastFeedItemWidget( + appData: widget.appData, + item: items[i], + ); + + }, + separatorBuilder: (c, i) => const Divider(height: 0), + itemCount: items.length, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: TextField( + controller: _controller, + onChanged: (value) { + var timer = Timer(const Duration(seconds: 2), () { + if (value.trim().length > 3) { + var searchTerm = value.trim(); + search(searchTerm); + } + }); + setState(() { + _timer?.cancel(); + _timer = timer; + }); + }, + ), + ), + body: loading ? Center(child: CircularProgressIndicator()) : getList(), + ); + } +} diff --git a/lib/src/screens/podcast/view/podcast_trending.dart b/lib/src/screens/podcast/view/podcast_trending.dart new file mode 100644 index 00000000..ff480c6c --- /dev/null +++ b/lib/src/screens/podcast/view/podcast_trending.dart @@ -0,0 +1,356 @@ +import 'dart:developer'; + +import 'package:acela/src/models/podcast/podcast_categories_response.dart'; +import 'package:acela/src/models/podcast/trending_podcast_response.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/podcast/controller/podcast_controller.dart'; +import 'package:acela/src/screens/podcast/controller/podcast_player_controller.dart'; +import 'package:acela/src/screens/podcast/view/add_rss_podcast.dart'; +import 'package:acela/src/screens/podcast/view/liked_podcasts.dart'; +import 'package:acela/src/screens/podcast/view/local_podcast_episode.dart'; +import 'package:acela/src/screens/podcast/view/podcast_search.dart'; +import 'package:acela/src/screens/podcast/widgets/audio_player/new_pod_cast_epidose_player.dart'; +import 'package:acela/src/screens/podcast/widgets/podcast_categories_body.dart'; +import 'package:acela/src/screens/podcast/widgets/podcast_feed_item.dart'; +import 'package:acela/src/screens/podcast/widgets/podcast_feeds_body.dart'; +import 'package:acela/src/screens/upload/podcast/podcast_upload_screen.dart'; +import 'package:acela/src/utils/podcast/podcast_communicator.dart'; +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:miniplayer/miniplayer.dart'; +import 'package:provider/provider.dart'; + +import '../../../widgets/fab_custom.dart'; +import '../../../widgets/fab_overlay.dart'; + +final miniPlayerNavigatorkey = GlobalKey(); + +class PodCastTrendingScreen extends StatefulWidget { + const PodCastTrendingScreen({ + Key? key, + required this.appData, + }); + + final HiveUserData appData; + + @override + State createState() => _PodCastTrendingScreenState(); +} + +class _PodCastTrendingScreenState extends State + with SingleTickerProviderStateMixin { + bool isMenuOpen = false; + late Future trendingFeeds; + late Future recentFeeds; + late Future liveFeeds; + late Future> categories; + final PodCastCommunicator podCastCommunicator = PodCastCommunicator(); + late TabController _tabController; + var currentIndex = 0; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 5, vsync: this); + _tabController.addListener(() { + setState(() { + currentIndex = _tabController.index; + }); + }); + trendingFeeds = podCastCommunicator.getTrendingPodcasts(); + recentFeeds = podCastCommunicator.getRecentPodcasts(); + categories = podCastCommunicator.getCategories(); + liveFeeds = podCastCommunicator.getLivePodcasts(); + } + + @override + void dispose() { + super.dispose(); + _tabController.dispose(); + } + + Widget getList(List items) { + return ListView.separated( + itemBuilder: (c, i) { + return Padding( + padding: EdgeInsets.only(bottom: i == items.length - 1 ? 65.0 : 0), + child: PodcastFeedItemWidget( + appData: widget.appData, + item: items[i], + ), + ); + }, + separatorBuilder: (c, i) => const Divider(height: 0), + itemCount: items.length, + ); + } + + @override + Widget build(BuildContext context) { + var text = currentIndex == 0 + ? 'Trending Podcasts' + : currentIndex == 1 + ? "RSS Podcasts" + : currentIndex == 2 + ? 'Explore Podcasts by Categories' + : currentIndex == 3 + ? 'Recent Podcasts & Episodes' + : 'Live Podcasts'; + return PopScope( + canPop: true, + child: MiniplayerWillPopScope( + onWillPop: () async { + final NavigatorState navigatorState = + miniPlayerNavigatorkey.currentState!; + if (!navigatorState.canPop()) { + Navigator.of(context, rootNavigator: true).pop(); + return true; + } + navigatorState.pop(); + return false; + }, + child: Stack( + children: [ + Navigator( + key: miniPlayerNavigatorkey, + onGenerateRoute: (settings) => MaterialPageRoute( + settings: settings, + builder: (context) => DefaultTabController( + length: 5, + child: Scaffold( + appBar: AppBar( + leading: BackButton( + onPressed: () { + log('popp'); + Navigator.of(context, rootNavigator: true).pop(); + }, + ), + leadingWidth: 30, + title: ListTile( + contentPadding: EdgeInsets.zero, + leading: Image.asset( + 'assets/pod-cast-logo-round.png', + width: 40, + height: 40, + ), + title: Text('Podcasts'), + subtitle: Text(text), + ), + bottom: TabBar( + controller: _tabController, + tabs: [ + Tab(icon: const Icon(Icons.trending_up)), + Tab(icon: const Icon(FontAwesomeIcons.rss)), + Tab(icon: const Icon(Icons.category)), + Tab(icon: const Icon(Icons.music_note)), + Tab(icon: const Icon(Icons.live_tv)), + ], + ), + actions: [ + IconButton( + onPressed: () { + var screen = PodCastSearch(appData: widget.appData); + var route = + MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + }, + icon: Icon(Icons.search), + ), + _postPodcastButton(widget.appData) + ], + ), + body: SafeArea( + child: Stack( + children: [ + TabBarView( + controller: _tabController, + children: [ + PodcastFeedsBody( + future: trendingFeeds, + appData: widget.appData), + _rssPodcastTab(context), + PodcastCategoriesBody( + appData: widget.appData, + future: categories, + ), + PodcastFeedsBody( + future: recentFeeds, appData: widget.appData), + PodcastFeedsBody( + future: liveFeeds, appData: widget.appData), + ], + ), + Consumer( + builder: (context, value, child) { + return Padding( + padding: EdgeInsets.only( + bottom: value.episodes.isEmpty ? 0 : 65.0), + child: _fabContainer(), + ); + }, + ) + ], + ), + ), + ), + ), + ), + ), + SafeArea( + child: Consumer( + builder: (context, value, child) { + return Miniplayer( + controller: value.miniplayerController, + minHeight: value.episodes.isEmpty ? 0 : 65, + maxHeight: MediaQuery.of(context).size.height, + builder: (height, percentage) { + if (value.episodes.isEmpty) { + return SizedBox.shrink(); + } else { + return NewPodcastEpidosePlayer( + key: ValueKey(value.episodes.first.id), + dragValue: percentage, + podcastEpisodes: value.episodes, + currentPodcastIndex: value.index); + } + }, + ); + }, + ), + ) + ], + ), + ), + ); + } + + Widget _postPodcastButton(HiveUserData userData) { + return Visibility( + visible: userData.username != null, + child: IconButton( + color: Theme.of(context).primaryColorLight, + onPressed: () { + var route = MaterialPageRoute( + builder: (c) => PodcastUploadScreen(data: widget.appData)); + Navigator.of(context).push(route); + }, + icon: Icon(Icons.add), + ), + ); + } + + Column _rssPodcastTab(BuildContext context) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 10.0, left: 15, right: 15), + child: OutlinedButton( + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4)))), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => AddRssPodcast(), + ), + ); + }, + child: Text( + "Follow a podcast by URL", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + ), + )), + ), + Expanded( + child: Consumer( + builder: (context, myType, child) { + return LikedPodcasts( + appData: widget.appData, + showAppBar: false, + filterOnlyRssPodcasts: true, + ); + }, + ), + ), + ], + ); + } + + Widget _fabContainer() { + if (!isMenuOpen) { + return FabCustom( + icon: Icons.bolt, + onTap: () { + setState(() { + isMenuOpen = true; + }); + }, + ); + } + return FabOverlay( + items: _fabItems(), + onBackgroundTap: () { + setState(() { + isMenuOpen = false; + }); + }, + ); + } + + List _fabItems() { + var search = FabOverItemData( + displayName: 'Search', + icon: Icons.search, + onTap: () { + setState(() { + isMenuOpen = false; + var screen = PodCastSearch(appData: widget.appData); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(miniPlayerNavigatorkey.currentState!.context) + .push(route); + }); + }, + ); + var favourites = FabOverItemData( + displayName: 'Bookmarks', + icon: Icons.bookmarks, + onTap: () { + setState(() { + isMenuOpen = false; + var screen = LikedPodcasts(appData: widget.appData); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(miniPlayerNavigatorkey.currentState!.context) + .push(route); + }); + }, + ); + var downloaded = FabOverItemData( + displayName: 'Downloaded Podcast Episode', + icon: Icons.download_rounded, + onTap: () { + setState(() { + isMenuOpen = false; + var screen = LocalPodcastEpisode( + appData: widget.appData, + ); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(miniPlayerNavigatorkey.currentState!.context) + .push(route); + }); + }, + ); + var close = FabOverItemData( + displayName: 'Close', + icon: Icons.close, + onTap: () { + setState(() { + isMenuOpen = false; + }); + }, + ); + var fabItems = [downloaded, favourites, search, close]; + + return fabItems; + } +} diff --git a/lib/src/screens/podcast/widgets/audio_player/action_tools.dart b/lib/src/screens/podcast/widgets/audio_player/action_tools.dart new file mode 100644 index 00000000..9c2e7f6a --- /dev/null +++ b/lib/src/screens/podcast/widgets/audio_player/action_tools.dart @@ -0,0 +1,480 @@ +// ignore_for_file: public_member_api_docs + +import 'dart:math'; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:rxdart/rxdart.dart'; + +class PositionData { + final Duration position; + final Duration bufferedPosition; + final Duration duration; + + PositionData(this.position, this.bufferedPosition, this.duration); +} + +class SeekBar extends StatefulWidget { + final Duration duration; + final Duration position; + final Duration bufferedPosition; + final ValueChanged? onChanged; + final ValueChanged? onChangeEnd; + + const SeekBar({ + Key? key, + required this.duration, + required this.position, + this.bufferedPosition = Duration.zero, + this.onChanged, + this.onChangeEnd, + }) : super(key: key); + + @override + _SeekBarState createState() => _SeekBarState(); +} + +class _SeekBarState extends State { + double? _dragValue; + bool _dragging = false; + late SliderThemeData _sliderThemeData; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _sliderThemeData = SliderTheme.of(context).copyWith( + trackHeight: 2.0, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final value = min( + _dragValue ?? widget.position.inMilliseconds.toDouble(), + widget.duration.inMilliseconds.toDouble(), + ); + if (_dragValue != null && !_dragging) { + _dragValue = null; + } + return Stack( + children: [ + SliderTheme( + data: _sliderThemeData.copyWith( + thumbShape: HiddenThumbComponentShape(), + // activeTrackColor: Colors.blue.shade100, + inactiveTrackColor: theme.primaryColorLight.withOpacity(0.5), + ), + child: ExcludeSemantics( + child: Slider( + min: 0.0, + max: widget.duration.inMilliseconds.toDouble(), + value: min(widget.bufferedPosition.inMilliseconds.toDouble(), + widget.duration.inMilliseconds.toDouble()), + onChanged: (value) {}, + ), + ), + ), + SliderTheme( + data: _sliderThemeData.copyWith( + inactiveTrackColor: Colors.transparent, + thumbShape: RoundSliderThumbShape(enabledThumbRadius: 8.0), + ), + child: Slider( + min: 0.0, + max: widget.duration.inMilliseconds.toDouble(), + value: value, + onChanged: (value) { + if (!_dragging) { + _dragging = true; + } + setState(() { + _dragValue = value; + }); + if (widget.onChanged != null) { + widget.onChanged!(Duration(milliseconds: value.round())); + } + }, + onChangeEnd: (value) { + if (widget.onChangeEnd != null) { + widget.onChangeEnd!(Duration(milliseconds: value.round())); + } + _dragging = false; + }, + ), + ), + // Positioned( + // right: 16.0, + // bottom: 0.0, + // child: Text( + // RegExp(r'((^0*[1-9]\d*:)?\d{2}:\d{2})\.\d+$') + // .firstMatch("$_remaining") + // ?.group(1) ?? + // '$_remaining', + // style: Theme.of(context).textTheme.bodySmall), + // ), + ], + ); + } + + Duration get _remaining => widget.duration - widget.position; +} + +class HiddenThumbComponentShape extends SliderComponentShape { + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) => Size.zero; + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation activationAnimation, + required Animation enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }) {} +} + +class LoggingAudioHandler extends CompositeAudioHandler { + LoggingAudioHandler(AudioHandler inner) : super(inner) { + playbackState.listen((state) { + _log('playbackState changed: $state'); + }); + queue.listen((queue) { + _log('queue changed: $queue'); + }); + queueTitle.listen((queueTitle) { + _log('queueTitle changed: $queueTitle'); + }); + mediaItem.listen((mediaItem) { + _log('mediaItem changed: $mediaItem'); + }); + ratingStyle.listen((ratingStyle) { + _log('ratingStyle changed: $ratingStyle'); + }); + androidPlaybackInfo.listen((androidPlaybackInfo) { + _log('androidPlaybackInfo changed: $androidPlaybackInfo'); + }); + customEvent.listen((dynamic customEventStream) { + _log('customEvent changed: $customEventStream'); + }); + customState.listen((dynamic customState) { + _log('customState changed: $customState'); + }); + } + + // TODO: Use logger. Use different log levels. + // ignore: avoid_print + void _log(String s) => print('----- LOG: $s'); + + @override + Future prepare() { + _log('prepare()'); + return super.prepare(); + } + + @override + Future prepareFromMediaId(String mediaId, + [Map? extras]) { + _log('prepareFromMediaId($mediaId, $extras)'); + return super.prepareFromMediaId(mediaId, extras); + } + + @override + Future prepareFromSearch(String query, [Map? extras]) { + _log('prepareFromSearch($query, $extras)'); + return super.prepareFromSearch(query, extras); + } + + @override + Future prepareFromUri(Uri uri, [Map? extras]) { + _log('prepareFromSearch($uri, $extras)'); + return super.prepareFromUri(uri, extras); + } + + @override + Future play() { + _log('play()'); + return super.play(); + } + + @override + Future playFromMediaId(String mediaId, [Map? extras]) { + _log('playFromMediaId($mediaId, $extras)'); + return super.playFromMediaId(mediaId, extras); + } + + @override + Future playFromSearch(String query, [Map? extras]) { + _log('playFromSearch($query, $extras)'); + return super.playFromSearch(query, extras); + } + + @override + Future playFromUri(Uri uri, [Map? extras]) { + _log('playFromUri($uri, $extras)'); + return super.playFromUri(uri, extras); + } + + @override + Future playMediaItem(MediaItem mediaItem) { + _log('playMediaItem($mediaItem)'); + return super.playMediaItem(mediaItem); + } + + @override + Future pause() { + _log('pause()'); + return super.pause(); + } + + @override + Future click([MediaButton button = MediaButton.media]) { + _log('click($button)'); + return super.click(button); + } + + @override + Future stop() { + _log('stop()'); + return super.stop(); + } + + @override + Future addQueueItem(MediaItem mediaItem) { + _log('addQueueItem($mediaItem)'); + return super.addQueueItem(mediaItem); + } + + @override + Future addQueueItems(List mediaItems) { + _log('addQueueItems($mediaItems)'); + return super.addQueueItems(mediaItems); + } + + @override + Future insertQueueItem(int index, MediaItem mediaItem) { + _log('insertQueueItem($index, $mediaItem)'); + return super.insertQueueItem(index, mediaItem); + } + + @override + Future updateQueue(List queue) { + _log('updateQueue($queue)'); + return super.updateQueue(queue); + } + + @override + Future updateMediaItem(MediaItem mediaItem) { + _log('updateMediaItem($mediaItem)'); + return super.updateMediaItem(mediaItem); + } + + @override + Future removeQueueItem(MediaItem mediaItem) { + _log('removeQueueItem($mediaItem)'); + return super.removeQueueItem(mediaItem); + } + + @override + Future removeQueueItemAt(int index) { + _log('removeQueueItemAt($index)'); + return super.removeQueueItemAt(index); + } + + @override + Future skipToNext() { + _log('skipToNext()'); + return super.skipToNext(); + } + + @override + Future skipToPrevious() { + _log('skipToPrevious()'); + return super.skipToPrevious(); + } + + @override + Future fastForward() { + _log('fastForward()'); + return super.fastForward(); + } + + @override + Future rewind() { + _log('rewind()'); + return super.rewind(); + } + + @override + Future skipToQueueItem(int index) { + _log('skipToQueueItem($index)'); + return super.skipToQueueItem(index); + } + + @override + Future seek(Duration position) { + _log('seek($position)'); + return super.seek(position); + } + + @override + Future setRating(Rating rating, [Map? extras]) { + _log('setRating($rating, $extras)'); + return super.setRating(rating, extras); + } + + @override + Future setCaptioningEnabled(bool enabled) { + _log('setCaptioningEnabled($enabled)'); + return super.setCaptioningEnabled(enabled); + } + + @override + Future setRepeatMode(AudioServiceRepeatMode repeatMode) { + _log('setRepeatMode($repeatMode)'); + return super.setRepeatMode(repeatMode); + } + + @override + Future setShuffleMode(AudioServiceShuffleMode shuffleMode) { + _log('setShuffleMode($shuffleMode)'); + return super.setShuffleMode(shuffleMode); + } + + @override + Future seekBackward(bool begin) { + _log('seekBackward($begin)'); + return super.seekBackward(begin); + } + + @override + Future seekForward(bool begin) { + _log('seekForward($begin)'); + return super.seekForward(begin); + } + + @override + Future setSpeed(double speed) { + _log('setSpeed($speed)'); + return super.setSpeed(speed); + } + + @override + Future customAction(String name, + [Map? extras]) async { + _log('customAction($name, extras)'); + final dynamic result = await super.customAction(name, extras); + _log('customAction -> $result'); + return result; + } + + @override + Future onTaskRemoved() { + _log('onTaskRemoved()'); + return super.onTaskRemoved(); + } + + @override + Future onNotificationDeleted() { + _log('onNotificationDeleted()'); + return super.onNotificationDeleted(); + } + + @override + Future> getChildren(String parentMediaId, + [Map? options]) async { + _log('getChildren($parentMediaId, $options)'); + final result = await super.getChildren(parentMediaId, options); + _log('getChildren -> $result'); + return result; + } + + @override + ValueStream> subscribeToChildren(String parentMediaId) { + _log('subscribeToChildren($parentMediaId)'); + final result = super.subscribeToChildren(parentMediaId); + result.listen((options) { + _log('$parentMediaId children changed with options $options'); + }); + return result; + } + + @override + Future getMediaItem(String mediaId) async { + _log('getMediaItem($mediaId)'); + final result = await super.getMediaItem(mediaId); + _log('getMediaItem -> $result'); + return result; + } + + @override + Future> search(String query, + [Map? extras]) async { + _log('search($query, $extras)'); + final result = await super.search(query, extras); + _log('search -> $result'); + return result; + } + + @override + Future androidSetRemoteVolume(int volumeIndex) { + _log('androidSetRemoteVolume($volumeIndex)'); + return super.androidSetRemoteVolume(volumeIndex); + } + + @override + Future androidAdjustRemoteVolume(AndroidVolumeDirection direction) { + _log('androidAdjustRemoteVolume($direction)'); + return super.androidAdjustRemoteVolume(direction); + } +} + +void showSliderDialog({ + required BuildContext context, + required String title, + required int divisions, + required double min, + required double max, + String valueSuffix = '', + // TODO: Replace these two by ValueStream. + required double value, + required Stream stream, + required ValueChanged onChanged, +}) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title, textAlign: TextAlign.center), + content: StreamBuilder( + stream: stream, + builder: (context, snapshot) => SizedBox( + height: 100.0, + child: Column( + children: [ + Text('${snapshot.data?.toStringAsFixed(1)}$valueSuffix', + style: const TextStyle( + fontFamily: 'Fixed', + fontWeight: FontWeight.bold, + fontSize: 24.0)), + Slider( + divisions: divisions, + min: min, + max: max, + value: snapshot.data ?? value, + onChanged: onChanged, + ), + ], + ), + ), + ), + ), + ); +} diff --git a/lib/src/screens/podcast/widgets/audio_player/audio_player_core_controls.dart b/lib/src/screens/podcast/widgets/audio_player/audio_player_core_controls.dart new file mode 100644 index 00000000..e8a9bba1 --- /dev/null +++ b/lib/src/screens/podcast/widgets/audio_player/audio_player_core_controls.dart @@ -0,0 +1,481 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:audio_service/audio_service.dart'; +import 'package:audio_session/audio_session.dart'; +import 'package:flutter/foundation.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:video_player/video_player.dart'; + +class GetAudioPlayer { + GetAudioPlayer._(); + + late AudioPlayerHandler audioHandler; + + static final GetAudioPlayer _instance = GetAudioPlayer._(); + + factory GetAudioPlayer() { + return _instance; + } +} + +class QueueState { + static const QueueState empty = + QueueState([], 0, [], AudioServiceRepeatMode.none); + + final List queue; + final int? queueIndex; + final List? shuffleIndices; + final AudioServiceRepeatMode repeatMode; + + const QueueState( + this.queue, this.queueIndex, this.shuffleIndices, this.repeatMode); + + bool get hasPrevious => + repeatMode != AudioServiceRepeatMode.none || (queueIndex ?? 0) > 0; + bool get hasNext => + repeatMode != AudioServiceRepeatMode.none || + (queueIndex ?? 0) + 1 < queue.length; + + List get indices => + shuffleIndices ?? List.generate(queue.length, (i) => i); +} + +/// An [AudioHandler] for playing a list of podcast episodes. +/// +/// This class exposes the interface and not the implementation. +abstract class AudioPlayerHandler implements AudioHandler { + Stream get queueState; + Future moveQueueItem(int currentIndex, int newIndex); + ValueStream get volume; + Future setVolume(double volume); + ValueStream get speed; + + VideoPlayerController? videoPlayerController; + ValueNotifier aspectRatioNotifier = ValueNotifier(null); + + void setUpVideoController( + String url, + ); + + Future currentPosition(); + + void disposeVideoController(); + + bool isVideo = false; + + bool isInitiated = false; + + bool shouldPlayVideo(); +} + +/// The implementation of [AudioPlayerHandler]. +/// +/// This handler is backed by a just_audio player. The player's effective +/// sequence is mapped onto the handler's queue, and the player's state is +/// mapped onto the handler's state. +class AudioPlayerHandlerImpl extends BaseAudioHandler + with SeekHandler + implements AudioPlayerHandler { + // ignore: close_sinks + final BehaviorSubject> _recentSubject = + BehaviorSubject.seeded([]); + final _mediaLibrary = MediaLibrary(); + final _player = AudioPlayer(); + final _playlist = ConcatenatingAudioSource(children: []); + @override + final BehaviorSubject volume = BehaviorSubject.seeded(1.0); + @override + final BehaviorSubject speed = BehaviorSubject.seeded(1.0); + final _mediaItemExpando = Expando(); + bool isVideo = false; + bool isInitiated = false; + + VideoPlayerController? videoPlayerController; + ValueNotifier aspectRatioNotifier = ValueNotifier(null); + + @override + bool shouldPlayVideo() { + return isVideo && videoPlayerController != null; + } + + void setUpVideoController( + String url, + ) { + disposeVideoController(); + if (url.startsWith("http")) { + this.videoPlayerController = VideoPlayerController.networkUrl( + Uri.parse(url), + videoPlayerOptions: VideoPlayerOptions(allowBackgroundPlayback: true)) + ..initialize(); + } else { + this.videoPlayerController = VideoPlayerController.file(File(url), + videoPlayerOptions: VideoPlayerOptions(allowBackgroundPlayback: true)) + ..initialize(); + } + videoPlayerController!.addListener(() { + aspectRatioNotifier.value = videoPlayerController!.value.aspectRatio; + _broadcastState(_player.playbackEvent); + }); + } + + void disposeVideoController() { + if (videoPlayerController != null) { + aspectRatioNotifier.value = null; + videoPlayerController!.seekTo(Duration.zero); + videoPlayerController!.removeListener(() { + _broadcastState(_player.playbackEvent); + }); + videoPlayerController!.dispose(); + } + } + + /// A stream of the current effective sequence from just_audio. + Stream> get _effectiveSequence => Rx.combineLatest3< + List?, + List?, + bool, + List?>(_player.sequenceStream, + _player.shuffleIndicesStream, _player.shuffleModeEnabledStream, + (sequence, shuffleIndices, shuffleModeEnabled) { + if (sequence == null) return []; + if (!shuffleModeEnabled) return sequence; + if (shuffleIndices == null) return null; + if (shuffleIndices.length != sequence.length) return null; + return shuffleIndices.map((i) => sequence[i]).toList(); + }).whereType>(); + + /// Computes the effective queue index taking shuffle mode into account. + int? getQueueIndex( + int? currentIndex, bool shuffleModeEnabled, List? shuffleIndices) { + final effectiveIndices = _player.effectiveIndices ?? []; + final shuffleIndicesInv = List.filled(effectiveIndices.length, 0); + for (var i = 0; i < effectiveIndices.length; i++) { + shuffleIndicesInv[effectiveIndices[i]] = i; + } + return (shuffleModeEnabled && + ((currentIndex ?? 0) < shuffleIndicesInv.length)) + ? shuffleIndicesInv[currentIndex ?? 0] + : currentIndex; + } + + /// A stream reporting the combined state of the current queue and the current + /// media item within that queue. + @override + Stream get queueState => + Rx.combineLatest3, PlaybackState, List, QueueState>( + queue, + playbackState, + _player.shuffleIndicesStream.whereType>(), + (queue, playbackState, shuffleIndices) => QueueState( + queue, + playbackState.queueIndex, + playbackState.shuffleMode == AudioServiceShuffleMode.all + ? shuffleIndices + : null, + playbackState.repeatMode, + )).where((state) => + state.shuffleIndices == null || + state.queue.length == state.shuffleIndices!.length); + + @override + Future setRepeatMode(AudioServiceRepeatMode repeatMode) async { + playbackState.add(playbackState.value.copyWith(repeatMode: repeatMode)); + await _player.setLoopMode(LoopMode.values[repeatMode.index]); + } + + @override + Future setSpeed(double speed) async { + this.speed.add(speed); + await _player.setSpeed(speed); + } + + @override + Future setVolume(double volume) async { + this.volume.add(volume); + await _player.setVolume(volume); + } + + AudioPlayerHandlerImpl() { + _init(); + } + + Future _init() async { + final session = await AudioSession.instance; + await session.configure(const AudioSessionConfiguration.speech()); + // Broadcast speed changes. Debounce so that we don't flood the notification + // with updates. + // Load and broadcast the initial queue + await updateQueue(_mediaLibrary.items[MediaLibrary.albumsRootId]!); + // For Android 11, record the most recent item so it can be resumed. + mediaItem + .whereType() + .listen((item) => _recentSubject.add([item])); + // Broadcast media item changes. + Rx.combineLatest4, bool, List?, MediaItem?>( + _player.currentIndexStream, + queue, + _player.shuffleModeEnabledStream, + _player.shuffleIndicesStream, + (index, queue, shuffleModeEnabled, shuffleIndices) { + final queueIndex = + getQueueIndex(index, shuffleModeEnabled, shuffleIndices); + return (queueIndex != null && queueIndex < queue.length) + ? queue[queueIndex] + : null; + }).whereType().distinct().listen(mediaItem.add); + // Propagate all events from the audio player to AudioService clients. + _player.playbackEventStream.listen(_broadcastState); + _player.shuffleModeEnabledStream + .listen((enabled) => _broadcastState(_player.playbackEvent)); + // In this example, the service stops when reaching the end. + _player.processingStateStream.listen((state) { + if (state == ProcessingState.completed) { + stop(); + _player.seek(Duration.zero, index: 0); + } + }); + // Broadcast the current queue. + _effectiveSequence + .map((sequence) => + sequence.map((source) => _mediaItemExpando[source]!).toList()) + .pipe(queue); + // Load the playlist. + _playlist.addAll(queue.value.map(_itemToSource).toList()); + await _player.setAudioSource(_playlist, preload: false); + } + + AudioSource _itemToSource(MediaItem mediaItem) { + if (mediaItem.id.startsWith("http")) { + final audioSource = AudioSource.uri(Uri.parse(mediaItem.id)); + _mediaItemExpando[audioSource] = mediaItem; + return audioSource; + } else { + final audioSource = AudioSource.file(mediaItem.id); + _mediaItemExpando[audioSource] = mediaItem; + return audioSource; + } + } + + List _itemsToSources(List mediaItems) => + mediaItems.map(_itemToSource).toList(); + + @override + Future> getChildren(String parentMediaId, + [Map? options]) async { + switch (parentMediaId) { + case AudioService.recentRootId: + // When the user resumes a media session, tell the system what the most + // recently played item was. + return _recentSubject.value; + default: + // Allow client to browse the media library. + return _mediaLibrary.items[parentMediaId]!; + } + } + + @override + Future addQueueItem(MediaItem mediaItem) async { + await _playlist.add(_itemToSource(mediaItem)); + } + + @override + Future addQueueItems(List mediaItems) async { + await _playlist.addAll(_itemsToSources(mediaItems)); + } + + @override + Future insertQueueItem(int index, MediaItem mediaItem) async { + await _playlist.insert(index, _itemToSource(mediaItem)); + } + + @override + Future updateQueue(List queue) async { + await _playlist.clear(); + await _playlist.addAll(_itemsToSources(queue)); + } + + @override + Future updateMediaItem(MediaItem mediaItem) async { + final index = queue.value.indexWhere((item) => item.id == mediaItem.id); + _mediaItemExpando[_player.sequence![index]] = mediaItem; + } + + @override + Future removeQueueItem(MediaItem mediaItem) async { + final index = queue.value.indexOf(mediaItem); + await _playlist.removeAt(index); + } + + @override + Future moveQueueItem(int currentIndex, int newIndex) async { + await _playlist.move(currentIndex, newIndex); + } + + @override + Future skipToNext() async { + disposeVideoController(); + _player.seekToNext(); + } + + @override + Future skipToPrevious() async { + disposeVideoController(); + _player.seekToPrevious(); + } + + @override + Future skipToQueueItem(int index) async { + if (index < 0 || index >= _playlist.children.length) return; + disposeVideoController(); + // This jumps to the beginning of the queue item at [index]. + _player.seek(Duration.zero, + index: _player.shuffleModeEnabled + ? _player.shuffleIndices![index] + : index); + } + + @override + Future play() => + shouldPlayVideo() ? videoPlayerController!.play() : _player.play(); + + @override + Future pause() => + shouldPlayVideo() ? videoPlayerController!.pause() : _player.pause(); + + Future currentPosition() async { + int duration = 0; + if (shouldPlayVideo()) { + var videoPosition = await videoPlayerController?.position; + if (videoPosition != null) { + duration = videoPosition.inSeconds; + } + } else { + duration = _player.position.inSeconds; + } + return duration; + } + + @override + Future seek(Duration position) => shouldPlayVideo() + ? videoPlayerController!.seekTo(position) + : _player.seek(position); + + @override + Future stop() async { + await _player.stop(); + await playbackState.firstWhere( + (state) => state.processingState == AudioProcessingState.idle); + } + + bool _isPlaying() => videoPlayerController?.value.isPlaying ?? false; + + AudioProcessingState _processingState() { + if (videoPlayerController == null) + return AudioProcessingState.loading; + else if (videoPlayerController!.value.isPlaying) + return AudioProcessingState.ready; + else if (videoPlayerController!.value.isBuffering) + return AudioProcessingState.loading; + else if (videoPlayerController!.value.isInitialized) + return AudioProcessingState.idle; + return AudioProcessingState.loading; + } + + Duration _bufferedPosition() { + if (videoPlayerController != null) { + DurationRange? currentBufferedRange = + (videoPlayerController!.value.buffered.isEmpty) + ? null + : (videoPlayerController?.value.buffered.firstWhere( + (durationRange) { + Duration position = videoPlayerController!.value.position; + bool isCurrentBufferedRange = + durationRange.start < position && + durationRange.end > position; + return isCurrentBufferedRange; + }, + orElse: () => DurationRange( + videoPlayerController!.value.position, + videoPlayerController!.value.position), + )); + if (currentBufferedRange == null) return Duration.zero; + return currentBufferedRange.end; + } else { + return Duration.zero; + } + } + + /// Broadcasts the current state to all clients. + void _broadcastState(PlaybackEvent event) { + final playing = _player.playing; + + List controls = []; + controls.add( + MediaControl.skipToPrevious, + ); + if (shouldPlayVideo()) { + controls.add((_isPlaying()) ? MediaControl.pause : MediaControl.play); + } else { + controls.add( + (playing) ? MediaControl.pause : MediaControl.play, + ); + } + controls.add( + MediaControl.skipToNext, + ); + final queueIndex = getQueueIndex( + event.currentIndex, _player.shuffleModeEnabled, _player.shuffleIndices); + playbackState.add(playbackState.value.copyWith( + controls: controls, + systemActions: const { + MediaAction.seek, + MediaAction.seekForward, + MediaAction.seekBackward, + MediaAction.pause, + MediaAction.play, + MediaAction.skipToNext, + MediaAction.skipToPrevious + }, + androidCompactActionIndices: const [0, 1, 3], + processingState: shouldPlayVideo() + ? _processingState() + : const { + ProcessingState.idle: AudioProcessingState.idle, + ProcessingState.loading: AudioProcessingState.loading, + ProcessingState.buffering: AudioProcessingState.buffering, + ProcessingState.ready: AudioProcessingState.ready, + ProcessingState.completed: AudioProcessingState.completed, + }[_player.processingState]!, + playing: shouldPlayVideo() ? _isPlaying() : playing, + updatePosition: shouldPlayVideo() + ? videoPlayerController?.value.position ?? Duration.zero + : _player.position, + bufferedPosition: + shouldPlayVideo() ? _bufferedPosition() : _player.bufferedPosition, + speed: shouldPlayVideo() + ? videoPlayerController?.value.playbackSpeed ?? 1.0 + : _player.speed, + queueIndex: queueIndex, + )); + } +} + +// disposeStream + +/// Provides access to a library of media items. In your app, this could come +/// from a database or web service. +class MediaLibrary { + static const albumsRootId = 'albums'; + + final items = >{ + AudioService.browsableRootId: const [ + MediaItem( + id: albumsRootId, + title: "Albums", + playable: false, + ), + ], + albumsRootId: [], + }; +} diff --git a/lib/src/screens/podcast/widgets/audio_player/new_pod_cast_epidose_player.dart b/lib/src/screens/podcast/widgets/audio_player/new_pod_cast_epidose_player.dart new file mode 100644 index 00000000..7a6618ff --- /dev/null +++ b/lib/src/screens/podcast/widgets/audio_player/new_pod_cast_epidose_player.dart @@ -0,0 +1,533 @@ +import 'dart:async'; +import 'dart:developer'; +import 'dart:ui'; + +import 'package:acela/src/models/podcast/podcast_episode_chapters.dart'; +import 'package:acela/src/models/podcast/podcast_episodes.dart'; +import 'package:acela/src/screens/podcast/controller/podcast_chapters_controller.dart'; +import 'package:acela/src/screens/podcast/controller/podcast_controller.dart'; +import 'package:acela/src/screens/podcast/widgets/audio_player/action_tools.dart'; +import 'package:acela/src/screens/podcast/widgets/audio_player/audio_player_core_controls.dart'; +import 'package:acela/src/screens/podcast/widgets/favourite.dart'; +import 'package:acela/src/screens/podcast/widgets/podcast_info_description.dart'; +import 'package:acela/src/screens/podcast/widgets/podcast_player_widgets/control_buttons.dart'; +import 'package:acela/src/screens/podcast/widgets/podcast_player_widgets/download_podcast_button.dart'; +import 'package:acela/src/screens/podcast/widgets/podcast_player_widgets/podcast_player_slider.dart'; +import 'package:acela/src/screens/podcast/widgets/podcast_player_widgets/progress_bar.dart'; +import 'package:acela/src/utils/seconds_to_duration.dart'; +import 'package:acela/src/widgets/cached_image.dart'; +import 'package:audio_service/audio_service.dart'; +import 'package:auto_scroll_text/auto_scroll_text.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +// import 'package:marquee/marquee.dart'; +import 'package:provider/provider.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:video_player/video_player.dart'; + +class NewPodcastEpidosePlayer extends StatefulWidget { + const NewPodcastEpidosePlayer( + {Key? key, + required this.podcastEpisodes, + required this.dragValue, + required this.currentPodcastIndex}) + : super(key: key); + + final List podcastEpisodes; + final int currentPodcastIndex; + final double dragValue; + + @override + State createState() => + _NewPodcastEpidosePlayerState(); +} + +class _NewPodcastEpidosePlayerState extends State { + final _audioHandler = GetAudioPlayer().audioHandler; + int currentPodcastIndex = 0; + + late final StreamSubscription queueSubscription; + late final PodcastController podcastController; + late PodcastEpisode currentPodcastEpisode; + late PodcastChapterController chapterController; + List? chapters; + late String originalTitle; + late String? originalImage; + late Timer timer; + + Stream get _bufferedPositionStream => _audioHandler.playbackState + .map((state) => state.bufferedPosition) + .distinct(); + + Stream get _durationStream => + _audioHandler.mediaItem.map((item) => item?.duration).distinct(); + + Stream get _positionDataStream => + Rx.combineLatest3( + AudioService.position, + _bufferedPositionStream, + _durationStream, + (position, bufferedPosition, duration) => PositionData( + position, bufferedPosition, duration ?? Duration.zero)); + + @override + void initState() { + super.initState(); + currentPodcastIndex = widget.currentPodcastIndex; + currentPodcastEpisode = widget.podcastEpisodes[currentPodcastIndex]; + log(currentPodcastEpisode.enclosureUrl!); + _setUpVideo(); + podcastController = context.read(); + timer = Timer.periodic(Duration(seconds: 1), (t) { + writeCurrentDurationLocal(); + }); + podcastController.isDurationContinuing = true; + originalImage = currentPodcastEpisode.networkImage; + originalTitle = currentPodcastEpisode.title!; + // TO-DO: Ram to handle chapters for offline player + // if (currentPodcastEpisode.enclosureUrl != null && currentPodcastEpisode.enclosureUrl!.startsWith("http")) { + chapterController = PodcastChapterController( + chapterUrl: currentPodcastEpisode.chaptersUrl, + totalDuration: currentPodcastEpisode.duration ?? 0, + audioPlayerHandler: _audioHandler); + // } + queueSubscription = _audioHandler.queueState.listen((event) {}); + queueSubscription.onData((data) { + _onEpisodeChange(data); + }); + } + + void _setUpVideo() { + if (!currentPodcastEpisode.isAudio) { + _audioHandler.isVideo = true; + _audioHandler.setUpVideoController(currentPodcastEpisode.enclosureUrl!); + } else { + _audioHandler.isVideo = false; + } + } + + void writeCurrentDurationLocal() async { + int seconds = await _audioHandler.currentPosition(); + if (seconds > 0) { + context.read().writeDurationOfEpisode( + currentPodcastEpisode.id!, + currentPodcastEpisode.enclosureUrl!, + seconds); + } + } + + void _onEpisodeChange(data) { + QueueState queueState = data as QueueState; + if (currentPodcastIndex != queueState.queueIndex) { + setState(() { + currentPodcastIndex = queueState.queueIndex ?? 0; + currentPodcastEpisode = widget.podcastEpisodes[currentPodcastIndex]; + podcastController.isDurationContinuing = true; + _setUpVideo(); + timer.cancel(); + timer = Timer.periodic(Duration(seconds: 1), (t) { + writeCurrentDurationLocal(); + }); + // if (currentPodcastEpisode.enclosureUrl != null && currentPodcastEpisode.enclosureUrl!.startsWith("http")) { + chapterController = PodcastChapterController( + chapterUrl: currentPodcastEpisode.chaptersUrl, + totalDuration: currentPodcastEpisode.duration ?? 0, + audioPlayerHandler: _audioHandler); + // } + originalTitle = currentPodcastEpisode.title!; + originalImage = currentPodcastEpisode.networkImage; + }); + } + } + + @override + void dispose() { + queueSubscription.cancel(); + timer.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final theme = Theme.of(context); + return ChangeNotifierProvider.value( + value: chapterController, + child: Scaffold( + body: SafeArea( + child: Stack( + fit: StackFit.expand, + children: [ + if (originalImage != null && originalImage!.isNotEmpty) + CachedImage( + imageUrl: originalImage, + ), + if (originalImage != null && originalImage!.isNotEmpty) + Positioned.fill( + child: ClipRRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 40, sigmaY: 40), + child: const SizedBox.shrink(), + ), + ), + ), + Positioned.fill( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient(colors: [ + theme.primaryColorDark, + theme.primaryColorDark.withOpacity(0.3) + ])), + )), + // if (widget.dragValue < 0.5) + Positioned( + top: 0, + left: 0, + right: 0, + child: Visibility( + maintainAnimation: true, + maintainSize: true, + maintainState: true, + visible: widget.dragValue < 0.5, + child: PodcastProgressBar( + duration: currentPodcastEpisode.duration, + positionStream: _positionDataStream), + )), + StreamBuilder( + stream: _audioHandler.mediaItem, + builder: (context, snapshot) { + return SingleChildScrollView( + physics: NeverScrollableScrollPhysics(), + child: SizedBox( + height: MediaQuery.of(context).size.height, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Spacer(), + _audioHandler.shouldPlayVideo() + ? SizedBox( + height: + MediaQuery.of(context).size.height * 0.45, + child: Center( + child: ValueListenableBuilder( + valueListenable: + _audioHandler.aspectRatioNotifier, + builder: (context, aspectRatio, child) { + return AspectRatio( + aspectRatio: aspectRatio ?? 1.5, + child: child); + }, + child: VideoPlayer( + _audioHandler.videoPlayerController!), + )), + ) + : Transform.translate( + offset: Offset( + lerpDouble(-150, 0, widget.dragValue) ?? 0, + lerpDouble(-152.5, 0, widget.dragValue) ?? + 0, + ), + child: Container( + height: lerpDouble( + 50, + MediaQuery.of(context) + .size + .height * + 0.45, + widget.dragValue) ?? + 0, + width: lerpDouble( + 50, + MediaQuery.of(context) + .size + .width * + 0.85, + widget.dragValue) ?? + 0, + margin: + EdgeInsets.symmetric(horizontal: 30), + // constraints: BoxConstraints( + // maxHeight: MediaQuery.of(context) + // .size + // .height * + // 0.45), + child: Selector( + selector: (_, myType) => myType.image, + builder: + (context, chapterImage, child) { + return CachedImage( + imageUrl: + chapterImage ?? originalImage, + imageHeight: MediaQuery.of(context) + .size + .height * + 0.45, + ); + }, + )), + ), + Spacer(), + AnimatedOpacity( + opacity: widget.dragValue == 1 + ? 1 + : widget.dragValue.clamp(0, 0.5), + duration: Duration(milliseconds: 150), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: 15.0, horizontal: 10), + child: Column( + children: [ + _title(screenWidth * 0.85), + const SizedBox( + height: 5, + ), + Text( + currentPodcastEpisode + .datePublishedPretty + .toString(), + style: TextStyle(fontSize: 12), + ), + ], + ), + ), + userToolbar(theme), + _slider(), + Gap(10), + ControlButtons( + _audioHandler, + chapterController: chapterController, + podcastEpisode: currentPodcastEpisode, + showSkipPreviousButtom: + widget.podcastEpisodes.length > 1, + positionStream: + _positionDataStream.asBroadcastStream(), + ), + ], + ), + ), + Spacer(), + ], + ), + ), + ); + }, + ), + if (widget.dragValue < 0.5) + AnimatedPositioned( + top: lerpDouble(10, 0, widget.dragValue), + left: lerpDouble(80, 20, widget.dragValue), + right: 10, + duration: Duration(milliseconds: 100), + child: AnimatedOpacity( + opacity: (lerpDouble(1, 6, widget.dragValue * (-0.5)) ?? 0) + .clamp(0, 1), + duration: Duration(milliseconds: 100), + child: Padding( + padding: const EdgeInsets.only(left: 0.0, right: 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: Alignment.centerLeft, + child: _title(screenWidth * 0.75, + height: 15, fontSize: 14)), + ControlButtons( + _audioHandler, + smallSize: true, + chapterController: chapterController, + podcastEpisode: currentPodcastEpisode, + showSkipPreviousButtom: + widget.podcastEpisodes.length > 1, + positionStream: + _positionDataStream.asBroadcastStream(), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + PodcastPlayerSlider _slider() { + return PodcastPlayerSlider( + episode: currentPodcastEpisode, + chapterController: chapterController, + audioPlayerHandler: _audioHandler, + positionDataStream: _positionDataStream, + currentPodcastEpisodeDuration: currentPodcastEpisode.duration); + } + + Selector _title(double maxwidth, + {double fontSize = 20, double height = 25}) { + return Selector( + selector: (_, myType) => myType.title, + builder: (context, chapterTitle, child) { + return SizedBox( + width: maxwidth, + height: height, + child: Utilities.textLines( + chapterTitle ?? originalTitle, + TextStyle( + fontWeight: FontWeight.bold, fontSize: fontSize), + maxwidth, + 3) > + 1 + ? AutoScrollText( + chapterTitle ?? originalTitle, + intervalSpaces: 10, + velocity: Velocity( + pixelsPerSecond: Offset(50, 0), + ), + style: TextStyle( + fontWeight: FontWeight.bold, fontSize: fontSize), + ) + : Text( + chapterTitle ?? originalTitle, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.bold, fontSize: fontSize), + ), + ); + }, + ); + } + + Widget userToolbar(ThemeData theme) { + Color iconColor = theme.primaryColorLight; + List tools = [ + IconButton( + constraints: const BoxConstraints(), + padding: EdgeInsets.zero, + icon: Icon(Icons.info_outline, color: iconColor), + onPressed: () { + _onInfoButtonTap(); + }, + ), + IconButton( + constraints: const BoxConstraints(), + padding: EdgeInsets.zero, + icon: Icon(Icons.share, color: iconColor), + onPressed: () { + Share.share(currentPodcastEpisode.enclosureUrl ?? ''); + }, + ), + DownloadPodcastButton( + color: iconColor, + episode: currentPodcastEpisode, + ), + FavouriteWidget( + toastType: "Podcast Episode", + disablePadding: true, + iconColor: iconColor, + isLiked: podcastController + .isLikedPodcastEpisodePresentLocally(currentPodcastEpisode), + onAdd: () { + podcastController + .storeLikedPodcastEpisodeLocally(currentPodcastEpisode); + }, + onRemove: () { + podcastController + .storeLikedPodcastEpisodeLocally(currentPodcastEpisode); + }, + ), + IconButton( + onPressed: () { + _onTapPodcastHistory(); + }, + icon: Icon( + Icons.list, + color: iconColor, + ), + ) + ]; + // if (context.read().username != null) { + // tools.insert( + // 3, + // IconButton( + // constraints: const BoxConstraints(), + // padding: EdgeInsets.zero, + // icon: Icon(CupertinoIcons.gift_fill, color: iconColor), + // onPressed: () { + // Navigator.of(context).push(MaterialPageRoute( + // builder: (context) => const ValueForValueView(), + // )); + // }, + // ), + // ); + // } + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: tools, + ); + } + + void _onInfoButtonTap() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + clipBehavior: Clip.hardEdge, + isDismissible: true, + builder: (context) { + return SizedBox( + height: MediaQuery.of(context).size.height * 0.7, + child: PodcastInfoDescroption( + title: currentPodcastEpisode.title, + description: currentPodcastEpisode.description)); + }, + ); + } + + void _onTapPodcastHistory() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + clipBehavior: Clip.hardEdge, + isDismissible: true, + builder: (context) { + return SizedBox( + height: MediaQuery.of(context).size.height * 0.7, + child: Scaffold( + appBar: AppBar( + title: Text("Podcast Episodes"), + ), + body: ListView.builder( + itemCount: widget.podcastEpisodes.length, + itemBuilder: (context, index) { + PodcastEpisode item = widget.podcastEpisodes[index]; + return ListTile( + onTap: () { + _audioHandler.skipToQueueItem(index); + Navigator.pop(context); + }, + trailing: Icon(Icons.play_circle_outline_outlined), + leading: CachedImage( + imageUrl: item.image, + imageHeight: 48, + imageWidth: 48, + loadingIndicatorSize: 25, + ), + title: Text( + item.title!, + style: TextStyle(fontSize: 14), + ), + ); + }, + ), + )); + }, + ); + } +} diff --git a/lib/src/screens/podcast/widgets/favourite.dart b/lib/src/screens/podcast/widgets/favourite.dart new file mode 100644 index 00000000..f638a5f4 --- /dev/null +++ b/lib/src/screens/podcast/widgets/favourite.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +class FavouriteWidget extends StatefulWidget { + const FavouriteWidget( + {Key? key, + required this.isLiked, + required this.onAdd, + required this.onRemove, + this.iconColor, + this.iconSize, + this.alignment, + this.disablePadding = false, + required this.toastType}) + : super(key: key); + + final bool isLiked; + final VoidCallback onAdd; + final VoidCallback onRemove; + final Color? iconColor; + final bool disablePadding; + final String toastType; + final double? iconSize; + final Alignment? alignment; + + @override + State createState() => _FavouriteWidgetState(); +} + +class _FavouriteWidgetState extends State { + late bool isLiked; + @override + void initState() { + isLiked = widget.isLiked; + super.initState(); + } + + @override + void didUpdateWidget(covariant FavouriteWidget oldWidget) { + isLiked = widget.isLiked; + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return IconButton( + alignment:widget.alignment , + constraints: widget.disablePadding ? BoxConstraints() : null, + padding: widget.disablePadding ? EdgeInsets.zero : null, + icon: Icon( + isLiked ? Icons.bookmark : Icons.bookmark_border, + size: widget.iconSize, + color: widget.iconColor, + ), + onPressed: () { + if (isLiked) { + widget.onRemove(); + setState(() { + isLiked = false; + }); + showSnackBar(false); + } else { + widget.onAdd(); + setState(() { + isLiked = true; + }); + showSnackBar(true); + } + }, + ); + } + + void showSnackBar(bool isAdding) { + final String message = isAdding + ? "is added to your bookmarks" + : "is removed from your bookmarks"; + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + backgroundColor: Colors.black, + content: Text( + 'The ${widget.toastType} $message', + style: TextStyle(color: Colors.white), + ), + duration: Duration(seconds: 3), + )); + } +} diff --git a/lib/src/screens/podcast/widgets/podcast_categories_body.dart b/lib/src/screens/podcast/widgets/podcast_categories_body.dart new file mode 100644 index 00000000..7316f7d5 --- /dev/null +++ b/lib/src/screens/podcast/widgets/podcast_categories_body.dart @@ -0,0 +1,89 @@ +import 'package:acela/src/models/podcast/podcast_categories_response.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/podcast/view/podcast_category_view.dart'; +import 'package:acela/src/widgets/loading_screen.dart'; +import 'package:acela/src/widgets/retry.dart'; +import 'package:flutter/material.dart'; + +class PodcastCategoriesBody extends StatefulWidget { + const PodcastCategoriesBody( + {Key? key, required this.future, required this.appData}) + : super(key: key); + + final Future> future; + final HiveUserData appData; + + @override + State createState() => _PodcastCategoriesBodyState(); +} + +class _PodcastCategoriesBodyState extends State { + late Future> future; + @override + void initState() { + future = widget.future; + super.initState(); + } + + @override + void didUpdateWidget(covariant PodcastCategoriesBody oldWidget) { + future = widget.future; + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: future, + builder: (context, snapshot) { + if (snapshot.hasError) { + return RetryScreen( + error: snapshot.error.toString(), + onRetry: () { + setState(() { + future = widget.future; + }); + }, + ); + } else if (snapshot.connectionState == ConnectionState.done) { + List list = snapshot.data!; + if (list.isEmpty) { + return RetryScreen( + error: 'No data found.', + onRetry: () { + setState(() { + future = widget.future; + }); + }, + ); + } else { + return getList(list); + } + } else { + return LoadingScreen(title: 'Loading', subtitle: 'Please wait..'); + } + }, + ); + } + + Widget getList(List items) { + return ListView.separated( + itemBuilder: (c, i) { + return ListTile( + onTap: () { + var screen = PodcastCategoryView( + appData: widget.appData, + categoryId: items[i].id!, + categoryName: items[i].name!, + ); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + }, + title: Text(items[i].name.toString()), + ); + }, + separatorBuilder: (c, i) => const Divider(height: 0), + itemCount: items.length, + ); + } +} diff --git a/lib/src/screens/podcast/widgets/podcast_feed_item.dart b/lib/src/screens/podcast/widgets/podcast_feed_item.dart new file mode 100644 index 00000000..59224833 --- /dev/null +++ b/lib/src/screens/podcast/widgets/podcast_feed_item.dart @@ -0,0 +1,68 @@ +import 'package:acela/src/models/podcast/trending_podcast_response.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/podcast/controller/podcast_controller.dart'; +import 'package:acela/src/screens/podcast/view/podcast_episodes/podcast_episodes_view.dart'; +import 'package:acela/src/screens/podcast/widgets/favourite.dart'; +import 'package:acela/src/widgets/cached_image.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class PodcastFeedItemWidget extends StatefulWidget { + const PodcastFeedItemWidget( + {Key? key, + required this.item, + required this.appData, + this.showLikeButton = true, + this.playOnMiniPlayer = true}) + : super(key: key); + + final PodCastFeedItem item; + final HiveUserData appData; + final bool showLikeButton; + final bool playOnMiniPlayer; + + @override + State createState() => _PodcastFeedItemWidgetState(); +} + +class _PodcastFeedItemWidgetState extends State { + @override + Widget build(BuildContext context) { + var title = widget.item.title ?? 'No title'; + var desc = ''; + // desc = "$desc${(widget.item.categories?.values ?? []).join(", ")}"; + final podcastController = context.read(); + return ListTile( + dense: true, + leading: CachedImage( + imageUrl: widget.item.networkImage, + imageHeight: 48, + imageWidth: 48, + loadingIndicatorSize: 25, + ), + title: Text(title), + subtitle: Text(widget.item.author ?? ""), + trailing: Visibility( + visible: widget.showLikeButton, + child: FavouriteWidget( + toastType: "Podcast", + isLiked: + podcastController.isLikedPodcastPresentLocally(widget.item), + onAdd: () { + podcastController.storeLikedPodcastLocally(widget.item); + }, + onRemove: () { + podcastController.storeLikedPodcastLocally(widget.item); + }), + ), + onTap: () { + var screen = PodcastEpisodesView( + feedItem: widget.item, + playOnMiniPlayer: widget.playOnMiniPlayer, + ); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + }, + ); + } +} diff --git a/lib/src/screens/podcast/widgets/podcast_feeds_body.dart b/lib/src/screens/podcast/widgets/podcast_feeds_body.dart new file mode 100644 index 00000000..9d26213a --- /dev/null +++ b/lib/src/screens/podcast/widgets/podcast_feeds_body.dart @@ -0,0 +1,82 @@ +import 'package:acela/src/models/podcast/trending_podcast_response.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/podcast/widgets/podcast_feed_item.dart'; +import 'package:acela/src/widgets/loading_screen.dart'; +import 'package:acela/src/widgets/retry.dart'; +import 'package:flutter/material.dart'; + +class PodcastFeedsBody extends StatefulWidget { + const PodcastFeedsBody( + {Key? key, required this.future, required this.appData}) + : super(key: key); + + final Future future; + final HiveUserData appData; + + @override + State createState() => _PodcastFeedsBodyState(); +} + +class _PodcastFeedsBodyState extends State { + late Future future; + @override + void initState() { + future = widget.future; + super.initState(); + } + + @override + void didUpdateWidget(covariant PodcastFeedsBody oldWidget) { + future = widget.future; + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: future, + builder: (context, snapshot) { + if (snapshot.hasError) { + return RetryScreen( + error: snapshot.error.toString(), + onRetry: () { + setState(() { + future = widget.future; + }); + }, + ); + } else if (snapshot.connectionState == ConnectionState.done) { + var data = snapshot.data as TrendingPodCastResponse; + List list = data.feeds != null && data.feeds!.isNotEmpty ? data.feeds! : data.items != null && data.items!.isNotEmpty ? data.items! : []; + if (list.isEmpty) { + return RetryScreen( + error: 'No data found.', + onRetry: () { + setState(() { + future = widget.future; + }); + }, + ); + } else { + return getList(list); + } + } else { + return LoadingScreen(title: 'Loading', subtitle: 'Please wait..'); + } + }, + ); + } + + Widget getList(List items) { + return ListView.separated( + itemBuilder: (c, i) { + return PodcastFeedItemWidget( + appData: widget.appData, + item: items[i], + ); + }, + separatorBuilder: (c, i) => const Divider(height: 0), + itemCount: items.length, + ); + } +} diff --git a/lib/src/screens/podcast/widgets/podcast_info_description.dart b/lib/src/screens/podcast/widgets/podcast_info_description.dart new file mode 100644 index 00000000..17ff9d4e --- /dev/null +++ b/lib/src/screens/podcast/widgets/podcast_info_description.dart @@ -0,0 +1,35 @@ +import 'package:acela/src/utils/seconds_to_duration.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; + +class PodcastInfoDescroption extends StatelessWidget { + const PodcastInfoDescroption({ + Key? key, + required this.title, + required this.description, + }) : super(key: key); + + final String? title; + final String? description; + + Widget descriptionMarkDown(String markDown) { + return Markdown( + padding: const EdgeInsets.all(10), + data: Utilities.removeAllHtmlTags(markDown), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(title ?? ""), + ), + body: SafeArea( + child: descriptionMarkDown( + description ?? "No content", + ), + ), + ); + } +} diff --git a/lib/src/screens/podcast/widgets/podcast_player.dart b/lib/src/screens/podcast/widgets/podcast_player.dart new file mode 100644 index 00000000..23cf2c15 --- /dev/null +++ b/lib/src/screens/podcast/widgets/podcast_player.dart @@ -0,0 +1,350 @@ +import 'package:acela/src/models/podcast/podcast_episodes.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/podcast/controller/podcast_controller.dart'; +import 'package:acela/src/screens/podcast/widgets/favourite.dart'; +import 'package:acela/src/screens/podcast/widgets/podcast_info_description.dart'; +import 'package:acela/src/screens/podcast/widgets/podcast_player_widgets/download_podcast_button.dart'; +import 'package:acela/src/screens/podcast/widgets/podcast_player_widgets/podcast_player_intercation_icon_button.dart'; +import 'package:acela/src/utils/seconds_to_duration.dart'; +import 'package:assets_audio_player/assets_audio_player.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:share_plus/share_plus.dart'; + +class PodcastEpisodePlayer extends StatefulWidget { + const PodcastEpisodePlayer( + {Key? key, + required this.data, + required this.podcastEpisodes, + this.episodeIndex}); + + final List podcastEpisodes; + final HiveUserData data; + final int? episodeIndex; + + @override + State createState() => _PodcastEpisodePlayerState(); +} + +class _PodcastEpisodePlayerState extends State { + late final PodcastController podcastController; + late PodcastEpisode curentPodcastEpisode; + int currentPodcastEpisodeIndex = 0; + var play = true; + var position = 0.0; + int initialPosition = 0; + + @override + void initState() { + super.initState(); + if (widget.episodeIndex != null) { + currentPodcastEpisodeIndex = widget.episodeIndex!; + } + curentPodcastEpisode = widget.podcastEpisodes[currentPodcastEpisodeIndex]; + podcastController = context.read(); + } + + List _fabButtonsOnRight() { + return [ + IconButton( + icon: Icon(Icons.share, color: Colors.blue), + onPressed: () { + // _betterPlayerController.pause(); + Share.share(curentPodcastEpisode.guid ?? ''); + }, + ), + SizedBox(height: 10), + IconButton( + icon: Icon(Icons.info, color: Colors.blue), + onPressed: () { + // _betterPlayerController.pause(); + // var screen = + // NewVideoDetailsInfo( + // appData: widget.data, + // item: widget.item, + // ); + // var route = MaterialPageRoute(builder: (c) => screen); + // Navigator.of(context).push(route); + }, + ), + SizedBox(height: 10), + ]; + } + + Widget userToolbar() { + Color iconColor = Colors.lightBlue; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + constraints: const BoxConstraints(), + padding: EdgeInsets.zero, + icon: Icon(Icons.info_outline, color: iconColor), + onPressed: () { + _onInfoButtonTap(); + // _betterPlayerController.pause(); + // var screen = + // NewVideoDetailsInfo( + // appData: widget.data, + // item: widget.item, + // ); + // var route = MaterialPageRoute(builder: (c) => screen); + // Navigator.of(context).push(route); + }, + ), + IconButton( + constraints: const BoxConstraints(), + padding: EdgeInsets.zero, + icon: Icon(Icons.share, color: iconColor), + onPressed: () { + // _betterPlayerController.pause(); + Share.share(curentPodcastEpisode.guid ?? ''); + }, + ), + DownloadPodcastButton( + color: iconColor, + episode: curentPodcastEpisode, + ), + FavouriteWidget( + toastType: "Podcast Episode", + disablePadding: true, + iconColor: iconColor, + isLiked: podcastController + .isLikedPodcastEpisodePresentLocally(curentPodcastEpisode), + onAdd: () { + podcastController + .storeLikedPodcastEpisodeLocally(curentPodcastEpisode); + }, + onRemove: () { + podcastController + .storeLikedPodcastEpisodeLocally(curentPodcastEpisode); + }), + ], + ); + } + + Widget actionBar() { + Color iconColor = Colors.white; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Visibility( + visible: widget.podcastEpisodes.length > 1, + child: PodcastPlayerInteractionIconButton( + onPressed: _playPrevious, + icon: Icons.skip_previous, + color: currentPodcastEpisodeIndex == 0 + ? iconColor.withOpacity(0.5) + : iconColor, + )), + PodcastPlayerInteractionIconButton( + horizontalPadding: 20, + onPressed: _reverseTenSeconds, + size: 30, + icon: Icons.replay_10, + color: iconColor), + GestureDetector( + onTap: _pausePlayer, + child: CircleAvatar( + backgroundColor: Colors.white, + child: Icon( + play ? Icons.pause : Icons.play_arrow, + size: 30, + color: Colors.black, + ), + ), + ), + PodcastPlayerInteractionIconButton( + size: 30, + horizontalPadding: 20, + onPressed: _forwardTenSeconds, + icon: Icons.forward_10, + color: iconColor), + Visibility( + visible: widget.podcastEpisodes.length > 1, + child: PodcastPlayerInteractionIconButton( + onPressed: _playNext, + icon: Icons.skip_next, + color: currentPodcastEpisodeIndex == + widget.podcastEpisodes.length - 1 + ? iconColor.withOpacity(0.5) + : iconColor, + )), + ], + ); + } + + void _onInfoButtonTap() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + clipBehavior: Clip.hardEdge, + isDismissible: true, + builder: (context) { + return SizedBox( + height: MediaQuery.of(context).size.height * 0.7, + child: PodcastInfoDescroption( + title: curentPodcastEpisode.title, + description: curentPodcastEpisode.description)); + }, + ); + } + + void _pausePlayer() { + setState(() { + play = !play; + }); + } + + void _playNext() { + if (currentPodcastEpisodeIndex != widget.podcastEpisodes.length - 1) { + setState(() { + position = 0; + currentPodcastEpisodeIndex++; + curentPodcastEpisode = + widget.podcastEpisodes[currentPodcastEpisodeIndex]; + initialPosition = 0; + play = true; + }); + } + } + + void _playPrevious() { + if (currentPodcastEpisodeIndex != 0) { + setState(() { + --currentPodcastEpisodeIndex; + curentPodcastEpisode = + widget.podcastEpisodes[currentPodcastEpisodeIndex]; + initialPosition = 0; + play = true; + }); + } + } + + void _forwardTenSeconds() { + setState(() { + int episodeDuration = curentPodcastEpisode.duration ?? 0; + initialPosition = (position + 10).toInt().clamp(0, episodeDuration); + if (initialPosition == episodeDuration) { + _playNext(); + } + }); + } + + void _reverseTenSeconds() { + setState(() { + initialPosition = + (position - 10).toInt().clamp(0, curentPodcastEpisode.duration ?? 0); + }); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + setState(() { + play = false; + }); + return true; + }, + child: SafeArea(child: _playerStatus()), + ); + } + + Widget _playerStatus() { + if (context.read().isOffline( + curentPodcastEpisode.enclosureUrl ?? "", + curentPodcastEpisode.id.toString())) { + return AudioWidget.file( + initialPosition: Duration(seconds: initialPosition), + path: podcastController.getOfflineUrl( + curentPodcastEpisode.enclosureUrl ?? "", + curentPodcastEpisode.id.toString()), + play: play, + // onFinished: _playNext, + child: child(context), + onReadyToPlay: (duration) { + //onReadyToPlay + }, + onPositionChanged: _onPositionChanged); + } else { + return AudioWidget.network( + initialPosition: Duration(seconds: initialPosition), + url: curentPodcastEpisode.enclosureUrl ?? '', + play: play, + // onFinished: _playNext, + child: child(context), + onReadyToPlay: (duration) { + //onReadyToPlay + }, + onPositionChanged: _onPositionChanged); + } + } + + void _onPositionChanged(Duration current, Duration duration) { + setState(() { + position = current.inSeconds.toDouble(); + }); + if (position != 0) { + int episodeDuration = curentPodcastEpisode.duration ?? 0; + if (position == episodeDuration) { + _playNext(); + } + } + } + + Column child(BuildContext context) { + var duration = curentPodcastEpisode.duration?.toDouble() ?? 0.0; + var pending = duration - position; + var pendingText = "${Utilities.formatTime(pending.toInt())}"; + var leadingText = "${Utilities.formatTime(position.toInt())}"; + double min = 0; + double max = curentPodcastEpisode.duration?.toDouble() ?? 0.0; + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.45), + child: Image.network(curentPodcastEpisode.image ?? '')), + Padding( + padding: const EdgeInsets.symmetric(vertical: 15.0, horizontal: 10), + child: Text( + curentPodcastEpisode.title ?? '', + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + userToolbar(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: Row( + children: [ + Text(leadingText), + Expanded( + child: Slider( + activeColor: Colors.white, + inactiveColor: Colors.white38, + min: min, + max: max, + value: (position.clamp(min, max)), + onChanged: (newValue) { + setState(() { + position = newValue; + initialPosition = newValue.toInt(); + }); + }, + ), + ), + Text(pendingText), + ], + ), + ), + actionBar(), + ], + ); + } +} diff --git a/lib/src/screens/podcast/widgets/podcast_player_widgets/control_buttons.dart b/lib/src/screens/podcast/widgets/podcast_player_widgets/control_buttons.dart new file mode 100644 index 00000000..9d41fd07 --- /dev/null +++ b/lib/src/screens/podcast/widgets/podcast_player_widgets/control_buttons.dart @@ -0,0 +1,223 @@ +import 'package:acela/src/models/podcast/podcast_episodes.dart'; +import 'package:acela/src/screens/podcast/controller/podcast_chapters_controller.dart'; +import 'package:acela/src/screens/podcast/controller/podcast_controller.dart'; +import 'package:acela/src/screens/podcast/widgets/audio_player/action_tools.dart'; +import 'package:acela/src/screens/podcast/widgets/audio_player/audio_player_core_controls.dart'; +import 'package:acela/src/screens/podcast/widgets/podcast_player_widgets/podcast_player_intercation_icon_button.dart'; +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class ControlButtons extends StatelessWidget { + final AudioPlayerHandler audioHandler; + + const ControlButtons(this.audioHandler, + {Key? key, + required this.showSkipPreviousButtom, + required this.podcastEpisode, + required this.positionStream, + required this.chapterController, + this.smallSize = false}) + : super(key: key); + + final bool showSkipPreviousButtom; + final PodcastEpisode podcastEpisode; + final Stream positionStream; + final PodcastChapterController chapterController; + final bool smallSize; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + bool isPaused = false; + Color iconColor = theme.primaryColorLight; + return Row( + mainAxisAlignment: + smallSize ? MainAxisAlignment.center : MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Visibility( + visible: showSkipPreviousButtom, + child: StreamBuilder( + stream: audioHandler.queueState, + builder: (context, snapshot) { + final queueState = snapshot.data ?? QueueState.empty; + return IconButton( + icon: Icon( + Icons.skip_previous, + color: chapterController.hasPreviousChapter() || + queueState.hasPrevious + ? iconColor + : iconColor.withOpacity(0.5), + ), + onPressed: () { + chapterController.jumpToPreviousChapter(queueState.hasPrevious + ? audioHandler.skipToPrevious + : () {}); + }, + ); + }, + ), + ), + if (!smallSize) + StreamBuilder( + stream: positionStream, + builder: (context, snapshot) { + final positionData = snapshot.data ?? + PositionData(Duration.zero, Duration.zero, Duration.zero); + return PodcastPlayerInteractionIconButton( + size: 35, + horizontalPadding: 20, + onPressed: () => goBackTenSeconds(positionData), + icon: Icons.replay_10, + color: iconColor); + }, + ), + StreamBuilder( + stream: audioHandler.playbackState, + builder: (context, snapshot) { + final playbackState = snapshot.data; + final processingState = playbackState?.processingState; + final playing = playbackState?.playing; + if (processingState == AudioProcessingState.idle && !isPaused) + audioHandler.play(); + if (processingState == AudioProcessingState.loading || + processingState == AudioProcessingState.buffering) { + return smallSize + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: SizedBox( + height: 15, + width: 15, + child: CircularProgressIndicator( + strokeWidth: 1, + color: theme.primaryColorLight, + ), + ), + ) + : CircleAvatar( + radius: smallSize ? 20 : 32, + backgroundColor: theme.primaryColorLight, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: theme.primaryColorDark, + ), + ); + } else if (playing != true) { + return GestureDetector( + onTap: audioHandler.play, + child: smallSize + ? Icon( + Icons.play_arrow, + size: 20, + ) + : CircleAvatar( + radius: 32, + backgroundColor: theme.primaryColorLight, + child: Icon( + Icons.play_arrow, + size: 35, + color: Colors.black, + ), + ), + ); + } else { + _continueFromDuration(context); + return GestureDetector( + onTap: () { + isPaused = !isPaused; + audioHandler.pause(); + }, + child: smallSize + ? Icon( + Icons.pause, + size: 20, + ) + : CircleAvatar( + radius: 32, + backgroundColor: theme.primaryColorLight, + child: Icon( + Icons.pause, + size: 35, + color: theme.primaryColorDark, + ), + ), + ); + } + }, + ), + if (!smallSize) + StreamBuilder( + stream: positionStream, + builder: (context, snapshot) { + final positionData = snapshot.data ?? + PositionData(Duration.zero, Duration.zero, Duration.zero); + return PodcastPlayerInteractionIconButton( + size: 35, + horizontalPadding: 20, + onPressed: () => _goForwardTenSeconds(positionData), + icon: Icons.forward_10, + color: iconColor); + }, + ), + Visibility( + visible: showSkipPreviousButtom, + child: StreamBuilder( + stream: audioHandler.queueState, + builder: (context, snapshot) { + final queueState = snapshot.data ?? QueueState.empty; + return IconButton( + icon: Icon( + Icons.skip_next, + color: + chapterController.hasNextChapter() || queueState.hasNext + ? iconColor + : iconColor.withOpacity(0.5), + ), + onPressed: () { + chapterController.jumpToNextChapter( + queueState.hasNext ? audioHandler.skipToNext : () {}); + }, + ); + }, + ), + ), + ], + ); + } + + void _continueFromDuration(BuildContext context) { + final podcastController = context.read(); + if (podcastController.isDurationContinuing) { + int? skipToDuration = podcastController.readSavedDurationOfEpisode( + podcastEpisode.id!, podcastEpisode.enclosureUrl!); + podcastController.isDurationContinuing = true; + if (skipToDuration != null) { + audioHandler.seek(Duration(seconds: skipToDuration)); + } + podcastController.isDurationContinuing = false; + } + } + + void _goForwardTenSeconds(PositionData positionData) { + chapterController.currentDuration = chapterController.currentDuration + 10; + chapterController.syncChapters(isInteracted: true, isReduced: false); + audioHandler.seek(Duration(seconds: positionData.position.inSeconds + 10)); + } + + void goBackTenSeconds(PositionData positionData) { + if (chapterController.currentDuration - 10 < 0) { + chapterController.currentDuration = 0; + } else { + chapterController.currentDuration = + chapterController.currentDuration - 10; + } + chapterController.syncChapters(isInteracted: true, isReduced: true); + if (positionData.position.inSeconds > 10) { + audioHandler + .seek(Duration(seconds: positionData.position.inSeconds - 10)); + } else { + audioHandler.seek(Duration(seconds: 0)); + } + } +} diff --git a/lib/src/screens/podcast/widgets/podcast_player_widgets/download_podcast_button.dart b/lib/src/screens/podcast/widgets/podcast_player_widgets/download_podcast_button.dart new file mode 100644 index 00000000..51139954 --- /dev/null +++ b/lib/src/screens/podcast/widgets/podcast_player_widgets/download_podcast_button.dart @@ -0,0 +1,202 @@ +import 'dart:isolate'; +import 'dart:ui'; +import 'package:acela/src/models/podcast/podcast_episodes.dart'; +import 'package:acela/src/screens/podcast/controller/podcast_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_downloader/flutter_downloader.dart'; +import 'package:provider/provider.dart'; + +enum DownloadStatus { downloading, downloaded, download, cancelDownload } + +class DownloadPodcastButton extends StatefulWidget { + const DownloadPodcastButton({ + Key? key, + required this.episode, + required this.color, + }) : super(key: key); + + final PodcastEpisode episode; + final Color color; + + @override + State createState() => _DownloadPodcastButtonState(); +} + +class _DownloadPodcastButtonState extends State { + late PodcastController podcastController; + final ValueNotifier downloadProgress = ValueNotifier(0); + late DownloadStatus status; + String? taskId; + ReceivePort _port = ReceivePort(); + + @override + void initState() { + super.initState(); + podcastController = context.read(); + _setStatusOnEpisodeChange(); + + IsolateNameServer.registerPortWithName( + _port.sendPort, 'downloader_send_port'); + _port.listen((dynamic data) async { + // String id = data[0]; + // DownloadTaskStatus statuss = (data[1]); + int progress = data[2]; + downloadProgress.value = progress.toDouble(); + print('progess is $progress'); + if (progress == 100 && + status == DownloadStatus.downloading && + taskId != null) { + podcastController.storeOfflinePodcastLocally(widget.episode); + setState(() { + status = DownloadStatus.downloaded; + }); + } + }); + + FlutterDownloader.registerCallback(downloadCallback); + } + + void _setStatusOnEpisodeChange() { + if (podcastController.isOffline( + widget.episode.enclosureUrl ?? "", widget.episode.id.toString())) { + status = DownloadStatus.downloaded; + } else { + status = DownloadStatus.download; + } + } + + @override + void didUpdateWidget(covariant DownloadPodcastButton oldWidget) { + if (oldWidget.episode.id != widget.episode.id) { + if (status == DownloadStatus.downloading) { + setState(() { + _cancelAndRemove(taskId); + status = DownloadStatus.download; + taskId = null; + }); + } else { + setState(() { + _setStatusOnEpisodeChange(); + }); + } + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + FlutterDownloader.cancelAll(); + IsolateNameServer.removePortNameMapping('downloader_send_port'); + super.dispose(); + } + + @pragma('vm:entry-point') + static void downloadCallback(String id, int status, int progress) { + final SendPort? send = + IsolateNameServer.lookupPortByName('downloader_send_port'); + send!.send([id, status, progress]); + } + + @override + Widget build(BuildContext context) { + return _build(context); + } + + Widget _build(BuildContext context) { + final theme = Theme.of(context); + final iconColor = widget.color; + if (status == DownloadStatus.downloading || + status == DownloadStatus.cancelDownload) { + return Stack( + alignment: Alignment.center, + children: [ + SizedBox( + height: 25, + width: 25, + child: ValueListenableBuilder( + valueListenable: downloadProgress, + builder: (context, progress, child) { + return CircularProgressIndicator( + strokeWidth: 2, + value: status == DownloadStatus.cancelDownload + ? null + : progress / 100, + valueColor: AlwaysStoppedAnimation(iconColor), + backgroundColor: theme.primaryColorLight.withOpacity(0.4), + ); + }), + ), + Visibility( + visible: true, + maintainAnimation: true, + maintainSemantics: true, + maintainSize: true, + maintainState: true, + child: IconButton( + onPressed: _cancelDownload, + icon: Icon( + Icons.stop, + size: 17.5, + )), + ) + ], + ); + } else if (status == DownloadStatus.download) { + return IconButton( + icon: Icon(Icons.download, color: iconColor), + onPressed: () { + try { + download(widget.episode.enclosureUrl.toString(), + podcastController.externalDir?.path ?? ""); + } catch (e) { + print("Error - ${e.toString()}"); + setState(() { + status = DownloadStatus.download; + }); + } + }, + ); + } else { + return Icon( + Icons.check, + color: iconColor, + ); + } + } + + void _cancelDownload() async { + if (taskId != null) { + setState(() { + status = DownloadStatus.cancelDownload; + }); + await _cancelAndRemove(taskId); + setState(() { + taskId = null; + status = DownloadStatus.download; + }); + } + } + + Future _cancelAndRemove(String? taskId) async { + if (taskId != null) { + await FlutterDownloader.cancel(taskId: taskId); + await FlutterDownloader.remove(taskId: taskId, shouldDeleteContent: true); + } + } + + void download(String url, String savePath) async { + downloadProgress.value = 0; + setState(() { + status = DownloadStatus.downloading; + }); + taskId = await FlutterDownloader.enqueue( + url: url, + savedDir: savePath, + fileName: podcastController.decodeAudioName(widget.episode.enclosureUrl!, + episodeId: widget.episode.id.toString(), + isAudio: widget.episode.isAudio), + showNotification: true, + openFileFromNotification: true, + ); + } +} diff --git a/lib/src/screens/podcast/widgets/podcast_player_widgets/podcast_player_intercation_icon_button.dart b/lib/src/screens/podcast/widgets/podcast_player_widgets/podcast_player_intercation_icon_button.dart new file mode 100644 index 00000000..c955fd03 --- /dev/null +++ b/lib/src/screens/podcast/widgets/podcast_player_widgets/podcast_player_intercation_icon_button.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +class PodcastPlayerInteractionIconButton extends StatelessWidget { + const PodcastPlayerInteractionIconButton( + {Key? key, + required this.icon, + required this.onPressed, + required this.color, + this.size, + this.horizontalPadding = 10}) + : super(key: key); + + final IconData icon; + final VoidCallback onPressed; + final Color color; + final double? size; + final double horizontalPadding; + @override + Widget build(BuildContext context) { + return IconButton( + padding: EdgeInsets.symmetric(horizontal: horizontalPadding ), + splashRadius: 18, + onPressed: onPressed, + icon: Icon( + icon, + color: color, + size: size, + ), + ); + } +} diff --git a/lib/src/screens/podcast/widgets/podcast_player_widgets/podcast_player_slider.dart b/lib/src/screens/podcast/widgets/podcast_player_widgets/podcast_player_slider.dart new file mode 100644 index 00000000..be7118b0 --- /dev/null +++ b/lib/src/screens/podcast/widgets/podcast_player_widgets/podcast_player_slider.dart @@ -0,0 +1,110 @@ +import 'dart:developer'; + +import 'package:acela/src/models/podcast/podcast_episodes.dart'; +import 'package:acela/src/screens/podcast/controller/podcast_chapters_controller.dart'; +import 'package:acela/src/screens/podcast/controller/podcast_controller.dart'; +import 'package:acela/src/screens/podcast/widgets/audio_player/action_tools.dart'; +import 'package:acela/src/screens/podcast/widgets/audio_player/audio_player_core_controls.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; + +class PodcastPlayerSlider extends StatelessWidget { + const PodcastPlayerSlider( + {Key? key, + required this.chapterController, + required this.audioPlayerHandler, + required this.currentPodcastEpisodeDuration, + required this.episode, + required this.positionDataStream}) + : super(key: key); + + final PodcastChapterController chapterController; + final AudioPlayerHandler audioPlayerHandler; + final int? currentPodcastEpisodeDuration; + final Stream positionDataStream; + final PodcastEpisode episode; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: StreamBuilder( + stream: positionDataStream, + builder: (context, snapshot) { + final positionData = snapshot.data ?? + PositionData(Duration.zero, Duration.zero, Duration.zero); + // writeCurrentDurationLocal(context, positionData.position.inSeconds); + var duration = currentPodcastEpisodeDuration?.toDouble() ?? 0.0; + var pending = duration - positionData.position.inSeconds; + var pendingText = formatDuration(pending.toInt()); + var leadingText = formatDuration(positionData.position.inSeconds); + chapterController.setDurationData(positionData); + chapterController.syncChapters(); + return Column( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + SeekBar( + duration: positionData.duration, + position: positionData.position, + onChanged: _onSlideChange, + onChangeEnd: (newPosition) { + audioPlayerHandler.seek(newPosition); + }, + ), + Positioned( + bottom: -8, + left: 0, + right: 0, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 22.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + leadingText, + style: TextStyle(fontSize: 12), + ), + Text(pendingText, style: TextStyle(fontSize: 12)), + ], + ), + )) + ], + ), + ], + ); + }, + ), + ); + } + + static String formatDuration(int seconds) { + Duration duration = Duration(seconds: seconds); + + if (duration.inHours < 1) { + return '${(duration.inMinutes % 60).toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}'; + } else { + return '${duration.inHours}:${(duration.inMinutes % 60).toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}'; + } + } + + void writeCurrentDurationLocal(BuildContext context, int seconds) { + Future.delayed(Duration(seconds: 10)).then((value) { + if (seconds > 0) { + log('writed'); + context.read().writeDurationOfEpisode( + episode.id!, episode.enclosureUrl!, seconds); + } + }); + } + + void _onSlideChange(Duration newPosition) { + chapterController.syncChapters( + isInteracted: true, + isReduced: newPosition.inSeconds < chapterController.currentDuration); + chapterController.currentDuration = newPosition.inSeconds; + audioPlayerHandler.seek(newPosition); + } +} diff --git a/lib/src/screens/podcast/widgets/podcast_player_widgets/progress_bar.dart b/lib/src/screens/podcast/widgets/podcast_player_widgets/progress_bar.dart new file mode 100644 index 00000000..8645695f --- /dev/null +++ b/lib/src/screens/podcast/widgets/podcast_player_widgets/progress_bar.dart @@ -0,0 +1,51 @@ +import 'package:acela/src/screens/podcast/widgets/audio_player/action_tools.dart'; +import 'package:flutter/material.dart'; + +class PodcastProgressBar extends StatefulWidget { + const PodcastProgressBar( + {required this.duration, required this.positionStream}); + + final int? duration; + final Stream positionStream; + + @override + State createState() => _PodcastProgressBarState(); +} + +class _PodcastProgressBarState extends State + with AutomaticKeepAliveClientMixin { + double initialValue = 0; + + @override + Widget build(BuildContext context) { + super.build(context); + final theme = Theme.of(context); + return StreamBuilder( + stream: widget.positionStream, + builder: (context, snapshot) { + final positionData = snapshot.data ?? + PositionData(Duration.zero, Duration.zero, Duration.zero); + return SizedBox( + height: 1.5, + child: LinearProgressIndicator( + color: theme.primaryColorLight, value: value(positionData)), + ); + }, + ); + } + + double value(PositionData positionData) { + double position = (positionData.position.inMilliseconds / + positionData.duration.inMilliseconds); + initialValue = position.isNaN ? initialValue : position; + return position.isNaN + ? initialValue + : (positionData.position.inMilliseconds / + positionData.duration.inMilliseconds) + .clamp(0.0, 1.0) + .toDouble(); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/lib/src/screens/podcast/widgets/podcast_player_widgets/value_for_value_textfield.dart b/lib/src/screens/podcast/widgets/podcast_player_widgets/value_for_value_textfield.dart new file mode 100644 index 00000000..346726fc --- /dev/null +++ b/lib/src/screens/podcast/widgets/podcast_player_widgets/value_for_value_textfield.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +class ValueForValueTextField extends StatelessWidget { + const ValueForValueTextField( + {Key? key, + required this.textEditingController, + required this.hinttext, + this.maxLines, + this.keyboardType}) + : super(key: key); + + final TextEditingController textEditingController; + final String hinttext; + final int? maxLines; + final TextInputType? keyboardType; + + @override + Widget build(BuildContext context) { + return TextField( + controller: textEditingController, + maxLines: maxLines ?? 1, + keyboardType: keyboardType, + decoration: InputDecoration( + fillColor: Colors.grey.shade800, + filled: true, + isDense: true, + hintText: hinttext, + border: border, + enabledBorder: border, + focusedBorder: border, + disabledBorder: border), + ); + } + + InputBorder get border => OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(6), + ), + ); +} diff --git a/lib/src/screens/podcast/widgets/value_for_value_view.dart b/lib/src/screens/podcast/widgets/value_for_value_view.dart new file mode 100644 index 00000000..faf36f4a --- /dev/null +++ b/lib/src/screens/podcast/widgets/value_for_value_view.dart @@ -0,0 +1,70 @@ +import 'package:acela/src/screens/podcast/widgets/podcast_player_widgets/value_for_value_textfield.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class ValueForValueView extends StatefulWidget { + const ValueForValueView({Key? key}) : super(key: key); + + @override + State createState() => _ValueForValueViewState(); +} + +class _ValueForValueViewState extends State { + final TextEditingController sats = TextEditingController(); + final TextEditingController message = TextEditingController(); + + @override + void dispose() { + sats.dispose(); + message.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('⚡️Value For Value⚡️'), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 15), + child: Column(children: [ + ValueForValueTextField( + keyboardType: TextInputType.number, + textEditingController: sats, + hinttext: 'Enter Sats'), + const SizedBox( + height: 15, + ), + ValueForValueTextField( + textEditingController: message, + maxLines: 3, + hinttext: 'Optional public message'), + const SizedBox( + height: 20, + ), + ]), + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () { + Navigator.pop(context); + }, + label: Row( + children: [ + Icon( + CupertinoIcons.gift_fill, + size: 25, + ), + const SizedBox( + width: 10, + ), + Text( + 'Boost it!', + style: TextStyle(fontSize: 16), + ) + ], + ), + ), + ); + } +} diff --git a/lib/src/screens/policy_aggrement/policy_repo/policy_repo.dart b/lib/src/screens/policy_aggrement/policy_repo/policy_repo.dart new file mode 100644 index 00000000..a7258a3a --- /dev/null +++ b/lib/src/screens/policy_aggrement/policy_repo/policy_repo.dart @@ -0,0 +1,15 @@ +import 'package:get_storage/get_storage.dart'; + +class PolicyRepo { + final GetStorage _storage = GetStorage(); + final String _policyKey = "policy"; + + bool isPolicyTermsAccepted() { + String? result = _storage.read(_policyKey); + return result != null && result == "true"; + } + + Future writePolicyStatus(bool status) async { + await _storage.write(_policyKey, status.toString()); + } +} diff --git a/lib/src/screens/policy_aggrement/presentation/policy_aggrement_view.dart b/lib/src/screens/policy_aggrement/presentation/policy_aggrement_view.dart new file mode 100644 index 00000000..74d1e039 --- /dev/null +++ b/lib/src/screens/policy_aggrement/presentation/policy_aggrement_view.dart @@ -0,0 +1,103 @@ +import 'package:acela/src/screens/policy_aggrement/policy_repo/policy_repo.dart'; +import 'package:acela/src/utils/constants.dart'; +import 'package:acela/src/utils/routes/routes.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class PolicyAggrementView extends StatelessWidget { + const PolicyAggrementView({super.key, this.hideButton = false}); + + final bool hideButton; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: ListTile( + contentPadding: EdgeInsets.zero, + title: Text( + "3Speak", + ), + subtitle: const Text("End User License Agreement"), + )), + bottomNavigationBar: !hideButton + ? SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 5, horizontal: kScreenHorizontalPaddingDigit), + child: FilledButton( + onPressed: () { + PolicyRepo().writePolicyStatus(true); + context.pushReplacementNamed(Routes.initialView); + }, + child: Text("Accept and Continue"), + ), + ), + ) + : null, + body: const SingleChildScrollView( + child: Padding( + padding: EdgeInsets.all(20), + child: Text('''## 1. Acknowledgement +This End-User License Agreement („EULA“) is a legal agreement between you and SN AnyDevice Software Solutions. + +This agreement is between You and SN AnyDevice Software Solutions only, and not Apple Inc. („Apple“). SN AnyDevice Software Solutions, not Apple is solely responsible for the 3Speak Mobile App App and their content. Although Apple ist not a party to this agreement, Apple has the right to enforce this agreement against you as a third party beneficiary relating to your use of the 3Speak Mobile App App. + +## 2. Scope of License +SN AnyDevice Software Solutions grants you a limited, non-exclusive, non-transferrable , revocable license to use the 3Speak Mobile App Apps for your personal, non-commercial purposes. You may only use the 3Speak Mobile App Apps on an iPhone, iPod Touch, iPad, or other Apple device that you own or control and as permitted by the Apple App Store Terms of Service. + +## 3. Maintenance and Support +Because the 3Speak Mobile App App is free to download and use, SN AnyDevice Software Solutions does not provide any maintenance or support for them. To the extent that any maintenance or support is required by applicable law, SN AnyDevice Software Solutions, not Apple, shall be obligated to furnish any such maintenance or support. + +## 4. Warranty +The 3Speak Mobile App App is provided for free on an “as is” basis. As such, SN AnyDevice Software Solutions disclaims all warranties about the 3Speak Mobile App App to the fullest extent permitted by law. To the extent any warranty exists under law that cannot be disclaimed, SN AnyDevice Software Solutions, not Apple, shall be solely responsible for such warranty. + +## 5. Product Claims +SN AnyDevice Software Solutions does not make any warranties concerning the 3Speak Mobile App App. To the extent you have any claim arising from or relating to your use of the 3Speak Mobile App, SN AnyDevice Software Solutions, not Apple is responsible for addressing any such claims, which may include, but not limited to: (i) product liability claims; (ii) any claim that the Licensed Application fails to conform to any applicable legal or regulatory requirement; and (iii) claims arising under consumer protection, privacy, or similar legislation, including in connection with the Licensed Application’s use of 3Speak Mobile App and the Hive Blockchain. The EULA may not limit the liability of SN AnyDevice Software Solutions to you what is permitted by applicable law. + +## 6. Intellectual Property Rights +SN AnyDevice Software Solutions shall not be obligated to idemnify or defend you with respect to any third party claim arising out or relating to the 3Speak Mobile App App. To the extent SN AnyDevice Software Solutions is required to provide idemnification by applicable law, SN AnyDevice Software Solutions, not Apple shall be solely responsible for the investigation, defense, settlement and discharge of any claims that the 3Speak Mobile App or your use of it infringes any third party intellectual property right. + +## 7. Legal Compliance +You represent and warrant that (i) you are not located in a country that is subject to a U.S. Government embargo, or that has been designated by the U.S. Government as a “terrorist supporting” country; and (ii) you are not listed on any U.S. Government list of prohibited or restricted parties. + +## 8. Developer Name and Address +Contact +SN AnyDevice Software Solutions +Pune, India +We can be reached via e-mail at 3Speak Mobile snanydevicess@gmail.com + +## 9. Third Party Terms of Agreement +You must comply with applicable third party terms of agreement when using the 3Speak Mobile App (e. g. your wireless data service agreement). Your right to use the 3Speak Mobile App will terminate immediately if you violate any provision of this License Agreement. The party providing your mobile OS (Apple) has no obligation whatsoever to furnish any maintenance and support services with respect to the 3Speak Mobile App. + +## 10. Third Party Beneficiary +Apple and Apple subsidiaries are third party beneficiaries of the EULA, and that, upon your acceptance, such third party beneficiary will have the right (and will be deemed to have accepted right) to enforce the agreement against you. + +## 11. User Generated Contributions +The App may invite you to chat, contribute to, or participate in blogs, message boards, online forums, and other functionality, and may provide you with the opportunity to create, submit, post, display, transmit, perform, publish, distribute, or broadcast content and materials to us or on the App, including but not limited to text, writings, video, audio, photographs, graphics, comments, suggestions, or personal information or other material (collectively, „Contributions“). +Contributions may be viewable by other users of the App and through third-party websites. As such, any Contributions you transmit may be treated as non-confidential and non-proprietary. When you create or make available any Contributions, you thereby represent and warrant that: + +1. the creation, distribution, transmission, public display, or performance, and the accessing, downloading, or copying of your Contributions do not and will not infringe the proprietary rights, including but not limited to the copyright, patent, trademark, trade secret, or moral rights of any third party. +2. you are the creator and owner of or have the necessary licenses, rights, consents, releases, and permissions to use and to authorize us, the App, and other users of the App to use your Contributions in any manner contemplated by the App and these Terms of Use. +3. you have the written consent, release, and/or permission of each and every identifiable individual person in your Contributions to use the name or likeness of each and every such identifiable individual person to enable inclusion and use of your Contributions in any manner contemplated by the App and these Terms of Use. +4. your Contributions are not false, inaccurate, or misleading. +5. your Contributions are not unsolicited or unauthorized advertising, promotional materials, pyramid schemes, chain letters, spam, mass mailings, or other forms of solicitation. +6. your Contributions are not obscene, lewd, lascivious, filthy, violent, harassing, libelous, slanderous, or otherwise objectionable (as determined by us). +7. your Contributions do not ridicule, mock, disparage, intimidate, or abuse anyone. +8. your Contributions do not advocate the violent overthrow of any government or incite, encourage, or threaten physical harm against another. +9. your Contributions do not violate any applicable law, regulation, or rule. +10. your Contributions do not violate the privacy or publicity rights of any third party. +11. your Contributions do not include sexual or pornographic material, defined by Webster’s Dictionary as „explicit descriptions or displays of sexual organs or activities intended to stimulate erotic rather than aesthetic or emotional feelings. +12. your Contributions do not contain any material that solicits personal information from anyone under the age of 18 or exploits people under the age of 18 in a sexual or violent manner. +13. your Contributions do not violate any federal or state law concerning pornography, or otherwise intended to protect the health or well-being of minors +14. your Contributions do not include any offensive comments that are connected to race, national origin, gender, sexual preference, or physical handicap. +15. your Contributions do not otherwise violate, or link to material that violates, any provision of these Terms of Use, or any applicable law or regulation. + +There is no tolerance for objectionable content or abusive users in the App. +Any use of the App in violation of the foregoing violates these Terms of Use and may result in, among other things, termination or suspension of your rights to use the App. +SN AnyDevice Software Solutions addresses all objectionable content within 24 hours.'''), + ), + ), + ); + } +} diff --git a/lib/src/screens/report/controller/report_controller.dart b/lib/src/screens/report/controller/report_controller.dart new file mode 100644 index 00000000..1f70a40e --- /dev/null +++ b/lib/src/screens/report/controller/report_controller.dart @@ -0,0 +1,101 @@ +import 'package:acela/src/models/user_account/action_response.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/report/model/report/report_post.dart'; +import 'package:acela/src/screens/report/model/report_user_model.dart'; +import 'package:acela/src/utils/communicator.dart'; +import 'package:flutter/material.dart'; + +class ReportController extends ChangeNotifier { + ReportController(); + + HiveUserData? userData; + + List reportedPosts = []; + List reportedUsers = []; + + bool shouldRefresh = false; + + void init() async { + if (this.userData != null && this.userData?.accessToken != null) { + List data = await Future.wait([ + Communicator().readReportedPosts(userData!), + Communicator().readReportedUsers(userData!) + ]); + ActionListDataResponse reportPostResponse = + data.first as ActionListDataResponse; + ActionListDataResponse reportUserResponse = + data.elementAt(1) as ActionListDataResponse; + if (reportPostResponse.isSuccess) { + reportedPosts = reportPostResponse.data!; + shouldRefresh = true; + } + if (reportUserResponse.isSuccess) { + reportedUsers = reportUserResponse.data!; + shouldRefresh = true; + } + notifyListeners(); + } + } + + Future reportUser(ReportUserModel report, + {required VoidCallback onSuccess, + required VoidCallback onFailure, + required VoidCallback onLogout, + required Function(String) showToast}) async { + if (userData?.accessToken == null) { + showToast("Session expired! Please log in again"); + onLogout(); + onFailure(); + } else { + ActionSingleDataResponse response = + await Communicator().reportUser(report, userData!); + if (response.isSuccess) { + reportedUsers = [...reportedUsers, report]; + showToast("Report has been submitted successfully"); + onSuccess(); + shouldRefresh = true; + notifyListeners(); + } else { + showToast(response.errorMessage); + onFailure(); + } + } + } + + void updateHiveUserData(HiveUserData newData) { + userData = newData; + reportedPosts.clear(); + reportedUsers.clear(); + init(); + } + + Future reportReply(ReportPostModel report, + {required VoidCallback onSuccess, + required VoidCallback onFailure, + required VoidCallback onLogout, + required Function(String) showToast}) async { + if (userData?.accessToken == null) { + showToast("Session expired! Please log in again"); + onLogout(); + onFailure(); + } else { + ActionSingleDataResponse response = + await Communicator().reportPost(report, userData!); + if (response.isSuccess) { + reportedPosts = [...reportedPosts, report]; + showToast("Report has been submitted successfully"); + onSuccess(); + shouldRefresh = true; + notifyListeners(); + } else { + showToast(response.errorMessage); + onFailure(); + } + } + } + + void turnRefreshOff() { + shouldRefresh = false; + notifyListeners(); + } +} diff --git a/lib/src/screens/report/model/report/error_model.dart b/lib/src/screens/report/model/report/error_model.dart new file mode 100644 index 00000000..c8adddce --- /dev/null +++ b/lib/src/screens/report/model/report/error_model.dart @@ -0,0 +1,22 @@ +import 'dart:convert'; + +class ErrorModel { + final String error; + + ErrorModel({ + required this.error, + }); + + factory ErrorModel.fromJson(Map json) => ErrorModel( + error: json["error"], + ); + + Map toJson() => { + "error": error, + }; + + factory ErrorModel.fromJsonString(String str) => + ErrorModel.fromJson(json.decode(str)); + + String errorModelToJson(ErrorModel data) => json.encode(data.toJson()); +} diff --git a/lib/src/screens/report/model/report/report_post.dart b/lib/src/screens/report/model/report/report_post.dart new file mode 100644 index 00000000..2c44afcf --- /dev/null +++ b/lib/src/screens/report/model/report/report_post.dart @@ -0,0 +1,31 @@ +import 'dart:convert'; + +class ReportPostModel { + final String username; + final String permlink; + final String? reason; + + const ReportPostModel({ + required this.permlink, + this.reason, + required this.username, + }); + + factory ReportPostModel.fromJson(Map json) => + ReportPostModel( + username: json["name"], + permlink: json["permlink"], + ); + + factory ReportPostModel.fromRawJson(String str) => + ReportPostModel.fromJson(json.decode(str)); + + static List fromRawListJson(String str) => + List.from( + json.decode(str).map((x) => ReportPostModel.fromJson(x))); + + Map toJson() => + {'username': username, 'permlink': permlink, "reason": reason}; + + String toRawJson() => json.encode(toJson()); +} diff --git a/lib/src/screens/report/model/report_user_model.dart b/lib/src/screens/report/model/report_user_model.dart new file mode 100644 index 00000000..85ac72a4 --- /dev/null +++ b/lib/src/screens/report/model/report_user_model.dart @@ -0,0 +1,26 @@ +import 'dart:convert'; + +class ReportUserModel { + final String username; + final String? reason; + + const ReportUserModel({ + this.reason, + required this.username, + }); + + factory ReportUserModel.fromJson(Map json) => + ReportUserModel( + username: json["name"], + ); + factory ReportUserModel.fromRawJson(String str) => + ReportUserModel.fromJson(json.decode(str)); + + static List fromRawListJson(String str) => + List.from( + json.decode(str).map((x) => ReportUserModel.fromJson(x))); + + Map toJson() => {'username': username, "reason": reason}; + + String toRawJson() => json.encode(toJson()); +} diff --git a/lib/src/screens/report/widgets/content_dialog_template.dart b/lib/src/screens/report/widgets/content_dialog_template.dart new file mode 100644 index 00000000..358294ca --- /dev/null +++ b/lib/src/screens/report/widgets/content_dialog_template.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +class ContentDialogTemplate extends StatelessWidget { + const ContentDialogTemplate({ + super.key, + required this.title, + required this.content, + this.maxWidth, + }); + + final String title; + final Widget? content; + final double? maxWidth; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AlertDialog( + contentPadding: EdgeInsets.zero, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12))), + title: Row( + children: [ + Expanded( + child: Text( + title, + style: theme.textTheme.titleMedium, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + IconButton( + onPressed: () { + Navigator.pop(context); + }, + icon: const Icon(Icons.cancel)) + ], + ), + content: content); + } +} diff --git a/lib/src/screens/report/widgets/report_pop_up_menu.dart b/lib/src/screens/report/widgets/report_pop_up_menu.dart new file mode 100644 index 00000000..42647645 --- /dev/null +++ b/lib/src/screens/report/widgets/report_pop_up_menu.dart @@ -0,0 +1,89 @@ +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/login/ha_login_screen.dart'; +import 'package:acela/src/screens/report/widgets/report_post_dialog.dart'; +import 'package:acela/src/utils/enum.dart'; +import 'package:adaptive_action_sheet/adaptive_action_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class ReportPopUpMenu extends StatelessWidget { + const ReportPopUpMenu({ + super.key, + required this.type, + required this.author, + this.permlink, + this.iconSize + }); + + final Report type; + final String author; + final String? permlink; + final double? iconSize; + + @override + Widget build(BuildContext context) { + return PopupMenuButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + itemBuilder: (BuildContext context) => >[ + const PopupMenuItem( + value: 'option1', + child: Text( + 'Report', + style: TextStyle(color: Colors.red), + ), + ), + ], + onSelected: (String value) { + var userData = context.read(); + switch (value) { + case 'option1': + if (userData.username == null) { + showAdaptiveActionSheet( + context: context, + title: const Text('You are not logged in. Please log in.'), + androidBorderRadius: 30, + actions: [ + BottomSheetAction( + title: Text('Log in'), + leading: Icon(Icons.login), + onPressed: (c) { + Navigator.of(c).pop(); + var screen = HiveAuthLoginScreen(appData: userData); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(c).push(route); + }), + ], + cancelAction: CancelAction(title: const Text('Cancel')), + ); + } else { + showDialog( + context: context, + builder: (_) => ReportPostDialog( + reportType: type, + rootContext: context, + author: author, + permlink: permlink, + onSuccessRemove: (reportType) { + // if (reportType == Report.reply) { + // context.read().removeReplies( + // widget.item.author, widget.item.permlink); + // } else { + // context + // .read() + // .removeAuthor(widget.item.author); + // } + }, + ), + ); + } + break; + } + }, + child: Icon( + Icons.more_vert, + size: iconSize, + ), + ); + } +} diff --git a/lib/src/screens/report/widgets/report_post_dialog.dart b/lib/src/screens/report/widgets/report_post_dialog.dart new file mode 100644 index 00000000..21e65639 --- /dev/null +++ b/lib/src/screens/report/widgets/report_post_dialog.dart @@ -0,0 +1,147 @@ +import 'package:acela/src/extensions/ui.dart'; +import 'package:acela/src/screens/login/provider/logout_provider.dart'; +import 'package:acela/src/screens/report/controller/report_controller.dart'; +import 'package:acela/src/screens/report/model/report/report_post.dart'; +import 'package:acela/src/screens/report/model/report_user_model.dart'; +import 'package:acela/src/screens/report/widgets/responsive_scroll_dialog.dart'; +import 'package:acela/src/utils/enum.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +class ReportPostDialog extends StatefulWidget { + const ReportPostDialog( + {super.key, + this.title, + required this.reportType, + this.permlink, + required this.author, + required this.rootContext, + required this.onSuccessRemove}) + : assert(!(reportType == Report.post && permlink == null), + "permlink is required to report a reply"); + + final Report reportType; + final String author; + final String? permlink; + final BuildContext rootContext; + final Function(Report reportType) onSuccessRemove; + final String? title; + + @override + State createState() => _ReportPostDialogState(); +} + +class _ReportPostDialogState extends State { + late ThemeData theme; + + @override + void didChangeDependencies() { + theme = Theme.of(context); + super.didChangeDependencies(); + } + + @override + Widget build(BuildContext context) { + return ResponsiveScrollDialog( + title: title, + content: Column( + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 15.0), + child: Text( + "Please select the option that best describes the problem"), + ), + _actionButton( + "Spam", + ), + _actionButton("Abuse"), + _actionButton("Harassment"), + _actionButton("Harmful misinforment"), + _actionButton("Glorifying Violence"), + _actionButton("Exposing Info"), + _actionButton("Cancel", isCancel: true), + ], + ), + ); + } + + String get title { + if (widget.title != null) { + return widget.title!; + } else if (widget.reportType == Report.user) { + return "Report user"; + } else { + return "Report message"; + } + } + + Widget _actionButton(String text, {bool isCancel = false}) { + return SizedBox( + width: double.infinity, + child: TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 10), + side: BorderSide( + color: theme.primaryColorDark.withOpacity(0.3), width: 0.7), + backgroundColor: theme.colorScheme.tertiaryContainer.withOpacity(0.4), + ), + onPressed: () { + Navigator.pop(context); + if (!isCancel) { + widget.rootContext.showLoader(); + if (widget.reportType == Report.post) { + _reportReply(text); + } else { + _reportUser(text); + } + } + }, + child: Text( + text, + style: theme.textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ), + ); + } + + void _reportReply(String text) { + context.read().reportReply( + ReportPostModel( + permlink: widget.permlink!, reason: text, username: widget.author), + onSuccess: _onSuccess, + onFailure: _onFailure, + onLogout: _onLogout, + showToast: (message) { + widget.rootContext.showSnackBar(message); + }, + ); + } + + void _onLogout() { + LogoutProvider().call(); + } + + void _reportUser(String text) { + context.read().reportUser( + ReportUserModel(reason: text, username: widget.author), + onSuccess: _onSuccess, + onFailure: _onFailure, + onLogout: _onLogout, + showToast: (message) { + widget.rootContext.showSnackBar(message); + }, + ); + } + + void _onFailure() { + widget.rootContext.hideLoader(); + } + + void _onSuccess() { + widget.rootContext.hideLoader(); + + widget.onSuccessRemove(widget.reportType); + } +} diff --git a/lib/src/screens/report/widgets/responsive_scroll_dialog.dart b/lib/src/screens/report/widgets/responsive_scroll_dialog.dart new file mode 100644 index 00000000..661e6eb4 --- /dev/null +++ b/lib/src/screens/report/widgets/responsive_scroll_dialog.dart @@ -0,0 +1,33 @@ +import 'package:acela/src/screens/report/widgets/content_dialog_template.dart'; +import 'package:flutter/material.dart'; + +class ResponsiveScrollDialog extends StatelessWidget { + const ResponsiveScrollDialog( + {super.key, + required this.title, + required this.content, + this.maxWidth, + this.width}); + + final String title; + final Widget? content; + final double? maxWidth; + final double? width; + + @override + Widget build(BuildContext context) { + final screenHeight = MediaQuery.of(context).size.height; + return ContentDialogTemplate( + title: title, + content: ConstrainedBox( + constraints: + BoxConstraints(maxWidth: maxWidth ?? 400, maxHeight: screenHeight), + child: SizedBox( + width: width ?? 30, + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), child: content), + ), + ), + ); + } +} diff --git a/lib/src/screens/search/search_screen.dart b/lib/src/screens/search/search_screen.dart new file mode 100644 index 00000000..e238fb64 --- /dev/null +++ b/lib/src/screens/search/search_screen.dart @@ -0,0 +1,124 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/home_screen/home_screen_feed_item/widgets/new_feed_list_item.dart'; +import 'package:acela/src/utils/graphql/gql_communicator.dart'; +import 'package:acela/src/utils/graphql/models/trending_feed_response.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class SearchScreen extends StatefulWidget { + const SearchScreen({Key? key}) : super(key: key); + + @override + State createState() => _SearchScreenState(); +} + +class _SearchScreenState extends State { + var text = ''; + late TextEditingController _controller; + Timer? _timer; + List results = []; + var loading = false; + + Future search(String term, HiveUserData appData) async { + setState(() { + loading = true; + }); + var searchResponse = + await GQLCommunicator().getSearchFeed(term, false, 0, appData.language); + setState(() { + loading = false; + results = searchResponse; + }); + } + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + PreferredSizeWidget _appBar(HiveUserData appData) { + return AppBar( + title: TextField( + controller: _controller, + onChanged: (value) { + var timer = Timer(const Duration(seconds: 1), () { + log('Text changed to $value'); + if (value.trim().length > 3) { + search(value.trim(), appData); + } + }); + setState(() { + _timer?.cancel(); + _timer = timer; + }); + }, + ), + ); + } + + Widget _searchResults(HiveUserData appData) { + return ListView.separated( + itemBuilder: (c, i) { + var item = results[i]; + return NewFeedListItem( + thumbUrl: item.spkvideo?.thumbnailUrl ?? '', + author: item.author?.username ?? '', + title: item.title ?? '', + createdAt: item.createdAt ?? DateTime.now(), + duration: item.spkvideo?.duration ?? 0.0, + comments: item.stats?.numComments, + hiveRewards: item.stats?.totalHiveReward, + votes: item.stats?.numVotes, + views: 0, + permlink: item.permlink ?? '', + onTap: () {}, + onUserTap: () {}, + item: item, + appData: appData, + ); + }, + separatorBuilder: (c, i) => const Divider(), + itemCount: results.length, + ); + } + + Widget _searchResultListView(HiveUserData appData) { + if (results.isEmpty && !loading) { + return Center( + child: Text('No search result found'), + ); + } else if (loading) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center(child: CircularProgressIndicator()), + const SizedBox( + height: 10, + ), + Center(child: Text('Loading search results....')), + ], + ); + } + return _searchResults(appData); + } + + @override + Widget build(BuildContext context) { + var appData = Provider.of(context); + return Scaffold( + appBar: _appBar(appData), + body: _searchResultListView(appData), + ); + } +} diff --git a/lib/src/screens/settings/add_cutom_union_indexer.dart b/lib/src/screens/settings/add_cutom_union_indexer.dart new file mode 100644 index 00000000..5bbaba6b --- /dev/null +++ b/lib/src/screens/settings/add_cutom_union_indexer.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +class AddNodeByUrl extends StatefulWidget { + const AddNodeByUrl({Key? key, required this.onAdd, required this.title}) + : super(key: key); + final Function(String) onAdd; + + final String title; + + @override + State createState() => _AddRssPodcastState(); +} + +class _AddRssPodcastState extends State { + final TextEditingController textEditingController = TextEditingController(); + + @override + void dispose() { + textEditingController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Add ${widget.title}'), + ), + body: Padding( + padding: const EdgeInsets.symmetric(vertical: 25, horizontal: 15.0), + child: _body(), + ), + ); + } + + Column _body() { + return Column(mainAxisAlignment: MainAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.only(top: 60.0, bottom: 30), + child: Icon( + Icons.new_label, + size: 60, + ), + ), + Text( + "Add ${widget.title} node by Url", + style: TextStyle(fontSize: 18), + textAlign: TextAlign.center, + ), + const SizedBox( + height: 8, + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 25), + child: TextField( + controller: textEditingController, + decoration: InputDecoration( + fillColor: Colors.grey.shade800, + filled: true, + hintText: "Enter URL", + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none), + ), + ), + SizedBox( + width: 125, + child: TextButton( + style: TextButton.styleFrom(backgroundColor: Colors.blue), + onPressed: onAdd, + child: Text( + "Save", + style: const TextStyle(color: Colors.white), + ), + ), + ) + ]); + } + + void onAdd() async { + if (textEditingController.text.trim().isNotEmpty) { + widget.onAdd(textEditingController.text.trim()); + } + } +} diff --git a/lib/src/screens/settings/settings_screen.dart b/lib/src/screens/settings/settings_screen.dart new file mode 100644 index 00000000..0d3c6f6c --- /dev/null +++ b/lib/src/screens/settings/settings_screen.dart @@ -0,0 +1,727 @@ +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/global_provider/image_resolution_provider.dart'; +import 'package:acela/src/global_provider/ipfs_node_provider.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/my_account/account_settings/widgets/delete_dialog.dart'; +import 'package:acela/src/screens/policy_aggrement/presentation/policy_aggrement_view.dart'; +import 'package:acela/src/screens/settings/add_cutom_union_indexer.dart'; +import 'package:acela/src/screens/settings/video_encoding_quality_picker_view.dart'; +import 'package:acela/src/utils/communicator.dart'; +import 'package:acela/src/utils/graphql/gql_communicator.dart'; +import 'package:adaptive_action_sheet/adaptive_action_sheet.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:provider/provider.dart'; +import 'package:upgrader/upgrader.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class VideoLanguage { + String name; + String code; + + VideoLanguage({ + required this.name, + required this.code, + }); +} + +class SettingsScreen extends StatefulWidget { + const SettingsScreen({Key? key, this.isUserFromUserSettings = false}) + : super(key: key); + + final bool isUserFromUserSettings; + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + var res = '480p'; + var languages = [ + VideoLanguage(code: "en", name: "English"), + VideoLanguage(code: "de", name: "Deutsch"), + VideoLanguage(code: "pt", name: "Portuguese"), + VideoLanguage(code: "fr", name: "Français"), + VideoLanguage(code: "es", name: "Español"), + VideoLanguage(code: "nl", name: "Nederlands"), + VideoLanguage(code: "ko", name: "한국어"), + VideoLanguage(code: "ru", name: "русский"), + VideoLanguage(code: "hu", name: "Magyar"), + VideoLanguage(code: "ro", name: "Română"), + VideoLanguage(code: "cs", name: "čeština"), + VideoLanguage(code: "pl", name: "Polskie"), + VideoLanguage(code: "in", name: "bahasa Indonesia"), + VideoLanguage(code: "bn", name: "বাংলা"), + VideoLanguage(code: "it", name: "Italian"), + VideoLanguage(code: "he", name: "עִברִית"), + VideoLanguage(code: "all", name: "All"), + ]; + bool isLoading = false; + + @override + void initState() { + super.initState(); + loadRes(); + } + + void loadRes() async { + const storage = FlutterSecureStorage(); + var newRes = await storage.read(key: 'resolution') ?? '480p'; + setState(() { + res = newRes; + }); + } + + Widget _divider() { + return const Divider( + height: 1, + color: Colors.blueGrey, + ); + } + + BottomSheetAction getAction(String optionName, HiveUserData appData) { + return BottomSheetAction( + title: Text(optionName), + onPressed: (context) async { + Navigator.of(context).pop(); + const storage = FlutterSecureStorage(); + await storage.write(key: 'resolution', value: optionName); + String? username = await storage.read(key: 'username'); + String? postingKey = await storage.read(key: 'postingKey'); + String? hasId = await storage.read(key: 'hasId'); + String? hasExpiry = await storage.read(key: 'hasExpiry'); + String? hasAuthKey = await storage.read(key: 'hasAuthKey'); + String? cookie = await storage.read(key: 'cookie'); + String? accessToken = await storage.read(key: 'accessToken'); + String? postingAuth = await storage.read(key: 'postingAuth'); + String rpc = await storage.read(key: 'rpc') ?? 'api.hive.blog'; + String union = await storage.read(key: 'union') ?? + GQLCommunicator.defaultGQLServer; + String? lang = await storage.read(key: 'lang'); + server.updateHiveUserData( + HiveUserData( + username: username, + postingKey: postingKey, + cookie: cookie, + accessToken: accessToken, + postingAuthority: postingAuth, + resolution: optionName, + rpc: rpc, + union: union, + loaded: true, + language: lang, + keychainData: hasId != null && + hasId.isNotEmpty && + hasExpiry != null && + hasExpiry.isNotEmpty && + hasAuthKey != null && + hasAuthKey.isNotEmpty + ? HiveKeychainData( + hasAuthKey: hasAuthKey, + hasExpiry: hasExpiry, + hasId: hasId, + ) + : null, + ), + ); + loadRes(); + }, + ); + } + + void tappedVideoRes(HiveUserData appData) { + showAdaptiveActionSheet( + context: context, + title: const Text('Set Default video resolution to'), + androidBorderRadius: 30, + actions: [ + getAction('480p', appData), + getAction('720p', appData), + getAction('1080p', appData), + ], + cancelAction: CancelAction(title: const Text('Cancel')), + ); + } + + Widget _changeTheme(BuildContext context) { + var isDarkMode = Provider.of(context); + return ListTile( + leading: !isDarkMode + ? const Icon(Icons.wb_sunny) + : const Icon(Icons.mode_night), + title: const Text("Change Theme"), + onTap: () async { + server.changeTheme(isDarkMode); + }, + ); + } + + BottomSheetAction getLangAction( + VideoLanguage language, + HiveUserData appData, + ) { + return BottomSheetAction( + title: Text(language.name), + onPressed: (context) async { + Navigator.of(context).pop(); + const storage = FlutterSecureStorage(); + if (language.code == 'all') { + await storage.delete(key: 'lang'); + await storage.delete(key: 'lang_display'); + } else { + await storage.write(key: 'lang', value: language.code); + await storage.write(key: 'lang_display', value: language.name); + } + server.updateHiveUserData( + HiveUserData( + username: appData.username, + postingKey: appData.postingKey, + cookie: appData.cookie, + accessToken: appData.accessToken, + resolution: appData.resolution, + rpc: appData.rpc, + postingAuthority: appData.postingAuthority.toString(), + union: appData.union, + loaded: true, + language: language.code == 'all' ? null : language.code, + keychainData: appData.keychainData, + ), + ); + }, + ); + } + + void tappedLanguage(HiveUserData appData) { + showAdaptiveActionSheet( + context: context, + title: const Text('Set Default Language Filter'), + androidBorderRadius: 30, + actions: languages.map((e) => getLangAction(e, appData)).toList(), + cancelAction: CancelAction(title: const Text('Cancel')), + ); + } + + Widget _appVersion(BuildContext context) { + final String? appCurrentVersion = + Upgrader.sharedInstance.currentInstalledVersion; + final String? newAvailableVersion = + Upgrader.sharedInstance.currentAppStoreVersion; + return ListTile( + leading: const Icon(Icons.app_settings_alt_sharp), + title: Text("Current Version $appCurrentVersion"), + subtitle: Text("Latest Version $newAvailableVersion"), + trailing: Visibility( + visible: appCurrentVersion != newAvailableVersion, + child: TextButton( + onPressed: () async { + String url = ""; + if (defaultTargetPlatform == TargetPlatform.android) { + url = + "https://play.google.com/store/apps/details?id=tv.threespeak.app"; + } else if (defaultTargetPlatform == TargetPlatform.iOS) { + url = "https://apps.apple.com/us/app/3speak/id1614771373"; + } + if (await canLaunchUrl(Uri.parse(url))) { + launchUrl(Uri.parse(url)); + } + }, + child: Text("Update"), + ), + ), + ); + } + + Future logout(HiveUserData data) async { + // Create storage + const storage = FlutterSecureStorage(); + await storage.delete(key: 'username'); + await storage.delete(key: 'postingKey'); + await storage.delete(key: 'accessToken'); + await storage.delete(key: 'postingAuth'); + await storage.delete(key: 'cookie'); + await storage.delete(key: 'hasId'); + await storage.delete(key: 'hasExpiry'); + await storage.delete(key: 'hasAuthKey'); + String resolution = await storage.read(key: 'resolution') ?? '480p'; + String rpc = await storage.read(key: 'rpc') ?? 'api.hive.blog'; + String union = + await storage.read(key: 'union') ?? GQLCommunicator.defaultGQLServer; + String? lang = await storage.read(key: 'lang'); + var newUserData = HiveUserData( + username: null, + postingKey: null, + keychainData: null, + cookie: null, + accessToken: null, + postingAuthority: null, + resolution: resolution, + rpc: rpc, + union: union, + loaded: true, + language: lang, + ); + server.updateHiveUserData(newUserData); + if (widget.isUserFromUserSettings) { + Navigator.of(context).pop(); + } + Navigator.of(context).pop(); + } + + Widget _deleteAccount() { + var data = context.read(); + return Visibility( + visible: data.username != null, + child: ListTile( + leading: const Icon( + Icons.delete, + color: Colors.red, + ), + title: const Text( + 'Delete Account', + style: TextStyle(color: Colors.red), + ), + onTap: () { + _deleteDialog(data); + }, + ), + ); + } + + void _deleteDialog(HiveUserData data) { + showDialog( + barrierDismissible: true, + useRootNavigator: true, + context: context, + builder: (context) { + return DeleteDialog( + onDelete: () async { + Navigator.pop(context); + try { + setState(() { + isLoading = true; + }); + bool status = await Communicator().deleteAccount(data); + if (status) { + await logout(data); + showMessage('Account Deleted Successfully'); + } else { + showError("Sorry, Something went wrong."); + } + setState(() { + isLoading = false; + }); + } catch (e) { + setState(() { + isLoading = false; + }); + showError("Sorry, Something went wrong."); + } + }, + ); + }, + ); + } + + Widget _changeLanguage(BuildContext context) { + var data = Provider.of(context); + var display = + languages.where((e) => e.code == data.language).firstOrNull?.name ?? + 'All Languages'; + return ListTile( + leading: const Icon(Icons.language), + title: const Text("Set Language Filter"), + trailing: Text(display), + onTap: () { + tappedLanguage(data); + }, + ); + } + + Widget _video(BuildContext context) { + var data = Provider.of(context); + return ListTile( + leading: const Icon(Icons.video_collection), + title: const Text("Video Resolution"), + subtitle: Text(res), + trailing: Icon(Icons.arrow_drop_down), + onTap: () async { + tappedVideoRes(data); + }, + ); + } + + Widget _image(BuildContext context) { + return Selector( + selector: (_, myType) => myType.resolution, + builder: (context, value, child) { + return ListTile( + leading: const Icon(Icons.image), + title: const Text("Image Resolution"), + subtitle: Text(value), + trailing: Icon(Icons.arrow_drop_down), + onTap: () async { + tappedImageRes(); + }, + ); + }, + ); + } + + Widget _autoPlayVideo(BuildContext context) { + var settingsProvider = context.read(); + return Selector( + selector: (_, myType) => myType.autoPlayVideo, + builder: (context, value, child) { + return ListTile( + contentPadding: EdgeInsets.only(left: 15, right: 10), + leading: const Icon(Icons.auto_mode_rounded), + title: const Text("Auto Play Video"), + trailing: Transform.scale( + scale: 0.7, + child: Switch( + value: value, + onChanged: (newVal) { + settingsProvider.autoPlayVideo = newVal; + }, + ), + ), + onTap: () async { + settingsProvider.autoPlayVideo = !settingsProvider.autoPlayVideo; + }, + ); + }, + ); + } + + void tappedImageRes() { + showAdaptiveActionSheet( + context: context, + title: const Text('Set Default video Image resolution to'), + androidBorderRadius: 30, + actions: [ + getImageResolutionAction(Resolution.r360), + getImageResolutionAction(Resolution.r480), + getImageResolutionAction(Resolution.r720), + getImageResolutionAction(Resolution.r1080), + ], + cancelAction: CancelAction(title: const Text('Cancel')), + ); + } + + BottomSheetAction getImageResolutionAction( + String resolution, + ) { + return BottomSheetAction( + title: Text(resolution), + onPressed: (context) async { + Navigator.of(context).pop(); + context.read().resolution = resolution; + }, + ); + } + + BottomSheetAction getActionForRpc(String serverUrl, HiveUserData user) { + return BottomSheetAction( + title: Text(serverUrl), + onPressed: (context) async { + Navigator.of(context).pop(); + const storage = FlutterSecureStorage(); + await storage.write(key: 'rpc', value: serverUrl); + server.updateHiveUserData( + HiveUserData( + username: user.username, + postingKey: user.postingKey, + keychainData: user.keychainData, + cookie: user.cookie, + accessToken: user.accessToken, + postingAuthority: user.postingAuthority.toString(), + resolution: user.resolution, + union: user.union, + rpc: serverUrl, + loaded: true, + language: user.language, + ), + ); + }, + ); + } + + void showBottomSheetForServer(HiveUserData user) { + var list = [ + 'api.hive.blog', + 'api.hive.blog', + 'api.deathwing.me', + 'hive-api.arcange.eu', + 'hived.emre.sh', + 'api.openhive.network', + 'rpc.ausbit.dev', + 'anyx.io', + 'techcoderx.com', + 'api.hive.blue', + 'api.pharesim.me', + 'hived.privex.io', + 'hive.roelandp.nl', + ].map((e) => getActionForRpc(e, user)).toList(); + showAdaptiveActionSheet( + context: context, + title: const Text('Select Hive API Node (RPC)'), + androidBorderRadius: 30, + actions: list, + cancelAction: CancelAction( + title: const Text( + 'Cancel', + style: TextStyle(color: Colors.deepOrange), + ), + ), + ); + } + + Widget _rpc(BuildContext context, HiveUserData user) { + return ListTile( + leading: const Icon(Icons.cloud), + title: const Text("Hive API Node (RPC)"), + subtitle: Text(user.rpc), + trailing: Icon(Icons.arrow_drop_down), + onTap: () { + showBottomSheetForServer(user); + }, + ); + } + + BottomSheetAction getActionForUnionIndexer( + String serverUrl, HiveUserData user) { + return BottomSheetAction( + title: Text(serverUrl), + onPressed: (context) async { + Navigator.of(context).pop(); + await _saveUnionIndexer(serverUrl, user); + }, + ); + } + + Future _saveUnionIndexer(String serverUrl, HiveUserData user) async { + const storage = FlutterSecureStorage(); + await storage.write(key: 'union', value: serverUrl); + server.updateHiveUserData( + HiveUserData( + username: user.username, + postingKey: user.postingKey, + cookie: user.cookie, + keychainData: user.keychainData, + accessToken: user.accessToken, + postingAuthority: user.postingAuthority.toString(), + resolution: user.resolution, + union: serverUrl, + rpc: user.rpc, + loaded: true, + language: user.language, + ), + ); + } + + void showBottomSheetForUnionIndexer(HiveUserData user) { + List nodes = []; + nodes.add(GQLCommunicator.defaultGQLServer); + if (user.union != GQLCommunicator.defaultGQLServer) { + nodes.add(user.union); + } + var list = nodes.map((e) => getActionForUnionIndexer(e, user)).toList(); + list.add(BottomSheetAction( + title: Text('Custom'), + onPressed: (context) async { + Navigator.pop(context); + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => AddNodeByUrl( + title: 'union indexer', + onAdd: (serverUrl) async { + Navigator.pop(context); + await _saveUnionIndexer(serverUrl, user); + }, + ), + ), + ); + }, + )); + showAdaptiveActionSheet( + context: context, + title: const Text('Select Union Indexer API Node'), + androidBorderRadius: 30, + actions: list, + cancelAction: CancelAction( + title: const Text( + 'Cancel', + style: TextStyle(color: Colors.deepOrange), + ), + ), + ); + } + + Widget _unionIndexer(BuildContext context, HiveUserData user) { + return ListTile( + leading: const Icon(Icons.computer), + title: const Text("Union Indexer API Node"), + subtitle: Text(user.union), + trailing: Icon(Icons.arrow_drop_down), + onTap: () { + showBottomSheetForUnionIndexer(user); + }, + ); + } + + Widget _ipfsNode() { + return ListTile( + leading: const Icon(Icons.view_in_ar), + title: const Text("IPFS Node"), + subtitle: Text(IpfsNodeProvider().nodeUrl), + trailing: Icon(Icons.arrow_drop_down), + onTap: () { + showIpfsNodeBottomSheet(); + }, + ); + } + + Widget _eula() { + return ListTile( + leading: const Icon(Icons.shield), + title: const Text("View EULA"), + trailing: Icon(Icons.arrow_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PolicyAggrementView( + hideButton: true, + ), + ), + ); + }, + ); + } + + Widget _videoEncoding() { + return ListTile( + leading: const Icon(Icons.video_file), + title: const Text("Video Encoding Resolutions"), + trailing: Icon(Icons.arrow_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoEncodingQualityPickerView(), + ), + ); + }, + ); + } + + void showIpfsNodeBottomSheet() { + List nodes = []; + nodes.add(IpfsNodeProvider().defaultIpfsNode); + if (IpfsNodeProvider().nodeUrl != IpfsNodeProvider().defaultIpfsNode) { + nodes.add(IpfsNodeProvider().nodeUrl); + } + var list = nodes.map((e) => _ipfsBottomSheetAction(e)).toList(); + list.add(BottomSheetAction( + title: Text('Custom'), + onPressed: (context) async { + Navigator.pop(context); + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => AddNodeByUrl( + title: 'IPFS node', + onAdd: (serverUrl) async { + Navigator.pop(context); + setState(() { + IpfsNodeProvider().changeIpfsNode(serverUrl); + }); + }, + ), + ), + ); + }, + )); + showAdaptiveActionSheet( + context: context, + title: const Text('Select IPFS Node'), + androidBorderRadius: 30, + actions: list, + cancelAction: CancelAction( + title: const Text( + 'Cancel', + style: TextStyle(color: Colors.deepOrange), + ), + ), + ); + } + + BottomSheetAction _ipfsBottomSheetAction(String url) { + return BottomSheetAction( + title: Text(url), + onPressed: (context) async { + Navigator.pop(context); + setState(() { + IpfsNodeProvider().changeIpfsNode(url); + }); + }, + ); + } + + void showMessage(String string) { + var snackBar = SnackBar(content: Text(string)); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + void showError(String string) { + var snackBar = SnackBar(content: Text('Error: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + Widget _drawerMenu(BuildContext context, HiveUserData user) { + return ListView( + children: [ + _changeLanguage(context), + _divider(), + _changeTheme(context), + _divider(), + _autoPlayVideo(context), + _divider(), + _video(context), + _divider(), + _image(context), + _divider(), + _videoEncoding(), + _divider(), + _rpc(context, user), + _divider(), + _unionIndexer(context, user), + _divider(), + _ipfsNode(), + _divider(), + _eula(), + _divider(), + + _appVersion(context), + _divider(), + _deleteAccount(), + _divider() + ], + ); + } + + @override + Widget build(BuildContext context) { + var user = Provider.of(context); + return Scaffold( + appBar: AppBar( + title: const Text('Settings'), + ), + body: isLoading + ? Center( + child: CircularProgressIndicator(), + ) + : _drawerMenu(context, user), + ); + } +} diff --git a/lib/src/screens/settings/video_encoding_quality_picker_view.dart b/lib/src/screens/settings/video_encoding_quality_picker_view.dart new file mode 100644 index 00000000..c88ed212 --- /dev/null +++ b/lib/src/screens/settings/video_encoding_quality_picker_view.dart @@ -0,0 +1,65 @@ +import 'package:acela/src/utils/storages/video_storage.dart'; +import 'package:flutter/material.dart'; + +class VideoEncodingQualityPickerView extends StatefulWidget { + @override + _VideoEncodingQualityPickerViewState createState() => + _VideoEncodingQualityPickerViewState(); +} + +class _VideoEncodingQualityPickerViewState + extends State { + final VideoStorage _storage = VideoStorage(); + List _qualities = []; + + @override + void initState() { + _qualities = _storage.readEncodingQualities(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Video Encoding Resolutions')), + body: ListView( + children: [ + CheckboxListTile( + title: Text('480p'), + value: _qualities.contains('480'), + onChanged: (value) {}, + ), + CheckboxListTile( + title: Text('720p'), + value: _qualities.contains('720'), + onChanged: (value) { + _onChanged('720'); + }, + ), + CheckboxListTile( + title: Text('1080p'), + value: _qualities.contains('1080'), + onChanged: (value) { + _onChanged('1080'); + }, + ), + ], + ), + ); + } + + void _onChanged(String quality) { + if (_qualities.contains(quality)) { + _qualities.remove(quality); + } else { + _qualities.add(quality); + } + if (mounted) { + setState(() { + _qualities.toSet().toList(); + _storage.writeVideoEncodingQuality(_qualities); + print(_qualities); + }); + } + } +} diff --git a/lib/src/screens/stories/new_stories_feed.dart b/lib/src/screens/stories/new_stories_feed.dart new file mode 100644 index 00000000..e629f5c5 --- /dev/null +++ b/lib/src/screens/stories/new_stories_feed.dart @@ -0,0 +1,138 @@ +/* +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/models/home_screen_feed_models/home_feed.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/widgets/loading_screen.dart'; +import 'package:acela/src/widgets/story_player.dart'; +import 'package:carousel_slider/carousel_slider.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' show get; +import 'package:provider/provider.dart'; +import 'package:wakelock/wakelock.dart'; + + +class NewStoriesFeedScreen extends StatefulWidget { + const NewStoriesFeedScreen({ + Key? key, + required this.isCTT, + }) : super(key: key); + final bool isCTT; + + @override + State createState() => _NewStoriesFeedScreenState(); +} + +class _NewStoriesFeedScreenState extends State { + CarouselSliderController controller = CarouselSliderController(); + Future>? _future; + + @override + void initState() { + super.initState(); + Wakelock.enable(); + _future = _loadAllFeeds(); + } + + @override + void dispose() { + super.dispose(); + Wakelock.disable(); + } + + Future> _loadAllFeeds() async { + if (widget.isCTT) { + var cttItems = + await _loadFeed("${server.domain}/apiv2/feeds/@spknetwork.chat"); + return cttItems + .where((e) => (e.isShorts == true || e.duration <= 90.0)) + .toList(); + } else { + var homeItems = await _loadFeed("${server.domain}/apiv2/feeds/Home"); + var newItems = await _loadFeed("${server.domain}/apiv2/feeds/new"); + // List newItems = []; + var trendingItems = + await _loadFeed("${server.domain}/apiv2/feeds/trending"); + var firstUploadsItems = + await _loadFeed("${server.domain}/apiv2/feeds/firstUploads"); + return [...homeItems, ...trendingItems, ...newItems, ...firstUploadsItems] + .toSet() + .toList() + .where((e) => ((e.isShorts == true || e.duration <= 90.0) && e.author != "spknetwork.chat")) + .toList(); + } + } + + Future> _loadFeed(String path) async { + var response = await get(Uri.parse(path)); + if (response.statusCode == 200) { + List list = homeFeedItemFromString(response.body); + return list; + } else { + throw 'Status code ${response.statusCode}'; + } + } + + Widget _fullPost(HomeFeedItem item, HiveUserData data) { + return StoryPlayer( + playUrl: item.getVideoUrl(data), + hlsUrl: item.playUrl, + thumbUrl: item.images.thumbnail, + data: data, + item: null, + homeFeedItem: item, + isPortrait: widget.isCTT ? true : false, + didFinish: () { + setState(() { + controller.nextPage(); + }); + }, + ); + } + + Widget carousel(HiveUserData data, List items) { + return Container( + child: CarouselSlider( + CarouselSliderController: controller, + options: CarouselOptions( + height: MediaQuery.of(context).size.height, + enableInfiniteScroll: true, + viewportFraction: 1, + scrollDirection: Axis.vertical, + ), + items: items.map((item) { + return Builder( + builder: (BuildContext context) { + return _fullPost(item, data); + }, + ); + }).toList(), + ), + ); + } + + Widget loadingData() { + return const LoadingScreen( + title: 'Loading Data', + subtitle: 'Please wait', + ); + } + + @override + Widget build(BuildContext context) { + var userData = Provider.of(context); + return FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (snapshot.hasError) { + return const Text('Error loading data. Please try again'); + } else if (snapshot.connectionState == ConnectionState.done) { + return carousel(userData, snapshot.data as List); + } else { + return loadingData(); + } + }, + ); + } +} + + */ diff --git a/lib/src/screens/stories/new_tab_based_stories.dart b/lib/src/screens/stories/new_tab_based_stories.dart new file mode 100644 index 00000000..a59cac36 --- /dev/null +++ b/lib/src/screens/stories/new_tab_based_stories.dart @@ -0,0 +1,192 @@ +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/home_screen/video_upload_sheet.dart'; +import 'package:acela/src/screens/stories/story_feed_list.dart'; +import 'package:flutter/material.dart'; + +class GQLStoriesScreen extends StatefulWidget { + const GQLStoriesScreen({ + Key? key, + required this.appData, + }); + + final HiveUserData appData; + + @override + State createState() => _GQLStoriesScreenState(); +} + +class _GQLStoriesScreenState extends State + with SingleTickerProviderStateMixin { + var isMenuOpen = false; + + List myTabs() { + return widget.appData.username != null + ? [ + // Tab(icon: Icon(Icons.home)), + Tab(icon: Icon(Icons.local_fire_department)), + Tab(icon: Icon(Icons.play_arrow)), + Tab(icon: Icon(Icons.looks_one)), + Tab(icon: Icon(Icons.person)), + Tab( + icon: Image.asset( + 'assets/ctt-logo.png', + width: 30, + height: 30, + ), + ), + ] + : [ + Tab(icon: Icon(Icons.local_fire_department)), + Tab(icon: Icon(Icons.play_arrow)), + Tab(icon: Icon(Icons.looks_one)), + Tab( + icon: Image.asset( + 'assets/ctt-logo.png', + width: 30, + height: 30, + ), + ), + ]; + } + + late TabController _tabController; + var currentIndex = 0; + + @override + void initState() { + super.initState(); + _tabController = TabController( + vsync: this, length: widget.appData.username != null ? 5 : 4); + _tabController.addListener(() { + setState(() { + currentIndex = _tabController.index; + }); + }); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + String getSubtitle() { + if (widget.appData.username != null) { + switch (currentIndex) { + case 0: + return 'Trending feed'; + case 1: + return 'New feed'; + case 2: + return 'First uploads'; + case 3: + return '@${widget.appData.username ?? 'User'}\'s feed'; + case 4: + return 'CTT Chat'; + default: + return 'User\'s feed'; + } + } else { + switch (currentIndex) { + case 0: + return 'Trending feed'; + case 1: + return 'New feed'; + case 2: + return 'First uploads'; + case 3: + return 'CTT Chat'; + default: + return 'User\'s feed'; + } + } + } + + Widget appBarHeader() { + return ListTile( + contentPadding: EdgeInsets.zero, + leading: Image.asset( + 'assets/branding/three_shorts_icon.png', + height: 40, + width: 40, + ), + title: Text( + '3Speak.tv', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + getSubtitle(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leadingWidth: 40, + leading: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: BackButton(), + ), + title: appBarHeader(), + bottom: TabBar( + controller: _tabController, + tabs: myTabs(), + ), + actions: [_postVideoButton(widget.appData)], + ), + body: SafeArea( + child: TabBarView( + controller: _tabController, + children: widget.appData.username != null + ? [ + StoryFeedList( + appData: widget.appData, + feedType: StoryFeedType.trendingFeed), + StoryFeedList( + appData: widget.appData, + feedType: StoryFeedType.newUploads), + StoryFeedList( + appData: widget.appData, + feedType: StoryFeedType.firstUploads), + StoryFeedList( + appData: widget.appData, + feedType: StoryFeedType.userFeed), + StoryFeedList( + appData: widget.appData, feedType: StoryFeedType.cttFeed), + ] + : [ + StoryFeedList( + appData: widget.appData, + feedType: StoryFeedType.trendingFeed), + StoryFeedList( + appData: widget.appData, + feedType: StoryFeedType.newUploads), + StoryFeedList( + appData: widget.appData, + feedType: StoryFeedType.firstUploads), + StoryFeedList( + appData: widget.appData, feedType: StoryFeedType.cttFeed), + ], + ), + ), + ); + } + + Widget _postVideoButton(HiveUserData data) { + return Visibility( + visible: data.username != null, + child: IconButton( + color: Theme.of(context).primaryColorLight, + onPressed: () { + VideoUploadSheet.show(data, context); + }, + icon: Icon(Icons.add), + ), + ); + } +} diff --git a/lib/src/screens/stories/stories_feed.dart b/lib/src/screens/stories/stories_feed.dart new file mode 100644 index 00000000..609da363 --- /dev/null +++ b/lib/src/screens/stories/stories_feed.dart @@ -0,0 +1,134 @@ +/* +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/models/stories/stories_feed_response.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/widgets/loading_screen.dart'; +import 'package:acela/src/widgets/story_player.dart'; +import 'package:carousel_slider/carousel_slider.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart'; +import 'package:provider/provider.dart'; + +class StoriesFeedScreen extends StatefulWidget { + const StoriesFeedScreen({ + Key? key, + required this.type, + required this.height, + required this.fitWidth, + }) : super(key: key); + final String type; + final double height; + final bool fitWidth; + + @override + State createState() => _StoriesFeedScreenState(); +} + +class _StoriesFeedScreenState extends State { + List items = []; + var isLoading = false; + var initialPage = 0; + CarouselSliderController controller = CarouselSliderController(); + bool isFilterMenuOn = false; + + @override + void initState() { + super.initState(); + loadData(0); + } + + void loadData(int length) async { + setState(() { + isLoading = true; + }); + var string = '${server.domain}/api/${widget.type}/more?skip=$length'; + var response = await get(Uri.parse(string)); + if (response.statusCode == 200) { + List list = + StoriesFeedResponseItem().fromJsonString(response.body, widget.type); + setState(() { + isLoading = false; + items = items + + list + .where((element) => + element.duration <= 90 || element.isReel == true) + .toList(); + var permlinks = Set(); + items.retainWhere((x) => permlinks.add(x.permlink)); + if (items.length < 15) { + loadData(length + list.length); + } + }); + } else { + showError('Status code ${response.statusCode}'); + setState(() { + isLoading = false; + items = []; + }); + } + } + + void showError(String string) { + var snackBar = SnackBar(content: Text('Error: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + void showMessage(String string) { + var snackBar = SnackBar(content: Text(string)); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + Widget loadingData() { + return const LoadingScreen( + title: 'Loading Data', + subtitle: 'Please wait', + ); + } + + Widget _fullPost(StoriesFeedResponseItem item, HiveUserData data) { + return StoryPlayer( + playUrl: item.getVideoUrl(data), + hlsUrl: item.playUrl, + thumbUrl: item.thumbnailValue, + data: data, + item: item, + homeFeedItem: null, + isPortrait: false, + didFinish: () { + setState(() { + controller.nextPage(); + }); + }, + ); + } + + Widget carousel(HiveUserData data) { + return SafeArea( + child: Container( + child: CarouselSlider( + CarouselSliderController: controller, + options: CarouselOptions( + height: MediaQuery.of(context).size.height, + enableInfiniteScroll: true, + viewportFraction: 1, + scrollDirection: Axis.vertical, + ), + items: items.map((item) { + return Builder( + builder: (BuildContext context) { + return _fullPost(item, data); + }, + ); + }).toList(), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + var userData = Provider.of(context); + return isLoading ? loadingData() : carousel(userData); + } +} +*/ \ No newline at end of file diff --git a/lib/src/screens/stories/stories_screen.dart b/lib/src/screens/stories/stories_screen.dart new file mode 100644 index 00000000..3585a58b --- /dev/null +++ b/lib/src/screens/stories/stories_screen.dart @@ -0,0 +1,71 @@ +/* +import 'package:acela/src/screens/stories/new_stories_feed.dart'; +import 'package:acela/src/screens/stories/stories_feed.dart'; +import 'package:flutter/material.dart'; + +class StoriesScreen extends StatefulWidget { + const StoriesScreen({Key? key}) : super(key: key); + + @override + State createState() => _StoriesScreenState(); +} + +class _StoriesScreenState extends State { + static List tabs = [ + Tab(child: Image.asset('assets/ctt-logo.png')), + Tab(icon: const Icon(Icons.video_camera_front_outlined)), + ]; + var fitWidth = true; + var cttKey = Key('ctt'); + var feedKey = Key('feed'); + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: tabs.length, + child: Builder( + builder: (context) { + var appBar = AppBar( + centerTitle: true, + title: Row( + children: [ + Image.asset( + "assets/branding/three_shorts_icon.png", + width: 40, + height: 40, + ), + const SizedBox(width: 15), + const Text('3Shorts') + ], + ), + actions: [ + IconButton( + onPressed: () { + setState(() { + cttKey = new Key('ctt+${DateTime.now().toIso8601String()}'); + feedKey = new Key('feed+${DateTime.now().toIso8601String()}'); + }); + }, + icon: const Icon(Icons.refresh), + ), + ], + bottom: TabBar( + tabs: tabs, + ), + ); + return Scaffold( + appBar: appBar, + body: TabBarView( + children: [ + NewStoriesFeedScreen(isCTT: true, key: cttKey), + NewStoriesFeedScreen(isCTT: false, key: feedKey), + ], + ), + ); + }, + ), + ); + } +} + + */ diff --git a/lib/src/screens/stories/story_feed_body.dart b/lib/src/screens/stories/story_feed_body.dart new file mode 100644 index 00000000..997b7c93 --- /dev/null +++ b/lib/src/screens/stories/story_feed_body.dart @@ -0,0 +1,64 @@ + +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/utils/graphql/models/trending_feed_response.dart'; +import 'package:acela/src/widgets/story_player.dart'; +import 'package:carousel_slider/carousel_slider.dart'; +import 'package:flutter/material.dart'; + +class StoryFeedDataBody extends StatefulWidget { + const StoryFeedDataBody( + {Key? key, + required this.items, + required this.appData, + required this.controller, this.onRemoveFavouriteCallback}) + : super(key: key); + + final List items; + final HiveUserData appData; + final CarouselSliderController controller; + final VoidCallback? onRemoveFavouriteCallback; + + @override + State createState() => _StoryFeedDataBodyState(); +} + +class _StoryFeedDataBodyState extends State { + @override + Widget build(BuildContext context) { + return carousel(widget.items, context); + } + + Widget _fullPost(GQLFeedItem item) { + return StoryPlayer( + item: item, + data: widget.appData, + onRemoveFavouriteCallback: widget.onRemoveFavouriteCallback, + didFinish: () { + setState(() { + widget.controller.nextPage(); + }); + }, + ); + } + + Widget carousel(List items, BuildContext context) { + return Container( + child: CarouselSlider( + options: CarouselOptions( + height: MediaQuery.of(context).size.height, + enableInfiniteScroll: true, + viewportFraction: 1, + scrollDirection: Axis.vertical, + ), + carouselController: widget.controller, + items: items.map((item) { + return Builder( + builder: (BuildContext context) { + return _fullPost(item); + }, + ); + }).toList(), + ), + ); + } +} diff --git a/lib/src/screens/stories/story_feed_list.dart b/lib/src/screens/stories/story_feed_list.dart new file mode 100644 index 00000000..84034bc7 --- /dev/null +++ b/lib/src/screens/stories/story_feed_list.dart @@ -0,0 +1,164 @@ +import 'package:acela/src/screens/stories/story_feed_body.dart'; +import 'package:acela/src/utils/graphql/gql_communicator.dart'; +import 'package:acela/src/utils/graphql/models/trending_feed_response.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/widgets/loading_screen.dart'; +import 'package:acela/src/widgets/retry.dart'; +import 'package:carousel_slider/carousel_slider.dart'; +import 'package:flutter/material.dart'; + +enum StoryFeedType { + cttFeed, + userFeed, + trendingFeed, + newUploads, + firstUploads, + userChannelFeed, + community, + trendingTag, +} + +class StoryFeedList extends StatefulWidget { + const StoryFeedList({ + Key? key, + required this.appData, + required this.feedType, + this.username, + this.community, + }); + + final StoryFeedType feedType; + final HiveUserData appData; + final String? username; + final String? community; + + @override + State createState() => _StoryFeedListState(); +} + +class _StoryFeedListState extends State + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + List items = []; + var firstPageLoaded = false; + var isLoading = false; + var hasFailed = false; + CarouselSliderController controller = CarouselSliderController(); + // final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + loadFeed(false); + } + + Future> loadWith(bool firstPage) { + try { + switch (widget.feedType) { + case StoryFeedType.trendingTag: + return GQLCommunicator() + .getTrendingTagFeed(widget.username ?? 'threespeak', true, firstPage ? 0 : items.length, widget.appData.language); + case StoryFeedType.cttFeed: + return GQLCommunicator() + .getCTTFeed(firstPage ? 0 : items.length, widget.appData.language); + case StoryFeedType.trendingFeed: + return GQLCommunicator() + .getTrendingFeed(true, firstPage ? 0 : items.length, widget.appData.language); + case StoryFeedType.newUploads: + return GQLCommunicator() + .getNewUploadsFeed(true, firstPage ? 0 : items.length, widget.appData.language); + case StoryFeedType.firstUploads: + return GQLCommunicator() + .getFirstUploadsFeed(true, firstPage ? 0 : items.length, widget.appData.language); + case StoryFeedType.userFeed: + return GQLCommunicator().getMyFeed( + widget.appData.username ?? 'sagarkothari88', + true, + firstPage ? 0 : items.length, widget.appData.language); + case StoryFeedType.userFeed: + return GQLCommunicator().getMyFeed( + widget.appData.username ?? 'sagarkothari88', + true, + firstPage ? 0 : items.length, widget.appData.language + ); + case StoryFeedType.userChannelFeed: + return GQLCommunicator().getUserFeed([widget.username ?? 'sagarkothari88'], + true, firstPage ? 0 : items.length, widget.appData.language); + case StoryFeedType.community: + return GQLCommunicator().getCommunity(widget.community ?? 'hive-181335', + true, firstPage ? 0 : items.length, widget.appData.language); + } + } catch (e) { + hasFailed = true; + throw e; + } + } + + void loadFeed(bool reset) async { + if (isLoading) return; + if (!firstPageLoaded) { + setState(() { + isLoading = true; + firstPageLoaded = false; + }); + var newItems = await loadWith(true); + setState(() { + items = newItems; + isLoading = false; + firstPageLoaded = true; + }); + } else { + setState(() { + isLoading = true; + if (reset) { + firstPageLoaded = false; + } + }); + var newItems = await loadWith(reset); + setState(() { + items = items + newItems; + isLoading = false; + firstPageLoaded = true; + }); + } + } + + + @override + Widget build(BuildContext context) { + super.build(context); + if (isLoading && !firstPageLoaded) { + return LoadingScreen(title: 'Loading', subtitle: 'Please wait'); + } else if (hasFailed) { + return RetryScreen( + onRetry: () async { + loadFeed(true); + }, + error: 'Something went wrong. Try again.', + ); + } else { + if (items.isEmpty) { + return Center( + child: Column( + children: [ + Spacer(), + Text( + 'We did not find anything to show.\nTap on Reload button to try again.'), + ElevatedButton( + onPressed: () { + loadFeed(true); + }, + child: Text('Reload'), + ), + Spacer(), + ], + ), + ); + } else { + return StoryFeedDataBody(items: items,appData: widget.appData,controller: controller,); + } + } + } +} diff --git a/lib/src/screens/stories/tab_based_stories.dart b/lib/src/screens/stories/tab_based_stories.dart new file mode 100644 index 00000000..3dc6046e --- /dev/null +++ b/lib/src/screens/stories/tab_based_stories.dart @@ -0,0 +1,355 @@ +/* +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/models/video_details_model/video_details.dart'; +import 'package:acela/src/screens/login/ha_login_screen.dart'; +import 'package:acela/src/utils/communicator.dart'; +import 'package:acela/src/widgets/loading_screen.dart'; +import 'package:acela/src/widgets/retry.dart'; +import 'package:acela/src/widgets/story_player.dart'; +import 'package:carousel_slider/carousel_slider.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' show get; +import 'package:wakelock/wakelock.dart'; + +class TabBasedStoriesScreen extends StatefulWidget { + const TabBasedStoriesScreen({ + Key? key, + required this.appData, + }); + + final HiveUserData appData; + + @override + State createState() => _TabBasedStoriesScreenState(); +} + +class _TabBasedStoriesScreenState extends State + with SingleTickerProviderStateMixin { + late Future> loadCtt; + late Future> loadHome; + late Future> loadTrending; + late Future> loadNew; + late Future> loadFirstUploads; + Future>? loadMyFeedVideos; + CarouselSliderController controller = CarouselSliderController(); + + var isMenuOpen = false; + + static List myTabs = [ + Tab( + icon: Image.asset( + 'assets/ctt-logo.png', + width: 20, + height: 20, + ), + ), + Tab(icon: Icon(Icons.person)), + Tab(icon: Icon(Icons.home)), + Tab(icon: Icon(Icons.local_fire_department)), + Tab(icon: Icon(Icons.play_arrow)), + Tab(icon: Icon(Icons.looks_one)), + ]; + + var urls = [ + '${Communicator.tsServer}/mobile/api/feed/user/@spknetwork.chat', + '${Communicator.tsServer}/mobile/api/feed/home?shorts=true', + '${Communicator.tsServer}/mobile/api/feed/trending?shorts=true', + '${Communicator.tsServer}/mobile/api/feed/new?shorts=true', + '${Communicator.tsServer}/mobile/api/feed/first?shorts=true', + ]; + + late TabController _tabController; + var currentIndex = 0; + + @override + void initState() { + super.initState(); + _tabController = TabController(vsync: this, length: myTabs.length); + _tabController.addListener(() { + setState(() { + currentIndex = _tabController.index; + }); + }); + loadCtt = _loadFeed(urls[0]); + loadHome = _loadFeed(urls[1]); + loadTrending = _loadFeed(urls[2]); + loadNew = _loadFeed(urls[3]); + loadFirstUploads = _loadFeed(urls[4]); + if (widget.appData.username != null) { + loadMyFeedVideos = Communicator().loadMyFeedVideos(widget.appData, true); + } + Wakelock.enable(); + } + + Future> _loadFeed(String url) async { + var response = await get(Uri.parse(url)); + if (response.statusCode == 200) { + return videoItemsFromString(response.body); + } else { + throw 'Status code ${response.statusCode}'; + } + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + Wakelock.disable(); + } + + void reloadWithIndex(int index) { + setState(() { + if (index == 0) { + loadCtt = _loadFeed(urls[0]); + } else if (index == 2) { + loadHome = _loadFeed(urls[1]); + } else if (index == 3) { + loadTrending = _loadFeed(urls[2]); + } else if (index == 4) { + loadNew = _loadFeed(urls[3]); + } else if (index == 5) { + loadFirstUploads = _loadFeed(urls[4]); + } + }); + } + + Widget futureBuilderForTrending(int index) { + return FutureBuilder( + future: (index == 0) + ? loadCtt + : (index == 2) + ? loadHome + : (index == 3) + ? loadTrending + : (index == 4) + ? loadNew + : loadFirstUploads, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: RetryScreen( + error: snapshot.error?.toString() ?? 'Something went wrong', + onRetry: () { + reloadWithIndex(index); + }, + ), + ); + } else if (snapshot.connectionState == ConnectionState.done) { + var list = snapshot.data as List; + if (list.isEmpty) { + return noDataFound(false, () { + reloadWithIndex(index); + }); + } + return carousel(list); + } else { + return LoadingScreen( + title: 'Loading Data', + subtitle: 'Please wait...', + ); + } + }, + ); + } + + Widget futureBuilderForMyFeed() { + return FutureBuilder( + future: loadMyFeedVideos, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: RetryScreen( + error: snapshot.error?.toString() ?? 'Something went wrong', + onRetry: () { + setState(() { + if (widget.appData.username != null) { + setState(() { + loadMyFeedVideos = + Communicator().loadMyFeedVideos(widget.appData, true); + }); + } + }); + }, + ), + ); + } else if (snapshot.connectionState == ConnectionState.done) { + var list = snapshot.data as List; + if (list.isEmpty) { + return noDataFound(true, () { + if (widget.appData.username != null) { + setState(() { + loadMyFeedVideos = + Communicator().loadMyFeedVideos(widget.appData, true); + }); + } + }); + } + return carousel(list); + } else { + return LoadingScreen( + title: 'Loading Data', + subtitle: 'Please wait...', + ); + } + }, + ); + } + + String getSubtitle() { + switch (currentIndex) { + case 0: + return '@spknetwork.chat'; + case 1: + return '@${widget.appData.username ?? 'User'}\'s feed - 3Shorts'; + case 2: + return 'Home feed - 3Shorts'; + case 3: + return 'Trending feed - 3Shorts'; + case 4: + return 'New feed - 3Shorts'; + case 5: + return 'First uploads - 3Shorts'; + default: + return 'User\'s feed - 3Shorts'; + } + } + + Widget noDataFound(bool isMyFeed, Function retry) { + return Column( + children: [ + Spacer(), + Icon(Icons.autorenew, size: 60), + SizedBox(height: 20), + Text( + 'We did not find anything to show.\nTap on retry to load again.${isMyFeed ? '\nOR Follow more users' : ''}', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + SizedBox(height: 20), + ElevatedButton( + onPressed: () { + retry(); + }, + child: Text('Retry'), + ), + Spacer(), + ], + ); + } + + Widget appBarHeader() { + return ListTile( + leading: Image.asset( + 'assets/branding/three_shorts_icon.png', + height: 40, + width: 40, + ), + title: Text('3Shorts'), + subtitle: Text(getSubtitle()), + ); + } + + Widget pleaseLogIn() { + return Column( + children: [ + Spacer(), + Icon(Icons.rss_feed, size: 60), + SizedBox(height: 20), + Text( + 'To see 3Shorts\nfrom whom you follow,\nplease login.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + SizedBox(height: 20), + ElevatedButton( + onPressed: () { + var screen = HiveAuthLoginScreen(appData: widget.appData); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + }, + child: Text('Login'), + ), + Spacer(), + ], + ); + } + + Widget _fullPost(VideoDetails item) { + return StoryPlayer( + playUrl: item.videoV2M3U8(widget.appData), + hlsUrl: item.rootVideoV2M3U8(), + thumbUrl: item.getThumbnail(), + data: widget.appData, + owner: item.owner, + permlink: item.permlink, + didFinish: () { + setState(() { + controller.nextPage(); + }); + }, + ); + } + + Widget carousel(List items) { + return Container( + child: CarouselSlider( + CarouselSliderController: controller, + options: CarouselOptions( + height: MediaQuery.of(context).size.height, + enableInfiniteScroll: true, + viewportFraction: 1, + scrollDirection: Axis.vertical, + ), + items: items.map((item) { + return Builder( + builder: (BuildContext context) { + return _fullPost(item); + }, + ); + }).toList(), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (widget.appData.username != null && loadMyFeedVideos == null) { + loadMyFeedVideos = Communicator().loadMyFeedVideos(widget.appData); + } + return Scaffold( + appBar: AppBar( + title: appBarHeader(), + bottom: TabBar( + controller: _tabController, + tabs: myTabs, + isScrollable: true, + ), + actions: widget.appData.username == null + ? [ + IconButton(onPressed: () { + var screen = HiveAuthLoginScreen(appData: widget.appData); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + }, icon: Icon(Icons.person)), + ] + : [], + ), + body: SafeArea( + child: TabBarView( + controller: _tabController, + children: [ + futureBuilderForTrending(0), + widget.appData.username != null + ? futureBuilderForMyFeed() + : pleaseLogIn(), + futureBuilderForTrending(2), + futureBuilderForTrending(3), + futureBuilderForTrending(4), + futureBuilderForTrending(5), + ], + ), + ), + ); + } +} +*/ \ No newline at end of file diff --git a/lib/src/screens/trending_tags/tag_favourite_provider.dart b/lib/src/screens/trending_tags/tag_favourite_provider.dart new file mode 100644 index 00000000..2e960d1a --- /dev/null +++ b/lib/src/screens/trending_tags/tag_favourite_provider.dart @@ -0,0 +1,50 @@ +import 'package:get_storage/get_storage.dart'; + +class TagFavoriteProvider { + final box = GetStorage(); + final String _tagLocalKey = '_tagLocalKey'; + + List getLikedTags() { + final String key = _tagLocalKey; + if (box.read(key) != null) { + List items = box.read(key); + return items; + } else { + return []; + } + } + + //check if the liked podcast single episode is present locally + bool isTagPresentLocally(String tag) { + final String key = _tagLocalKey; + if (box.read(key) != null) { + List json = box.read(key); + int index = json.indexWhere((element) => + element == tag); + return index != -1; + } else { + return false; + } + } + + //sotre the single podcast episode locally if user likes it + void storeLikedTagLocally(String tag,{bool forceRemove = false}) { + final String key = _tagLocalKey; + if (box.read(key) != null) { + List json = box.read(key); + int index = + json.indexWhere((element) => element == tag); + if (index == -1 && !forceRemove) { + json.add(tag); + box.write(key, json); + } else { + json.removeWhere((element) =>element == tag); + box.write(key, json); + } + } else { + box.write(key, [tag]); + } + print(box.read(key)); + } + +} diff --git a/lib/src/screens/trending_tags/trending_tag_videos.dart b/lib/src/screens/trending_tags/trending_tag_videos.dart new file mode 100644 index 00000000..0b581277 --- /dev/null +++ b/lib/src/screens/trending_tags/trending_tag_videos.dart @@ -0,0 +1,98 @@ +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/home_screen/home_screen_feed_list.dart'; +import 'package:acela/src/screens/podcast/widgets/favourite.dart'; +import 'package:acela/src/screens/stories/story_feed_list.dart'; +import 'package:acela/src/screens/trending_tags/tag_favourite_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class TrendingTagVideos extends StatefulWidget { + const TrendingTagVideos({ + Key? key, + required this.tag, + }); + + final String tag; + + @override + State createState() => _TrendingTagVideosState(); +} + +class _TrendingTagVideosState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + var currentIndex = 0; + static List tabs = [ + Tab( + icon: Icon(Icons.video_camera_front_outlined), + ), + Tab( + icon: Image.asset( + 'assets/branding/three_shorts_icon.png', + width: 30, + height: 30, + ), + ), + ]; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: tabs.length, vsync: this); + _tabController.addListener(() { + setState(() { + currentIndex = _tabController.index; + }); + }); + } + + @override + void dispose() { + super.dispose(); + _tabController.dispose(); + } + + @override + Widget build(BuildContext context) { + var appData = Provider.of(context); + var provider = TagFavoriteProvider(); + return Scaffold( + appBar: AppBar( + title: ListTile( + leading: const Icon(Icons.tag), + title: Text(widget.tag), + ), + bottom: TabBar( + controller: _tabController, + tabs: tabs, + ), + actions: [ + FavouriteWidget( + toastType: "Tag", + isLiked: provider.isTagPresentLocally(widget.tag), + onAdd: () { + provider.storeLikedTagLocally(widget.tag); + }, + onRemove: () { + provider.storeLikedTagLocally(widget.tag); + }) + ], + ), + body: TabBarView( + controller: _tabController, + children: [ + HomeScreenFeedList( + appData: appData, + feedType: HomeScreenFeedType.trendingTag, + owner: widget.tag, + ), + StoryFeedList( + appData: appData, + feedType: StoryFeedType.trendingTag, + username: widget.tag, + ), + ], + ), + ); + } +} diff --git a/lib/src/screens/trending_tags/trending_tags.dart b/lib/src/screens/trending_tags/trending_tags.dart new file mode 100644 index 00000000..9b5a2016 --- /dev/null +++ b/lib/src/screens/trending_tags/trending_tags.dart @@ -0,0 +1,105 @@ +import 'package:acela/src/models/trending_tags/trending_tags_response.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/trending_tags/trending_tag_videos.dart'; +import 'package:acela/src/utils/graphql/gql_communicator.dart'; +import 'package:acela/src/widgets/loading_screen.dart'; +import 'package:acela/src/widgets/retry.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class TrendingTagsWidget extends StatefulWidget { + const TrendingTagsWidget({Key? key}); + + @override + State createState() => _TrendingTagsWidgetState(); +} + +class _TrendingTagsWidgetState extends State + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + Future getData() async { + return await GQLCommunicator().getTrendingTags(); + } + + Widget _listTileSubtitle(TrendingTagResponseDataTrendingTag item, int max) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Trending Score: ${item.score}"), + Container( + height: 5, + ), + LinearProgressIndicator( + value: item.score / max, + ) + ], + ); + } + + Widget _listTile(TrendingTagResponseDataTrendingTag item, int max, HiveUserData data) { + return ListTile( + leading: const Icon(Icons.tag), + title: Text(item.tag), + subtitle: _listTileSubtitle(item, max), + onTap: () { + var screen = TrendingTagVideos(tag: item.tag); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + }, + ); + } + + Widget _list(List data, HiveUserData appData) { + return ListView.separated( + itemBuilder: (context, index) { + return _listTile(data[index], data[0].score, appData); + }, + separatorBuilder: (context, index) => const Divider(), + itemCount: data.length, + ); + } + + Widget _body(HiveUserData data) { + return FutureBuilder( + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.hasError) { + return RetryScreen( + error: snapshot.error?.toString() ?? "Something went wrong", + onRetry: getData, + ); + } else if (snapshot.hasData && + snapshot.connectionState == ConnectionState.done) { + return Container( + margin: const EdgeInsets.only(top: 10, bottom: 10), + child: _list((snapshot.data as TrendingTagResponse) + .data + ?.trendingTags + ?.tags ?? + [], data), + ); + } else { + return RetryScreen( + error: "Something went wrong", + onRetry: getData, + ); + } + } else { + return const LoadingScreen( + title: 'Loading Data', + subtitle: 'Please wait', + ); + } + }, + future: getData()); + } + + @override + Widget build(BuildContext context) { + var appData = Provider.of(context); + super.build(context); + return _body(appData); + } +} diff --git a/lib/src/screens/upload/new_video_upload_screen.dart b/lib/src/screens/upload/new_video_upload_screen.dart new file mode 100644 index 00000000..0c8442e1 --- /dev/null +++ b/lib/src/screens/upload/new_video_upload_screen.dart @@ -0,0 +1,465 @@ +import 'dart:async'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/models/video_details_model/video_details.dart'; +import 'package:acela/src/screens/my_account/update_video/video_primary_info.dart'; +import 'package:acela/src/utils/communicator.dart'; +import 'package:acela/src/widgets/custom_circle_avatar.dart'; +import 'package:ffmpeg_kit_flutter_https_gpl/ffprobe_kit.dart'; +import 'package:ffmpeg_kit_flutter_https_gpl/media_information_session.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:images_picker/images_picker.dart'; +import 'package:localstorage/localstorage.dart'; +import 'package:provider/provider.dart'; +import 'package:tus_client/tus_client.dart'; +import 'package:video_compress/video_compress.dart'; +import 'package:video_thumbnail/video_thumbnail.dart'; + +class UploadedItem { + String fileName; + String filePath; + + UploadedItem({required this.fileName, required this.filePath}); + + toJSONEncodable() { + Map m = new Map(); + m['fileName'] = fileName; + m['filePath'] = filePath; + return m; + } +} + +class UploadedItemList { + List items = []; + + toJSONEncodable() { + return items.map((item) { + return item.toJSONEncodable(); + }).toList(); + } +} + +class NewVideoUploadScreen extends StatefulWidget { + const NewVideoUploadScreen({ + Key? key, + required this.camera, + required this.data, + }) : super(key: key); + final bool camera; + final HiveUserData data; + + @override + State createState() => _NewVideoUploadScreenState(); +} + +class _NewVideoUploadScreenState extends State { + var didShowFilePicker = false; + var didPickFile = false; + + // var didCompress = false; + var didUpload = false; + var didTakeDefaultThumbnail = false; + var didUploadThumbnail = false; + var didMoveToQueue = false; + + var timeShowFilePicker = '0.5 seconds'; + var timePickFile = ''; + + // var timeCompress = ''; + var timeUpload = ''; + var timeTakeDefaultThumbnail = ''; + var timeUploadThumbnail = ''; + var timeMoveToQueue = ''; + + var didStartPickFile = false; + + // var didStartCompress = false; + var didStartUpload = false; + var didStartTakeDefaultThumbnail = false; + var didStartUploadThumbnail = false; + var didStartMoveToQueue = false; + + var progress = 0.0; + var thumbnailUploadProgress = 0.0; + var compressionProgress = 0.0; + late Subscription _subscription; + HiveUserData? user; + final ImagePicker _picker = ImagePicker(); + final LocalStorage storage = LocalStorage('uploaded_data'); + final UploadedItemList list = UploadedItemList(); + + @override + void initState() { + super.initState(); + var items = storage.getItem('uploads'); + if (items != null) { + setState(() { + list.items = List.from( + (items as List).map( + (item) => UploadedItem( + fileName: item['fileName'], + filePath: item['filePath'], + ), + ), + ); + }); + } + _subscription = VideoCompress.compressProgress$.subscribe((progress) { + debugPrint('progress: $progress'); + setState(() { + compressionProgress = progress; + }); + }); + Timer.periodic(const Duration(milliseconds: 500), (timer) { + timer.cancel(); + videoPickerFunction(); + }); + } + + @override + void dispose() { + super.dispose(); + _subscription.unsubscribe(); + } + + void _addItem(String fileName, String filePath) { + setState(() { + final item = new UploadedItem(fileName: fileName, filePath: filePath); + list.items.add(item); + _saveToStorage(); + }); + } + + void _saveToStorage() { + storage.setItem('uploads', list.toJSONEncodable()); + } + + Future getImageInfo(String filePath) async { + Image img = Image.file(File(filePath)); + final c = new Completer(); + img.image + .resolve(new ImageConfiguration()) + .addListener(new ImageStreamListener((ImageInfo i, bool _) { + c.complete(i); + })); + return c.future; + } + + void videoPickerFunction() async { + try { + if (user?.username == null) { + throw 'User not logged in'; + } + // Step 1. Select Video + var dateStartGettingVideo = DateTime.now(); + setState(() { + didStartPickFile = true; + didShowFilePicker = true; + }); + + final XFile? file; + file = await _picker.pickVideo( + source: widget.camera ? ImageSource.camera : ImageSource.gallery, + preferredCameraDevice: CameraDevice.front, + ); + if (file != null) { + setState(() { + didPickFile = true; + }); + + var originalFileName = file.name; + var fileToSave = File(file.path); + log(originalFileName); + log("path - ${file.path}"); + var alreadyUploaded = list.items.contains((e) { + return e.fileName == originalFileName || e.filePath == file!.path; + }); + if (alreadyUploaded) { + throw 'This video is already uploaded by you'; + } + var size = await file.length(); + + if (widget.camera) { + await ImagesPicker.saveVideoToAlbum(fileToSave); + } + var dateEndGettingVideo = DateTime.now(); + var diff = dateEndGettingVideo.difference(dateStartGettingVideo); + setState(() { + timePickFile = '${diff.inSeconds} seconds'; + didPickFile = true; + }); + + // Step 3. Video upload + var dateStartUploadVideo = DateTime.now(); + setState(() { + didStartUpload = true; + }); + var fileSize = size; + var sizeInMb = fileSize / 1000 / 1000; + log("Compressed video file size in mb is - $sizeInMb"); + if (sizeInMb > 1024) { + throw 'Video is too big to be uploaded from mobile (exceeding 500 mb)'; + } + var path = file.path; + MediaInformationSession session = + await FFprobeKit.getMediaInformation(path); + var info = session.getMediaInformation(); + var duration = + (double.tryParse(info?.getDuration() ?? "0.0") ?? 0.0).toInt(); + log('Video duration is $duration'); + var name = await initiateUpload(path, false); + var dateEndUploadVideo = DateTime.now(); + diff = dateEndUploadVideo.difference(dateStartUploadVideo); + setState(() { + timeUpload = '${diff.inSeconds} seconds'; + didUpload = true; + }); + // --- Step 3. Video upload + + // Step 4. Generate Thumbnail + var dateStartTakingThumbnail = DateTime.now(); + setState(() { + didStartTakeDefaultThumbnail = true; + }); + var thumbPath = await getThumbnail(path); + var dateEndTakingThumbnail = DateTime.now(); + diff = dateEndTakingThumbnail.difference(dateStartTakingThumbnail); + setState(() { + timeTakeDefaultThumbnail = '${diff.inSeconds} seconds'; + didTakeDefaultThumbnail = true; + }); + + // --- Step 4. Generate Thumbnail + + // Step 5. Upload Thumbnail + var dateStartUploadThumbnail = DateTime.now(); + setState(() { + didStartUploadThumbnail = true; + }); + var thumbName = await initiateUpload(thumbPath, true); + var dateEndUploadThumbnail = DateTime.now(); + diff = dateEndUploadThumbnail.difference(dateStartUploadThumbnail); + setState(() { + timeUploadThumbnail = '${diff.inSeconds} seconds'; + didUploadThumbnail = true; + }); + // --- Step 5. Upload Thumbnail + log('Uploaded file name is $name'); + log('Uploaded thumbnail file name is $thumbName'); + + // Step 6. Move Video to Queue + var dateStartMoveToQueue = DateTime.now(); + setState(() { + didStartMoveToQueue = true; + }); + var videoUploadInfo = await Communicator().uploadInfo( + user: user!, + thumbnail: thumbName, + oFilename: originalFileName, + duration: duration, + size: fileSize.toDouble(), + tusFileName: name, + ); + _addItem(originalFileName, file.path); + log(videoUploadInfo.status); + var videosInfo = await Communicator().loadVideos(widget.data); + var item = videosInfo.firstWhere((element) => + element.permlink == videoUploadInfo.permlink && + element.owner == videoUploadInfo.owner); + var dateEndMoveToQueue = DateTime.now(); + diff = dateEndMoveToQueue.difference(dateStartMoveToQueue); + setState(() { + timeMoveToQueue = '${diff.inSeconds} seconds'; + didMoveToQueue = true; + showMessage('Video is uploaded & moved to encoding queue'); + showMyDialog(item); + }); + // Step 6. Move Video to Queue + } else { + throw 'User cancelled the video picker'; + } + } catch (e) { + setState(() { + Navigator.of(context).pop(); + }); + rethrow; + } + } + + void showMyDialog(VideoDetails item) { + Widget nowButton = TextButton( + onPressed: () async { + Navigator.of(context).pop(); + Navigator.of(context).pop(); + var screen = VideoPrimaryInfo(item: item, justForEditing: true); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + }, + child: const Text('Next')); + AlertDialog alert = AlertDialog( + title: Text("🎉 Upload Complete 🎉"), + content: Text( + "As soon as your video is uploaded on decentralised IPFS infrastructure, it'll be published"), + actions: [ + nowButton, + ], + ); + showDialog( + context: context, builder: (c) => alert, barrierDismissible: false); + } + + void showMessage(String string) { + var snackBar = SnackBar(content: Text('Message: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + Future initiateUpload(String path, bool isThumbnail) async { + final xfile = XFile(path); + final client = TusClient( + Uri.parse(Communicator.fsServer), + xfile, + store: TusMemoryStore(), + ); + var name = ""; + await client.upload( + onComplete: () async { + log("Complete!"); + // Prints the uploaded file URL + log(client.uploadUrl.toString()); + var url = client.uploadUrl.toString(); + var ipfsName = url.replaceAll("${Communicator.fsServer}/", ""); + // var pathImageThumb = await getThumbnail(xfile.path); + setState(() { + // this.ipfsName = ipfsName; + // this.thumbUrl = pathImageThumb; + if (isThumbnail) { + didUploadThumbnail = true; + } else { + didUpload = true; + } + }); + name = ipfsName; + }, + onProgress: (progress) { + log("Progress: $progress"); + setState(() { + if (isThumbnail) { + thumbnailUploadProgress = progress / 100.0; + } else { + this.progress = progress / 100.0; + } + }); + }, + ); + return name; + } + + Future getThumbnail(String path) async { + try { + Directory tempDir = Directory.systemTemp; + var imagePath = await VideoThumbnail.thumbnailFile( + video: path, + thumbnailPath: tempDir.path, + imageFormat: ImageFormat.PNG, + maxWidth: 320, + quality: 100, + ); + if (imagePath == null) { + throw 'Could not generate video thumbnail'; + } + return imagePath; + } catch (e) { + throw 'Error generating video thumbnail ${e.toString()}'; + } + } + + @override + Widget build(BuildContext context) { + var user = Provider.of(context); + if (user.username != null && this.user == null) { + this.user = user; + } + return Scaffold( + appBar: AppBar( + title: ListTile( + leading: CustomCircleAvatar( + height: 36, + width: 36, + url: 'https://images.hive.blog/u/${user.username ?? ''}/avatar', + ), + title: Text(user.username ?? ''), + subtitle: Text('Video Upload Process'), + ), + ), + body: ListView( + children: [ + // ListTile( + // title: const Text('Launching Video Picker'), + // trailing: didShowFilePicker + // ? const Icon(Icons.check, color: Colors.lightGreen) + // : const Icon(Icons.pending), + // subtitle: didShowFilePicker ? Text(timeShowFilePicker) : null, + // ), + // ListTile( + // title: const Text('Getting/Compressing the Video'), + // trailing: !didPickFile + // ? !didStartPickFile + // ? const Icon(Icons.pending) + // : const CircularProgressIndicator() + // : const Icon(Icons.check, color: Colors.lightGreen), + // subtitle: didPickFile ? Text(timePickFile) : null, + // ), + ListTile( + title: Text( + 'Uploading video (${didUpload ? 100.0 : (progress * 100).toStringAsFixed(2)}%)'), + trailing: !didStartUpload + ? const Icon(Icons.pending) + : !didUpload + ? SizedBox( + width: 200, + child: LinearProgressIndicator(value: progress), + ) + : const Icon(Icons.check, color: Colors.lightGreen), + subtitle: didUpload ? Text(timeUpload) : null, + ), + ListTile( + title: const Text('Taking video thumbnail'), + trailing: !didStartTakeDefaultThumbnail + ? const Icon(Icons.pending) + : !didTakeDefaultThumbnail + ? const CircularProgressIndicator() + : const Icon(Icons.check, color: Colors.lightGreen), + subtitle: + didTakeDefaultThumbnail ? Text(timeTakeDefaultThumbnail) : null, + ), + ListTile( + title: Text( + 'Uploading thumbnail (${didUpload ? 100.0 : (thumbnailUploadProgress * 100).toStringAsFixed(2)}%)'), + trailing: !didStartUploadThumbnail + ? const Icon(Icons.pending) + : !didUploadThumbnail + ? SizedBox( + width: 200, + child: LinearProgressIndicator( + value: thumbnailUploadProgress), + ) + : const Icon(Icons.check, color: Colors.lightGreen), + subtitle: didUploadThumbnail ? Text(timeUploadThumbnail) : null, + ), + ListTile( + title: const Text('Move video to Encoding Queue'), + trailing: !didStartMoveToQueue + ? const Icon(Icons.pending) + : !didMoveToQueue + ? const CircularProgressIndicator() + : const Icon(Icons.check, color: Colors.lightGreen), + subtitle: didMoveToQueue ? Text(timeMoveToQueue) : null, + ), + ], + ), + ); + } +} diff --git a/lib/src/screens/upload/podcast/audio_details_info.dart b/lib/src/screens/upload/podcast/audio_details_info.dart new file mode 100644 index 00000000..14e67bc1 --- /dev/null +++ b/lib/src/screens/upload/podcast/audio_details_info.dart @@ -0,0 +1,904 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/global_provider/ipfs_node_provider.dart'; +import 'package:acela/src/models/login/login_bridge_response.dart'; +import 'package:acela/src/models/my_account/video_ops.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/my_account/update_video/add_bene_sheet.dart'; +import 'package:acela/src/screens/settings/settings_screen.dart'; +import 'package:acela/src/utils/communicator.dart'; +import 'package:acela/src/utils/safe_convert.dart'; +import 'package:acela/src/widgets/custom_circle_avatar.dart'; +import 'package:acela/src/widgets/loading_screen.dart'; +import 'package:adaptive_action_sheet/adaptive_action_sheet.dart'; +import 'package:croppy/croppy.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:image/image.dart' as img; +import 'package:image_picker/image_picker.dart'; +import 'package:provider/provider.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:tus_client/tus_client.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +class AudioDetailsInfoScreen extends StatefulWidget { + const AudioDetailsInfoScreen({ + Key? key, + required this.title, + required this.description, + required this.selectedCommunity, + required this.hasKey, + required this.hasAuthKey, + required this.appData, + required this.isNsfwContent, + required this.owner, + required this.size, + required this.duration, + required this.oFileName, + required this.episode, + }) : super(key: key); + + final String owner; + final String title; + final String description; + final String hasKey; + final String hasAuthKey; + final String selectedCommunity; + final HiveUserData appData; + final bool isNsfwContent; + final int size; + final int duration; + final String oFileName; + final String episode; + + @override + State createState() => _AudioDetailsInfoScreenState(); +} + +class _AudioDetailsInfoScreenState extends State { + var isCompleting = false; + var isPickingImage = false; + var uploadStarted = false; + var uploadComplete = false; + var thumbIpfs = ''; + var thumbUrl = ''; + var tags = 'threespeak,mobile'; + var progress = 0.0; + var processText = ''; + TextEditingController tagsController = TextEditingController(); + final ImagePicker _picker = ImagePicker(); + String? hiveKeychainTransactionId; + late WebSocketChannel socket; + var socketClosed = true; + String? qrCode; + var timer = 0; + var timeoutValue = 0; + Timer? ticker; + var loadingQR = false; + var shouldShowHiveAuth = false; + var powerUp100 = false; + late List beneficiaries; + var podcastEpisodeId = ""; + + var languages = [ + VideoLanguage(code: "en", name: "English"), + VideoLanguage(code: "de", name: "Deutsch"), + VideoLanguage(code: "pt", name: "Portuguese"), + VideoLanguage(code: "fr", name: "Français"), + VideoLanguage(code: "es", name: "Español"), + VideoLanguage(code: "nl", name: "Nederlands"), + VideoLanguage(code: "ko", name: "한국어"), + VideoLanguage(code: "ru", name: "русский"), + VideoLanguage(code: "hu", name: "Magyar"), + VideoLanguage(code: "ro", name: "Română"), + VideoLanguage(code: "cs", name: "čeština"), + VideoLanguage(code: "pl", name: "Polskie"), + VideoLanguage(code: "in", name: "bahasa Indonesia"), + VideoLanguage(code: "bn", name: "বাংলা"), + VideoLanguage(code: "it", name: "Italian"), + VideoLanguage(code: "he", name: "עִברִית"), + ]; + var selectedLanguage = VideoLanguage(code: "en", name: "English"); + + @override + Widget build(BuildContext context) { + var user = Provider.of(context); + return Scaffold( + appBar: AppBar( + title: ListTile( + leading: CustomCircleAvatar( + height: 36, + width: 36, + url: 'https://images.hive.blog/u/${user.username ?? ''}/avatar', + ), + title: Text(user.username ?? ''), + subtitle: Text('Provide more details to publish'), + ), + ), + body: isCompleting + ? (qrCode == null) + ? Center( + child: LoadingScreen( + title: 'Please wait', + subtitle: processText, + ), + ) + : _showQRCodeAndKeychainButton(qrCode!) + : Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _tagField(), + _thumbnailPicker(user), + const Text('Tap to change Podcast Episode thumbnail'), + _rewardType(), + _beneficiaries(), + _changeLanguage(), + ], + ), + floatingActionButton: isCompleting + ? null + : FloatingActionButton.extended( + label: Text('Publish'), + onPressed: () { + if (user.username != null) { + completePodcastUpload(user); + } + }, + icon: Icon(Icons.post_add), + ), + ); + } + + void completePodcastUpload(HiveUserData user) async { + if (thumbIpfs.isEmpty) { + showError('Please set Thumbnail'); + } else { + const platform = MethodChannel('com.example.acela/auth'); + setState(() { + isCompleting = true; + processText = 'Updating Podcast info'; + }); + try { + final String ipfsUrl = IpfsNodeProvider().nodeUrl; + var podcastResponse = await Communicator().uploadPodcast( + user: user, + size: widget.size, + episode: widget.episode, + oFilename: widget.oFileName, + title: widget.title, + description: widget.description, + isNsfwContent: widget.isNsfwContent, + tags: tags, + thumbnail: thumbIpfs, + communityID: widget.selectedCommunity, + declineRewards: false, + duration: widget.duration, + ); + podcastEpisodeId = podcastResponse.id; + await Future.delayed(const Duration(seconds: 1), () {}); + var title = base64.encode(utf8.encode(podcastResponse.title)); + var description = podcastResponse.description; + description = base64.encode(utf8.encode(description)); + var ipfsHash = ""; + if (podcastResponse.enclosureUrl.isNotEmpty) { + ipfsHash = podcastResponse.enclosureUrl + .replaceAll(ipfsUrl, "") + .replaceAll("ipfs://", ""); + } + var thumbnail = + podcastResponse.thumbnail.replaceAll("ipfs://", ipfsUrl); + var enclosureUrl = + podcastResponse.enclosureUrl.replaceAll("ipfs://", ipfsUrl); + final String response = await platform.invokeMethod('newPostPodcast', { + 'thumbnail': thumbnail, + 'enclosureUrl': enclosureUrl, + 'description': description, + 'title': title, + 'tags': tags, + 'username': user.username, + 'permlink': podcastResponse.permlink, + 'duration': widget.duration.toDouble(), + 'size': widget.size, + 'originalFilename': widget.oFileName, + 'firstUpload': podcastResponse.firstUpload, + 'bene': '', + 'beneW': '', + 'postingKey': user.postingKey ?? '', + 'community': widget.selectedCommunity, + 'ipfsHash': ipfsHash, + 'hasKey': user.keychainData?.hasId ?? '', + 'hasAuthKey': user.keychainData?.hasAuthKey ?? '', + 'newBene': base64.encode( + utf8.encode(BeneficiariesJson.toJsonString(beneficiaries))), + 'language': selectedLanguage.code, + 'powerUp': powerUp100, + }); + log('Response from platform $response'); + var bridgeResponse = LoginBridgeResponse.fromJsonString(response); + if (bridgeResponse.error == "" && + bridgeResponse.valid && + (bridgeResponse.data ?? "").isEmpty) { + showMessage( + 'Please wait. Podcast is posted on Hive but needs to be marked as published.'); + Future.delayed(const Duration(seconds: 6), () async { + if (mounted) { + try { + await Communicator().updatePublishStateForPodcastEpisode( + user, podcastResponse.id); + setState(() { + isCompleting = false; + processText = ''; + showMessage( + 'Congratulations. Your Podcast Episode is published.'); + showMyDialog(); + }); + } catch (e) { + setState( + () { + isCompleting = false; + processText = ''; + showMessage( + 'Podcast is posted on Hive but needs to be marked as published. Please hit Save button again after few seconds.'); + }, + ); + } + } + }); + } else if (bridgeResponse.error == "" && + bridgeResponse.data != null && + user.keychainData?.hasAuthKey != null) { + var socketData = { + "cmd": "sign_req", + "account": user.username!, + "token": user.keychainData!.hasId, + "data": bridgeResponse.data!, + }; + var jsonData = json.encode(socketData); + socket.sink.add(jsonData); + } else { + throw bridgeResponse.error; + } + } catch (e) { + showError(e.toString()); + setState(() { + isCompleting = false; + processText = ''; + }); + } + } + } + + void showError(String string) { + var snackBar = SnackBar(content: Text('Error: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + void showMessage(String string) { + var snackBar = SnackBar(content: Text('Message: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + @override + void initState() { + super.initState(); + beneficiaries = [ + BeneficiariesJson(account: 'sagarkothari88', src: 'mobile', weight: 1), + BeneficiariesJson( + account: 'spk.beneficiary', src: 'threespeak', weight: 10), + BeneficiariesJson( + account: widget.appData.username!, src: 'author', weight: 89), + ]; + tagsController.text = "threespeak,mobile"; + socket = WebSocketChannel.connect( + Uri.parse(Communicator.hiveAuthServer), + ); + socket.stream.listen((message) { + var map = json.decode(message) as Map; + var cmd = asString(map, 'cmd'); + if (cmd.isNotEmpty) { + switch (cmd) { + case "connected": + setState(() { + timeoutValue = asInt(map, 'timeout'); + }); + break; + case "auth_wait": + log('You are not logged in.'); + break; + case "auth_ack": + log('You are not logged in.'); + break; + case "auth_nack": + log('You are not logged in.'); + break; + case "sign_wait": + var uuid = asString(map, 'uuid'); + var jsonData = { + "account": widget.owner, + "uuid": uuid, + "key": widget.hasKey, + "host": Communicator.hiveAuthServer + }; + var jsonString = json.encode(jsonData); + var utf8Data = utf8.encode(jsonString); + var qr = base64.encode(utf8Data); + qr = "has://sign_req/$qr"; + setState(() { + loadingQR = false; + qrCode = qr; + var uri = Uri.tryParse(qr); + if (uri != null) { + launchUrl(uri); + } + timer = timeoutValue; + ticker = Timer.periodic(Duration(seconds: 1), (tickrr) { + if (timer == 0) { + setState(() { + tickrr.cancel(); + qrCode = null; + }); + } else { + setState(() { + timer--; + }); + } + }); + }); + break; + case "sign_ack": + setState(() { + qrCode = null; + }); + showMessage( + 'Please wait. Podcast is posted on Hive but needs to be marked as published.'); + Future.delayed(const Duration(seconds: 6), () async { + if (mounted) { + try { + await Communicator().updatePublishStateForPodcastEpisode( + widget.appData, podcastEpisodeId); + setState(() { + isCompleting = false; + processText = ''; + showMessage( + 'Congratulations. Your Podcast Episode is published.'); + showMyDialog(); + }); + } catch (e) { + setState( + () { + isCompleting = false; + processText = ''; + showMessage( + 'Podcast is posted on Hive but needs to be marked as published. Please hit Save button again after few seconds.'); + }, + ); + } + } + }); + break; + case "sign_nack": + setState(() { + isCompleting = false; + processText = ''; + qrCode = null; + }); + var uuid = asString(map, 'uuid'); + showError( + "Transaction - $uuid was declined. Please hit save button again to try again."); + break; + case "sign_err": + setState(() { + qrCode = null; + isCompleting = false; + processText = ''; + }); + var uuid = asString(map, 'uuid'); + showError("Transaction - $uuid failed."); + break; + default: + log('Default case here'); + } + } + }, onError: (e) async { + await Future.delayed(Duration(seconds: 2)); + socket = WebSocketChannel.connect( + Uri.parse(Communicator.hiveAuthServer), + ); + }, onDone: () async { + await Future.delayed(Duration(seconds: 2)); + socket = WebSocketChannel.connect( + Uri.parse(Communicator.hiveAuthServer), + ); + }, cancelOnError: true); + } + + Future initiateUpload( + HiveUserData data, + XFile xFile, + ) async { + if (uploadStarted) return; + setState(() { + uploadStarted = true; + }); + final client = TusClient( + Uri.parse(Communicator.fsServer), + xFile, + store: TusMemoryStore(), + ); + await client.upload( + onComplete: () async { + print("Complete!"); + print(client.uploadUrl.toString()); + var url = client.uploadUrl.toString(); + var ipfsName = url.replaceAll("${Communicator.fsServer}/", ""); + setState(() { + thumbUrl = url; + thumbIpfs = ipfsName; + uploadComplete = true; + uploadStarted = false; + }); + }, + onProgress: (progress) { + log("Progress: $progress"); + setState(() { + this.progress = progress; + }); + }, + ); + } + + void showMyDialog() { + Widget okButton = TextButton( + child: Text("Okay"), + onPressed: () { + Navigator.of(context).pop(); + Navigator.of(context).pop(); + Navigator.of(context).pop(); + }, + ); + AlertDialog alert = AlertDialog( + title: Text("🎉 Congratulations 🎉"), + content: Text( + "Your Podcast is published on Hive & podcast is marked as published."), + actions: [ + okButton, + ], + ); + showDialog(context: context, builder: (c) => alert); + } + + void showDialogForAfter10Seconds(String message) { + Widget okButton = TextButton( + child: Text("Okay"), + onPressed: () { + Navigator.of(context).pop(); + }, + ); + AlertDialog alert = AlertDialog( + title: Text("🎉 Congratulations 🎉"), + content: Text(message), + actions: [ + okButton, + ], + ); + showDialog(context: context, builder: (c) => alert); + } + + Widget _rewardType() { + return Padding( + padding: const EdgeInsets.only(left: 15, right: 15, top: 20, bottom: 20), + child: Row( + children: [ + Text(powerUp100 ? '100% power' : '50% power'), + const Spacer(), + Switch( + value: powerUp100, + onChanged: (newVal) { + setState(() { + powerUp100 = newVal; + }); + }, + ) + ], + ), + ); + } + + Widget _thumbnailPicker(HiveUserData user) { + return Center( + child: Container( + width: 320, + height: 160, + margin: const EdgeInsets.all(10), + decoration: const BoxDecoration( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(24.0), + topRight: Radius.circular(24.0), + ), + boxShadow: [ + BoxShadow( + color: Colors.black12, + spreadRadius: 3, + blurRadius: 3, + ) + ]), + child: InkWell( + child: Center( + child: isPickingImage + ? const CircularProgressIndicator() + : progress > 0.0 && progress < 100.0 + ? CircularProgressIndicator(value: progress) + : thumbUrl.isNotEmpty + ? Image.network( + thumbUrl, + width: 320, + height: 160, + ) + : const Text( + 'Tap here to add thumbnail for your podcast\n\nThumbnail is MANDATORY to set.', + textAlign: TextAlign.center), + ), + onTap: () async { + try { + setState(() { + isPickingImage = true; + if (thumbUrl.isNotEmpty) { + thumbUrl = ""; + } + if (thumbIpfs.isNotEmpty) { + thumbIpfs = ""; + } + }); + XFile? file = + await _picker.pickImage(source: ImageSource.gallery); + CropImageResult? result; + if (file != null) { + if (defaultTargetPlatform == TargetPlatform.android) { + result = await showMaterialImageCropper( + context, + imageProvider: FileImage(File(file.path)), + enabledTransformations: Transformation.values, + allowedAspectRatios: [ + CropAspectRatio(width: 3, height: 3), //squre shape + ], + postProcessFn: (result) async { + return result; + }, + ); + } else { + result = await showCupertinoImageCropper( + context, + imageProvider: FileImage(File(file.path)), + enabledTransformations: Transformation.values, + allowedAspectRatios: [ + CropAspectRatio(width: 3, height: 3), //squre shape + ], + postProcessFn: (result) async { + return result; + }, + ); + } + if (result != null) { + var croppedfile = + await saveCroppedImageToFile(result.uiImage, file.path); + file = XFile(croppedfile.path); + + await initiateUpload(user, file); + setState(() { + isPickingImage = false; + }); + } else { + _onCancel(); + throw 'User cancelled image cropper'; + } + } else { + _onCancel(); + file = null; + throw 'User cancelled image picker'; + } + } catch (e) { + showError(e.toString()); + setState(() { + isPickingImage = false; + }); + } + }, + ), + ), + ); + } + + void _onCancel() { + setState(() { + isPickingImage = false; + if (thumbUrl.isNotEmpty) { + thumbUrl = ""; + } + if (thumbIpfs.isNotEmpty) { + thumbIpfs = ""; + } + }); + } + + Future resizeImage(File file) async { + img.Image image = img.decodeImage(file.readAsBytesSync())!; + img.Image resizedImage = img.copyResize(image, width: 1080, height: 1080); + List compressedBytes = img.encodeJpg( + resizedImage, + ); + File compressedFile = File(file.path); + compressedFile.writeAsBytesSync(compressedBytes); + return compressedFile; + } + + Future saveCroppedImageToFile( + ui.Image croppedImage, String savePath) async { + ByteData? byteData = + await croppedImage.toByteData(format: ui.ImageByteFormat.png); + Uint8List pngBytes = byteData!.buffer.asUint8List(); + String filePath = "$savePath"; + await File(filePath).writeAsBytes(pngBytes); + if (croppedImage.width > 1080 || croppedImage.height > 1080) { + return await resizeImage(File(filePath)); + } else { + return File(filePath); + } + } + + Widget _tagField() { + return Container( + margin: const EdgeInsets.only(left: 10, right: 10), + child: TextField( + controller: tagsController, + decoration: const InputDecoration( + hintText: 'Comma separated tags', + labelText: 'Tags', + ), + onChanged: (text) { + setState(() { + tags = text; + }); + }, + maxLines: 1, + minLines: 1, + maxLength: 150, + ), + ); + } + + Widget _beneficiaries() { + return Container( + padding: const EdgeInsets.all(10), + child: InkWell( + onTap: () { + beneficiariesBottomSheet(); + }, + child: Row( + children: [ + Text('Podcast Participants:'), + Spacer(), + Icon(Icons.arrow_drop_down), + ], + ), + ), + ); + } + + void showAlertForAddBene(List benes) { + showModalBottomSheet( + context: context, + builder: (context) { + return AddBeneSheet( + benes: benes, + onSave: (newBenes) { + setState(() { + beneficiaries = newBenes; + }); + }, + ); + }, + ); + } + + void beneficiariesBottomSheet() { + var filteredBenes = beneficiaries + .where((element) => + element.src != 'ENCODER_PAY' && + element.src != 'mobile' && + element.src != 'threespeak') + .toList(); + showModalBottomSheet( + context: context, + builder: (context) { + return SafeArea( + child: Container( + height: 400, + child: Scaffold( + appBar: AppBar( + title: Text('Podcast Participants'), + actions: [ + if (beneficiaries.length < 8) + IconButton( + onPressed: () { + Navigator.of(context).pop(); + showAlertForAddBene(beneficiaries); + }, + icon: Icon(Icons.add)) + ], + ), + body: ListView.separated( + itemBuilder: (c, i) { + return ListTile( + leading: CustomCircleAvatar( + height: 40, + width: 40, + url: server.userOwnerThumb(filteredBenes[i].account), + ), + title: Text(filteredBenes[i].account), + subtitle: Text( + '${filteredBenes[i].src} ( ${filteredBenes[i].weight} % )'), + trailing: (filteredBenes[i].src == 'participant') + ? IconButton( + onPressed: () { + var currentBenes = beneficiaries; + var author = currentBenes + .where((e) => e.account == widget.owner) + .firstOrNull; + if (author == null) return; + var otherBenes = currentBenes + .where((e) => + e.src != 'author' && + e.account != filteredBenes[i].account) + .toList(); + author.weight = + author.weight + filteredBenes[i].weight; + otherBenes.add(author); + setState(() { + beneficiaries = otherBenes; + }); + Navigator.of(context).pop(); + }, + icon: Icon( + Icons.delete, + color: Colors.red, + ), + ) + : null, + ); + }, + separatorBuilder: (c, i) => const Divider(), + itemCount: filteredBenes.length, + ), + ), + ), + ); + }, + ); + } + + Widget _showQRCodeAndKeychainButton(String qr) { + Widget hkButton = ElevatedButton( + onPressed: () { + var uri = Uri.tryParse(qr); + if (uri != null) { + launchUrl(uri); + } + }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.black), + child: Image.asset('assets/hive-keychain-image.png', width: 100), + ); + Widget haButton = ElevatedButton( + onPressed: () { + setState(() { + shouldShowHiveAuth = true; + }); + }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.black), + child: Image.asset('assets/hive_auth_button.png', width: 120), + ); + Widget qrCode = InkWell( + child: Container( + decoration: BoxDecoration(color: Colors.white), + child: QrImageView( + data: qr, + size: 150.0, + gapless: true, + ), + ), + onTap: () { + var uri = Uri.tryParse(qr); + if (uri != null) { + launchUrl(uri); + } + }, + ); + var backButton = ElevatedButton.icon( + onPressed: () { + setState(() { + shouldShowHiveAuth = false; + }); + }, + icon: Icon(Icons.arrow_back), + label: Text("Back"), + ); + List array = []; + if (shouldShowHiveAuth) { + array = [ + backButton, + const SizedBox(width: 10), + qrCode, + ]; + } else { + array = [ + haButton, + const SizedBox(width: 10), + hkButton, + ]; + } + return Center( + child: Column( + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: array, + ), + SizedBox(height: 10), + SizedBox( + width: 200, + child: LinearProgressIndicator( + value: timer.toDouble() / timeoutValue.toDouble(), + semanticsLabel: 'Timeout Timer for HiveAuth QR', + ), + ), + ], + ), + ], + ), + ); + } + + BottomSheetAction getLangAction(VideoLanguage language) { + return BottomSheetAction( + title: Text(language.name), + onPressed: (context) async { + setState(() { + selectedLanguage = language; + Navigator.of(context).pop(); + }); + }, + ); + } + + void tappedLanguage() { + showAdaptiveActionSheet( + context: context, + title: const Text('Set Default Language Filter'), + androidBorderRadius: 30, + actions: languages.map((e) => getLangAction(e)).toList(), + cancelAction: CancelAction(title: const Text('Cancel')), + ); + } + + Widget _changeLanguage() { + var display = selectedLanguage.name; + return ListTile( + leading: const Icon(Icons.language), + title: const Text("Set Language Filter"), + trailing: Text(display), + onTap: () { + tappedLanguage(); + }, + ); + } +} diff --git a/lib/src/screens/upload/podcast/audio_primary_info.dart b/lib/src/screens/upload/podcast/audio_primary_info.dart new file mode 100644 index 00000000..a396aa57 --- /dev/null +++ b/lib/src/screens/upload/podcast/audio_primary_info.dart @@ -0,0 +1,215 @@ +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/communities_screen/communities_screen.dart'; +import 'package:acela/src/screens/upload/podcast/audio_details_info.dart'; +import 'package:acela/src/widgets/custom_circle_avatar.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class AudioPrimaryInfo extends StatefulWidget { + const AudioPrimaryInfo({ + Key? key, + required this.title, + required this.url, + required this.size, + required this.duration, + required this.episode, + }) : super(key: key); + + final String title; + final String url; + final int size; + final int duration; + final String episode; + + @override + State createState() => _AudioPrimaryInfoState(); +} + +class _AudioPrimaryInfoState extends State { + var title = ''; + var description = ''; + var titleController = TextEditingController(); + var descriptionController = TextEditingController(); + late String selectedCommunity; //= 'hive-181335'; + late String selectedCommunityVisibleName; //= 'Threespeak'; + var isNsfwContent = false; + + @override + void initState() { + super.initState(); + selectedCommunity = 'hive-181335'; + selectedCommunityVisibleName = 'Three Speak'; + titleController.text = widget.title; + title = widget.title; + } + + Widget _notSafe() { + return Row( + children: [ + Text(isNsfwContent + ? 'Audio is NOT SAFE for work.' + : 'Audio is Safe for work.'), + const Spacer(), + Switch( + value: isNsfwContent, + onChanged: (newVal) { + setState(() { + isNsfwContent = newVal; + }); + }, + ) + ], + ); + } + + Widget _communityPicker() { + return Row( + children: [ + const Text('Select Community:'), + Spacer(), + InkWell( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (c) => CommunitiesScreen( + withoutScaffold: false, + didSelectCommunity: (name, id) { + setState(() { + selectedCommunity = id; + selectedCommunityVisibleName = name; + }); + }, + ), + ), + ); + }, + child: Row( + children: [ + Text(selectedCommunityVisibleName), + SizedBox(width: 10), + CustomCircleAvatar( + width: 44, + height: 44, + url: server.communityIcon(selectedCommunity), + ), + ], + ), + ), + ], + ); + } + + Widget _body() { + return SafeArea( + child: Container( + margin: const EdgeInsets.all(20), + child: Column( + children: [ + TextField( + decoration: InputDecoration( + hintText: 'Audio title goes here', + labelText: 'Title', + suffixIcon: IconButton( + onPressed: () { + titleController.clear(); + setState(() { + title = ''; + }); + }, + icon: Icon(Icons.clear), + ), + ), + onChanged: (text) { + setState(() { + title = text; + }); + }, + controller: titleController, + maxLines: 1, + minLines: 1, + maxLength: 150, + ), + TextFormField( + decoration: InputDecoration( + hintText: 'Audio description', + labelText: 'Description', + suffixIcon: IconButton( + onPressed: () { + descriptionController.clear(); + setState(() { + description = ''; + }); + }, + icon: Icon(Icons.clear), + ), + ), + onChanged: (text) { + setState(() { + description = text; + }); + }, + controller: descriptionController, + maxLines: 8, + minLines: 5, + ), + const SizedBox(height: 10), + _communityPicker(), + const SizedBox(height: 10), + _notSafe(), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + var appData = Provider.of(context); + return Scaffold( + appBar: AppBar( + title: ListTile( + leading: CustomCircleAvatar( + height: 36, + width: 36, + url: + 'https://images.hive.blog/u/${appData.username ?? ''}/avatar', + ), + title: Text(appData.username ?? ''), + ), + ), + body: _body(), + floatingActionButton: FloatingActionButton( + onPressed: () { + if (title.isEmpty) { + showError('Title is required'); + } else if (description.isEmpty) { + showError('Description is required'); + } else { + var screen = AudioDetailsInfoScreen( + title: title, + oFileName: widget.title, + duration: widget.duration, + size: widget.size, + description: description, + appData: appData, + selectedCommunity: selectedCommunity, + isNsfwContent: isNsfwContent, + hasKey: appData.keychainData?.hasId ?? "", + hasAuthKey: appData.keychainData?.hasAuthKey ?? "", + owner: appData.username ?? "", + episode: widget.episode, + ); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + } + }, + child: const Text('Next'), + )); + } + + void showError(String string) { + var snackBar = SnackBar(content: Text('Error: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } +} diff --git a/lib/src/screens/upload/podcast/podcast_upload_screen.dart b/lib/src/screens/upload/podcast/podcast_upload_screen.dart new file mode 100644 index 00000000..6b99fd91 --- /dev/null +++ b/lib/src/screens/upload/podcast/podcast_upload_screen.dart @@ -0,0 +1,308 @@ +import 'dart:async'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/upload/new_video_upload_screen.dart'; +import 'package:acela/src/screens/upload/podcast/audio_primary_info.dart'; +import 'package:acela/src/utils/communicator.dart'; +import 'package:acela/src/widgets/custom_circle_avatar.dart'; +import 'package:ffmpeg_kit_flutter_https_gpl/ffprobe_kit.dart'; +import 'package:ffmpeg_kit_flutter_https_gpl/media_information_session.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:localstorage/localstorage.dart'; +import 'package:provider/provider.dart'; +import 'package:tus_client/tus_client.dart'; +import 'package:video_compress/video_compress.dart'; +import 'package:video_thumbnail/video_thumbnail.dart'; + +class PodcastUploadScreen extends StatefulWidget { + const PodcastUploadScreen({ + Key? key, + required this.data, + }) : super(key: key); + final HiveUserData data; + + @override + State createState() => _PodcastUploadScreenState(); +} + +class _PodcastUploadScreenState extends State { + late Timer timer; + var didShowFilePicker = false; + var didPickFile = false; + + // var didCompress = false; + var didUpload = false; + + var timeShowFilePicker = '0.5 seconds'; + var timePickFile = ''; + + // var timeCompress = ''; + var timeUpload = ''; + + var didStartPickFile = false; + + // var didStartCompress = false; + var didStartUpload = false; + var progress = 0.0; + String fileName = ""; + String audioUrl = ""; + String tusFileName = ""; + int fileSize = 0; + int duration = 0; + + late Subscription _subscription; + HiveUserData? user; + final LocalStorage storage = LocalStorage('uploaded_audio_data'); + final UploadedItemList list = UploadedItemList(); + + @override + void initState() { + super.initState(); + var items = storage.getItem('audio_uploads'); + if (items != null) { + setState(() { + list.items = List.from( + (items as List).map( + (item) => UploadedItem( + fileName: item['fileName'], + filePath: item['filePath'], + ), + ), + ); + }); + } + timer = Timer.periodic(const Duration(milliseconds: 500), (timer) { + timer.cancel(); + videoPickerFunction(); + }); + } + + @override + void dispose() { + super.dispose(); + timer.cancel(); + // _subscription.unsubscribe(); + } + + void _addItem(String fileName, String filePath) { + setState(() { + final item = new UploadedItem(fileName: fileName, filePath: filePath); + list.items.add(item); + _saveToStorage(); + }); + } + + void _saveToStorage() { + storage.setItem('uploads', list.toJSONEncodable()); + } + + void videoPickerFunction() async { + try { + if (user?.username == null) { + throw 'User not logged in'; + } + // Step 1. Select Video + var dateStartGettingVideo = DateTime.now(); + setState(() { + didStartPickFile = true; + didShowFilePicker = true; + }); + + FilePickerResult? pickerResult = await FilePicker.platform.pickFiles(type: Platform.isIOS ? FileType.any : FileType.audio); + final XFile? file; + file = pickerResult != null ? XFile(pickerResult.files.single.path ?? "") : null; + if (file != null) { + setState(() { + didPickFile = true; + }); + + var originalFileName = file.name; + setState(() { + fileName = originalFileName; + }); + log(originalFileName); + log("path - ${file.path}"); + var alreadyUploaded = list.items.contains((e) { + return e.fileName == originalFileName || e.filePath == file!.path; + }); + var extension = file.path.split(".").last; + if (!(extension == "mp3" || extension == "m4a")) { + throw 'Podcast should be in mp3/m4a format.'; + } + if (alreadyUploaded) { + throw 'This podcast is already uploaded by you'; + } + var size = await file.length(); + var dateEndGettingVideo = DateTime.now(); + var diff = dateEndGettingVideo.difference(dateStartGettingVideo); + setState(() { + timePickFile = '${diff.inSeconds} seconds'; + didPickFile = true; + }); + + // Step 3. Video upload + var dateStartUploadVideo = DateTime.now(); + setState(() { + didStartUpload = true; + }); + var fileSize = size; + var sizeInMb = fileSize / 1000 / 1000; + log("Compressed audio file size in mb is - $sizeInMb"); + if (sizeInMb > 1024) { + throw 'Podcast Episode is too big to be uploaded from mobile (exceeding 500 mb)'; + } + var path = file.path; + MediaInformationSession session = await FFprobeKit.getMediaInformation(path); + var info = session.getMediaInformation(); + var duration = (double.tryParse(info?.getDuration() ?? "0.0") ?? 0.0).toInt(); + log('Podcast Episode duration is $duration'); + setState(() { + this.duration = duration; + this.fileSize = fileSize; + }); + var name = await initiateUpload(path); + log(name); + var dateEndUploadVideo = DateTime.now(); + diff = dateEndUploadVideo.difference(dateStartUploadVideo); + setState(() { + timeUpload = '${diff.inSeconds} seconds'; + didUpload = true; + tusFileName = name; + }); + _addItem(originalFileName, file.path); + showMessage('Podcast Episode Audio is uploaded. Hit Next to finish next action items to publish podcast episode.'); + showMyDialog(); + // Step 6. Move Video to Queue + } else { + throw 'User cancelled the audio picker'; + } + } catch (e) { + setState(() { + Navigator.of(context).pop(); + }); + rethrow; + } + } + + void showMyDialog() { + Widget nowButton = TextButton( + onPressed: () async { + Navigator.of(context).pop(); + Navigator.of(context).pop(); + var screen = AudioPrimaryInfo( + url: audioUrl, + title: fileName, + size: fileSize, + duration: duration, + episode: tusFileName, + ); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + }, + child: const Text('Next')); + AlertDialog alert = AlertDialog( + title: Text("🎉 Podcast Episode Audio Uploaded 🎉"), + actions: [ + nowButton, + ], + ); + showDialog(context: context, builder: (c) => alert, barrierDismissible: false); + } + + void showMessage(String string) { + var snackBar = SnackBar(content: Text('Message: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + Future initiateUpload(String path) async { + final xfile = XFile(path); + final client = TusClient( + Uri.parse(Communicator.fsServer), + xfile, + store: TusMemoryStore(), + ); + var name = ""; + await client.upload( + onComplete: () async { + log("Complete!"); + // Prints the uploaded file URL + log(client.uploadUrl.toString()); + var url = client.uploadUrl.toString(); + var ipfsName = url.replaceAll("${Communicator.fsServer}/", ""); + // var pathImageThumb = await getThumbnail(xfile.path); + setState(() { + didUpload = true; + audioUrl = url; + }); + name = ipfsName; + }, + onProgress: (progress) { + log("Progress: $progress"); + setState(() { + this.progress = progress / 100.0; + }); + }, + ); + return name; + } + + Future getThumbnail(String path) async { + try { + Directory tempDir = Directory.systemTemp; + var imagePath = await VideoThumbnail.thumbnailFile( + video: path, + thumbnailPath: tempDir.path, + imageFormat: ImageFormat.PNG, + maxWidth: 320, + quality: 100, + ); + if (imagePath == null) { + throw 'Could not generate video thumbnail'; + } + return imagePath; + } catch (e) { + throw 'Error generating video thumbnail ${e.toString()}'; + } + } + + @override + Widget build(BuildContext context) { + var user = Provider.of(context); + if (user.username != null && this.user == null) { + this.user = user; + } + return Scaffold( + appBar: AppBar( + title: ListTile( + leading: CustomCircleAvatar( + height: 36, + width: 36, + url: 'https://images.hive.blog/u/${user.username ?? ''}/avatar', + ), + title: Text(user.username ?? ''), + subtitle: Text('Audio Upload Process'), + ), + ), + body: ListView( + children: [ + ListTile( + title: Text('Uploading Audio (${didUpload ? 100.0 : (progress * 100).toStringAsFixed(2)}%)'), + trailing: !didStartUpload + ? const Icon(Icons.pending) + : !didUpload + ? SizedBox( + width: 200, + child: LinearProgressIndicator(value: progress), + ) + : const Icon(Icons.check, color: Colors.lightGreen), + subtitle: didUpload ? Text(timeUpload) : null, + ), + ], + ), + ); + } +} diff --git a/lib/src/screens/upload/posting_authority_guide_screen.dart b/lib/src/screens/upload/posting_authority_guide_screen.dart new file mode 100644 index 00000000..5b59e7f3 --- /dev/null +++ b/lib/src/screens/upload/posting_authority_guide_screen.dart @@ -0,0 +1,71 @@ +import 'package:acela/src/utils/constants.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class PostingAuthorityGuideScreen extends StatelessWidget { + const PostingAuthorityGuideScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text("Posting authority"), + ), + body: SafeArea( + child: SingleChildScrollView( + padding: kScreenPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + text(context, "Step 1", "Open Keychain app."), + const SizedBox(height: 8), + text(context, "Step 2", "Open In-AppBrowser"), + const SizedBox(height: 8), + text(context, "Step 3", "Open ", [ + TextSpan( + text: "Peakd.com", + style: TextStyle(color: Colors.blue), + recognizer: TapGestureRecognizer() + ..onTap = () async { + var url = Uri.parse('https://peakd.com/'); + launchUrl(url); + }, + ), + TextSpan( + text: " & login with your account", + ), + ]), + const SizedBox(height: 8), + text(context, "Step 4", "Open Options menu by tapping on ..."), + const SizedBox(height: 8), + text(context, "Step 5", "Tap on Keys & Permissions."), + const SizedBox(height: 8), + Image.asset('assets/ps_guide_1.png'), + const SizedBox(height: 8), + text(context, "Step 6", "Add Threespeak as posting authority"), + const SizedBox(height: 8), + Image.asset('assets/ps_guide_2.png'), + ], + ), + ), + ), + ); + } + + RichText text(BuildContext context, String boldText, String normalText, + [List? textspans]) { + return RichText( + text: TextSpan( + style: Theme.of(context).textTheme.bodyLarge, + children: [ + TextSpan( + text: "$boldText: ", + style: const TextStyle(fontWeight: FontWeight.bold), // Bold text + ), + TextSpan(text: normalText, children: textspans), + ], + ), + ); + } +} diff --git a/lib/src/screens/upload/video/controller/video_upload_controller.dart b/lib/src/screens/upload/video/controller/video_upload_controller.dart new file mode 100644 index 00000000..8b968e6f --- /dev/null +++ b/lib/src/screens/upload/video/controller/video_upload_controller.dart @@ -0,0 +1,191 @@ +import 'package:acela/src/models/my_account/video_ops.dart'; +import 'package:acela/src/models/user_account/action_response.dart'; +import 'package:acela/src/models/user_account/user_model.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/models/video_upload/video_device_encode_upload_model.dart'; +import 'package:acela/src/screens/settings/settings_screen.dart'; +import 'package:acela/src/screens/upload/video/mixins/video_save_mixin.dart'; +import 'package:acela/src/screens/upload/video/mixins/video_upload_mixin.dart'; +import 'package:acela/src/utils/communicator.dart'; +import 'package:acela/src/utils/enum.dart'; +import 'package:flutter/material.dart'; + +class VideoUploadController extends ChangeNotifier with Upload, VideoSaveMixin { + String title = ''; + String description = ''; + String tags = ''; + String communityName = ''; + String communityId = ''; + bool isNsfwContent = false; + bool isPower100 = false; + List beneficaries = []; + late VideoLanguage language; + bool isDeviceEncoding = false; + ValueNotifier hasPostingAuthority = ValueNotifier(null); + + final String? userName; + + VideoUploadController(this.userName) { + setCommunity(); + setLanguage(); + getStatusForPostingAuthority(); + } + + void setCommunity({ + String? communityName, + String? communityId, + }) { + this.communityName = communityName ?? 'Three Speak'; + this.communityId = communityId ?? 'hive-181335'; + } + + void setTags({String? tags}) { + String defaultTag = 'threespeak,mobile'; + this.tags = tags ?? defaultTag; + if (!this.tags.contains(defaultTag)) { + if (this.tags.isEmpty) { + this.tags = '$defaultTag'; + } else { + this.tags = '${this.tags},$defaultTag'; + } + } + } + + void setBeneficiares({bool resetBeneficiares = false}) { + if (beneficaries.isEmpty || resetBeneficiares) { + if (resetBeneficiares) { + beneficaries.clear(); + } + if (this.userName != 'sagarkothari88') { + beneficaries.add( + BeneficiariesJson( + account: 'sagarkothari88', + src: 'MOBILE_APP_PAY', + weight: 1, + isDefault: true), + ); + } + if (this.userName != 'spk.beneficiary') { + beneficaries.add(BeneficiariesJson( + account: 'spk.beneficiary', + src: 'threespeak', + weight: 10, + isDefault: true)); + } + } + } + + void setLanguage({VideoLanguage? language}) { + this.language = language ?? VideoLanguage(code: "en", name: "English"); + } + + Future validateAndSaveVideo(HiveUserData userData, + {required Function(bool) successDialog, + required Function(String) successSnackbar, + required Function(String) errorSnackbar, + required bool publishLater, + DateTime? scheduledData}) async { + isSaving.value = true; + + if (hasPostingAuthority.value != true) { + hasPostingAuthority.value = await _hasPostingAuthority(); + } + if (hasPostingAuthority.value == null) { + isSaving.value = false; + errorSnackbar("Failed to check posting authority"); + } else { + if (!isDeviceEncoding) { + await saveVideo(userData, uploadedVideoItem, hasPostingAuthority.value!, + title: title, + description: description, + tags: tags, + beneficiaries: beneficaries, + communityId: communityId, + isNsfwContent: isNsfwContent, + language: language, + isPowerUp100: isPower100, + thumbIpfs: thumbnailUploadResponse.value!.name, + successDialog: () => successDialog(hasPostingAuthority.value!), + errorSnackbar: errorSnackbar); + } else { + if ((!publishLater || scheduledData != null) && + !hasPostingAuthority.value!) { + isSaving.value = false; + errorSnackbar( + "Need posting authority for 3speak to use this feature"); + return; + } + await saveDeviceEncodedVideo( + userData, + VideoDeviceEncodeUploadModel( + originalFilename: videoInfo!.originalFilename!, + duration: videoInfo!.duration!, + size: videoInfo!.duration!, + width: videoInfo!.width!, + height: videoInfo!.height!, + owner: userData.username!, + title: title, + description: description, + isReel: !videoInfo!.isLandscape! && videoInfo!.duration! <= 90, + isNsfwContent: isNsfwContent, + tags: tags, + communityID: communityId, + beneficiaries: beneficaries, + rewardPowerup: isPower100, + publishLater: publishLater, + scheduled: scheduledData != null, + publishData: scheduledData, + tusId: videoInfo!.tusId!), + hasPostingAuthority.value!, + errorSnackbar: errorSnackbar, + successDialog: () => + successDialog(hasPostingAuthority.value! && !publishLater)); + } + } + } + + void getStatusForPostingAuthority() async { + hasPostingAuthority.value = await _hasPostingAuthority(); + } + + Future _hasPostingAuthority() async { + if (this.userName != null) { + ActionSingleDataResponse response = + await Communicator().getAccountInfo(this.userName!); + if (response.isSuccess) { + return response.data!.hasThreeSpeakPostingAuthority(); + } + } + return null; + } + + void resetController() { + page = 0; + thumbnailUploadProgress.value = 0; + videoUploadProgress.value = 0; + finalUploadProgress.value = 0; + uploadStatus.value = UploadStatus.idle; + pageController.dispose(); + pageController = PageController(); + title = ''; + description = ''; + videoInfo = null; + setCommunity(); + setTags(); + isNsfwContent = false; + setBeneficiares(resetBeneficiares: true); + setLanguage(); + thumbnailUploadResponse = ValueNotifier(null); + thumbnailUploadStatus.value = UploadStatus.idle; + isSaving.value = false; + pickedThumbnail = null; + isDeviceEncoding = false; + getStatusForPostingAuthority(); + } + + @override + void dispose() { + pageController.dispose(); + super.dispose(); + } +} diff --git a/lib/src/screens/upload/video/encoding/folder_path.dart b/lib/src/screens/upload/video/encoding/folder_path.dart new file mode 100644 index 00000000..0455b22e --- /dev/null +++ b/lib/src/screens/upload/video/encoding/folder_path.dart @@ -0,0 +1,155 @@ +import 'dart:io'; + +import 'package:acela/src/screens/upload/video/encoding/resolution.dart'; +import 'package:acela/src/screens/upload/video/encoding/video_encoder.dart'; +import 'package:archive/archive_io.dart'; +import 'package:flutter/foundation.dart'; + +class FolderPath { + String getStorageDirectory() { + Directory appDocDir = Directory.systemTemp; + return appDocDir.path; + } + + String path() => "${getStorageDirectory()}/${VideoEncoder.foldername}"; + + Future createFolder({String? customFolderPath}) async { + String internalStoragePath = customFolderPath ?? getStorageDirectory(); + String folderPath = '$internalStoragePath/${VideoEncoder.foldername}'; + + Directory folder = Directory(folderPath); + // if (!(await folder.exists())) { + await folder.create(); + // } + + return folderPath; + } + + File readZipFile() { + return File("${path()}/${VideoEncoder.zipFileName}"); + } + + Future printM3u8Contents() async { + String rootPath = getStorageDirectory(); + Directory folder = + Directory("$rootPath/${VideoEncoder.foldername}/manifest.m3u8"); + String filePath = folder.path; + File file = File(filePath); + if (await file.exists()) { + try { + String contents = await file.readAsString(); + debugPrint("M3U8 File Contents:\n"); + debugPrint(contents); + } catch (e) { + debugPrint("Error reading the file: $e"); + } + } else { + debugPrint("File not found at path: $filePath"); + } + } + + Future printFolderContent(String path) async { + Directory folder = Directory(path); + bool result = await folder.exists(); + + if (result) { + debugPrint("Folder exists: $path"); + debugPrint("Listing all files and folders:"); + try { + await for (var entity in folder.list()) { + debugPrint(entity.path); + } + } catch (e) { + debugPrint("Error listing files: $e"); + } + } else { + debugPrint("Folder does not exist."); + } + + return result; + } + + Future deleteDirectory() async { + String rootPath = await getStorageDirectory(); + Directory folder = Directory("$rootPath/${VideoEncoder.foldername}"); + if (await folder.exists()) { + folder.deleteSync(recursive: true); + debugPrint( + 'Directory and its contents deleted: ${VideoEncoder.foldername}'); + } + } + + void generateMasterManifest( + String manifestPath, List scales) { + String masterManifestPath = '$manifestPath/manifest.m3u8'; + + File masterManifestFile = File(masterManifestPath); + + RandomAccessFile masterManifestAccessFile = + masterManifestFile.openSync(mode: FileMode.write); + + masterManifestAccessFile.writeStringSync('#EXTM3U\n'); + masterManifestAccessFile.writeStringSync('#EXT-X-VERSION:3\n'); + for (var resolution in scales) { + if (resolution == '1080') { + masterManifestAccessFile.writeStringSync( + '#EXT-X-STREAM-INF:BANDWIDTH=2000000,CODECS="mp4a.40.2",RESOLUTION=${resolution}x-2,NAME=1080\n'); + masterManifestAccessFile + .writeStringSync('${resolution}p_video.m3u8\n'); + } else if (resolution == '720') { + masterManifestAccessFile.writeStringSync( + '#EXT-X-STREAM-INF:BANDWIDTH=1327000,CODECS="mp4a.40.2",RESOLUTION=${resolution}x-2,NAME=720\n'); + masterManifestAccessFile + .writeStringSync('${resolution}p_video.m3u8\n'); + } else { + masterManifestAccessFile.writeStringSync( + '#EXT-X-STREAM-INF:BANDWIDTH=1327000,CODECS="mp4a.40.2",RESOLUTION=480x-2,NAME=720\n'); + masterManifestAccessFile + .writeStringSync('480p_video.m3u8\n'); + } + } + masterManifestAccessFile.closeSync(); + debugPrint('Master Manifest generated at location - ${masterManifestPath}'); + } + + void createZip(String sourcePath, String zipFilePath) { + ZipFileEncoder archiveBuilder = ZipFileEncoder(); + archiveBuilder.zipDirectory(Directory(sourcePath), + filename: '$zipFilePath/out.zip'); + } + + Future zipFolder( + String folderPath, + ) async { + // Get a directory reference + final folder = Directory(folderPath); + + if (!folder.existsSync()) { + throw Exception("The folder does not exist"); + } + + // Create a ZIP archive + final archive = Archive(); + + // Recursively add files and directories + for (var entity in folder.listSync(recursive: true)) { + if (entity is File) { + // Read the file and add it to the archive + final fileBytes = entity.readAsBytesSync(); + final relativePath = entity.path.substring(folder.path.length + 1); + archive.addFile(ArchiveFile(relativePath, fileBytes.length, fileBytes)); + } + } + + // Encode the archive and write it to a ZIP file + final zipData = ZipEncoder().encode(archive); + + // final zipFile = File(folderPath); + // zipFile.writeAsBytesSync(zipData!); + + final zipFile = File("$folderPath/${VideoEncoder.zipFileName}"); + zipFile.writeAsBytesSync(zipData!); + + print('Folder zipped successfully to ${zipFile.path}'); + } +} diff --git a/lib/src/screens/upload/video/encoding/resolution.dart b/lib/src/screens/upload/video/encoding/resolution.dart new file mode 100644 index 00000000..069dba7d --- /dev/null +++ b/lib/src/screens/upload/video/encoding/resolution.dart @@ -0,0 +1,26 @@ +import 'package:equatable/equatable.dart'; + +class VideoResolution extends Equatable { + final int width; + final int height; + final String resolution; + final bool isLandscape; + final bool convertVideo; + final int? originalWidth; + final int? originalHeight; + + const VideoResolution({ + required this.width, + required this.height, + required this.isLandscape, + this.originalWidth, + this.originalHeight, + this.convertVideo = true, + }) : resolution = '${height}p' ; + + @override + List get props => [width, height, isLandscape, convertVideo]; + + static String quality(VideoResolution resolution) => + '${resolution.isLandscape ? resolution.width : resolution.height}'; +} diff --git a/lib/src/screens/upload/video/encoding/video_encoder.dart b/lib/src/screens/upload/video/encoding/video_encoder.dart new file mode 100644 index 00000000..697ed906 --- /dev/null +++ b/lib/src/screens/upload/video/encoding/video_encoder.dart @@ -0,0 +1,274 @@ +import 'dart:async'; +import 'dart:developer'; +import 'package:acela/src/screens/upload/video/encoding/folder_path.dart'; +import 'package:acela/src/screens/upload/video/encoding/resolution.dart'; +import 'package:acela/src/utils/storages/video_storage.dart'; +import 'package:ffmpeg_kit_flutter_https_gpl/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_https_gpl/ffmpeg_session.dart'; +import 'package:ffmpeg_kit_flutter_https_gpl/ffprobe_kit.dart' as ffprobe; +import 'package:ffmpeg_kit_flutter_https_gpl/media_information_session.dart'; +import 'package:flutter/foundation.dart'; + +class VideoEncoder { + static List allResolutions = [720, 480]; + + static String testFolderPath = '/storage/emulated/0/Download/$foldername'; + + static String testPath = '/storage/emulated/0/Download'; + + static String foldername = 'encoded_videos'; + static String zipFileName = "video.zip"; + + late MediaInformationSession info; + final VideoStorage _storage = VideoStorage(); + + Future getVideoResolution(String filePath) async { + int rotation = await getVideoRotation(filePath); + + int width = info.getMediaInformation()?.getAllProperties()?['streams'][0] + ?['width'] ?? + info.getMediaInformation()?.getAllProperties()?['streams'][1] + ?['width'] ?? + 480; + int height = info.getMediaInformation()?.getAllProperties()?['streams'][0] + ?['height'] ?? + info.getMediaInformation()?.getAllProperties()?['streams'][1] + ?['height'] ?? + 320; + + if (rotation == 90 || + rotation == 270 || + rotation == -90 || + rotation == -270) { + int temp = width; + width = height; + height = temp; + } + + bool isLandscape = width > height; + return VideoResolution( + originalHeight: height, + originalWidth: width, + width: getEvenDigit(width), + height: getEvenDigit(height), + isLandscape: isLandscape, + convertVideo: !VideoEncoder.allResolutions + .contains(isLandscape ? width : height)); + } + + Future getVideoRotation(String filePath) async { + try { + info = await ffprobe.FFprobeKit.getMediaInformation(filePath); + var properties = info.getMediaInformation()?.getAllProperties(); + + if (properties?['streams'] is List && + properties!['streams'].isNotEmpty && + properties['streams'][0]['side_data_list'] is List && + properties['streams'][0]['side_data_list'].isNotEmpty && + properties['streams'][0]['side_data_list'][0]['rotation'] != null) { + return properties['streams'][0]['side_data_list'][0]['rotation']; + } + return 0; + } catch (e) { + print('Error retrieving video rotation: $e'); + return 0; + } + } + + VideoResolution getResolution( + VideoResolution resolution, int targetResolution) { + if (resolution.isLandscape) { + // int target = + // ((targetResolution * resolution.height) / resolution.width).round(); + int target = + ((targetResolution * resolution.width) / resolution.height).round(); + var res = VideoResolution( + width: getEvenDigit(target), + height: targetResolution, + isLandscape: resolution.isLandscape); + return res; + } else { + int target = + ((targetResolution * resolution.width) / resolution.height).round(); + return VideoResolution( + width: getEvenDigit(target), + height: targetResolution, + isLandscape: resolution.isLandscape); + } + } + + int getEvenDigit(int number) { + return number.isOdd ? (number + 1) ~/ 2 * 2 : number; + } + + List generateTargetResolutions( + VideoResolution originalResolution, + ) { + debugPrint(originalResolution.toString()); + List targetResolutions = [originalResolution]; + + for (int resolution in VideoEncoder.allResolutions) { + if (!isTargetSolutionAlreadyContainResolution( + targetResolutions, resolution) && + isOriginalResolutionGreater( + resolution, + originalResolution, + )) { + targetResolutions + .add(getResolution(targetResolutions.last, resolution)); + targetResolutions.removeWhere((element) { + int originalResolutionNum = + originalResolution.isLandscape ? element.width : element.height; + bool notContains = + !VideoEncoder.allResolutions.contains(originalResolutionNum); + if (notContains && VideoEncoder.allResolutions.length >= 2) { + if (originalResolutionNum < VideoEncoder.allResolutions.first && + originalResolutionNum > VideoEncoder.allResolutions[1]) { + return false; + } + } + return notContains; + }); + } + } + log(targetResolutions.toString()); + return targetResolutions; + } + + bool isTargetSolutionAlreadyContainResolution( + List resolutions, int targetResolution) { + for (var item in resolutions) { + bool isLandscape = item.isLandscape; + if (isLandscape) { + return item.width == targetResolution; + } else { + return item.height == targetResolution; + } + } + return false; + } + + isOriginalResolutionGreater( + int resolution, VideoResolution originalResolution) { + return originalResolution.height >= resolution; + // if (originalResolution.isLandscape) { + // return originalResolution.width >= resolution; + // } else { + // return originalResolution.height >= resolution; + // } + } + + Future convertToMultipleResolutions( + String inputPath, + ValueNotifier progressListener, + VoidCallback onComplete, + Function(double) duration, + Function(String) onError) async { + FolderPath folderPath = FolderPath(); + await folderPath.deleteDirectory(); + String encodingPath = await folderPath.createFolder(); + + List scales = _storage.readEncodingQualities(); + List progressList = List.filled(scales.length, 0.0); + + StreamController combinedProgressStream = + StreamController(); + + await _progressListener( + combinedProgressStream, progressListener, onComplete, onError); + + for (int i = 0; i < scales.length; i++) { + debugPrint('${i + 1} encoding started for resolution ${scales[i]}'); + await encodeVideo( + inputPath, + encodingPath, + scales[i], + () => combinedProgressStream.isClosed, + duration, (individualProgress, session) async { + progressList[i] = individualProgress; + double combinedProgress = + progressList.reduce((a, b) => a + b) / scales.length; + if (!combinedProgressStream.isClosed) { + combinedProgressStream.add(combinedProgress); + } else { + session?.cancel(); + } + }, onError); + debugPrint('${i + 1} encoding ended for resolution ${scales[i]}'); + if (i == scales.length - 1) { + folderPath.generateMasterManifest(encodingPath, scales); + debugPrint('Manifest file generated'); + } + } + } + + Future _progressListener( + StreamController combinedProgressStream, + ValueNotifier progressListener, + VoidCallback onComplete, + Function(String) onError) async { + await combinedProgressStream.stream.listen((combinedProgress) async { + try { + progressListener.value = combinedProgress / 100; + debugPrint("Overall Progress: ${combinedProgress.toStringAsFixed(2)}%"); + if (combinedProgress == 100) { + combinedProgressStream.close(); + + onComplete(); + } + } catch (e) { + combinedProgressStream.close(); + onError(e.toString()); + } + }, onError: (e) { + combinedProgressStream.close(); + onError(e.toString()); + }, cancelOnError: true); + } + + Future encodeVideo( + String inputPath, + String outputPath, + // VideoResolution resolution, + String scale, + bool Function() isStreamCancelled, + Function(double) setDuration, + Function(double, FFmpegSession?) onProgressUpdate, + Function(String) onError) async { + String command; + if (scale == '720') { + command = + '''-i $inputPath -vf scale=${scale}:-2,setsar=1:1 -c:v libx264 -crf 22 -b:v 0.65M -start_number 0 -hls_time 10 -hls_list_size 0 -f hls $outputPath/${scale}p_video.m3u8'''; + } else { + command = + '''-i $inputPath -vf scale=${scale}:-2,setsar=1:1 -c:v libx264 -crf 22 -b:v 0.35M -start_number 0 -hls_time 10 -hls_list_size 0 -f hls $outputPath/${scale}p_video.m3u8'''; + } + String? duratio = info.getMediaInformation()?.getDuration(); + setDuration(double.parse(duratio!)); + FFmpegSession? session; + session = await FFmpegKit.executeAsync( + command, + (session) { + if (!isStreamCancelled()) { + onProgressUpdate(100, session); + debugPrint('Video encoding completed successfully'); + } + }, + (log) {}, + (statistics) async { + double progress = + ((statistics.getTime()) ~/ double.parse(duratio)) / 10; + if (isStreamCancelled()) { + session?.cancel(); + onProgressUpdate(progress, session); + debugPrint('stream closed session cancelled'); + return; + } else { + onProgressUpdate(progress, null); + } + }, + ).catchError((e) { + onError(e.toString()); + }); + } +} diff --git a/lib/src/screens/upload/video/mixins/video_save_mixin.dart b/lib/src/screens/upload/video/mixins/video_save_mixin.dart new file mode 100644 index 00000000..03054b80 --- /dev/null +++ b/lib/src/screens/upload/video/mixins/video_save_mixin.dart @@ -0,0 +1,71 @@ +import 'package:acela/src/models/my_account/video_ops.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/models/video_upload/video_device_encode_upload_model.dart'; +import 'package:acela/src/models/video_upload/video_upload_prepare_response.dart'; +import 'package:acela/src/screens/settings/settings_screen.dart'; +import 'package:acela/src/utils/communicator.dart'; +import 'package:flutter/cupertino.dart'; + +mixin VideoSaveMixin { + ValueNotifier savingText = ValueNotifier('Saving video info'); + ValueNotifier isSaving = ValueNotifier(false); + + Future saveVideo( + HiveUserData user, + VideoUploadInfo item, + bool hasPostingAuthority, { + required String title, + required String description, + required bool isNsfwContent, + required String tags, + required String thumbIpfs, + required String communityId, + required List beneficiaries, + required VideoLanguage language, + required bool isPowerUp100, + required VoidCallback successDialog, + required Function(String) errorSnackbar, + }) async { + try { + String body = + "${description}${hasPostingAuthority ? "
Uploaded using 3Speak Mobile App" : ""}"; + await Communicator().updateInfo( + user: user, + videoId: item.id, + title: title, + description: body, + isNsfwContent: isNsfwContent, + tags: tags, + beneficiaries: beneficiaries, + thumbnail: thumbIpfs.isEmpty ? null : thumbIpfs, + communityID: communityId, + ); + isSaving.value = false; + successDialog(); + } catch (e) { + isSaving.value = false; + errorSnackbar(e.toString()); + } + } + + Future saveDeviceEncodedVideo( + HiveUserData user, + VideoDeviceEncodeUploadModel data, + bool hasPostingAuthority, { + required Function(String) errorSnackbar, + required VoidCallback successDialog, + }) async { + try { + var updatedData = data.copyWith( + description: + "${data.description}${hasPostingAuthority ? "
Uploaded using 3Speak Mobile App" : ""}"); + await Communicator() + .saveDeviceEncodedVideo(user: user, data: updatedData); + isSaving.value = false; + successDialog(); + } catch (e) { + isSaving.value = false; + errorSnackbar(e.toString()); + } + } +} diff --git a/lib/src/screens/upload/video/mixins/video_upload_mixin.dart b/lib/src/screens/upload/video/mixins/video_upload_mixin.dart new file mode 100644 index 00000000..688fe6f5 --- /dev/null +++ b/lib/src/screens/upload/video/mixins/video_upload_mixin.dart @@ -0,0 +1,256 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/models/video_upload/upload_response.dart'; +import 'package:acela/src/models/video_upload/video_info.dart'; +import 'package:acela/src/models/video_upload/video_upload_prepare_response.dart'; +import 'package:acela/src/screens/upload/video/encoding/folder_path.dart'; +import 'package:acela/src/screens/upload/video/encoding/resolution.dart'; +import 'package:acela/src/screens/upload/video/encoding/video_encoder.dart'; +import 'package:acela/src/utils/communicator.dart'; +import 'package:acela/src/utils/enum.dart'; +import 'package:ffmpeg_kit_flutter_https_gpl/ffprobe_kit.dart'; +import 'package:ffmpeg_kit_flutter_https_gpl/media_information_session.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:tus_client/tus_client.dart'; +import 'package:video_thumbnail/video_thumbnail.dart'; + +mixin Upload { + PageController pageController = PageController(); + ValueNotifier videoUploadProgress = ValueNotifier(0); + ValueNotifier finalUploadProgress = ValueNotifier(0); + ValueNotifier thumbnailUploadProgress = ValueNotifier(0); + ValueNotifier uploadStatus = ValueNotifier(UploadStatus.idle); + ValueNotifier thumbnailUploadStatus = + ValueNotifier(UploadStatus.idle); + ValueNotifier thumbnailUploadResponse = ValueNotifier(null); + + int page = 0; + VideoInfo? videoInfo; + late VideoUploadInfo uploadedVideoItem; + File? pickedThumbnail; + + Future onUpload( + {required XFile pickedVideoFile, + required HiveUserData hiveUserData, + required Function(String) onError, + required bool isDeviceEncoding}) async { + if (isDeviceEncoding) { + await localEncodeAndUpload(pickedVideoFile, hiveUserData, onError); + } else { + await serverEncodeAndUpload(pickedVideoFile, hiveUserData, onError); + } + } + + Future localEncodeAndUpload(XFile pickedVideoFile, + HiveUserData hiveUserData, Function(String) onError) async { + var size = await pickedVideoFile.length(); + var originalFileName = pickedVideoFile.name; + videoInfo = VideoInfo(size: size, originalFilename: originalFileName); + _initiateNextUpload(); + uploadStatus.value = UploadStatus.started; + _checkFileSize(size); + var path = pickedVideoFile.path; + FolderPath().printFolderContent(FolderPath().path()); + encodeVideoAndThen(path, () async { + _initiateNextUpload(); + await _setThumbnailForLocalEncode(path); + _initiateNextUpload(); + var zipPath = FolderPath().readZipFile().path; + var response = await _uploadToServer(zipPath, finalUploadProgress); + videoInfo = videoInfo!.copyWith(tusId: response.url); + uploadStatus.value = UploadStatus.ended; + _initiateNextUpload(); + }, onError); + } + + Future serverEncodeAndUpload(XFile pickedVideoFile, + HiveUserData hiveUserData, Function(String) onError) async { + var size = await pickedVideoFile.length(); + var originalFileName = pickedVideoFile.name; + + _initiateNextUpload(); + uploadStatus.value = UploadStatus.started; + log('upload started'); + int fileSize = _checkFileSize(size); + var path = pickedVideoFile.path; + var videoUploadReponse = await _uploadToServer(path, videoUploadProgress); + var name = videoUploadReponse.name; + _initiateNextUpload(); + var thumbPath = await _getThumbnailForServerEncode(path); + _initiateNextUpload(); + var thumbReponse = await uploadThumbnail(thumbPath); + _initiateNextUpload(); + + log('Uploaded file name is $name'); + log('Uploaded thumbnail file name is ${thumbReponse.name}'); + uploadedVideoItem = await _encodeAndUploadInfo(path, hiveUserData, + thumbReponse.name, originalFileName, fileSize, name); + uploadStatus.value = UploadStatus.ended; + _initiateNextUpload(); + } + + bool isFreshUpload() { + return uploadStatus.value == UploadStatus.idle; + } + + void _initiateNextUpload() { + if (pageController.hasClients) { + page++; + pageController.animateToPage(page, + duration: const Duration(milliseconds: 250), curve: Curves.easeIn); + } else { + page++; + } + } + + void jumpToPage() { + pageController.jumpToPage( + page, + ); + } + + Future _setThumbnailForLocalEncode(String path) async { + FolderPath folderPath = FolderPath(); + String resultPath = folderPath.path(); + final newThumbnailPath = '${resultPath}/thumbnail.png'; + File thumbnailFile; + if (pickedThumbnail == null) { + var imagePath = await VideoThumbnail.thumbnailFile( + video: path, + thumbnailPath: folderPath.path(), + imageFormat: ImageFormat.PNG, + maxWidth: 320, + quality: 100, + ); + if (imagePath == null) { + throw 'Could not generate video thumbnail'; + } + thumbnailFile = File(imagePath); + await thumbnailFile.rename(newThumbnailPath); + } else { + thumbnailFile = await pickedThumbnail!.copy(newThumbnailPath); + } + + await folderPath.zipFolder(resultPath); + folderPath.printFolderContent(resultPath); + debugPrint(folderPath.printM3u8Contents().toString()); + debugPrint(folderPath.printFolderContent(resultPath).toString()); + return newThumbnailPath; + } + + Future _getThumbnailForServerEncode(String path) async { + try { + Directory tempDir = Directory.systemTemp; + var imagePath = await VideoThumbnail.thumbnailFile( + video: path, + thumbnailPath: tempDir.path, + imageFormat: ImageFormat.PNG, + maxWidth: 320, + quality: 100, + ); + if (imagePath == null) { + throw 'Could not generate video thumbnail'; + } + return imagePath; + } catch (e) { + throw 'Error generating video thumbnail ${e.toString()}'; + } + } + + int _checkFileSize(int size) { + var fileSize = size; + var sizeInMb = fileSize / 1000 / 1000; + log("Compressed video file size in mb is - $sizeInMb"); + if (sizeInMb > 1024) { + throw 'Video is too big to be uploaded from mobile (exceeding 500 mb)'; + } + return fileSize; + } + + Future uploadThumbnail(String path) async { + thumbnailUploadStatus.value = UploadStatus.started; + var thumbReponse = await _uploadToServer(path, thumbnailUploadProgress); + thumbnailUploadStatus.value = UploadStatus.ended; + thumbnailUploadResponse.value = (thumbReponse); + return thumbReponse; + } + + Future _encodeAndUploadInfo( + String path, + HiveUserData hiveUserData, + String thumbName, + String originalFileName, + int fileSize, + String name) async { + MediaInformationSession session = + await FFprobeKit.getMediaInformation(path); + var info = session.getMediaInformation(); + var duration = + (double.tryParse(info?.getDuration() ?? "0.0") ?? 0.0).toInt(); + + return await Communicator().uploadInfo( + user: hiveUserData, + thumbnail: thumbName, + oFilename: originalFileName, + duration: duration, + size: fileSize.toDouble(), + tusFileName: name, + ); + } + + Future _uploadToServer( + String path, ValueNotifier? progressIndicator) async { + try { + final xfile = XFile(path); + final client = TusClient( + Uri.parse(Communicator.fsServer), + xfile, + store: TusMemoryStore(), + ); + var name = ""; + var url = ''; + await client.upload( + onComplete: () async { + if (progressIndicator != null) progressIndicator.value = 1.0; + debugPrint("Complete!"); + debugPrint(client.uploadUrl.toString()); + url = client.uploadUrl.toString(); + var ipfsName = url.replaceAll("${Communicator.fsServer}/", ""); + name = ipfsName; + }, + onProgress: (progress) { + log("Progress: $progress"); + if (progressIndicator != null) + progressIndicator.value = progress / 100.0; + }, + ); + return UploadResponse(name: name, url: url); + } catch (e) { + rethrow; + } + } + + Future encodeVideoAndThen(String filePath, VoidCallback onComplete, + Function(String) onError) async { + VideoEncoder encoder = VideoEncoder(); + VideoResolution? originalResolution = + await encoder.getVideoResolution(filePath); + videoInfo = videoInfo!.copyWith( + isLandscape: originalResolution!.isLandscape, + height: originalResolution.originalHeight, + width: originalResolution.originalWidth); + // List all = + // encoder.generateTargetResolutions(originalResolution); + await encoder.convertToMultipleResolutions( + filePath, + // all, + videoUploadProgress, + onComplete, + (duration) => + videoInfo = videoInfo!.copyWith(duration: duration.toInt()), + onError); + } +} diff --git a/lib/src/screens/upload/video/thumbnail_picker_view.dart b/lib/src/screens/upload/video/thumbnail_picker_view.dart new file mode 100644 index 00000000..c2aae915 --- /dev/null +++ b/lib/src/screens/upload/video/thumbnail_picker_view.dart @@ -0,0 +1,102 @@ +import 'dart:io'; + +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/upload/video/video_upload_screen.dart'; +import 'package:acela/src/utils/constants.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; + +class ThumbnailPickerView extends StatefulWidget { + const ThumbnailPickerView( + {super.key, + required this.appData, + required this.isCamera, + required this.isDeviceEncode, + required this.videoFile}); + + final HiveUserData appData; + final bool isCamera; + final bool isDeviceEncode; + final XFile videoFile; + @override + State createState() => _ThumbnailPickerViewState(); +} + +class _ThumbnailPickerViewState extends State { + File? file; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + title: Text("Pick Thumbnail"), + ), + floatingActionButton: SafeArea( + child: FloatingActionButton.extended( + label: Text("Next"), + onPressed: () { + var screen = VideoUploadScreen( + isCamera: widget.isCamera, + videoFile: widget.videoFile, + appData: widget.appData, + isDeviceEncode: widget.isDeviceEncode, + thumbnailFile: file, + ); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).pop(); + Navigator.of(context).pop(); + Navigator.of(context).push(route); + }, + ), + ), + body: SafeArea( + child: Padding( + padding: kScreenPadding, + child: Column( + children: [ + InkWell( + onTap: _onTap, + child: Container( + color: theme.cardColor.withOpacity(0.5), + width: double.infinity, + height: 160, + child: file != null ? Image.file(file!) : null), + ), + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.upload), + const SizedBox( + width: 7, + ), + Text( + "Tap here to set thumbnail", + style: TextStyle(color: Colors.red), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Future _onTap() async { + try { + final XFile? file = + await ImagePicker().pickImage(source: ImageSource.gallery); + if (file != null) { + if (mounted) { + setState(() { + this.file = File(file.path); + }); + } + } + } catch (e) {} + } +} diff --git a/lib/src/screens/upload/video/video_editor/crop_page.dart b/lib/src/screens/upload/video/video_editor/crop_page.dart new file mode 100644 index 00000000..4713eb9b --- /dev/null +++ b/lib/src/screens/upload/video/video_editor/crop_page.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import 'package:fraction/fraction.dart'; +import 'package:video_editor/video_editor.dart'; + +class CropPage extends StatelessWidget { + const CropPage({super.key, required this.controller}); + + final VideoEditorController controller; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 30), + child: Column(children: [ + Row(children: [ + Expanded( + child: IconButton( + onPressed: () => + controller.rotate90Degrees(RotateDirection.left), + icon: const Icon(Icons.rotate_left), + ), + ), + Expanded( + child: IconButton( + onPressed: () => + controller.rotate90Degrees(RotateDirection.right), + icon: const Icon(Icons.rotate_right), + ), + ) + ]), + const SizedBox(height: 15), + Expanded( + child: CropGridViewer.edit( + controller: controller, + rotateCropArea: false, + margin: const EdgeInsets.symmetric(horizontal: 20), + ), + ), + const SizedBox(height: 15), + Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Expanded( + flex: 2, + child: IconButton( + onPressed: () => Navigator.pop(context), + icon: const Center( + child: Text( + "cancel", + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + ), + Expanded( + flex: 4, + child: AnimatedBuilder( + animation: controller, + builder: (_, __) => Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + onPressed: () => + controller.preferredCropAspectRatio = controller + .preferredCropAspectRatio + ?.toFraction() + .inverse() + .toDouble(), + icon: controller.preferredCropAspectRatio != null && + controller.preferredCropAspectRatio! < 1 + ? const Icon( + Icons.panorama_vertical_select_rounded) + : const Icon(Icons.panorama_vertical_rounded), + ), + IconButton( + onPressed: () => + controller.preferredCropAspectRatio = controller + .preferredCropAspectRatio + ?.toFraction() + .inverse() + .toDouble(), + icon: controller.preferredCropAspectRatio != null && + controller.preferredCropAspectRatio! > 1 + ? const Icon( + Icons.panorama_horizontal_select_rounded) + : const Icon(Icons.panorama_horizontal_rounded), + ), + ], + ), + Row( + children: [ + _buildCropButton(context, null), + _buildCropButton(context, 1.toFraction()), + _buildCropButton( + context, Fraction.fromString("9/16")), + _buildCropButton(context, Fraction.fromString("3/4")), + ], + ) + ], + ), + ), + ), + Expanded( + flex: 2, + child: IconButton( + onPressed: () { + controller.applyCacheCrop(); + Navigator.pop(context); + }, + icon: Center( + child: Text( + "done", + style: TextStyle( + color: const CropGridStyle().selectedBoundariesColor, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ]), + ]), + ), + ), + ); + } + + Widget _buildCropButton(BuildContext context, Fraction? f) { + if (controller.preferredCropAspectRatio != null && + controller.preferredCropAspectRatio! > 1) f = f?.inverse(); + + return Flexible( + child: TextButton( + style: ElevatedButton.styleFrom( + elevation: 0, + backgroundColor: controller.preferredCropAspectRatio == f?.toDouble() + ? Colors.grey.shade800 + : null, + foregroundColor: controller.preferredCropAspectRatio == f?.toDouble() + ? Colors.white + : null, + textStyle: Theme.of(context).textTheme.bodySmall, + ), + onPressed: () => controller.preferredCropAspectRatio = f?.toDouble(), + child: Text(f == null ? 'free' : '${f.numerator}:${f.denominator}'), + ), + ); + } +} diff --git a/lib/src/screens/upload/video/video_editor/export_service.dart b/lib/src/screens/upload/video/video_editor/export_service.dart new file mode 100644 index 00000000..d73fecc3 --- /dev/null +++ b/lib/src/screens/upload/video/video_editor/export_service.dart @@ -0,0 +1,49 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:ffmpeg_kit_flutter_https_gpl/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_https_gpl/ffmpeg_kit_config.dart'; +import 'package:ffmpeg_kit_flutter_https_gpl/ffmpeg_session.dart'; +import 'package:ffmpeg_kit_flutter_https_gpl/return_code.dart'; +import 'package:ffmpeg_kit_flutter_https_gpl/statistics.dart'; + +import 'package:video_editor/video_editor.dart'; + +class ExportService { + static Future dispose() async { + final executions = await FFmpegKit.listSessions(); + if (executions.isNotEmpty) await FFmpegKit.cancel(); + } + + static Future runFFmpegCommand( + FFmpegVideoEditorExecute execute, { + required void Function(File file) onCompleted, + void Function(Object, StackTrace)? onError, + void Function(Statistics)? onProgress, + }) { + log('FFmpeg start process with command = ${execute.command}'); + return FFmpegKit.executeAsync( + execute.command, + (session) async { + final state = + FFmpegKitConfig.sessionStateToString(await session.getState()); + final code = await session.getReturnCode(); + + if (ReturnCode.isSuccess(code)) { + onCompleted(File(execute.outputPath)); + } else { + if (onError != null) { + onError( + Exception( + 'FFmpeg process exited with state $state and return code $code.\n${await session.getOutput()}'), + StackTrace.current, + ); + } + return; + } + }, + null, + onProgress, + ); + } +} diff --git a/lib/src/screens/upload/video/video_editor/video_edit_screen.dart b/lib/src/screens/upload/video/video_editor/video_edit_screen.dart new file mode 100644 index 00000000..b6e5c493 --- /dev/null +++ b/lib/src/screens/upload/video/video_editor/video_edit_screen.dart @@ -0,0 +1,220 @@ +import 'dart:io'; +import 'package:acela/src/screens/upload/video/video_editor/export_service.dart'; +import 'package:acela/src/screens/upload/video/video_editor/widgets/video_edit_action_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:video_editor/video_editor.dart'; + +class VideoEditorScreen extends StatefulWidget { + const VideoEditorScreen({super.key, required this.file}); + + final File file; + + @override + State createState() => _VideoEditorScreenState(); +} + +class _VideoEditorScreenState extends State { + final _exportingProgress = ValueNotifier(0.0); + final _isExporting = ValueNotifier(false); + final double height = 60; + + late final VideoEditorController _controller = VideoEditorController.file( + widget.file, + minDuration: const Duration(seconds: 1), + maxDuration: const Duration(minutes: 120), + ); + + @override + void initState() { + super.initState(); + _controller + .initialize(aspectRatio: 9 / 16) + .then((_) => setState(() {})) + .catchError((error) { + Navigator.pop(context); + }, test: (e) => e is VideoMinDurationError); + } + + @override + void dispose() async { + _exportingProgress.dispose(); + _isExporting.dispose(); + _controller.dispose(); + ExportService.dispose(); + super.dispose(); + } + + void _showErrorSnackBar(String message) => + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + duration: const Duration(seconds: 1), + ), + ); + + void _exportVideo() async { + _exportingProgress.value = 0; + _isExporting.value = true; + + final config = VideoFFmpegVideoEditorConfig( + _controller, + ); + + await ExportService.runFFmpegCommand( + await config.getExecuteConfig(), + onProgress: (stats) { + _exportingProgress.value = + config.getFFmpegProgress(stats.getTime().toInt()); + }, + onError: (e, s) => _showErrorSnackBar("Error on export video :("), + onCompleted: (file) { + _isExporting.value = false; + if (!mounted) return; + + Navigator.pop(context, file); + }, + ); + } + + @override + Widget build(BuildContext context) { + return PopScope( + onPopInvokedWithResult: (didPop, result) => false, + child: Scaffold( + backgroundColor: Colors.black, + appBar: VideoEditActionBar( + height: height, + controller: _controller, + isExporting: _isExporting, + exportingProgress: _exportingProgress, + exportVideo: _exportVideo), + body: _controller.initialized + ? SafeArea( + child: Column( + children: [ + Expanded( + child: Stack( + alignment: Alignment.center, + children: [ + CropGridViewer.preview(controller: _controller), + AnimatedBuilder( + animation: _controller.video, + builder: (_, __) => AnimatedOpacity( + opacity: _controller.isPlaying ? 0 : 1, + duration: kThemeAnimationDuration, + child: GestureDetector( + onTap: _controller.video.play, + child: Container( + width: 40, + height: 40, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.play_arrow, + color: Colors.black, + ), + ), + ), + ), + ), + ], + ), + ), + Gap(30), + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: _trimSlider(), + ), + ValueListenableBuilder( + valueListenable: _isExporting, + builder: (_, bool export, Widget? child) => + AnimatedSize( + duration: kThemeAnimationDuration, + child: export ? child : null, + ), + child: ValueListenableBuilder( + valueListenable: _exportingProgress, + builder: (_, double value, __) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Exporting video ${(value * 100).ceil()}%", + style: const TextStyle(fontSize: 12), + ), + Gap(5), + LinearProgressIndicator( + value: value, + ) + ], + ), + ), + )) + ], + ), + ) + : const Center(child: CircularProgressIndicator()), + ), + ); + } + + String formatter(Duration duration) => [ + duration.inMinutes.remainder(60).toString().padLeft(2, '0'), + duration.inSeconds.remainder(60).toString().padLeft(2, '0') + ].join(":"); + + List _trimSlider() { + return [ + AnimatedBuilder( + animation: Listenable.merge([ + _controller, + _controller.video, + ]), + builder: (_, __) { + final int duration = _controller.videoDuration.inSeconds; + final double pos = _controller.trimPosition * duration; + + return Padding( + padding: EdgeInsets.symmetric(horizontal: height / 4), + child: Row( + children: [ + Text(formatter(Duration(seconds: pos.toInt()))), + const Expanded(child: SizedBox()), + AnimatedOpacity( + opacity: _controller.isTrimming ? 1 : 0, + duration: kThemeAnimationDuration, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(formatter(_controller.startTrim)), + const SizedBox(width: 10), + Text(formatter(_controller.endTrim)), + ], + ), + ), + ], + ), + ); + }, + ), + Container( + width: MediaQuery.of(context).size.width, + margin: EdgeInsets.symmetric(vertical: height / 4), + child: TrimSlider( + controller: _controller, + height: height, + horizontalMargin: height / 4, + child: TrimTimeline( + controller: _controller, + padding: const EdgeInsets.only(top: 10), + ), + ), + ) + ]; + } +} diff --git a/lib/src/screens/upload/video/video_editor/video_picker_screen.dart b/lib/src/screens/upload/video/video_editor/video_picker_screen.dart new file mode 100644 index 00000000..fd6bda28 --- /dev/null +++ b/lib/src/screens/upload/video/video_editor/video_picker_screen.dart @@ -0,0 +1,174 @@ +import 'dart:io'; + +import 'package:acela/src/extensions/ui.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/home_screen/home_screen_feed_item/widgets/video_encoder_switch.dart'; +import 'package:acela/src/screens/upload/video/thumbnail_picker_view.dart'; +import 'package:acela/src/screens/upload/video/video_editor/video_edit_screen.dart'; +import 'package:acela/src/screens/upload/video/video_editor/video_result_player.dart'; +import 'package:acela/src/screens/upload/video/video_upload_screen.dart'; +import 'package:acela/src/utils/constants.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:provider/provider.dart'; + +class VideoPickerScreen extends StatefulWidget { + const VideoPickerScreen({super.key}); + + @override + State createState() => _VideoPickerScreenState(); +} + +class _VideoPickerScreenState extends State { + final ValueNotifier isDeviceEncode = ValueNotifier(true); + + XFile? file; + @override + Widget build(BuildContext context) { + return Scaffold( + floatingActionButton: FloatingActionButton.extended( + label: Text("Next"), onPressed: () => onNext(context)), + appBar: AppBar( + title: Text("Pick your video"), + ), + body: SafeArea( + child: SingleChildScrollView( + padding: kScreenVerticalPadding, + child: Column( + children: [ + if (file != null) + Padding( + padding: kScreenHorizontalPadding, + child: Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Column( + children: [ + _videoHeader(context), + Container( + color: Colors.grey.shade900.withOpacity(0.2), + width: double.infinity, + height: 300, + child: VideoResultPlayer(video: File(file!.path))), + ], + ), + ), + ), + ListTile( + leading: Icon(Icons.video_file), + title: Text( + "Pick video", + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: "Camera", + onPressed: () => _pickVideo(true), + icon: Icon(Icons.camera), + ), + Gap(5), + IconButton( + tooltip: "Gallery", + onPressed: _pickVideo, + icon: Icon(Icons.image), + ), + ], + ), + ), + ListTile( + onTap: () { + isDeviceEncode.value = !isDeviceEncode.value; + }, + title: const Text('Encode video on device'), + leading: const Icon(Icons.emergency_outlined), + trailing: VideoEncoderSwitch( + valueNotifier: isDeviceEncode, + ), + ) + ], + ), + ), + ), + ); + } + + Row _videoHeader(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text( + file!.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + )), + Gap(15), + TextButton.icon( + onPressed: () async { + File? file = await Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) => + VideoEditorScreen( + file: File(this.file!.path)), + ), + ); + if (file != null && mounted) { + setState(() { + this.file = XFile(file.path); + }); + } + }, + icon: Icon( + Icons.edit, + size: 18, + ), + label: Text("Edit Video")) + ], + ); + } + + void onNext(BuildContext context) { + var data = context.read(); + if (file == null) { + context.showSnackBar("Please pick video to proceed"); + } else { + var screen; + if (isDeviceEncode.value) { + screen = ThumbnailPickerView( + isCamera: false, + appData: data, + videoFile: file!, + isDeviceEncode: isDeviceEncode.value, + ); + } else { + screen = VideoUploadScreen( + isCamera: false, + videoFile: file!, + appData: data, + isDeviceEncode: isDeviceEncode.value, + ); + } + if(!isDeviceEncode.value){ + Navigator.of(context).pop(); + } + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + } + } + + void _pickVideo([bool isCamera = false]) async { + final XFile? file; + file = await ImagePicker().pickVideo( + source: isCamera ? ImageSource.camera : ImageSource.gallery, + preferredCameraDevice: CameraDevice.front, + ); + if (file != null) { + if (mounted) { + setState(() { + this.file = file; + }); + } + } + } +} diff --git a/lib/src/screens/upload/video/video_editor/video_result_player.dart b/lib/src/screens/upload/video/video_editor/video_result_player.dart new file mode 100644 index 00000000..31983832 --- /dev/null +++ b/lib/src/screens/upload/video/video_editor/video_result_player.dart @@ -0,0 +1,127 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +class VideoResultPlayer extends StatefulWidget { + const VideoResultPlayer({super.key, required this.video}); + + final File video; + + @override + State createState() => _VideoResultPlayerState(); +} + +class _VideoResultPlayerState extends State { + VideoPlayerController? _controller; + bool _showControls = true; + bool _isMuted = true; + Timer? _hideControlsTimer; + + @override + void initState() { + super.initState(); + _init(); + } + + void _init() { + _controller = VideoPlayerController.file(widget.video) + ..initialize().then((_) { + setState(() {}); + _controller?.play(); + _controller?.setLooping(true); + if (_isMuted) { + _controller?.setVolume(_isMuted ? 0 : 1); + } + }); + + _startHideTimer(); + } + + @override + void didUpdateWidget(covariant VideoResultPlayer oldWidget) { + if (oldWidget != widget) { + _controller?.dispose(); + _init(); + } + super.didUpdateWidget(oldWidget); + } + + void _startHideTimer() { + _hideControlsTimer?.cancel(); + _hideControlsTimer = Timer(const Duration(seconds: 2), () { + setState(() => _showControls = false); + }); + } + + void _togglePlayPause() { + if (_controller!.value.isPlaying) { + _controller!.pause(); + } else { + _controller!.play(); + } + setState(() => _showControls = true); + _startHideTimer(); + } + + void _toggleMute() { + _isMuted = !_isMuted; + _controller?.setVolume(_isMuted ? 0 : 1); + setState(() {}); + } + + @override + void dispose() { + _hideControlsTimer?.cancel(); + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _togglePlayPause, + child: Container( + height: 300, + width: double.infinity, + child: ClipRRect( + child: Stack( + alignment: Alignment.center, + children: [ + AspectRatio( + aspectRatio: _controller?.value.aspectRatio ?? 1, + child: _controller?.value.isInitialized == true + ? VideoPlayer(_controller!) + : const Center(child: CircularProgressIndicator()), + ), + if (_showControls) + AnimatedOpacity( + opacity: _showControls ? 1 : 0, + duration: const Duration(milliseconds: 300), + child: Icon( + _controller!.value.isPlaying + ? Icons.pause + : Icons.play_arrow, + size: 50, + color: Colors.white.withOpacity(0.8), + ), + ), + Positioned( + bottom: 5, + right: 5, + child: IconButton( + icon: Icon( + _isMuted ? Icons.volume_off : Icons.volume_up, + color: Colors.white, + ), + onPressed: _toggleMute, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/screens/upload/video/video_editor/widgets/video_edit_action_bar.dart b/lib/src/screens/upload/video/video_editor/widgets/video_edit_action_bar.dart new file mode 100644 index 00000000..35ed99ce --- /dev/null +++ b/lib/src/screens/upload/video/video_editor/widgets/video_edit_action_bar.dart @@ -0,0 +1,108 @@ +import 'package:acela/src/screens/upload/video/video_editor/crop_page.dart'; +import 'package:flutter/material.dart'; +import 'package:video_editor/video_editor.dart'; + +class VideoEditActionBar extends StatelessWidget + implements PreferredSizeWidget { + final double height; + final VideoEditorController controller; + final ValueNotifier isExporting; + final ValueNotifier exportingProgress; + final VoidCallback exportVideo; + + const VideoEditActionBar({ + required this.height, + required this.controller, + required this.isExporting, + required this.exportingProgress, + required this.exportVideo, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Container( + color: Colors.black, + height: preferredSize.height, + child: Row( + children: [ + Expanded( + child: IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.exit_to_app), + tooltip: 'Leave editor', + ), + ), + const VerticalDivider(endIndent: 22, indent: 22), + Expanded( + child: IconButton( + onPressed: () => + controller.rotate90Degrees(RotateDirection.left), + icon: const Icon(Icons.rotate_left), + tooltip: 'Rotate unclockwise', + ), + ), + Expanded( + child: IconButton( + onPressed: () => + controller.rotate90Degrees(RotateDirection.right), + icon: const Icon(Icons.rotate_right), + tooltip: 'Rotate clockwise', + ), + ), + Expanded( + child: IconButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CropPage(controller: controller), + ), + ), + icon: const Icon(Icons.crop), + tooltip: 'Open crop screen', + ), + ), + const VerticalDivider(endIndent: 22, indent: 22), + Expanded( + child: ValueListenableBuilder( + valueListenable: isExporting, + builder: (_, bool export, Widget? child) => AnimatedSize( + duration: kThemeAnimationDuration, + child: export + ? child + : PopupMenuButton( + tooltip: 'Open export menu', + icon: const Icon(Icons.save), + itemBuilder: (context) => [ + PopupMenuItem( + onTap: exportVideo, + child: const Text('Export video'), + ), + ], + ), + ), + child: ValueListenableBuilder( + valueListenable: exportingProgress, + builder: (_, double value, __) => Center( + child: SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + value: value, + backgroundColor: Colors.grey.shade50.withOpacity(0.3), + strokeWidth: 2.5, + ), + ), + ), + ), + ), + ) + ], + ), + ), + ); + } + + @override + Size get preferredSize => Size.fromHeight(height); +} diff --git a/lib/src/screens/upload/video/video_upload_screen.dart b/lib/src/screens/upload/video/video_upload_screen.dart new file mode 100644 index 00000000..8ffac602 --- /dev/null +++ b/lib/src/screens/upload/video/video_upload_screen.dart @@ -0,0 +1,440 @@ +import 'dart:io'; + +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/my_account/my_account_screen.dart'; +import 'package:acela/src/screens/upload/video/controller/video_upload_controller.dart'; +import 'package:acela/src/screens/upload/video/widgets/beneficaries_tile.dart'; +import 'package:acela/src/screens/upload/video/widgets/community_picker.dart'; +import 'package:acela/src/screens/upload/video/widgets/confirm_schedule_time_dialog.dart'; +import 'package:acela/src/screens/upload/video/widgets/language_tile.dart'; +import 'package:acela/src/screens/upload/video/widgets/posting_authority_warning_widget.dart'; +import 'package:acela/src/screens/upload/video/widgets/publish_fab.dart'; +import 'package:acela/src/screens/upload/video/widgets/reward_type_widget.dart'; +import 'package:acela/src/screens/upload/video/widgets/thumbnail_picker.dart'; +import 'package:acela/src/screens/upload/video/widgets/uploadProgressExpansionTile.dart'; +import 'package:acela/src/screens/upload/video/widgets/upload_textfield.dart'; +import 'package:acela/src/screens/upload/video/widgets/video_upload_divider.dart'; +import 'package:acela/src/screens/upload/video/widgets/video_upload_success_dialog.dart'; +import 'package:acela/src/screens/upload/video/widgets/work_type_widget.dart'; +import 'package:acela/src/utils/constants.dart'; +import 'package:acela/src/utils/enum.dart'; +import 'package:acela/src/utils/storages/video_storage.dart'; +import 'package:acela/src/widgets/loading_screen.dart'; +import 'package:acela/src/widgets/user_profile_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_expandable_fab/flutter_expandable_fab.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:omni_datetime_picker/omni_datetime_picker.dart'; +import 'package:provider/provider.dart'; + +class VideoUploadScreen extends StatefulWidget { + const VideoUploadScreen( + {Key? key, + required this.appData, + required this.isCamera, + required this.isDeviceEncode, + this.thumbnailFile, + this.videoFile}) + : super(key: key); + + final HiveUserData appData; + + final bool isCamera; + final bool isDeviceEncode; + final File? thumbnailFile; + final XFile? videoFile; + @override + State createState() => _VideoUploadScreenState(); +} + +class _VideoUploadScreenState extends State { + late final TextEditingController titleController; + late final TextEditingController descriptionController; + late final TextEditingController tagsController; + + DateTime? scheduledTime; + + @override + void initState() { + final controller = context.read(); + controller.pickedThumbnail = widget.thumbnailFile; + titleController = TextEditingController(text: controller.title); + descriptionController = TextEditingController(text: controller.description); + tagsController = TextEditingController(text: controller.tags); + controller.setBeneficiares(); + controller.isDeviceEncoding = widget.isDeviceEncode; + super.initState(); + } + + @override + void dispose() { + titleController.dispose(); + descriptionController.dispose(); + tagsController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final controller = context.read(); + return Scaffold( + appBar: AppBar( + title: ListTile( + contentPadding: EdgeInsets.zero, + leading: UserProfileImage( + radius: 35, userName: context.read().username!), + title: Text("Upload your video"))), + floatingActionButtonLocation: ExpandableFab.location, + resizeToAvoidBottomInset: false, + floatingActionButton: saveButton(controller), + bottomNavigationBar: PostingAuthorityWarningWidget(), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 15), + child: ValueListenableBuilder( + valueListenable: controller.isSaving, + builder: (context, isPublishing, child) { + if (isPublishing) { + return Center( + child: ValueListenableBuilder( + valueListenable: controller.savingText, + builder: (context, publishingText, child) { + return LoadingScreen( + title: 'Please wait', + subtitle: publishingText, + ); + }, + )); + } else { + return child!; + } + }, + child: SingleChildScrollView( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.isDeviceEncode) + Padding( + padding: const EdgeInsets.only(bottom: 15.0), + child: videoQualities(context), + ), + UploadProgressExpandableTile( + currentPage: controller.page, + pageController: controller.pageController, + mediaUploadProgress: controller.videoUploadProgress, + finalUploadProgress: controller.finalUploadProgress, + thumbnailUploadProgress: + controller.thumbnailUploadProgress, + uploadStatus: controller.uploadStatus, + isLocalEncode: widget.isDeviceEncode, + onUpload: () { + _onUpload(context, controller); + }), + const SizedBox(height: 15), + UploadTextField( + textEditingController: titleController, + hintText: 'Video title goes here', + labelText: 'Title', + minLines: 1, + maxLines: 1, + maxLength: 150, + onChanged: (value) { + controller.title = value; + }), + const SizedBox( + height: 10, + ), + UploadTextField( + textEditingController: tagsController, + hintText: 'threespeak,mobile', + labelText: 'Tags', + maxLines: 1, + minLines: 1, + maxLength: 150, + onChanged: (value) { + controller.setTags(tags: value); + }), + const SizedBox( + height: 10, + ), + UploadTextField( + textEditingController: descriptionController, + hintText: 'Video description', + labelText: 'Description', + maxLines: 8, + minLines: 5, + onChanged: (value) { + controller.description = value; + }), + communityTile(controller), + const VideoUploadDivider(), + _workType(controller), + const VideoUploadDivider(), + _rewardType(controller), + const VideoUploadDivider(), + _beneficiaryTile(controller), + const VideoUploadDivider(), + _languageTile(controller), + const VideoUploadDivider(), + if (!controller.isDeviceEncoding) + _thumbnailPicker(controller), + const SizedBox( + height: 50, + ) + ], + ), + ), + )), + ), + ); + } + + ThumbnailPicker _thumbnailPicker(VideoUploadController controller) { + return ThumbnailPicker( + isDeviceEncode: controller.isDeviceEncoding, + thumbnailUploadStatus: controller.thumbnailUploadStatus, + thumbnailUploadProgress: controller.thumbnailUploadProgress, + thumbnailUploadRespone: controller.thumbnailUploadResponse, + onUploadFile: (file) { + if (controller.isDeviceEncoding) { + } else { + controller.uploadThumbnail(file.path); + } + }, + ); + } + + LanguageTile _languageTile(VideoUploadController controller) { + return LanguageTile( + selectedLanguage: controller.language, + onChanged: (value) { + controller.setLanguage(language: value); + }); + } + + Widget _workType(VideoUploadController controller) { + return WorkTypeWidget( + isNsfwContent: controller.isNsfwContent, + onChanged: (newValue) { + controller.isNsfwContent = newValue; + }, + ); + } + + Widget communityTile(VideoUploadController controller) { + return CommunityPicker( + communityName: controller.communityName, + communityId: controller.communityId, + onChanged: (name, id) { + controller.setCommunity(communityName: name, communityId: id); + }, + ); + } + + Widget _rewardType(VideoUploadController controller) { + return RewardTypeWidget( + isPower100: controller.isPower100, + onChanged: (value) { + controller.isPower100 = value; + }); + } + + Widget _beneficiaryTile(VideoUploadController controller) { + return BeneficiariesTile( + userName: context.read().username!, + beneficiaries: controller.beneficaries, + onChanged: (beneficaries) => controller.beneficaries = beneficaries, + ); + } + + Future _onUpload( + BuildContext context, VideoUploadController controller) async { + controller.jumpToPage(); + if (controller.uploadStatus.value == UploadStatus.idle) { + try { + await controller.onUpload( + isDeviceEncoding: controller.isDeviceEncoding, + hiveUserData: widget.appData, + pickedVideoFile: widget.videoFile!, + onError: (e) => _onError(e, context, controller)); + } catch (e) { + _onError(e, context, controller); + } + } + } + + void _onError( + dynamic e, BuildContext context, VideoUploadController controller) { + showMessage(e.toString()); + Navigator.pop(context); + controller.resetController(); + } + + void showMessage( + String string, + ) { + var snackBar = SnackBar(content: Text('Message: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + void showError(String string) { + var snackBar = SnackBar(content: Text('Error: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + void showSuccessDialog( + bool hasPostingAuthority, { + required VoidCallback resetControllerCallback, + required bool publishLater, + required bool scheduleLater, + }) { + showDialog( + barrierDismissible: false, + context: context, + builder: (c) => VideoUploadSucessDialog( + publishLater: publishLater, + hasPostingAuthority: hasPostingAuthority, + scheduleLater: scheduleLater, + )).whenComplete(() { + resetControllerCallback(); + Navigator.pop(context); + if (!hasPostingAuthority || publishLater) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MyAccountScreen( + data: widget.appData, + initialTabIndex: publishLater ? 0 : 2, + ), + ), + ); + } + }); + } + + Future _pickDateTime(VideoUploadController controller) async { + final DateTime now = DateTime.now(); + final DateTime minAllowedTime = now.add(Duration(minutes: 59)); + DateTime? picked = await showOmniDateTimePicker( + context: context, + initialDate: scheduledTime ?? minAllowedTime, + firstDate: minAllowedTime, + lastDate: now.add(Duration(days: 31)), + is24HourMode: false, + isShowSeconds: false, + ); + + if (picked != null) { + if (picked.isBefore(now.add(Duration(minutes: 59)))) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Please pick a time at least 1 hour from now."), + ), + ); + return; + } + scheduledTime = picked; + _showConfirmationDialog(picked, controller); + } + } + + void _showConfirmationDialog( + DateTime picked, VideoUploadController controller) { + showDialog( + context: context, + builder: (BuildContext context) { + return ConfirmSceduleTimeDialog( + dateTime: picked, + onConfirm: () { + Navigator.of(context).pop(); + _save(controller, false, picked); + }, + onCancel: () { + Navigator.of(context).pop(); + }, + onPickAgain: () { + Navigator.of(context).pop(); + _pickDateTime(controller); + }, + ); + }, + ); + } + + Widget saveButton(VideoUploadController controller) { + return ValueListenableBuilder( + valueListenable: controller.isSaving, + builder: (context, isPulishing, child) { + return Visibility(visible: !isPulishing, child: child!); + }, + child: PublishFab( + isDeviceEncode: widget.isDeviceEncode, + onPublishNow: () => _save(controller, false), + onPublishLater: () => _save(controller, true), + onSchedulePublish: () => validate(controller, () { + _pickDateTime(controller); + }), + )); + } + + Future _save(VideoUploadController controller, bool publishLater, + [DateTime? scheduledDate]) async { + validate(controller, () async { + return await controller.validateAndSaveVideo(widget.appData, + successDialog: (hasPostingAuthority) => showSuccessDialog( + scheduleLater: scheduledDate != null, + hasPostingAuthority, + publishLater: publishLater, + resetControllerCallback: controller.resetController), + successSnackbar: (message) => showMessage( + message, + ), + errorSnackbar: (message) => showError(message), + publishLater: publishLater, + scheduledData: scheduledDate); + }); + } + + void validate( + VideoUploadController controller, + VoidCallback onValidate, + ) { + if (controller.uploadStatus.value != UploadStatus.ended) { + showMessage('Only after the video is upload, you can pulish the video'); + } else if (controller.title.isEmpty) { + showMessage('Title is Required'); + } else if (controller.description.isEmpty) { + showMessage('Description is Required'); + } else if (controller.thumbnailUploadResponse.value == null && + !controller.isDeviceEncoding) { + showMessage('Thumbnail is Required'); + } else { + onValidate(); + } + } + + Widget videoQualities(BuildContext context) { + final theme = Theme.of(context); + List qualities = VideoStorage().readEncodingQualities(); + ; + List updatedList = qualities.map((e) => e + 'p').toList(); + return Padding( + padding: + const EdgeInsets.symmetric(horizontal: kScreenHorizontalPaddingDigit), + child: RichText( + text: TextSpan( + text: 'Your video will be processed to ', + style: theme.textTheme.bodyMedium, + children: [ + TextSpan( + text: updatedList.join(', '), + style: theme.textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.bold)) + ], + ), + ), + ); + } +} diff --git a/lib/src/screens/upload/video/widgets/beneficaries_tile.dart b/lib/src/screens/upload/video/widgets/beneficaries_tile.dart new file mode 100644 index 00000000..a37ab3e0 --- /dev/null +++ b/lib/src/screens/upload/video/widgets/beneficaries_tile.dart @@ -0,0 +1,213 @@ +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/models/my_account/video_ops.dart'; +import 'package:acela/src/screens/my_account/update_video/add_bene_sheet.dart'; +import 'package:acela/src/utils/constants.dart'; +import 'package:acela/src/widgets/custom_circle_avatar.dart'; +import 'package:acela/src/widgets/user_profile_image.dart'; +import 'package:flutter/material.dart'; + +class BeneficiariesTile extends StatefulWidget { + const BeneficiariesTile( + {Key? key, + required this.userName, + required this.beneficiaries, + required this.onChanged}) + : super(key: key); + + final String userName; + final List beneficiaries; + final Function(List beneficaries) onChanged; + + @override + State createState() => _BeneficiariesTileState(); +} + +class _BeneficiariesTileState extends State { + late List beneficiaries; + + @override + void initState() { + beneficiaries = widget.beneficiaries; + super.initState(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return InkWell( + onTap: () { + if (beneficiaries + .where((element) => !element.isDefault) + .toList() + .length > + 0) { + beneficiariesBottomSheet(context); + }else{ + showAlertForAddBene(beneficiaries); + } + }, + child: Padding( + padding: const EdgeInsets.all(kScreenHorizontalPaddingDigit), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text('Video Participants:'), + Spacer(), + Icon(Icons.arrow_drop_down), + ], + ), + Visibility( + visible: beneficiaries.isNotEmpty, + child: Padding( + padding: const EdgeInsets.only(top: 12.0), + child: Wrap( + spacing: 0, + runSpacing: 0, + children: List.generate( + beneficiaries.length, + (index) => _beneficarieNameTile(theme, index, context), + ), + )), + ) + ], + ), + ), + ); + } + + Visibility _beneficarieNameTile( + ThemeData theme, int index, BuildContext context) { + return Visibility( + visible: !beneficiaries[index].isDefault, + child: Container( + margin: EdgeInsets.only(right: 6, bottom: 8), + padding: EdgeInsets.only(top: 2, bottom: 2, right: 8, left: 3), + decoration: BoxDecoration( + color: theme.cardColor, + borderRadius: BorderRadius.all(Radius.circular(20))), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + UserProfileImage( + radius: 20, userName: beneficiaries[index].account), + const SizedBox( + width: 5, + ), + Text( + beneficiaries[index].account, + style: TextStyle( + color: Theme.of(context).primaryColorLight.withOpacity(0.7), + ), + ), + ], + ), + ), + ); + } + + void beneficiariesBottomSheet(BuildContext context) { + var filteredBenes = beneficiaries + .where((element) => + element.src != 'ENCODER_PAY' && + element.src != 'MOBILE_APP_PAY' && + element.src != 'MOBILE_APP_PAY_AND_ENCODER_PAY' && + element.src != 'threespeak') + .toList(); + showModalBottomSheet( + context: context, + builder: (context) { + return SafeArea( + child: Container( + height: 400, + child: Scaffold( + appBar: AppBar( + title: Text('Video Participants'), + actions: [ + if (beneficiaries.length < 7) + IconButton( + onPressed: () { + Navigator.of(context).pop(); + showAlertForAddBene(beneficiaries); + }, + icon: Icon(Icons.add)) + ], + ), + body: ListView.separated( + itemBuilder: (c, i) { + return ListTile( + leading: CustomCircleAvatar( + height: 40, + width: 40, + url: server.userOwnerThumb(filteredBenes[i].account), + ), + title: Text(filteredBenes[i].account), + subtitle: Text( + '${filteredBenes[i].src} ( ${filteredBenes[i].weight} % )'), + trailing: IconButton( + onPressed: () { + setState(() { + beneficiaries.removeWhere((element) => + element.account == + filteredBenes[i].account); + }); + Navigator.of(context).pop(); + }, + icon: Icon( + Icons.delete, + color: Colors.red, + ), + ) + + ); + }, + separatorBuilder: (c, i) => const Divider(), + itemCount: filteredBenes.length, + ), + ), + ), + ); + }, + ); + } + + void showAlertForAddBene(List benes) { + if (beneficiaries.length == 7 || maxLimitReached()) { + showError('Maximum limit reached'); + } else { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) { + return AddBeneSheet( + benes: benes, + onSave: (newBenes) { + setState(() { + beneficiaries = newBenes; + }); + widget.onChanged(newBenes); + }, + ); + }, + ); + } + } + + bool maxLimitReached() { + int weight = 0; + beneficiaries.forEach((element) { + weight += element.weight; + }); + if (weight >= 99) { + return true; + } + return false; + } + + void showError(String string) { + var snackBar = SnackBar(content: Text('Error: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } +} diff --git a/lib/src/screens/upload/video/widgets/community_picker.dart b/lib/src/screens/upload/video/widgets/community_picker.dart new file mode 100644 index 00000000..1b0fb6a6 --- /dev/null +++ b/lib/src/screens/upload/video/widgets/community_picker.dart @@ -0,0 +1,93 @@ +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/screens/communities_screen/communities_screen.dart'; +import 'package:acela/src/utils/constants.dart'; +import 'package:acela/src/widgets/custom_circle_avatar.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class CommunityPicker extends StatefulWidget { + const CommunityPicker( + {Key? key, + required this.communityName, + required this.communityId, + required this.onChanged}) + : super(key: key); + + final String communityName; + final String communityId; + final Function(String, String) onChanged; + + @override + State createState() => _CommunityPickerState(); +} + +class _CommunityPickerState extends State { + String selectedCommunityName = ""; + String selectedCommunityId = ""; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: InkWell( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (c) => CommunitiesScreen( + withoutScaffold: false, + didSelectCommunity: (name, id) { + setState(() { + selectedCommunityName = name; + selectedCommunityId = id; + }); + widget.onChanged(name, id); + }, + ), + ), + ); + }, + child: Padding( + padding: EdgeInsets.only( + left: kScreenHorizontalPaddingDigit, + right: kScreenHorizontalPaddingDigit, + top: 7), + child: Row( + children: [ + const Text('Select Community:'), + Spacer(), + Stack( + alignment: Alignment.centerRight, + children: [ + Visibility( + visible: selectedCommunityId.isNotEmpty && + selectedCommunityName.isNotEmpty, + maintainState: true, + maintainSemantics: true, + maintainSize: true, + maintainAnimation: true, + child: Row( + children: [ + Text(selectedCommunityName), + SizedBox(width: 10), + CustomCircleAvatar( + width: 44, + height: 44, + url: server.communityIcon(selectedCommunityId), + ), + ], + ), + ), + Visibility( + visible: selectedCommunityId.isEmpty || + selectedCommunityName.isEmpty, + child: Icon(Icons.arrow_drop_down), + ), + ], + ) + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/screens/upload/video/widgets/confirm_schedule_time_dialog.dart b/lib/src/screens/upload/video/widgets/confirm_schedule_time_dialog.dart new file mode 100644 index 00000000..fd160de8 --- /dev/null +++ b/lib/src/screens/upload/video/widgets/confirm_schedule_time_dialog.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class ConfirmSceduleTimeDialog extends StatelessWidget { + final DateTime dateTime; + final VoidCallback onConfirm; + final VoidCallback onCancel; + final VoidCallback onPickAgain; + + const ConfirmSceduleTimeDialog({ + Key? key, + required this.dateTime, + required this.onConfirm, + required this.onCancel, + required this.onPickAgain, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final String formattedDate = + DateFormat('EEEE, MMMM d, y h:mm a').format(dateTime); + + return AlertDialog( + title: const Text('Confirm Publish Time'), + content: Text( + 'Are you sure you want to schedule your publish at:\n\n$formattedDate?', + style: TextStyle(fontWeight: FontWeight.bold), + ), + actions: [ + TextButton( + onPressed: onCancel, + child: const Text('Cancel'), + ), + TextButton( + onPressed: onPickAgain, + child: const Text('Pick Again'), + ), + TextButton( + onPressed: onConfirm, + child: const Text('Confirm'), + ), + ], + ); + } +} diff --git a/lib/src/screens/upload/video/widgets/language_tile.dart b/lib/src/screens/upload/video/widgets/language_tile.dart new file mode 100644 index 00000000..84dfce77 --- /dev/null +++ b/lib/src/screens/upload/video/widgets/language_tile.dart @@ -0,0 +1,80 @@ +import 'package:acela/src/screens/settings/settings_screen.dart'; +import 'package:acela/src/utils/constants.dart'; +import 'package:adaptive_action_sheet/adaptive_action_sheet.dart'; +import 'package:flutter/material.dart'; + +class LanguageTile extends StatefulWidget { + const LanguageTile( + {Key? key, required this.selectedLanguage, required this.onChanged}) + : super(key: key); + + final VideoLanguage selectedLanguage; + final Function(VideoLanguage) onChanged; + + @override + State createState() => _LanguageTileState(); +} + +class _LanguageTileState extends State { + late VideoLanguage selectedLanguage; + var languages = [ + VideoLanguage(code: "en", name: "English"), + VideoLanguage(code: "de", name: "Deutsch"), + VideoLanguage(code: "pt", name: "Portuguese"), + VideoLanguage(code: "fr", name: "Français"), + VideoLanguage(code: "es", name: "Español"), + VideoLanguage(code: "nl", name: "Nederlands"), + VideoLanguage(code: "ko", name: "한국어"), + VideoLanguage(code: "ru", name: "русский"), + VideoLanguage(code: "hu", name: "Magyar"), + VideoLanguage(code: "ro", name: "Română"), + VideoLanguage(code: "cs", name: "čeština"), + VideoLanguage(code: "pl", name: "Polskie"), + VideoLanguage(code: "in", name: "bahasa Indonesia"), + VideoLanguage(code: "bn", name: "বাংলা"), + VideoLanguage(code: "it", name: "Italian"), + VideoLanguage(code: "he", name: "עִברִית"), + ]; + + @override + void initState() { + selectedLanguage = widget.selectedLanguage; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: kScreenHorizontalPadding, + leading: const Icon(Icons.language), + title: const Text("Set Language Filter"), + trailing: Text(selectedLanguage.name), + onTap: () { + tappedLanguage(); + }, + ); + } + + void tappedLanguage() { + showAdaptiveActionSheet( + context: context, + title: const Text('Set Default Language Filter'), + androidBorderRadius: 30, + actions: languages.map((e) => getLangAction(e)).toList(), + cancelAction: CancelAction(title: const Text('Cancel')), + ); + } + + BottomSheetAction getLangAction(VideoLanguage language) { + return BottomSheetAction( + title: Text(language.name), + onPressed: (context) async { + widget.onChanged(language); + setState(() { + selectedLanguage = language; + Navigator.of(context).pop(); + }); + }, + ); + } +} diff --git a/lib/src/screens/upload/video/widgets/posting_authority_warning_widget.dart b/lib/src/screens/upload/video/widgets/posting_authority_warning_widget.dart new file mode 100644 index 00000000..205b89cd --- /dev/null +++ b/lib/src/screens/upload/video/widgets/posting_authority_warning_widget.dart @@ -0,0 +1,49 @@ +import 'package:acela/src/screens/upload/posting_authority_guide_screen.dart'; +import 'package:acela/src/screens/upload/video/controller/video_upload_controller.dart'; +import 'package:acela/src/widgets/blink_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class PostingAuthorityWarningWidget extends StatelessWidget { + const PostingAuthorityWarningWidget({super.key}); + + @override + Widget build(BuildContext context) { + final videoUploadController = context.read(); + final theme = Theme.of(context); + return ValueListenableBuilder( + valueListenable: videoUploadController.hasPostingAuthority, + builder: (context, isGiven, child) { + if (!videoUploadController.isDeviceEncoding || isGiven == true) + return SizedBox.shrink(); + return Container( + color: theme.scaffoldBackgroundColor, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Divider( + height: 1, + ), + BlinkWidget( + child: ListTile( + onTap: () { + Navigator.push(context, + MaterialPageRoute(builder: (context) { + return PostingAuthorityGuideScreen(); + })); + }, + title: Center( + child: Text( + "Posting authority not given to @threespeak", + style: TextStyle(color: Colors.red), + textAlign: TextAlign.center, + ), + ), + ), + ) + ], + ), + ); + }); + } +} diff --git a/lib/src/screens/upload/video/widgets/publish_fab.dart b/lib/src/screens/upload/video/widgets/publish_fab.dart new file mode 100644 index 00000000..a3bd3ed0 --- /dev/null +++ b/lib/src/screens/upload/video/widgets/publish_fab.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_expandable_fab/flutter_expandable_fab.dart'; + +class PublishFab extends StatefulWidget { + final VoidCallback onPublishNow; + final VoidCallback onPublishLater; + final VoidCallback onSchedulePublish; + final bool isDeviceEncode; + + const PublishFab( + {Key? key, + required this.onPublishNow, + required this.onPublishLater, + required this.isDeviceEncode, + required this.onSchedulePublish}) + : super(key: key); + + @override + State createState() => _PublishFabState(); +} + +class _PublishFabState extends State { + final _key = GlobalKey(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Stack( + children: [ + ExpandableFab( + key: _key, + distance: 60.0, + openButtonBuilder: + DefaultFloatingActionButtonBuilder(child: Icon(Icons.publish)), + type: ExpandableFabType.up, + overlayStyle: ExpandableFabOverlayStyle( + color: Theme.of(context).primaryColorDark.withOpacity(0.6)), + children: [ + if (widget.isDeviceEncode) + _item(theme, "Schedule Publish", Icons.schedule, + widget.onSchedulePublish), + if (widget.isDeviceEncode) + _item(theme, "Publish later", Icons.hourglass_bottom, + widget.onPublishLater), + _item(theme, "Publish Now", Icons.publish, widget.onPublishNow), + ], + ), + ], + ); + } + + Row _item(ThemeData theme, String label, IconData icon, VoidCallback onTap) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Container( + padding: EdgeInsets.symmetric(horizontal: 15, vertical: 5), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade600), + child: Text( + label, + style: theme.textTheme.titleMedium, + ), + ), + SizedBox(width: 20), + FloatingActionButton.small( + heroTag: label, + onPressed: () { + _key.currentState?.toggle(); + onTap(); + }, + tooltip: label, + child: Icon(icon), + ), + ], + ); + } +} diff --git a/lib/src/screens/upload/video/widgets/reward_type_widget.dart b/lib/src/screens/upload/video/widgets/reward_type_widget.dart new file mode 100644 index 00000000..de396e4f --- /dev/null +++ b/lib/src/screens/upload/video/widgets/reward_type_widget.dart @@ -0,0 +1,46 @@ +import 'package:acela/src/utils/constants.dart'; +import 'package:flutter/material.dart'; + +class RewardTypeWidget extends StatefulWidget { + const RewardTypeWidget( + {Key? key, required this.isPower100, required this.onChanged}) + : super(key: key); + + final bool isPower100; + final Function(bool) onChanged; + + @override + State createState() => _RewardTypeWidgetState(); +} + +class _RewardTypeWidgetState extends State { + late bool isPower100; + + @override + void initState() { + isPower100 = widget.isPower100; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: kScreenHorizontalPadding, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(isPower100 ? '100% power' : '50% power'), + Switch( + value: isPower100, + onChanged: (newValue) { + setState(() { + isPower100 = newValue; + }); + widget.onChanged(newValue); + }, + ) + ], + ), + ); + } +} diff --git a/lib/src/screens/upload/video/widgets/thumbnail_picker.dart b/lib/src/screens/upload/video/widgets/thumbnail_picker.dart new file mode 100644 index 00000000..8fe9e99e --- /dev/null +++ b/lib/src/screens/upload/video/widgets/thumbnail_picker.dart @@ -0,0 +1,141 @@ +import 'package:acela/src/models/video_upload/upload_response.dart'; +import 'package:acela/src/utils/constants.dart'; +import 'package:acela/src/utils/enum.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; + +class ThumbnailPicker extends StatefulWidget { + const ThumbnailPicker( + {Key? key, + required this.thumbnailUploadProgress, + required this.thumbnailUploadRespone, + required this.onUploadFile, + required this.thumbnailUploadStatus, + required this.isDeviceEncode}) + : super(key: key); + + final ValueNotifier thumbnailUploadProgress; + final ValueNotifier thumbnailUploadRespone; + final Function(XFile) onUploadFile; + final bool isDeviceEncode; + final ValueNotifier thumbnailUploadStatus; + + @override + State createState() => _ThumbnailPickerState(); +} + +class _ThumbnailPickerState extends State { + bool isPickingImage = false; + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return ValueListenableBuilder( + valueListenable: widget.thumbnailUploadStatus, + builder: (context, uploadStatus, child) { + return InkWell( + child: Padding( + padding: const EdgeInsets.all(kScreenHorizontalPaddingDigit), + child: Column( + children: [ + Stack( + children: [ + Positioned( + top: 10, + right: 10, + child: Icon( + Icons.emergency, + size: 15, + color: Colors.red, + )), + Container( + color: theme.cardColor.withOpacity(0.5), + width: 320, + height: 160, + child: ValueListenableBuilder( + valueListenable: widget.thumbnailUploadProgress, + builder: (context, progress, child) { + return Center( + child: uploadStatus == UploadStatus.started + ? CircularProgressIndicator( + value: progress, + valueColor: AlwaysStoppedAnimation( + theme.primaryColorLight), + backgroundColor: !isPickingImage + ? theme.primaryColorLight + .withOpacity(0.4) + : null, + ) + : ValueListenableBuilder( + valueListenable: + widget.thumbnailUploadRespone, + builder: (context, value, child) { + return value != null + ? widget.isDeviceEncode + ? Image.asset(value.url) + : Image.network(value.url) + : const SizedBox.shrink(); + }, + ), + ); + }, + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.upload), + const SizedBox( + width: 7, + ), + Text( + "Tap here to set thumbnail", + style: TextStyle(color: Colors.red), + ), + ], + ), + ), + ], + ), + ), + onTap: () async { + await _onTap(uploadStatus); + }, + ); + }, + ); + } + + Future _onTap(UploadStatus uploadStatus) async { + if (uploadStatus != UploadStatus.started) { + try { + setState(() { + isPickingImage = true; + }); + final XFile? file = + await ImagePicker().pickImage(source: ImageSource.gallery); + if (file != null) { + setState(() { + isPickingImage = false; + }); + widget.onUploadFile(file); + } else { + throw 'User cancelled image picker'; + } + } catch (e) { + showError(e.toString()); + setState(() { + isPickingImage = false; + }); + } + } + } + + void showError(String string) { + var snackBar = SnackBar(content: Text('Error: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } +} diff --git a/lib/src/screens/upload/video/widgets/uploadProgressExpansionTile.dart b/lib/src/screens/upload/video/widgets/uploadProgressExpansionTile.dart new file mode 100644 index 00000000..8de35988 --- /dev/null +++ b/lib/src/screens/upload/video/widgets/uploadProgressExpansionTile.dart @@ -0,0 +1,268 @@ +import 'package:acela/src/utils/constants.dart'; +import 'package:acela/src/utils/enum.dart'; +import 'package:flutter/material.dart'; + +class UploadProgressExpandableTile extends StatefulWidget { + const UploadProgressExpandableTile( + {Key? key, + required this.onUpload, + required this.mediaUploadProgress, + required this.thumbnailUploadProgress, + required this.finalUploadProgress, + required this.uploadStatus, + required this.pageController, + required this.currentPage, + required this.isLocalEncode}) + : super(key: key); + + final Function() onUpload; + final int currentPage; + final ValueNotifier mediaUploadProgress; + final ValueNotifier thumbnailUploadProgress; + final ValueNotifier finalUploadProgress; + final ValueNotifier uploadStatus; + final PageController pageController; + final bool isLocalEncode; + + @override + State createState() => + _UploadProgressExpandableTileState(); +} + +class _UploadProgressExpandableTileState + extends State { + late int _pageIndex; + bool isExpanded = false; + + @override + void initState() { + _pageIndex = widget.currentPage; + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + widget.onUpload(); + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: kScreenHorizontalPadding, + child: ExpansionPanelList( + expandedHeaderPadding: const EdgeInsets.only(top: 0), + elevation: 0, + expansionCallback: (int index, bool isExpanded) { + setState( + () { + this.isExpanded = isExpanded; + }, + ); + }, + children: [ + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return SizedBox( + height: 55, + child: Stack( + children: [ + PageView( + physics: const NeverScrollableScrollPhysics(), + onPageChanged: (value) { + setState( + () { + _pageIndex = value; + }, + ); + }, + controller: widget.pageController, + scrollDirection: Axis.vertical, + children: _uploadWidgets( + showStartEndWidgets: true, showWidgets: !isExpanded), + ), + Visibility( + visible: isExpanded, + child: ValueListenableBuilder( + valueListenable: widget.uploadStatus, + builder: (context, uploadStatus, child) { + return ListTile( + title: Text( + uploadStatusString(uploadStatus), + ), + ); + }, + ), + ), + ], + ), + ); + }, + body: Column( + children: + _uploadWidgets(showStartEndWidgets: false, showWidgets: true), + ), + isExpanded: isExpanded, + ), + ], + ), + ); + } + + String uploadStatusString(UploadStatus status) { + if (status == UploadStatus.idle) { + return "Waiting to Upload"; + } else if (status == UploadStatus.started) { + return "Uploading (${(_pageIndex)}/${widget.isLocalEncode ? 3 : 4})"; + } else { + return "Upload Complete"; + } + } + + List _uploadWidgets( + {required bool showStartEndWidgets, required bool showWidgets}) { + return [ + Visibility( + visible: showStartEndWidgets && showWidgets, + child: ListTile( + leading: !isExpanded + ? _progessIndicator(showBacgroundColor: false) + : null, + title: Text('Waiting to upload'), + trailing: isExpanded + ? _progessIndicator(showBacgroundColor: false) + : null), + ), + Visibility( + visible: showWidgets, + child: ListTile( + leading: videoUploadProgressWidget(!isExpanded), + title: Text(widget.isLocalEncode ? "Video Encode" : 'Video Upload'), + trailing: videoUploadProgressWidget(isExpanded)), + ), + Visibility( + visible: showWidgets, + child: ListTile( + leading: fetchingVideoThumbnailProgressWidget(!isExpanded), + title: const Text('Fetching Video Thumbnail'), + trailing: fetchingVideoThumbnailProgressWidget(isExpanded)), + ), + if (!widget.isLocalEncode) + Visibility( + visible: showWidgets, + child: ListTile( + leading: thumbnailUploadProgressWidget(!isExpanded), + title: Text('Thumbnail Upload'), + trailing: thumbnailUploadProgressWidget(isExpanded)), + ), + Visibility( + visible: showWidgets, + child: ListTile( + leading: moveWidgetToEncodingQueueProgressWidget(!isExpanded), + title: Text(widget.isLocalEncode + ? "Video Upload" + : 'Preparing for Encoding'), + trailing: moveWidgetToEncodingQueueProgressWidget(isExpanded)), + ), + Visibility( + visible: showStartEndWidgets && showWidgets, + child: ListTile( + leading: !isExpanded + ? const Icon(Icons.check, color: Colors.lightGreen) + : null, + title: Text('Upload Complete'), + trailing: isExpanded + ? const Icon(Icons.check, color: Colors.lightGreen) + : null, + ), + ), + ]; + } + + Widget? videoUploadProgressWidget(bool isVisible) { + if (!isVisible) { + return null; + } else if (_pageIndex < 1) { + return const Icon(Icons.pending); + } else if (_pageIndex == 1) { + return ValueListenableBuilder( + valueListenable: widget.mediaUploadProgress, + builder: (context, progress, child) { + return _progessIndicator(progress: progress); + }, + ); + } else { + return const Icon(Icons.check, color: Colors.lightGreen); + } + } + + Widget? fetchingVideoThumbnailProgressWidget(bool isVisible) { + if (!isVisible) { + return null; + } else if (_pageIndex < 2) { + return const Icon(Icons.pending); + } else { + if (_pageIndex == 2) { + return _progessIndicator(showBacgroundColor: false); + } else { + return const Icon(Icons.check, color: Colors.lightGreen); + } + } + } + + Widget? thumbnailUploadProgressWidget(bool isVisible) { + if (!isVisible) { + return null; + } else if (_pageIndex < 3) { + return const Icon(Icons.pending); + } else { + if (_pageIndex == 3) { + return ValueListenableBuilder( + valueListenable: widget.thumbnailUploadProgress, + builder: (context, progress, child) { + return _progessIndicator(progress: progress); + }, + ); + } else { + return const Icon(Icons.check, color: Colors.lightGreen); + } + } + } + + Widget? moveWidgetToEncodingQueueProgressWidget(bool isVisible) { + int pageIndex = widget.isLocalEncode ? 3 : 4; + if (!isVisible) { + return null; + } else if (_pageIndex < pageIndex) { + return const Icon(Icons.pending); + } else { + if (_pageIndex == pageIndex) { + if (widget.isLocalEncode) { + return ValueListenableBuilder( + valueListenable: widget.finalUploadProgress, + builder: (context, progress, child) { + return _progessIndicator(progress: progress); + }, + ); + } else { + return _progessIndicator(showBacgroundColor: false); + } + } else { + return const Icon(Icons.check, color: Colors.lightGreen); + } + } + } + + Widget _progessIndicator({double? progress, bool showBacgroundColor = true}) { + final theme = Theme.of(context); + return SizedBox( + height: 25, + width: 25, + child: CircularProgressIndicator( + strokeWidth: 2.5, + value: progress, + valueColor: AlwaysStoppedAnimation(theme.primaryColorLight), + backgroundColor: showBacgroundColor + ? theme.primaryColorLight.withOpacity(0.4) + : null, + ), + ); + } +} diff --git a/lib/src/screens/upload/video/widgets/upload_textfield.dart b/lib/src/screens/upload/video/widgets/upload_textfield.dart new file mode 100644 index 00000000..d2b74b43 --- /dev/null +++ b/lib/src/screens/upload/video/widgets/upload_textfield.dart @@ -0,0 +1,74 @@ +import 'package:acela/src/utils/constants.dart'; +import 'package:flutter/material.dart'; + +class UploadTextField extends StatelessWidget { + const UploadTextField( + {Key? key, + required this.textEditingController, + required this.hintText, + required this.labelText, + this.maxLines, + this.maxLength, + this.minLines, + required this.onChanged}) + : super(key: key); + + final TextEditingController textEditingController; + final String hintText; + final String labelText; + final int? maxLines; + final int? maxLength; + final int? minLines; + final Function(String) onChanged; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: kScreenHorizontalPadding, + child: TextField( + controller: textEditingController, + decoration: InputDecoration( + border: border(), + filled: true, + isDense: true, + fillColor: theme.cardColor, + hintText: hintText, + labelText: labelText, + suffixIcon: _clearButton(), + ), + onChanged: onChanged, + maxLines: maxLines, + minLines: minLines, + maxLength: maxLength, + ), + ); + } + + ValueListenableBuilder _clearButton() { + return ValueListenableBuilder( + valueListenable: textEditingController, + builder: (context, value, child) { + return Visibility( + visible: textEditingController.text.isNotEmpty, child: child!); + }, + child: IconButton( + splashRadius: 15, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () { + onChanged(''); + textEditingController.clear(); + }, + icon: const Icon( + Icons.cancel, + size: 20, + ), + ), + ); + } + + OutlineInputBorder border() => OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.all(Radius.circular(4))); +} diff --git a/lib/src/screens/upload/video/widgets/video_upload_divider.dart b/lib/src/screens/upload/video/widgets/video_upload_divider.dart new file mode 100644 index 00000000..126a284c --- /dev/null +++ b/lib/src/screens/upload/video/widgets/video_upload_divider.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +class VideoUploadDivider extends StatelessWidget { + const VideoUploadDivider({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Divider( + color: Theme.of(context).cardColor.withOpacity(0.7), + ); + } +} diff --git a/lib/src/screens/upload/video/widgets/video_upload_success_dialog.dart b/lib/src/screens/upload/video/widgets/video_upload_success_dialog.dart new file mode 100644 index 00000000..9be05b11 --- /dev/null +++ b/lib/src/screens/upload/video/widgets/video_upload_success_dialog.dart @@ -0,0 +1,159 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; + +class VideoUploadSucessDialog extends StatefulWidget { + const VideoUploadSucessDialog( + {Key? key, + required this.hasPostingAuthority, + required this.publishLater, + required this.scheduleLater}) + : super(key: key); + + final bool hasPostingAuthority; + final bool publishLater; + final bool scheduleLater; + + @override + State createState() => + _VideoUploadSucessDialogState(); +} + +class _VideoUploadSucessDialogState extends State { + late Timer colorChangeTimer; + late Timer enableButtonTimer; + late Timer valueTimer; + int colorIndex = 0; + int timerCount = 5; + Random random = Random(); + bool enableButton = false; + + static const List colors = [ + Colors.red, + Colors.tealAccent, + Colors.blue, + Colors.pink, + Colors.purple, + Colors.yellow, + Colors.brown, + Colors.lightGreenAccent, + Colors.lime, + Colors.cyan, + Colors.amber, + Colors.redAccent + ]; + + @override + void initState() { + super.initState(); + _init(); + } + + void _init() { + colorChangeTimer = Timer.periodic(Duration(milliseconds: 500), (timer) { + if (mounted) { + setState(() { + colorIndex = random.nextInt(5); + }); + } + }); + enableButtonTimer = Timer.periodic(Duration(seconds: 5), (timer) { + if (mounted) { + setState(() { + enableButton = true; + enableButtonTimer.cancel(); + }); + } + }); + valueTimer = Timer.periodic(Duration(seconds: 1), (timer) { + if (mounted) { + setState(() { + timerCount--; + if (timerCount == 0) { + valueTimer.cancel(); + } + }); + } + }); + } + + @override + void dispose() { + colorChangeTimer.cancel(); + enableButtonTimer.cancel(); + valueTimer.cancel(); + super.dispose(); + } + + String getContentText() { + return widget.scheduleLater + ? "Your video will be automatically published on schedule time" + : "As soon as your video is uploaded on decentralised IPFS infrastructure, it'll be published"; + } + + String getPublishText() { + if (widget.publishLater) { + return "🚨 You can publish the video later from my account.🚨"; + } else if (widget.hasPostingAuthority) { + return "🚨 Your Video will be automatically published 🚨"; + } else { + return "🚨 You will have to publish from my account after it is processed. It will NOT be published automatically. 🚨"; + } + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async => enableButton, + child: AlertDialog( + title: Text("🎉 Upload Complete 🎉"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(getContentText()), + SizedBox(height: 10), + Text( + getPublishText(), + style: TextStyle( + color: colors[colorIndex], + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + actions: [ + Stack( + children: [ + TextButton( + style: TextButton.styleFrom( + backgroundColor: Theme.of(context).primaryColor, + ), + child: Text( + "${widget.scheduleLater ? "Done" : widget.hasPostingAuthority ? "AutoPublish" : "Okay. I will"} ${timerCount != 0 ? timerCount : ""}", + ), + onPressed: () { + Navigator.pop(context); + }, + ), + Positioned.fill( + top: 4, + bottom: 4, + child: Visibility( + visible: !enableButton, + child: Container( + decoration: BoxDecoration( + color: Colors.black12.withOpacity(0.5), + borderRadius: BorderRadius.all(Radius.circular(40)), + ), + ), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/src/screens/upload/video/widgets/work_type_widget.dart b/lib/src/screens/upload/video/widgets/work_type_widget.dart new file mode 100644 index 00000000..93a5bd54 --- /dev/null +++ b/lib/src/screens/upload/video/widgets/work_type_widget.dart @@ -0,0 +1,51 @@ +import 'package:acela/src/utils/constants.dart'; +import 'package:flutter/material.dart'; + +class WorkTypeWidget extends StatefulWidget { + const WorkTypeWidget( + {Key? key, required this.isNsfwContent, required this.onChanged}) + : super(key: key); + + final bool isNsfwContent; + final Function(bool) onChanged; + + @override + State createState() => _WorkTypeWidgetState(); +} + +class _WorkTypeWidgetState extends State { + late bool isNsfwContent; + + @override + void initState() { + isNsfwContent = widget.isNsfwContent; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.all(kScreenHorizontalPaddingDigit), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Checkbox( + visualDensity: VisualDensity.compact, + value: isNsfwContent, onChanged: (newValue) { + setState(() { + isNsfwContent = newValue!; + }); + widget.onChanged(newValue!); + },), + Expanded( + child: Text( + "You should check this option if your content is NSFW", + style: TextStyle(color: Colors.red), + ), + ), + + ], + ), + ); + } +} diff --git a/lib/src/screens/user_channel_screen/follower_list_tile.dart b/lib/src/screens/user_channel_screen/follower_list_tile.dart new file mode 100644 index 00000000..52fff588 --- /dev/null +++ b/lib/src/screens/user_channel_screen/follower_list_tile.dart @@ -0,0 +1,85 @@ +import 'dart:developer'; + +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/widgets/custom_circle_avatar.dart'; +import 'package:flutter/material.dart'; + +class FollowerListTile extends StatefulWidget { + const FollowerListTile({Key? key, required this.name}) : super(key: key); + final String name; + + @override + State createState() => _FollowerListTileState(); +} + +class _FollowerListTileState extends State + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + // Future _loadUserProfile(String hiveApiUrl) async { + // var client = http.Client(); + // var body = UserProfileRequest.forOwner(widget.name).toJsonString(); + // var response = + // await client.post(Uri.parse('https://$hiveApiUrl'), body: body); + // if (response.statusCode == 200) { + // return UserProfileResponse.fromString(response.body); + // } else { + // throw "Status code is ${response.statusCode}"; + // } + // } + + // Widget _futureUserProfile(HiveUserData appData) { + // return FutureBuilder( + // future: _loadUserProfile(appData.rpc), + // builder: (context, snapshot) { + // if (snapshot.hasError) { + // return ListTile(title: Text(widget.name)); + // } else if (snapshot.hasData && + // snapshot.connectionState == ConnectionState.done) { + // var data = snapshot.data! as UserProfileResponse; + // return ListTile( + // leading: SizedBox( + // height: 40, + // width: 40, + // child: FadeInImage.assetNetwork( + // placeholder: 'assets/branding/three_speak_logo.png', + // image: data.result.metadata.profile.profileImage, + // fit: BoxFit.cover, + // placeholderErrorBuilder: (BuildContext context, Object error, + // StackTrace? stackTrace) { + // return Image.asset('assets/branding/three_speak_logo.png'); + // }, + // imageErrorBuilder: (BuildContext context, Object error, + // StackTrace? stackTrace) { + // return Image.asset('assets/branding/three_speak_logo.png'); + // }, + // ), + // ), + // title: Text(widget.name), + // subtitle: Text('Reputation: ${data.result.reputation}'), + // ); + // } else { + // return ListTile(title: Text(widget.name)); + // } + // }, + // ); + // } + + @override + Widget build(BuildContext context) { + super.build(context); + // return _futureUserProfile(); + return ListTile( + leading: CustomCircleAvatar( + height: 40, + width: 40, + url: server.userOwnerThumb(widget.name), + ), + title: Text(widget.name), + onTap: () { + log('User tapped on hive user list item ${widget.name}'); + }, + ); + } +} diff --git a/lib/src/screens/user_channel_screen/user_channel_following.dart b/lib/src/screens/user_channel_screen/user_channel_following.dart new file mode 100644 index 00000000..a626cd02 --- /dev/null +++ b/lib/src/screens/user_channel_screen/user_channel_following.dart @@ -0,0 +1,86 @@ +import 'package:acela/src/models/user_profile/request/user_followers_request.dart'; +import 'package:acela/src/models/user_profile/response/followers_and_following.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/user_channel_screen/follower_list_tile.dart'; +import 'package:acela/src/widgets/loading_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:provider/provider.dart'; + +class UserChannelFollowingWidget extends StatefulWidget { + const UserChannelFollowingWidget( + {Key? key, required this.owner, required this.isFollowers}) + : super(key: key); + final String owner; + final bool isFollowers; + + @override + State createState() => + _UserChannelFollowingWidgetState(); +} + +class _UserChannelFollowingWidgetState extends State + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + Future _loadFollowers(String author, String hiveApiUrl) async { + var client = http.Client(); + var body = widget.isFollowers + ? UserFollowerRequest.followers(widget.owner).toJsonString() + : UserFollowerRequest.following(widget.owner).toJsonString(); + var response = + await client.post(Uri.parse('https://$hiveApiUrl'), body: body); + if (response.statusCode == 200) { + return Followers.fromJsonString(response.body); + } else { + throw "Status code is ${response.statusCode}"; + } + } + + Widget _listTile(FollowerItem item) { + return FollowerListTile( + name: widget.isFollowers ? item.follower : item.following, + ); + } + + Widget _futureFollowers(HiveUserData appData) { + return FutureBuilder( + future: _loadFollowers(widget.owner, appData.rpc), + builder: (context, snapshot) { + if (snapshot.hasError) { + return const Text('Error loading user followers'); + } else if (snapshot.hasData && + snapshot.connectionState == ConnectionState.done) { + var data = snapshot.data! as Followers; + if (data.result.isEmpty) { + return Center( + child: Text( + 'No ${widget.isFollowers ? 'Followers' : 'Followings'} found.'), + ); + } + return ListView.separated( + itemBuilder: (context, index) { + return _listTile(data.result[index]); + }, + separatorBuilder: (context, index) => const Divider( + thickness: 0, height: 1, color: Colors.transparent), + itemCount: data.result.length, + ); + } else { + return const LoadingScreen( + title: 'Loading Data', + subtitle: 'Please wait', + ); + } + }, + ); + } + + @override + Widget build(BuildContext context) { + super.build(context); + var appData = Provider.of(context); + return _futureFollowers(appData); + } +} diff --git a/lib/src/screens/user_channel_screen/user_channel_profile.dart b/lib/src/screens/user_channel_screen/user_channel_profile.dart new file mode 100644 index 00000000..10e462c4 --- /dev/null +++ b/lib/src/screens/user_channel_screen/user_channel_profile.dart @@ -0,0 +1,92 @@ +import 'package:acela/src/models/user_profile/request/user_profile_request.dart'; +import 'package:acela/src/models/user_profile/response/user_profile.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/utils/seconds_to_duration.dart'; +import 'package:acela/src/widgets/loading_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:http/http.dart' as http; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class UserChannelProfileWidget extends StatefulWidget { + const UserChannelProfileWidget({Key? key, required this.owner}) + : super(key: key); + final String owner; + + @override + State createState() => + _UserChannelProfileWidgetState(); +} + +class _UserChannelProfileWidgetState extends State + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + Future _loadUserProfile(String hiveApiUrl) async { + var client = http.Client(); + var body = UserProfileRequest.forOwner(widget.owner).toJsonString(); + var response = + await client.post(Uri.parse('https://$hiveApiUrl'), body: body); + if (response.statusCode == 200) { + return UserProfileResponse.fromString(response.body); + } else { + throw "Status code is ${response.statusCode}"; + } + } + + Widget _cover(String url) { + return FadeInImage.assetNetwork( + height: 150, + placeholder: 'assets/branding/three_speak_logo.png', + image: url, + fit: BoxFit.cover, + imageErrorBuilder: + (BuildContext context, Object error, StackTrace? stackTrace) { + return Image.asset('assets/branding/three_speak_logo.png'); + }, + ); + } + + Widget _descriptionMarkDown(String markDown) { + return Markdown( + padding: const EdgeInsets.all(3), + data: Utilities.removeAllHtmlTags(markDown), + onTapLink: (text, url, title) { + launchUrl(Uri.parse(url ?? 'https://google.com')); + }, + ); + } + + String _generateMarkDown(UserProfileResponse data) { + return "![cover image](${data.result.metadata.profile.coverImage})\n## Bio:\n${data.result.metadata.profile.about}\n\n\n## Created At:\n${Utilities.parseAndFormatDateTime(data.result.created)}\n\n## Last Seen At:\n${Utilities.parseAndFormatDateTime(data.result.active)}\n\n## Total Hive Posts:\n${data.result.postCount}\n\n## Hive Reputation:\n${data.result.reputation}\n\n## Location:\n${data.result.metadata.profile.location.isEmpty ? 'None' : data.result.metadata.profile.location}\n\n## Website:\n${data.result.metadata.profile.website.isEmpty ? 'None' : data.result.metadata.profile.website}"; + } + + Widget _futureUserProfile(HiveUserData appData) { + return FutureBuilder( + future: _loadUserProfile(appData.rpc), + builder: (context, snapshot) { + if (snapshot.hasError) { + return const Text('Error loading user profile'); + } else if (snapshot.hasData && + snapshot.connectionState == ConnectionState.done) { + var data = snapshot.data! as UserProfileResponse; + return _descriptionMarkDown(_generateMarkDown(data)); + } else { + return const LoadingScreen( + title: 'Loading Data', + subtitle: 'Please wait', + ); + } + }, + ); + } + + @override + Widget build(BuildContext context) { + super.build(context); + var appData = Provider.of(context); + return _futureUserProfile(appData); + } +} diff --git a/lib/src/screens/user_channel_screen/user_channel_screen.dart b/lib/src/screens/user_channel_screen/user_channel_screen.dart new file mode 100644 index 00000000..6aaada7b --- /dev/null +++ b/lib/src/screens/user_channel_screen/user_channel_screen.dart @@ -0,0 +1,185 @@ +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/home_screen/home_screen_feed_list.dart'; +import 'package:acela/src/screens/podcast/widgets/favourite.dart'; +import 'package:acela/src/screens/report/widgets/report_pop_up_menu.dart'; +import 'package:acela/src/screens/stories/story_feed_list.dart'; +import 'package:acela/src/screens/user_channel_screen/user_channel_following.dart'; +import 'package:acela/src/screens/user_channel_screen/user_channel_profile.dart'; +import 'package:acela/src/screens/user_channel_screen/user_channel_videos.dart'; +import 'package:acela/src/screens/user_channel_screen/user_favourite_provider.dart'; +import 'package:acela/src/utils/enum.dart'; +import 'package:acela/src/widgets/custom_circle_avatar.dart'; +import 'package:adaptive_action_sheet/adaptive_action_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:share_plus/share_plus.dart'; + +class UserChannelScreen extends StatefulWidget { + const UserChannelScreen({Key? key, required this.owner, this.onPop}) + : super(key: key); + final String owner; + final VoidCallback? onPop; + + @override + _UserChannelScreenState createState() => _UserChannelScreenState(); +} + +class _UserChannelScreenState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + var currentIndex = 0; + var videoKey = GlobalKey(); + var userFavouriteProvider = UserFavoriteProvider(); + + static List tabs = [ + Tab( + icon: Icon(Icons.video_camera_front_outlined), + ), + Tab( + icon: Image.asset( + 'assets/branding/three_shorts_icon.png', + width: 30, + height: 30, + ), + ), + Tab(icon: Icon(Icons.info)), + Tab(text: 'Followers'), + Tab(text: 'Following'), + ]; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: tabs.length, vsync: this); + _tabController.addListener(() { + setState(() { + currentIndex = _tabController.index; + }); + }); + } + + @override + void deactivate() { + if (widget.onPop != null) widget.onPop!(); + super.deactivate(); + } + + @override + void dispose() { + super.dispose(); + _tabController.dispose(); + } + + // Widget _sortButton() { + // return IconButton( + // onPressed: () { + // _showBottomSheet(); + // }, + // icon: const Icon(Icons.sort), + // ); + // } + + void _showBottomSheet() { + showAdaptiveActionSheet( + context: context, + title: const Text('Sort by:'), + androidBorderRadius: 30, + actions: [ + BottomSheetAction( + title: const Text('Newest'), + onPressed: (context) { + videoKey.currentState?.sortByNewest(); + }, + ), + BottomSheetAction( + title: const Text('Most Viewed'), + onPressed: (context) { + videoKey.currentState?.sortByMostViewed(); + }, + ), + ], + cancelAction: CancelAction(title: const Text('Cancel')), + ); + } + + @override + Widget build(BuildContext context) { + var appData = Provider.of(context); + return Scaffold( + appBar: AppBar( + leadingWidth: 30, + title: Row( + children: [ + CustomCircleAvatar( + height: 36, + width: 36, + url: server.userOwnerThumb(widget.owner), + ), + const SizedBox( + width: 10, + ), + Expanded( + child: Text( + widget.owner, + style: TextStyle(fontSize: 16), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ) + ], + ), + actions: [ + FavouriteWidget( + toastType: "User", + isLiked: userFavouriteProvider.isUserPresentLocally(widget.owner), + onAdd: () { + userFavouriteProvider.storeLikedUserLocally(widget.owner); + }, + onRemove: () { + userFavouriteProvider.storeLikedUserLocally(widget.owner); + }), + IconButton( + onPressed: () async { + Share.share("https://3speak.tv/rss/${widget.owner}.xml"); + }, + icon: Icon(Icons.rss_feed), + ), + IconButton( + onPressed: () async { + Share.share("https://3speak.tv/user/${widget.owner}"); + }, + icon: Icon(Icons.share), + ), + ReportPopUpMenu( + type: Report.user, + author: widget.owner, + ) + ], + bottom: TabBar( + controller: _tabController, + tabs: tabs, + isScrollable: true, + ), + ), + body: TabBarView( + controller: _tabController, + children: [ + HomeScreenFeedList( + appData: appData, + feedType: HomeScreenFeedType.userChannelFeed, + owner: widget.owner, + ), + StoryFeedList( + appData: appData, + feedType: StoryFeedType.userChannelFeed, + username: widget.owner, + ), + UserChannelProfileWidget(owner: widget.owner), + UserChannelFollowingWidget(owner: widget.owner, isFollowers: true), + UserChannelFollowingWidget(owner: widget.owner, isFollowers: false), + ], + ), + ); + } +} diff --git a/lib/src/screens/user_channel_screen/user_channel_videos.dart b/lib/src/screens/user_channel_screen/user_channel_videos.dart new file mode 100644 index 00000000..5b6bcccc --- /dev/null +++ b/lib/src/screens/user_channel_screen/user_channel_videos.dart @@ -0,0 +1,201 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; + +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/models/hive_post_info/hive_post_info.dart'; +import 'package:acela/src/models/home_screen_feed_models/home_feed.dart'; +import 'package:acela/src/screens/video_details_screen/video_details_screen.dart'; +import 'package:acela/src/screens/video_details_screen/video_details_view_model.dart'; +import 'package:acela/src/utils/seconds_to_duration.dart'; +import 'package:acela/src/widgets/list_tile_video.dart'; +import 'package:acela/src/widgets/loading_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' show get; +import 'package:http/http.dart' as http; +import 'package:timeago/timeago.dart' as timeago; + +class UserChannelVideos extends StatefulWidget { + const UserChannelVideos({ + Key? key, + required this.owner, + required this.rpc, + }) : super(key: key); + final String owner; + final String rpc; + + @override + State createState() => UserChannelVideosState(); +} + +class UserChannelVideosState extends State + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + var isLoading = false; + List list = []; + Map payout = {}; + + @override + void initState() { + super.initState(); + loadFeed(); + } + + void sortByNewest() { + setState(() { + list.sort((a, b) { + return (a.createdAt ?? DateTime.now()) + .compareTo(b.createdAt ?? DateTime.now()); + }); + list = list.reversed.toList(); + }); + } + + void sortByMostViewed() { + setState(() { + list.sort((a, b) { + return a.views > b.views + ? -1 + : a.views < b.views + ? 1 + : 0; + }); + }); + } + + void loadFeed() async { + setState(() { + isLoading = true; + }); + var response = + await get(Uri.parse("${server.domain}/apiv2/feeds/@${widget.owner}")); + if (response.statusCode == 200) { + List list = homeFeedItemFromString(response.body); + setState(() { + this.list = list; + isLoading = false; + }); + var i = 0; + Timer.periodic(const Duration(seconds: 1), (timer) { + fetchHiveInfo(list[i].author, list[i].permlink, widget.rpc); + i += 1; + if (i == list.length) { + timer.cancel(); + } + }); + } else { + showError("Status code is ${response.statusCode}"); + setState(() { + this.list = []; + isLoading = false; + }); + } + } + + // fetch hive info + void fetchHiveInfo(String user, String permlink, String hiveApiUrl) async { + var request = http.Request('POST', Uri.parse('https://$hiveApiUrl')); + request.body = json.encode({ + "id": 1, + "jsonrpc": "2.0", + "method": "bridge.get_discussion", + "params": {"author": user, "permlink": permlink, "observer": ""} + }); + http.StreamedResponse response = await request.send(); + if (response.statusCode == 200) { + var string = await response.stream.bytesToString(); + var result = HivePostInfo.fromJsonString(string) + .result + .resultData + .where((element) => element.permlink == permlink) + .first; + setState(() { + var upVotes = result.activeVotes.where((e) => e.rshares > 0).length; + var downVotes = result.activeVotes.where((e) => e.rshares < 0).length; + payout["$user/$permlink"] = PayoutInfo( + payout: result.payout, + downVotes: downVotes, + upVotes: upVotes, + ); + }); + } else { + print(response.reasonPhrase); + } + } + + Widget _tileTitle(HomeFeedItem item, BuildContext context, + Function(HomeFeedItem) onUserTap) { + String timeInString = + item.createdAt != null ? "📆 ${timeago.format(item.createdAt!)}" : ""; + String duration = "🕚 ${Utilities.formatTime(item.duration.toInt())}"; + String views = "▶ ${item.views}"; + double? payoutAmount = payout["${item.author}/${item.permlink}"]?.payout; + int? upVotes = payout["${item.author}/${item.permlink}"]?.upVotes; + int? downVotes = payout["${item.author}/${item.permlink}"]?.downVotes; + return ListTileVideo( + placeholder: 'assets/branding/three_speak_logo.png', + url: item.images.thumbnail, + userThumbUrl: server.userOwnerThumb(item.author), + title: item.title, + subtitle: "$timeInString $duration $views", + onUserTap: () { + onUserTap(item); + }, + user: item.author, + permlink: item.permlink, + shouldResize: true, + isIpfs: item.playUrl.contains('ipfs'), + ); + } + + Widget _listTile(HomeFeedItem item, BuildContext context, + Function(HomeFeedItem) onTap, Function(HomeFeedItem) onUserTap) { + return ListTile( + contentPadding: EdgeInsets.zero, + minVerticalPadding: 0, + title: _tileTitle(item, context, onUserTap), + onTap: () { + onTap(item); + }, + ); + } + + void showError(String string) { + var snackBar = SnackBar(content: Text('Error: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + Widget _futureVideos() { + if (isLoading) { + return const LoadingScreen( + title: 'Loading Data', + subtitle: 'Please wait', + ); + } + if (list.isEmpty) { + return Center(child: const Text('No videos found.')); + } + return ListView.separated( + itemBuilder: (context, index) { + return _listTile(list[index], context, (item) { + var viewModel = VideoDetailsViewModel( + author: item.author, permlink: item.permlink); + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => VideoDetailsScreen(vm: viewModel))); + }, (owner) { + log("tapped on user ${owner.author}"); + }); + }, + separatorBuilder: (context, index) => + const Divider(thickness: 0, height: 15, color: Colors.transparent), + itemCount: list.length, + ); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return _futureVideos(); + } +} diff --git a/lib/src/screens/user_channel_screen/user_favourite_provider.dart b/lib/src/screens/user_channel_screen/user_favourite_provider.dart new file mode 100644 index 00000000..11053832 --- /dev/null +++ b/lib/src/screens/user_channel_screen/user_favourite_provider.dart @@ -0,0 +1,47 @@ +import 'package:get_storage/get_storage.dart'; + +class UserFavoriteProvider { + final box = GetStorage(); + final String _usersLocalKey = '_userLocalKey'; + + List getBookmarkedUsers() { + final String key = _usersLocalKey; + if (box.read(key) != null) { + List items = box.read(key); + return items; + } else { + return []; + } + } + + //check if the liked podcast single episode is present locally + bool isUserPresentLocally(String userName) { + final String key = _usersLocalKey; + if (box.read(key) != null) { + List json = box.read(key); + int index = json.indexWhere((element) => element == userName); + return index != -1; + } else { + return false; + } + } + + //sotre the single podcast episode locally if user likes it + void storeLikedUserLocally(String userName, {bool forceRemove = false}) { + final String key = _usersLocalKey; + if (box.read(key) != null) { + List json = box.read(key); + int index = json.indexWhere((element) => element == userName); + if (index == -1 && !forceRemove) { + json.add(userName); + box.write(key, json); + } else { + json.removeWhere((element) => element == userName); + box.write(key, json); + } + } else { + box.write(key, [userName]); + } + print(box.read(key)); + } +} diff --git a/lib/src/screens/video_details_screen/comment/comment_action_menu.dart b/lib/src/screens/video_details_screen/comment/comment_action_menu.dart new file mode 100644 index 00000000..7b66398e --- /dev/null +++ b/lib/src/screens/video_details_screen/comment/comment_action_menu.dart @@ -0,0 +1,114 @@ +import 'package:acela/src/models/hive_comments/new_hive_comment/newest_comment_model.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/login/ha_login_screen.dart'; +import 'package:acela/src/screens/video_details_screen/comment/hive_comment_dialog.dart'; +import 'package:acela/src/screens/video_details_screen/hive_upvote_dialog.dart'; +import 'package:acela/src/widgets/bottom_sheet_outline.dart'; +import 'package:acela/src/widgets/menu_circle_action_button.dart'; +import 'package:adaptive_action_sheet/adaptive_action_sheet.dart'; +import 'package:flutter/material.dart'; + +class CommentActionMenu extends StatelessWidget { + const CommentActionMenu( + {Key? key, + required this.appData, + required this.author, + required this.permlink, + required this.onUpVote, + required this.depth, + required this.onSubCommentAdded}) + : super(key: key); + + final HiveUserData appData; + final String author; + final String permlink; + final int depth; + final VoidCallback onUpVote; + final Function(CommentItemModel) onSubCommentAdded; + + @override + Widget build(BuildContext context) { + return BottomSheetOutline( + children: [ + MenuCircleActionButton( + onTap: () => onUpvoteTap(context), + icon: Icons.thumb_up_sharp, + text: "Upvote", + ), + MenuCircleActionButton( + onTap: () => onReplyTap(context), + icon: Icons.reply, + text: "Reply", + ), + MenuCircleActionButton( + onTap: () { + Navigator.pop(context); + }, + icon: Icons.close, + text: "Cancel", + backgroundColor: Colors.red, + ), + ], + ); + } + + void onUpvoteTap(BuildContext context) { + Navigator.pop(context); + showModalBottomSheet( + context: context, + builder: (context) => SizedBox( + child: HiveUpvoteDialog( + author: author, + permlink: permlink, + username: appData.username ?? "", + accessToken: appData.accessToken, + postingAuthority: appData.postingAuthority, + hasKey: appData.keychainData?.hasId ?? "", + hasAuthKey: appData.keychainData?.hasAuthKey ?? "", + activeVotes: [], + onClose: () {}, + onDone: onUpVote, + ), + ), + ); + } + + void onReplyTap(BuildContext context) { + Navigator.of(context).pop(); + if (appData.username == null) { + showAdaptiveActionSheet( + context: context, + title: const Text('You are not logged in. Please log in to comment.'), + androidBorderRadius: 30, + actions: [ + BottomSheetAction( + title: Text('Log in'), + leading: Icon(Icons.login), + onPressed: (c) { + Navigator.of(c).pop(); + var screen = HiveAuthLoginScreen(appData: appData); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(c).push(route); + }), + ], + cancelAction: CancelAction(title: const Text('Cancel')), + ); + return; + } + var screen = HiveCommentDialog( + author: author, + permlink: permlink, + depth:depth , + username: appData.username ?? "", + hasKey: appData.keychainData?.hasId ?? "", + hasAuthKey: appData.keychainData?.hasAuthKey ?? "", + onClose: () {}, + onDone: (newComment) async { + if(newComment!=null){ + onSubCommentAdded(newComment); + } + }, + ); + Navigator.of(context).push(MaterialPageRoute(builder: (c) => screen)); + } +} diff --git a/lib/src/screens/video_details_screen/comment/comment_search_bar.dart b/lib/src/screens/video_details_screen/comment/comment_search_bar.dart new file mode 100644 index 00000000..1f6c8178 --- /dev/null +++ b/lib/src/screens/video_details_screen/comment/comment_search_bar.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; + +class CommentSearchBar extends StatefulWidget { + const CommentSearchBar( + {required this.onChanged, + required this.textEditingController, + required this.showSearchBar}); + + final Function(String value) onChanged; + final TextEditingController textEditingController; + final ValueNotifier showSearchBar; + @override + State createState() => _CommentSearchBarState(); +} + +class _CommentSearchBarState extends State { + late FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + } + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final border = outLineBorder(); + final theme = Theme.of(context); + return ValueListenableBuilder( + valueListenable: widget.showSearchBar, + builder: (context, showSearchBar, child) { + if (showSearchBar) { + _focusNode.requestFocus(); + } else { + _focusNode.unfocus(); + } + return PopScope( + canPop: !showSearchBar, + onPopInvoked: (didPop) { + if (showSearchBar) { + _onClear(); + return; + } + }, + child: Column( + children: [ + AnimatedContainer( + height: showSearchBar ? 50 : 0, + duration: const Duration(milliseconds: 250), + padding: const EdgeInsets.only( + left: 8, right: 8, top: 8, bottom: 4), + child: child!), + Visibility(visible: showSearchBar, child: Divider()), + ], + ), + ); + }, + child: TextField( + focusNode: _focusNode, + controller: widget.textEditingController, + onChanged: widget.onChanged, + decoration: InputDecoration( + prefixIcon: FittedBox( + child: Padding( + padding: const EdgeInsets.all(5.0), + child: Icon( + Icons.search, + ), + ), + ), + hintText: "Search for comment, username", + suffixIcon: getSuffixIcon(theme), + fillColor: Theme.of(context).primaryColorDark, + filled: true, + contentPadding: const EdgeInsets.only(bottom: 13), + border: border, + focusedBorder: border, + enabledBorder: border, + disabledBorder: border, + ), + )); + } + + OutlineInputBorder outLineBorder() { + return const OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.all(Radius.circular(4))); + } + + Widget getSuffixIcon(ThemeData theme) { + return IconButton( + splashRadius: 15, + onPressed: () { + _onClear(); + }, + icon: Text('Done')); + } + + void _onClear() { + _focusNode.unfocus(); + widget.showSearchBar.value = false; + widget.textEditingController.clear(); + widget.onChanged(""); + } +} diff --git a/lib/src/screens/video_details_screen/comment/comment_view_appbar.dart b/lib/src/screens/video_details_screen/comment/comment_view_appbar.dart new file mode 100644 index 00000000..b01df849 --- /dev/null +++ b/lib/src/screens/video_details_screen/comment/comment_view_appbar.dart @@ -0,0 +1,47 @@ +import 'package:acela/src/screens/video_details_screen/comment/controller/comment_controller.dart'; +import 'package:acela/src/screens/video_details_screen/comment/menu.dart'; +import 'package:acela/src/utils/enum.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class CommentViewAppbar extends StatelessWidget implements PreferredSizeWidget { + const CommentViewAppbar( + {Key? key, required this.state, required this.showSearchBar, required this.searchKey}) + : super(key: key); + + final ViewState state; + final ValueNotifier showSearchBar; + final TextEditingController searchKey; + + @override + Widget build(BuildContext context) { + int numberOfComments = context + .select((value) => value.disPlayedItems.length); + return AppBar( + title: Text( + 'Comments${state != ViewState.loading ? ' ($numberOfComments)' : ''}'), + actions: [ + ValueListenableBuilder( + valueListenable: showSearchBar, + builder: (context, showSearchButton, child) { + return Visibility( + visible: numberOfComments != 0 && !showSearchButton, + child: child!); + }, + child: IconButton( + onPressed: () { + showSearchBar.value = true; + }, + icon: Icon(Icons.search), + ), + ), + Menu( + searchKey: searchKey, + ), + ], + ); + } + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); +} diff --git a/lib/src/screens/video_details_screen/comment/controller/comment_controller.dart b/lib/src/screens/video_details_screen/comment/controller/comment_controller.dart new file mode 100644 index 00000000..8ed11fac --- /dev/null +++ b/lib/src/screens/video_details_screen/comment/controller/comment_controller.dart @@ -0,0 +1,240 @@ +import 'dart:developer'; + +import 'package:acela/src/models/hive_comments/new_hive_comment/newest_comment_model.dart'; +import 'package:acela/src/utils/enum.dart'; +import 'package:acela/src/utils/graphql/gql_communicator.dart'; +import 'package:flutter/material.dart'; + +class CommentController extends ChangeNotifier { + final GQLCommunicator _gqlCommunicator = GQLCommunicator(); + ViewState viewState = ViewState.loading; + List disPlayedItems = []; + List items = []; + Sort sort = Sort.newest; + bool _commentHighlighterTrigger = false; + + bool get commentHighlighterTrigger => _commentHighlighterTrigger; + + set commentHighlighterTrigger(value) { + _commentHighlighterTrigger = value; + notifyListeners(); + } + + int? animateToCommentIndex = null; + + final String author; + final String permlink; + + CommentController({required this.author, required this.permlink}) { + _init(); + } + + void _init() async { + try { + disPlayedItems = [ + ...await _gqlCommunicator.getComments(author, permlink) + ]; + disPlayedItems = refactorComments(disPlayedItems, permlink); + items = disPlayedItems; + if (disPlayedItems.isEmpty) { + viewState = ViewState.empty; + } else { + viewState = ViewState.data; + } + notifyListeners(); + } catch (e) { + viewState = ViewState.error; + notifyListeners(); + } + } + + void addTopLevelComment(CommentItemModel comment, String searchKey) { + if (viewState == ViewState.empty) { + viewState = ViewState.data; + } + items = [ + comment, + ...items, + ]; + items = refactorComments(items, permlink); + if (searchKey.isEmpty) { + disPlayedItems = items; + } else { + if (_isSearchedKeyPresent(comment, searchKey)) { + if (sort == Sort.newest) { + disPlayedItems = [comment, ...disPlayedItems]; + } else { + disPlayedItems = [ + ...disPlayedItems, + comment, + ]; + } + } + animateToCommentIndex = sort == Sort.newest ? 0 : items.length - 1; + } + notifyListeners(); + } + + void addSubLevelComment( + CommentItemModel comment, int index, String searchKey) { + if (searchKey.isNotEmpty) { + var item = disPlayedItems[index]; + var newIndex = items.indexWhere((element) => element == item); + if (newIndex != -1) { + items[newIndex] = + items[newIndex].copyWith(children: items[newIndex].children + 1); + items = [...items, comment]; + items = refactorComments(items, permlink); + animateToCommentIndex = newIndex + 1; + if (_isSearchedKeyPresent(comment, searchKey)) { + if (sort == Sort.newest) { + disPlayedItems = [comment, ...disPlayedItems]; + } else { + disPlayedItems = [ + ...disPlayedItems, + comment, + ]; + } + } + } + } else { + disPlayedItems[index] = disPlayedItems[index] + .copyWith(children: disPlayedItems[index].children + 1); + disPlayedItems = [...disPlayedItems, comment]; + disPlayedItems = refactorComments(disPlayedItems, permlink); + items = disPlayedItems; + } + notifyListeners(); + } + + void onUpvote( + CommentItemModel comment, int index, String userName, String searchKey) { + CommentItemModel mutatedComment = comment.copyWith(activeVotes: [ + ...comment.activeVotes, + CommentActiveVote(voter: userName) + ]); + if (comment.stats != null) { + mutatedComment = mutatedComment.copyWith( + stats: mutatedComment.stats!.copyWith( + totalVotes: (mutatedComment.stats!.totalVotes ?? 0) + 1)); + } + + if (searchKey.isNotEmpty) { + var item = disPlayedItems[index]; + disPlayedItems[index] = mutatedComment; + var newIndex = items.indexWhere((element) => element == item); + if (newIndex != -1) { + items[newIndex] = mutatedComment; + } + } else { + disPlayedItems[index] = mutatedComment; + items = disPlayedItems; + } + + notifyListeners(); + } + + void refresh() async { + viewState = ViewState.loading; + notifyListeners(); + _init(); + } + + void onSort(Sort sort, String searchKey) { + if (this.sort != sort) { + this.sort = sort; + if (searchKey.isEmpty) { + disPlayedItems = [...refactorComments(disPlayedItems, permlink)]; + items = [...disPlayedItems]; + } else { + if (animateToCommentIndex != null) { + animateToCommentIndex = null; + } + items = [...refactorComments(items, permlink)]; + _sortList(disPlayedItems); + disPlayedItems = [...disPlayedItems]; + } + notifyListeners(); + } + } + + List refactorComments( + List content, String parentPermlink) { + List refactoredComments = []; + var newContent = List.from(content); + for (var e in newContent) { + e.visited = false; + } + _sortList(newContent); + refactoredComments.addAll( + newContent.where((e) => e.parentPermlink == parentPermlink).toList()); + while (refactoredComments.where((e) => e.visited == false).isNotEmpty) { + var firstComment = + refactoredComments.where((e) => e.visited == false).first; + var indexOfFirstElement = refactoredComments.indexOf(firstComment); + if (firstComment.children != 0) { + List children = newContent + .where((e) => e.parentPermlink == firstComment.permlink) + .toList(); + children.sort((a, b) { + var aTime = a.created; + var bTime = b.created; + if (aTime.isAfter(bTime)) { + return -1; + } else if (bTime.isAfter(aTime)) { + return 1; + } else { + return 0; + } + }); + refactoredComments.insertAll(indexOfFirstElement + 1, children); + } + firstComment.visited = true; + } + log('Returning ${refactoredComments.length} elements'); + return refactoredComments; + } + + void onSearch(String keyword) { + Set data = {}; + if (keyword.isNotEmpty) { + for (CommentItemModel item in items) { + if (_isSearchedKeyPresent(item, keyword)) { + data.add(item); + } + } + + disPlayedItems = data.toList(); + _sortList(disPlayedItems); + notifyListeners(); + } else { + disPlayedItems = items; + disPlayedItems = refactorComments(disPlayedItems, permlink); + notifyListeners(); + } + } + + void _sortList(List list) { + list.sort((a, b) { + var bTime = sort == Sort.newest ? b.created : a.created; + var aTime = sort == Sort.newest ? a.created : b.created; + if (aTime.isAfter(bTime)) { + return -1; + } else if (bTime.isAfter(aTime)) { + return 1; + } else { + return 0; + } + }); + } + + bool _isSearchedKeyPresent(CommentItemModel item, String keyword) { + if (item.body.toLowerCase().contains(keyword.toLowerCase())) { + return true; + } + if (item.author.toLowerCase().contains(keyword.toLowerCase())) { + return true; + } + return false; + } +} diff --git a/lib/src/screens/video_details_screen/comment/hive_comment.dart b/lib/src/screens/video_details_screen/comment/hive_comment.dart new file mode 100644 index 00000000..e5d57cc5 --- /dev/null +++ b/lib/src/screens/video_details_screen/comment/hive_comment.dart @@ -0,0 +1,289 @@ +import 'package:acela/src/models/hive_comments/new_hive_comment/newest_comment_model.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/video_details_screen/comment/comment_action_menu.dart'; +import 'package:acela/src/screens/video_details_screen/comment/controller/comment_controller.dart'; +import 'package:acela/src/utils/enum.dart'; +import 'package:acela/src/utils/seconds_to_duration.dart'; +import 'package:acela/src/widgets/confirmation_dialog.dart'; +import 'package:acela/src/widgets/user_profile_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:provider/provider.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:timeago/timeago.dart' as timeago; +import 'package:url_launcher/url_launcher.dart'; + +class CommentTile extends StatefulWidget { + const CommentTile( + {Key? key, + required this.comment, + required this.isPadded, + required this.index, + required this.currentUser, + required this.searchKey, + required this.itemScrollController}) + : super(key: key); + + final CommentItemModel comment; + final bool isPadded; + final int index; + final String currentUser; + final String searchKey; + final ItemScrollController itemScrollController; + + @override + State createState() => _CommentTileState(); +} + +class _CommentTileState extends State + with AutomaticKeepAliveClientMixin { + late int votes; + late bool isUpvoted; + bool animate = false; + bool animated = false; + Duration duration = Duration.zero; + late bool isHidden; + late Color color; + + @override + void initState() { + _initVoteStatus(); + _initAnimation(); + isHidden = (widget.comment.authorReputation ?? 0) < 0 || + (widget.comment.netRshares ?? 0) < 0; + super.initState(); + } + + void _initAnimation() { + if (!animated) { + Duration difference = DateTime.now().difference(widget.comment.created); + animate = difference.inSeconds < 5; + if (animate) { + color = Colors.grey.shade500; + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + if (mounted) + setState(() { + duration = Duration(seconds: 5); + color = Colors.transparent; + animated = true; + animate = false; + }); + }); + } else { + color = Colors.transparent; + } + } else { + color = Colors.transparent; + } + } + + void _callbackToAnimate() { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + if (mounted) + setState(() { + duration = Duration.zero; + color = Colors.grey.shade500; + }); + await Future.delayed(Duration(milliseconds: 50)); + if (mounted) + setState(() { + duration = Duration(seconds: 5); + color = Colors.transparent; + animated = true; + animate = false; + }); + }); + } + + void _initVoteStatus() { + votes = widget.comment.stats?.totalVotes ?? 0; + isUpvoted = widget.comment.activeVotes + .contains(CommentActiveVote(voter: widget.currentUser)); + } + + @override + void didUpdateWidget(covariant CommentTile oldWidget) { + _initVoteStatus(); + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + super.build(context); + var item = widget.comment; + var author = item.author; + var body = item.body; + var timeInString = "${timeago.format(item.created)}"; + var depth = (widget.isPadded ? 50.0 : 0.2 * 25.0); + var style = TextStyle(color: Colors.white, fontWeight: FontWeight.w600); + return Selector( + selector: (_, provider) => provider.commentHighlighterTrigger, + builder: (context, value, child) { + final controller = context.read(); + if (widget.index == controller.animateToCommentIndex && value) { + _callbackToAnimate(); + controller.animateToCommentIndex = null; + controller.commentHighlighterTrigger = false; + } + return child!; + }, + child: InkWell( + onTap: () { + if (isHidden) { + _showCommentUnMuteDialog(); + } else { + if (!widget.comment.isLocallyAdded) { + _showBottomSheet(item, onUpvote: () { + context.read().onUpvote( + item, widget.index, widget.currentUser, widget.searchKey); + setState(() { + votes++; + isUpvoted = true; + }); + }); + } + } + }, + child: AnimatedContainer( + duration: duration, + color: color, + onEnd: () => setState(() { + duration = Duration.zero; + }), + padding: EdgeInsets.only( + left: depth + 15, + right: 15, + bottom: 15, + top: widget.isPadded ? 0 : 15), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + UserProfileImage(userName: item.author), + const SizedBox( + width: 8, + ), + Expanded( + child: Row( + children: [ + Text(author, style: style), + const SizedBox( + width: 12, + ), + Icon( + isUpvoted ? Icons.thumb_up : Icons.thumb_up_outlined, + size: 15, + ), + const SizedBox( + width: 5, + ), + Text(votes.toString(), style: style), + const SizedBox( + width: 12, + ), + Icon( + Icons.schedule, + size: 15, + ), + const SizedBox( + width: 5, + ), + Expanded( + child: Text( + timeInString, + style: style, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ),), + if (isHidden) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Icon(Icons.visibility_off), + ) + ], + ), + ) + ], + ), + const SizedBox( + height: 8, + ), + if (!isHidden) _comment(body) + ], + ), + ), + ), + ); + } + + Widget _comment(String text) { + return MarkdownBody( + data: Utilities.removeAllHtmlTags(text), + shrinkWrap: true, + onTapLink: (text, url, title) { + launchUrl(Uri.parse(url ?? 'https://google.com')); + }, + ); + } + + void _showBottomSheet(CommentItemModel item, + {required VoidCallback onUpvote}) { + FocusScope.of(context).unfocus(); + final controller = context.read(); + showModalBottomSheet( + backgroundColor: const Color(0xFF1B1A1A), + useRootNavigator: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12))), + context: context, + builder: (context) => CommentActionMenu( + depth: item.depth, + onSubCommentAdded: (newComment) { + controller.addSubLevelComment( + newComment, widget.index, widget.searchKey); + + if (widget.searchKey.isNotEmpty && + controller.disPlayedItems.contains(newComment)) { + _animteToAddedComment(controller.sort == Sort.newest + ? 0 + : controller.disPlayedItems.length - 1); + } + }, + onUpVote: onUpvote, + appData: context.read(), + author: item.author, + permlink: item.permlink, + ), + ); + } + + void _showCommentUnMuteDialog() { + showDialog( + barrierDismissible: true, + useRootNavigator: true, + context: context, + builder: (context) { + return ConfirmationDialog( + title: "Muted comment", + content: "Are you sure you want to see muted comment ?", + onConfirm: () { + if (mounted) + setState(() { + isHidden = false; + }); + }); + }, + ); + } + + void _animteToAddedComment(int index) { + widget.itemScrollController.scrollTo( + index: index, + duration: Duration(milliseconds: 200), + curve: Curves.easeInOutCubic); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/lib/src/screens/video_details_screen/comment/hive_comment_dialog.dart b/lib/src/screens/video_details_screen/comment/hive_comment_dialog.dart new file mode 100644 index 00000000..eaf3397e --- /dev/null +++ b/lib/src/screens/video_details_screen/comment/hive_comment_dialog.dart @@ -0,0 +1,391 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; +import 'package:acela/src/models/hive_comments/new_hive_comment/newest_comment_model.dart'; +import 'package:acela/src/models/login/login_bridge_response.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/utils/communicator.dart'; +import 'package:acela/src/utils/safe_convert.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +class HiveCommentDialog extends StatefulWidget { + const HiveCommentDialog( + {Key? key, + required this.username, + required this.author, + required this.permlink, + required this.hasKey, + required this.hasAuthKey, + required this.onClose, + required this.onDone, + this.depth}) + : super(key: key); + final String username; + final String author; + final String permlink; + final String hasKey; + final String hasAuthKey; + final int? depth; + final Function(CommentItemModel? comment) onDone; + final Function onClose; + + @override + State createState() => _HiveCommentDialogState(); +} + +class _HiveCommentDialogState extends State { + var isCommenting = false; + late WebSocketChannel socket; + var socketClosed = true; + String? qrCode; + var timer = 0; + var timeoutValue = 0; + Timer? ticker; + var loadingQR = false; + var textController = TextEditingController(); + var text = ''; + var shouldShowHiveAuth = false; + + void showError(String string) { + var snackBar = SnackBar(content: Text('Error: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + void showMessage(String string) { + var snackBar = SnackBar(content: Text('Message: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + @override + void initState() { + super.initState(); + socket = WebSocketChannel.connect( + Uri.parse(Communicator.hiveAuthServer), + ); + socket.stream.listen((message) { + var map = json.decode(message) as Map; + var cmd = asString(map, 'cmd'); + if (cmd.isNotEmpty) { + switch (cmd) { + case "connected": + setState(() { + timeoutValue = asInt(map, 'timeout'); + }); + break; + case "auth_wait": + log('You are not logged in.'); + break; + case "auth_ack": + log('You are not logged in.'); + break; + case "auth_nack": + log('You are not logged in.'); + break; + case "sign_wait": + var uuid = asString(map, 'uuid'); + var jsonData = { + "account": widget.username, + "uuid": uuid, + "key": widget.hasKey, + "host": Communicator.hiveAuthServer + }; + var jsonString = json.encode(jsonData); + var utf8Data = utf8.encode(jsonString); + var qr = base64.encode(utf8Data); + qr = "has://sign_req/$qr"; + var uri = Uri.tryParse(qr); + if (uri != null) { + launchUrl(uri); + } + setState(() { + loadingQR = false; + qrCode = qr; + var uri = Uri.tryParse(qr); + if (uri != null) { + launchUrl(uri); + } + timer = timeoutValue; + ticker = Timer.periodic(Duration(seconds: 1), (tickrr) { + if (timer == 0) { + setState(() { + tickrr.cancel(); + qrCode = null; + }); + } else { + setState(() { + timer--; + }); + } + }); + }); + break; + case "sign_ack": + Future.delayed(const Duration(seconds: 6), () { + if (mounted) { + setState(() { + String currentUserName = widget.username; + CommentItemModel addedComment = CommentItemModel( + created: DateTime.now(), + author: currentUserName, + isLocallyAdded: true, + permlink: + "re-$currentUserName-${DateTime.now().toIso8601String()}", + parentAuthor: widget.author, + parentPermlink: widget.permlink, + body: textController.text, + depth: widget.depth == null ? 1 : widget.depth! + 1, + children: 0, + ); + isCommenting = false; + widget.onDone(addedComment); + Navigator.of(context).pop(); + }); + } + }); + break; + case "sign_nack": + setState(() { + isCommenting = false; + ticker?.cancel(); + qrCode = null; + }); + showError("Comment was declined. Please try again."); + break; + case "sign_err": + setState(() { + ticker?.cancel(); + qrCode = null; + }); + showError("Upvote action failed."); + break; + default: + log('Default case here'); + } + } + }, onError: (e) async { + await Future.delayed(Duration(seconds: 2)); + socket = WebSocketChannel.connect( + Uri.parse(Communicator.hiveAuthServer), + ); + }, onDone: () async { + await Future.delayed(Duration(seconds: 2)); + socket = WebSocketChannel.connect( + Uri.parse(Communicator.hiveAuthServer), + ); + }, cancelOnError: true); + } + + Widget _comment() { + return Container( + padding: EdgeInsets.all(10), + child: TextField( + decoration: const InputDecoration( + labelText: 'Comment', + hintText: 'Enter comment here.', + ), + keyboardType: TextInputType.multiline, + minLines: 3, + maxLines: 6, + controller: textController, + onChanged: (newTextValue) { + setState(() { + text = newTextValue; + }); + }, + ), + ); + } + + void saveButtonTapped(HiveUserData data) async { + setState(() { + isCommenting = true; + }); + try { + var user = data.username; + if (user == null) return; + const platform = MethodChannel('com.example.acela/auth'); + var description = base64.encode(utf8.encode(text)); + final String result = await platform.invokeMethod('commentOnContent', { + 'user': user, + 'author': widget.author, + 'permlink': widget.permlink, + 'comment': description, + 'postingKey': data.postingKey ?? '', + 'hasKey': data.keychainData?.hasId ?? '', + 'hasAuthKey': data.keychainData?.hasAuthKey ?? '', + }); + var response = LoginBridgeResponse.fromJsonString(result); + if (response.valid && response.error.isEmpty) { + log("Successful upvote and bridge communication"); + if (response.error.isEmpty && + response.data != null && + response.data!.isNotEmpty && + data.keychainData?.hasAuthKey != null) { + var socketData = { + "cmd": "sign_req", + "account": data.username!, + "token": data.keychainData!.hasId, + "data": response.data!, + }; + log('Socket message is - ${json.encode(socketData)}'); + loadingQR = true; + var jsonData = json.encode(socketData); + socket.sink.add(jsonData); + } else if (response.error.isEmpty) { + Future.delayed(const Duration(seconds: 6), () { + if (mounted) { + setState(() { + isCommenting = false; + String currentUserName = widget.username; + CommentItemModel addedComment = CommentItemModel( + created: DateTime.now(), + isLocallyAdded: true, + author: currentUserName, + permlink: + "re-$currentUserName-${DateTime.now().toIso8601String()}", + parentAuthor: widget.author, + parentPermlink: widget.permlink, + body: textController.text, + depth: widget.depth == null ? 1 : widget.depth! + 1, + children: 0, + ); + widget.onDone(addedComment); + showMessage('Comment published successfully'); + Navigator.of(context).pop(); + }); + } + }); + } + } + } catch (e) { + showError('Something went wrong.\n${e.toString()}'); + } + } + + Widget _showQRCodeAndKeychainButton(String qr) { + Widget hkButton = ElevatedButton( + onPressed: () { + var uri = Uri.tryParse(qr); + if (uri != null) { + launchUrl(uri); + } + }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.black), + child: Image.asset('assets/hive-keychain-image.png', width: 100), + ); + Widget haButton = ElevatedButton( + onPressed: () { + setState(() { + shouldShowHiveAuth = true; + }); + }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.black), + child: Image.asset('assets/hive_auth_button.png', width: 120), + ); + Widget qrCode = InkWell( + child: Container( + decoration: BoxDecoration(color: Colors.white), + child: QrImageView( + data: qr, + size: 150.0, + gapless: true, + ), + ), + onTap: () { + var uri = Uri.tryParse(qr); + if (uri != null) { + launchUrl(uri); + } + }, + ); + var backButton = ElevatedButton.icon( + onPressed: () { + setState(() { + shouldShowHiveAuth = false; + }); + }, + icon: Icon(Icons.arrow_back), + label: Text("Back"), + ); + List array = []; + if (shouldShowHiveAuth) { + array = [ + backButton, + const SizedBox(width: 10), + qrCode, + ]; + } else { + array = [ + haButton, + const SizedBox(width: 10), + hkButton, + ]; + } + return Center( + child: Column( + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: array, + ), + SizedBox(height: 10), + SizedBox( + width: 200, + child: LinearProgressIndicator( + value: timer.toDouble() / timeoutValue.toDouble(), + semanticsLabel: 'Timeout Timer for HiveAuth QR', + ), + ), + ], + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + var data = Provider.of(context); + return Scaffold( + appBar: AppBar( + title: ListTile( + contentPadding: EdgeInsets.zero, + title: Text("Add Comment"), + subtitle: Text( + "@${widget.author}/${widget.permlink}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + actions: isCommenting || text.length == 0 || qrCode != null + ? [] + : [ + IconButton( + onPressed: () { + saveButtonTapped(data); + }, + icon: Icon(Icons.comment), + ), + ], + ), + body: SafeArea( + child: isCommenting + ? qrCode != null + ? _showQRCodeAndKeychainButton(qrCode!) + : const Center(child: CircularProgressIndicator()) + : qrCode != null + ? _showQRCodeAndKeychainButton(qrCode!) + : _comment(), + ), + ); + } +} diff --git a/lib/src/screens/video_details_screen/comment/menu.dart b/lib/src/screens/video_details_screen/comment/menu.dart new file mode 100644 index 00000000..3d17a4c5 --- /dev/null +++ b/lib/src/screens/video_details_screen/comment/menu.dart @@ -0,0 +1,52 @@ +import 'package:acela/src/screens/video_details_screen/comment/controller/comment_controller.dart'; +import 'package:acela/src/utils/enum.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class Menu extends StatelessWidget { + const Menu({ + Key? key, + required this.searchKey, + }) : super( + key: key, + ); + + final TextEditingController searchKey; + + @override + Widget build(BuildContext context) { + final controller = context.read(); + return PopupMenuButton( + constraints: BoxConstraints(), + padding: EdgeInsets.zero, + tooltip: "Sort", + icon: Icon( + Icons.filter_list, + ), + itemBuilder: (context) { + return [ + PopupMenuItem( + height: 40, + padding: const EdgeInsets.only(left: 10), + onTap: () => controller.onSort(Sort.newest, searchKey.text.trim()), + child: Text( + 'Newest', + style: TextStyle( + color: controller.sort == Sort.newest ? Colors.blue : null), + ), + ), + PopupMenuItem( + height: 40, + padding: const EdgeInsets.only(left: 10), + onTap: () => controller.onSort(Sort.oldest, searchKey.text.trim()), + child: Text( + 'Oldest', + style: TextStyle( + color: controller.sort == Sort.oldest ? Colors.blue : null), + ), + ) + ]; + }, + ); + } +} diff --git a/lib/src/screens/video_details_screen/comment/video_details_comments.dart b/lib/src/screens/video_details_screen/comment/video_details_comments.dart new file mode 100644 index 00000000..e9fd4403 --- /dev/null +++ b/lib/src/screens/video_details_screen/comment/video_details_comments.dart @@ -0,0 +1,321 @@ +import 'package:acela/src/models/hive_comments/new_hive_comment/newest_comment_model.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/login/ha_login_screen.dart'; +import 'package:acela/src/screens/video_details_screen/comment/comment_search_bar.dart'; +import 'package:acela/src/screens/video_details_screen/comment/comment_view_appbar.dart'; +import 'package:acela/src/screens/video_details_screen/comment/controller/comment_controller.dart'; +import 'package:acela/src/screens/video_details_screen/comment/hive_comment.dart'; +import 'package:acela/src/screens/video_details_screen/comment/hive_comment_dialog.dart'; +import 'package:acela/src/utils/enum.dart'; +import 'package:acela/src/utils/graphql/models/trending_feed_response.dart'; +import 'package:adaptive_action_sheet/adaptive_action_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; + +class VideoDetailsComments extends StatefulWidget { + const VideoDetailsComments({ + Key? key, + required this.author, + required this.permlink, + required this.rpc, + required this.item, + required this.appData, + }) : super(key: key); + final String author; + final String permlink; + final String rpc; + final GQLFeedItem item; + final HiveUserData appData; + + @override + State createState() => _VideoDetailsCommentsState(); +} + +class _VideoDetailsCommentsState extends State { + final ValueNotifier showSearchBar = ValueNotifier(false); + final TextEditingController searchController = TextEditingController(); + final ItemScrollController itemScrollController = ItemScrollController(); + final ScrollOffsetController scrollOffsetController = + ScrollOffsetController(); + final ItemPositionsListener itemPositionsListener = + ItemPositionsListener.create(); + final ScrollOffsetListener scrollOffsetListener = + ScrollOffsetListener.create(); + late final CommentController controller; + + @override + void initState() { + controller = + CommentController(author: widget.author, permlink: widget.permlink); + _addListener(); + super.initState(); + } + + void _addListener() { + showSearchBar.addListener(_searchBarListener); + } + + void _searchBarListener() async { + if (!showSearchBar.value) { + if (controller.animateToCommentIndex != null) { + await Future.delayed(Duration(milliseconds: 300)); + _animteToAddedComment(controller.animateToCommentIndex!); + controller.commentHighlighterTrigger = true; + } + } + } + + @override + void dispose() { + searchController.dispose(); + showSearchBar.removeListener(_searchBarListener); + super.dispose(); + } + + Widget commentsListView() { + return Selector>( + shouldRebuild: (previous, next) => + previous != next || previous.length != next.length, + selector: (_, myType) => myType.disPlayedItems, + builder: (context, items, child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CommentSearchBar( + showSearchBar: showSearchBar, + onChanged: (value) { + controller.onSearch(value.trim()); + }, + textEditingController: searchController), + items.isNotEmpty + ? Expanded( + child: Container( + margin: const EdgeInsets.only(top: 10, bottom: 10), + child: searchController.text.trim().isEmpty + ? RefreshIndicator( + onRefresh: () async { + if (searchController.text.trim().isEmpty) { + controller.refresh(); + } + }, + child: _commentListViewBuilder(items), + ) + : _commentListViewBuilder(items), + ), + ) + : Expanded( + child: Center( + child: Text("No Results Found"), + ), + ), + ], + ); + }, + ); + } + + ScrollablePositionedList _commentListViewBuilder( + List items) { + return ScrollablePositionedList.separated( + itemScrollController: itemScrollController, + scrollOffsetController: scrollOffsetController, + itemPositionsListener: itemPositionsListener, + scrollOffsetListener: scrollOffsetListener, + itemBuilder: (context, index) { + final CommentItemModel item = items[index]; + return CommentTile( + key: ValueKey( + '${item.author}/${item.permlink}/${item.created.toIso8601String()}'), + itemScrollController: itemScrollController, + isPadded: item.depth != 1 && searchController.text.isEmpty, + currentUser: widget.appData.username!, + comment: item, + index: index, + searchKey: searchController.text.trim(), + ); + }, + separatorBuilder: (context, index) { + bool commentDividerVisibility = true; + commentDividerVisibility = + _commentDividerVisibility(index, items, commentDividerVisibility); + return Visibility( + visible: commentDividerVisibility, + child: const Divider( + height: 10, + color: Colors.blueGrey, + ), + ); + }, + itemCount: items.length, + ); + } + + bool _commentDividerVisibility( + int index, List items, bool drawLine) { + if (index + 1 < items.length) { + if ((items[index + 1].depth == 1)) { + drawLine = true; + } else { + drawLine = false; + } + } + return drawLine; + } + + Widget _addCommentButton() { + return SafeArea( + child: Selector( + selector: (context, provider) => provider.viewState, + builder: (context, viewState, child) { + if (viewState == ViewState.data || viewState == ViewState.empty) { + return Padding( + padding: const EdgeInsets.only(left: 10.0, right: 10, bottom: 10), + child: SizedBox( + height: 35, + child: TextButton.icon( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + backgroundColor: Colors.blue, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(4), + ), + ), + ), + onPressed: () => commentPressed(controller), + icon: Icon( + Icons.add, + color: Colors.white, + ), + label: Text( + "Add a Comment", + style: TextStyle(color: Colors.white), + ), + ), + ), + ); + } else { + return SizedBox.shrink(); + } + }, + ), + ); + } + + void commentPressed(CommentController controller) { + if (widget.appData.username == null) { + showAdaptiveActionSheet( + context: context, + title: const Text('You are not logged in. Please log in to comment.'), + androidBorderRadius: 30, + actions: [ + BottomSheetAction( + title: Text('Log in'), + leading: Icon(Icons.login), + onPressed: (c) { + Navigator.of(c).pop(); + var screen = HiveAuthLoginScreen(appData: widget.appData); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(c).push(route); + }), + ], + cancelAction: CancelAction(title: const Text('Cancel')), + ); + return; + } + var screen = HiveCommentDialog( + author: widget.item.author?.username ?? 'sagarkothari88', + permlink: widget.item.permlink ?? 'ctbtwcxbbd', + username: widget.appData.username ?? "", + hasKey: widget.appData.keychainData?.hasId ?? "", + hasAuthKey: widget.appData.keychainData?.hasAuthKey ?? "", + onClose: () {}, + onDone: (newComment) async { + if (newComment != null) { + controller.addTopLevelComment( + newComment, searchController.text.trim()); + int animateToindex = controller.sort == Sort.newest + ? 0 + : controller.disPlayedItems.length - 1; + if (searchController.text.isEmpty) { + _animteToAddedComment(animateToindex); + } else if (controller.disPlayedItems.contains(newComment)) { + _animteToAddedComment(animateToindex); + } + } + }, + ); + Navigator.of(context).push(MaterialPageRoute(builder: (c) => screen)); + } + + void _animteToAddedComment(int index) { + itemScrollController.scrollTo( + index: index, + duration: Duration(milliseconds: 200), + curve: Curves.easeInOutCubic); + } + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: controller, + builder: (context, child) { + return Selector( + selector: (_, myType) => myType.viewState, + builder: (context, state, child) { + return Scaffold( + bottomNavigationBar: _addCommentButton(), + appBar: CommentViewAppbar( + state: state, + searchKey: searchController, + showSearchBar: showSearchBar, + ), + body: SafeArea( + child: _body( + state, + ), + ), + ); + }, + ); + }); + } + + Widget _body( + ViewState state, + ) { + if (state == ViewState.data) { + return commentsListView(); + } else if (state == ViewState.empty) { + return Center( + child: Text("No comments found"), + ); + } else if (state == ViewState.error) { + return Container( + margin: const EdgeInsets.all(10), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("Sorry, something went wrong"), + TextButton( + onPressed: () => controller.refresh(), child: Text("Retry")) + ], + ), + ), + ); + } else { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + SizedBox(child: CircularProgressIndicator(value: null)), + SizedBox(height: 20), + Text('Loading comments'), + ], + ), + ); + } + } +} diff --git a/lib/src/screens/video_details_screen/hive_upvote_dialog.dart b/lib/src/screens/video_details_screen/hive_upvote_dialog.dart new file mode 100644 index 00000000..86718065 --- /dev/null +++ b/lib/src/screens/video_details_screen/hive_upvote_dialog.dart @@ -0,0 +1,475 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; + +import 'package:acela/src/models/action_response.dart'; +import 'package:acela/src/models/hive_post_info/hive_post_info.dart'; +import 'package:acela/src/models/login/login_bridge_response.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/utils/communicator.dart'; +import 'package:acela/src/utils/safe_convert.dart'; +import 'package:acela/src/widgets/user_profile_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +class HiveUpvoteDialog extends StatefulWidget { + const HiveUpvoteDialog({ + Key? key, + required this.username, + required this.author, + required this.permlink, + required this.hasKey, + required this.hasAuthKey, + required this.accessToken, + required this.postingAuthority, + required this.activeVotes, + required this.onClose, + required this.onDone, + }) : super(key: key); + final String username; + final String author; + final String permlink; + final String hasKey; + final String hasAuthKey; + final String? accessToken; + final bool postingAuthority; + final Function onDone; + final Function onClose; + final List activeVotes; + + @override + State createState() => _HiveUpvoteDialogState(); +} + +class _HiveUpvoteDialogState extends State { + var isUpVoting = false; + var sliderValue = 0.1; + late WebSocketChannel socket; + var socketClosed = true; + String? qrCode; + var timer = 0; + var timeoutValue = 0; + Timer? ticker; + var loadingQR = false; + var shouldShowHiveAuth = false; + + void showError(String string) { + var snackBar = SnackBar(content: Text('Error: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + void showMessage(String string) { + var snackBar = SnackBar(content: Text('Message: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + @override + void initState() { + super.initState(); + if (widget.activeVotes + .where((element) => element.voter == widget.username) + .length > + 0) { + } else { + socket = WebSocketChannel.connect( + Uri.parse(Communicator.hiveAuthServer), + ); + socket.stream.listen((message) { + var map = json.decode(message) as Map; + var cmd = asString(map, 'cmd'); + if (cmd.isNotEmpty) { + switch (cmd) { + case "connected": + setState(() { + timeoutValue = asInt(map, 'timeout'); + }); + break; + case "auth_wait": + log('You are not logged in.'); + break; + case "auth_ack": + log('You are not logged in.'); + break; + case "auth_nack": + log('You are not logged in.'); + break; + case "sign_wait": + var uuid = asString(map, 'uuid'); + var jsonData = { + "account": widget.username, + "uuid": uuid, + "key": widget.hasKey, + "host": Communicator.hiveAuthServer + }; + var jsonString = json.encode(jsonData); + var utf8Data = utf8.encode(jsonString); + var qr = base64.encode(utf8Data); + qr = "has://sign_req/$qr"; + var uri = Uri.tryParse(qr); + if (uri != null) { + launchUrl(uri); + } + setState(() { + loadingQR = false; + qrCode = qr; + var uri = Uri.tryParse(qr); + if (uri != null) { + launchUrl(uri); + } + timer = timeoutValue; + ticker = Timer.periodic(Duration(seconds: 1), (tickrr) { + if (timer == 0) { + setState(() { + tickrr.cancel(); + qrCode = null; + }); + } else { + setState(() { + timer--; + }); + } + }); + }); + break; + case "sign_ack": + Future.delayed(const Duration(seconds: 6), () { + if (mounted) { + setState(() { + isUpVoting = false; + widget.onDone(); + Navigator.of(context).pop(); + }); + } + }); + break; + case "sign_nack": + setState(() { + ticker?.cancel(); + qrCode = null; + }); + showError("Upvote was declined. Please try again."); + break; + case "sign_err": + setState(() { + ticker?.cancel(); + qrCode = null; + }); + showError("Upvote action failed."); + break; + default: + log('Default case here'); + } + } + }, onError: (e) async { + await Future.delayed(Duration(seconds: 2)); + socket = WebSocketChannel.connect( + Uri.parse(Communicator.hiveAuthServer), + ); + }, onDone: () async { + await Future.delayed(Duration(seconds: 2)); + socket = WebSocketChannel.connect( + Uri.parse(Communicator.hiveAuthServer), + ); + }, cancelOnError: true); + } + } + + Widget _upVoteSlider() { + var data = Provider.of(context); + var user = data.username; + if (user == null) return Container(); + var voteValue = sliderValue * 100; + var intVoteValue = voteValue.round(); + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + percentageButtons(0.1), + percentageButtons(0.25), + percentageButtons(0.5), + percentageButtons(0.75), + percentageButtons(1), + ], + ), + Slider( + value: sliderValue, + min: 0.1, + divisions: 40, + label: '${(sliderValue * 100).round()} %', + activeColor: sliderValue >= 0.0 + ? Theme.of(context).colorScheme.primary + : Colors.red, + onChanged: (val) { + setState(() { + sliderValue = val; + }); + }, + ), + const SizedBox(height: 10), + Text( + "$intVoteValue %${sliderValue >= 0.0 ? "" : "\nDownVote discourages content creator.\nPlease be double sure when downVoting 👎 content."}", + textAlign: TextAlign.center), + ], + ); + } + + Widget percentageButtons(double slideValue) { + return Padding( + padding: const EdgeInsets.only(right: 15.0), + child: GestureDetector( + onTap: () { + setState(() { + sliderValue = slideValue; + }); + }, + child: CircleAvatar( + radius: 20, + child: Text("${(slideValue * 100).round()}", + style: const TextStyle(color: Colors.white, fontSize: 15)), + ), + ), + ); + } + + void saveButtonTapped(HiveUserData data) async { + setState(() { + isUpVoting = true; + }); + var voteValue = sliderValue * 10000; + var user = data.username; + if (user == null) return; + // if (widget.accessToken != null && widget.postingAuthority) { + // ActionResponse response = await Communicator() + // .vote(widget.accessToken!, widget.author, widget.permlink); + // if (response.valid && response.error.isEmpty) { + // // Future.delayed(const Duration(seconds: 6), () { + // if (mounted) { + // setState(() { + // isUpVoting = false; + // widget.onDone(); + // Navigator.of(context).pop(); + // }); + // } + // // }); + // } else { + // showError(response.error); + // if (isUpVoting && mounted) { + // setState(() { + // isUpVoting = false; + // }); + // } + // } + // } + // else { + try { + const platform = MethodChannel('com.example.acela/auth'); + final String result = await platform.invokeMethod('voteContent', { + 'user': user, + 'author': widget.author, + 'permlink': widget.permlink, + 'weight': voteValue, + 'postingKey': data.postingKey ?? '', + 'hasKey': data.keychainData?.hasId ?? '', + 'hasAuthKey': data.keychainData?.hasAuthKey ?? '', + }); + var response = LoginBridgeResponse.fromJsonString(result); + if (response.valid && response.error.isEmpty) { + if (response.error == "" && + response.data != null && + response.data!.isNotEmpty && + data.keychainData?.hasAuthKey != null) { + var socketData = { + "cmd": "sign_req", + "account": data.username!, + "token": data.keychainData!.hasId, + "data": response.data!, + }; + loadingQR = true; + var jsonData = json.encode(socketData); + socket.sink.add(jsonData); + } else { + Future.delayed(const Duration(seconds: 6), () { + if (mounted) { + setState(() { + isUpVoting = false; + widget.onDone(); + Navigator.of(context).pop(); + }); + } + }); + } + } else { + if (isUpVoting && mounted) { + setState(() { + isUpVoting = false; + }); + } + showError('Something went wrong.\n${response.error}'); + } + } catch (e) { + if (isUpVoting && mounted) { + setState(() { + isUpVoting = false; + }); + } + showError('Something went wrong.\n${e.toString()}'); + } + // } + } + + Widget _showQRCodeAndKeychainButton(String qr) { + Widget hkButton = ElevatedButton( + onPressed: () { + var uri = Uri.tryParse(qr); + if (uri != null) { + launchUrl(uri); + } + }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.black), + child: Image.asset('assets/hive-keychain-image.png', width: 100), + ); + Widget haButton = ElevatedButton( + onPressed: () { + setState(() { + shouldShowHiveAuth = true; + }); + }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.black), + child: Image.asset('assets/hive_auth_button.png', width: 120), + ); + Widget qrCode = InkWell( + child: Container( + decoration: BoxDecoration(color: Colors.white), + child: QrImageView( + data: qr, + size: 150.0, + gapless: true, + ), + ), + onTap: () { + var uri = Uri.tryParse(qr); + if (uri != null) { + launchUrl(uri); + } + }, + ); + var backButton = ElevatedButton.icon( + onPressed: () { + setState(() { + shouldShowHiveAuth = false; + }); + }, + icon: Icon(Icons.arrow_back), + label: Text("Back"), + ); + List array = []; + if (shouldShowHiveAuth) { + array = [ + backButton, + const SizedBox(width: 10), + qrCode, + ]; + } else { + array = [ + haButton, + const SizedBox(width: 10), + hkButton, + ]; + } + return Center( + child: Column( + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: array, + ), + SizedBox(height: 10), + SizedBox( + width: 200, + child: LinearProgressIndicator( + value: timer.toDouble() / timeoutValue.toDouble(), + semanticsLabel: 'Timeout Timer for HiveAuth QR', + ), + ), + ], + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + var data = Provider.of(context); + return SizedBox( + height: 300, + child: Scaffold( + floatingActionButton: isUpVoting || sliderValue == 0.0 || qrCode != null + ? const SizedBox.shrink() + : FloatingActionButton( + onPressed: () { + saveButtonTapped(data); + }, + child: Icon( + sliderValue > 0.0 + ? Icons.thumb_up_sharp + : Icons.thumb_down_alt_sharp, + color: sliderValue > 0.0 ? Colors.white : Colors.red, + ), + ), + appBar: AppBar( + toolbarHeight: 60, + leadingWidth: 30, + automaticallyImplyLeading: false, + title: ListTile( + contentPadding: EdgeInsets.zero, + leading: UserProfileImage( + userName: widget.author, + ), + title: Text( + "Upvote", + style: + TextStyle(color: Colors.white, fontWeight: FontWeight.w500), + ), + subtitle: Text( + "@${widget.author}/${widget.permlink}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + actions: [ + IconButton( + splashRadius: 30, + icon: const Icon( + Icons.cancel, + size: 28, + ), + onPressed: () { + widget.onClose(); + Navigator.of(context).pop(); + }, + ), + ]), + body: SafeArea( + child: isUpVoting + ? qrCode != null + ? _showQRCodeAndKeychainButton(qrCode!) + : const Center(child: CircularProgressIndicator()) + : qrCode != null + ? _showQRCodeAndKeychainButton(qrCode!) + : _upVoteSlider(), + ), + ), + ); + } +} diff --git a/lib/src/screens/video_details_screen/new_video_details/new_video_details_screen.dart b/lib/src/screens/video_details_screen/new_video_details/new_video_details_screen.dart new file mode 100644 index 00000000..05edb432 --- /dev/null +++ b/lib/src/screens/video_details_screen/new_video_details/new_video_details_screen.dart @@ -0,0 +1,812 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/global_provider/image_resolution_provider.dart'; +import 'package:acela/src/global_provider/video_setting_provider.dart'; +import 'package:acela/src/models/hive_post_info/hive_post_info.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/home_screen/home_screen_feed_item/widgets/feed_item_grid_view.dart'; +import 'package:acela/src/screens/home_screen/home_screen_feed_item/widgets/new_feed_list_item.dart'; +import 'package:acela/src/screens/login/ha_login_screen.dart'; +import 'package:acela/src/screens/podcast/widgets/favourite.dart'; +import 'package:acela/src/screens/trending_tags/trending_tag_videos.dart'; +import 'package:acela/src/screens/video_details_screen/comment/video_details_comments.dart'; +import 'package:acela/src/screens/video_details_screen/hive_upvote_dialog.dart'; +import 'package:acela/src/screens/video_details_screen/new_video_details/video_detail_favourite_provider.dart'; +import 'package:acela/src/screens/video_details_screen/new_video_details_info.dart'; +import 'package:acela/src/utils/graphql/gql_communicator.dart'; +import 'package:acela/src/utils/graphql/models/trending_feed_response.dart'; +import 'package:acela/src/utils/routes/routes.dart'; +import 'package:acela/src/utils/seconds_to_duration.dart'; +import 'package:acela/src/widgets/box_loading/video_detail_feed_loader.dart'; +import 'package:acela/src/widgets/box_loading/video_feed_loader.dart'; +import 'package:acela/src/widgets/cached_image.dart'; +import 'package:adaptive_action_sheet/adaptive_action_sheet.dart'; +import 'package:better_player/better_player.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart' as http; +import 'package:provider/provider.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:timeago/timeago.dart' as timeago; +import 'package:wakelock_plus/wakelock_plus.dart'; + +class NewVideoDetailsScreen extends StatefulWidget { + const NewVideoDetailsScreen( + {Key? key, + this.item, + this.betterPlayerController, + required this.author, + required this.permlink, + this.onPop}); + + final GQLFeedItem? item; + final String author; + final String permlink; + final VoidCallback? onPop; + final BetterPlayerController? betterPlayerController; + + @override + State createState() => _NewVideoDetailsScreenState(); +} + +class _NewVideoDetailsScreenState extends State { + late BetterPlayerController _betterPlayerController; + late GQLFeedItem item; + bool isLoadingVideo = true; + HivePostInfoPostResultBody? postInfo; + var selectedChip = 0; + late final VideoSettingProvider videoSettingProvider; + late HiveUserData appData; + List suggestions = []; + bool isSuggestionsLoading = true; + + @override + void initState() { + appData = context.read(); + videoSettingProvider = context.read(); + super.initState(); + WakelockPlus.enable(); + loadDataAndVideo(); + loadHiveInfo(); + loadSuggestions(); + } + + @override + void dispose() { + if (widget.betterPlayerController == null) { + _betterPlayerController.videoPlayerController! + .removeListener(_videoPlayerListener); + } + super.dispose(); + WakelockPlus.disable(); + } + + @override + void deactivate() { + changeControlsVisibility(false); + if (widget.onPop != null) widget.onPop!(); + super.deactivate(); + } + + void loadSuggestions() async { + var items = await GQLCommunicator().getRelated( + widget.author, + widget.permlink, + appData.language, + ); + if (mounted) { + setState(() { + suggestions = items; + isSuggestionsLoading = false; + }); + } + } + + void loadHiveInfo() async { + if (mounted) { + setState(() { + postInfo = null; + }); + } + var data = await fetchHiveInfoForThisVideo(appData.rpc); + if (mounted) { + setState(() { + postInfo = data; + }); + } + } + + Future fetchHiveInfoForThisVideo( + String hiveApiUrl) async { + var request = http.Request('POST', Uri.parse('https://$hiveApiUrl')); + request.body = json.encode({ + "id": 1, + "jsonrpc": "2.0", + "method": "bridge.get_discussion", + "params": { + "author": widget.author, + "permlink": widget.permlink, + "observer": "" + } + }); + http.StreamedResponse response = await request.send(); + if (response.statusCode == 200) { + var string = await response.stream.bytesToString(); + var result = HivePostInfo.fromJsonString(string) + .result + .resultData + .where((element) => element.permlink == (widget.permlink)) + .first; + return result; + } else { + print(response.reasonPhrase); + throw response.reasonPhrase ?? 'Can not load payout info'; + } + } + + Widget videoThumbnail() { + return Selector( + selector: (context, myType) => myType.resolution, + builder: (context, value, child) { + return CachedImage( + imageUrl: Utilities.getProxyImage( + value, (item.spkvideo?.thumbnailUrl ?? '')), + imageHeight: 230, + imageWidth: double.infinity, + ); + }); + } + + void setupVideo(String url) { + BetterPlayerConfiguration betterPlayerConfiguration = + BetterPlayerConfiguration( + // aspectRatio: size.width / size.height, + fit: BoxFit.contain, + autoPlay: true, + fullScreenByDefault: false, + placeholder: videoThumbnail(), + controlsConfiguration: BetterPlayerControlsConfiguration( + enablePip: false, + enableFullscreen: defaultTargetPlatform == TargetPlatform.android, + enableSkips: true, + ), + autoDetectFullscreenAspectRatio: false, + deviceOrientationsOnFullScreen: const [ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + DeviceOrientation.portraitDown, + DeviceOrientation.portraitUp + ], + deviceOrientationsAfterFullScreen: const [ + DeviceOrientation.portraitDown, + DeviceOrientation.portraitUp + ], + autoDispose: true, + expandToFill: true, + allowedScreenSleep: false, + ); + BetterPlayerDataSource dataSource = BetterPlayerDataSource( + BetterPlayerDataSourceType.network, + (item.isVideo) + ? Platform.isAndroid + ? url.replaceAll("/manifest.m3u8", "/480p/index.m3u8") + : url + : item.playUrl!, + videoFormat: item.isVideo + ? BetterPlayerVideoFormat.hls + : BetterPlayerVideoFormat.other, + ); + setState(() { + _betterPlayerController = + BetterPlayerController(betterPlayerConfiguration); + }); + _betterPlayerController.setupDataSource(dataSource); + } + + void _videoPlayerListener() { + if (_betterPlayerController.videoPlayerController != null && + _betterPlayerController.videoPlayerController!.value.initialized) { + if (_betterPlayerController.videoPlayerController!.value.volume == 0.0 && + !videoSettingProvider.isMuted) { + videoSettingProvider.changeMuteStatus(true); + } else if (_betterPlayerController.videoPlayerController!.value.volume != + 0.0 && + videoSettingProvider.isMuted) { + videoSettingProvider.changeMuteStatus(false); + } + } + } + + void loadDataAndVideo() async { + if (widget.item != null) { + item = widget.item!; + } else { + var data = await GQLCommunicator() + .getVideoDetails(widget.author, widget.permlink); + if (mounted) { + setState(() { + item = data; + }); + } + } + if (widget.betterPlayerController != null) { + _betterPlayerController = widget.betterPlayerController!; + changeControlsVisibility(true); + if (mounted) { + setState(() { + isLoadingVideo = false; + }); + } + } else { + if (item.isVideo) { + var url = item.videoV2M3U8(appData); + try { + var data = await http.get(Uri.parse(url)); + if (data.body.contains('failed to resolve /ipfs')) { + debugPrint('Invalid url. let\'s update it ${url}'); + url = item.mobileEncodedVideoUrl(); + } else { + debugPrint('Valid URL. lets not update it. - ${data.body}'); + } + } catch (e) { + debugPrint('Invalid url. let\'s update it ${url}'); + url = item.mobileEncodedVideoUrl(); + } + setupVideo(url); + } else { + setupVideo(item.playUrl!); + } + if (mounted) { + setState(() { + isLoadingVideo = false; + }); + } + + if (videoSettingProvider.isMuted) { + _betterPlayerController.setVolume(0.0); + } + _betterPlayerController.videoPlayerController! + .addListener(_videoPlayerListener); + } + } + + void fullscreenTapped() async { + _betterPlayerController.pause(); + var position = + await _betterPlayerController.videoPlayerController?.position; + var seconds = position?.inSeconds; + if (seconds == null) return; + debugPrint('position is $position'); + const platform = MethodChannel('com.example.acela/auth'); + await platform.invokeMethod('playFullscreen', { + 'url': item.videoV2M3U8(appData), + 'seconds': seconds, + }); + } + + Widget _videoPlayerStack(double screenHeight, bool isGridView) { + return SliverToBoxAdapter( + child: Hero( + tag: '${item.author}/${item.permlink}', + child: SizedBox( + height: isGridView ? screenHeight * 0.4 : 230, + child: Stack( + children: [ + BetterPlayer( + controller: _betterPlayerController, + ), + _fullScreenButtonForIos(), + ], + ), + ), + ), + ); + } + + Padding _fullScreenButtonForIos() { + return Padding( + padding: const EdgeInsets.only(top: 10.0), + child: Column( + children: [ + SizedBox(height: 10), + Row( + children: [ + SizedBox(width: 10), + CircleAvatar( + backgroundColor: Colors.black.withOpacity(0.6), + child: IconButton( + onPressed: () { + Navigator.of(context).pop(); + }, + icon: Icon( + Icons.arrow_back_outlined, + color: Colors.white, + ), + ), + ), + SizedBox(width: 10), + Visibility( + visible: defaultTargetPlatform == TargetPlatform.iOS, + child: CircleAvatar( + backgroundColor: Colors.black.withOpacity(0.6), + child: IconButton( + onPressed: () { + fullscreenTapped(); + }, + icon: Icon( + Icons.fullscreen, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _userInfo() { + String timeInString = + item.createdAt != null ? "${timeago.format(item.createdAt!)}" : ""; + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 10.0, bottom: 5), + child: ListTile( + contentPadding: EdgeInsets.only(top: 0, left: 15, right: 15), + dense: true, + splashColor: Colors.transparent, + onTap: () { + context.pushNamed(Routes.userView, pathParameters: { + 'author': item.author?.username ?? "sagarkothari88" + }); + }, + leading: ClipOval( + child: CachedImage( + imageUrl: + 'https://images.hive.blog/u/${item.author?.username ?? 'sagarkothari88'}/avatar', + imageHeight: 40, + imageWidth: 40, + ), + ), + title: Text( + item.title ?? 'No title', + style: TextStyle( + color: Theme.of(context).primaryColorLight, + fontWeight: FontWeight.bold, + fontSize: 17), + ), + subtitle: Row( + children: [ + Expanded( + child: Text( + item.author?.username ?? "sagarkothari88", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: Theme.of(context).primaryColorLight), + ), + ), + const SizedBox( + width: 10, + ), + Text( + timeInString, + style: TextStyle( + color: Theme.of(context).primaryColorLight.withOpacity(0.7), + fontWeight: FontWeight.w500), + ), + ], + ), + ), + ), + ); + } + + void showError(String string) { + var snackBar = SnackBar(content: Text('Error: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + void showVoters() { + List voters = []; + bool currentUserPresentInVoters = false; + if (postInfo != null) { + if (appData.username != null) { + int userNameInVotesIndex = postInfo!.activeVotes + .indexWhere((element) => element.voter == appData.username); + if (userNameInVotesIndex != -1) { + currentUserPresentInVoters = true; + voters.add(appData.username!); + for (int i = 0; i < postInfo!.activeVotes.length; i++) { + if (i != userNameInVotesIndex) { + voters.add(postInfo!.activeVotes[i].voter); + } + } + } else { + postInfo!.activeVotes.forEach((element) { + voters.add(element.voter); + }); + } + } else { + postInfo!.activeVotes.forEach((element) { + voters.add(element.voter); + }); + } + } + postInfo!.activeVotes.forEach((element) { + print(element.voter); + }); + showModalBottomSheet( + context: context, + builder: (context) { + return Column( + children: [ + AppBar( + title: Text("Voters (${voters.length})"), + actions: [ + IconButton( + onPressed: () { + Navigator.pop(context); + upvotePressed(); + }, + icon: Icon( + Icons.thumb_up_sharp, + color: isUserVoted() ? Colors.blue : Colors.grey, + )) + ], + ), + Expanded( + child: ListView.builder( + padding: EdgeInsets.symmetric(vertical: 15, horizontal: 15), + itemCount: voters.length, + itemBuilder: (context, index) { + return ListTile( + minLeadingWidth: 0, + dense: true, + minVerticalPadding: 0, + leading: Container( + height: 30, + width: 30, + decoration: BoxDecoration( + color: Colors.grey, + shape: BoxShape.circle, + image: DecorationImage( + image: NetworkImage( + server.userOwnerThumb(voters[index]), + ), + ), + ), + ), + title: Text( + voters[index], + style: TextStyle( + color: index == 0 && currentUserPresentInVoters + ? Colors.blue + : null), + ), + ); + }, + ), + ), + ], + ); + }, + ); + } + + void upvotePressed() { + if (postInfo == null) return; + if (appData.username == null) { + showAdaptiveActionSheet( + context: context, + title: const Text('You are not logged in. Please log in to upvote.'), + androidBorderRadius: 30, + actions: [ + BottomSheetAction( + title: Text('Log in'), + leading: Icon(Icons.login), + onPressed: (c) { + Navigator.of(c).pop(); + var screen = HiveAuthLoginScreen(appData: appData); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(c).push(route); + }), + ], + cancelAction: CancelAction(title: const Text('Cancel')), + ); + return; + } + if (postInfo!.activeVotes + .map((e) => e.voter) + .contains(appData.username ?? 'sagarkothari88') == + true) { + showError('You have already voted for this video'); + } + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) { + return HiveUpvoteDialog( + author: item.author?.username ?? 'sagarkothari88', + permlink: item.permlink ?? 'ctbtwcxbbd', + username: appData.username ?? "", + accessToken: appData.accessToken, + postingAuthority: appData.postingAuthority, + hasKey: appData.keychainData?.hasId ?? "", + hasAuthKey: appData.keychainData?.hasAuthKey ?? "", + activeVotes: postInfo!.activeVotes, + onClose: () {}, + onDone: () { + loadHiveInfo(); + }, + ); + }, + ); + } + + void infoPressed(double screenWidth) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => NewVideoDetailsInfo( + appData: appData, + item: item, + ), + )); + _playVideoAfterPush(); + } + + void seeCommentsPressed() { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) { + return VideoDetailsComments( + author: item.author?.username ?? 'sagarkothari88', + permlink: item.permlink ?? 'ctbtwcxbbd', + rpc: appData.rpc, + appData: appData, + item: item, + ); + }, + ), + ); + _playVideoAfterPush(); + } + + void _playVideoAfterPush() { + Future.delayed(Duration(milliseconds: 800)) + .then((value) => _betterPlayerController.play()); + } + + Widget _actionBar(double width) { + final VideoFavoriteProvider provider = VideoFavoriteProvider(); + Color color = Theme.of(context).primaryColorLight; + String votes = "${item.stats?.numVotes ?? 0}"; + String comments = "${item.stats?.numComments ?? 0}"; + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + onPressed: () { + infoPressed(width); + }, + icon: Icon(Icons.info, color: color), + ), + Row( + children: [ + IconButton( + onPressed: () { + seeCommentsPressed(); + }, + icon: Icon(Icons.comment, color: color), + ), + Text(comments, + style: TextStyle( + color: Theme.of(context) + .primaryColorLight + .withOpacity(0.7), + fontSize: 13)) + ], + ), + Row( + children: [ + IconButton( + onPressed: () { + if (postInfo != null) { + showVoters(); + } + }, + icon: Icon( + isUserVoted() ? Icons.thumb_up : Icons.thumb_up_outlined, + color: color), + ), + Text(votes, + style: TextStyle( + color: Theme.of(context) + .primaryColorLight + .withOpacity(0.7), + fontSize: 13)) + ], + ), + IconButton( + onPressed: () { + Share.share( + 'https://3speak.tv/watch?v=${item.author?.username ?? 'sagarkothari88'}/${item.permlink}'); + }, + icon: Icon(Icons.share, color: color), + ), + FavouriteWidget( + toastType: "Video", + iconColor: color, + isLiked: provider.isLikedVideoPresentLocally(item), + onAdd: () { + provider.storeLikedVideoLocally(item); + }, + onRemove: () { + provider.storeLikedVideoLocally(item); + }) + ], + ), + ), + ); + } + + bool isUserVoted() { + if (appData.username != null) { + if (postInfo != null && postInfo!.activeVotes.isNotEmpty) { + int index = postInfo!.activeVotes + .indexWhere((element) => element.voter == appData.username); + if (index != -1) { + return true; + } + } + } + return false; + } + + Widget _chipList() { + List tags = item.tags ?? ['threespeak', 'video', 'threeshorts']; + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: 15.0, top: 5), + child: SizedBox( + height: 33, + child: ListView.builder( + padding: EdgeInsets.symmetric(horizontal: 8), + scrollDirection: Axis.horizontal, + itemCount: tags.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: InkWell( + borderRadius: BorderRadius.all( + Radius.circular(18), + ), + onTap: () { + var screen = TrendingTagVideos(tag: tags[index]); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + }, + child: Container( + alignment: Alignment.center, + padding: + EdgeInsets.symmetric(vertical: 5, horizontal: 15), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context) + .primaryColorLight + .withOpacity(0.3)), + borderRadius: BorderRadius.all( + Radius.circular(18), + ), + ), + child: Text( + tags[index], + style: TextStyle( + color: Theme.of(context).primaryColorLight, + fontSize: 14, + fontWeight: FontWeight.w500), + ), + ), + ), + ); + }), + ), + ), + ); + } + + void changeControlsVisibility(bool showControls) { + if (widget.betterPlayerController != null) { + widget.betterPlayerController!.setControlsAlwaysVisible(false); + widget.betterPlayerController!.setControlsEnabled(showControls); + widget.betterPlayerController!.setControlsVisibility(showControls); + } + } + + @override + Widget build(BuildContext context) { + var width = MediaQuery.of(context).size.width; + var height = MediaQuery.of(context).size.height; + final isGridView = MediaQuery.of(context).size.shortestSide > 600; + return PopScope( + onPopInvoked: (value) { + changeControlsVisibility(false); + if (widget.onPop != null) widget.onPop!(); + }, + child: Scaffold( + body: SafeArea( + child: CustomScrollView( + slivers: [ + !isLoadingVideo + ? _videoPlayerStack(height, isGridView) + : sliverSizedBox(), + !isLoadingVideo ? _userInfo() : sliverSizedBox(), + !isLoadingVideo ? _actionBar(width) : sliverSizedBox(), + !isLoadingVideo ? _chipList() : sliverSizedBox(), + SliverVisibility( + visible: isLoadingVideo, + sliver: SliverToBoxAdapter( + child: VideoDetailFeedLoader(isGridView: isGridView), + ), + ), + isSuggestionsLoading + ? VideoFeedLoader( + isSliver: true, + isGridView: isGridView, + ) + : isGridView + ? _sliverGridView() + : _sliverListView(), + ], + )), + ), + ); + } + + Widget sliverSizedBox() { + return const SliverToBoxAdapter( + child: SizedBox.shrink(), + ); + } + + Widget _sliverGridView() { + return FeedItemGridWidget(items: suggestions, appData: appData); + } + + SliverList _sliverListView() { + return SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + var item = suggestions[index]; + return NewFeedListItem( + thumbUrl: item.spkvideo?.thumbnailUrl ?? '', + author: item.author?.username ?? '', + title: item.title ?? '', + createdAt: item.createdAt ?? DateTime.now(), + duration: item.spkvideo?.duration ?? 0.0, + comments: item.stats?.numComments, + hiveRewards: item.stats?.totalHiveReward, + votes: item.stats?.numVotes, + views: 0, + permlink: item.permlink ?? '', + onTap: () {}, + onUserTap: () {}, + item: item, + appData: appData, + ); + }, + childCount: suggestions.length, + ), + ); + } +} diff --git a/lib/src/screens/video_details_screen/new_video_details/video_detail_favourite_provider.dart b/lib/src/screens/video_details_screen/new_video_details/video_detail_favourite_provider.dart new file mode 100644 index 00000000..d945dcd3 --- /dev/null +++ b/lib/src/screens/video_details_screen/new_video_details/video_detail_favourite_provider.dart @@ -0,0 +1,59 @@ +import 'package:acela/src/utils/graphql/models/trending_feed_response.dart'; +import 'package:get_storage/get_storage.dart'; + +class VideoFavoriteProvider { + final box = GetStorage(); + final String _likedVideoLocalKey = 'liked_video'; + final String _shortsVideoLocalKey = 'liked_shorts_video'; + + List getLikedVideos({bool isShorts = false}) { + final String key = !isShorts ? _likedVideoLocalKey : _shortsVideoLocalKey; + if (box.read(key) != null) { + List json = box.read(key); + List items = + json.map((e) => GQLFeedItem.fromJson(e)).toList(); + return items; + } else { + return []; + } + } + + //check if the liked podcast single episode is present locally + bool isLikedVideoPresentLocally(GQLFeedItem item, {bool isShorts = false}) { + final String key = !isShorts ? _likedVideoLocalKey : _shortsVideoLocalKey; + if (box.read(key) != null) { + List json = box.read(key); + int index = json.indexWhere((element) => + checkUniqueId('${item.author?.username}/${item.permlink}', element)); + return index != -1; + } else { + return false; + } + } + + //sotre the single podcast episode locally if user likes it + void storeLikedVideoLocally(GQLFeedItem item, {bool isShorts = false,bool forceRemove = false}) { + final String key = !isShorts ? _likedVideoLocalKey : _shortsVideoLocalKey; + final String identifier = '${item.author?.username}/${item.permlink}'; + if (box.read(key) != null) { + List json = box.read(key); + int index = + json.indexWhere((element) => checkUniqueId(identifier, element)); + if (index == -1 && !forceRemove) { + json.add(item.toJson()); + box.write(key, json); + } else { + json.removeWhere((element) => checkUniqueId(identifier, element)); + box.write(key, json); + } + } else { + box.write(key, [item.toJson()]); + } + print(box.read(key)); + } + + bool checkUniqueId(String id, dynamic value) { + print(id == "${value['author']['username']}/${value['permlink']}"); + return id == "${value['author']['username']}/${value['permlink']}"; + } +} diff --git a/lib/src/screens/video_details_screen/new_video_details_info.dart b/lib/src/screens/video_details_screen/new_video_details_info.dart new file mode 100644 index 00000000..2f15a8de --- /dev/null +++ b/lib/src/screens/video_details_screen/new_video_details_info.dart @@ -0,0 +1,68 @@ +import 'dart:convert'; + +import 'package:acela/src/models/login/login_bridge_response.dart'; +import 'package:acela/src/utils/graphql/models/trending_feed_response.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +class NewVideoDetailsInfo extends StatefulWidget { + const NewVideoDetailsInfo({ + Key? key, + required this.appData, + required this.item, + }) : super(key: key); + final GQLFeedItem item; + final HiveUserData appData; + + @override + State createState() => _NewVideoDetailsInfoState(); +} + +class _NewVideoDetailsInfoState extends State { + late final WebViewController controller; + + @override + void initState() { + controller = WebViewController(); + getHtmlAndLoad(); + super.initState(); + } + + void getHtmlAndLoad() async { + String markdownText = + widget.item.spkvideo?.body ?? widget.item.body ?? "No content"; + const platform = MethodChannel('com.example.acela/auth'); + var markdownTextData = base64.encode(utf8.encode(markdownText)); + final String markdownTextDataResponse = + await platform.invokeMethod('getHTMLStringForContent', { + 'string': markdownTextData, + }); + var bridgeResponseForMarkDown = + LoginBridgeResponse.fromJsonString(markdownTextDataResponse); + var resultedString = + utf8.decode(base64.decode(bridgeResponseForMarkDown.data ?? "")); + var color = true ? 'white' : 'black'; + var htmlString = + resultedString.replaceAll(" _VideoDetailsScreenState(); } class _VideoDetailsScreenState extends State { - final widgets = VideoDetailsScreenWidgets(); - VideoPlayerController? _controller; - VideoDetailsViewModel? vm; + late Future> recommendedVideos; + Future>? _loadComments; + + Future? _fetchHiveInfoForThisVideo; + + @override + void initState() { + super.initState(); + WakelockPlus.enable(); + recommendedVideos = widget.vm.getRecommendedVideos(); + } @override void dispose() { super.dispose(); - _controller?.dispose(); + WakelockPlus.disable(); } - void initPlayer(String url) { - _controller ??= VideoPlayerController.network(url) - ..initialize().then((_) { - setState(() { - _controller?.play(); - }); - }); + void onUserTap() { + _betterPlayerController.pause(); + context.pushNamed(Routes.userView, + pathParameters: {'author': widget.vm.author}); } - void initViewModel(BuildContext context) { - final args = ModalRoute.of(context)!.settings.arguments - as VideoDetailsScreenArguments; - vm ??= VideoDetailsViewModel( - stateUpdated: () { - setState(() {}); + // used when there is an error state in loading video info + Widget container(String title, Widget body) { + return Scaffold( + body: body, + appBar: AppBar( + title: Text(title), + ), + ); + } + + //region Video Info + // video description + Widget descriptionMarkDown(String markDown) { + return Markdown( + data: Utilities.removeAllHtmlTags(markDown), + onTapLink: (text, url, title) { + launchUrl(Uri.parse(url ?? 'https://google.com')); + }, + ); + } + + // fetch hive info + Future fetchHiveInfoForThisVideo( + String hiveApiUrl) async { + var request = http.Request('POST', Uri.parse('https://$hiveApiUrl')); + request.body = json.encode({ + "id": 1, + "jsonrpc": "2.0", + "method": "bridge.get_discussion", + "params": { + "author": widget.vm.author, + "permlink": widget.vm.permlink, + "observer": "" + } + }); + http.StreamedResponse response = await request.send(); + if (response.statusCode == 200) { + var string = await response.stream.bytesToString(); + var result = HivePostInfo.fromJsonString(string) + .result + .resultData + .where((element) => element.permlink == widget.vm.permlink) + .first; + return result; + } else { + print(response.reasonPhrase); + throw response.reasonPhrase ?? 'Can not load payout info'; + } + } + + void fullscreenTapped(VideoDetails details, HiveUserData appData) async { + _betterPlayerController.pause(); + var position = + await _betterPlayerController.videoPlayerController?.position; + var seconds = position?.inSeconds; + if (seconds == null) return; + debugPrint('position is $position'); + const platform = MethodChannel('com.example.acela/auth'); + await platform.invokeMethod('playFullscreen', { + 'url': details.getVideoUrl(appData), + 'seconds': seconds, + }); + } + + // video description + Widget titleAndSubtitle( + VideoDetails details, HiveUserData appData, double ratio) { + return FutureBuilder( + future: _fetchHiveInfoForThisVideo, + builder: (builder, snapshot) { + if (snapshot.hasError) { + return const Text('Error loading hive payout info'); + } else if (snapshot.hasData && + snapshot.connectionState == ConnectionState.done) { + String string = + "📆 ${timeago.format(DateTime.parse(details.created))} · ▶ ${details.views} views · 👥 ${details.community}"; + var data = snapshot.data as HivePostInfoPostResultBody; + var upVotes = data.activeVotes.where((e) => e.rshares > 0).length; + var downVotes = data.activeVotes.where((e) => e.rshares < 0).length; + String priceAndVotes = "👍 $upVotes · 👎 $downVotes"; + return Container( + margin: const EdgeInsets.all(10), + child: Column( + children: [ + Row( + children: [ + InkWell( + child: CustomCircleAvatar( + height: 40, + width: 40, + url: server.userOwnerThumb(details.owner)), + onTap: () { + context.pushNamed(Routes.userView, + pathParameters: {'author': details.owner}); + }, + ), + SizedBox(width: 10), + InkWell( + child: Text(details.owner, + style: Theme.of(context).textTheme.bodyLarge), + onTap: () { + context.pushNamed(Routes.userView, + pathParameters: {'author': details.owner}); + }), + SizedBox(width: 10), + Spacer(), + IconButton( + onPressed: () { + var screen = HiveCommentDialog( + author: widget.vm.author, + permlink: widget.vm.permlink, + username: appData.username ?? "", + hasKey: appData.keychainData?.hasId ?? "", + hasAuthKey: appData.keychainData?.hasAuthKey ?? "", + onClose: () {}, + onDone: (comment) { + setState(() { + _fetchHiveInfoForThisVideo = + fetchHiveInfoForThisVideo(appData.rpc); + }); + }, + ); + Navigator.of(context) + .push(MaterialPageRoute(builder: (c) => screen)); + }, + icon: Icon( + Icons.message_outlined, + color: Colors.blue, + ), + ), + IconButton( + icon: Icon( + Icons.share, + color: Colors.blue, + ), + onPressed: () { + Share.share( + 'https://3speak.tv/watch?v=${widget.vm.author}/${widget.vm.permlink}'); + }, + ), + if (data.activeVotes + .where( + (element) => element.voter == appData.username) + .length == + 0) + IconButton( + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + clipBehavior: Clip.hardEdge, + builder: (context) { + return SizedBox( + height: + MediaQuery.of(context).size.height * 0.4, + child: HiveUpvoteDialog( + author: widget.vm.author, + permlink: widget.vm.permlink, + username: appData.username ?? "", + accessToken: appData.accessToken, + postingAuthority: appData.postingAuthority, + hasKey: appData.keychainData?.hasId ?? "", + hasAuthKey: + appData.keychainData?.hasAuthKey ?? "", + activeVotes: data.activeVotes, + onClose: () {}, + onDone: () { + setState(() { + _fetchHiveInfoForThisVideo = + fetchHiveInfoForThisVideo( + appData.rpc); + }); + }, + ), + ); + }); + }, + icon: Icon( + Icons.thumbs_up_down, + color: Colors.blue, + ), + ) + else + Container(), + ], + ), + SizedBox(height: 10), + InkWell( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(details.title, + style: Theme.of(context).textTheme.bodyLarge), + const SizedBox(height: 3), + Text(string, + style: Theme.of(context).textTheme.bodySmall), + const SizedBox(height: 3), + Text(priceAndVotes, + style: Theme.of(context).textTheme.bodySmall), + ], + ), + ), + const Icon(Icons.arrow_drop_down_outlined), + ], + ), + onTap: () { + showModalForDescription(details, ratio); + }, + ), + ], + ), + ); + } else { + return const Text('Loading hive payout info'); + } + }, + ); + } + +// video description + void showModalForDescription(VideoDetails details, double ratio) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + clipBehavior: Clip.hardEdge, + builder: (context) { + return SizedBox( + height: + MediaQuery.of(context).size.height - (ratio < 1.0 ? 460 : 230), + child: Stack( + children: [ + Container( + margin: const EdgeInsets.only(top: 55), + child: descriptionMarkDown(details.description), + ), + Container( + height: 55, + child: AppBar( + title: Text(details.title), + actions: [ + IconButton( + onPressed: () {}, + icon: const Icon(Icons.fullscreen), + ), + details.playUrl.contains('ipfs') + ? IconButton( + onPressed: () { + var ipfsHash = details.playUrl + .replaceAll(IpfsNodeProvider().nodeUrl, "") + .replaceAll("/manifest.m3u8", ""); + Share.share( + "Copy this IPFS Hash & Pin it on your system - $ipfsHash"); + }, + icon: Image.asset( + 'assets/ipfs-logo.png', + width: 20, + height: 20, + ), + ) + : Container(), + ], + ), + ) + ], + ), + ); + }, + ); + } + +//endregion + +//region Video Comments +// video comments + Widget listTile(HiveComment comment) { + var item = comment; + var userThumb = server.userOwnerThumb(item.author); + var body = item.body; + double width = MediaQuery.of(context).size.width - 90 - 20; + return Container( + margin: const EdgeInsets.all(10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CustomCircleAvatar(height: 25, width: 25, url: userThumb), + Container(margin: const EdgeInsets.only(right: 10)), + SizedBox( + width: width, + child: MarkdownBody( + data: Utilities.removeAllHtmlTags(body) + .split('') + .take(100) + .join(''), + shrinkWrap: true, + onTapLink: (text, url, title) { + launchUrl(Uri.parse(url ?? 'https://google.com')); + }, + ), + ), + const Icon(Icons.arrow_right_outlined) + ], + ), + ); + } + +// video comments + Widget commentsSection(List comments, HiveUserData appData) { + var filtered = comments.where((element) => + (element.netRshares ?? 0) >= 0 && (element.authorReputation ?? 0) >= 0); + if (filtered.isEmpty) { + return Container( + margin: const EdgeInsets.all(10), + child: const Text('No comments added'), + ); + } + return InkWell( + child: Container( + margin: const EdgeInsets.only(left: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Comments :', style: Theme.of(context).textTheme.bodyLarge), + listTile(filtered.last) + ], + ), + ), + onTap: () { + // Navigator.of(context).push( + // MaterialPageRoute( + // builder: (context) { + // return VideoDetailsComments( + // author: widget.vm.author, + // permlink: widget.vm.permlink, + // rpc: appData.rpc, + // appData: appData, + // item:, + // ); + // }, + // ), + // ); + }, + ); + } + +// video comments + Widget videoComments(HiveUserData appData) { + return FutureBuilder( + future: _loadComments, + builder: (builder, snapshot) { + if (snapshot.hasError) { + String text = + 'Something went wrong while loading video comments - ${snapshot.error?.toString() ?? ""}'; + return Container(margin: const EdgeInsets.all(10), child: Text(text)); + } else if (snapshot.hasData && + snapshot.connectionState == ConnectionState.done) { + var data = snapshot.data! as List; + return commentsSection(data, appData); + } else { + return Container( + margin: const EdgeInsets.all(10), + child: Row( + children: const [ + SizedBox( + height: 15, + width: 15, + child: CircularProgressIndicator(), + ), + SizedBox(width: 10), + Text('Loading comments') + ], + ), + ); + } + }, + ); + } + +//endregion + + Widget videoWithDetailsWithoutRecommendation( + VideoDetails details, + HiveUserData appData, + double ratio, + ) { + return Container( + margin: EdgeInsets.only(top: ratio < 1.0 ? 460 : 230), + child: ListView.separated( + itemBuilder: (context, index) { + if (index == 0) { + return titleAndSubtitle(details, appData, ratio); + } else if (index == 1) { + return videoComments(appData); + } else { + return ListTile( + contentPadding: EdgeInsets.zero, + minVerticalPadding: 0, + title: const Text('Unknown'), + ); + } }, - item: args.item); - vm?.loadVideoInfo(); - vm?.loadComments(args.item.owner, args.item.permlink); + separatorBuilder: (context, index) => + const Divider(thickness: 0, height: 15, color: Colors.transparent), + itemCount: 2, + ), + ); + } + +// container list view + Widget videoWithDetails( + VideoDetails details, HiveUserData appData, double ratio) { + return FutureBuilder( + future: recommendedVideos, + builder: (builder, snapshot) { + if (snapshot.hasError) { + return videoWithDetailsWithoutRecommendation( + details, appData, ratio); + } else if (snapshot.hasData && + snapshot.connectionState == ConnectionState.done) { + var recommendations = + snapshot.data as List; + return Container( + margin: EdgeInsets.only(top: ratio < 1.0 ? 460 : 230), + child: ListView.separated( + itemBuilder: (context, index) { + if (index == 0) { + return titleAndSubtitle(details, appData, ratio); + } else if (index == 1) { + return videoComments(appData); + } else if (index == 2) { + return const ListTile( + title: Text('Recommended Videos'), + ); + } else { + var item = recommendations[index - 3]; + return NewFeedListItem( + appData: appData, + thumbUrl: server.resizedImage(item.image), + author: item.owner, + title: item.title, + createdAt: null, + duration: null, + views: null, + comments: null, + hiveRewards: null, + votes: null, + permlink: item.mediaid, + onTap: () { + _betterPlayerController.pause(); + }, + onUserTap: () { + _betterPlayerController.pause(); + }, + ); + } + }, + separatorBuilder: (context, index) => const Divider( + thickness: 0, height: 15, color: Colors.transparent), + itemCount: recommendations.length + 2, + ), + ); + } else { + return videoWithDetailsWithoutRecommendation( + details, appData, ratio); + } + }); + } + +// container list view - recommendations + + late BetterPlayerController _betterPlayerController; + + Widget videoRecommendationListItem(VideoRecommendationItem item) { + return ListTileVideo( + placeholder: 'assets/branding/three_speak_logo.png', + url: item.image, + userThumbUrl: server.userOwnerThumb(item.owner), + title: item.title, + subtitle: "", + onUserTap: () { + _betterPlayerController.pause(); + context + .pushNamed(Routes.userView, pathParameters: {'author': item.owner}); + }, + user: item.owner, + permlink: item.mediaid, + shouldResize: false, + isIpfs: false, + ); + } + + void setupVideo(String url, double ratio) { + BetterPlayerConfiguration betterPlayerConfiguration = + BetterPlayerConfiguration( + aspectRatio: ratio, + fit: BoxFit.contain, + autoPlay: true, + fullScreenByDefault: false, + controlsConfiguration: BetterPlayerControlsConfiguration( + enablePip: false, + enableFullscreen: false, + enableSkips: true, + ), + autoDetectFullscreenAspectRatio: false, + autoDetectFullscreenDeviceOrientation: false, + autoDispose: true, + expandToFill: true, + allowedScreenSleep: false, + ); + BetterPlayerDataSource dataSource = BetterPlayerDataSource( + BetterPlayerDataSourceType.network, + Platform.isAndroid + ? url.replaceAll("/manifest.m3u8", "/480p/index.m3u8") + : url, + videoFormat: BetterPlayerVideoFormat.hls, + ); + _betterPlayerController = BetterPlayerController(betterPlayerConfiguration); + _betterPlayerController.setupDataSource(dataSource); + } + + Widget _videoPlayerStack( + VideoDetails data, HiveUserData userData, double ratio) { + return SizedBox( + height: (ratio < 1.0 ? 460 : 230), + child: Stack( + children: [ + BetterPlayer( + controller: _betterPlayerController, + ), + Column( + children: [ + SizedBox(height: 10), + Row( + children: [ + SizedBox(width: 10), + CircleAvatar( + backgroundColor: Colors.black.withOpacity(0.6), + child: IconButton( + onPressed: () { + Navigator.of(context).pop(); + }, + icon: Icon( + Icons.arrow_back_outlined, + color: Colors.white, + ), + ), + ), + SizedBox(width: 15), + CircleAvatar( + backgroundColor: Colors.black.withOpacity(0.6), + child: IconButton( + onPressed: () { + fullscreenTapped(data, userData); + }, + icon: Icon( + Icons.fullscreen, + color: Colors.white, + ), + ), + ), + ], + ), + ], + ), + ], + ), + ); } + Widget _futureForLoadingRatio(HiveUserData userData, VideoDetails data) { + return FutureBuilder( + future: Communicator().getAspectRatio(data.playUrl), + builder: (builder, snapshot) { + if (snapshot.hasError) { + String text = + 'Something went wrong while loading video information - ${snapshot.error?.toString() ?? ""}'; + return container(widget.vm.author, Text(text)); + } else if (snapshot.hasData && + snapshot.connectionState == ConnectionState.done) { + var size = snapshot.data as VideoSize; + var ratio = size.width / size.height; + setupVideo(data.getVideoUrl(userData), ratio); + return Scaffold( + body: SafeArea( + child: Stack( + children: [ + videoWithDetails(data, userData, ratio), + _videoPlayerStack(data, userData, ratio), + ], + ), + ), + ); + } else { + return container( + widget.vm.author, + const LoadingScreen( + title: 'Loading Data', + subtitle: 'Please wait', + ), + ); + } + }); + } + +// main container @override Widget build(BuildContext context) { - initViewModel(context); - Widget videoView = widgets.getPlayer(context, _controller, initPlayer); - FloatingActionButton btn = widgets.getFab(_controller, () { + var userData = Provider.of(context); + if (_loadComments == null) { setState(() { - _controller?.value.isPlaying ?? false - ? _controller?.pause() - : _controller?.play(); + _loadComments = widget.vm.loadFirstSetOfComments( + widget.vm.author, + widget.vm.permlink, + userData.rpc, + ); }); - }); - return widgets.tabBar( - context, - btn, - videoView, - vm); + } + if (_fetchHiveInfoForThisVideo == null) { + setState(() { + _fetchHiveInfoForThisVideo = fetchHiveInfoForThisVideo(userData.rpc); + }); + } + return FutureBuilder( + future: widget.vm.getVideoDetails(), + builder: (builder, snapshot) { + if (snapshot.hasError) { + String text = + 'Something went wrong while loading video information - ${snapshot.error?.toString() ?? ""}'; + return container(widget.vm.author, Text(text)); + } else if (snapshot.hasData && + snapshot.connectionState == ConnectionState.done) { + var data = snapshot.data as VideoDetails?; + if (data != null) { + return _futureForLoadingRatio(userData, data); + } else { + return container( + widget.vm.author, + const Text( + "Something went wrong while loading video information"), + ); + } + } else { + return container( + widget.vm.author, + const LoadingScreen( + title: 'Loading Data', + subtitle: 'Please wait', + ), + ); + } + }, + ); } } diff --git a/lib/src/screens/video_details_screen/video_details_view_model.dart b/lib/src/screens/video_details_screen/video_details_view_model.dart index f833fbb9..e4987751 100644 --- a/lib/src/screens/video_details_screen/video_details_view_model.dart +++ b/lib/src/screens/video_details_screen/video_details_view_model.dart @@ -1,100 +1,88 @@ -import 'package:acela/src/models/hive_comments/request/hive_comments_request.dart'; +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/models/hive_comments/request/hive_comment_request.dart'; import 'package:acela/src/models/hive_comments/response/hive_comments.dart'; -import 'package:acela/src/models/home_screen_feed_models/home_feed_models.dart'; -import 'package:acela/src/models/video_details_model/video_details_description.dart'; -import 'package:acela/src/screens/home_screen/home_screen_view_model.dart'; +import 'package:acela/src/models/video_details_model/video_details.dart'; +import 'package:acela/src/models/video_recommendation_models/video_recommendation.dart'; import 'package:http/http.dart' show get; import 'package:http/http.dart' as http; -import 'package:acela/src/bloc/server.dart'; class VideoDetailsViewModel { - // loading info - LoadState descState = LoadState.notStarted; - String descError = 'Something went wrong'; - VideoDetailsDescription? description; - - // view - Function() stateUpdated; - HomeFeed item; - - // loading comments - LoadState commentsState = LoadState.notStarted; - String commentsError = 'Something went wrong'; - List comments = []; + String author; + String permlink; - VideoDetailsViewModel({required this.stateUpdated, required this.item}); + VideoDetailsViewModel({required this.author, required this.permlink}); - void loadVideoInfo() { - if (descState != LoadState.notStarted) return; - descState = LoadState.loading; - stateUpdated(); - final endPoint = "${server.domain}/apiv2/@${item.owner}/${item.permlink}"; - get(Uri.parse(endPoint)) - .then((response) { - VideoDetailsDescription desc = - videoDetailsDescriptionFromJson(response.body); - descState = LoadState.succeeded; - description = desc; - stateUpdated(); - }).catchError((error) { - descError = - 'Something went wrong.\nError is $error'; - descState = LoadState.failed; - stateUpdated(); - }); + Future getVideoDetails() async { + final endPoint = "${server.domain}/apiv2/@$author/$permlink"; + var response = await get(Uri.parse(endPoint)); + if (response.statusCode == 200) { + VideoDetails data = VideoDetails.fromJsonString(response.body); + return data; + } else { + throw "Status code = ${response.statusCode}"; + } } - void loadComments(String author, String permlink) { - if (commentsState != LoadState.notStarted) return; - commentsState = LoadState.loading; - var client = http.Client(); - var request = http.Request('POST', Uri.parse(server.hiveDomain)); - request.body = - hiveCommentsRequestToJson(HiveCommentsRequest.from(author, permlink)); - client - .send(request) - .then((response) => response.stream.bytesToString()) - .then((value) { - HiveComments hiveComments = hiveCommentsFromJson(value); - commentsState = LoadState.succeeded; - comments = hiveComments.result; - stateUpdated(); - scanComments(); - }).catchError((error) { - commentsError = 'Something went wrong.\nError is $error'; - commentsState = LoadState.failed; - stateUpdated(); - }); + Future> getRecommendedVideos() async { + final endPoint = "${server.domain}/apiv2/recommended?v=$author/$permlink"; + var response = await get(Uri.parse(endPoint)); + if (response.statusCode == 200) { + var data = videoRecommendationItemsFromJson(response.body); + return data; + } else { + throw "Status code = ${response.statusCode}"; + } } - void childrenComments(String author, String permlink, int index) { + Future> loadComments( + String author, + String permlink, + String hiveApiUrl, + ) async { var client = http.Client(); - var request = http.Request('POST', Uri.parse(server.hiveDomain)); - request.body = - hiveCommentsRequestToJson(HiveCommentsRequest.from(author, permlink)); - client - .send(request) - .then((response) => response.stream.bytesToString()) - .then((value) { - HiveComments hiveComments = hiveCommentsFromJson(value); - comments.insertAll(index + 1, hiveComments.result); - stateUpdated(); - scanComments(); - }).catchError((error) { - // commentsError = 'Something went wrong.\nError is $error'; - // commentsState = LoadState.failed; - // stateUpdated(); - }); - } - - void scanComments() { - for(var i=0; i < comments.length; i++) { - if (comments[i].children > 0) { - if (comments.where((e) => e.parentPermlink == comments[i].permlink).isEmpty) { - childrenComments(comments[i].author, comments[i].permlink, i); - break; + var body = + hiveCommentRequestToJson(HiveCommentRequest.from([author, permlink])); + var response = + await client.post(Uri.parse('https://$hiveApiUrl'), body: body); + if (response.statusCode == 200) { + var hiveCommentsResponse = hiveCommentsFromString(response.body); + var comments = hiveCommentsResponse.result; + for (var i = 0; i < comments.length; i++) { + if (comments[i].children > 0) { + if (comments + .where((e) => e.parentPermlink == comments[i].permlink) + .isEmpty) { + var newComments = await loadComments( + comments[i].author, + comments[i].permlink, + hiveApiUrl, + ); + comments.insertAll(i + 1, newComments); + } } } + return comments; + } else { + throw "Status code is ${response.statusCode}"; + } + } + + Future> loadFirstSetOfComments( + String author, + String permlink, + String hiveApiUrl, + ) async { + var client = http.Client(); + var body = + hiveCommentRequestToJson(HiveCommentRequest.from([author, permlink])); + var response = + await client.post(Uri.parse('https://$hiveApiUrl'), body: body); + if (response.statusCode == 200) { + var hiveCommentsResponse = hiveCommentsFromString(response.body); + var comments = hiveCommentsResponse.result; + return comments; + } else { + throw "Status code is ${response.statusCode}"; } } } diff --git a/lib/src/screens/video_details_screen/video_details_widgets.dart b/lib/src/screens/video_details_screen/video_details_widgets.dart deleted file mode 100644 index 0d2661ab..00000000 --- a/lib/src/screens/video_details_screen/video_details_widgets.dart +++ /dev/null @@ -1,165 +0,0 @@ -import 'package:acela/src/bloc/server.dart'; -import 'package:acela/src/screens/home_screen/home_screen_view_model.dart'; -import 'package:acela/src/screens/video_details_screen/video_details_view_model.dart'; -import 'package:acela/src/screens/video_details_screen/video_details_screen.dart'; -import 'package:acela/src/widgets/custom_circle_avatar.dart'; -import 'package:acela/src/widgets/loading_screen.dart'; -import 'package:acela/src/widgets/retry.dart'; -import 'package:flutter/material.dart'; -import 'package:timeago/timeago.dart' as timeago; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:video_player/video_player.dart'; - -class VideoDetailsScreenWidgets { - static const List tabs = [ - Tab(text: 'Video'), - Tab(text: 'Description'), - Tab(text: 'Comments') - ]; - - Widget tabBar( - BuildContext context, - FloatingActionButton fab, - Widget videoView, - VideoDetailsViewModel? vm, - ) { - final args = ModalRoute.of(context)!.settings.arguments - as VideoDetailsScreenArguments; - return DefaultTabController( - length: tabs.length, - child: Builder( - builder: (context) { - // final TabController tabController = DefaultTabController.of(context)!; - return Scaffold( - appBar: AppBar( - title: Text(args.item.title), - bottom: const TabBar(tabs: tabs), - ), - body: TabBarView( - children: [ - videoView, - getDescription(context, vm), - getComments(context, vm) - ], - ), - floatingActionButton: fab, - ); - }, - ), - ); - } - - Widget descriptionMarkDown(String markDown) { - return Container( - margin: const EdgeInsets.all(10), - child: Markdown( - data: markDown, - ), - ); - } - - Widget getDescription(BuildContext context, VideoDetailsViewModel? vm) { - return vm?.descState == LoadState.loading - ? const LoadingScreen() - : vm?.descState == LoadState.failed - ? RetryScreen( - error: vm?.descError ?? "Something went wrong", - onRetry: () { - vm?.descState = LoadState.notStarted; - vm?.loadVideoInfo(); - }) - : descriptionMarkDown(vm!.description!.description); - } - - Widget commentsListView(VideoDetailsViewModel? vm) { - return ListView.separated( - itemBuilder: (context, index) { - var item = vm!.comments[index]; - var userThumb = server.userOwnerThumb(item.author); - var author = item.author; - var body = item.body; - var upVotes = item.activeVotes.where((e) => e.percent > 0).length; - var downVotes = - item.activeVotes.where((e) => e.percent < 0).length; - var payout = item.pendingPayoutValue.replaceAll(" HBD", ""); - var timeInString = "📆 ${timeago.format(item.created)}"; - var text = - "👤 $author 👍 $upVotes 👎 $downVotes 💰 $payout $timeInString"; - var depth = (item.depth * 25.0) - 25; - double width = MediaQuery.of(context).size.width - 70 - depth; - - return ListTile( - title: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container(margin: EdgeInsets.only(left: depth)), - CustomCircleAvatar(height: 25, width: 25, url: userThumb), - Container(margin: const EdgeInsets.only(right: 10)), - SizedBox( - width: width, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MarkdownBody(data: body, shrinkWrap: true,), - Container(margin: const EdgeInsets.only(bottom: 10)), - Text( - text, - style: Theme.of(context).textTheme.bodyText1, - ), - ], - ), - ) - ], - ), - onTap: () { - print("Tapped"); - }, - ); - }, - separatorBuilder: (context, index) => const Divider( - height: 10, - color: Colors.blueGrey, - ), - itemCount: vm!.comments.length); - } - - Widget getComments(BuildContext context, VideoDetailsViewModel? vm) { - return vm?.commentsState == LoadState.loading - ? const LoadingScreen() - : vm?.commentsState == LoadState.failed - ? RetryScreen( - error: vm?.commentsError ?? "Something went wrong", - onRetry: () { - vm?.commentsState = LoadState.notStarted; - vm?.loadComments(vm.item.owner, vm.item.permlink); - }) - : commentsListView(vm); - } - - Widget getPlayer(BuildContext context, VideoPlayerController? _controller, - Function(String) initPlayer) { - final args = ModalRoute.of(context)!.settings.arguments - as VideoDetailsScreenArguments; - String url = args.item.ipfs == null - ? "https://threespeakvideo.b-cdn.net/${args.item.permlink}/default.m3u8" - : "https://ipfs-3speak.b-cdn.net/ipfs/${args.item.ipfs}/default.m3u8"; - initPlayer(url); - return Center( - child: _controller?.value.isInitialized ?? false - ? VideoPlayer(_controller!) - : Container(), - ); - } - - FloatingActionButton getFab( - VideoPlayerController? _controller, Function() onPressed) { - return FloatingActionButton( - onPressed: () { - onPressed(); - }, - child: Icon( - _controller?.value.isPlaying ?? false ? Icons.pause : Icons.play_arrow, - ), - ); - } -} diff --git a/lib/src/utils/communicator.dart b/lib/src/utils/communicator.dart new file mode 100644 index 00000000..86d96e69 --- /dev/null +++ b/lib/src/utils/communicator.dart @@ -0,0 +1,1014 @@ +import 'dart:convert'; +import 'dart:developer'; + +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/models/action_response.dart'; +import 'package:acela/src/models/communities_models/request/communities_request_model.dart'; +import 'package:acela/src/models/communities_models/response/communities_response_models.dart'; +import 'package:acela/src/models/hive_post_info/hive_post_info.dart'; +import 'package:acela/src/models/hive_post_info/hive_user_posting_key.dart'; +import 'package:acela/src/models/home_screen_feed_models/home_feed.dart'; +import 'package:acela/src/models/login/memo_response.dart'; +import 'package:acela/src/models/my_account/video_ops.dart'; +import 'package:acela/src/models/podcast/upload/podcast_episode_upload_response.dart'; +import 'package:acela/src/models/user_account/action_response.dart'; +import 'package:acela/src/models/user_account/user_model.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/models/video_details_model/video_details.dart'; +import 'package:acela/src/models/video_upload/does_post_exists.dart'; +import 'package:acela/src/models/video_upload/video_device_encode_upload_model.dart'; +import 'package:acela/src/models/video_upload/video_upload_complete_request.dart'; +import 'package:acela/src/models/video_upload/video_upload_login_response.dart'; +import 'package:acela/src/models/video_upload/video_upload_prepare_response.dart'; +import 'package:acela/src/screens/report/model/report/error_model.dart'; +import 'package:acela/src/screens/report/model/report/report_post.dart'; +import 'package:acela/src/screens/report/model/report_user_model.dart'; +import 'package:acela/src/utils/graphql/gql_communicator.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; +import 'package:http/http.dart'; + +class VideoSize { + double width; + double height; + + VideoSize({ + required this.width, + required this.height, + }); +} + +class Communicator { + // Production + static const tsServer = "https://studio.3speak.tv"; + static const fsServer = "https://uploads.3speak.tv/files"; + + // Android + // static const fsServer = "http://10.0.2.2:1080/files"; + // static const tsServer = "http://10.0.2.2:13050"; + + // iOS + // static const tsServer = "http://localhost:13050"; + // static const fsServer = "http://localhost:1080/files"; + + // iOS Devices - Local Server Testing + // static const tsServer = "http://192.168.29.53:13050"; + // static const fsServer = "http://192.168.29.53:1080/files"; + + // iOS Devices - Local server testing different router + // static const tsServer = "http://192.168.1.4:13050"; + // static const fsServer = "http://192.168.1.4:1080/files"; + + // static const hiveApiUrl = 'api.hive.blog'; + static const threeSpeakCDN = 'https://ipfs-3speak.b-cdn.net'; + static const hiveAuthServer = 'wss://hive-auth.arcange.eu'; + static const acelaServer = 'https://acela.us-02.infra.3speak.tv'; + + Future doesPostNotExist( + String user, + String post, + String hiveApiUrl, + ) async { + var response = await http.post( + Uri.parse('https://$hiveApiUrl'), + headers: { + 'Content-Type': 'application/json', + }, + body: jsonEncode({ + "id": 1, + "jsonrpc": "2.0", + "method": "bridge.get_discussion", + "params": { + "author": user, + "permlink": post, + "observer": user, + } + }), + ); + var resultString = response.body; + var data = DoesPostExistsResponse.fromJsonString(resultString); + var error = data.error?.data ?? ""; + return error.contains("does not exist"); + } + + Future getAspectRatio(String playUrl) async { + var request = http.Request('GET', Uri.parse(playUrl)); + http.StreamedResponse response = await request.send(); + if (response.statusCode == 200) { + var responseBody = await response.stream.bytesToString(); + var exp = RegExp(r"RESOLUTION=(.+),"); + var matches = exp.allMatches(responseBody); + if (matches.isEmpty) { + exp = RegExp(r"RESOLUTION=(.+)\n"); + matches = exp.allMatches(responseBody); + } + if (matches.isNotEmpty) { + var firstMatch = (matches.first.group(0) ?? '') + .replaceAll('RESOLUTION=', '') + .replaceAll(',', '') + .replaceAll('\n', ''); + var comps = firstMatch.split("x"); + if (comps.length == 2) { + var width = double.tryParse(comps[0]); + var height = double.tryParse(comps[1]); + if (width != null && height != null) { + return VideoSize(width: width, height: height); + } else { + return VideoSize(width: 320, height: 160); + } + } else { + return VideoSize(width: 320, height: 160); + } + } else { + return VideoSize(width: 320, height: 160); + } + } else { + log(response.reasonPhrase.toString()); + throw response.reasonPhrase.toString(); + } + } + + Future getPublicKey(String user, String hiveApiUrl) async { + var request = http.Request('POST', Uri.parse('https://$hiveApiUrl')); + request.body = json.encode({ + "id": 8, + "jsonrpc": "2.0", + "method": "database_api.find_accounts", + "params": { + "accounts": [user] + } + }); + http.StreamedResponse response = await request.send(); + if (response.statusCode == 200) { + var responseBody = await response.stream.bytesToString(); + var key = HiveUserPostingKey.fromString(responseBody); + return key.publicPostingKey; + } else { + log(response.reasonPhrase.toString()); + throw response.reasonPhrase.toString(); + } + } + + Future> getListOfCommunities( + String? query, + String hiveApiUrl, + ) async { + var client = http.Client(); + var body = + CommunitiesRequestModel(params: CommunitiesRequestParams(query: query)) + .toJsonString(); + var response = + await client.post(Uri.parse('https://$hiveApiUrl'), body: body); + if (response.statusCode == 200) { + var communitiesResponse = + communitiesResponseModelFromString(response.body); + return communitiesResponse.result; + } else { + throw "Status code is ${response.statusCode}"; + } + } + + Future fetchHiveInfo( + String user, + String permlink, + String hiveApiUrl, + ) async { + var request = http.Request('POST', Uri.parse('https://$hiveApiUrl')); + request.body = json.encode({ + "id": 1, + "jsonrpc": "2.0", + "method": "bridge.get_discussion", + "params": {"author": user, "permlink": permlink, "observer": ""} + }); + http.StreamedResponse response = await request.send(); + if (response.statusCode == 200) { + var string = await response.stream.bytesToString(); + var result = HivePostInfo.fromJsonString(string) + .result + .resultData + .where((element) => element.permlink == permlink) + .first; + var upVotes = result.activeVotes.where((e) => e.rshares > 0).length; + var downVotes = result.activeVotes.where((e) => e.rshares < 0).length; + return PayoutInfo( + payout: result.payout, + downVotes: downVotes, + upVotes: upVotes, + ); + } else { + var string = await response.stream.bytesToString(); + var error = ErrorResponse.fromJsonString(string).error ?? + response.reasonPhrase.toString(); + log('Error from server is $error'); + throw error; + } + } + + Future _getAccessToken( + HiveUserData user, String encryptedToken) async { + const platform = MethodChannel('com.example.acela/auth'); + var key = user.postingKey == null && user.keychainData != null + ? dotenv.env['MOBILE_CLIENT_PRIVATE_KEY'] + : user.postingKey ?? ""; + final String result = await platform.invokeMethod('encryptedToken', { + 'username': user.username, + 'postingKey': key, + 'encryptedToken': encryptedToken, + }); + var memo = MemoResponse.fromJsonString(result); + if (memo.error.isNotEmpty) { + throw memo.error; + } else if (memo.decrypted.isEmpty) { + throw 'Decrypted memo is empty'; + } + return memo.decrypted.replaceFirst("#", ''); + } + + Future getValidCookie(HiveUserData user) async { + var uri = '${Communicator.tsServer}/mobile/login?username=${user.username}'; + if (user.keychainData != null && user.postingKey == null) { + uri = + '${Communicator.tsServer}/mobile/login?username=${user.username}&client=mobile'; + } + var map; + if (user.cookie != null) { + map = {"cookie": user.cookie!}; + } + try { + http.Response response = await get(Uri.parse(uri), headers: map); + if (response.statusCode == 200) { + var string = response.body; + var loginResponse = VideoUploadLoginResponse.fromJsonString(string); + if (loginResponse.error != null && loginResponse.error!.isNotEmpty) { + throw 'Error - ${loginResponse.error}'; + } else if (loginResponse.memo != null && + loginResponse.memo!.isNotEmpty) { + var token = await _getAccessToken(user, loginResponse.memo!); + var url = + '${Communicator.tsServer}/mobile/login?username=${user.username}&access_token=$token'; + var request = http.Request('GET', Uri.parse(url)); + http.StreamedResponse response = await request.send(); + var string = await response.stream.bytesToString(); + var tokenResponse = VideoUploadLoginResponse.fromJsonString(string); + var cookie = response.headers['set-cookie']; + if (tokenResponse.error != null && tokenResponse.error!.isNotEmpty) { + throw 'Error - ${tokenResponse.error}'; + } else if (tokenResponse.network == "hive" && + tokenResponse.banned != true && + tokenResponse.userId != null && + cookie != null && + cookie.isNotEmpty) { + const storage = FlutterSecureStorage(); + await storage.write(key: 'cookie', value: cookie); + String resolution = await storage.read(key: 'resolution') ?? '480p'; + String rpc = await storage.read(key: 'rpc') ?? 'api.hive.blog'; + String union = await storage.read(key: 'union') ?? + GQLCommunicator.defaultGQLServer; + var newData = HiveUserData( + username: user.username, + postingKey: user.postingKey, + keychainData: user.keychainData, + union: union, + cookie: cookie, + postingAuthority: null, + accessToken: token, + resolution: resolution, + rpc: rpc, + loaded: true, + language: user.language, + ); + server.updateHiveUserData(newData); + return cookie; + } else { + log('This should never happen. No error, no user info. How?'); + throw 'Something went wrong.'; + } + } else if (loginResponse.network == "hive" && + loginResponse.banned != true && + loginResponse.userId != null && + user.cookie != null) { + return user.cookie!; + } else { + log('This should never happen. No error, no memo, no user info. How?'); + throw 'Something went wrong.'; + } + } else if (response.statusCode == 500) { + var string = response.body; + var errorResponse = VideoUploadLoginResponse.fromJsonString(string); + if (errorResponse.error != null && + errorResponse.error!.isNotEmpty && + errorResponse.error == 'session expired') { + const storage = FlutterSecureStorage(); + await storage.delete(key: 'cookie'); + String resolution = await storage.read(key: 'resolution') ?? '480p'; + String rpc = await storage.read(key: 'rpc') ?? 'api.hive.blog'; + String union = await storage.read(key: 'union') ?? + GQLCommunicator.defaultGQLServer; + var newData = HiveUserData( + postingAuthority: null, + accessToken: null, + username: user.username, + postingKey: user.postingKey, + keychainData: user.keychainData, + cookie: null, + resolution: resolution, + rpc: rpc, + union: union, + loaded: true, + language: user.language, + ); + server.updateHiveUserData(newData); + return await getValidCookie(newData); + } else { + throw errorResponse.error.toString(); + } + } else { + throw 'Status code ${response.statusCode}'; + } + } catch (e) { + throw e; + } + } + + Future uploadInfo({ + required HiveUserData user, + required String thumbnail, + required String oFilename, + required int duration, + required double size, + required String tusFileName, + }) async { + var cookie = await getValidCookie(user); + var request = http.Request( + 'POST', Uri.parse('${Communicator.tsServer}/mobile/api/upload_info')); + request.body = NewVideoUploadCompleteRequest( + size: size, + thumbnail: thumbnail, + oFilename: oFilename, + duration: duration, + filename: tusFileName, + owner: user.username ?? '', + ).toJsonString(); + Map map = { + "cookie": cookie, + "Content-Type": "application/json" + }; + request.headers.addAll(map); + try { + http.StreamedResponse response = await request.send(); + if (response.statusCode == 200) { + log("Successfully sent upload complete"); + var string = await response.stream.bytesToString(); + log('Video complete response is\n$string'); + return VideoUploadInfo.fromJsonString(string); + } else { + var string = await response.stream.bytesToString(); + var error = ErrorResponse.fromJsonString(string).error ?? + response.reasonPhrase.toString(); + log('Error from server is $error'); + throw error; + } + } catch (e) { + log('Error from server is ${e.toString()}'); + rethrow; + } + } + + Future updateInfo( + {required HiveUserData user, + required String videoId, + required String title, + required String description, + required bool isNsfwContent, + required String tags, + required String? thumbnail, + required String communityID, + required List beneficiaries}) async { + var request = http.Request( + 'POST', Uri.parse('${Communicator.tsServer}/mobile/api/update_info')); + var bene = beneficiaries + .map((e) => e.copyWith(account: e.account.toLowerCase())) + .toList() + ..sort( + (a, b) => a.account.toLowerCase().compareTo(b.account.toLowerCase())); + var cookie = await getValidCookie(user); + request.body = VideoUploadCompleteRequest( + beneficiaries: json.encode(bene.map((e) => e.toJson()).toList()), + videoId: videoId, + title: title, + description: description, + isNsfwContent: isNsfwContent, + tags: tags, + thumbnail: thumbnail, + communityID: communityID, + ).toJsonString(); + Map map = { + "cookie": cookie, + "Content-Type": "application/json" + }; + request.headers.addAll(map); + try { + http.StreamedResponse response = await request.send(); + if (response.statusCode == 200) { + log("Successfully sent upload complete"); + var string = await response.stream.bytesToString(); + log('Video complete response is\n$string'); + return VideoDetails.fromJsonString(string); + } else { + var string = await response.stream.bytesToString(); + var error = ErrorResponse.fromJsonString(string).error ?? + response.reasonPhrase.toString(); + log('Error from server is $error'); + throw error; + } + } catch (e) { + log('Error from server is ${e.toString()}'); + rethrow; + } + } + + Future saveDeviceEncodedVideo({ + required HiveUserData user, + required VideoDeviceEncodeUploadModel data, + }) async { + final uri = Uri.parse('${Communicator.tsServer}/mobile/api/upload_zip'); + var cookie = await getValidCookie(user); + + Map headers = { + "cookie": cookie, + "Content-Type": "application/json", + }; + + try { + var response = await http.post( + uri, + headers: headers, + body: data.toJsonString(), + ); + + if (response.statusCode == 200) { + log("Successfully sent upload complete"); + log('Video complete response is\n${response.body}'); + return response.body; + } else { + var error = ErrorResponse.fromJsonString(response.body).error ?? + response.reasonPhrase.toString(); + log('Error from server is $error'); + throw error; + } + } catch (e) { + log('Error from server is ${e.toString()}'); + rethrow; + } + } + + Future updateThumb({ + required HiveUserData user, + required String videoId, + required String thumbnail, + }) async { + var request = http.Request( + 'POST', + Uri.parse('${Communicator.tsServer}/mobile/api/update_thumbnail'), + ); + request.body = VideoThumbUpdateRequest( + videoId: videoId, + thumbnail: thumbnail, + ).toJsonString(); + Map map = { + "cookie": user.cookie ?? "", + "Content-Type": "application/json" + }; + request.headers.addAll(map); + try { + http.StreamedResponse response = await request.send(); + if (response.statusCode == 200) { + log("Successfully sent upload complete"); + var string = await response.stream.bytesToString(); + log('Video complete response is\n$string'); + return VideoDetails.fromJsonString(string); + } else { + var string = await response.stream.bytesToString(); + var error = ErrorResponse.fromJsonString(string).error ?? + response.reasonPhrase.toString(); + log('Error from server is $error'); + throw error; + } + } catch (e) { + log('Error from server is ${e.toString()}'); + rethrow; + } + } + + Future> loadAnyFeed(Uri uri) async { + var request = http.Request('GET', uri); + http.StreamedResponse response = await request.send(); + if (response.statusCode == 200) { + var string = await response.stream.bytesToString(); + var videos = videoItemsFromString(string); + return videos; + } else { + var string = await response.stream.bytesToString(); + var error = ErrorResponse.fromJsonString(string).error ?? + response.reasonPhrase.toString(); + log('Error from server is $error'); + throw error; + } + } + + Future> loadNewHomeFeed(bool shorts, + [int skip = 0]) async { + return await loadAnyFeed(Uri.parse( + '${Communicator.tsServer}/mobile/api/feed/home?shorts=${shorts ? 'true' : 'false'}&skip=$skip')); + } + + Future> loadNewTrendingFeed(bool shorts, + [int skip = 0]) async { + return await loadAnyFeed(Uri.parse( + '${Communicator.tsServer}/mobile/api/feed/trending?shorts=${shorts ? 'true' : 'false'}&skip=$skip')); + } + + Future> loadNewNewFeed(bool shorts, [int skip = 0]) async { + return await loadAnyFeed(Uri.parse( + '${Communicator.tsServer}/mobile/api/feed/new?shorts=${shorts ? 'true' : 'false'}&skip=$skip')); + } + + Future> loadNewFirstUploadsFeed(bool shorts, + [int skip = 0]) async { + return await loadAnyFeed(Uri.parse( + '${Communicator.tsServer}/mobile/api/feed/first?shorts=${shorts ? 'true' : 'false'}&skip=$skip')); + } + + Future> loadNewUserFeed(String user, bool shorts, + [int skip = 0]) async { + return await loadAnyFeed(Uri.parse( + '${Communicator.tsServer}/mobile/api/feed/user/@$user/?shorts=${shorts ? 'true' : 'false'}&skip=$skip')); + } + + Future> loadNewCommunityFeed(String community, bool shorts, + [int skip = 0]) async { + return await loadAnyFeed(Uri.parse( + '${Communicator.tsServer}/mobile/api/feed/community/@$community/?shorts=${shorts ? 'true' : 'false'}&skip=$skip')); + } + + Future> loadMyFeedVideos(HiveUserData user, + [bool shorts = false]) async { + log("Starting my feed videos ${DateTime.now().toIso8601String()}"); + var text = + '${Communicator.tsServer}/mobile/api/feed/@${user.username ?? 'sagarkothari88'}'; + if (shorts) { + text = '$text?shorts=true'; + } + var request = http.Request('GET', Uri.parse(text)); + http.StreamedResponse response = await request.send(); + if (response.statusCode == 200) { + var string = await response.stream.bytesToString(); + log('My Feed videos response\n\n$string\n\n'); + var videos = videoItemsFromString(string); + log("Ended fetch videos ${DateTime.now().toIso8601String()}"); + return videos; + } else { + var string = await response.stream.bytesToString(); + var error = ErrorResponse.fromJsonString(string).error ?? + response.reasonPhrase.toString(); + log('Error from server is $error'); + throw error; + } + } + + Future> loadVideos(HiveUserData user) async { + log("Starting fetch videos ${DateTime.now().toIso8601String()}"); + var cookie = await getValidCookie(user); + var request = http.Request( + 'GET', Uri.parse('${Communicator.tsServer}/mobile/api/my-videos')); + Map map = {"cookie": cookie}; + request.headers.addAll(map); + http.StreamedResponse response = await request.send(); + if (response.statusCode == 200) { + var string = await response.stream.bytesToString(); + log('My videos response\n\n$string\n\n'); + var videos = videoItemsFromString(string); + log("Ended fetch videos ${DateTime.now().toIso8601String()}"); + return videos; + } else { + var string = await response.stream.bytesToString(); + var error = ErrorResponse.fromJsonString(string).error ?? + response.reasonPhrase.toString(); + log('Error from server is $error'); + throw error; + } + } + + Future updatePublishState(HiveUserData user, String videoId) async { + var cookie = await getValidCookie(user); + var request = http.Request('POST', + Uri.parse('${Communicator.tsServer}/mobile/api/my-videos/iPublished')); + request.body = "{\"videoId\": \"$videoId\"}"; + Map map = { + "cookie": cookie, + "Content-Type": "application/json" + }; + request.headers.addAll(map); + http.StreamedResponse response = await request.send(); + if (response.statusCode == 200) { + var string = await response.stream.bytesToString(); + var result = VideoOpsResponse.fromJsonString(string); + if (result.success) { + return; + } else { + throw 'Error updating video status'; + } + } else { + var string = await response.stream.bytesToString(); + var error = ErrorResponse.fromJsonString(string).error ?? + response.reasonPhrase.toString(); + log('Error from server is $error'); + throw error; + } + } + + Future updatePublishStateForPodcastEpisode( + HiveUserData user, String episodeId) async { + var cookie = await getValidCookie(user); + var request = http.Request('POST', + Uri.parse('${Communicator.tsServer}/mobile/api/podcast/iPublished')); + request.body = "{\"episodeId\": \"$episodeId\"}"; + Map map = { + "cookie": cookie, + "Content-Type": "application/json" + }; + request.headers.addAll(map); + http.StreamedResponse response = await request.send(); + if (response.statusCode == 200) { + var string = await response.stream.bytesToString(); + var result = VideoOpsResponse.fromJsonString(string); + if (result.success) { + return; + } else { + throw 'Error updating podcast status'; + } + } else { + var string = await response.stream.bytesToString(); + var error = ErrorResponse.fromJsonString(string).error ?? + response.reasonPhrase.toString(); + log('Error from server is $error'); + throw error; + } + } + + Future deleteVideo(String permlink, HiveUserData user) async { + var cookie = await getValidCookie(user); + Map headers = { + "Cookie": cookie, + "Content-Type": "application/json" + }; + http.Response response = await get( + Uri.parse('https://studio.3speak.tv/mobile/api/video/$permlink/delete'), + headers: headers); + + try { + if (response.statusCode == 200) { + Map map = json.decode(response.body); + if (map['success'] && map['message'] == 'Video deleted successfully.') { + print(response.body); + return true; + } else { + return false; + } + } else { + print(response.reasonPhrase); + return false; + } + } catch (e) { + return false; + } + } + + Future deleteAccount(HiveUserData user) async { + try { + var cookie = await getValidCookie(user); + Map map = {"cookie": cookie}; + http.Response response = await get( + Uri.parse('${Communicator.tsServer}/mobile/api/account/delete'), + headers: map); + if (response.statusCode == 200) { + var map = json.decode(response.body); + return map['success'] && map['message'] == '3Speak Account Deleted.'; + } else { + var string = response.body; + log(string); + var error = ErrorResponse.fromJsonString(string).error ?? + response.reasonPhrase.toString(); + log('Error from server is $error'); + return false; + } + } catch (e) { + return false; + } + } + + Future uploadPodcast({ + required HiveUserData user, + required String oFilename, + required int duration, + required int size, + required String title, + required String description, + required bool isNsfwContent, + required String tags, + required String thumbnail, + required String communityID, + required bool declineRewards, + required String episode, // upload path where podcast episode was uploaded + }) async { + var request = http.Request( + 'POST', Uri.parse('${Communicator.tsServer}/mobile/api/podcast/add')); + request.body = json.encode({ + 'oFilename': oFilename, + 'duration': duration, + 'size': size, + 'isNsfwContent': isNsfwContent, + 'title': title, + 'description': description, + 'communityID': communityID, + 'thumbnail': thumbnail, + 'episode': episode, + }); + var cookie = await getValidCookie(user); + Map map = { + "cookie": cookie, + "Content-Type": "application/json", + "authorization": "Bearer $cookie" + }; + request.headers.addAll(map); + try { + http.StreamedResponse response = await request.send(); + if (response.statusCode == 200) { + var string = await response.stream.bytesToString(); + return PodcastEpisodeUploadResponse + .podcastEpisodeUploadResponseFromJson(string); + } else { + var string = await response.stream.bytesToString(); + var error = ErrorResponse.fromJsonString(string).error ?? + response.reasonPhrase.toString(); + log('Error from server is $error'); + throw error; + } + } catch (e) { + log('Error from server is ${e.toString()}'); + rethrow; + } + } + + Future login( + String userName, + String proofOfPayload, + String proof, + ) async { + var headers = { + 'Accept': 'application/json, text/plain', + 'Content-Type': 'application/json' + }; + try { + var body = json.encode({ + "username": userName, + "network": "hive", + "authority_type": "posting", + "proof_payload": proofOfPayload, + "proof": proof + }); + http.Response response = await post( + Uri.parse('${Communicator.acelaServer}/api/v1/auth/login_singleton'), + headers: headers, + body: body); + + if (response.statusCode >= 200 && response.statusCode < 300) { + return ActionResponse( + data: json.decode(response.body)['access_token'], + valid: true, + error: ''); + } else if (response.statusCode == 400) { + return ActionResponse( + data: '', + valid: false, + error: json.decode(response.body)['reason']); + } else { + return ActionResponse(data: '', valid: false, error: 'Server Error'); + } + } catch (e) { + return ActionResponse(data: '', valid: false, error: e.toString()); + } + } + + Future vote( + String accessToken, String userName, String permlink) async { + var headers = { + 'Accept': 'application/json, text/plain, */*', + 'Content-Type': 'application/json', + "authorization": "Bearer $accessToken" + }; + try { + var body = json.encode({ + "author": userName, + "permlink": permlink, + }); + http.Response response = await post( + Uri.parse('${Communicator.acelaServer}/api/v1/hive/vote'), + headers: headers, + body: body); + + if (response.statusCode >= 200 && response.statusCode < 300) { + return ActionResponse( + data: json.decode(response.body)['id'], valid: true, error: ''); + } else { + log(json.decode(response.body).toString()); + return ActionResponse(data: '', valid: false, error: 'Server Error'); + } + } catch (e) { + return ActionResponse(data: '', valid: false, error: e.toString()); + } + } + + Future postComment( + {required String parentAuthor, + required String parentPermlink, + required String author, + required String authorization, + required String commentBody}) async { + var headers = { + 'Accept': 'application/json, text/plain, */*', + 'Content-Type': 'application/json', + "authorization": "Bearer $authorization" + }; + try { + var body = json.encode({ + "body": commentBody, + "parent_author": parentAuthor, + "parent_permlink": parentPermlink, + "author": author + }); + http.Response response = await post( + Uri.parse('${Communicator.acelaServer}/api/v1/hive/post_comment'), + headers: headers, + body: body); + + if (response.statusCode >= 200 && response.statusCode < 300) { + return ActionResponse( + data: json.decode(response.body)['id'], valid: true, error: ''); + } else { + return ActionResponse(data: '', valid: false, error: 'Server Error'); + } + } catch (e) { + return ActionResponse(data: '', valid: false, error: e.toString()); + } + } + + Future> getAccountInfo( + String accountName) async { + try { + final String jsonString = await MethodChannel('com.example.acela/auth') + .invokeMethod('getAccountInfo', { + 'username': accountName, + }); + + ActionSingleDataResponse response = + ActionSingleDataResponse.fromJsonString( + jsonString, UserModel.fromJson); + return response; + } catch (e) { + return ActionSingleDataResponse( + status: ResponseStatus.failed, errorMessage: e.toString()); + } + } + + Future> reportPost( + ReportPostModel report, HiveUserData user) async { + try { + var headers = { + 'Content-Type': 'application/json', + "authorization": user.accessToken!, + }; + + Response response = await http.post( + Uri.parse('$tsServer/mobile/report-post'), + headers: headers, + body: report.toRawJson(), + ); + + if (response.statusCode == 200) { + return ActionSingleDataResponse( + errorMessage: "", + status: ResponseStatus.success, + isSuccess: true, + data: true); + } else { + return ActionSingleDataResponse( + errorMessage: ErrorModel.fromJsonString(response.body).error, + status: ResponseStatus.failed); + } + } catch (e) { + return ActionSingleDataResponse( + errorMessage: "Something went wrong", + status: ResponseStatus.failed, + ); + } + } + Future> reportUser( + ReportUserModel report, HiveUserData user) async { + try { + var headers = { + 'Content-Type': 'application/json', + "authorization": user.accessToken!, + }; + + Response response = await http.post( + Uri.parse('$tsServer/mobile/report-user'), + headers: headers, + body: report.toRawJson(), + ); + + if (response.statusCode == 200) { + return ActionSingleDataResponse( + errorMessage: "", + status: ResponseStatus.success, + isSuccess: true, + data: true); + } else { + return ActionSingleDataResponse( + errorMessage: ErrorModel.fromJsonString(response.body).error, + status: ResponseStatus.failed); + } + } catch (e) { + return ActionSingleDataResponse( + errorMessage: "Something went wrong", + status: ResponseStatus.failed, + ); + } + } + + Future> readReportedPosts( + HiveUserData user) async { + try { + var headers = { + 'Content-Type': 'application/json', + "authorization": user.accessToken!, + }; + Response response = await http.get( + Uri.parse('$tsServer/mobile/reported-posts'), + headers: headers, + ); + + if (response.statusCode == 200) { + return ActionListDataResponse( + errorMessage: "", + status: ResponseStatus.success, + isSuccess: true, + data: ReportPostModel.fromRawListJson(response.body)); + } else { + return ActionListDataResponse( + errorMessage: "Something went wrong", + status: ResponseStatus.failed, + ); + } + } catch (e) { + return ActionListDataResponse( + errorMessage: e.toString(), + status: ResponseStatus.failed, + ); + } + } + + Future> readReportedUsers( + HiveUserData user) async { + try { + var headers = { + 'Content-Type': 'application/json', + "authorization": user.accessToken!, + }; + Response response = await http.get( + Uri.parse('$tsServer/mobile/reported-users'), + headers: headers, + ); + + if (response.statusCode == 200) { + return ActionListDataResponse( + errorMessage: "", + status: ResponseStatus.success, + isSuccess: true, + data: ReportUserModel.fromRawListJson(response.body)); + } else { + return ActionListDataResponse( + errorMessage: "Something went wrong", + status: ResponseStatus.failed, + ); + } + } catch (e) { + return ActionListDataResponse( + errorMessage: e.toString(), + status: ResponseStatus.failed, + ); + } + } +} diff --git a/lib/src/utils/constants.dart b/lib/src/utils/constants.dart new file mode 100644 index 00000000..7c598f2e --- /dev/null +++ b/lib/src/utils/constants.dart @@ -0,0 +1,10 @@ +import 'package:flutter/cupertino.dart'; + +const kScreenHorizontalPaddingDigit = 15.0; +const kScreenVerticalPaddingDigit = 15.0; +const kScreenHorizontalPadding = + EdgeInsets.symmetric(horizontal: kScreenHorizontalPaddingDigit); +const kScreenVerticalPadding = + EdgeInsets.symmetric(vertical: kScreenVerticalPaddingDigit); +const kScreenPadding = EdgeInsets.symmetric( + vertical: 15, horizontal: kScreenHorizontalPaddingDigit); diff --git a/lib/src/utils/enum.dart b/lib/src/utils/enum.dart new file mode 100644 index 00000000..03db1242 --- /dev/null +++ b/lib/src/utils/enum.dart @@ -0,0 +1,7 @@ +enum ViewState { loading, data, empty, error } + +enum UploadStatus { idle, started, ended } + +enum Sort { newest, oldest } + +enum Report { user, post } diff --git a/lib/src/utils/graphql/gql_communicator.dart b/lib/src/utils/graphql/gql_communicator.dart new file mode 100644 index 00000000..ab81edc3 --- /dev/null +++ b/lib/src/utils/graphql/gql_communicator.dart @@ -0,0 +1,278 @@ +import 'dart:convert'; +import 'dart:developer'; + +import 'package:acela/src/models/hive_comments/new_hive_comment/new_hive_comment.dart'; +import 'package:acela/src/models/hive_comments/new_hive_comment/newest_comment_model.dart'; +import 'package:acela/src/models/trending_tags/trending_tags_response.dart'; +import 'package:acela/src/utils/graphql/models/trending_feed_response.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; +import 'package:http/http.dart'; + +class GQLCommunicator { + static const defaultGQLServer = "union.us-02.infra.3speak.tv"; + // static const gqlServer = "https://union.us-02.infra.3speak.tv/api/v2/graphql"; + static const dataQuery = + "{\n items {\n created_at\n title\n ... on HivePost {\n permlink\n lang\n title\n tags\n spkvideo\n stats {\n num_comments\n num_votes\n total_hive_reward\n }\n author {\n username\n }\n json_metadata {\n raw\n }\n }\n }\n }\n}"; + + Future> getGQLFeed(String operation, String query) async { + const storage = FlutterSecureStorage(); + String union = + await storage.read(key: 'union') ?? GQLCommunicator.defaultGQLServer; + String gqlServer = "https://$union/api/v2/graphql"; + var headers = { + 'Connection': 'keep-alive', + 'content-type': 'application/json', + }; + var request = http.Request('POST', Uri.parse(gqlServer)); + request.body = json + .encode({"query": query, "operationName": operation, "extensions": {}}); + request.headers.addAll(headers); + + http.StreamedResponse response = await request.send(); + + if (response.statusCode == 200) { + var string = await response.stream.bytesToString(); + var responseData = GraphQlFeedResponse.fromRawJson(string); + List items = []; + if ((responseData.data?.trendingFeed?.items ?? []).isNotEmpty) { + items = responseData.data?.trendingFeed?.items ?? []; + } else if ((responseData.data?.socialFeed?.items ?? []).isNotEmpty) { + items = responseData.data?.socialFeed?.items ?? []; + } else if ((responseData.data?.relatedFeed?.items ?? []).isNotEmpty) { + items = responseData.data?.relatedFeed?.items ?? []; + } else if ((responseData.data?.searchFeed?.items ?? []).isNotEmpty) { + items = responseData.data?.searchFeed?.items ?? []; + } + return items.where((element) => element.spkvideo != null).toList(); + } else { + print(response.reasonPhrase); + throw response.reasonPhrase ?? 'Error occurred'; + } + } + + Future getTrendingTags() async { + var headers = { + 'Connection': 'keep-alive', + 'content-type': 'application/json', + }; + const storage = FlutterSecureStorage(); + String union = + await storage.read(key: 'union') ?? GQLCommunicator.defaultGQLServer; + String gqlServer = "https://$union/api/v2/graphql"; + var request = http.Request('POST', Uri.parse(gqlServer)); + var query = + "query TrendingTags {\n trendingTags(limit: 50) {\n tags {\n score\n tag\n }\n }\n}"; + request.body = json.encode( + {"query": query, "operationName": "TrendingTags", "extensions": {}}); + request.headers.addAll(headers); + + http.StreamedResponse response = await request.send(); + + if (response.statusCode == 200) { + var string = await response.stream.bytesToString(); + return TrendingTagResponse.fromRawJson(string); + } else { + print(response.reasonPhrase); + throw response.reasonPhrase ?? 'Error occurred'; + } + } + + Future> getTrendingFeed( + bool isShorts, int skip, String? lang) async { + var spkVideoQuery = + "\nspkvideo: {only: true${isShorts ? ", isShort: true" : ""}}\n"; + var feedOptionsQuery = + "\nfeedOptions: { ${lang != null ? "byLang: {_eq: \"$lang\"}" : ""} }\n"; + var paginationQuery = "\npagination: { limit: 50, skip: $skip }\n"; + return getGQLFeed('TrendingFeed', + "query TrendingFeed {\n trendingFeed($spkVideoQuery$feedOptionsQuery$paginationQuery)\n$dataQuery"); + } + + Future> getTrendingTagFeed( + String tag, bool isShorts, int skip, String? lang) async { + var spkVideoQuery = + "\nspkvideo: {only: true, firstUpload: true${isShorts ? ", isShort: true" : ""}}\n"; + var feedOptionsQuery = + "\nfeedOptions: { byTag: {_eq: \"$tag\"} \n ${lang != null ? "byLang: {_eq: \"$lang\"}" : ""} }\n"; + var paginationQuery = "\npagination: { limit: 50, skip: $skip }\n"; + return getGQLFeed('TrendingTagFeed', + "query TrendingTagFeed {\n trendingFeed($spkVideoQuery$feedOptionsQuery$paginationQuery)\n$dataQuery"); + } + + Future> getFirstUploadsFeed( + bool isShorts, int skip, String? lang) async { + var spkVideoQuery = + "\nspkvideo: {only: true, firstUpload: true${isShorts ? ", isShort: true" : ""}}\n"; + var feedOptionsQuery = + "\nfeedOptions: { ${lang != null ? "byLang: {_eq: \"$lang\"}" : ""} }\n"; + var paginationQuery = "\npagination: { limit: 50, skip: $skip }\n"; + return getGQLFeed('FirstUploadsFeed', + "query FirstUploadsFeed {\n trendingFeed($spkVideoQuery$feedOptionsQuery$paginationQuery)\n$dataQuery"); + } + + Future> getNewUploadsFeed( + bool isShorts, int skip, String? lang) async { + var spkVideoQuery = + "\nspkvideo: {only: true${isShorts ? ", isShort: true" : ""}}\n"; + var feedOptionsQuery = + "\nfeedOptions: { ${lang != null ? "byLang: {_eq: \"$lang\"}" : ""} }\n"; + var paginationQuery = "\npagination: { limit: 50, skip: $skip }\n"; + return getGQLFeed('NewUploadsFeed', + "query NewUploadsFeed {\n socialFeed($spkVideoQuery$feedOptionsQuery$paginationQuery)\n$dataQuery"); + } + + Future> getMyFeed( + String username, bool isShorts, int skip, String? lang) async { + var spkVideoQuery = + "\nspkvideo: {only: true${isShorts ? ", isShort: true" : ""}}\n"; + var feedOptionsQuery = + "\nfeedOptions: { byFollower: \"$username\"${lang != null ? ", byLang: {_eq: \"$lang\"}" : ""} }\n"; + var paginationQuery = "\npagination: { limit: 50, skip: $skip }\n"; + return getGQLFeed('MyFeed', + "query MyFeed {\n socialFeed($spkVideoQuery$feedOptionsQuery$paginationQuery)\n$dataQuery"); + } + + Future> getRelated( + String author, String permlink, String? lang) async { + var spkVideoQuery = + "\nauthor: \"$author\", permlink: \"$permlink\"\nspkvideo: {only: true }\n"; + var feedOptionsQuery = + "\nfeedOptions: { ${lang != null ? "byLang: {_eq: \"$lang\"}" : ""} }\n"; + return getGQLFeed('RelatedFeed', + "query RelatedFeed {\n relatedFeed($spkVideoQuery$feedOptionsQuery)\n$dataQuery"); + } + + Future> getUserFeed( + List authors, bool isShorts, int skip, String? lang) async { + var authorsQuery = "{_in: [${authors.map((e) => '"$e"').join(",")}]}"; + var spkVideoQuery = + "\nspkvideo: {only: true${isShorts ? ", isShort: true" : ""}}\n"; + var feedOptionsQuery = + "\nfeedOptions: { byCreator: $authorsQuery ${lang != null ? ", byLang: {_eq: \"$lang\"}" : ""} }\n"; + var paginationQuery = "\npagination: { limit: 50, skip: $skip }\n"; + return getGQLFeed('UserChannelFeed', + "query UserChannelFeed {\n socialFeed($spkVideoQuery$feedOptionsQuery$paginationQuery)\n$dataQuery"); + } + + Future> getSearchFeed( + String term, bool isShorts, int skip, String? lang) async { + var spkVideoQuery = + "\nsearchTerm: \"$term\"\nspkvideo: {only: true${isShorts ? ", isShort: true" : ""}}\n"; + var feedOptionsQuery = + "\nfeedOptions: { ${lang != null ? ", byLang: {_eq: \"$lang\"}" : ""} }\n"; + var paginationQuery = "\npagination: { limit: 50, skip: $skip }\n"; + return getGQLFeed('SearchFeed', + "query SearchFeed {\n searchFeed($spkVideoQuery$feedOptionsQuery$paginationQuery)\n$dataQuery"); + } + + Future> getCommunity( + String community, bool isShorts, int skip, String? lang) async { + var spkVideoQuery = + "\nspkvideo: {only: true${isShorts ? ", isShort: true" : ""}}\n"; + var feedOptionsQuery = + "\nfeedOptions: { byCommunity: { _eq: \"$community\" } ${lang != null ? ", byLang: {_eq: \"$lang\"}" : ""} }\n"; + var paginationQuery = "\npagination: { limit: 50, skip: $skip }\n"; + return getGQLFeed('CommunityFeed', + "query CommunityFeed {\n socialFeed($spkVideoQuery$feedOptionsQuery$paginationQuery)\n$dataQuery"); + } + + Future> getCTTFeed(int skip, String? lang) async { + var authors = ["spknetwork.chat", "neopch.ctt", "noakmilo.ctt"]; + var authorsQuery = "{_in: [${authors.map((e) => '"$e"').join(",")}]}"; + var spkVideoQuery = "\nspkvideo: {only: true }\n"; + var feedOptionsQuery = + "\nfeedOptions: { byCreator: $authorsQuery ${lang != null ? ", byLang: {_eq: \"$lang\"}" : ""} }\n"; + var paginationQuery = "\npagination: { limit: 50, skip: $skip }\n"; + return getGQLFeed('UserChannelFeed', + "query UserChannelFeed {\n socialFeed($spkVideoQuery$feedOptionsQuery$paginationQuery)\n$dataQuery"); + } + + Future> getHiveComments( + String userName, String permLink) async { + try { + var headers = { + 'Connection': 'keep-alive', + 'content-type': 'application/json', + }; + var body = json.encode({ + "query": + "query GetComments {\n socialPost(author: \"$userName\", permlink: \"$permLink\") {\n ... on HivePost {\n children {\n ... on HivePost {\n body\n permlink\n created_at\n author {\n username\n }\n stats {\n num_votes\n }\n children {\n ... on HivePost {\n body\n permlink\n created_at\n author {\n username\n }\n stats {\n num_votes\n }\n }\n children {\n ... on HivePost {\n body\n permlink\n created_at\n author {\n username\n }\n stats {\n num_votes\n }\n }\n }\n }\n }\n }\n body\n }\n }\n}", + "operationName": "GetComments", + "extensions": {} + }); + http.Response response = await post( + Uri.parse('https://union.us-02.infra.3speak.tv/api/v2/graphql'), + headers: headers, + body: body); + + if (response.statusCode == 200) { + var string = response.body; + return GQLHiveCommentReponse.fromRawJson(string) + .data + .socialPost + .children ?? + []; + } else { + throw response.reasonPhrase ?? 'Error occurred'; + } + } catch (e) { + throw e; + } + } + + Future getVideoDetails(String author, String permlink) async { + var headers = { + 'Connection': 'keep-alive', + 'content-type': 'application/json', + }; + const storage = FlutterSecureStorage(); + String union = + await storage.read(key: 'union') ?? GQLCommunicator.defaultGQLServer; + String gqlServer = "https://$union/api/v2/graphql"; + var request = http.Request('POST', Uri.parse(gqlServer)); + var query = + "query MyQuery {\n socialPost(author: \"edmundochauran\", permlink: \"dbgmwaox\") {\n ... on HivePost {\n spkvideo\n title\n lang\n json_metadata {\n raw\n }\n created_at\n tags\n author {\n username\n }\n permlink\n stats {\n num_comments\n num_votes\n total_hive_reward\n }\n community\n body\n app_metadata\n }\n }\n}"; + request.body = json + .encode({"query": query, "operationName": "MyQuery", "extensions": {}}); + request.headers.addAll(headers); + + http.StreamedResponse response = await request.send(); + + if (response.statusCode == 200) { + var string = await response.stream.bytesToString(); + var responseData = VideoDetailsFeed.fromRawJson(string); + return responseData.item; + } else { + print(response.reasonPhrase); + throw response.reasonPhrase ?? 'Error occurred'; + } + } + + Future> getComments( + String author, String permlink) async { + try { + var headers = {'content-type': 'application/json'}; + var request = http.Request('POST', Uri.parse('https://api.hive.blog/')); + request.body = json.encode({ + "id": 9, + "jsonrpc": "2.0", + "method": "bridge.get_discussion", + "params": {"author": author, "permlink": permlink} + }); + request.headers.addAll(headers); + + http.StreamedResponse response = await request.send(); + + if (response.statusCode == 200) { + CommentResponseModel commentResponse = CommentResponseModel.fromRawJson( + await response.stream.bytesToString()); + return commentResponse.comments; + } else { + throw (response.reasonPhrase.toString()); + } + } catch (e) { + throw (e.toString()); + } + } +} diff --git a/lib/src/utils/graphql/models/trending_feed_response.dart b/lib/src/utils/graphql/models/trending_feed_response.dart new file mode 100644 index 00000000..9c905aa5 --- /dev/null +++ b/lib/src/utils/graphql/models/trending_feed_response.dart @@ -0,0 +1,501 @@ +import 'dart:convert'; + +import 'package:acela/src/global_provider/ipfs_node_provider.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/utils/communicator.dart'; + +class GraphQlFeedResponse { + GraphQlFeedResponseData? data; + + GraphQlFeedResponse({ + this.data, + }); + + factory GraphQlFeedResponse.fromRawJson(String str) => + GraphQlFeedResponse.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory GraphQlFeedResponse.fromJson(Map json) => + GraphQlFeedResponse( + data: json["data"] == null + ? null + : GraphQlFeedResponseData.fromJson(json["data"]), + ); + + Map toJson() => { + "data": data?.toJson(), + }; +} + +class GraphQlFeedResponseData { + TrendingFeed? trendingFeed; + TrendingFeed? socialFeed; + TrendingFeed? relatedFeed; + TrendingFeed? searchFeed; + + GraphQlFeedResponseData({ + this.trendingFeed, + this.socialFeed, + this.relatedFeed, + this.searchFeed, + }); + + factory GraphQlFeedResponseData.fromRawJson(String str) => + GraphQlFeedResponseData.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory GraphQlFeedResponseData.fromJson(Map json) => + GraphQlFeedResponseData( + trendingFeed: json["trendingFeed"] == null + ? null + : TrendingFeed.fromJson(json["trendingFeed"]), + socialFeed: json["socialFeed"] == null + ? null + : TrendingFeed.fromJson(json["socialFeed"]), + relatedFeed: json["relatedFeed"] == null + ? null + : TrendingFeed.fromJson(json["relatedFeed"]), + searchFeed: json["searchFeed"] == null + ? null + : TrendingFeed.fromJson(json["searchFeed"]), + ); + + Map toJson() => { + "trendingFeed": trendingFeed?.toJson(), + "socialFeed": socialFeed?.toJson(), + "relatedFeed": socialFeed?.toJson(), + "searchFeed": searchFeed?.toJson(), + }; +} + +class TrendingFeed { + List? items; + + TrendingFeed({ + this.items, + }); + + factory TrendingFeed.fromRawJson(String str) => + TrendingFeed.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory TrendingFeed.fromJson(Map json) => TrendingFeed( + items: json["items"] == null + ? [] + : List.from( + json["items"]!.map((x) => GQLFeedItem.fromJson(x))), + ); + + Map toJson() => { + "items": items == null + ? [] + : List.from(items!.map((x) => x.toJson())), + }; +} + +class GQLFeedItem { + GQLFeedItemStats? stats; + Spkvideo? spkvideo; + final String? playUrl; + String? permlink; + String? lang; + DateTime? createdAt; + GQLFeedCommunity? community; + String? title; + List? tags; + GQLFeedItemAuthor? author; + String? body; + List? children; + final bool isVideo; + + GQLFeedItem({ + required this.playUrl, + required this.isVideo, + this.stats, + this.spkvideo, + this.permlink, + this.lang, + this.createdAt, + this.community, + this.title, + this.tags, + this.author, + this.body, + this.children, + }); + + factory GQLFeedItem.fromRawJson(String str) => + GQLFeedItem.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory GQLFeedItem.fromJson(Map json) => GQLFeedItem( + playUrl: json['json_metadata']?['raw']?['video']?['info']?['video_v2'], + isVideo: json['json_metadata']?['raw']?['video']?['info']?['video_v2'] + ?.endsWith('.m3u8') ?? + true, + stats: json["stats"] == null + ? null + : GQLFeedItemStats.fromJson(json["stats"]), + spkvideo: json["spkvideo"] == null + ? null + : Spkvideo.fromJson(json["spkvideo"]), + permlink: json["permlink"], + lang: json["lang"], + createdAt: json["created_at"] == null + ? null + : DateTime.parse(json["created_at"]), + community: json["community"] == null + ? null + : GQLFeedCommunity.fromJson(json["community"]), + title: json["title"], + tags: json["tags"] == null + ? [] + : List.from(json["tags"]!.map((x) => x)), + author: json["author"] == null + ? null + : GQLFeedItemAuthor.fromJson(json["author"]), + body: json["body"], + children: json["children"] == null + ? [] + : List.from( + json["children"]!.map((x) => GQLFeedItemChild.fromJson(x))), + ); + + Map toJson() => { + "stats": stats?.toJson(), + "spkvideo": spkvideo?.toJson(), + "permlink": permlink, + "lang": lang, + "created_at": createdAt?.toIso8601String(), + "community": community?.toJson(), + "title": title, + "tags": tags == null ? [] : List.from(tags!.map((x) => x)), + "author": author?.toJson(), + "body": body, + "children": children == null + ? [] + : List.from(children!.map((x) => x.toJson())), + }; + + String get thumbnailValue { + if ((spkvideo?.thumbnailUrl ?? '').startsWith("http")) { + return spkvideo!.thumbnailUrl!; + } + return '${Communicator.threeSpeakCDN}/ipfs/${(spkvideo?.thumbnailUrl ?? '').replaceAll("ipfs://", '')}'; + } + + String videoV2M3U8(HiveUserData data) { + if ((spkvideo?.playUrl ?? '').contains('ipfs')) { + // example + // https://ipfs-3speak.b-cdn.net/ipfs/QmTRDJcgtt66pxs3ZnQCdRw57b69NS2TQvF4yHwaux5grT/manifest.m3u8 + // https://ipfs-3speak.b-cdn.net/ipfs/QmTRDJcgtt66pxs3ZnQCdRw57b69NS2TQvF4yHwaux5grT/480p/index.m3u8 + // https://ipfs-3speak.b-cdn.net/ipfs/QmWADpD1PWPnmYVkSZvgokU5vcN2qZqvsHCA985GZ5Jf4r/manifest.m3u8 + var url = (spkvideo?.playUrl ?? '') + .replaceAll('ipfs://', IpfsNodeProvider().nodeUrl) + .replaceAll('manifest', '${data.resolution}/index'); + return url; + } + return spkvideo?.playUrl ?? ''; + } + + // 480p_video.m3u8 + String mobileEncodedVideoUrl() { + if ((spkvideo?.playUrl ?? '').contains('ipfs')) { + // example + // https://ipfs-3speak.b-cdn.net/ipfs/QmTRDJcgtt66pxs3ZnQCdRw57b69NS2TQvF4yHwaux5grT/manifest.m3u8 + // https://ipfs-3speak.b-cdn.net/ipfs/QmTRDJcgtt66pxs3ZnQCdRw57b69NS2TQvF4yHwaux5grT/480p/index.m3u8 + // https://ipfs-3speak.b-cdn.net/ipfs/QmWADpD1PWPnmYVkSZvgokU5vcN2qZqvsHCA985GZ5Jf4r/manifest.m3u8 + var url = (spkvideo?.playUrl ?? '') + .replaceAll('ipfs://', IpfsNodeProvider().nodeUrl) + .replaceAll('manifest', '480p_video'); + return url; + } + return spkvideo?.playUrl ?? ''; + } + + String get hlsUrl { + if ((spkvideo?.playUrl ?? '').contains('ipfs')) { + // example + // https://ipfs-3speak.b-cdn.net/ipfs/QmTRDJcgtt66pxs3ZnQCdRw57b69NS2TQvF4yHwaux5grT/manifest.m3u8 + // https://ipfs-3speak.b-cdn.net/ipfs/QmTRDJcgtt66pxs3ZnQCdRw57b69NS2TQvF4yHwaux5grT/480p/index.m3u8 + // https://ipfs-3speak.b-cdn.net/ipfs/QmWADpD1PWPnmYVkSZvgokU5vcN2qZqvsHCA985GZ5Jf4r/manifest.m3u8 + var url = (spkvideo?.playUrl ?? '') + .replaceAll('ipfs://', IpfsNodeProvider().nodeUrl); + return url; + } + return spkvideo?.playUrl ?? ''; + } +} + +class GQLFeedItemAuthor { + String? username; + + GQLFeedItemAuthor({ + this.username, + }); + + factory GQLFeedItemAuthor.fromRawJson(String str) => + GQLFeedItemAuthor.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory GQLFeedItemAuthor.fromJson(Map json) => + GQLFeedItemAuthor( + username: json["username"], + ); + + Map toJson() => { + "username": username, + }; +} + +class GQLFeedItemChild { + GQLFeedItemAuthor? author; + String? body; + DateTime? createdAt; + String? permlink; + String? title; + + GQLFeedItemChild({ + this.author, + this.body, + this.createdAt, + this.permlink, + this.title, + }); + + factory GQLFeedItemChild.fromRawJson(String str) => + GQLFeedItemChild.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory GQLFeedItemChild.fromJson(Map json) => + GQLFeedItemChild( + author: json["author"] == null + ? null + : GQLFeedItemAuthor.fromJson(json["author"]), + body: json["body"], + createdAt: json["created_at"] == null + ? null + : DateTime.parse(json["created_at"]), + permlink: json["permlink"], + title: json["title"], + ); + + Map toJson() => { + "author": author?.toJson(), + "body": body, + "created_at": createdAt?.toIso8601String(), + "permlink": permlink, + "title": title, + }; +} + +class GQLFeedCommunity { + String? id; + String? about; + bool? needsUpdate; + String? title; + CommunityImages? images; + List? topics; + String? username; + DateTime? createdAt; + String? description; + String? flagText; + bool? isNsfw; + String? lang; + List>? roles; + int? subscribers; + + GQLFeedCommunity({ + this.id, + this.about, + this.needsUpdate, + this.title, + this.images, + this.topics, + this.username, + this.createdAt, + this.description, + this.flagText, + this.isNsfw, + this.lang, + this.roles, + this.subscribers, + }); + + factory GQLFeedCommunity.fromRawJson(String str) => + GQLFeedCommunity.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory GQLFeedCommunity.fromJson(Map json) => + GQLFeedCommunity( + id: json["_id"], + about: json["about"], + needsUpdate: json["needs_update"], + title: json["title"], + images: json["images"] == null + ? null + : CommunityImages.fromJson(json["images"]), + topics: json["topics"] == null + ? [] + : List.from(json["topics"]!.map((x) => x)), + username: json["username"], + createdAt: json["created_at"] == null + ? null + : DateTime.parse(json["created_at"]), + description: json["description"], + flagText: json["flag_text"], + isNsfw: json["is_nsfw"], + lang: json["lang"], + roles: json["roles"] == null + ? [] + : List>.from( + json["roles"]!.map((x) => List.from(x.map((x) => x)))), + subscribers: json["subscribers"], + ); + + Map toJson() => { + "_id": id, + "about": about, + "needs_update": needsUpdate, + "title": title, + "images": images?.toJson(), + "topics": + topics == null ? [] : List.from(topics!.map((x) => x)), + "username": username, + "created_at": createdAt?.toIso8601String(), + "description": description, + "flag_text": flagText, + "is_nsfw": isNsfw, + "lang": lang, + "roles": roles == null + ? [] + : List.from( + roles!.map((x) => List.from(x.map((x) => x)))), + "subscribers": subscribers, + }; +} + +class CommunityImages { + String? avatar; + String? cover; + + CommunityImages({ + this.avatar, + this.cover, + }); + + factory CommunityImages.fromRawJson(String str) => + CommunityImages.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory CommunityImages.fromJson(Map json) => + CommunityImages( + avatar: json["avatar"], + cover: json["cover"], + ); + + Map toJson() => { + "avatar": avatar, + "cover": cover, + }; +} + +class Spkvideo { + String? thumbnailUrl; + String? playUrl; + double? duration; + bool? isShort; + String? body; + int? height; + int? width; + + Spkvideo( + {this.thumbnailUrl, + this.playUrl, + this.duration, + this.isShort, + this.body, + this.height, + this.width}); + + factory Spkvideo.fromRawJson(String str) => + Spkvideo.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory Spkvideo.fromJson(Map json) => Spkvideo( + thumbnailUrl: json["thumbnail_url"], + playUrl: json["play_url"], + duration: json["duration"]?.toDouble(), + isShort: json["is_short"], + body: json["body"], + height: json["height"], + width: json["width"], + ); + + Map toJson() => { + "thumbnail_url": thumbnailUrl, + "play_url": playUrl, + "duration": duration, + "is_short": isShort, + "body": body, + "width": width, + "height": height, + }; +} + +class GQLFeedItemStats { + double? totalHiveReward; + int? numVotes; + int? numComments; + + GQLFeedItemStats({ + this.totalHiveReward, + this.numVotes, + this.numComments, + }); + + factory GQLFeedItemStats.fromRawJson(String str) => + GQLFeedItemStats.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory GQLFeedItemStats.fromJson(Map json) => + GQLFeedItemStats( + totalHiveReward: json["total_hive_reward"]?.toDouble(), + numVotes: json["num_votes"], + numComments: json["num_comments"], + ); + + Map toJson() => { + "total_hive_reward": totalHiveReward, + "num_votes": numVotes, + "num_comments": numComments, + }; +} + +class VideoDetailsFeed { + final GQLFeedItem item; + + VideoDetailsFeed({required this.item}); + + factory VideoDetailsFeed.fromRawJson(String str) => + VideoDetailsFeed.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory VideoDetailsFeed.fromJson(Map json) => + VideoDetailsFeed( + item: GQLFeedItem.fromJson(json['data']['socialPost']), + ); + + Map toJson() => {"item": item.toJson()}; +} diff --git a/lib/src/utils/podcast/podcast_communicator.dart b/lib/src/utils/podcast/podcast_communicator.dart new file mode 100644 index 00000000..2b3723da --- /dev/null +++ b/lib/src/utils/podcast/podcast_communicator.dart @@ -0,0 +1,123 @@ +import 'dart:convert'; +import 'dart:developer'; + +import 'package:acela/src/models/podcast/podcast_categories_response.dart'; +import 'package:acela/src/models/podcast/podcast_episode_chapters.dart'; +import 'package:acela/src/models/podcast/podcast_episodes.dart'; +import 'package:acela/src/models/podcast/trending_podcast_response.dart'; +import 'package:convert/convert.dart'; +import 'package:crypto/crypto.dart'; +import 'package:dart_rss/domain/rss_feed.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:http/http.dart' as http; + +class PodCastCommunicator { + static var baseUrl = "https://api.podcastindex.org/api/1.0/"; + + Future fetchPodCast(String path) async { + var unixTime = (DateTime.now().millisecondsSinceEpoch / 1000).round(); + String newUnixTime = unixTime.toString(); + var apiKey = dotenv.env['PC_API_KEY'] ?? ''; + var apiSecret = dotenv.env['PC_API_SECRET'] ?? ''; + var firstChunk = utf8.encode(apiKey); + var secondChunk = utf8.encode(apiSecret); + var thirdChunk = utf8.encode(newUnixTime); + + var output = new AccumulatorSink(); + var input = sha1.startChunkedConversion(output); + input.add(firstChunk); + input.add(secondChunk); + input.add(thirdChunk); + input.close(); + var digest = output.events.single; + + Map headers = { + "X-Auth-Date": newUnixTime, + "X-Auth-Key": apiKey, + "Authorization": digest.toString(), + "User-Agent": "ThreeSpeak/2.0.0+$newUnixTime" + }; + + var uriString = '$baseUrl$path'; + var uri = Uri.parse(uriString); + + final response = await http.get(uri, headers: headers); + + if (response.statusCode == 200) { + // If the server did return a 200 OK response, + // then parse the JSON. + return response.body; + // return PodCastIndex.fromJson(json.decode(response.body)); + } else { + // If the server did not return a 200 OK response, + // then throw an exception. + throw Exception('Failed to load album'); + } + } + + Future getTrendingPodcasts() async { + var response = await fetchPodCast('podcasts/trending'); + return TrendingPodCastResponse.fromRawJson(response); + } + + Future getLivePodcasts() async { + var response = await fetchPodCast('episodes/live'); + try { + return TrendingPodCastResponse.fromRawJson(response); + } catch (e) { + log('Error is - ${e.toString()}'); + rethrow; + } + } + + Future getRecentPodcasts() async { + var response = await fetchPodCast('recent/feeds'); + return TrendingPodCastResponse.fromRawJson(response); + } + + Future getFeedsByCategory(int categoryId) async { + var response = await fetchPodCast('recent/feeds?max=30&cat=$categoryId'); + return TrendingPodCastResponse.fromRawJson(response); + } + + Future> getCategories() async { + var response = await fetchPodCast('categories/list'); + return PodcastCategoriesResponse.fromRawJson(response).feeds ?? []; + } + + Future getSearchResults(String searchTerm) async { + var response = await fetchPodCast('search/byterm?q=$searchTerm'); + return TrendingPodCastResponse.fromRawJson(response); + } + + Future getPodcastEpisodesByFeedId( + String feedId) async { + var response = await fetchPodCast('/episodes/byfeedid?id=$feedId'); + return PodcastEpisodesByFeedResponse.fromRawJson(response); + } + + Future getPodcastFeedByRss(String rssUrl) async { + final client = http.Client(); + final response = await client.get(Uri.parse(rssUrl)); + RssFeed result = RssFeed.parse(response.body); + return PodCastFeedItem.fromRss(result, rssUrl); + } + + Future getPodcastEpisodesByRss( + String rssUrl) async { + final client = http.Client(); + final response = await client.get(Uri.parse(rssUrl)); + RssFeed result = RssFeed.parse(response.body); + return PodcastEpisodesByFeedResponse( + items: result.items.map((e) => PodcastEpisode.fromRss(e)).toList()); + } + + Future> getPodcastEpisodeChapters( + String url) async { + final client = http.Client(); + final response = await client.get(Uri.parse(url)); + PodcastEpisodeChapterResponse result = + PodcastEpisodeChapterResponse.fromRawJson(response.body); + return result.chapters; + } +} diff --git a/lib/src/utils/routes/app_router.dart b/lib/src/utils/routes/app_router.dart new file mode 100644 index 00000000..ec2ed34e --- /dev/null +++ b/lib/src/utils/routes/app_router.dart @@ -0,0 +1,70 @@ +import 'package:acela/src/models/navigation_models/new_video_detail_screen_navigation_model.dart'; +import 'package:acela/src/screens/home_screen/default_screen.dart'; +import 'package:acela/src/screens/policy_aggrement/policy_repo/policy_repo.dart'; +import 'package:acela/src/screens/policy_aggrement/presentation/policy_aggrement_view.dart'; +import 'package:acela/src/screens/user_channel_screen/user_channel_screen.dart'; +import 'package:acela/src/screens/video_details_screen/new_video_details/new_video_details_screen.dart'; +import 'package:acela/src/utils/routes/routes.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class AppRouter { + static GoRouter router = GoRouter(routes: routes()); + + static List routes() { + // PolicyRepo().writePolicyStatus(false); + return [ + GoRoute( + path: '/', + name: Routes.initialView, + builder: (context, state) => DefaultView(), + redirect: (context, state) => + PolicyRepo().isPolicyTermsAccepted() ? null : "/policy", + ), + GoRoute( + path: '/policy', + name: Routes.policyView, + builder: (context, state) => PolicyAggrementView()), + GoRoute( + path: '/${Routes.videoDetailsView}/:author/:permlink', + name: Routes.videoDetailsView, + builder: (context, state) { + NewVideoDetailScreenNavigationParameter? parameters = + (state.extra) as NewVideoDetailScreenNavigationParameter?; + return NewVideoDetailsScreen( + item: parameters?.item, + onPop: parameters?.onPop, + betterPlayerController: parameters?.betterPlayerController, + author: state.pathParameters['author']!, + permlink: state.pathParameters['permlink']!, + ); + }, + redirect: (context, state) { + final author = state.pathParameters['author']; + final permlink = state.pathParameters['permlink']; + if (author == null || permlink == null) { + return '/'; + } + return null; + }, + ), + GoRoute( + path: '/${Routes.userView}/:author', + name: Routes.userView, + builder: (context, state) { + return UserChannelScreen( + owner: state.pathParameters['author']!, + onPop: (state.extra) as VoidCallback?, + ); + }, + redirect: (context, state) { + final author = state.pathParameters['author']; + if (author == null) { + return '/'; + } + return null; + }, + ), + ]; + } +} diff --git a/lib/src/utils/routes/routes.dart b/lib/src/utils/routes/routes.dart new file mode 100644 index 00000000..f8fdd3f4 --- /dev/null +++ b/lib/src/utils/routes/routes.dart @@ -0,0 +1,7 @@ +class Routes { + static const String initialView = 'initial'; + static const String policyView = 'policy'; + static const String userView = 'user'; + static const String videoDetailsView = 'videoDetails'; + +} diff --git a/lib/src/utils/safe_convert.dart b/lib/src/utils/safe_convert.dart new file mode 100644 index 00000000..9775a25d --- /dev/null +++ b/lib/src/utils/safe_convert.dart @@ -0,0 +1,69 @@ + +int asInt(Map? json, String key, {int defaultValue = 0}) { + if (json == null || !json.containsKey(key)) return defaultValue; + var value = json[key]; + if (value == null) return defaultValue; + if (value is int) return value; + if (value is double) return value.toInt(); + if (value is bool) return value ? 1 : 0; + if (value is String) return int.tryParse(value) ?? double.tryParse(value)?.toInt() ?? defaultValue; + return defaultValue; +} + +double asDouble(Map? json, String key, {double defaultValue = 0.0}) { + if (json == null || !json.containsKey(key)) return defaultValue; + var value = json[key]; + if (value == null) return defaultValue; + if (value is double) return value; + if (value is int) return value.toDouble(); + if (value is bool) return value ? 1.0 : 0.0; + if (value is String) return double.tryParse(value) ?? defaultValue; + return defaultValue; +} + +bool asBool(Map? json, String key, {bool defaultValue = false}) { + if (json == null || !json.containsKey(key)) return defaultValue; + var value = json[key]; + if (value == null) return defaultValue; + if (value is bool) return value; + if (value is int) return value == 0 ? false : true; + if (value is double) return value == 0 ? false : true; + if (value is String) { + if (value == "1" || value.toLowerCase() == "true") return true; + if (value == "0" || value.toLowerCase() == "false") return false; + } + return defaultValue; +} + +String asString(Map? json, String key, {String defaultValue = ""}) { + if (json == null || !json.containsKey(key)) return defaultValue; + var value = json[key]; + if (value == null) return defaultValue; + if (value is String) return value; + if (value is int) return value.toString(); + if (value is double) return value.toString(); + if (value is bool) return value ? "true" : "false"; + return defaultValue; +} + +String asDynamicString(dynamic value) { + if (value == null) return ""; + if (value is String) return value; + return ""; +} + +Map asMap(Map? json, String key, {Map? defaultValue}) { + if (json == null || !json.containsKey(key)) return defaultValue ?? {}; + var value = json[key]; + if (value == null) return defaultValue ?? {}; + if (value is Map) return value; + return defaultValue ?? {}; +} + +List asList(Map? json, String key, {List? defaultValue}) { + if (json == null || !json.containsKey(key)) return defaultValue ?? []; + var value = json[key]; + if (value == null) return defaultValue ?? []; + if (value is List) return value; + return defaultValue ?? []; +} diff --git a/lib/src/utils/seconds_to_duration.dart b/lib/src/utils/seconds_to_duration.dart index b666ebb5..f771b689 100644 --- a/lib/src/utils/seconds_to_duration.dart +++ b/lib/src/utils/seconds_to_duration.dart @@ -1,6 +1,48 @@ +import 'package:acela/src/global_provider/image_resolution_provider.dart'; +import 'package:flutter/material.dart'; class Utilities { static String formatTime(int seconds) { return '${(Duration(seconds: seconds))}'.split('.')[0].padLeft(8, '0'); } -} \ No newline at end of file + + static String parseAndFormatDateTime(String dateTime) { + var dt = DateTime.parse(dateTime); + return "${dt.year}-${dt.month}-${dt.day}"; + } + + static String removeAllHtmlTags(String htmlText) { + RegExp exp = RegExp(r"<[^>]*>", multiLine: true, caseSensitive: true); + + return htmlText.replaceAll(exp, ''); + } + + static Duration doubleToDuration(double value) { + int seconds = value.toInt(); + int milliseconds = ((value - seconds) * 1000).round(); + return Duration(seconds: seconds, milliseconds: milliseconds); + } + + static double durationToDouble(Duration duration) { + return duration.inMilliseconds / 1000.0; + } + + static int textLines( + String text, TextStyle style, double maxWidth, int? maxLines) { + TextSpan textSpan = TextSpan(text: text, style: style); + TextPainter textPainter = TextPainter( + text: textSpan, + maxLines: maxLines, + textAlign: TextAlign.left, + textDirection: TextDirection.ltr, + ); + textPainter.layout(maxWidth: maxWidth); + + return textPainter.computeLineMetrics().length; + } + + static getProxyImage(String resolution, String imageUrl) { + String actualResolution = Resolution.removePFromResolution(resolution); + return 'https://images.hive.blog/${actualResolution}x0/$imageUrl'; + } +} diff --git a/lib/src/utils/storages/video_storage.dart b/lib/src/utils/storages/video_storage.dart new file mode 100644 index 00000000..a6851fbd --- /dev/null +++ b/lib/src/utils/storages/video_storage.dart @@ -0,0 +1,24 @@ +import 'dart:convert'; + +import 'package:get_storage/get_storage.dart'; + +class VideoStorage { + final List defaultVideoEncodingQuality = ['480']; + final GetStorage _storage = GetStorage(); + + final String videoEncodeKey = "videoEncodeKey"; + + List readEncodingQualities() { + String? result = _storage.read(videoEncodeKey); + if (result != null) { + return (json.decode(result) as List) + .map((e) => e as String) + .toList(); + } + return defaultVideoEncodingQuality; + } + + Future writeVideoEncodingQuality(List data) async { + await _storage.write(videoEncodeKey, json.encode(data)); + } +} diff --git a/lib/src/widgets/blink_widget.dart b/lib/src/widgets/blink_widget.dart new file mode 100644 index 00000000..116c22d5 --- /dev/null +++ b/lib/src/widgets/blink_widget.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +class BlinkWidget extends StatefulWidget { + const BlinkWidget({super.key, required this.child, this.end}); + + final Widget child; + final double? end; + + @override + State createState() => _BlinkWidgetState(); +} + +class _BlinkWidgetState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _animation; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + duration: const Duration(milliseconds: 1000), + vsync: this, + )..repeat(reverse: true); + + _animation = + Tween(begin: 1, end: widget.end ?? 0.2).animate(_controller); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (BuildContext context, Widget? child) { + return Opacity( + opacity: _animation.value, + child: child, + ); + }, + child: widget.child); + } +} diff --git a/lib/src/widgets/bottom_sheet_outline.dart b/lib/src/widgets/bottom_sheet_outline.dart new file mode 100644 index 00000000..05d2273f --- /dev/null +++ b/lib/src/widgets/bottom_sheet_outline.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +class BottomSheetOutline extends StatelessWidget { + const BottomSheetOutline( + {Key? key, required this.children, this.bottomSheetHeight}) + : super(key: key); + + final List children; + final double? bottomSheetHeight; + + @override + Widget build(BuildContext context) { + return Container( + height: bottomSheetHeight ?? 160, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 0), + decoration: const BoxDecoration( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(15), + topRight: Radius.circular(15), + ), + ), + child: Column( + children: [ + Container( + height: 5, + width: 60, + decoration: BoxDecoration( + color: Colors.grey.shade600, + borderRadius: const BorderRadius.all(Radius.circular(16))), + ), + Padding( + padding: + EdgeInsets.only(left: 24.0, right: 24, bottom: 20, top: 30), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: children), + ), + ], + ), + ); + } +} diff --git a/lib/src/widgets/box_loading/box_loading.dart b/lib/src/widgets/box_loading/box_loading.dart new file mode 100644 index 00000000..742c01cc --- /dev/null +++ b/lib/src/widgets/box_loading/box_loading.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +class BoxLoadingIndicator extends StatefulWidget { + const BoxLoadingIndicator({ required this.child, this.end}); + + final Widget child; + final double? end; + + @override + State createState() => _BoxLoadingIndicatorState(); +} + +class _BoxLoadingIndicatorState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _animation; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + duration: const Duration(milliseconds: 1000), + vsync: this, + )..repeat(reverse: true); + + _animation = Tween(begin: 1, end: widget.end ?? 0.2).animate(_controller); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (BuildContext context, Widget? child) { + return Opacity( + opacity: _animation.value, + child: child, + ); + }, + child: widget.child); + } +} diff --git a/lib/src/widgets/box_loading/box_trail.dart b/lib/src/widgets/box_loading/box_trail.dart new file mode 100644 index 00000000..57f176d9 --- /dev/null +++ b/lib/src/widgets/box_loading/box_trail.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +class BoxTrail extends StatelessWidget { + const BoxTrail( + {this.width, this.margin, this.height, this.borderRadius, this.shape}); + + final double? width; + final double? margin; + final double? height; + final double? borderRadius; + final BoxShape? shape; + + @override + Widget build(BuildContext context) { + return Container( + height: height ?? 10, + width: width ?? double.infinity, + margin: EdgeInsets.only(right: margin ?? 0), + decoration: BoxDecoration( + shape: shape ?? BoxShape.rectangle, + color: Theme.of(context).primaryColorLight == Colors.black + ? Colors.grey.shade400 + : Colors.grey.shade900, + borderRadius: shape == null + ? BorderRadius.all( + Radius.circular(borderRadius ?? 30), + ) + : null), + ); + } +} diff --git a/lib/src/widgets/box_loading/video_detail_feed_loader.dart b/lib/src/widgets/box_loading/video_detail_feed_loader.dart new file mode 100644 index 00000000..adf8fc07 --- /dev/null +++ b/lib/src/widgets/box_loading/video_detail_feed_loader.dart @@ -0,0 +1,85 @@ +import 'package:acela/src/widgets/box_loading/box_loading.dart'; +import 'package:acela/src/widgets/box_loading/box_trail.dart'; +import 'package:flutter/material.dart'; + +class VideoDetailFeedLoader extends StatelessWidget { + const VideoDetailFeedLoader({Key? key, required this.isGridView}) + : super(key: key); + + final bool isGridView; + + @override + Widget build(BuildContext context) { + final screenHeight = MediaQuery.of(context).size.height; + final screenWidth = MediaQuery.of(context).size.width; + return BoxLoadingIndicator( + child: Column( + children: [ + BoxTrail( + borderRadius: 0, + height: isGridView ? screenHeight * 0.4 : 230, + ), + Padding( + padding: const EdgeInsets.only(top: 10.0, bottom: 5), + child: ListTile( + contentPadding: EdgeInsets.only(top: 0, left: 15, right: 15), + dense: true, + leading: BoxTrail( + height: 40, + width: 40, + shape: BoxShape.circle, + ), + title: BoxTrail( + width: screenWidth * 0.8, + ), + subtitle: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + BoxTrail( + width: screenWidth * 0.4, + ), + BoxTrail(width: 80), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate( + 5, + (index) => BoxTrail( + height: 35, + width: 35, + shape: BoxShape.circle, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 15.0, top: 15), + child: SizedBox( + height: 33, + child: ListView.builder( + padding: EdgeInsets.symmetric(horizontal: 8), + scrollDirection: Axis.horizontal, + itemCount: 5, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: BoxTrail( + width: 130, + borderRadius: 18, + ), + ); + }, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/widgets/box_loading/video_feed_loader.dart b/lib/src/widgets/box_loading/video_feed_loader.dart new file mode 100644 index 00000000..0ea85558 --- /dev/null +++ b/lib/src/widgets/box_loading/video_feed_loader.dart @@ -0,0 +1,82 @@ +import 'package:acela/src/widgets/box_loading/video_item_loader.dart'; +import 'package:flutter/material.dart'; + +class VideoFeedLoader extends StatelessWidget { + const VideoFeedLoader( + {Key? key, this.isGridView = false, this.isSliver = false}) + : super(key: key); + + final bool isGridView; + final bool isSliver; + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + int crossAxisCount = getCrossAxisCount(screenWidth); + return isGridView + ? isSliver + ? SliverFillRemaining( + child: _gridViewLoader(crossAxisCount, context), + ) + : _gridViewLoader(crossAxisCount, context) + : isSliver + ? SliverToBoxAdapter( + child: _listViewLoader(), + ) + : _listViewLoader(); + } + + Padding _listViewLoader() { + return Padding( + padding: const EdgeInsets.only(top: 10.0), + child: ListView( + physics: ClampingScrollPhysics(), + shrinkWrap: true, + children: List.generate( + 6, + (index) => Padding( + padding: const EdgeInsets.only(bottom: 10.0), + child: VideoItemLoader( + isGridView: false, + ), + ), + ), + ), + ); + } + + Padding _gridViewLoader(int crossAxisCount, BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 15, horizontal: 20), + child: GridView.builder( + itemCount: 25, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + childAspectRatio: + MediaQuery.of(context).orientation == Orientation.landscape + ? 1.25 + : 1.4, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemBuilder: (context, index) { + return VideoItemLoader( + isGridView: isGridView, + ); + }, + ), + ); + } + + int getCrossAxisCount(double width) { + if (width > 1300) { + return 4; + } else if (width > 974 && width < 1300) { + return 3; + } else if (width > 650 && width < 974) { + return 2; + } else { + return 2; + } + } +} diff --git a/lib/src/widgets/box_loading/video_item_loader.dart b/lib/src/widgets/box_loading/video_item_loader.dart new file mode 100644 index 00000000..167d06c7 --- /dev/null +++ b/lib/src/widgets/box_loading/video_item_loader.dart @@ -0,0 +1,75 @@ +import 'package:acela/src/widgets/box_loading/box_loading.dart'; +import 'package:acela/src/widgets/box_loading/box_trail.dart'; +import 'package:flutter/material.dart'; + +class VideoItemLoader extends StatefulWidget { + const VideoItemLoader({Key? key, required this.isGridView}); + + final bool isGridView; + @override + State createState() => _VideoItemLoaderState(); +} + +class _VideoItemLoaderState extends State { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return BoxLoadingIndicator( + child: Column( + children: [ + widget.isGridView ? Expanded(child: BoxTrail( + shape: BoxShape.rectangle, + height: 230, + ),) : + BoxTrail( + shape: BoxShape.rectangle, + height: 230, + ), + Padding( + padding: + const EdgeInsets.only(top: 10.0, bottom: 5, left: 13, right: 13), + child: Row( + children: [ + BoxTrail( + shape: BoxShape.circle, + height: 40, + width: 40, + ), + const SizedBox( + width: 10, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 10.0), + child: BoxTrail(), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const BoxTrail( + width: 90, + ), + const SizedBox( + width: 15, + ), + const BoxTrail( + width: 70, + ), + ], + ), + ], + )) + ], + ), + ), + ], + )); + } +} diff --git a/lib/src/widgets/cached_image.dart b/lib/src/widgets/cached_image.dart new file mode 100644 index 00000000..420071f8 --- /dev/null +++ b/lib/src/widgets/cached_image.dart @@ -0,0 +1,59 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +class CachedImage extends StatelessWidget { + const CachedImage( + {Key? key, + required this.imageUrl, + this.imageHeight, + this.imageWidth, + this.loadingIndicatorSize, + this.borderRadius, + this.fit}) + : super(key: key); + + final String? imageUrl; + final double? imageHeight; + final double? imageWidth; + final double? loadingIndicatorSize; + final BoxFit? fit; + final double? borderRadius; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).primaryColorLight == Colors.black + ? Colors.grey.shade400 + : Colors.grey.shade900, + borderRadius: BorderRadius.all(Radius.circular(borderRadius ?? 0))), + child: CachedNetworkImage( + imageUrl: imageUrl ?? '', + height: imageHeight, + width: imageWidth, + fit: fit ?? (imageHeight != null ? BoxFit.cover : null), + // progressIndicatorBuilder: (context, url, downloadProgress) => + // imageHeight != null + // ? Center( + // child: SizedBox( + // height: loadingIndicatorSize ?? 50, + // width: loadingIndicatorSize ?? 50, + // child: CircularProgressIndicator( + // value: downloadProgress.progress, + // strokeWidth: 1.5, + // ), + // ), + // ) + // : CircularProgressIndicator( + // value: downloadProgress.progress, + // strokeWidth: 1.5, + // ), + errorWidget: (context, url, error) => Image.asset( + 'assets/ctt-logo.png', + height: imageHeight, + width: imageWidth, + ), + ), + ); + } +} diff --git a/lib/src/widgets/confirmation_dialog.dart b/lib/src/widgets/confirmation_dialog.dart new file mode 100644 index 00000000..78d9958c --- /dev/null +++ b/lib/src/widgets/confirmation_dialog.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +class ConfirmationDialog extends StatelessWidget { + const ConfirmationDialog({ + required this.title, + required this.content, + required this.onConfirm, + }); + + final String title; + final String content; + final VoidCallback onConfirm; + + @override + Widget build(BuildContext context) { + return AlertDialog( + actionsPadding: const EdgeInsets.only(bottom: 20, right: 20), + title: Text( + title, + ), + content: Text( + content, + ), + actions: [ + DialogButton( + text: "No", + onPressed: () { + Navigator.pop(context); + }), + DialogButton( + text: "Yes", + onPressed: () { + Navigator.pop(context); + onConfirm(); + }, + ), + ], + ); + } +} + +class DialogButton extends StatelessWidget { + const DialogButton({ + required this.text, + required this.onPressed, + }); + + final String text; + final Function() onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 25, + child: TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + backgroundColor: Theme.of(context).primaryColor), + onPressed: onPressed, + child: Text( + text, + )), + ); + } +} diff --git a/lib/src/widgets/custom_circle_avatar.dart b/lib/src/widgets/custom_circle_avatar.dart index e8dfd9c6..3238b4d3 100644 --- a/lib/src/widgets/custom_circle_avatar.dart +++ b/lib/src/widgets/custom_circle_avatar.dart @@ -2,11 +2,12 @@ import 'package:flutter/material.dart'; class CustomCircleAvatar extends StatelessWidget { const CustomCircleAvatar( - {Key? key, required this.height, required this.width, required this.url}) + {Key? key, required this.height, required this.width, required this.url,this.color}) : super(key: key); final double height; final double width; final String url; + final Color? color; @override Widget build(BuildContext context) { @@ -15,6 +16,7 @@ class CustomCircleAvatar extends StatelessWidget { width: width, child: CircleAvatar( backgroundImage: NetworkImage(url), + backgroundColor:color ?? Colors.transparent, radius: 100, ), ); diff --git a/lib/src/widgets/fab_custom.dart b/lib/src/widgets/fab_custom.dart new file mode 100644 index 00000000..cb063eac --- /dev/null +++ b/lib/src/widgets/fab_custom.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +class FabCustom extends StatelessWidget { + const FabCustom({ + Key? key, + required this.icon, + required this.onTap, + }) : super(key: key); + final IconData icon; + final Function onTap; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const Spacer(), + Row( + children: [ + const Spacer(), + FloatingActionButton( + onPressed: () { + onTap(); + }, + child: Icon(icon), + ), + const SizedBox(width: 10), + ], + ), + const SizedBox(height: 10), + ], + ); + } +} diff --git a/lib/src/widgets/fab_overlay.dart b/lib/src/widgets/fab_overlay.dart new file mode 100644 index 00000000..0e40d7bf --- /dev/null +++ b/lib/src/widgets/fab_overlay.dart @@ -0,0 +1,104 @@ +import 'package:acela/src/widgets/custom_circle_avatar.dart'; +import 'package:flutter/material.dart'; + +class FabOverItemData { + String displayName; + IconData icon; + Function onTap; + String? url; + String? image; + + FabOverItemData({ + required this.displayName, + required this.icon, + required this.onTap, + this.url, + this.image + }); +} + +class FabOverlay extends StatelessWidget { + const FabOverlay({ + Key? key, + required this.items, + required this.onBackgroundTap, + }) : super(key: key); + final List items; + final Function onBackgroundTap; + + Widget _singleItem(BuildContext context, FabOverItemData data) { + late Widget child; + if (data.url != null) { + child = CustomCircleAvatar( + height: 40, + width: 40, + url: data.url!, + ); + } else if (data.image != null) { + child = Image.asset(data.image!, width: 30, height: 30); + } else { + child = Icon(data.icon); + } + return Column( + children: [ + const SizedBox(height: 5), + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(10)), + color: Theme + .of(context) + .colorScheme.background, + ), + child: Text(data.displayName), + ), + const SizedBox(width: 5), + FloatingActionButton( + mini: true, + onPressed: () { + data.onTap(); + }, + child: child + ), + ], + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + var widgets = items.map((e) => _singleItem(context, e)).toList(); + return InkWell( + onTap: () { + onBackgroundTap(); + }, + child: Container( + decoration: BoxDecoration( + color: Theme + .of(context) + .scaffoldBackgroundColor + .withAlpha(200), + ), + child: Row( + children: [ + const Spacer(), + Column( + children: [ + const Spacer(), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: widgets, + ), + const SizedBox(height: 10) + ], + ), + const SizedBox(width: 10), + ], + ), + ), + ); + } +} diff --git a/lib/src/widgets/full_screen_video_player.dart b/lib/src/widgets/full_screen_video_player.dart new file mode 100644 index 00000000..04e2bfd6 --- /dev/null +++ b/lib/src/widgets/full_screen_video_player.dart @@ -0,0 +1,60 @@ +import 'package:better_player/better_player.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class SPKFullScreenVideoPlayer extends StatefulWidget { + const SPKFullScreenVideoPlayer({Key? key, required this.playUrl}) : super(key: key); + final String playUrl; + + @override + _SPKFullScreenVideoPlayerState createState() => _SPKFullScreenVideoPlayerState(); +} + +class _SPKFullScreenVideoPlayerState extends State { + late BetterPlayerController _betterPlayerController; + + @override + void initState() { + BetterPlayerConfiguration betterPlayerConfiguration = + BetterPlayerConfiguration( + aspectRatio: 16 / 9, + // fit: BoxFit.contain, + autoPlay: true, + fullScreenByDefault: false, + controlsConfiguration: BetterPlayerControlsConfiguration( + enablePip: true, + enableFullscreen: false, + ), + deviceOrientationsOnFullScreen: [ + DeviceOrientation.portraitUp, + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeLeft, + DeviceOrientation.portraitDown, + ], + autoDetectFullscreenAspectRatio: true, + autoDetectFullscreenDeviceOrientation: true, + autoDispose: true, + expandToFill: true, + ); + BetterPlayerDataSource dataSource = BetterPlayerDataSource( + BetterPlayerDataSourceType.network, + widget.playUrl, + videoFormat: BetterPlayerVideoFormat.hls, + ); + _betterPlayerController = BetterPlayerController(betterPlayerConfiguration); + _betterPlayerController.setupDataSource(dataSource); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Full screen player'), + ), + body: BetterPlayer( + controller: _betterPlayerController, + ), + ); + } +} \ No newline at end of file diff --git a/lib/src/widgets/gql_feed_list_item.dart b/lib/src/widgets/gql_feed_list_item.dart new file mode 100644 index 00000000..bc7e2896 --- /dev/null +++ b/lib/src/widgets/gql_feed_list_item.dart @@ -0,0 +1,138 @@ + +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/video_details_screen/new_video_details_info.dart'; +import 'package:acela/src/utils/graphql/models/trending_feed_response.dart'; +import 'package:acela/src/screens/video_details_screen/video_details_view_model.dart'; +import 'package:acela/src/utils/routes/routes.dart'; +import 'package:acela/src/utils/seconds_to_duration.dart'; +import 'package:acela/src/widgets/custom_circle_avatar.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import 'package:timeago/timeago.dart' as timeago; + +class GQLFeedListItemWidget extends StatefulWidget { + const GQLFeedListItemWidget({ + Key? key, + required this.gqlFeedItem, + }) : super(key: key); + final GQLFeedItem gqlFeedItem; + + @override + State createState() => _GQLFeedListItemWidgetState(); +} + +class _GQLFeedListItemWidgetState extends State { + Widget listTile() { + String timeInString = widget.gqlFeedItem.createdAt != null + ? "📝 ${timeago.format(widget.gqlFeedItem.createdAt!)}" + : ""; + String durationString = widget.gqlFeedItem.spkvideo?.duration != null + ? " 🕚 ${Utilities.formatTime(widget.gqlFeedItem.spkvideo!.duration!.toInt())} " + : ""; + String viewsString = + widget.gqlFeedItem.stats?.numComments != null ? "💬 ${widget.gqlFeedItem.stats!.numComments} comments" : ""; + String author = widget.gqlFeedItem.author?.username ?? 'sagarkothari88'; + return Stack( + children: [ + ListTile( + tileColor: Colors.black, + contentPadding: EdgeInsets.zero, + title: Image.network( + widget.gqlFeedItem.spkvideo?.thumbnailUrl ?? '', + fit: BoxFit.cover, + height: 230, + ), + subtitle: ListTile( + contentPadding: EdgeInsets.all(2), + dense: true, + leading: InkWell( + child: CustomCircleAvatar( + width: 40, + height: 40, + url: server.userOwnerThumb(author), + ), + onTap: () { + context.pushNamed(Routes.userView, pathParameters: {'author': author}); + }, + ), + title: Padding( + padding: const EdgeInsets.only(bottom: 5.0), + child: Text(widget.gqlFeedItem.title ?? ''), + ), + subtitle: Row( + children: [ + InkWell( + child: Text('👤 $author'), + onTap: () { + context.pushNamed(Routes.userView, pathParameters: {'author': author}); + }, + ), + SizedBox(width: 10), + Text("\$ ${(widget.gqlFeedItem.stats?.totalHiveReward ?? 0.0).toStringAsFixed(3)} · 👍 ${widget.gqlFeedItem.stats?.numVotes ?? 0.0} · 🏷️ ${widget.gqlFeedItem.tags?.first ?? 'No Tag/Community'}"), + ], + ), + ), + onTap: () { + var viewModel = VideoDetailsViewModel( + author: author, + permlink: widget.gqlFeedItem.permlink ?? '', + ); + var screen = NewVideoDetailsInfo(appData: context.read(),item: widget.gqlFeedItem,); + var route = MaterialPageRoute(builder: (context) => screen); + Navigator.of(context).push(route); + }, + ), + Column( + children: [ + const SizedBox(height: 208), + Row( + children: [ + SizedBox(width: 5), + if (timeInString.isNotEmpty) + Container( + padding: EdgeInsets.all(2), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(6), + ), + child: Text(timeInString, + style: TextStyle(color: Colors.white)), + ), + Spacer(), + if (viewsString.isNotEmpty) + Container( + padding: EdgeInsets.all(2), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(6), + ), + child: Text(viewsString, + style: TextStyle(color: Colors.white)), + ), + Spacer(), + if (durationString.isNotEmpty) + Container( + padding: EdgeInsets.all(2), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(6), + ), + child: Text(durationString, + style: TextStyle(color: Colors.white)), + ), + SizedBox(width: 5), + ], + ) + ], + ) + ], + ); + } + + @override + Widget build(BuildContext context) { + return listTile(); + } +} diff --git a/lib/src/widgets/list_tile_video.dart b/lib/src/widgets/list_tile_video.dart index 20754629..39a188f5 100644 --- a/lib/src/widgets/list_tile_video.dart +++ b/lib/src/widgets/list_tile_video.dart @@ -1,102 +1,206 @@ -import 'package:acela/src/utils/form_factor.dart'; +import 'dart:convert'; + +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/models/hive_post_info/hive_post_info.dart'; +import 'package:acela/src/models/home_screen_feed_models/home_feed.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:provider/provider.dart'; + import 'custom_circle_avatar.dart'; -import '../utils/form_factor.dart'; -class ListTileVideo extends StatelessWidget { - const ListTileVideo( - {Key? key, - required this.placeholder, - required this.url, - required this.userThumbUrl, - required this.title, - required this.subtitle}) - : super(key: key); +class ListTileVideo extends StatefulWidget { + const ListTileVideo({ + Key? key, + required this.placeholder, + required this.url, + required this.userThumbUrl, + required this.title, + required this.subtitle, + required this.onUserTap, + required this.user, + required this.permlink, + required this.shouldResize, + required this.isIpfs, + }) : super(key: key); final String placeholder; final String url; final String userThumbUrl; final String title; final String subtitle; + final Function onUserTap; + final String user; + final String permlink; + final bool shouldResize; + final bool isIpfs; + + @override + State createState() => _ListTileVideoState(); +} + +class _ListTileVideoState extends State { + Future? _fetchHiveInfo; - Widget _commonContainer(BuildContext context, Widget child) { + Widget _errorIndicator() { return Container( - decoration: const BoxDecoration( - boxShadow: [ - BoxShadow( - // color: Colors.grey, - // blurRadius: 10, - blurStyle: BlurStyle.outer, - ) - ], + height: 220, + decoration: BoxDecoration( + image: DecorationImage( + image: Image.asset(widget.placeholder).image, + fit: BoxFit.fitWidth, + ), ), - child: child, ); } - Widget _listType(BuildContext context) { - double width = MediaQuery.of(context).size.width - 60 - 340; - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Image.network(url), - Container(width: 10), - SizedBox( - width: width, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: Theme.of(context).textTheme.headline5, - ), - Container(height: 10), - Text(subtitle, style: Theme.of(context).textTheme.headline6), - ], - ), - ) - ], + Future fetchHiveInfo( + String user, String permlink, String hiveApiUrl) async { + var request = http.Request('POST', Uri.parse('https://$hiveApiUrl')); + request.body = json.encode({ + "id": 1, + "jsonrpc": "2.0", + "method": "bridge.get_discussion", + "params": {"author": user, "permlink": permlink, "observer": ""} + }); + http.StreamedResponse response = await request.send(); + if (response.statusCode == 200) { + var string = await response.stream.bytesToString(); + var result = HivePostInfo.fromJsonString(string) + .result + .resultData + .where((element) => element.permlink == permlink) + .first; + var upVotes = result.activeVotes.where((e) => e.rshares > 0).length; + var downVotes = result.activeVotes.where((e) => e.rshares < 0).length; + return PayoutInfo( + payout: result.payout, + downVotes: downVotes, + upVotes: upVotes, + ); + } else { + print(response.reasonPhrase); + throw response.reasonPhrase ?? 'Could not load hive payout info'; + } + } + + Widget payoutInfo() { + return FutureBuilder( + future: _fetchHiveInfo, + builder: (builder, snapshot) { + if (snapshot.hasError) { + return const Text('Error loading hive payout info'); + } else if (snapshot.hasData && + snapshot.connectionState == ConnectionState.done) { + var data = snapshot.data as PayoutInfo; + String priceAndVotes = + "\$ ${data.payout?.toStringAsFixed(3)} · 👍 ${data.upVotes} · 👎 ${data.downVotes}"; + return Text(priceAndVotes, + style: Theme.of(context).textTheme.bodyMedium); + } else { + return const Text('Loading hive payout info'); + } + }, ); } Widget _thumbnailType(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - FadeInImage.assetNetwork( - placeholder: placeholder, - image: url, - fit: BoxFit.cover, - ), - Container( - padding: const EdgeInsets.all(10), - child: Row( + var isDarkMode = Provider.of(context); + return Container( + margin: EdgeInsets.all(3), + decoration: BoxDecoration(boxShadow: [ + BoxShadow( + color: isDarkMode ? Colors.black26 : Colors.black12, + spreadRadius: 3, + blurRadius: 3, + ) + ]), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( children: [ - CustomCircleAvatar(height: 40, width: 40, url: userThumbUrl), - Container(width: 5), - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: Theme.of(context).textTheme.bodyText2), - Container(height: 5), - Text(subtitle, style: Theme.of(context).textTheme.bodyText1) - ], + SizedBox( + height: 220, + width: MediaQuery.of(context).size.width, + child: FadeInImage.assetNetwork( + placeholder: widget.placeholder, + image: widget.shouldResize + ? server.resizedImage(widget.url) + : widget.url, + fit: BoxFit.fitWidth, + placeholderErrorBuilder: (BuildContext context, Object error, + StackTrace? stackTrace) { + return _errorIndicator(); + }, + imageErrorBuilder: (BuildContext context, Object error, + StackTrace? stackTrace) { + return _errorIndicator(); + }, ), - ) + ), + widget.isIpfs + ? Container( + margin: EdgeInsets.all(10), + child: Image.asset('assets/ipfs-logo.png', + width: 30, height: 30), + ) + : Container(), ], ), - ) - ], + Container( + padding: const EdgeInsets.all(3), + child: Row( + children: [ + SizedBox( + width: 50, + child: InkWell( + child: Column( + children: [ + CustomCircleAvatar( + height: 45, width: 45, url: widget.userThumbUrl), + SizedBox(height: 3), + Text(widget.user, + style: Theme.of(context).textTheme.bodyMedium), + ], + ), + onTap: () { + widget.onUserTap(); + }, + ), + ), + SizedBox(width: 5), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.title, + style: Theme.of(context).textTheme.bodyLarge), + SizedBox(height: 2), + Text(widget.subtitle, + style: Theme.of(context).textTheme.bodyMedium), + SizedBox(height: 2), + payoutInfo(), + ], + ), + ) + ], + ), + ), + ], + ), ); } @override Widget build(BuildContext context) { - ScreenType type = FormFactor.getFormFactor(context); - Widget widget = type == ScreenType.desktop || type == ScreenType.tablet - ? _listType(context) - : _thumbnailType(context); - return _commonContainer(context, widget); + var user = Provider.of(context); + if (_fetchHiveInfo == null) { + setState(() { + _fetchHiveInfo = fetchHiveInfo(widget.user, widget.permlink, user.rpc); + }); + } + return _thumbnailType(context); } } diff --git a/lib/src/widgets/loading_screen.dart b/lib/src/widgets/loading_screen.dart index 2742f840..72e412af 100644 --- a/lib/src/widgets/loading_screen.dart +++ b/lib/src/widgets/loading_screen.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; class LoadingScreen extends StatelessWidget { - const LoadingScreen({Key? key}) : super(key: key); + const LoadingScreen({Key? key, required this.title, required this.subtitle}) + : super(key: key); + final String title; + final String subtitle; @override Widget build(BuildContext context) { @@ -9,14 +12,26 @@ class LoadingScreen extends StatelessWidget { child: Column( children: [ const Spacer(), - const CircularProgressIndicator(value: null,), - const SizedBox(height: 20,), - Text('Loading Data', style: Theme.of(context).textTheme.bodyText1,), - const SizedBox(height: 10,), - Text('Please wait', style: Theme.of(context).textTheme.bodyText2,), + const CircularProgressIndicator( + value: null, + ), + const SizedBox( + height: 20, + ), + Text( + title, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox( + height: 10, + ), + Text( + subtitle, + style: Theme.of(context).textTheme.bodyMedium, + ), const Spacer(), ], ), ); } -} \ No newline at end of file +} diff --git a/lib/src/widgets/menu_circle_action_button.dart b/lib/src/widgets/menu_circle_action_button.dart new file mode 100644 index 00000000..22e83c73 --- /dev/null +++ b/lib/src/widgets/menu_circle_action_button.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +class MenuCircleActionButton extends StatelessWidget { + const MenuCircleActionButton( + {required this.text, + required this.icon, + this.backgroundColor, + required this.onTap}); + + final String text; + final IconData icon; + final Color? backgroundColor; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + GestureDetector( + onTap: onTap, + child: Container( + height: 60, + width: 60, + decoration: BoxDecoration( + color: backgroundColor ?? Colors.grey.shade800, + shape: BoxShape.circle, + border: Border.all(color: Colors.white30, width: 1), + ), + child: Icon( + icon, + color: Colors.white, + size: 28, + ), + ), + ), + const SizedBox( + height: 5, + ), + Text( + text, + style: + const TextStyle(color: Colors.white, fontWeight: FontWeight.w500), + ) + ], + ); + } +} diff --git a/lib/src/widgets/retry.dart b/lib/src/widgets/retry.dart index 401ee02b..abbcecb2 100644 --- a/lib/src/widgets/retry.dart +++ b/lib/src/widgets/retry.dart @@ -14,6 +14,8 @@ class RetryScreen extends StatelessWidget { Widget build(BuildContext context) { return Center( child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, children: [ ElevatedButton(onPressed: onRetry, child: const Text('Retry')), Text(error) diff --git a/lib/src/widgets/shorts_xlist_item.dart b/lib/src/widgets/shorts_xlist_item.dart new file mode 100644 index 00000000..01250600 --- /dev/null +++ b/lib/src/widgets/shorts_xlist_item.dart @@ -0,0 +1,154 @@ + +import 'package:acela/src/utils/seconds_to_duration.dart'; +import 'package:flutter/material.dart'; +import 'package:timeago/timeago.dart' as timeago; + +class ShortsXListItem extends StatefulWidget { + const ShortsXListItem({ + Key? key, + required this.createdAt, + required this.duration, + required this.views, + required this.thumbUrl, + required this.author, + required this.title, + required this.rpc, + required this.permlink, + required this.onTap, + required this.onUserTap, + }) : super(key: key); + + final DateTime? createdAt; + final double? duration; + final int? views; + final String thumbUrl; + final String author; + final String title; + final String rpc; + final String permlink; + final Function onTap; + final Function onUserTap; + + @override + State createState() => _ShortsXListItemState(); +} + +class _ShortsXListItemState extends State { + Widget listTile() { + String timeInString = widget.createdAt != null + ? "📝 ${timeago.format(widget.createdAt!)}" + : ""; + String durationString = widget.duration != null + ? " 🕚 ${Utilities.formatTime(widget.duration!.toInt())} " + : ""; + String viewsString = + widget.views != null ? "👁️ ${widget.views} views" : ""; + return Stack( + children: [ + ListTile( + tileColor: Colors.black, + contentPadding: EdgeInsets.zero, + title: Image.network( + widget.thumbUrl, + fit: BoxFit.fitHeight, + height: 130, + width: 65, + ), + // subtitle: ListTile( + // contentPadding: EdgeInsets.all(2), + // dense: true, + // leading: InkWell( + // child: CustomCircleAvatar( + // width: 40, + // height: 40, + // url: server.userOwnerThumb(widget.author), + // ), + // onTap: () { + // widget.onUserTap(); + // var screen = UserChannelScreen(owner: widget.author); + // var route = MaterialPageRoute(builder: (c) => screen); + // Navigator.of(context).push(route); + // }, + // ), + // title: Padding( + // padding: const EdgeInsets.only(bottom: 5.0), + // child: Text(widget.title), + // ), + // subtitle: Row( + // children: [ + // InkWell( + // child: Text('👤 ${widget.author}'), + // onTap: () { + // widget.onUserTap(); + // var screen = UserChannelScreen(owner: widget.author); + // var route = MaterialPageRoute(builder: (c) => screen); + // Navigator.of(context).push(route); + // }, + // ), + // SizedBox(width: 10), + // ], + // ), + // ), + onTap: () { + // widget.onTap(); + // var viewModel = VideoDetailsViewModel( + // author: widget.author, + // permlink: widget.permlink, + // ); + // var screen = VideoDetailsScreen(vm: viewModel); + // var route = MaterialPageRoute(builder: (context) => screen); + // Navigator.of(context).push(route); + }, + ), + // Column( + // children: [ + // const SizedBox(height: 208), + // Row( + // children: [ + // SizedBox(width: 5), + // if (timeInString.isNotEmpty) + // Container( + // padding: EdgeInsets.all(2), + // decoration: BoxDecoration( + // color: Colors.black, + // borderRadius: BorderRadius.circular(6), + // ), + // child: Text(timeInString, + // style: TextStyle(color: Colors.white)), + // ), + // Spacer(), + // if (viewsString.isNotEmpty) + // Container( + // padding: EdgeInsets.all(2), + // decoration: BoxDecoration( + // color: Colors.black, + // borderRadius: BorderRadius.circular(6), + // ), + // child: Text(viewsString, + // style: TextStyle(color: Colors.white)), + // ), + // Spacer(), + // if (durationString.isNotEmpty) + // Container( + // padding: EdgeInsets.all(2), + // decoration: BoxDecoration( + // color: Colors.black, + // borderRadius: BorderRadius.circular(6), + // ), + // child: Text(durationString, + // style: TextStyle(color: Colors.white)), + // ), + // SizedBox(width: 5), + // ], + // ) + // ], + // ) + ], + ); + } + + @override + Widget build(BuildContext context) { + return listTile(); + } +} diff --git a/lib/src/widgets/story_player.dart b/lib/src/widgets/story_player.dart new file mode 100644 index 00000000..2f55d6bb --- /dev/null +++ b/lib/src/widgets/story_player.dart @@ -0,0 +1,466 @@ +import 'dart:convert'; +import 'dart:developer'; +import 'package:acela/src/bloc/server.dart'; +import 'package:acela/src/global_provider/video_setting_provider.dart'; +import 'package:acela/src/screens/podcast/widgets/favourite.dart'; +import 'package:acela/src/screens/video_details_screen/comment/hive_comment_dialog.dart'; +import 'package:acela/src/screens/video_details_screen/new_video_details/video_detail_favourite_provider.dart'; +import 'package:acela/src/utils/graphql/models/trending_feed_response.dart'; +import 'package:acela/src/models/hive_post_info/hive_post_info.dart'; +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/login/ha_login_screen.dart'; +import 'package:acela/src/screens/video_details_screen/hive_upvote_dialog.dart'; +import 'package:acela/src/screens/video_details_screen/new_video_details_info.dart'; +import 'package:acela/src/screens/video_details_screen/comment/video_details_comments.dart'; +import 'package:acela/src/utils/communicator.dart'; +import 'package:acela/src/utils/routes/routes.dart'; +import 'package:adaptive_action_sheet/adaptive_action_sheet.dart'; +import 'package:better_player/better_player.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:http/http.dart' as http; +import 'package:timeago/timeago.dart' as timeago; + +class StoryPlayer extends StatefulWidget { + const StoryPlayer({ + Key? key, + required this.didFinish, + required this.item, + required this.data, + this.onRemoveFavouriteCallback, + }) : super(key: key); + final GQLFeedItem item; + final Function didFinish; + final HiveUserData data; + final VoidCallback? onRemoveFavouriteCallback; + + @override + _StoryPlayerState createState() => _StoryPlayerState(); +} + +class _StoryPlayerState extends State { + late BetterPlayerController _betterPlayerController; + HivePostInfoPostResultBody? postInfo; + bool controlsVisible = false; + late final VideoSettingProvider videoSettingProvider; + + var aspectRatio = 0.0; // 0.5625 + double? height; + double? width; + + @override + void dispose() { + super.dispose(); + _betterPlayerController.removeEventsListener(controlsVisibilityListenener); + _betterPlayerController.videoPlayerController! + .removeListener(_videoPlayerListener); + _betterPlayerController.dispose(); + } + + @override + void initState() { + videoSettingProvider = context.read(); + super.initState(); + updateRatio(); + loadHiveInfo(); + } + + void loadHiveInfo() async { + setState(() { + postInfo = null; + }); + var data = await fetchHiveInfoForThisVideo(widget.data.rpc); + setState(() { + postInfo = data; + }); + } + + Future fetchHiveInfoForThisVideo( + String hiveApiUrl) async { + var request = http.Request('POST', Uri.parse('https://$hiveApiUrl')); + request.body = json.encode({ + "id": 1, + "jsonrpc": "2.0", + "method": "bridge.get_discussion", + "params": { + "author": widget.item.author?.username ?? 'sagarkothari88', + "permlink": widget.item.permlink ?? 'ctbtwcxbbd', + "observer": "" + } + }); + http.StreamedResponse response = await request.send(); + if (response.statusCode == 200) { + var string = await response.stream.bytesToString(); + var result = HivePostInfo.fromJsonString(string) + .result + .resultData + .where((element) => + element.permlink == (widget.item.permlink ?? 'ctbtwcxbbd')) + .first; + return result; + } else { + print(response.reasonPhrase); + throw response.reasonPhrase ?? 'Can not load payout info'; + } + } + + void updateRatio() async { + var ratio = await Communicator().getAspectRatio(widget.item.hlsUrl); + setState(() { + aspectRatio = ratio.width / ratio.height; + setupPlayer(); + }); + } + + void setupPlayer() { + BetterPlayerConfiguration config = BetterPlayerConfiguration( + aspectRatio: aspectRatio, + fit: BoxFit.fitHeight, + autoPlay: true, + fullScreenByDefault: false, + deviceOrientationsOnFullScreen: [ + DeviceOrientation.portraitUp, + ], + autoDispose: true, + expandToFill: true, + controlsConfiguration: BetterPlayerControlsConfiguration( + showControls: true, + showControlsOnInitialize: false, + enableFullscreen: false, + enableMute: true), + showPlaceholderUntilPlay: true, + allowedScreenSleep: false, + eventListener: (event) { + log('type - ${event.betterPlayerEventType.toString()}'); + if (event.betterPlayerEventType == BetterPlayerEventType.finished) { + widget.didFinish(); + } + }, + ); + BetterPlayerDataSource dataSource = BetterPlayerDataSource( + BetterPlayerDataSourceType.network, + widget.item.videoV2M3U8(widget.data), + videoFormat: BetterPlayerVideoFormat.hls, + ); + setState(() { + _betterPlayerController = BetterPlayerController(config); + _betterPlayerController.setupDataSource(dataSource); + }); + if (videoSettingProvider.isMuted) { + _betterPlayerController.setVolume(0.0); + } + _betterPlayerController.videoPlayerController! + .addListener(_videoPlayerListener); + _betterPlayerController.addEventsListener(controlsVisibilityListenener); + } + + void _videoPlayerListener() { + if (_betterPlayerController.videoPlayerController != null && + _betterPlayerController.videoPlayerController!.value.initialized) { + if (_betterPlayerController.videoPlayerController!.value.volume == 0.0 && + !videoSettingProvider.isMuted) { + videoSettingProvider.changeMuteStatus(true); + } else if (_betterPlayerController.videoPlayerController!.value.volume != + 0.0 && + videoSettingProvider.isMuted) { + videoSettingProvider.changeMuteStatus(false); + } + } + } + + void controlsVisibilityListenener(BetterPlayerEvent p0) { + if (p0.betterPlayerEventType == BetterPlayerEventType.controlsVisible) { + if (!controlsVisible) { + setState(() { + controlsVisible = true; + }); + } + } else { + if (p0.betterPlayerEventType == BetterPlayerEventType.controlsHiddenEnd) { + if (controlsVisible) { + setState(() { + controlsVisible = false; + }); + } + } + } + } + + void showError(String string) { + var snackBar = SnackBar(content: Text('Error: $string')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + void seeCommentsPressed() { + _betterPlayerController.pause(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) { + return VideoDetailsComments( + appData: widget.data, + item: widget.item, + author: widget.item.author?.username ?? 'sagarkothari88', + permlink: widget.item.permlink ?? 'ctbtwcxbbd', + rpc: widget.data.rpc, + ); + }, + ), + ); + } + + void upvotePressed() { + if (postInfo == null) return; + if (widget.data.username == null) { + _betterPlayerController.pause(); + showAdaptiveActionSheet( + context: context, + title: const Text('You are not logged in. Please log in to upvote.'), + androidBorderRadius: 30, + actions: [ + BottomSheetAction( + title: Text('Log in'), + leading: Icon(Icons.login), + onPressed: (c) { + Navigator.of(c).pop(); + var screen = HiveAuthLoginScreen(appData: widget.data); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(c).push(route); + }), + ], + cancelAction: CancelAction(title: const Text('Cancel')), + ); + return; + } + if (postInfo!.activeVotes + .map((e) => e.voter) + .contains(widget.data.username ?? 'sagarkothari88') == + true) { + showError('You have already voted for this 3Shorts'); + } + _betterPlayerController.pause(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + clipBehavior: Clip.hardEdge, + builder: (context) { + return SizedBox( + height: MediaQuery.of(context).size.height * 0.4, + child: HiveUpvoteDialog( + author: widget.item.author?.username ?? 'sagarkothari88', + permlink: widget.item.permlink ?? 'ctbtwcxbbd', + username: widget.data.username ?? "", + hasKey: widget.data.keychainData?.hasId ?? "", + hasAuthKey: widget.data.keychainData?.hasAuthKey ?? "", + accessToken: widget.data.accessToken, + postingAuthority: widget.data.postingAuthority, + activeVotes: postInfo!.activeVotes, + onClose: () {}, + onDone: () { + setState(() { + postInfo = postInfo!.copyWith(activeVotes: [ + ...postInfo!.activeVotes, + ActiveVotesItem(voter: widget.data.username!) + ]); + }); + }, + ), + ); + }, + ); + } + + List _fabButtonsOnRight() { + final VideoFavoriteProvider provider = VideoFavoriteProvider(); + return [ + FavouriteWidget( + toastType: "Video Short", + iconColor: Colors.blue, + isLiked: + provider.isLikedVideoPresentLocally(widget.item, isShorts: true), + onAdd: () { + provider.storeLikedVideoLocally(widget.item, isShorts: true); + }, + onRemove: () { + provider.storeLikedVideoLocally(widget.item, isShorts: true); + if (widget.onRemoveFavouriteCallback != null) + widget.onRemoveFavouriteCallback!(); + }), + IconButton( + icon: Icon(Icons.share, color: Colors.blue), + onPressed: () { + _betterPlayerController.pause(); + Share.share( + 'https://3speak.tv/watch?v=${widget.item.author?.username ?? ''}/${widget.item.permlink ?? ''}'); + }, + ), + SizedBox(height: 10), + IconButton( + icon: Icon(Icons.info, color: Colors.blue), + onPressed: () { + _betterPlayerController.pause(); + var screen = NewVideoDetailsInfo( + appData: widget.data, + item: widget.item, + ); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(context).push(route); + }, + ), + SizedBox(height: 10), + IconButton( + icon: Icon(Icons.comment, color: Colors.blue), + onPressed: () { + seeCommentsPressed(); + }, + ), + SizedBox(height: 10), + IconButton( + onPressed: () { + if (postInfo != null) { + upvotePressed(); + } + }, + icon: Icon(isVoted ? Icons.thumb_up : Icons.thumb_up_outlined, + color: Colors.blue), + ), + SizedBox(height: 10), + IconButton( + icon: Icon(Icons.fullscreen, color: Colors.blue), + onPressed: () async { + _betterPlayerController.pause(); + var position = + await _betterPlayerController.videoPlayerController?.position; + debugPrint('position is $position'); + var seconds = position?.inSeconds; + if (seconds == null) return; + const platform = MethodChannel('com.example.acela/auth'); + await platform.invokeMethod('playFullscreen', { + 'url': widget.item.videoV2M3U8(widget.data), + 'seconds': seconds, + }); + }, + ), + SizedBox(height: 10), + ]; + } + + bool get isVoted { + if (widget.data.username == null) { + return false; + } else if (postInfo != null && + postInfo!.activeVotes + .contains(ActiveVotesItem(voter: widget.data.username!))) { + return true; + } + + return false; + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Stack( + children: [ + aspectRatio == 0.0 + ? Center(child: CircularProgressIndicator()) + : BetterPlayer( + controller: _betterPlayerController, + ), + Visibility( + visible: !controlsVisible, + child: Padding( + padding: const EdgeInsets.only(left: 5.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: IconButton( + icon: Row( + children: [ + ClipOval( + child: CachedNetworkImage( + height: 40, + width: 40, + imageUrl: server.userOwnerThumb( + widget.item.author?.username ?? + 'sagarkothari88'), + progressIndicatorBuilder: + (context, url, progress) => Container( + padding: EdgeInsets.all(8), + height: 40, + width: 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.blue)), + child: CircularProgressIndicator( + strokeWidth: 1, + ), + ), + errorWidget: (context, url, error) => Container( + height: 40, + width: 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.blue)), + ), + ), + ), + const SizedBox( + width: 15, + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.item.author!.username!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + Text( + "${timeago.format(widget.item.createdAt!)}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12), + ), + ], + ), + ) + ], + ), + onPressed: () { + context.pushNamed(Routes.userView, pathParameters: { + 'author': + widget.item.author?.username ?? 'sagarkothari88' + }); + }, + ), + ), + const SizedBox( + width: 35, + ), + Container( + decoration: BoxDecoration( + color: Colors.black54, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.end, + children: _fabButtonsOnRight(), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/widgets/upvote_button.dart b/lib/src/widgets/upvote_button.dart new file mode 100644 index 00000000..e95c1773 --- /dev/null +++ b/lib/src/widgets/upvote_button.dart @@ -0,0 +1,103 @@ +import 'package:acela/src/models/user_stream/hive_user_stream.dart'; +import 'package:acela/src/screens/login/ha_login_screen.dart'; +import 'package:acela/src/screens/video_details_screen/hive_upvote_dialog.dart'; +import 'package:acela/src/utils/graphql/models/trending_feed_response.dart'; +import 'package:adaptive_action_sheet/adaptive_action_sheet.dart'; +import 'package:flutter/material.dart'; + +class UpvoteButton extends StatefulWidget { + const UpvoteButton( + {Key? key, required this.appData, required this.item, this.votes}) + : super(key: key); + + final HiveUserData appData; + final GQLFeedItem item; + final int? votes; + + @override + State createState() => _UpvoteButtonState(); +} + +class _UpvoteButtonState extends State { + late int votes; + @override + void initState() { + votes = widget.votes ?? 0; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 1.0), + child: Row( + children: [ + SizedBox( + height: 15, + width: 25, + child: IconButton( + padding: EdgeInsets.zero, + constraints: BoxConstraints(), + onPressed: upvotePressed, + icon: Icon( + Icons.thumb_up_sharp, + size: 14, + ), + ), + ), + Text( + ' $votes', + style: TextStyle( + color: Theme.of(context).primaryColorLight.withOpacity(0.7), + fontSize: 12), + ), + ], + ), + ); + } + + void upvotePressed() { + if (widget.appData.username == null) { + showAdaptiveActionSheet( + context: context, + title: const Text('You are not logged in. Please log in to upvote.'), + androidBorderRadius: 30, + actions: [ + BottomSheetAction( + title: Text('Log in'), + leading: Icon(Icons.login), + onPressed: (c) { + Navigator.of(c).pop(); + var screen = HiveAuthLoginScreen(appData: widget.appData); + var route = MaterialPageRoute(builder: (c) => screen); + Navigator.of(c).push(route); + }), + ], + cancelAction: CancelAction(title: const Text('Cancel')), + ); + return; + } + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) { + return HiveUpvoteDialog( + author: widget.item.author!.username ?? 'sagarkothari88', + permlink: widget.item.permlink ?? 'ctbtwcxbbd', + username: widget.appData.username ?? "", + hasKey: widget.appData.keychainData?.hasId ?? "", + hasAuthKey: widget.appData.keychainData?.hasAuthKey ?? "", + accessToken: widget.appData.accessToken, + postingAuthority: widget.appData.postingAuthority, + activeVotes: [], + onClose: () {}, + onDone: () { + setState(() { + votes++; + }); + }, + ); + }, + ); + } +} diff --git a/lib/src/widgets/user_profile_image.dart b/lib/src/widgets/user_profile_image.dart new file mode 100644 index 00000000..bfda8169 --- /dev/null +++ b/lib/src/widgets/user_profile_image.dart @@ -0,0 +1,29 @@ +import 'package:acela/src/bloc/server.dart'; +import 'package:flutter/material.dart'; + +class UserProfileImage extends StatelessWidget { + const UserProfileImage({Key? key, required this.userName, this.radius}) + : super(key: key); + + final String userName; + final double? radius; + + @override + Widget build(BuildContext context) { + return Container( + height: radius ?? 40, + width: radius ?? 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.grey.shade800, + image: userName.isNotEmpty + ? DecorationImage( + fit: BoxFit.cover, + image: NetworkImage( + server.userOwnerThumb(userName), + ), + ) + : null), + ); + } +} diff --git a/lib/src/widgets/video_player.dart b/lib/src/widgets/video_player.dart new file mode 100644 index 00000000..ce852c1d --- /dev/null +++ b/lib/src/widgets/video_player.dart @@ -0,0 +1,55 @@ +// import 'package:video_player/video_player.dart'; +// import 'package:chewie/chewie.dart'; + + +import 'package:flutter/material.dart'; + +import 'package:better_player/better_player.dart'; + +class SPKVideoPlayer extends StatefulWidget { + const SPKVideoPlayer({Key? key, required this.playUrl}) : super(key: key); + final String playUrl; + + @override + _SPKVideoPlayerState createState() => _SPKVideoPlayerState(); +} + +class _SPKVideoPlayerState extends State { + late BetterPlayerController _betterPlayerController; + + @override + void initState() { + BetterPlayerConfiguration betterPlayerConfiguration = + BetterPlayerConfiguration( + aspectRatio: 16 / 9, + fit: BoxFit.contain, + autoPlay: true, + fullScreenByDefault: false, + controlsConfiguration: BetterPlayerControlsConfiguration( + enablePip: true, + enableFullscreen: false, + enableSkips: true, + pipMenuIcon: Icons.picture_in_picture, + ), + autoDetectFullscreenAspectRatio: false, + autoDetectFullscreenDeviceOrientation: false, + autoDispose: true, + expandToFill: true, + ); + BetterPlayerDataSource dataSource = BetterPlayerDataSource( + BetterPlayerDataSourceType.network, + widget.playUrl, + videoFormat: BetterPlayerVideoFormat.hls, + ); + _betterPlayerController = BetterPlayerController(betterPlayerConfiguration); + _betterPlayerController.setupDataSource(dataSource); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return BetterPlayer( + controller: _betterPlayerController, + ); + } +} \ No newline at end of file diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817a..d634c50e 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,42 @@ import FlutterMacOS import Foundation +import assets_audio_player +import audio_service +import audio_session +import device_info_plus +import ffmpeg_kit_flutter_https_gpl +import file_selector_macos +import flutter_secure_storage_macos +import just_audio +import package_info_plus +import path_provider_foundation +import share_plus +import shared_preferences_foundation +import sqflite_darwin +import url_launcher_macos +import video_compress +import video_player_avfoundation +import wakelock_plus +import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AssetsAudioPlayerPlugin.register(with: registry.registrar(forPlugin: "AssetsAudioPlayerPlugin")) + AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin")) + AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FFmpegKitFlutterPlugin.register(with: registry.registrar(forPlugin: "FFmpegKitFlutterPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + VideoCompressPlugin.register(with: registry.registrar(forPlugin: "VideoCompressPlugin")) + FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) + FLTWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "FLTWebViewFlutterPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 556f6e06..c9fe6abc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,292 +1,1833 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "88399e291da5f7e889359681a8f64b18c5123e03576b01f32a6a276611e511c3" + url: "https://pub.dev" + source: hosted + version: "78.0.0" + _macros: + dependency: transitive + description: dart + source: sdk + version: "0.3.3" + adaptive_action_sheet: + dependency: "direct main" + description: + name: adaptive_action_sheet + sha256: "35190411e7136a3c244d58accdf254ac7158eac38e57a62d85b0477a7988f1c5" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "62899ef43d0b962b056ed2ebac6b47ec76ffd003d5f7c4e4dc870afe63188e33" + url: "https://pub.dev" + source: hosted + version: "7.1.0" + archive: + dependency: "direct main" + description: + name: archive + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + url: "https://pub.dev" + source: hosted + version: "3.6.1" args: dependency: transitive description: - name: args - url: "https://pub.dartlang.org" + name: args + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + url: "https://pub.dev" + source: hosted + version: "2.6.0" + assets_audio_player: + dependency: "direct main" + description: + path: "." + ref: master + resolved-ref: "28a7f52db65fd8b8ac0bdc39013d3de978c8a375" + url: "https://github.com/florent37/Flutter-AssetsAudioPlayer.git" + source: git + version: "3.1.1" + async: + dependency: transitive + description: + name: async + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + url: "https://pub.dev" + source: hosted + version: "2.12.0" + audio_service: + dependency: "direct main" + description: + name: audio_service + sha256: f6c8191bef6b843da34675dd0731ad11d06094c36b691ffcf3148a4feb2e585f + url: "https://pub.dev" + source: hosted + version: "0.18.16" + audio_service_platform_interface: + dependency: transitive + description: + name: audio_service_platform_interface + sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + audio_service_web: + dependency: transitive + description: + name: audio_service_web + sha256: "4cdc2127cd4562b957fb49227dc58e3303fafb09bde2573bc8241b938cf759d9" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + audio_session: + dependency: transitive + description: + name: audio_session + sha256: b2a26ba8b7efa1790d6460e82971fde3e398cfbe2295df9dea22f3499d2c12a7 + url: "https://pub.dev" + source: hosted + version: "0.1.23" + auto_scroll_text: + dependency: "direct main" + description: + name: auto_scroll_text + sha256: "8de28056f844f24f13771606417ffa109397f75a66440fe60ec2d38c133e16dc" + url: "https://pub.dev" + source: hosted + version: "0.0.7" + auto_size_text: + dependency: "direct main" + description: + name: auto_size_text + sha256: "3f5261cd3fb5f2a9ab4e2fc3fba84fd9fcaac8821f20a1d4e71f557521b22599" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + better_player: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: fae11a11ebd752289c8126f51ca802453f57f9bd + url: "https://github.com/RAMb002/betterplayer.git" + source: git + version: "0.0.83" + bloc: + dependency: transitive + description: + name: bloc + sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e" + url: "https://pub.dev" + source: hosted + version: "8.1.4" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "294a2edaf4814a378725bfe6358210196f5ea37af89ecd81bfa32960113d4948" + url: "https://pub.dev" + source: hosted + version: "4.0.3" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "99d3980049739a985cf9b21f30881f46db3ebc62c5b8d5e60e27440876b1ba1e" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "74691599a5bc750dc96a6b4bfd48f7d9d66453eab04c7f4063134800d6a5c573" + url: "https://pub.dev" + source: hosted + version: "2.4.14" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" + url: "https://pub.dev" + source: hosted + version: "8.0.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "28a712df2576b63c6c005c465989a348604960c0958d28be5303ba9baa841ac2" + url: "https://pub.dev" + source: hosted + version: "8.9.3" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f" + url: "https://pub.dev" + source: hosted + version: "3.3.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "9e90e78ae72caa874a323d78fa6301b3fb8fa7ea76a8f96dc5b5bf79f283bf2f" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "205d6a9f1862de34b93184f22b9d2d94586b2f05c581d546695e3d8f6a805cd7" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + carousel_slider: + dependency: "direct main" + description: + name: carousel_slider + sha256: "7b006ec356205054af5beaef62e2221160ea36b90fb70a35e4deacd49d0349ae" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + cassowary: + dependency: transitive + description: + name: cassowary + sha256: f304452beaf93b9349daaeeda23f853578c9dd8674c06c6100fda0319c46b967 + url: "https://pub.dev" + source: hosted + version: "0.4.3" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://pub.dev" + source: hosted + version: "2.0.3" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + url: "https://pub.dev" + source: hosted + version: "4.10.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + croppy: + dependency: "direct main" + description: + name: croppy + sha256: "14bb40fd6c1771b093a907ddbf24df9aa49a4e6e379dd630602eb446e30ec629" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + csslib: + dependency: transitive + description: + name: csslib + sha256: "831883fb353c8bdc1d71979e5b342c7d88acfbc643113c14ae51e2442ea0f20f" + url: "https://pub.dev" + source: hosted + version: "0.17.3" + cupertino_icons: + dependency: transitive + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dart_rss: + dependency: "direct main" + description: + name: dart_rss + sha256: "9aee5c0713a48ff55e48752db68cfb2b1dfdff9c5c81adb5993492fb604f5e02" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "27eb0ae77836989a3bc541ce55595e8ceee0992807f14511552a898ddd0d88ac" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + dbus: + dependency: transitive + description: + name: dbus + sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: a7fd703482b391a87d60b6061d04dfdeab07826b96f9abd8f5ed98068acc0074 + url: "https://pub.dev" + source: hosted + version: "10.1.2" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: "0b04e02b30791224b31969eb1b50d723498f402971bff3630bca2ba839bd1ed2" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + url: "https://pub.dev" + source: hosted + version: "1.3.2" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + ffmpeg_kit_flutter_https_gpl: + dependency: "direct main" + description: + name: ffmpeg_kit_flutter_https_gpl + sha256: "004d69b4fff606ff4289ca942df9ad42385868da55141ef2ee0cbbceaa4b7d9a" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + ffmpeg_kit_flutter_platform_interface: + dependency: transitive + description: + name: ffmpeg_kit_flutter_platform_interface + sha256: addf046ae44e190ad0101b2fde2ad909a3cd08a2a109f6106d2f7048b7abedee + url: "https://pub.dev" + source: hosted + version: "0.2.1" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: be325344c1f3070354a1d84a231a1ba75ea85d413774ec4bdf444c023342e030 + url: "https://pub.dev" + source: hosted + version: "5.5.0" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" + url: "https://pub.dev" + source: hosted + version: "0.9.3+2" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "271ab9986df0c135d45c3cdb6bd0faa5db6f4976d3e4b437cf7d0f258d941bfc" + url: "https://pub.dev" + source: hosted + version: "0.9.4+2" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.dev" + source: hosted + version: "2.6.2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "8f5d2f6590d51ecd9179ba39c64f722edc15226cc93dcc8698466ad36a4a85a4" + url: "https://pub.dev" + source: hosted + version: "0.9.3+3" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_bloc: + dependency: transitive + description: + name: flutter_bloc + sha256: b594505eac31a0518bdcb4b5b79573b8d9117b193cc80cc12e17d639b10aa27a + url: "https://pub.dev" + source: hosted + version: "8.1.6" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "8207f27539deb83732fdda03e259349046a39a4c767269285f449ade355d54ba" + url: "https://pub.dev" + source: hosted + version: "3.3.1" + flutter_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b + url: "https://pub.dev" + source: hosted + version: "5.2.1" + flutter_downloader: + dependency: "direct main" + description: + name: flutter_downloader + sha256: b6da5495b6258aa7c243d0f0a5281e3430b385bccac11cc508f981e653b25aa6 + url: "https://pub.dev" + source: hosted + version: "1.11.8" + flutter_expandable_fab: + dependency: "direct main" + description: + name: flutter_expandable_fab + sha256: "85275279d19faf4fbe5639dc1f139b4555b150e079d056f085601a45688af12c" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + flutter_markdown: + dependency: "direct main" + description: + name: flutter_markdown + sha256: e37f4c69a07b07bb92622ef6b131a53c9aae48f64b176340af9e8e5238718487 + url: "https://pub.dev" + source: hosted + version: "0.7.5" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "615a505aef59b151b46bbeef55b36ce2b6ed299d160c51d84281946f0aa0ce0e" + url: "https://pub.dev" + source: hosted + version: "2.0.24" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "22dbf16f23a4bcf9d35e51be1c84ad5bb6f627750565edd70dab70f3ff5fff8f" + url: "https://pub.dev" + source: hosted + version: "8.1.0" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: bf7404619d7ab5c0a1151d7c4e802edad8f33535abfbeff2f9e1fe1274e2d705 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "38f9501c7cb6f38961ef0e1eacacee2b2d4715c63cc83fe56449c4d3d0b47255" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_spinkit: + dependency: "direct main" + description: + name: flutter_spinkit + sha256: d2696eed13732831414595b98863260e33e8882fc069ee80ec35d4ac9ddb0472 + url: "https://pub.dev" + source: hosted + version: "5.2.1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_widget_from_html_core: + dependency: transitive + description: + name: flutter_widget_from_html_core + sha256: e8f4f8b461a140ffb7c71f938bc76efc758893e7468843d9dbf70cb0b9e900cb + url: "https://pub.dev" + source: hosted + version: "0.8.5+3" + font_awesome_flutter: + dependency: "direct main" + description: + name: font_awesome_flutter + sha256: d3a89184101baec7f4600d58840a764d2ef760fe1c5a20ef9e6b0e9b24a07a3a + url: "https://pub.dev" + source: hosted + version: "10.8.0" + fraction: + dependency: "direct main" + description: + name: fraction + sha256: "7804c9a73d26bd3d5ccf52b7225eecd0af4e33b310729726dc8f8bb14c217716" + url: "https://pub.dev" + source: hosted + version: "5.0.4" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + fwfh_text_style: + dependency: transitive + description: + name: fwfh_text_style + sha256: "5f8b587fd223a6bf14aad3d3da5e7ced0628becbd0768f8e7ae25ff6b9f3d2ec" + url: "https://pub.dev" + source: hosted + version: "2.23.8" + gap: + dependency: "direct main" + description: + name: gap + sha256: f19387d4e32f849394758b91377f9153a1b41d79513ef7668c088c77dbc6955d + url: "https://pub.dev" + source: hosted + version: "3.0.1" + get: + dependency: transitive + description: + name: get + sha256: e4e7335ede17452b391ed3b2ede016545706c01a02292a6c97619705e7d2a85e + url: "https://pub.dev" + source: hosted + version: "4.6.6" + get_storage: + dependency: "direct main" + description: + name: get_storage + sha256: "39db1fffe779d0c22b3a744376e86febe4ade43bf65e06eab5af707dc84185a2" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "7c2d40b59890a929824f30d442e810116caf5088482629c894b9e4478c67472d" + url: "https://pub.dev" + source: hosted + version: "14.6.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + html: + dependency: transitive + description: + name: html + sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" + url: "https://pub.dev" + source: hosted + version: "0.15.4" + http: + dependency: "direct main" + description: + name: http + sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2" + url: "https://pub.dev" + source: hosted + version: "0.13.6" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: "direct main" + description: + name: image + sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d + url: "https://pub.dev" + source: hosted + version: "4.3.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: b62d34a506e12bb965e824b6db4fbf709ee4589cf5d3e99b45ab2287b008ee0c + url: "https://pub.dev" + source: hosted + version: "0.8.12+20" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100" + url: "https://pub.dev" + source: hosted + version: "0.8.12+2" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "3f5ad1e8112a9a6111c46d0b57a7be2286a9a07fc6e1976fdf5be2bd31d4ff62" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0" + url: "https://pub.dev" + source: hosted + version: "2.10.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + images_picker: + dependency: "direct main" + description: + name: images_picker + sha256: cc99347a6fa93228bf92f15ce36e4474256e4af0e6c0e1f0e7e9f047adbccd5b + url: "https://pub.dev" + source: hosted + version: "1.2.11" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + url: "https://pub.dev" + source: hosted + version: "0.18.1" + inview_notifier_list: + dependency: "direct main" + description: + name: inview_notifier_list + sha256: "1ca80ee39aa585e84a4b9dc1fe7211c5f64614ce3064b0007d9396073e852e14" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: "8f52361c07497a7f2c16c13aac159f9be6fb12b1d67719eac98a21d9a205d571" + url: "https://pub.dev" + source: hosted + version: "6.9.2" + just_audio: + dependency: "direct main" + description: + name: just_audio + sha256: a49e7120b95600bd357f37a2bb04cd1e88252f7cdea8f3368803779b925b1049 + url: "https://pub.dev" + source: hosted + version: "0.9.42" + just_audio_platform_interface: + dependency: transitive + description: + name: just_audio_platform_interface + sha256: "0243828cce503c8366cc2090cefb2b3c871aa8ed2f520670d76fd47aa1ab2790" + url: "https://pub.dev" + source: hosted + version: "4.3.0" + just_audio_web: + dependency: transitive + description: + name: just_audio_web + sha256: "9a98035b8b24b40749507687520ec5ab404e291d2b0937823ff45d92cb18d448" + url: "https://pub.dev" + source: hosted + version: "0.4.13" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + url: "https://pub.dev" + source: hosted + version: "10.0.8" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + url: "https://pub.dev" + source: hosted + version: "3.0.9" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + localstorage: + dependency: "direct main" + description: + name: localstorage + sha256: fdff4f717114e992acfd4045dc4a9ab9b987ca57f020965d63e3eb34089c60d8 + url: "https://pub.dev" + source: hosted + version: "4.0.1+4" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + macros: + dependency: transitive + description: + name: macros + sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" + url: "https://pub.dev" + source: hosted + version: "0.1.3-main.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: ef2a1298144e3f985cc736b22e0ccdaf188b5b3970648f2d9dc13efd1d9df051 + url: "https://pub.dev" + source: hosted + version: "7.2.2" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + miniplayer: + dependency: "direct main" + description: + name: miniplayer + sha256: "6e12c27aef7432fc16508460a6dc824f3edfeb01761bd0dbfbccc84d516121bf" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + omni_datetime_picker: + dependency: "direct main" + description: + name: omni_datetime_picker + sha256: db3d92e23513a34655adf7706f258c1b6049984aed0b78f8f274a6222c71360e + url: "https://pub.dev" + source: hosted + version: "2.0.4" + os_detect: + dependency: transitive + description: + name: os_detect + sha256: e704fb99aa30b2b9a284d87a28eef9ba262f68c25c963d5eb932f54cad07784f + url: "https://pub.dev" + source: hosted + version: "2.0.2" + overlay_support: + dependency: "direct main" + description: + name: overlay_support + sha256: fc39389bfd94e6985e1e13b2a88a125fc4027608485d2d4e2847afe1b2bb339c + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "70c421fe9d9cc1a9a7f3b05ae56befd469fe4f8daa3b484823141a55442d858d" + url: "https://pub.dev" + source: hosted + version: "8.1.2" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: a5ef9986efc7bf772f2696183a3992615baa76c1ffb1189318dd8803778fb05b + url: "https://pub.dev" + source: hosted + version: "3.0.2" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" + url: "https://pub.dev" source: hosted - version: "2.3.0" - async: + version: "2.2.15" + path_provider_foundation: dependency: transitive description: - name: async - url: "https://pub.dartlang.org" + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" source: hosted - version: "2.8.2" - boolean_selector: + version: "2.4.1" + path_provider_linux: dependency: transitive description: - name: boolean_selector - url: "https://pub.dartlang.org" + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" source: hosted - version: "2.1.0" - characters: + version: "2.2.1" + path_provider_platform_interface: dependency: transitive description: - name: characters - url: "https://pub.dartlang.org" + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" source: hosted - version: "1.2.0" - charcode: + version: "2.1.2" + path_provider_windows: dependency: transitive description: - name: charcode - url: "https://pub.dartlang.org" + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" source: hosted - version: "1.3.1" - clock: + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb" + url: "https://pub.dev" + source: hosted + version: "11.3.1" + permission_handler_android: dependency: transitive description: - name: clock - url: "https://pub.dartlang.org" + name: permission_handler_android + sha256: "71bbecfee799e65aff7c744761a57e817e73b738fedf62ab7afd5593da21f9f1" + url: "https://pub.dev" source: hosted - version: "1.1.0" - collection: + version: "12.0.13" + permission_handler_apple: dependency: transitive description: - name: collection - url: "https://pub.dartlang.org" + name: permission_handler_apple + sha256: e6f6d73b12438ef13e648c4ae56bd106ec60d17e90a59c4545db6781229082a0 + url: "https://pub.dev" source: hosted - version: "1.15.0" - csslib: + version: "9.4.5" + permission_handler_html: dependency: transitive description: - name: csslib - url: "https://pub.dartlang.org" + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" source: hosted - version: "0.17.1" - fake_async: + version: "0.1.3+5" + permission_handler_platform_interface: dependency: transitive description: - name: fake_async - url: "https://pub.dartlang.org" + name: permission_handler_platform_interface + sha256: e9c8eadee926c4532d0305dff94b85bf961f16759c3af791486613152af4b4f9 + url: "https://pub.dev" source: hosted - version: "1.2.0" - firebase_core: - dependency: "direct main" + version: "4.2.3" + permission_handler_windows: + dependency: transitive description: - name: firebase_core - url: "https://pub.dartlang.org" + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" source: hosted - version: "1.12.0" - firebase_core_platform_interface: + version: "0.2.1" + petitparser: dependency: transitive description: - name: firebase_core_platform_interface - url: "https://pub.dartlang.org" + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" source: hosted - version: "4.2.4" - firebase_core_web: + version: "6.0.2" + platform: dependency: transitive description: - name: firebase_core_web - url: "https://pub.dartlang.org" + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" source: hosted - version: "1.5.4" - flutter: + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + pretty_qr_code: dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" description: - name: flutter_lints - url: "https://pub.dartlang.org" + name: pretty_qr_code + sha256: cbdb4af29da1c1fa21dd76f809646c591320ab9e435d3b0eab867492d43607d5 + url: "https://pub.dev" source: hosted - version: "1.0.4" - flutter_markdown: + version: "3.3.0" + provider: dependency: "direct main" description: - name: flutter_markdown - url: "https://pub.dartlang.org" + name: provider + sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + url: "https://pub.dev" source: hosted - version: "0.6.9" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: + version: "6.1.2" + pub_semver: dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - html: + description: + name: pub_semver + sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + pubspec_parse: dependency: transitive description: - name: html - url: "https://pub.dartlang.org" + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" source: hosted - version: "0.15.0" - http: + version: "1.5.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: dependency: "direct main" description: - name: http - url: "https://pub.dartlang.org" + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" source: hosted - version: "0.13.4" - http_parser: + version: "4.1.0" + rxdart: dependency: transitive description: - name: http_parser - url: "https://pub.dartlang.org" + name: rxdart + sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + url: "https://pub.dev" source: hosted - version: "4.0.0" - js: + version: "0.27.7" + scrollable_positioned_list: + dependency: "direct main" + description: + name: scrollable_positioned_list + sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287" + url: "https://pub.dev" + source: hosted + version: "0.3.8" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "6327c3f233729374d0abaafd61f6846115b2a481b4feddd8534211dc10659400" + url: "https://pub.dev" + source: hosted + version: "10.1.3" + share_plus_platform_interface: dependency: transitive description: - name: js - url: "https://pub.dartlang.org" + name: share_plus_platform_interface + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + url: "https://pub.dev" source: hosted - version: "0.6.3" - lints: + version: "5.0.2" + shared_preferences: dependency: transitive description: - name: lints - url: "https://pub.dartlang.org" + name: shared_preferences + sha256: a752ce92ea7540fc35a0d19722816e04d0e72828a4200e83a98cf1a1eb524c9a + url: "https://pub.dev" source: hosted - version: "1.0.1" - markdown: + version: "2.3.5" + shared_preferences_android: dependency: transitive description: - name: markdown - url: "https://pub.dartlang.org" + name: shared_preferences_android + sha256: "02a7d8a9ef346c9af715811b01fbd8e27845ad2c41148eefd31321471b41863d" + url: "https://pub.dev" source: hosted - version: "4.0.1" - matcher: + version: "2.4.0" + shared_preferences_foundation: dependency: transitive description: - name: matcher - url: "https://pub.dartlang.org" + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" source: hosted - version: "0.12.11" - meta: + version: "2.5.4" + shared_preferences_linux: dependency: transitive description: - name: meta - url: "https://pub.dartlang.org" + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" source: hosted - version: "1.7.0" - path: + version: "2.4.1" + shared_preferences_platform_interface: dependency: transitive description: - name: path - url: "https://pub.dartlang.org" + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" source: hosted - version: "1.8.0" - pedantic: + version: "2.4.1" + shared_preferences_web: dependency: transitive description: - name: pedantic - url: "https://pub.dartlang.org" + name: shared_preferences_web + sha256: d2ca4132d3946fec2184261726b355836a82c33d7d5b67af32692aff18a4684e + url: "https://pub.dev" source: hosted - version: "1.11.1" - plugin_platform_interface: + version: "2.4.2" + shared_preferences_windows: dependency: transitive description: - name: plugin_platform_interface - url: "https://pub.dartlang.org" + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + url: "https://pub.dev" + source: hosted + version: "2.0.1" sky_engine: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" + url: "https://pub.dev" + source: hosted + version: "1.3.5" source_span: dependency: transitive description: name: source_span - url: "https://pub.dartlang.org" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" + url: "https://pub.dev" source: hosted - version: "1.8.1" + version: "2.4.0" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" + url: "https://pub.dev" + source: hosted + version: "2.5.4+6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c" + url: "https://pub.dev" + source: hosted + version: "2.4.1+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: name: stack_trace - url: "https://pub.dartlang.org" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - url: "https://pub.dartlang.org" + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: name: string_scanner - url: "https://pub.dartlang.org" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" + url: "https://pub.dev" + source: hosted + version: "3.3.0+3" term_glyph: dependency: transitive description: name: term_glyph - url: "https://pub.dartlang.org" + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.2" test_api: dependency: transitive description: name: test_api - url: "https://pub.dartlang.org" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + url: "https://pub.dev" source: hosted - version: "0.4.3" + version: "0.7.4" timeago: dependency: "direct main" description: name: timeago - url: "https://pub.dartlang.org" + sha256: "054cedf68706bb142839ba0ae6b135f6b68039f0b8301cbe8784ae653d5ff8de" + url: "https://pub.dev" + source: hosted + version: "3.7.0" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + transparent_image: + dependency: transitive + description: + name: transparent_image + sha256: e8991d955a2094e197ca24c645efec2faf4285772a4746126ca12875e54ca02f + url: "https://pub.dev" + source: hosted + version: "2.0.1" + tus_client: + dependency: "direct main" + description: + name: tus_client + sha256: "59f6015cd67f7615b30dd8addf27229e4d6f72f24d8ca677a3b22940fcad2dd1" + url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "1.0.2" typed_data: dependency: transitive description: name: typed_data - url: "https://pub.dartlang.org" + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + upgrader: + dependency: "direct main" + description: + name: upgrader + sha256: d45483694620883107c2f5ca1dff7cdd4237b16810337a9c9c234203eb79eb5f + url: "https://pub.dev" + source: hosted + version: "10.3.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" + url: "https://pub.dev" + source: hosted + version: "6.3.14" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626" + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" vector_math: dependency: transitive description: name: vector_math - url: "https://pub.dartlang.org" + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.4" + version: + dependency: transitive + description: + name: version + sha256: "3d4140128e6ea10d83da32fef2fa4003fccbf6852217bb854845802f04191f94" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + video_compress: + dependency: "direct main" + description: + name: video_compress + sha256: "5b42d89f3970c956bad7a86c29682b0892c11a4ddf95ae6e29897ee28788e377" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + video_editor: + dependency: "direct main" + description: + name: video_editor + sha256: "263be52e052118389f372f055f59c2fda5c7beecfdb706b899d2e05be8740c22" + url: "https://pub.dev" + source: hosted + version: "3.0.0" video_player: dependency: "direct main" description: name: video_player - url: "https://pub.dartlang.org" + sha256: "4a8c3492d734f7c39c2588a3206707a05ee80cef52e8c7f3b2078d430c84bc17" + url: "https://pub.dev" source: hosted - version: "2.2.15" + version: "2.9.2" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "7018dbcb395e2bca0b9a898e73989e67c0c4a5db269528e1b036ca38bcca0d0b" + url: "https://pub.dev" + source: hosted + version: "2.7.17" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: "33224c19775fd244be2d6e3dbd8e1826ab162877bd61123bf71890772119a2b7" + url: "https://pub.dev" + source: hosted + version: "2.6.5" video_player_platform_interface: dependency: transitive description: name: video_player_platform_interface - url: "https://pub.dartlang.org" + sha256: "229d7642ccd9f3dc4aba169609dd6b5f3f443bb4cc15b82f7785fcada5af9bbb" + url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "6.2.3" video_player_web: dependency: transitive description: name: video_player_web - url: "https://pub.dartlang.org" + sha256: "881b375a934d8ebf868c7fb1423b2bfaa393a0a265fa3f733079a86536064a10" + url: "https://pub.dev" source: hosted - version: "2.0.6" + version: "2.3.3" video_player_web_hls: dependency: "direct main" description: name: video_player_web_hls - url: "https://pub.dartlang.org" + sha256: "3157e36de50a74f8c06d4ec9b08e5b7453b5529dd62daef9750b0c52b7c3be36" + url: "https://pub.dev" + source: hosted + version: "1.0.0+3" + video_thumbnail: + dependency: "direct main" + description: + name: video_thumbnail + sha256: "3455c189d3f0bb4e3fc2236475aa84fe598b9b2d0e08f43b9761f5bc44210016" + url: "https://pub.dev" + source: hosted + version: "0.5.3" + visibility_detector: + dependency: transitive + description: + name: visibility_detector + sha256: "15c54a459ec2c17b4705450483f3d5a2858e733aee893dcee9d75fd04814940d" + url: "https://pub.dev" + source: hosted + version: "0.3.3" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + url: "https://pub.dev" + source: hosted + version: "14.3.1" + wakelock_platform_interface: + dependency: transitive + description: + name: wakelock_platform_interface + sha256: "1f4aeb81fb592b863da83d2d0f7b8196067451e4df91046c26b54a403f9de621" + url: "https://pub.dev" + source: hosted + version: "0.3.0" + wakelock_plus: + dependency: "direct main" + description: + name: wakelock_plus + sha256: "36c88af0b930121941345306d259ec4cc4ecca3b151c02e3a9e71aede83c615e" + url: "https://pub.dev" + source: hosted + version: "1.2.10" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "70e780bc99796e1db82fe764b1e7dcb89a86f1e5b3afb1db354de50f2e41eb7a" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + wakelock_web: + dependency: "direct main" + description: + name: wakelock_web + sha256: "1b256b811ee3f0834888efddfe03da8d18d0819317f20f6193e2922b41a501b5" + url: "https://pub.dev" + source: hosted + version: "0.4.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web: + dependency: transitive + description: + name: web + sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + url: "https://pub.dev" + source: hosted + version: "0.1.6" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + sha256: "889a0a678e7c793c308c68739996227c9661590605e70b1f6cf6b9a6634f7aec" + url: "https://pub.dev" + source: hosted + version: "4.10.0" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "3d535126f7244871542b2f0b0fcf94629c9a14883250461f9abe1a6644c1c379" + url: "https://pub.dev" + source: hosted + version: "4.2.0" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: d937581d6e558908d7ae3dc1989c4f87b786891ab47bb9df7de548a151779d8d + url: "https://pub.dev" + source: hosted + version: "2.10.0" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: "4adc14ea9a770cc9e2c8f1ac734536bd40e82615bd0fa6b94be10982de656cc7" + url: "https://pub.dev" + source: hosted + version: "3.17.0" + win32: + dependency: "direct main" + description: + name: win32 + sha256: "154360849a56b7b67331c21f09a386562d88903f90a1099c5987afc1912e1f29" + url: "https://pub.dev" + source: hosted + version: "5.10.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" + url: "https://pub.dev" + source: hosted + version: "1.1.5" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" source: hosted - version: "0.1.11+3" + version: "3.1.3" sdks: - dart: ">=2.15.1 <3.0.0" - flutter: ">=2.5.0" + dart: ">=3.7.0-0 <4.0.0" + flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index fae3b7e4..24c06461 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: acela -description: 3Speak App +description: 3Speak # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. @@ -15,10 +15,10 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.0.0+1 +version: 3.3.2+122 environment: - sdk: ">=2.15.1 <3.0.0" + sdk: ^3.5.0 # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions @@ -27,25 +27,88 @@ environment: # the latest version available on pub.dev. To see which dependencies have newer # versions available, run `flutter pub outdated`. dependencies: - firebase_core: ^1.12.0 + assets_audio_player: + git: + url: https://github.com/florent37/Flutter-AssetsAudioPlayer.git + ref: master + adaptive_action_sheet: ^2.0.1 + better_player: + git: + url: https://github.com/RAMb002/betterplayer.git + + crypto: ^3.0.3 + device_info_plus: ^10.1.2 + file_picker: ^5.2.10 flutter: sdk: flutter - flutter_markdown: ^0.6.9 + flutter_dotenv: ^5.0.2 + flutter_markdown: ^0.7.3+1 + flutter_secure_storage: ^8.0.0 + font_awesome_flutter: ^10.1.0 http: ^0.13.4 + intl: ^0.18.1 + json_annotation: ^4.4.0 + overlay_support: ^2.0.1 + provider: ^6.0.2 + share_plus: ^10.0.0 timeago: ^3.1.0 + tus_client: ^1.0.2 + url_launcher: ^6.0.20 video_player: ^2.2.14 - video_player_web_hls: ^0.1.11+3 + video_player_web_hls: ^1.0.0+3 + video_compress: ^3.1.0 + video_thumbnail: ^0.5.0 + image_picker: ^1.1.2 + localstorage: ^4.0.0+1 + permission_handler: ^11.3.1 + path_provider: ^2.0.11 + images_picker: ^1.2.11 + carousel_slider: ^5.0.0 + uuid: ^4.4.2 + web_socket_channel: ^3.0.1 + qr_flutter: ^4.0.0 + pretty_qr_code: ^3.3.0 + webview_flutter: ^4.2.0 + get_storage: ^2.1.1 + flutter_downloader: ^1.11.2 + cached_network_image: ^3.3.0 + audio_service: ^0.18.12 + just_audio: ^0.9.35 + upgrader: ^10.3.0 + dart_rss: ^3.0.1 + inview_notifier_list: ^3.0.0 + equatable: ^2.0.5 + croppy: ^1.1.4 + image: ^4.1.4 + go_router: ^14.2.3 + scrollable_positioned_list: ^0.3.8 + auto_size_text: ^3.0.0 + gap: ^3.0.1 + auto_scroll_text: ^0.0.7 + flutter_spinkit: ^5.2.1 + miniplayer: ^1.0.1 + win32: ^5.5.4 + wakelock_plus: ^1.2.8 + ffmpeg_kit_flutter_https_gpl: 6.0.3 + archive: ^3.6.1 + wakelock_web: ^0.4.0 + video_editor: ^3.0.0 + fraction: ^5.0.4 + flutter_expandable_fab: ^2.3.0 + omni_datetime_picker: ^2.0.4 dev_dependencies: flutter_test: sdk: flutter + build_runner: + json_serializable: # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is # activated in the `analysis_options.yaml` file located at the root of your # package. See that file for information about deactivating specific lint # rules and activating additional ones. - flutter_lints: ^1.0.0 + flutter_lints: ^4.0.0 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -60,8 +123,18 @@ flutter: # To add assets to your application, add an assets section, like this: assets: + - assets/ - assets/branding/three_speak_logo.png - assets/branding/three_speak_icon.png + - assets/branding/three_shorts_icon.png + - assets/hive_auth_button.png + - assets/hive-keychain-image.png + - assets/ipfs-logo.png + - assets/hiveauth_icon.png + - assets/ctt-logo.png + - assets/podcast-index.jpg + - assets/pod-cast-logo-round.png + - dotenv # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg diff --git a/web/favicon.png b/web/favicon.png deleted file mode 100644 index 8aaa46ac..00000000 Binary files a/web/favicon.png and /dev/null differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png deleted file mode 100644 index b749bfef..00000000 Binary files a/web/icons/Icon-192.png and /dev/null differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png deleted file mode 100644 index 88cfd48d..00000000 Binary files a/web/icons/Icon-512.png and /dev/null differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png deleted file mode 100644 index eb9b4d76..00000000 Binary files a/web/icons/Icon-maskable-192.png and /dev/null differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png deleted file mode 100644 index d69c5669..00000000 Binary files a/web/icons/Icon-maskable-512.png and /dev/null differ diff --git a/web/index.html b/web/index.html deleted file mode 100644 index 0a62fb09..00000000 --- a/web/index.html +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - - - - - - - - - - - - - acela - - - - - - - - diff --git a/web/manifest.json b/web/manifest.json deleted file mode 100644 index 4918763a..00000000 --- a/web/manifest.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "acela", - "short_name": "acela", - "start_url": ".", - "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "3Speak App", - "orientation": "portrait-primary", - "prefer_related_applications": false, - "icons": [ - { - "src": "icons/Icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "icons/Icon-512.png", - "sizes": "512x512", - "type": "image/png" - }, - { - "src": "icons/Icon-maskable-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "icons/Icon-maskable-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ] -}