├── .env.example ├── .gitignore ├── .java-version ├── .metadata ├── LICENSE ├── README.md ├── analysis_options.yaml ├── android ├── .gitignore ├── app │ ├── build.gradle │ └── src │ │ ├── debug │ │ └── AndroidManifest.xml │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── kindjeff │ │ │ │ └── anycast │ │ │ │ └── MainActivity.kt │ │ └── res │ │ │ ├── drawable-v21 │ │ │ └── launch_background.xml │ │ │ ├── drawable │ │ │ └── launch_background.xml │ │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.png │ │ │ └── launcher_icon.png │ │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.png │ │ │ └── launcher_icon.png │ │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.png │ │ │ └── launcher_icon.png │ │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── launcher_icon.png │ │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── launcher_icon.png │ │ │ ├── values-night │ │ │ └── styles.xml │ │ │ └── values │ │ │ └── styles.xml │ │ └── profile │ │ └── AndroidManifest.xml ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties └── settings.gradle ├── assets ├── icon │ └── icon.png ├── images │ ├── 200x200.png │ ├── subscription_intro.png │ └── subscription_intro_2.png └── lottie │ ├── loading.json │ ├── loading_black.json │ └── robot_loading.json ├── devtools_options.yaml ├── docs └── img │ ├── appstore.svg │ ├── feat_ai_chat.png │ ├── feat_ai_trans.png │ ├── feat_country.png │ ├── feat_rss.png │ ├── logo.png │ ├── main.png │ └── playstore.svg ├── firebase.json ├── ios ├── .gitignore ├── Flutter │ ├── AppFrameworkInfo.plist │ ├── Debug.xcconfig │ └── Release.xcconfig ├── Podfile ├── Runner.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── WorkspaceSettings.xcsettings │ └── xcshareddata │ │ └── xcschemes │ │ └── Runner.xcscheme ├── Runner.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── WorkspaceSettings.xcsettings ├── Runner │ ├── AppDelegate.swift │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── Icon-App-1024x1024@1x.png │ │ │ ├── Icon-App-20x20@1x.png │ │ │ ├── Icon-App-20x20@2x.png │ │ │ ├── Icon-App-20x20@3x.png │ │ │ ├── Icon-App-29x29@1x.png │ │ │ ├── Icon-App-29x29@2x.png │ │ │ ├── Icon-App-29x29@3x.png │ │ │ ├── Icon-App-40x40@1x.png │ │ │ ├── Icon-App-40x40@2x.png │ │ │ ├── Icon-App-40x40@3x.png │ │ │ ├── Icon-App-50x50@1x.png │ │ │ ├── Icon-App-50x50@2x.png │ │ │ ├── Icon-App-57x57@1x.png │ │ │ ├── Icon-App-57x57@2x.png │ │ │ ├── Icon-App-60x60@2x.png │ │ │ ├── Icon-App-60x60@3x.png │ │ │ ├── Icon-App-72x72@1x.png │ │ │ ├── Icon-App-72x72@2x.png │ │ │ ├── Icon-App-76x76@1x.png │ │ │ ├── Icon-App-76x76@2x.png │ │ │ └── Icon-App-83.5x83.5@2x.png │ │ └── LaunchImage.imageset │ │ │ ├── Contents.json │ │ │ ├── LaunchImage.png │ │ │ ├── LaunchImage@2x.png │ │ │ ├── LaunchImage@3x.png │ │ │ └── README.md │ ├── Base.lproj │ │ ├── LaunchScreen.storyboard │ │ └── Main.storyboard │ ├── Info.plist │ ├── Runner-Bridging-Header.h │ └── Runner.entitlements ├── RunnerTests │ └── RunnerTests.swift ├── Share Extension │ ├── Base.lproj │ │ └── MainInterface.storyboard │ ├── Info.plist │ ├── Share Extension.entitlements │ └── ShareViewController.swift └── firebase_app_id_file.json ├── lib ├── api │ ├── error_handler.dart │ ├── podcasts.dart │ ├── share.dart │ ├── subtitles.dart │ └── user.dart ├── main.dart ├── models │ ├── episode.dart │ ├── feed_episode.dart │ ├── helper.dart │ ├── history_episode.dart │ ├── player.dart │ ├── playlist.dart │ ├── playlist_episode.dart │ ├── settings.dart │ ├── subscription.dart │ ├── subtitle.dart │ ├── table_creator.dart │ └── translation.dart ├── pages │ ├── channel.dart │ ├── chat.dart │ ├── discover.dart │ ├── feeds.dart │ ├── login.dart │ ├── player.dart │ ├── playlists.dart │ ├── podcasts.dart │ ├── settings.dart │ ├── styles.dart │ └── subscriptions.dart ├── states │ ├── cache.dart │ ├── cardlist.dart │ ├── channel.dart │ ├── chat.dart │ ├── discover.dart │ ├── feed_episode.dart │ ├── history.dart │ ├── import_indicator.dart │ ├── player.dart │ ├── playlist.dart │ ├── playlist_episode.dart │ ├── share.dart │ ├── subscription.dart │ ├── subtitle.dart │ ├── tab.dart │ ├── translation.dart │ └── user.dart ├── styles.dart ├── utils │ ├── audio_handler.dart │ ├── formatters.dart │ ├── http_client.dart │ ├── keepalive.dart │ └── rss_fetcher.dart └── widgets │ ├── animation.dart │ ├── appbar.dart │ ├── bottom_nav_bar.dart │ ├── card.dart │ ├── detail.dart │ ├── expandable_text.dart │ ├── handler.dart │ ├── import_export.dart │ ├── import_indicator.dart │ ├── play_icon.dart │ ├── privacy.dart │ └── share.dart ├── pubspec.yaml └── test └── widget_test.dart /.env.example: -------------------------------------------------------------------------------- 1 | PURCHASES_IOS_API_KEY= 2 | PURCHASES_ANDROID_API_KEY= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # custom 2 | /android/app/google-services.json 3 | /ios/Runner/GoogleService-Info.plist 4 | /lib/firebase_options.dart 5 | *.env 6 | 7 | # Do not remove or rename entries in this file, only add new ones 8 | # See https://github.com/flutter/flutter/issues/128635 for more context. 9 | 10 | # Miscellaneous 11 | *.class 12 | *.lock 13 | *.log 14 | *.pyc 15 | *.swp 16 | .DS_Store 17 | .atom/ 18 | .buildlog/ 19 | .history 20 | .svn/ 21 | 22 | # IntelliJ related 23 | *.iml 24 | *.ipr 25 | *.iws 26 | .idea/ 27 | 28 | # Visual Studio Code related 29 | .classpath 30 | .project 31 | .settings/ 32 | .vscode/* 33 | 34 | # Flutter repo-specific 35 | /bin/cache/ 36 | /bin/internal/bootstrap.bat 37 | /bin/internal/bootstrap.sh 38 | /bin/mingit/ 39 | /dev/benchmarks/mega_gallery/ 40 | /dev/bots/.recipe_deps 41 | /dev/bots/android_tools/ 42 | /dev/devicelab/ABresults*.json 43 | /dev/docs/doc/ 44 | /dev/docs/api_docs.zip 45 | /dev/docs/flutter.docs.zip 46 | /dev/docs/lib/ 47 | /dev/docs/pubspec.yaml 48 | /dev/integration_tests/**/xcuserdata 49 | /dev/integration_tests/**/Pods 50 | /packages/flutter/coverage/ 51 | version 52 | analysis_benchmark.json 53 | 54 | # packages file containing multi-root paths 55 | .packages.generated 56 | 57 | # Flutter/Dart/Pub related 58 | **/doc/api/ 59 | .dart_tool/ 60 | .flutter-plugins 61 | .flutter-plugins-dependencies 62 | **/generated_plugin_registrant.dart 63 | .packages 64 | .pub-preload-cache/ 65 | .pub-cache/ 66 | .pub/ 67 | build/ 68 | flutter_*.png 69 | linked_*.ds 70 | unlinked.ds 71 | unlinked_spec.ds 72 | 73 | # Android related 74 | **/android/**/gradle-wrapper.jar 75 | .gradle/ 76 | **/android/captures/ 77 | **/android/gradlew 78 | **/android/gradlew.bat 79 | **/android/local.properties 80 | **/android/**/GeneratedPluginRegistrant.java 81 | **/android/key.properties 82 | *.jks 83 | 84 | # iOS/XCode related 85 | **/ios/**/*.mode1v3 86 | **/ios/**/*.mode2v3 87 | **/ios/**/*.moved-aside 88 | **/ios/**/*.pbxuser 89 | **/ios/**/*.perspectivev3 90 | **/ios/**/*sync/ 91 | **/ios/**/.sconsign.dblite 92 | **/ios/**/.tags* 93 | **/ios/**/.vagrant/ 94 | **/ios/**/DerivedData/ 95 | **/ios/**/Icon? 96 | **/ios/**/Pods/ 97 | **/ios/**/.symlinks/ 98 | **/ios/**/profile 99 | **/ios/**/xcuserdata 100 | **/ios/.generated/ 101 | **/ios/Flutter/.last_build_id 102 | **/ios/Flutter/App.framework 103 | **/ios/Flutter/Flutter.framework 104 | **/ios/Flutter/Flutter.podspec 105 | **/ios/Flutter/Generated.xcconfig 106 | **/ios/Flutter/ephemeral 107 | **/ios/Flutter/app.flx 108 | **/ios/Flutter/app.zip 109 | **/ios/Flutter/flutter_assets/ 110 | **/ios/Flutter/flutter_export_environment.sh 111 | **/ios/ServiceDefinitions.json 112 | **/ios/Runner/GeneratedPluginRegistrant.* 113 | 114 | # macOS 115 | **/Flutter/ephemeral/ 116 | **/Pods/ 117 | **/macos/Flutter/GeneratedPluginRegistrant.swift 118 | **/macos/Flutter/ephemeral 119 | **/xcuserdata/ 120 | 121 | # Windows 122 | **/windows/flutter/generated_plugin_registrant.cc 123 | **/windows/flutter/generated_plugin_registrant.h 124 | **/windows/flutter/generated_plugins.cmake 125 | 126 | # Linux 127 | **/linux/flutter/generated_plugin_registrant.cc 128 | **/linux/flutter/generated_plugin_registrant.h 129 | **/linux/flutter/generated_plugins.cmake 130 | 131 | # Coverage 132 | coverage/ 133 | 134 | # Symbols 135 | app.*.symbols 136 | 137 | # Exceptions to above rules. 138 | !**/ios/**/default.mode1v3 139 | !**/ios/**/default.mode2v3 140 | !**/ios/**/default.pbxuser 141 | !**/ios/**/default.perspectivev3 142 | !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages 143 | !/dev/ci/**/Gemfile.lock 144 | !.vscode/settings.json 145 | -------------------------------------------------------------------------------- /.java-version: -------------------------------------------------------------------------------- 1 | 11.0 2 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: "17025dd88227cd9532c33fa78f5250d548d87e9a" 8 | channel: "stable" 9 | 10 | project_type: app 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a 17 | base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a 18 | - platform: android 19 | create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a 20 | base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a 21 | - platform: ios 22 | create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a 23 | base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a 24 | - platform: linux 25 | create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a 26 | base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a 27 | - platform: macos 28 | create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a 29 | base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a 30 | - platform: web 31 | create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a 32 | base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a 33 | - platform: windows 34 | create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a 35 | base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a 36 | 37 | # User provided section 38 | 39 | # List of Local paths (relative to this file) that should be 40 | # ignored by the migrate tool. 41 | # 42 | # Files that are not part of the templates will be ignored by default. 43 | unmanaged_files: 44 | - 'lib/main.dart' 45 | - 'ios/Runner.xcodeproj/project.pbxproj' 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Anycast 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Anycast
4 |
5 | An AI-powered Podcast App
6 |

7 | 8 |

9 | Cross-platform, Seamless RSS Integration, Global Content Discovery, and AI 10 |

11 | 12 |

13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |

26 | 27 |

28 | 29 |

30 | 31 | ## Website 32 | 33 | [https://anycast.website](https://anycast.website) 34 | 35 | ## Features 36 | 39 | 40 | 41 | 42 | 43 | 52 | 53 | 54 | 55 | 60 | 61 | 62 | 63 | 71 | 72 | 73 | 74 | 82 | 83 |
44 | AI Transcription 45 |
46 |
    47 |
  • Support 10+ languages
  • 48 |
  • Bilingual Subtitle
  • 49 |
  • Export to LRC
  • 50 |
51 |
56 | AI Chat 57 |
58 | Curious about this podcast? Ask it anything. 59 |
64 | Good RSS Integration 65 |
66 |
    67 |
  • Subscribe to podcasts from any iTunes compatible RSS feed.
  • 68 |
  • Import or export your subscriptions with OPML.
  • 69 |
70 |
75 | Access podcasts from all over the world 76 |
77 |
    78 |
  • Tens of countries available.
  • 79 |
  • A variety of types of podcasts.
  • 80 |
81 |
84 | 85 | ## TODO 86 | 87 | - [ ] Create different playlists 88 | - [ ] Brand new UI design 89 | - [ ] Support time navigation in show notes 90 | - [ ] Support for custom ASR API and Chat API without mandatory login 91 | - [ ] Compiler conditions for the open-source version, without requiring Firebase / RevenueCat configurations 92 | - [ ] Carplay support 93 | - [ ] AI recommendations 94 | - [ ] Enhanced note-taking features 95 | 96 | ## Contributing 97 | 98 | Conditional compilation will be supported soon, allowing you to compile with minimal (or no) extra steps. 99 | 100 | Currently, when cloning and compiling the project, the following additional files are required: 101 | 102 | - `.env` 103 | - `android/app/google-services.json` 104 | - `android/key.properties` 105 | - `ios/Runner/GoogleService-Info.plist` 106 | - `lib/firebase_options.dart` 107 | 108 | ## License 109 | 110 | Anycast is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 111 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at https://dart.dev/lints. 17 | # 18 | # Instead of disabling a lint rule for the entire project in the 19 | # section below, it can also be suppressed for a single line of code 20 | # or a specific dart file by using the `// ignore: name_of_lint` and 21 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 22 | # producing the lint. 23 | rules: 24 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 25 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 26 | 27 | # Additional information about this file can be found at 28 | # https://dart.dev/guides/language/analysis-options 29 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | 9 | # Remember to never publicly share your keystore. 10 | # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app 11 | key.properties 12 | **/*.keystore 13 | **/*.jks 14 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "com.android.application" 3 | // START: FlutterFire Configuration 4 | id 'com.google.gms.google-services' 5 | // END: FlutterFire Configuration 6 | id "kotlin-android" 7 | id "dev.flutter.flutter-gradle-plugin" 8 | } 9 | 10 | def localProperties = new Properties() 11 | def localPropertiesFile = rootProject.file('local.properties') 12 | if (localPropertiesFile.exists()) { 13 | localPropertiesFile.withReader('UTF-8') { reader -> 14 | localProperties.load(reader) 15 | } 16 | } 17 | 18 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 19 | if (flutterVersionCode == null) { 20 | flutterVersionCode = '1' 21 | } 22 | 23 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 24 | if (flutterVersionName == null) { 25 | flutterVersionName = '1.0' 26 | } 27 | 28 | def keystoreProperties = new Properties() 29 | def keystorePropertiesFile = rootProject.file('key.properties') 30 | if (keystorePropertiesFile.exists()) { 31 | keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) 32 | } 33 | 34 | android { 35 | namespace "com.kindjeff.anycast" 36 | compileSdkVersion flutter.compileSdkVersion 37 | // ndkVersion flutter.ndkVersion 38 | ndkVersion = "27.0.12077973" 39 | 40 | compileOptions { 41 | sourceCompatibility JavaVersion.VERSION_1_8 42 | targetCompatibility JavaVersion.VERSION_1_8 43 | } 44 | 45 | kotlinOptions { 46 | jvmTarget = '1.8' 47 | } 48 | 49 | sourceSets { 50 | main.java.srcDirs += 'src/main/kotlin' 51 | } 52 | 53 | defaultConfig { 54 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 55 | applicationId "com.kindjeff.anycast" 56 | // You can update the following values to match your application needs. 57 | // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. 58 | minSdkVersion 23 59 | targetSdkVersion flutter.targetSdkVersion 60 | versionCode flutterVersionCode.toInteger() 61 | versionName flutterVersionName 62 | } 63 | 64 | 65 | signingConfigs { 66 | release { 67 | keyAlias keystoreProperties['keyAlias'] 68 | keyPassword keystoreProperties['keyPassword'] 69 | storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null 70 | storePassword keystoreProperties['storePassword'] 71 | } 72 | } 73 | buildTypes { 74 | release { 75 | // TODO: Add your own signing config for the release build. 76 | // Signing with the debug keys for now, so `flutter run --release` works. 77 | signingConfig signingConfigs.release 78 | } 79 | } 80 | } 81 | 82 | flutter { 83 | source '../..' 84 | } 85 | 86 | dependencies {} 87 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 13 | 21 | 25 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 49 | 50 | 51 | 52 | 53 | 55 | 56 | 57 | 58 | 59 | 61 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/com/kindjeff/anycast/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.kindjeff.anycast 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() { 6 | } 7 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/android/app/src/main/res/mipmap-hdpi/launcher_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/android/app/src/main/res/mipmap-mdpi/launcher_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext.kotlin_version = '2.1.0' 3 | repositories { 4 | google() 5 | mavenCentral() 6 | } 7 | 8 | dependencies { 9 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 10 | } 11 | } 12 | 13 | allprojects { 14 | repositories { 15 | google() 16 | mavenCentral() 17 | } 18 | } 19 | 20 | rootProject.buildDir = '../build' 21 | subprojects { 22 | project.buildDir = "${rootProject.buildDir}/${project.name}" 23 | } 24 | subprojects { 25 | project.evaluationDependsOn(':app') 26 | } 27 | 28 | tasks.register("clean", Delete) { 29 | delete rootProject.buildDir 30 | } 31 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx4G 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | android.defaults.buildfeatures.buildconfig=true 5 | android.nonTransitiveRClass=false 6 | android.nonFinalResIds=false 7 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | zipStoreBase=GRADLE_USER_HOME 4 | zipStorePath=wrapper/dists 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip 6 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | def flutterSdkPath = { 3 | def properties = new Properties() 4 | file("local.properties").withInputStream { properties.load(it) } 5 | def flutterSdkPath = properties.getProperty("flutter.sdk") 6 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 7 | return flutterSdkPath 8 | } 9 | settings.ext.flutterSdkPath = flutterSdkPath() 10 | 11 | includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") 12 | 13 | repositories { 14 | google() 15 | mavenCentral() 16 | gradlePluginPortal() 17 | } 18 | 19 | plugins { 20 | id "dev.flutter.flutter-gradle-plugin" version "1.0.0" apply false 21 | } 22 | } 23 | 24 | plugins { 25 | id "org.jetbrains.kotlin.android" version "2.1.0" apply false 26 | id "dev.flutter.flutter-plugin-loader" version "1.0.0" 27 | id "com.android.application" version '8.7.3' apply false 28 | // START: FlutterFire Configuration 29 | id "com.google.gms.google-services" version "4.3.15" apply false 30 | // END: FlutterFire Configuration 31 | } 32 | 33 | include ":app" 34 | -------------------------------------------------------------------------------- /assets/icon/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/assets/icon/icon.png -------------------------------------------------------------------------------- /assets/images/200x200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/assets/images/200x200.png -------------------------------------------------------------------------------- /assets/images/subscription_intro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/assets/images/subscription_intro.png -------------------------------------------------------------------------------- /assets/images/subscription_intro_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/assets/images/subscription_intro_2.png -------------------------------------------------------------------------------- /assets/lottie/loading.json: -------------------------------------------------------------------------------- 1 | {"v":"4.8.0","meta":{"g":"LottieFiles AE 1.0.0","a":"","k":"","d":"","tc":"#FFFFFF"},"fr":25,"ip":25,"op":55,"w":800,"h":800,"nm":"Loading #13","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Line","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[400,400,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[60.688,0],[0,55.228],[-60.688,0],[0,0],[-60.688,0],[0,55.228],[60.688,0]],"o":[[0,0],[-60.688,0],[0,-55.229],[60.688,0],[0,0],[60.688,0],[0,-55.229],[-60.688,0]],"v":[[0,0],[-159.333,100],[-245,0],[-159.333,-100],[0,0],[159.333,100],[245,0],[159.333,-100]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":39,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[25]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":15,"s":[10]},{"t":30,"s":[25]}],"ix":1},"e":{"a":0,"k":50,"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":30,"s":[360]}],"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":125,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Line","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[400,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":25,"op":150,"st":25,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Line transparent","sr":1,"ks":{"o":{"a":0,"k":20,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[400,400,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[60.688,0],[0,55.228],[-60.688,0],[0,0],[-60.688,0],[0,55.228],[60.688,0]],"o":[[0,0],[-60.688,0],[0,-55.229],[60.688,0],[0,0],[60.688,0],[0,-55.229],[-60.688,0]],"v":[[0,0],[-159.333,100],[-245,0],[-159.333,-100],[0,0],[159.333,100],[245,0],[159.333,-100]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":39,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":125,"st":0,"bm":0}],"markers":[]} -------------------------------------------------------------------------------- /assets/lottie/loading_black.json: -------------------------------------------------------------------------------- 1 | {"v":"4.8.0","meta":{"g":"LottieFiles AE 1.0.0","a":"","k":"","d":"","tc":"#FFFFFF"},"fr":25,"ip":25,"op":55,"w":800,"h":800,"nm":"Loading #13","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Line","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[400,400,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[60.688,0],[0,55.228],[-60.688,0],[0,0],[-60.688,0],[0,55.228],[60.688,0]],"o":[[0,0],[-60.688,0],[0,-55.229],[60.688,0],[0,0],[60.688,0],[0,-55.229],[-60.688,0]],"v":[[0,0],[-159.333,100],[-245,0],[-159.333,-100],[0,0],[159.333,100],[245,0],[159.333,-100]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":39,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[25]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":15,"s":[10]},{"t":30,"s":[25]}],"ix":1},"e":{"a":0,"k":50,"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":30,"s":[360]}],"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":125,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Line","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[400,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":25,"op":150,"st":25,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Line transparent","sr":1,"ks":{"o":{"a":0,"k":20,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[400,400,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[60.688,0],[0,55.228],[-60.688,0],[0,0],[-60.688,0],[0,55.228],[60.688,0]],"o":[[0,0],[-60.688,0],[0,-55.229],[60.688,0],[0,0],[60.688,0],[0,-55.229],[-60.688,0]],"v":[[0,0],[-159.333,100],[-245,0],[-159.333,-100],[0,0],[159.333,100],[245,0],[159.333,-100]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":39,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":125,"st":0,"bm":0}],"markers":[]} -------------------------------------------------------------------------------- /devtools_options.yaml: -------------------------------------------------------------------------------- 1 | extensions: 2 | -------------------------------------------------------------------------------- /docs/img/feat_ai_chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/docs/img/feat_ai_chat.png -------------------------------------------------------------------------------- /docs/img/feat_ai_trans.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/docs/img/feat_ai_trans.png -------------------------------------------------------------------------------- /docs/img/feat_country.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/docs/img/feat_country.png -------------------------------------------------------------------------------- /docs/img/feat_rss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/docs/img/feat_rss.png -------------------------------------------------------------------------------- /docs/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/docs/img/logo.png -------------------------------------------------------------------------------- /docs/img/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/docs/img/main.png -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | {"flutter":{"platforms":{"android":{"default":{"projectId":"anycast-412313","appId":"1:1092551717876:android:19617e703f779a0074ffa1","fileOutput":"android/app/google-services.json"}},"ios":{"default":{"projectId":"anycast-412313","appId":"1:1092551717876:ios:5c9c489a2d619ca074ffa1","uploadDebugSymbols":false,"fileOutput":"ios/Runner/GoogleService-Info.plist"}},"dart":{"lib/firebase_options.dart":{"projectId":"anycast-412313","configurations":{"android":"1:1092551717876:android:19617e703f779a0074ffa1","ios":"1:1092551717876:ios:5c9c489a2d619ca074ffa1"}}}}}} -------------------------------------------------------------------------------- /ios/.gitignore: -------------------------------------------------------------------------------- 1 | **/dgph 2 | *.mode1v3 3 | *.mode2v3 4 | *.moved-aside 5 | *.pbxuser 6 | *.perspectivev3 7 | **/*sync/ 8 | .sconsign.dblite 9 | .tags* 10 | **/.vagrant/ 11 | **/DerivedData/ 12 | Icon? 13 | **/Pods/ 14 | **/.symlinks/ 15 | profile 16 | xcuserdata 17 | **/.generated/ 18 | Flutter/App.framework 19 | Flutter/Flutter.framework 20 | Flutter/Flutter.podspec 21 | Flutter/Generated.xcconfig 22 | Flutter/ephemeral/ 23 | Flutter/app.flx 24 | Flutter/app.zip 25 | Flutter/flutter_assets/ 26 | Flutter/flutter_export_environment.sh 27 | ServiceDefinitions.json 28 | Runner/GeneratedPluginRegistrant.* 29 | 30 | # Exceptions to above rules. 31 | !default.mode1v3 32 | !default.mode2v3 33 | !default.pbxuser 34 | !default.perspectivev3 35 | -------------------------------------------------------------------------------- /ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 12.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment this line to define a global platform for your project 2 | platform :ios, '13.0' 3 | 4 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 5 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 6 | 7 | project 'Runner', { 8 | 'Debug' => :debug, 9 | 'Profile' => :release, 10 | 'Release' => :release, 11 | } 12 | 13 | def flutter_root 14 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) 15 | unless File.exist?(generated_xcode_build_settings_path) 16 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" 17 | end 18 | 19 | File.foreach(generated_xcode_build_settings_path) do |line| 20 | matches = line.match(/FLUTTER_ROOT\=(.*)/) 21 | return matches[1].strip if matches 22 | end 23 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" 24 | end 25 | 26 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) 27 | 28 | flutter_ios_podfile_setup 29 | 30 | Pod::PICKER_MEDIA = false 31 | Pod::PICKER_AUDIO = false 32 | Pod::PICKER_DOCUMENT = true 33 | 34 | target 'Runner' do 35 | use_frameworks! 36 | use_modular_headers! 37 | 38 | flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) 39 | target 'RunnerTests' do 40 | inherit! :search_paths 41 | end 42 | target 'Share Extension' do 43 | inherit! :search_paths 44 | end 45 | end 46 | 47 | post_install do |installer| 48 | installer.pods_project.targets.each do |target| 49 | flutter_additional_ios_build_settings(target) 50 | 51 | target.build_configurations.each do |config| 52 | config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ 53 | '$(inherited)', 54 | 'AUDIO_SESSION_MICROPHONE=0' 55 | ] 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 43 | 49 | 50 | 51 | 52 | 53 | 63 | 65 | 71 | 72 | 73 | 74 | 80 | 82 | 88 | 89 | 90 | 91 | 93 | 94 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Flutter 3 | 4 | @main 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | GeneratedPluginRegistrant.register(with: self) 11 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-App-20x20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-App-20x20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-App-29x29@1x.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-App-29x29@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "29x29", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-App-29x29@3x.png", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-App-40x40@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "40x40", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-App-40x40@3x.png", 43 | "scale" : "3x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-App-60x60@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "60x60", 53 | "idiom" : "iphone", 54 | "filename" : "Icon-App-60x60@3x.png", 55 | "scale" : "3x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-App-20x20@1x.png", 61 | "scale" : "1x" 62 | }, 63 | { 64 | "size" : "20x20", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-App-20x20@2x.png", 67 | "scale" : "2x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-App-29x29@1x.png", 73 | "scale" : "1x" 74 | }, 75 | { 76 | "size" : "29x29", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-App-29x29@2x.png", 79 | "scale" : "2x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-App-40x40@1x.png", 85 | "scale" : "1x" 86 | }, 87 | { 88 | "size" : "40x40", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-App-40x40@2x.png", 91 | "scale" : "2x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-App-76x76@1x.png", 97 | "scale" : "1x" 98 | }, 99 | { 100 | "size" : "76x76", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-App-76x76@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "83.5x83.5", 107 | "idiom" : "ipad", 108 | "filename" : "Icon-App-83.5x83.5@2x.png", 109 | "scale" : "2x" 110 | }, 111 | { 112 | "size" : "1024x1024", 113 | "idiom" : "ios-marketing", 114 | "filename" : "Icon-App-1024x1024@1x.png", 115 | "scale" : "1x" 116 | } 117 | ], 118 | "info" : { 119 | "version" : 1, 120 | "author" : "xcode" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LaunchImage.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "LaunchImage@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "LaunchImage@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sljeff/anycast/1005b5a063a4e691dbccad537e287cf81a768ce9/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AppGroupId 6 | $(CUSTOM_GROUP_ID) 7 | CADisableMinimumFrameDurationOnPhone 8 | 9 | CFBundleDevelopmentRegion 10 | $(DEVELOPMENT_LANGUAGE) 11 | CFBundleDisplayName 12 | Anycast 13 | CFBundleExecutable 14 | $(EXECUTABLE_NAME) 15 | CFBundleIdentifier 16 | $(PRODUCT_BUNDLE_IDENTIFIER) 17 | CFBundleInfoDictionaryVersion 18 | 6.0 19 | CFBundleName 20 | anycast 21 | CFBundlePackageType 22 | APPL 23 | CFBundleShortVersionString 24 | $(FLUTTER_BUILD_NAME) 25 | CFBundleSignature 26 | ???? 27 | CFBundleURLTypes 28 | 29 | 30 | CFBundleTypeRole 31 | Editor 32 | CFBundleURLSchemes 33 | 34 | com.googleusercontent.apps.1092551717876-ugib9p1irmilcqr6hcshcmnj9sg6edfm 35 | 36 | 37 | 38 | CFBundleTypeRole 39 | Editor 40 | CFBundleURLSchemes 41 | 42 | ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER) 43 | 44 | 45 | 46 | CFBundleVersion 47 | $(FLUTTER_BUILD_NUMBER) 48 | ITSAppUsesNonExemptEncryption 49 | 50 | LSRequiresIPhoneOS 51 | 52 | NSAppTransportSecurity 53 | 54 | NSAllowsArbitraryLoads 55 | 56 | NSAllowsArbitraryLoadsForMedia 57 | 58 | 59 | UIApplicationSupportsIndirectInputEvents 60 | 61 | UIBackgroundModes 62 | 63 | audio 64 | 65 | UILaunchStoryboardName 66 | LaunchScreen 67 | UIMainStoryboardFile 68 | Main 69 | UISupportedInterfaceOrientations 70 | 71 | UIInterfaceOrientationPortrait 72 | UIInterfaceOrientationLandscapeLeft 73 | UIInterfaceOrientationLandscapeRight 74 | 75 | UISupportedInterfaceOrientations~ipad 76 | 77 | UIInterfaceOrientationPortrait 78 | UIInterfaceOrientationPortraitUpsideDown 79 | UIInterfaceOrientationLandscapeLeft 80 | UIInterfaceOrientationLandscapeRight 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" 2 | -------------------------------------------------------------------------------- /ios/Runner/Runner.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.applesignin 6 | 7 | Default 8 | 9 | com.apple.security.application-groups 10 | 11 | group.com.kindjeff.ShareExtention 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /ios/RunnerTests/RunnerTests.swift: -------------------------------------------------------------------------------- 1 | import Flutter 2 | import UIKit 3 | import XCTest 4 | 5 | class RunnerTests: XCTestCase { 6 | 7 | func testExample() { 8 | // If you add code to the Runner application, consider adding tests here. 9 | // See https://developer.apple.com/documentation/xctest for more information about using XCTest. 10 | } 11 | 12 | } 13 | -------------------------------------------------------------------------------- /ios/Share Extension/Base.lproj/MainInterface.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /ios/Share Extension/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AppGroupId 6 | $(CUSTOM_GROUP_ID) 7 | CFBundleVersion 8 | $(FLUTTER_BUILD_NUMBER) 9 | NSExtension 10 | 11 | NSExtensionAttributes 12 | 13 | NSExtensionActivationRule 14 | 15 | NSExtensionActivationSupportsFileWithMaxCount 16 | 1 17 | NSExtensionActivationSupportsText 18 | 19 | 20 | 21 | NSExtensionMainStoryboard 22 | MainInterface 23 | NSExtensionPointIdentifier 24 | com.apple.share-services 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /ios/Share Extension/Share Extension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.com.kindjeff.ShareExtention 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ios/firebase_app_id_file.json: -------------------------------------------------------------------------------- 1 | { 2 | "file_generated_by": "FlutterFire CLI", 3 | "purpose": "FirebaseAppID & ProjectID for this Firebase app in this directory", 4 | "GOOGLE_APP_ID": "1:1092551717876:ios:5c9c489a2d619ca074ffa1", 5 | "FIREBASE_PROJECT_ID": "anycast-412313", 6 | "GCM_SENDER_ID": "1092551717876" 7 | } -------------------------------------------------------------------------------- /lib/api/error_handler.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:anycast/pages/login.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:get/get.dart'; 6 | import 'package:google_fonts/google_fonts.dart'; 7 | import 'package:http/http.dart' as http; 8 | import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; 9 | 10 | class ErrorHandler { 11 | static Future handle(int code, http.Response response) async { 12 | debugPrint(code.toString()); 13 | debugPrint(response.body); 14 | 15 | if (code == 401) { 16 | await handle401(); 17 | return; 18 | } else if (code == 403) { 19 | await handle403(response); 20 | return; 21 | } 22 | 23 | var style = GoogleFonts.roboto( 24 | color: Colors.white, 25 | fontSize: 14, 26 | fontWeight: FontWeight.w700, 27 | ); 28 | Get.dialog( 29 | AlertDialog( 30 | title: Text('Error $code', style: style), 31 | content: Text(response.body, style: style), 32 | actions: [ 33 | TextButton( 34 | onPressed: () { 35 | Get.back(); 36 | }, 37 | child: Text('OK', style: style), 38 | ), 39 | ], 40 | ), 41 | ); 42 | } 43 | 44 | static Future handle401() async { 45 | showMaterialModalBottomSheet( 46 | expand: true, 47 | context: Get.context!, 48 | builder: (context) { 49 | return const LoginPage(); 50 | }, 51 | closeProgressThreshold: 0.9, 52 | ); 53 | } 54 | 55 | static Future handle403(http.Response response) async { 56 | var body = utf8.decode(response.bodyBytes); 57 | var data = jsonDecode(body) as Map; 58 | 59 | var error = data['error'] as String; 60 | var errorCode = data['code'] as int; 61 | 62 | if (errorCode == 2) { 63 | handle401(); 64 | return; 65 | } 66 | 67 | var style = GoogleFonts.roboto( 68 | color: Colors.white, 69 | fontSize: 14, 70 | fontWeight: FontWeight.w700, 71 | ); 72 | Get.dialog( 73 | AlertDialog( 74 | title: Text('Error', style: style), 75 | content: Text(error, style: style), 76 | actions: [ 77 | TextButton( 78 | onPressed: () { 79 | Get.back(); 80 | }, 81 | child: Text('OK', style: style), 82 | ), 83 | ], 84 | ), 85 | ); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /lib/api/podcasts.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:anycast/models/feed_episode.dart'; 4 | import 'package:anycast/models/subscription.dart'; 5 | import 'package:anycast/utils/http_client.dart'; 6 | import 'package:http/http.dart' as http; 7 | 8 | const host = 'api.anycast.website'; 9 | 10 | Future> searchChannels(String searchText) async { 11 | var url = Uri( 12 | host: host, 13 | scheme: 'https', 14 | path: '/search_channel/v2', 15 | queryParameters: { 16 | 'keyword': searchText, 17 | 'limit': '20', 18 | }, 19 | ); 20 | 21 | var response = await fetchWithRetry(url.toString()); 22 | var body = utf8.decode(response!.bodyBytes); 23 | Map data = jsonDecode(body); 24 | 25 | List subscriptions = []; 26 | for (var item in data['data']['channel_list']) { 27 | subscriptions.add(resMap2Channel(item)); 28 | } 29 | return subscriptions; 30 | } 31 | 32 | SubscriptionModel resMap2Channel(Map item) { 33 | return SubscriptionModel.fromMap({ 34 | 'rssFeedUrl': item['rss_url'], 35 | 'title': item['title'].trim(), 36 | 'description': item['description'].trim(), 37 | 'imageUrl': item['small_cover_url'], 38 | 'link': item['link'], 39 | 'categories': item['keywords'].join(','), 40 | 'author': item['author'].trim(), 41 | 'email': '', 42 | }); 43 | } 44 | 45 | class EpisodeWithChannel { 46 | FeedEpisodeModel? episode; 47 | SubscriptionModel? channel; 48 | 49 | EpisodeWithChannel(this.episode, this.channel); 50 | } 51 | 52 | Future> searchEpisodes(String searchText) async { 53 | var url = Uri( 54 | host: host, 55 | scheme: 'https', 56 | path: '/search_episode', 57 | queryParameters: { 58 | 'keyword': searchText, 59 | 'limit': '20', 60 | }, 61 | ); 62 | 63 | var response = await fetchWithRetry(url.toString()); 64 | var body = utf8.decode(response!.bodyBytes); 65 | Map data = jsonDecode(body); 66 | 67 | List episodes = []; 68 | for (var item in data['data']) { 69 | episodes.add(EpisodeWithChannel( 70 | FeedEpisodeModel.fromMap({ 71 | 'channelTitle': item['channel']['title'], 72 | 'rssFeedUrl': item['channel']['rss_url'], 73 | 'title': item['title'], 74 | 'description': item['description'], 75 | 'duration': item['duration'], 76 | 'enclosureUrl': item['url'], 77 | 'pubDate': parsePubDate(item['release_date'])!.millisecondsSinceEpoch, 78 | 'imageUrl': item['cover_url'], 79 | }), 80 | resMap2Channel(item['channel']), 81 | )); 82 | } 83 | return episodes; 84 | } 85 | 86 | DateTime? parsePubDate(String? pubDate) { 87 | if (pubDate == null) { 88 | return null; 89 | } 90 | return DateTime.parse(pubDate); 91 | } 92 | 93 | class Category { 94 | String name; 95 | String id; 96 | String imageUrl; 97 | String nightImageUrl; 98 | 99 | Category(this.name, this.id, this.imageUrl, this.nightImageUrl); 100 | } 101 | 102 | Future> listCategories() async { 103 | var url = Uri( 104 | host: host, 105 | scheme: 'https', 106 | path: '/categories', 107 | ); 108 | 109 | var response = await http.get(url); 110 | var body = utf8.decode(response.bodyBytes); 111 | Map data = jsonDecode(body); 112 | 113 | var result = []; 114 | for (var item in data['data']) { 115 | result.add(Category( 116 | item['name'], 117 | item['id'], 118 | item['image_url'], 119 | item['night_image_url'], 120 | )); 121 | } 122 | return result; 123 | } 124 | 125 | Future> listChannelsByCategoryId( 126 | String categoryId, String country) async { 127 | var url = Uri( 128 | host: host, 129 | scheme: 'https', 130 | path: '/top_channels/v2', 131 | queryParameters: { 132 | 'category_id': categoryId, 133 | 'country': country, 134 | }, 135 | ); 136 | 137 | var response = await http.get(url); 138 | var body = utf8.decode(response.bodyBytes); 139 | Map data = jsonDecode(body); 140 | 141 | List subscriptions = []; 142 | if (data['data'] == null) { 143 | return subscriptions; 144 | } 145 | 146 | for (var item in data['data']['list']) { 147 | subscriptions.add(SubscriptionModel.fromMap({ 148 | 'rssFeedUrl': item['rss_url'], 149 | 'title': item['title'].trim(), 150 | 'description': item['description'].trim(), 151 | 'imageUrl': item['small_cover_url'], 152 | 'link': item['link'], 153 | 'categories': item['keywords'].join(','), 154 | 'author': item['author'].trim(), 155 | 'email': '', 156 | })); 157 | } 158 | return subscriptions; 159 | } 160 | -------------------------------------------------------------------------------- /lib/api/share.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | import 'package:retry/retry.dart'; 5 | import 'package:crypto/crypto.dart'; 6 | 7 | import 'package:http/http.dart' as http; 8 | 9 | const password = 'cjp2PGN3zuf5cfh'; 10 | const headers = {'Content-Type': 'application/json'}; 11 | 12 | Future getShortUrl(Uri origin) async { 13 | var api = Uri( 14 | scheme: 'https', 15 | host: 's.anycast.website', 16 | path: '/', 17 | ); 18 | 19 | Object? err; 20 | var resp = http.Response('', 500); 21 | 22 | try { 23 | resp = await (retry( 24 | () => http 25 | .post(api, 26 | headers: headers, 27 | body: jsonEncode({ 28 | "cmd": "add", 29 | "url": origin.toString(), 30 | "password": password, 31 | "key": getMd5(origin.toString()), 32 | })) 33 | .timeout(const Duration(seconds: 3)), 34 | retryIf: (e) => e is SocketException || e is TimeoutException, 35 | maxAttempts: 3, 36 | )); 37 | } catch (e) { 38 | err = e; 39 | } 40 | if (err != null || resp.statusCode != 200) { 41 | return null; 42 | } 43 | var body = utf8.decode(resp.bodyBytes); 44 | Map data = jsonDecode(body); 45 | if (data['status'] != 200) { 46 | return null; 47 | } 48 | return Uri( 49 | scheme: 'https', 50 | host: 's.kindjeff.com', 51 | path: '/${data['key']}', 52 | ).toString(); 53 | } 54 | 55 | String getMd5(String input) { 56 | return md5.convert(utf8.encode(input)).toString(); 57 | } 58 | -------------------------------------------------------------------------------- /lib/api/subtitles.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'package:anycast/api/error_handler.dart'; 3 | import 'package:anycast/utils/http_client.dart'; 4 | import 'package:http/http.dart' as http; 5 | 6 | const host = 'api.anycast.website'; 7 | 8 | class Subtitle { 9 | double? start; 10 | double? end; 11 | String? text; 12 | 13 | Map toJson() => { 14 | 'start': start, 15 | 'end': end, 16 | 'text': text, 17 | }; 18 | 19 | static Subtitle fromMap(Map json) { 20 | var subtitle = Subtitle(); 21 | subtitle.start = json['start']; 22 | subtitle.end = json['end']; 23 | subtitle.text = json['text']; 24 | return subtitle; 25 | } 26 | } 27 | 28 | class SubtitleResult { 29 | String? status; 30 | String? language; 31 | List? subtitles; 32 | String? summary; 33 | } 34 | 35 | Future getSubtitles(String enclosureUrl) async { 36 | var url = Uri( 37 | host: host, 38 | scheme: 'https', 39 | path: '/subtitles', 40 | ); 41 | var req = {'enclosure_url': enclosureUrl}; 42 | 43 | var response = await reqWithAuth(url.toString(), method: "POST", data: req); 44 | if (response.statusCode < 200 || response.statusCode >= 300) { 45 | ErrorHandler.handle(response.statusCode, response); 46 | return SubtitleResult()..status = 'failed'; 47 | } 48 | 49 | var body = utf8.decode(response.bodyBytes); 50 | Map data = jsonDecode(body); 51 | 52 | var result = SubtitleResult(); 53 | if (data['status'] != 'succeeded') { 54 | result.status = data['status']; 55 | return result; 56 | } 57 | 58 | result.language = data['subtitle']['detected_language']; 59 | result.status = data['status']; 60 | result.summary = ''; 61 | 62 | result.subtitles = []; 63 | for (var item in data['subtitle']['segments']) { 64 | var subtitle = Subtitle(); 65 | subtitle.start = item['start']; 66 | subtitle.end = item['end']; 67 | subtitle.text = item['text']; 68 | result.subtitles!.add(subtitle); 69 | } 70 | return result; 71 | } 72 | 73 | Future?> getTranslation( 74 | String enclosureUrl, String language) async { 75 | var url = Uri( 76 | host: host, 77 | scheme: 'https', 78 | path: '/subtitles/translate', 79 | ); 80 | var req = jsonEncode({'enclosure_url': enclosureUrl, 'language': language}); 81 | var headers = {'Content-Type': 'application/json'}; 82 | 83 | var response = await http.post(url, body: req, headers: headers); 84 | var body = utf8.decode(response.bodyBytes); 85 | Map data = jsonDecode(body); 86 | if (data['translation'] == null) { 87 | return null; 88 | } 89 | 90 | var result = []; 91 | for (var item in data['translation']) { 92 | result.add(Subtitle.fromMap({ 93 | 'start': item['start'], 94 | 'end': item['end'], 95 | 'text': item['text'], 96 | })); 97 | } 98 | return result; 99 | } 100 | 101 | Future chatAPI(String enclosureUrl, String input, 102 | List> history) async { 103 | var url = Uri( 104 | host: host, 105 | scheme: 'https', 106 | path: '/subtitles/chat', 107 | ); 108 | 109 | var resp = await reqWithAuth(url.toString(), method: "POST", data: { 110 | 'enclosure_url': enclosureUrl, 111 | 'user_input': input, 112 | 'history': history, 113 | }); 114 | 115 | if (resp.statusCode < 200 || resp.statusCode >= 300) { 116 | return resp.body; 117 | } 118 | 119 | var body = utf8.decode(resp.bodyBytes); 120 | Map data = jsonDecode(body); 121 | return data['result']; 122 | } 123 | -------------------------------------------------------------------------------- /lib/api/user.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:anycast/api/error_handler.dart'; 4 | import 'package:anycast/utils/http_client.dart'; 5 | import 'package:flutter/cupertino.dart'; 6 | import 'package:jiffy/jiffy.dart'; 7 | 8 | const host = 'https://api.anycast.website'; 9 | 10 | class User { 11 | late String uid; 12 | Jiffy? expireAt; 13 | // this month left 14 | late int remaining; 15 | late int plus; 16 | 17 | User.fromJson(Map json) { 18 | uid = json['uid']; 19 | if (json['expired_at'] == null) { 20 | expireAt = null; 21 | } else { 22 | // 2024-07-29T15:35:52+00:00 23 | expireAt = Jiffy.parse( 24 | json['expired_at'], 25 | pattern: 'yyyy-MM-ddTHH:mm:ssZ', 26 | ); 27 | } 28 | remaining = json['remaining']; 29 | plus = json['plus']; 30 | } 31 | } 32 | 33 | Future getUser() async { 34 | var resp = await reqWithAuth('$host/user', method: 'GET'); 35 | 36 | if (resp.statusCode == 401) { 37 | ErrorHandler.handle401(); 38 | return null; 39 | } 40 | 41 | var data = jsonDecode(resp.body) as Map; 42 | 43 | try { 44 | return User.fromJson(data); 45 | } catch (e) { 46 | debugPrint(e.toString()); 47 | return null; 48 | } 49 | } 50 | 51 | Future deleteUser() async { 52 | var resp = await reqWithAuth('$host/user', method: 'DELETE'); 53 | 54 | if (resp.statusCode != 200) { 55 | ErrorHandler.handle(resp.statusCode, resp); 56 | return null; 57 | } 58 | 59 | return true; 60 | } 61 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:anycast/states/cache.dart'; 2 | import 'package:anycast/states/chat.dart'; 3 | import 'package:anycast/states/discover.dart'; 4 | import 'package:anycast/states/feed_episode.dart'; 5 | import 'package:anycast/states/import_indicator.dart'; 6 | import 'package:anycast/states/player.dart'; 7 | import 'package:anycast/states/playlist.dart'; 8 | import 'package:anycast/states/share.dart'; 9 | import 'package:anycast/states/subscription.dart'; 10 | import 'package:anycast/states/subtitle.dart'; 11 | import 'package:anycast/states/tab.dart'; 12 | import 'package:anycast/states/translation.dart'; 13 | import 'package:anycast/states/user.dart'; 14 | import 'package:anycast/styles.dart'; 15 | import 'package:anycast/utils/audio_handler.dart'; 16 | import 'package:anycast/widgets/bottom_nav_bar.dart'; 17 | import 'package:audio_service/audio_service.dart'; 18 | import 'package:flutter/material.dart'; 19 | import 'package:flutter/services.dart'; 20 | import 'package:get/get.dart'; 21 | import 'package:google_fonts/google_fonts.dart'; 22 | import 'pages/discover.dart'; 23 | import 'pages/playlists.dart'; 24 | import 'pages/podcasts.dart'; 25 | import 'package:firebase_core/firebase_core.dart'; 26 | import 'firebase_options.dart'; 27 | import 'package:sentry_flutter/sentry_flutter.dart'; 28 | import 'package:flutter_dotenv/flutter_dotenv.dart'; 29 | 30 | void main() async { 31 | WidgetsFlutterBinding.ensureInitialized(); 32 | SystemChrome.setPreferredOrientations([ 33 | DeviceOrientation.portraitUp, 34 | DeviceOrientation.portraitDown, 35 | ]); 36 | 37 | await dotenv.load(fileName: '.env'); 38 | 39 | await AudioService.init( 40 | builder: () => MyAudioHandler(), 41 | config: const AudioServiceConfig( 42 | androidNotificationChannelId: 'com.kindjeff.anycast.audio', 43 | androidNotificationChannelName: 'Anycast', 44 | androidNotificationOngoing: true, 45 | androidStopForegroundOnPause: true, 46 | androidNotificationIcon: 'mipmap/ic_launcher', 47 | ), 48 | ); 49 | 50 | await Firebase.initializeApp( 51 | options: DefaultFirebaseOptions.currentPlatform, 52 | ); 53 | 54 | await SentryFlutter.init( 55 | (options) { 56 | options.dsn = 57 | 'https://9168d8befab4c7bb5eeecd15beb2daa2@o359483.ingest.us.sentry.io/4507654787170304'; 58 | // Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring. 59 | // We recommend adjusting this value in production. 60 | options.tracesSampleRate = 1.0; 61 | // The sampling rate for profiling is relative to tracesSampleRate 62 | // Setting to 1.0 will profile 100% of sampled transactions: 63 | options.profilesSampleRate = 1.0; 64 | }, 65 | appRunner: () => runApp(const NavigationBarApp()), 66 | ); 67 | } 68 | 69 | class NavigationBarApp extends StatelessWidget { 70 | static final homeTabController = Get.put(HomeTabController()); 71 | 72 | const NavigationBarApp({super.key}); 73 | 74 | @override 75 | Widget build(BuildContext context) { 76 | Get.put(SettingsController()); 77 | Get.put(SubtitleController()); 78 | Get.put(SubscriptionController()); 79 | Get.put(PlayerController()); 80 | Get.put(PlaylistController()); 81 | Get.put(CacheController()); 82 | Get.lazyPut(() => DiscoverController()); 83 | Get.lazyPut(() => FeedEpisodeController()); 84 | Get.put(TranslationController()); 85 | Get.put(AuthController()); 86 | Get.put(RevenueCatController()); 87 | Get.put(ShareController()); 88 | Get.put(ChatController()); 89 | Get.put(ImportIndicatorController()); 90 | return GetMaterialApp( 91 | debugShowCheckedModeBanner: false, 92 | theme: ThemeData( 93 | appBarTheme: const AppBarTheme( 94 | toolbarHeight: 156, 95 | scrolledUnderElevation: 0, 96 | titleSpacing: 0, 97 | centerTitle: false, 98 | ), 99 | colorScheme: const ColorScheme( 100 | brightness: Brightness.dark, 101 | primary: DarkColor.primary, 102 | onPrimary: DarkColor.primaryLightMax, 103 | secondary: DarkColor.secondaryColor, 104 | onSecondary: DarkColor.secondaryColor, 105 | error: DarkColor.accentColor, 106 | onError: DarkColor.accentColor, 107 | surface: DarkColor.primaryBackgroundDark, 108 | onSurface: DarkColor.primaryBackgroundDark), 109 | textTheme: TextTheme( 110 | displayLarge: DarkColor.mainTitle, 111 | displayMedium: DarkColor.secondaryTitle, 112 | displaySmall: DarkColor.defaultMainText, 113 | ), 114 | bottomSheetTheme: const BottomSheetThemeData( 115 | modalBackgroundColor: Color(0xFF111316), 116 | modalElevation: 0, 117 | backgroundColor: Color(0xFF111316), 118 | shape: RoundedRectangleBorder( 119 | borderRadius: BorderRadius.only( 120 | topLeft: Radius.circular(24), 121 | topRight: Radius.circular(24), 122 | ), 123 | ), 124 | ), 125 | tabBarTheme: TabBarTheme( 126 | labelColor: const Color(0xFF6EE7B7), 127 | indicatorColor: const Color(0xFF6EE7B7), 128 | indicatorSize: TabBarIndicatorSize.label, 129 | dividerColor: const Color(0xFF9CA3AF), 130 | dividerHeight: 1, 131 | labelStyle: TextStyle( 132 | fontSize: 12, 133 | fontFamily: GoogleFonts.comfortaa().fontFamily, 134 | fontWeight: FontWeight.w400, 135 | ), 136 | unselectedLabelColor: const Color(0xFFD1D5DB), 137 | unselectedLabelStyle: TextStyle( 138 | fontSize: 12, 139 | fontFamily: GoogleFonts.comfortaa().fontFamily, 140 | fontWeight: FontWeight.w400, 141 | ), 142 | ), 143 | ), 144 | home: Scaffold( 145 | body: Obx( 146 | () => IndexedStack( 147 | index: homeTabController.selectedIndex.value, 148 | children: const [ 149 | PodcastsPage(), 150 | Playlists(), 151 | Discover(), 152 | ], 153 | ), 154 | ), 155 | bottomNavigationBar: const BottomNavBar(), 156 | ), 157 | ); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /lib/models/episode.dart: -------------------------------------------------------------------------------- 1 | class Episode { 2 | // fields 3 | int? id; 4 | String? title; 5 | String? description; 6 | int? duration; // in milliseconds 7 | String? enclosureUrl; 8 | int? pubDate; // unix timestamp 9 | String? imageUrl; 10 | String? channelTitle; 11 | String? rssFeedUrl; 12 | } 13 | -------------------------------------------------------------------------------- /lib/models/feed_episode.dart: -------------------------------------------------------------------------------- 1 | import 'package:sqflite/sqflite.dart'; 2 | import 'episode.dart'; 3 | 4 | String tableName = 'feedEpisode'; 5 | 6 | Future feedEpisodeTableCreator(DatabaseExecutor db) { 7 | return db.execute(""" 8 | CREATE TABLE IF NOT EXISTS $tableName ( 9 | id INTEGER PRIMARY KEY, 10 | title TEXT, 11 | description TEXT, 12 | duration INTEGER, 13 | enclosureUrl TEXT UNIQUE, 14 | pubDate INTEGER, 15 | imageUrl TEXT, 16 | channelTitle TEXT, 17 | rssFeedUrl TEXT 18 | ) 19 | """); 20 | } 21 | 22 | class FeedEpisodeModel extends Episode { 23 | Map toMap() { 24 | var map = { 25 | 'channelTitle': channelTitle, 26 | 'rssFeedUrl': rssFeedUrl, 27 | 'title': title, 28 | 'description': description, 29 | 'duration': duration, 30 | 'enclosureUrl': enclosureUrl, 31 | 'pubDate': pubDate, 32 | 'imageUrl': imageUrl, 33 | }; 34 | if (id != null) { 35 | map['id'] = id; 36 | } 37 | 38 | return map; 39 | } 40 | 41 | FeedEpisodeModel.fromMap(Map map) { 42 | id = map['id']; 43 | channelTitle = map['channelTitle']; 44 | rssFeedUrl = map['rssFeedUrl']; 45 | title = map['title']; 46 | description = map['description']; 47 | duration = map['duration']; 48 | enclosureUrl = map['enclosureUrl']; 49 | pubDate = map['pubDate']; 50 | imageUrl = map['imageUrl']; 51 | } 52 | 53 | static Future> listAll(DatabaseExecutor db) async { 54 | return db 55 | .rawQuery('SELECT * FROM $tableName ORDER BY pubDate DESC') 56 | .then((List> maps) { 57 | return List.generate(maps.length, (i) { 58 | return FeedEpisodeModel.fromMap(maps[i]); 59 | }); 60 | }); 61 | } 62 | 63 | Future save(DatabaseExecutor db) async { 64 | if (id == null) { 65 | id = await db.insert(tableName, toMap()); 66 | } else { 67 | await db.update(tableName, toMap(), where: 'id = ?', whereArgs: [id]); 68 | } 69 | } 70 | 71 | static Future removeByEnclosureUrls( 72 | DatabaseExecutor db, List enclosureUrls) async { 73 | await db.rawDelete( 74 | 'DELETE FROM $tableName WHERE enclosureUrl IN (${enclosureUrls.map((e) => '?').join(',')})', 75 | enclosureUrls, 76 | ); 77 | } 78 | 79 | static Future insertMany( 80 | DatabaseExecutor db, List episodes) async { 81 | var batch = db.batch(); 82 | for (var episode in episodes) { 83 | batch.insert(tableName, episode.toMap(), 84 | conflictAlgorithm: ConflictAlgorithm.replace); 85 | } 86 | await batch.commit(noResult: true); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /lib/models/helper.dart: -------------------------------------------------------------------------------- 1 | import 'package:anycast/models/history_episode.dart'; 2 | import 'package:anycast/models/settings.dart'; 3 | import 'package:anycast/models/subtitle.dart'; 4 | import 'package:anycast/models/translation.dart'; 5 | 6 | import 'feed_episode.dart'; 7 | import 'player.dart'; 8 | import 'playlist.dart'; 9 | import 'playlist_episode.dart'; 10 | import 'subscription.dart'; 11 | import 'table_creator.dart'; 12 | import 'package:sqflite/sqflite.dart'; 13 | import 'package:path/path.dart'; 14 | 15 | List tableCreators = [ 16 | feedEpisodeTableCreator, 17 | playlistEpisodeTableCreator, 18 | subscriptionTableCreator, 19 | playlistTableCreator, 20 | playerTableCreator, 21 | settingsTableCreator, 22 | subtitleTableCreator, 23 | historyEpisodeTableCreator, 24 | translationCreateTable, 25 | ]; 26 | 27 | var migrations = { 28 | // 3 -> 4 29 | 4: [ 30 | 'ALTER TABLE settings ADD COLUMN continuousPlaying INTEGER DEFAULT 1', 31 | ], 32 | }; 33 | 34 | class DatabaseHelper { 35 | static final DatabaseHelper _instance = DatabaseHelper.internal(); 36 | factory DatabaseHelper() => _instance; 37 | static Database? _db; 38 | 39 | DatabaseHelper.internal(); 40 | 41 | Future get db async { 42 | if (_db != null) return _db!; 43 | _db = await initDb(); 44 | return _db!; 45 | } 46 | 47 | Future initDb() async { 48 | String databasesPath = await getDatabasesPath(); 49 | String path = join(databasesPath, 'anycast.db'); 50 | 51 | // delete existing if any 52 | // await deleteDatabase(path); 53 | 54 | // create new 55 | Database db = await openDatabase( 56 | path, 57 | version: 4, 58 | onCreate: (Database db, version) async { 59 | for (TableCreator tableCreator in tableCreators) { 60 | await tableCreator(db); 61 | } 62 | }, 63 | onUpgrade: (db, oldVersion, newVersion) { 64 | var sorted = migrations.keys.toList()..sort(); 65 | 66 | for (int version in sorted) { 67 | if (oldVersion < version) { 68 | for (String sql in migrations[version]!) { 69 | db.execute(sql); 70 | print('executed $sql'); 71 | } 72 | } 73 | } 74 | }, 75 | ); 76 | return db; 77 | } 78 | 79 | Future close() async { 80 | var dbClient = await db; 81 | return dbClient.close(); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /lib/models/history_episode.dart: -------------------------------------------------------------------------------- 1 | import 'package:sqflite/sqflite.dart'; 2 | import 'episode.dart'; 3 | 4 | String tableName = 'historyEpisode'; 5 | 6 | Future historyEpisodeTableCreator(DatabaseExecutor db) { 7 | return db.execute(""" 8 | CREATE TABLE IF NOT EXISTS $tableName ( 9 | id INTEGER PRIMARY KEY, 10 | title TEXT, 11 | description TEXT, 12 | duration INTEGER, 13 | enclosureUrl TEXT UNIQUE, 14 | pubDate INTEGER, 15 | imageUrl TEXT, 16 | channelTitle TEXT, 17 | rssFeedUrl TEXT 18 | ) 19 | """); 20 | } 21 | 22 | class HistoryEpisodeModel extends Episode { 23 | Map toMap() { 24 | var map = { 25 | 'channelTitle': channelTitle, 26 | 'rssFeedUrl': rssFeedUrl, 27 | 'title': title, 28 | 'description': description, 29 | 'duration': duration, 30 | 'enclosureUrl': enclosureUrl, 31 | 'pubDate': pubDate, 32 | 'imageUrl': imageUrl, 33 | }; 34 | if (id != null) { 35 | map['id'] = id; 36 | } 37 | 38 | return map; 39 | } 40 | 41 | HistoryEpisodeModel.fromMap(Map map) { 42 | id = map['id']; 43 | channelTitle = map['channelTitle']; 44 | rssFeedUrl = map['rssFeedUrl']; 45 | title = map['title']; 46 | description = map['description']; 47 | duration = map['duration']; 48 | enclosureUrl = map['enclosureUrl']; 49 | pubDate = map['pubDate']; 50 | imageUrl = map['imageUrl']; 51 | } 52 | 53 | static Future> listAll(DatabaseExecutor db) async { 54 | return db 55 | .rawQuery('SELECT * FROM $tableName ORDER BY id DESC') 56 | .then((List> maps) { 57 | return List.generate(maps.length, (i) { 58 | return HistoryEpisodeModel.fromMap(maps[i]); 59 | }); 60 | }); 61 | } 62 | 63 | static Future deleteAll(DatabaseExecutor db) async { 64 | await db.delete(tableName); 65 | } 66 | 67 | static Future delete(DatabaseExecutor db, String enclosureUrl) async { 68 | await db.delete( 69 | tableName, 70 | where: 'enclosureUrl = ?', 71 | whereArgs: [enclosureUrl], 72 | ); 73 | } 74 | 75 | // delete first and then insert 76 | static Future insert( 77 | DatabaseExecutor db, HistoryEpisodeModel model) async { 78 | await db.delete( 79 | tableName, 80 | where: 'enclosureUrl = ?', 81 | whereArgs: [model.enclosureUrl], 82 | ); 83 | await db.insert( 84 | tableName, 85 | model.toMap(), 86 | conflictAlgorithm: ConflictAlgorithm.replace, 87 | ); 88 | } 89 | 90 | static Future deleteMany( 91 | DatabaseExecutor db, List enclosureUrls) async { 92 | await db.delete( 93 | tableName, 94 | where: 'enclosureUrl IN (${enclosureUrls.map((e) => '?').join(',')})', 95 | whereArgs: enclosureUrls, 96 | ); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /lib/models/player.dart: -------------------------------------------------------------------------------- 1 | import 'package:sqflite/sqflite.dart'; 2 | 3 | var tableName = 'player'; 4 | Future playerTableCreator(DatabaseExecutor db) { 5 | return db.execute(""" 6 | CREATE TABLE IF NOT EXISTS $tableName ( 7 | id INTEGER PRIMARY KEY, 8 | currentPlaylistId INTEGER 9 | ) 10 | """).then((v) { 11 | // create default 1 if not exists 12 | db.rawInsert(""" 13 | INSERT OR IGNORE INTO $tableName (id, currentPlaylistId) 14 | VALUES (1, NULL) 15 | """); 16 | }); 17 | } 18 | 19 | class PlayerModel { 20 | int? id; 21 | int? currentPlaylistId; 22 | 23 | Map toMap() { 24 | var map = { 25 | 'currentPlaylistId': currentPlaylistId, 26 | }; 27 | if (id != null) { 28 | map['id'] = id; 29 | } 30 | 31 | return map; 32 | } 33 | 34 | PlayerModel.empty(); 35 | 36 | PlayerModel.fromMap(Map map) { 37 | id = map['id']; 38 | currentPlaylistId = map['currentPlaylistId']; 39 | } 40 | 41 | static Future update(DatabaseExecutor db, PlayerModel player) async { 42 | // await db.update(tableName, player.toMap(), where: 'id = ?', whereArgs: [1]); 43 | // insert, on conflict replace 44 | await db.rawInsert(""" 45 | INSERT OR REPLACE INTO $tableName (id, currentPlaylistId) 46 | VALUES (1, ?)""", [player.currentPlaylistId]); 47 | } 48 | 49 | static Future get(DatabaseExecutor db) async { 50 | return db 51 | .rawQuery('SELECT * FROM $tableName WHERE id = 1') 52 | .then((List> maps) { 53 | return PlayerModel.fromMap(maps[0]); 54 | }); 55 | } 56 | 57 | static Future delete(DatabaseExecutor db) async { 58 | await db.delete(tableName, where: 'id = ?', whereArgs: [1]); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /lib/models/playlist.dart: -------------------------------------------------------------------------------- 1 | import 'package:sqflite/sqflite.dart'; 2 | 3 | var tableName = 'playlist'; 4 | Future playlistTableCreator(DatabaseExecutor db) { 5 | return db.execute(""" 6 | CREATE TABLE IF NOT EXISTS $tableName ( 7 | id INTEGER PRIMARY KEY, 8 | title TEXT, 9 | position INTEGER 10 | ) 11 | """).then((v) { 12 | // create default 1 if not exists 13 | db.rawInsert(""" 14 | INSERT OR IGNORE INTO $tableName (id, title, position) 15 | VALUES (1, 'Default', 1) 16 | """); 17 | }); 18 | } 19 | 20 | class PlaylistModel { 21 | int? id; 22 | String? title; 23 | int? position; 24 | 25 | Map toMap() { 26 | var map = { 27 | 'title': title, 28 | 'position': position, 29 | }; 30 | if (id != null) { 31 | map['id'] = id; 32 | } 33 | 34 | return map; 35 | } 36 | 37 | PlaylistModel.fromMap(Map map) { 38 | id = map['id']; 39 | title = map['title']; 40 | position = map['position']; 41 | } 42 | 43 | static Future> listAll(DatabaseExecutor db) async { 44 | return db 45 | .rawQuery('SELECT * FROM $tableName ORDER BY position ASC') 46 | .then((List> maps) { 47 | return List.generate(maps.length, (i) { 48 | return PlaylistModel.fromMap(maps[i]); 49 | }); 50 | }); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /lib/models/playlist_episode.dart: -------------------------------------------------------------------------------- 1 | import 'package:audio_service/audio_service.dart'; 2 | import 'package:sqflite/sqflite.dart'; 3 | 4 | import 'episode.dart'; 5 | 6 | String tableName = 'playlistEpisode'; 7 | Future playlistEpisodeTableCreator(DatabaseExecutor db) { 8 | return db.execute(""" 9 | CREATE TABLE IF NOT EXISTS $tableName ( 10 | id INTEGER PRIMARY KEY, 11 | title TEXT, 12 | description TEXT, 13 | duration INTEGER, 14 | enclosureUrl TEXT UNIQUE, 15 | pubDate INTEGER, 16 | imageUrl TEXT, 17 | channelTitle TEXT, 18 | rssFeedUrl TEXT, 19 | playlistId INTEGER, 20 | position REAL, 21 | playedDuration INTEGER 22 | ) 23 | """); 24 | } 25 | 26 | const minPositionGap = 0.0005; 27 | 28 | class PlaylistEpisodeModel extends Episode { 29 | int? playlistId; 30 | double? position; 31 | int? playedDuration; // in milliseconds 32 | 33 | Map toMap() { 34 | var map = { 35 | 'playlistId': playlistId, 36 | 'position': position, 37 | 'playedDuration': playedDuration, 38 | 'title': title, 39 | 'description': description, 40 | 'duration': duration, 41 | 'enclosureUrl': enclosureUrl, 42 | 'pubDate': pubDate, 43 | 'imageUrl': imageUrl, 44 | 'channelTitle': channelTitle, 45 | 'rssFeedUrl': rssFeedUrl, 46 | }; 47 | if (id != null) { 48 | map['id'] = id; 49 | } 50 | 51 | return map; 52 | } 53 | 54 | PlaylistEpisodeModel.empty(); 55 | 56 | PlaylistEpisodeModel.fromMap(Map map) { 57 | id = map['id']; 58 | playlistId = map['playlistId']; 59 | position = map['position']; 60 | playedDuration = map['playedDuration']; 61 | title = map['title']; 62 | description = map['description']; 63 | duration = map['duration']; 64 | enclosureUrl = map['enclosureUrl']; 65 | pubDate = map['pubDate']; 66 | imageUrl = map['imageUrl']; 67 | channelTitle = map['channelTitle']; 68 | rssFeedUrl = map['rssFeedUrl']; 69 | } 70 | 71 | static Future> listByPlaylistId( 72 | DatabaseExecutor db, playlistId) async { 73 | return db.rawQuery( 74 | 'SELECT * FROM $tableName WHERE playlistId = ? ORDER BY position ASC', 75 | [playlistId]).then((List> maps) { 76 | return List.generate(maps.length, (i) { 77 | return PlaylistEpisodeModel.fromMap(maps[i]); 78 | }); 79 | }); 80 | } 81 | 82 | Future save(DatabaseExecutor db) async { 83 | if (id == null) { 84 | id = await db.insert(tableName, toMap()); 85 | } else { 86 | await db.update(tableName, toMap(), where: 'id = ?', whereArgs: [id]); 87 | } 88 | } 89 | 90 | static Future insertOrUpdateByIndex(DatabaseExecutor db, int playlistId, 91 | int index, PlaylistEpisodeModel ep) async { 92 | var episode = await getByEnclosureUrl(db, ep.enclosureUrl!); 93 | 94 | episode ??= ep; 95 | 96 | var episodes = await listByPlaylistId(db, playlistId); 97 | var positionLeft = index > 0 ? episodes[index - 1].position : null; 98 | var positionRight = 99 | index < episodes.length ? episodes[index].position : null; 100 | var isTooClose = false; 101 | 102 | if (positionLeft != null && positionRight != null) { 103 | episode.position = (positionLeft + positionRight) / 2; 104 | if (positionRight - positionLeft < minPositionGap) { 105 | isTooClose = true; 106 | } 107 | } else if (positionLeft != null) { 108 | episode.position = positionLeft + minPositionGap * 3; 109 | } else if (positionRight != null) { 110 | episode.position = positionRight - minPositionGap * 3; 111 | } else { 112 | episode.position = 0; 113 | } 114 | 115 | if (episode.id == null) { 116 | await episode.save(db); 117 | } else { 118 | await db.update(tableName, episode.toMap(), 119 | where: 'id = ?', whereArgs: [episode.id]); 120 | } 121 | 122 | if (isTooClose) { 123 | await _reorder(db, playlistId); 124 | } 125 | } 126 | 127 | static Future _reorder(DatabaseExecutor db, int playlistId) async { 128 | var episodes = await listByPlaylistId(db, playlistId); 129 | episodes.sort((a, b) => a.position!.compareTo(b.position!)); 130 | for (var i = 0; i < episodes.length; i++) { 131 | episodes[i].position = i.toDouble(); 132 | await db.update(tableName, episodes[i].toMap(), 133 | where: 'id = ?', whereArgs: [episodes[i].id]); 134 | } 135 | } 136 | 137 | static Future getByEnclosureUrl( 138 | DatabaseExecutor db, String enclosureUrl) async { 139 | return db.rawQuery('SELECT * FROM $tableName WHERE enclosureUrl = ?', 140 | [enclosureUrl]).then((List> maps) { 141 | if (maps.isEmpty) { 142 | return null; 143 | } 144 | return PlaylistEpisodeModel.fromMap(maps[0]); 145 | }); 146 | } 147 | 148 | static Future deleteByEnclosureUrl( 149 | DatabaseExecutor db, String enclosureUrl) async { 150 | await db.delete(tableName, 151 | where: 'enclosureUrl = ?', whereArgs: [enclosureUrl]); 152 | } 153 | 154 | void updatePlayedDuration(DatabaseExecutor db) async { 155 | await db.update(tableName, {'playedDuration': playedDuration}, 156 | where: 'enclosureUrl = ?', whereArgs: [enclosureUrl]); 157 | } 158 | 159 | // format: 21:32 / 31:56 160 | static String getPlayedAndTotalTime(int playedDuration, int duration) { 161 | var played = Duration(milliseconds: playedDuration); 162 | var total = Duration(milliseconds: duration); 163 | return '${played.inMinutes}:${(played.inSeconds % 60).toString().padLeft(2, '0')} / ${total.inMinutes}:${(total.inSeconds % 60).toString().padLeft(2, '0')}'; 164 | } 165 | 166 | MediaItem toMediaItem() { 167 | return MediaItem( 168 | id: enclosureUrl!, 169 | album: channelTitle, 170 | title: title!, 171 | artUri: Uri.parse(imageUrl!), 172 | duration: duration != null ? Duration(milliseconds: duration!) : null, 173 | ); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /lib/models/settings.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:sqflite/sqflite.dart'; 4 | 5 | var tableName = 'settings'; 6 | Future settingsTableCreator(DatabaseExecutor db) { 7 | return db.execute(""" 8 | CREATE TABLE IF NOT EXISTS $tableName ( 9 | id INTEGER PRIMARY KEY, 10 | darkMode INTEGER, 11 | speed REAL, 12 | skipSilence INTEGER, 13 | autoSleepTimer TEXT, 14 | maxCacheCount INTEGER, 15 | countryCode TEXT, 16 | targetLanguage TEXT, 17 | autoRefreshInterval INTEGER, 18 | maxFeedEpisodes INTEGER, 19 | maxHistoryEpisodes INTEGER, 20 | continuousPlaying INTEGER DEFAULT 1 21 | ) 22 | """).then((v) { 23 | var code = Platform.localeName; 24 | // en_US / zh_Hans_CN / zh_CN 25 | var languageAndCountry = code.split('_'); 26 | var language = 'en'; 27 | var country = 'US'; 28 | if (languageAndCountry.length > 1) { 29 | language = code.split('_')[0]; 30 | country = code.split('_')[languageAndCountry.length - 1]; 31 | } 32 | 33 | db.rawInsert(""" 34 | INSERT OR IGNORE INTO $tableName (id, darkMode, speed, skipSilence, autoSleepTimer, maxCacheCount, countryCode, targetLanguage, autoRefreshInterval, maxFeedEpisodes, maxHistoryEpisodes) 35 | VALUES (1, 0, 1.0, 0, '0,0,0', 10, '$country', '$language', 300, 100, 100) 36 | """); 37 | }); 38 | } 39 | 40 | class SettingsModel { 41 | int? id; 42 | bool? darkMode; 43 | double? speed; 44 | bool? skipSilence; 45 | String? autoSleepTimer; // startHour,endHour,countdownMinIndex 46 | int? maxCacheCount; 47 | String? countryCode; 48 | String? targetLanguage; 49 | int? autoRefreshInterval; // seconds 50 | int? maxFeedEpisodes; 51 | int? maxHistoryEpisodes; 52 | bool? continuousPlaying; 53 | 54 | Map toMap() { 55 | var map = { 56 | 'darkMode': darkMode, 57 | 'speed': speed, 58 | 'skipSilence': skipSilence, 59 | 'autoSleepTimer': autoSleepTimer, 60 | 'maxCacheCount': maxCacheCount, 61 | 'countryCode': countryCode, 62 | 'targetLanguage': targetLanguage, 63 | 'autoRefreshInterval': autoRefreshInterval, 64 | 'maxFeedEpisodes': maxFeedEpisodes, 65 | 'maxHistoryEpisodes': maxHistoryEpisodes, 66 | 'continuousPlaying': continuousPlaying, 67 | }; 68 | if (id != null) { 69 | map['id'] = id; 70 | } 71 | 72 | return map; 73 | } 74 | 75 | SettingsModel.fromMap(Map map) { 76 | id = map['id']; 77 | darkMode = map['darkMode'] == 1; 78 | speed = map['speed']; 79 | skipSilence = map['skipSilence'] == 1; 80 | autoSleepTimer = map['autoSleepTimer']; 81 | maxCacheCount = map['maxCacheCount']; 82 | countryCode = map['countryCode']; 83 | targetLanguage = map['targetLanguage']; 84 | autoRefreshInterval = map['autoRefreshInterval']; 85 | maxFeedEpisodes = map['maxFeedEpisodes']; 86 | maxHistoryEpisodes = map['maxHistoryEpisodes']; 87 | continuousPlaying = map['continuousPlaying'] == 1; 88 | } 89 | 90 | static Future get(DatabaseExecutor db) async { 91 | return db 92 | .rawQuery('SELECT * FROM $tableName WHERE id = 1') 93 | .then((List> maps) { 94 | return SettingsModel.fromMap(maps[0]); 95 | }); 96 | } 97 | 98 | static Future setDarkMode(DatabaseExecutor db, bool darkMode) async { 99 | return db.rawUpdate(""" 100 | UPDATE $tableName 101 | SET darkMode = ? 102 | WHERE id = 1 103 | """, [darkMode ? 1 : 0]); 104 | } 105 | 106 | static Future setSpeed(DatabaseExecutor db, double speed) async { 107 | return db.rawUpdate(""" 108 | UPDATE $tableName 109 | SET speed = ? 110 | WHERE id = 1 111 | """, [speed]); 112 | } 113 | 114 | static Future setSkipSilence( 115 | DatabaseExecutor db, bool skipSilence) async { 116 | return db.rawUpdate(""" 117 | UPDATE $tableName 118 | SET skipSilence = ? 119 | WHERE id = 1 120 | """, [skipSilence ? 1 : 0]); 121 | } 122 | 123 | static Future setAutoSleepTimer( 124 | DatabaseExecutor db, int start, int end, int minsIndex) async { 125 | var autoSleepTimer = '$start,$end,$minsIndex'; 126 | return db.rawUpdate(""" 127 | UPDATE $tableName 128 | SET autoSleepTimer = ? 129 | WHERE id = 1 130 | """, [autoSleepTimer]); 131 | } 132 | 133 | static Future setContinuousPlaying( 134 | DatabaseExecutor db, bool continuousPlaying) async { 135 | return db.rawUpdate(""" 136 | UPDATE $tableName 137 | SET continuousPlaying = ? 138 | WHERE id = 1 139 | """, [continuousPlaying ? 1 : 0]); 140 | } 141 | 142 | static Future set( 143 | DatabaseExecutor db, String field, dynamic value) async { 144 | await db.rawUpdate(""" 145 | UPDATE $tableName 146 | SET $field = ? 147 | WHERE id = 1 148 | """, [value]); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /lib/models/subscription.dart: -------------------------------------------------------------------------------- 1 | import 'package:anycast/utils/rss_fetcher.dart'; 2 | import 'package:sqflite/sqflite.dart'; 3 | 4 | Future subscriptionTableCreator(DatabaseExecutor db) { 5 | return db.execute(""" 6 | CREATE TABLE IF NOT EXISTS subscription ( 7 | id INTEGER PRIMARY KEY, 8 | rssFeedUrl TEXT UNIQUE, 9 | title TEXT UNIQUE, 10 | description TEXT, 11 | imageUrl TEXT, 12 | link TEXT, 13 | categories TEXT, 14 | author TEXT, 15 | email TEXT, 16 | lastUpdated INTEGER 17 | ) 18 | """); 19 | } 20 | 21 | class SubscriptionModel { 22 | int? id; 23 | String? rssFeedUrl; 24 | String? title; 25 | String? description; 26 | String? imageUrl; 27 | String? link; 28 | String? categories; // comma separated list of categories 29 | String? author; 30 | String? email; 31 | int? lastUpdated; // unix timestamp 32 | 33 | SubscriptionModel.empty(); 34 | 35 | Map toMap() { 36 | var map = { 37 | 'rssFeedUrl': rssFeedUrl, 38 | 'title': title, 39 | 'description': description, 40 | 'imageUrl': imageUrl, 41 | 'link': link, 42 | 'categories': categories, 43 | 'author': author, 44 | 'email': email, 45 | 'lastUpdated': lastUpdated, 46 | }; 47 | if (id != null) { 48 | map['id'] = id; 49 | } 50 | 51 | return map; 52 | } 53 | 54 | SubscriptionModel.fromMap(Map map) { 55 | id = map['id']; 56 | rssFeedUrl = map['rssFeedUrl']; 57 | title = map['title']; 58 | description = map['description']; 59 | imageUrl = map['imageUrl']; 60 | link = map['link']; 61 | categories = map['categories']; 62 | author = map['author']; 63 | email = map['email']; 64 | lastUpdated = map['lastUpdated']; 65 | } 66 | 67 | Future save(DatabaseExecutor db) async { 68 | if (id == null) { 69 | id = await db.insert('subscription', toMap()); 70 | } else { 71 | await db 72 | .update('subscription', toMap(), where: 'id = ?', whereArgs: [id]); 73 | } 74 | } 75 | 76 | static Future> listAll(DatabaseExecutor db) async { 77 | return db 78 | .rawQuery('SELECT * FROM subscription ORDER BY title ASC') 79 | .then((List> maps) { 80 | return List.generate(maps.length, (i) { 81 | return SubscriptionModel.fromMap(maps[i]); 82 | }); 83 | }); 84 | } 85 | 86 | static Future addMany( 87 | DatabaseExecutor db, List subscriptions) async { 88 | Batch batch = db.batch(); 89 | // insert or update 90 | for (var subscription in subscriptions) { 91 | batch.insert('subscription', subscription.toMap(), 92 | conflictAlgorithm: ConflictAlgorithm.replace); 93 | } 94 | await batch.commit(noResult: true); 95 | } 96 | 97 | static Future remove( 98 | DatabaseExecutor db, SubscriptionModel subscription) async { 99 | await db.delete('subscription', 100 | where: 'rssFeedUrl = ? or title = ?', 101 | whereArgs: [subscription.rssFeedUrl, subscription.title]); 102 | } 103 | 104 | Future listAllEpisodes() async { 105 | return fetchPodcastsByUrls([rssFeedUrl!], onlyFistEpisode: false) 106 | .then((value) { 107 | if (value.isEmpty) { 108 | return PodcastImportData(this, []); 109 | } 110 | return value[0]; 111 | }); 112 | } 113 | 114 | static Future getOrFetch( 115 | DatabaseExecutor db, String rssFeedUrl) async { 116 | return db.rawQuery('SELECT * FROM subscription WHERE rssFeedUrl = ?', 117 | [rssFeedUrl]).then((List> maps) async { 118 | if (maps.isEmpty) { 119 | var data = await fetchPodcastsByUrls([rssFeedUrl]); 120 | return data[0].subscription; 121 | } else { 122 | return SubscriptionModel.fromMap(maps.first); 123 | } 124 | }); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /lib/models/subtitle.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:anycast/api/subtitles.dart'; 4 | import 'package:anycast/utils/formatters.dart'; 5 | import 'package:sqflite/sqflite.dart'; 6 | 7 | var tableName = 'subtitle'; 8 | Future subtitleTableCreator(DatabaseExecutor db) async { 9 | await db.execute(""" 10 | CREATE TABLE IF NOT EXISTS $tableName ( 11 | id INTEGER PRIMARY KEY, 12 | enclosureUrl TEXT UNIQUE, 13 | status TEXT, 14 | subtitle TEXT, 15 | language TEXT, 16 | summary TEXT 17 | ) 18 | """); 19 | } 20 | 21 | class SubtitleModel { 22 | int? id; 23 | String? enclosureUrl; 24 | String? status; 25 | String? subtitle; 26 | String? language; 27 | String? summary; 28 | 29 | SubtitleModel.empty(); 30 | 31 | Map toMap() { 32 | var map = { 33 | 'enclosureUrl': enclosureUrl, 34 | 'status': status, 35 | 'subtitle': subtitle, 36 | 'language': language, 37 | 'summary': summary, 38 | }; 39 | if (id != null) { 40 | map['id'] = id; 41 | } 42 | 43 | return map; 44 | } 45 | 46 | SubtitleModel.fromMap(Map map) { 47 | id = map['id']; 48 | enclosureUrl = map['enclosureUrl']; 49 | status = map['status']; 50 | subtitle = map['subtitle']; 51 | language = map['language']; 52 | summary = map['summary']; 53 | } 54 | 55 | static Future get( 56 | DatabaseExecutor db, String enclosureUrl) async { 57 | var result = await db.query( 58 | tableName, 59 | where: 'enclosureUrl = ?', 60 | whereArgs: [enclosureUrl], 61 | ); 62 | if (result.isEmpty) { 63 | return SubtitleModel.empty(); 64 | } 65 | return SubtitleModel.fromMap(result.first); 66 | } 67 | 68 | static Future insert(DatabaseExecutor db, SubtitleModel model) async { 69 | // validation 70 | if (model.subtitle == null || 71 | model.subtitle!.isEmpty || 72 | model.language == null || 73 | model.subtitle == 'null') { 74 | return; 75 | } 76 | 77 | await db.insert( 78 | tableName, 79 | model.toMap(), 80 | conflictAlgorithm: ConflictAlgorithm.replace, 81 | ); 82 | } 83 | 84 | static Future> list(Database db) async { 85 | var result = await db.query(tableName); 86 | var map = {}; 87 | for (var item in result) { 88 | map[item['enclosureUrl'] as String] = item['status'] as String; 89 | } 90 | return map; 91 | } 92 | 93 | static Future delete(DatabaseExecutor db, String enclosureUrl) async { 94 | await db.delete( 95 | tableName, 96 | where: 'enclosureUrl = ?', 97 | whereArgs: [enclosureUrl], 98 | ); 99 | } 100 | 101 | String toLrc() { 102 | List subtitles = []; 103 | if (subtitle == null || subtitle!.isEmpty || subtitle == 'null') { 104 | return ''; 105 | } 106 | for (var item in jsonDecode(subtitle!)) { 107 | subtitles.add(Subtitle.fromMap(item)); 108 | } 109 | var lrc = ''; 110 | for (var i = 0; i < subtitles.length; i++) { 111 | var subtitle = subtitles[i]; 112 | lrc += '[${formatLrcTime(subtitle.start!)}]${subtitle.text}\n'; 113 | lrc += '[${formatLrcTime(subtitle.end!)}]\n'; 114 | } 115 | return lrc; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /lib/models/table_creator.dart: -------------------------------------------------------------------------------- 1 | import 'package:sqflite/sqflite.dart'; 2 | 3 | typedef TableCreator = Future Function(DatabaseExecutor db); 4 | -------------------------------------------------------------------------------- /lib/models/translation.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:anycast/api/subtitles.dart'; 4 | import 'package:anycast/utils/formatters.dart'; 5 | import 'package:sqflite/sqflite.dart'; 6 | 7 | var tableName = 'translation'; 8 | 9 | Future translationCreateTable(DatabaseExecutor db) async { 10 | await db.execute(""" 11 | CREATE TABLE IF NOT EXISTS $tableName ( 12 | id INTEGER PRIMARY KEY AUTOINCREMENT, 13 | enclosureUrl TEXT UNIQUE, 14 | status TEXT, 15 | translation TEXT, 16 | language TEXT 17 | ) 18 | """); 19 | } 20 | 21 | class TranslationModel { 22 | int? id; 23 | String? enclosureUrl; 24 | String? status; 25 | String? translation; 26 | String? language; 27 | 28 | TranslationModel.empty(); 29 | 30 | Map toMap() { 31 | var map = { 32 | 'enclosureUrl': enclosureUrl, 33 | 'status': status, 34 | 'translation': translation, 35 | 'language': language, 36 | }; 37 | if (id != null) { 38 | map['id'] = id; 39 | } 40 | 41 | return map; 42 | } 43 | 44 | TranslationModel.fromMap(Map map) { 45 | id = map['id']; 46 | enclosureUrl = map['enclosureUrl']; 47 | status = map['status']; 48 | translation = map['translation']; 49 | language = map['language']; 50 | } 51 | 52 | static Future get( 53 | DatabaseExecutor db, String enclosureUrl, String language) async { 54 | var result = await db.query( 55 | tableName, 56 | where: 'enclosureUrl = ? AND language = ?', 57 | whereArgs: [enclosureUrl, language], 58 | ); 59 | if (result.isEmpty) { 60 | return null; 61 | } 62 | return TranslationModel.fromMap(result.first); 63 | } 64 | 65 | static Future insert( 66 | DatabaseExecutor db, TranslationModel model) async { 67 | await db.insert( 68 | tableName, 69 | model.toMap(), 70 | conflictAlgorithm: ConflictAlgorithm.replace, 71 | ); 72 | } 73 | 74 | String toLrc() { 75 | List translations = []; 76 | if (translation == null) { 77 | return ''; 78 | } 79 | for (var item in jsonDecode(translation!)) { 80 | translations.add(Subtitle.fromMap(item)); 81 | } 82 | var lrc = ''; 83 | for (var i = 0; i < translations.length; i++) { 84 | var t = translations[i]; 85 | lrc += '[${formatLrcTime(t.start!)}]${t.text}\n'; 86 | lrc += '[${formatLrcTime(t.end!)}]\n'; 87 | } 88 | return lrc; 89 | } 90 | 91 | static Future delete(DatabaseExecutor db, String enclosureUrl) async { 92 | await db.delete( 93 | tableName, 94 | where: 'enclosureUrl = ?', 95 | whereArgs: [enclosureUrl], 96 | ); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /lib/pages/chat.dart: -------------------------------------------------------------------------------- 1 | import 'package:anycast/models/episode.dart'; 2 | import 'package:anycast/states/chat.dart'; 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:flutter_chat_ui/flutter_chat_ui.dart' as chat; 6 | import 'package:get/get.dart'; 7 | import 'package:google_fonts/google_fonts.dart'; 8 | import 'package:iconify_flutter/iconify_flutter.dart'; 9 | import 'package:iconify_flutter/icons/ant_design.dart'; 10 | 11 | class Chat extends GetView { 12 | final Episode episode; 13 | 14 | const Chat({super.key, required this.episode}); 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | return PopScope( 19 | onPopInvoked: (didPop) { 20 | if (didPop) { 21 | controller.clearMessages(); 22 | } 23 | }, 24 | child: Scaffold( 25 | appBar: AppBar( 26 | toolbarHeight: 64, 27 | backgroundColor: Colors.deepPurpleAccent, 28 | foregroundColor: Colors.white, 29 | leading: IconButton( 30 | onPressed: () { 31 | controller.clearMessages(); 32 | Get.back(); 33 | }, 34 | style: IconButton.styleFrom( 35 | shape: const CircleBorder(), 36 | backgroundColor: Colors.black26, 37 | ), 38 | icon: const Icon(Icons.close_rounded, color: Colors.white), 39 | ), 40 | title: Container( 41 | padding: const EdgeInsets.symmetric(horizontal: 16), 42 | child: Text( 43 | episode.title ?? '', 44 | maxLines: 2, 45 | overflow: TextOverflow.ellipsis, 46 | style: GoogleFonts.comfortaa( 47 | fontSize: 16, 48 | fontWeight: FontWeight.w600, 49 | ), 50 | ), 51 | ), 52 | ), 53 | body: Obx( 54 | () { 55 | return chat.Chat( 56 | messages: controller.messages.value, 57 | emptyState: Center( 58 | child: Text( 59 | "Curious about the podcast?\n" 60 | "Let's chat!", 61 | textAlign: TextAlign.center, 62 | style: GoogleFonts.comfortaa( 63 | fontSize: 16, 64 | fontWeight: FontWeight.w600, 65 | color: Colors.grey, 66 | ), 67 | ), 68 | ), 69 | onSendPressed: (text) { 70 | if (controller.isLoading.value) { 71 | return; 72 | } 73 | 74 | var inputTextController = controller.getTC(); 75 | 76 | inputTextController.clear(); 77 | 78 | final enclosureUrl = episode.enclosureUrl!; 79 | controller.sendMessage(text, enclosureUrl); 80 | }, 81 | user: controller.human, 82 | onAttachmentPressed: () { 83 | controller.clearMessages(); 84 | }, 85 | theme: const chat.DarkChatTheme( 86 | attachmentButtonIcon: Iconify( 87 | AntDesign.clear, 88 | color: Colors.white, 89 | ), 90 | ), 91 | inputOptions: chat.InputOptions( 92 | inputClearMode: chat.InputClearMode.never, 93 | autocorrect: false, 94 | textEditingController: controller.getTC(), 95 | ), 96 | ); 97 | }, 98 | ), 99 | ), 100 | ); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /lib/pages/podcasts.dart: -------------------------------------------------------------------------------- 1 | import 'package:anycast/pages/subscriptions.dart'; 2 | import 'package:anycast/utils/keepalive.dart'; 3 | import 'package:anycast/widgets/appbar.dart'; 4 | import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 5 | import 'package:flutter/material.dart'; 6 | import 'package:anycast/pages/feeds.dart'; 7 | 8 | class PodcastsPage extends StatelessWidget { 9 | const PodcastsPage({super.key}); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | return const Scaffold( 14 | appBar: MyAppBar( 15 | title: 'PODCAST', 16 | ), 17 | body: DefaultTabController( 18 | length: 2, 19 | child: Column( 20 | children: [ 21 | TabBar( 22 | tabs: [ 23 | Tab( 24 | text: 'Inbox', 25 | icon: Icon(FluentIcons.mail_inbox_all_24_filled), 26 | ), 27 | Tab( 28 | text: 'Subscriptions', 29 | icon: Icon(FluentIcons.library_24_filled), 30 | ), 31 | ], 32 | ), 33 | Expanded( 34 | child: TabBarView( 35 | children: [ 36 | KeepAliveWrapper(key: Key('feeds'), child: Feeds()), 37 | KeepAliveWrapper( 38 | key: Key('subscriptions'), child: Subscriptions()), 39 | ], 40 | ), 41 | ), 42 | ], 43 | ), 44 | )); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lib/pages/styles.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class AppStyles { 4 | static const TextStyle buttonText = TextStyle( 5 | fontSize: 16, 6 | fontWeight: FontWeight.bold, 7 | color: Colors.white, 8 | decoration: TextDecoration.none, 9 | ); 10 | 11 | static const TextStyle labelText = TextStyle( 12 | fontSize: 14, 13 | fontWeight: FontWeight.normal, 14 | color: Colors.black, 15 | decoration: TextDecoration.none, 16 | ); 17 | 18 | static const BoxDecoration buttonDecoration = BoxDecoration( 19 | color: Colors.blue, 20 | borderRadius: BorderRadius.all(Radius.circular(8)), 21 | ); 22 | 23 | static const TextStyle titleText = TextStyle( 24 | fontSize: 20, 25 | fontWeight: FontWeight.bold, 26 | color: Colors.black, 27 | decoration: TextDecoration.none, 28 | ); 29 | 30 | static const TextStyle subtitleText = TextStyle( 31 | fontSize: 16, 32 | fontWeight: FontWeight.normal, 33 | color: Colors.grey, 34 | decoration: TextDecoration.none, 35 | ); 36 | 37 | static const BoxDecoration cardDecoration = BoxDecoration( 38 | color: Colors.white, 39 | borderRadius: BorderRadius.all(Radius.circular(12)), 40 | boxShadow: [ 41 | BoxShadow( 42 | color: Colors.grey, 43 | spreadRadius: 2, 44 | blurRadius: 5, 45 | offset: Offset(0, 3), 46 | ), 47 | ], 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /lib/pages/subscriptions.dart: -------------------------------------------------------------------------------- 1 | import 'package:anycast/widgets/card.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:anycast/states/subscription.dart'; 4 | import 'package:get/get.dart'; 5 | import 'package:google_fonts/google_fonts.dart'; 6 | 7 | class Subscriptions extends StatelessWidget { 8 | static final controller = Get.put(SubscriptionController()); 9 | 10 | const Subscriptions({super.key}); 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | return Obx(() { 15 | if (controller.subscriptions.isEmpty) { 16 | return Center( 17 | child: SizedBox( 18 | width: 300, 19 | child: Text( 20 | 'Whoops! \n\nLooks like your podcast galaxy is still unexplored.', 21 | textAlign: TextAlign.center, 22 | style: TextStyle( 23 | color: Colors.white, 24 | fontSize: 24, 25 | fontFamily: GoogleFonts.comfortaa().fontFamily, 26 | fontWeight: FontWeight.w700, 27 | letterSpacing: 2.40, 28 | ), 29 | ), 30 | ), 31 | ); 32 | } 33 | return ListView.separated( 34 | separatorBuilder: (context, index) => const SizedBox(height: 12), 35 | padding: const EdgeInsets.only(left: 12, right: 12, top: 12), 36 | itemCount: controller.subscriptions.length, 37 | itemBuilder: (context, index) { 38 | return PodcastCard(subscription: controller.subscriptions[index]); 39 | }, 40 | ); 41 | }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/states/cache.dart: -------------------------------------------------------------------------------- 1 | import 'package:anycast/models/helper.dart'; 2 | import 'package:anycast/models/playlist_episode.dart'; 3 | import 'package:anycast/states/player.dart'; 4 | import 'package:flutter_cache_manager_plus/flutter_cache_manager_plus.dart'; 5 | import 'package:get/get.dart'; 6 | 7 | class CacheController extends GetxController { 8 | var key2FileResponse = {}.obs; 9 | 10 | var cacheManager = CacheManager( 11 | Config( 12 | 'anycast_episode', 13 | maxNrOfCacheObjects: Get.find().maxCacheCount.value, 14 | ), 15 | ); 16 | 17 | @override 18 | void onInit() { 19 | super.onInit(); 20 | 21 | DatabaseHelper().db.then((db) { 22 | PlaylistEpisodeModel.listByPlaylistId(db, 1).then((list) { 23 | for (var e in list) { 24 | cacheManager.checkFileInCache(e.enclosureUrl!).then((info) { 25 | if (info != null) { 26 | set(e.enclosureUrl!, info); 27 | } 28 | }); 29 | } 30 | }); 31 | }); 32 | } 33 | 34 | double? get(String key) { 35 | if (key2FileResponse[key] == null) { 36 | return null; 37 | } 38 | 39 | if (key2FileResponse[key]! is FileInfo) { 40 | return 100; 41 | } else if (key2FileResponse[key]! is DownloadProgress) { 42 | return (key2FileResponse[key]! as DownloadProgress).progress; 43 | } 44 | return null; 45 | } 46 | 47 | void set(String key, FileResponse value) { 48 | key2FileResponse[key] = value; 49 | } 50 | 51 | void download(String url) { 52 | set(url, DownloadProgress(url, 100, 0)); 53 | cacheManager.getFileStream(url, withProgress: true).listen((event) { 54 | set(url, event); 55 | }); 56 | } 57 | 58 | void updateCacheConfig() { 59 | cacheManager = CacheManager(Config( 60 | 'anycast_episode', 61 | maxNrOfCacheObjects: Get.find().maxCacheCount.value, 62 | )); 63 | } 64 | 65 | void remove(String url) { 66 | key2FileResponse.remove(url); 67 | cacheManager.removeFile(url); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /lib/states/cardlist.dart: -------------------------------------------------------------------------------- 1 | import 'package:get/get.dart'; 2 | 3 | class CardListController extends GetxController { 4 | var expandedIndex = (-1).obs; 5 | 6 | void expand(int index) { 7 | if (expandedIndex.value == index) { 8 | expandedIndex.value = -1; 9 | } else { 10 | expandedIndex.value = index; 11 | } 12 | } 13 | 14 | void close() { 15 | expandedIndex.value = -1; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /lib/states/channel.dart: -------------------------------------------------------------------------------- 1 | import 'package:anycast/models/feed_episode.dart'; 2 | import 'package:anycast/models/helper.dart'; 3 | import 'package:anycast/models/subscription.dart'; 4 | import 'package:anycast/states/feed_episode.dart'; 5 | import 'package:anycast/states/subscription.dart'; 6 | import 'package:anycast/utils/formatters.dart'; 7 | import 'package:flutter/widgets.dart'; 8 | import 'package:get/get.dart'; 9 | 10 | class ChannelController extends GetxController { 11 | var channel = SubscriptionModel.empty().obs; 12 | var episodes = [].obs; 13 | var isLoading = true.obs; 14 | var subscribed = false.obs; 15 | var isReversed = false.obs; 16 | var backgroundColor = const Color(0xFF111316).obs; 17 | 18 | get showEpisodes => isReversed.value ? episodes.reversed.toList() : episodes; 19 | 20 | var helper = DatabaseHelper(); 21 | var subscriptionController = Get.find(); 22 | 23 | ChannelController({required SubscriptionModel channel}) { 24 | this.channel.value = channel; 25 | subscribed.value = subscriptionController.exists(channel); 26 | } 27 | 28 | @override 29 | void onInit() { 30 | super.onInit(); 31 | load().then((_) { 32 | isLoading.value = false; 33 | }); 34 | } 35 | 36 | void _updateColor() { 37 | if (channel.value.imageUrl == null || channel.value.imageUrl!.isEmpty) { 38 | return; 39 | } 40 | updatePaletteGenerator(channel.value.imageUrl!).then((color) { 41 | backgroundColor.value = color; 42 | }); 43 | } 44 | 45 | Future load() async { 46 | var podcastData = await channel.value.listAllEpisodes(); 47 | episodes.value = podcastData.feedEpisodes!; 48 | if (channel.value.imageUrl == null || channel.value.imageUrl!.isEmpty) { 49 | channel.value = podcastData.subscription!; 50 | } 51 | _updateColor(); 52 | } 53 | 54 | void subscribe() { 55 | subscribed.value = true; 56 | // add newest episode to feed 57 | if (episodes.isNotEmpty) { 58 | var newestEpisode = episodes.first; 59 | Get.find().addMany([newestEpisode]); 60 | channel.value.lastUpdated = newestEpisode.pubDate; 61 | } 62 | subscriptionController.addMany([channel.value]); 63 | } 64 | 65 | void unsubscribe() { 66 | subscribed.value = false; 67 | subscriptionController.remove(channel.value); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /lib/states/chat.dart: -------------------------------------------------------------------------------- 1 | import 'package:anycast/api/subtitles.dart'; 2 | import 'package:get/get.dart'; 3 | import 'package:flutter_chat_types/flutter_chat_types.dart' as types; 4 | import 'package:uuid/uuid.dart'; 5 | import 'package:flutter_chat_ui/flutter_chat_ui.dart' as chat; 6 | 7 | class MyInputTextController extends chat.InputTextFieldController { 8 | var isDisposed = false; 9 | 10 | @override 11 | void dispose() { 12 | isDisposed = true; 13 | super.dispose(); 14 | } 15 | } 16 | 17 | class ChatController extends GetxController { 18 | final Rx> messages = Rx>([]); 19 | final isLoading = false.obs; 20 | 21 | final human = const types.User(id: 'human'); 22 | final ai = const types.User(id: 'ai'); 23 | 24 | var _inputTextController = MyInputTextController(); 25 | 26 | MyInputTextController getTC() { 27 | // if is disposed, create a new one 28 | if (_inputTextController.isDisposed) { 29 | _inputTextController = MyInputTextController(); 30 | } 31 | return _inputTextController; 32 | } 33 | 34 | void sendMessage(types.PartialText message, String enclosureUrl) { 35 | final textMessage = types.TextMessage( 36 | id: const Uuid().v4(), 37 | text: message.text, 38 | author: human, 39 | ); 40 | 41 | messages.value = [textMessage, ...messages.value]; 42 | 43 | List> history = []; 44 | if (messages.value.length > 1) { 45 | for (int i = 1; i <= 10 && i < messages.value.length; i++) { 46 | history.add({ 47 | messages.value[i].author.id: 48 | (messages.value[i] as types.TextMessage).text, 49 | }); 50 | } 51 | } 52 | history = history.reversed.toList(); 53 | 54 | send2AI(enclosureUrl, textMessage.text, history); 55 | } 56 | 57 | void send2AI( 58 | String enclosureUrl, String input, List> history) { 59 | isLoading.value = true; 60 | 61 | messages.value = [ 62 | types.TextMessage( 63 | id: const Uuid().v4(), 64 | text: '...', 65 | author: ai, 66 | ), 67 | ...messages.value, 68 | ]; 69 | 70 | chatAPI(enclosureUrl, input, history).then((result) { 71 | messages.value = [ 72 | types.TextMessage( 73 | id: const Uuid().v4(), 74 | text: result, 75 | author: ai, 76 | ), 77 | ...messages.value.skip(1), 78 | ]; 79 | isLoading.value = false; 80 | }); 81 | } 82 | 83 | void clearMessages() { 84 | messages.value = []; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /lib/states/discover.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:get/get.dart'; 3 | 4 | class DiscoverController extends GetxController { 5 | var searchController = TextEditingController(); 6 | final searchText = ''.obs; 7 | final isLoading = false.obs; 8 | } 9 | -------------------------------------------------------------------------------- /lib/states/feed_episode.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:anycast/models/feed_episode.dart'; 4 | import 'package:anycast/models/helper.dart'; 5 | import 'package:anycast/models/playlist_episode.dart'; 6 | import 'package:anycast/states/player.dart'; 7 | import 'package:anycast/states/playlist.dart'; 8 | import 'package:anycast/utils/audio_handler.dart'; 9 | import 'package:easy_refresh/easy_refresh.dart'; 10 | import 'package:flutter/material.dart'; 11 | import 'package:get/get.dart'; 12 | 13 | class FeedEpisodeController extends GetxController { 14 | final episodes = [].obs; 15 | final progress = 0.0.obs; 16 | 17 | final DatabaseHelper helper = DatabaseHelper(); 18 | final MyAudioHandler audioHandler = MyAudioHandler(); 19 | final scrollController = ScrollController(); 20 | final refreshController = EasyRefreshController( 21 | controlFinishRefresh: true, 22 | controlFinishLoad: true, 23 | ); 24 | Timer? refresher; 25 | DateTime? lastRefresh; 26 | 27 | @override 28 | void onInit() { 29 | super.onInit(); 30 | load(episodes); 31 | 32 | initAutoRefresher(); 33 | 34 | Future.delayed(const Duration(seconds: 2), () { 35 | autoFetch(); 36 | }); 37 | } 38 | 39 | void addMany(List episodes) { 40 | helper.db.then((db) => { 41 | FeedEpisodeModel.insertMany(db, episodes).then((db) { 42 | load(episodes); 43 | }) 44 | }); 45 | } 46 | 47 | Future removeByEnclosureUrls(List urls) async { 48 | episodes.removeWhere((episode) => urls.contains(episode.enclosureUrl)); 49 | helper.db.then((db) => {FeedEpisodeModel.removeByEnclosureUrls(db, urls)}); 50 | } 51 | 52 | void load(List episodes) { 53 | helper.db.then((db) => { 54 | FeedEpisodeModel.listAll(db).then((episodes) { 55 | this.episodes.value = episodes; 56 | }) 57 | }); 58 | } 59 | 60 | static PlaylistEpisodeModel feed2playlist( 61 | int playlistId, FeedEpisodeModel episode) { 62 | var playlistEpisode = 63 | PlaylistEpisodeModel.fromMap(Map.from({ 64 | 'title': episode.title, 65 | 'description': episode.description, 66 | 'duration': episode.duration, 67 | 'enclosureUrl': episode.enclosureUrl, 68 | 'pubDate': episode.pubDate, 69 | 'imageUrl': episode.imageUrl, 70 | 'channelTitle': episode.channelTitle, 71 | 'rssFeedUrl': episode.rssFeedUrl, 72 | 'playlistId': playlistId, 73 | 'position': double.infinity, 74 | 'playedDuration': 0, 75 | })); 76 | 77 | return playlistEpisode; 78 | } 79 | 80 | Future addToPlaylist( 81 | int playlistId, FeedEpisodeModel episode) async { 82 | // add to default playlist; remove from feeds 83 | var playlistEpisode = feed2playlist(playlistId, episode); 84 | 85 | var position = 0; 86 | var playerController = Get.find(); 87 | if (playerController.player.value.currentPlaylistId == playlistId) { 88 | if (playerController.playlistEpisode.value.enclosureUrl == 89 | episode.enclosureUrl) { 90 | return playerController.playlistEpisode.value; 91 | } 92 | position = 1; 93 | } 94 | 95 | var playlistEpisodeController = Get.find() 96 | .getEpisodeControllerByPlaylistId(playlistId); 97 | await playlistEpisodeController.add(position, playlistEpisode); 98 | 99 | return playlistEpisode; 100 | } 101 | 102 | Future addToTop( 103 | int playlistId, FeedEpisodeModel episode) async { 104 | var playlistEpisode = feed2playlist(playlistId, episode); 105 | var playlistEpisodeController = Get.find() 106 | .getEpisodeControllerByPlaylistId(playlistId); 107 | return await playlistEpisodeController.add(0, playlistEpisode); 108 | } 109 | 110 | void autoFetch() async { 111 | var now = DateTime.now(); 112 | 113 | if (lastRefresh != null && 114 | now.difference(lastRefresh!) < const Duration(minutes: 1)) { 115 | return; 116 | } 117 | lastRefresh = now; 118 | refreshController.callRefresh(); 119 | } 120 | 121 | void initAutoRefresher() async { 122 | if (refresher != null) { 123 | refresher!.cancel(); 124 | } 125 | var interval = Get.find().autoRefreshInterval.value; 126 | refresher = Timer.periodic( 127 | Duration( 128 | seconds: interval, 129 | ), (timer) async { 130 | autoFetch(); 131 | }); 132 | } 133 | 134 | Future removeOld(int maxCount) async { 135 | if (episodes.length <= maxCount) { 136 | return; 137 | } 138 | 139 | var episodesNeedRemove = episodes.sublist(maxCount); 140 | var enclosureUrls = 141 | episodesNeedRemove.map((episode) => episode.enclosureUrl!).toList(); 142 | 143 | episodes.removeRange(maxCount, episodes.length); 144 | 145 | helper.db.then( 146 | (db) => {FeedEpisodeModel.removeByEnclosureUrls(db, enclosureUrls)}); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /lib/states/history.dart: -------------------------------------------------------------------------------- 1 | import 'package:anycast/models/feed_episode.dart'; 2 | import 'package:anycast/models/helper.dart'; 3 | import 'package:anycast/models/history_episode.dart'; 4 | import 'package:get/get.dart'; 5 | 6 | class HistoryController extends GetxController { 7 | var episodes = [].obs; 8 | var isLoading = true.obs; 9 | 10 | static final db = DatabaseHelper().db; 11 | 12 | @override 13 | void onInit() { 14 | super.onInit(); 15 | load(); 16 | } 17 | 18 | Future load() async { 19 | db.then((db) async { 20 | episodes.value = await HistoryEpisodeModel.listAll(db); 21 | isLoading.value = false; 22 | }); 23 | } 24 | 25 | void deleteAll() { 26 | episodes.value = []; 27 | 28 | db.then((db) async { 29 | await HistoryEpisodeModel.deleteAll(db); 30 | }); 31 | } 32 | 33 | void delete(String enclosureUrl) { 34 | episodes.removeWhere((e) => e.enclosureUrl == enclosureUrl); 35 | 36 | db.then((db) async { 37 | await HistoryEpisodeModel.delete(db, enclosureUrl); 38 | }); 39 | } 40 | 41 | void insert(HistoryEpisodeModel episode) { 42 | episodes.removeWhere((e) => e.enclosureUrl == episode.enclosureUrl); 43 | episodes.insert(0, episode); 44 | 45 | db.then((db) async { 46 | await HistoryEpisodeModel.insert(db, episode); 47 | }); 48 | } 49 | 50 | FeedEpisodeModel toFeedEpisode(HistoryEpisodeModel episode) { 51 | return FeedEpisodeModel.fromMap(episode.toMap()); 52 | } 53 | 54 | Future removeOld(int maxCount) async { 55 | if (episodes.length <= maxCount) { 56 | return; 57 | } 58 | 59 | var oldEpisodes = episodes.sublist(maxCount); 60 | var urls = oldEpisodes.map((e) => e.enclosureUrl!).toList(); 61 | 62 | episodes.removeRange(maxCount, episodes.length); 63 | 64 | db.then((db) async { 65 | await HistoryEpisodeModel.deleteMany(db, urls); 66 | }); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /lib/states/import_indicator.dart: -------------------------------------------------------------------------------- 1 | import 'package:get/get.dart'; 2 | 3 | class ImportIndicatorController extends GetxController { 4 | Rx progress = 0.0.obs; 5 | 6 | void updateProgress(double value) { 7 | progress.value = value; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /lib/states/playlist.dart: -------------------------------------------------------------------------------- 1 | import 'package:anycast/models/helper.dart'; 2 | import 'package:anycast/states/player.dart'; 3 | import 'package:anycast/states/playlist_episode.dart'; 4 | import 'package:anycast/models/playlist.dart'; 5 | import 'package:anycast/utils/audio_handler.dart'; 6 | import 'package:get/get.dart'; 7 | 8 | class PlaylistController extends GetxController { 9 | final playlists = [].obs; 10 | final episodesControllers = [].obs; 11 | final isLoading = true.obs; 12 | var episodeEnclosureSet = {}.obs; 13 | 14 | final DatabaseHelper helper = DatabaseHelper(); 15 | 16 | @override 17 | void onInit() { 18 | super.onInit(); 19 | load(); 20 | } 21 | 22 | void load() { 23 | isLoading.value = true; 24 | helper.db.then((db) => { 25 | PlaylistModel.listAll(db).then((playlists) { 26 | this.playlists.value = playlists; 27 | List futures = []; 28 | for (var playlist in playlists) { 29 | var c = PlaylistEpisodeController(playlistId: playlist.id!); 30 | episodesControllers.add(c); 31 | futures.add(c.loadManually()); 32 | } 33 | Future.wait(futures).then((value) { 34 | isLoading.value = false; 35 | var playerController = Get.find(); 36 | if (playerController.player.value.currentPlaylistId != null) { 37 | var episodes = 38 | playerController.playlistEpisodeController!.episodes; 39 | if (episodes.isNotEmpty) { 40 | playerController.playlistEpisode.value = episodes[0]; 41 | playerController.positionData.value = PositionData( 42 | position: 43 | Duration(milliseconds: episodes[0].playedDuration!), 44 | duration: Duration(milliseconds: episodes[0].duration!), 45 | bufferedPosition: Duration.zero, 46 | ); 47 | } 48 | } 49 | }); 50 | }) 51 | }); 52 | } 53 | 54 | PlaylistEpisodeController getEpisodeControllerByPlaylistId(int playlistId) { 55 | for (var i = 0; i < playlists.length; i++) { 56 | if (playlists[i].id == playlistId) { 57 | return episodesControllers[i]; 58 | } 59 | } 60 | throw Exception('Playlist not found'); 61 | } 62 | 63 | bool isInPlaylists(String enclosureUrl) { 64 | return episodeEnclosureSet.contains(enclosureUrl); 65 | } 66 | 67 | void addToSet(List enclosureUrls) { 68 | episodeEnclosureSet.addAll(enclosureUrls); 69 | } 70 | 71 | void removeFromSet(String enclosureUrl) { 72 | episodeEnclosureSet.remove(enclosureUrl); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lib/states/playlist_episode.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:anycast/models/helper.dart'; 4 | import 'package:anycast/models/playlist_episode.dart'; 5 | import 'package:anycast/states/cache.dart'; 6 | import 'package:anycast/states/player.dart'; 7 | import 'package:anycast/states/playlist.dart'; 8 | import 'package:anycast/states/subtitle.dart'; 9 | import 'package:anycast/states/translation.dart'; 10 | import 'package:get/get.dart'; 11 | 12 | class PlaylistEpisodeController extends GetxController { 13 | final episodes = [].obs; 14 | 15 | final int playlistId; 16 | PlaylistEpisodeController({required this.playlistId}); 17 | 18 | final DatabaseHelper helper = DatabaseHelper(); 19 | 20 | Future loadManually() async { 21 | var db = await helper.db; 22 | var episodes = await PlaylistEpisodeModel.listByPlaylistId(db, playlistId); 23 | this.episodes.value = episodes; 24 | Get.find() 25 | .addToSet(episodes.map((e) => e.enclosureUrl!).toList()); 26 | } 27 | 28 | Future add( 29 | int position, PlaylistEpisodeModel episode) async { 30 | // if exist, only move it 31 | var oldIndex = 32 | episodes.indexWhere((e) => e.enclosureUrl == episode.enclosureUrl); 33 | if (oldIndex != -1) { 34 | var ep = episodes[oldIndex]; 35 | if (oldIndex == position) { 36 | return ep; 37 | } 38 | episodes.removeAt(oldIndex); 39 | episodes.insert(position, ep); 40 | // 更新到数据库去 41 | helper.db.then((db) => PlaylistEpisodeModel.insertOrUpdateByIndex( 42 | db, playlistId, position, ep)); 43 | return ep; 44 | } 45 | episodes.insert(position, episode); 46 | // episodes.insert(position, episode); 47 | Get.find().addToSet([episode.enclosureUrl!]); 48 | helper.db.then((db) => PlaylistEpisodeModel.insertOrUpdateByIndex( 49 | db, playlistId, position, episode)); 50 | return episode; 51 | } 52 | 53 | void remove(String enclosureUrl) { 54 | var oldIndex = episodes.indexWhere((e) => e.enclosureUrl == enclosureUrl); 55 | if (oldIndex == -1) { 56 | return; 57 | } 58 | episodes.removeAt(oldIndex); 59 | Get.find().removeFromSet(enclosureUrl); 60 | 61 | helper.db.then((db) { 62 | PlaylistEpisodeModel.deleteByEnclosureUrl(db, enclosureUrl); 63 | }); 64 | 65 | Get.find().remove(enclosureUrl); 66 | Get.find().remove(enclosureUrl); 67 | Get.find().remove(enclosureUrl); 68 | } 69 | 70 | void removeTop() { 71 | var url = episodes[0].enclosureUrl; 72 | remove(url!); 73 | } 74 | 75 | Future moveToTop(PlaylistEpisodeModel episode) async { 76 | var oldIndex = episodes.indexWhere((e) => e.id == episode.id); 77 | episodes.removeAt(oldIndex); 78 | episodes.insert(0, episode); 79 | 80 | return helper.db.then((db) => 81 | PlaylistEpisodeModel.insertOrUpdateByIndex(db, playlistId, 0, episode)); 82 | } 83 | 84 | Future updatePlayedDuration(Duration duration) async { 85 | var episode = episodes[0]; 86 | episode.playedDuration = duration.inMilliseconds; 87 | episodes[0] = PlaylistEpisodeModel.fromMap(episode.toMap()); 88 | return helper.db.then((db) { 89 | episode.updatePlayedDuration(db); 90 | }); 91 | } 92 | 93 | Future move(int from, int to) async { 94 | if (from == to) { 95 | return; 96 | } 97 | if (from == 0) { 98 | var playerController = Get.find(); 99 | 100 | playerController.pause().then((_) { 101 | // wait for episodes reorder 102 | sleep(const Duration(milliseconds: 100)); 103 | playerController.setByEpisode(episodes[0]); 104 | }); 105 | } 106 | if (to == 0) { 107 | var playerController = Get.find(); 108 | playerController.pause().then((_) { 109 | sleep(const Duration(milliseconds: 100)); 110 | playerController.setByEpisode(episodes[0]); 111 | }); 112 | } 113 | 114 | var episode = episodes.removeAt(from); 115 | if (to > from) { 116 | to -= 1; 117 | } 118 | episodes.insert(to, episode); 119 | 120 | helper.db.then((db) => PlaylistEpisodeModel.insertOrUpdateByIndex( 121 | db, playlistId, to, episode)); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /lib/states/share.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:anycast/pages/feeds.dart'; 4 | import 'package:anycast/widgets/share.dart'; 5 | import 'package:get/get.dart'; 6 | import 'package:receive_sharing_intent/receive_sharing_intent.dart'; 7 | 8 | class ShareController extends GetxController { 9 | late StreamSubscription _intentSub; 10 | final _sharedFiles = [].obs; 11 | final progress = 0.0.obs; 12 | final _opmls = [].obs; 13 | 14 | SharedMediaFile? get sharedFile => 15 | _sharedFiles.isNotEmpty ? _sharedFiles.first : null; 16 | List get opmls => _opmls; 17 | 18 | @override 19 | void onInit() { 20 | super.onInit(); 21 | _initReceiveSharingIntent(); 22 | } 23 | 24 | @override 25 | void onClose() { 26 | super.onClose(); 27 | _intentSub.cancel(); 28 | } 29 | 30 | void _initReceiveSharingIntent() { 31 | // Listen to media sharing coming from outside the app while the app is in the memory. 32 | _intentSub = ReceiveSharingIntent.instance.getMediaStream().listen((value) { 33 | _sharedFiles.clear(); 34 | _sharedFiles.addAll(value); 35 | 36 | _parseOPML().then((_) { 37 | Get.dialog(const ShareDialog()); 38 | }); 39 | }, onError: (err) { 40 | print("getIntentDataStream error: $err"); 41 | }); 42 | 43 | // Get the media sharing coming from outside the app while the app is closed. 44 | ReceiveSharingIntent.instance.getInitialMedia().then((value) { 45 | if (value.isEmpty) { 46 | return; 47 | } 48 | 49 | _sharedFiles.clear(); 50 | _sharedFiles.addAll(value); 51 | 52 | Future.delayed(const Duration(seconds: 2), () { 53 | _parseOPML().then((_) { 54 | Get.dialog(const ShareDialog(), barrierDismissible: false); 55 | }); 56 | }); 57 | 58 | // Tell the library that we are done processing the intent. 59 | ReceiveSharingIntent.instance.reset(); 60 | }); 61 | } 62 | 63 | Future _parseOPML() async { 64 | if (sharedFile == null) { 65 | return; 66 | } 67 | var path = sharedFile!.path; 68 | if (path.startsWith('file://')) { 69 | path = Uri.parse(sharedFile!.path).toFilePath(); 70 | } 71 | 72 | var result = await parseOPML(path); 73 | _opmls.value = result; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/states/subscription.dart: -------------------------------------------------------------------------------- 1 | import 'package:anycast/models/helper.dart'; 2 | import 'package:anycast/models/subscription.dart'; 3 | import 'package:get/get.dart'; 4 | 5 | class SubscriptionController extends GetxController { 6 | final subscriptions = [].obs; 7 | 8 | final DatabaseHelper helper = DatabaseHelper(); 9 | 10 | @override 11 | void onInit() { 12 | super.onInit(); 13 | load(); 14 | } 15 | 16 | void load() { 17 | helper.db.then((db) => { 18 | SubscriptionModel.listAll(db).then((subscriptions) { 19 | this.subscriptions.value = subscriptions; 20 | }) 21 | }); 22 | } 23 | 24 | void addMany(subscriptions) { 25 | helper.db.then((db) => { 26 | SubscriptionModel.addMany(db, subscriptions).then((_) { 27 | load(); 28 | }) 29 | }); 30 | } 31 | 32 | void remove(SubscriptionModel subscription) { 33 | var index = subscriptions 34 | .indexWhere((element) => element.title == subscription.title); 35 | subscriptions.removeAt(index); 36 | helper.db.then((db) { 37 | SubscriptionModel.remove(db, subscription); 38 | }); 39 | } 40 | 41 | bool exists(SubscriptionModel m) { 42 | // rssFeedUrl or title or id exists 43 | for (var s in subscriptions) { 44 | if (s.rssFeedUrl == m.rssFeedUrl || s.title == m.title || s.id == m.id) { 45 | return true; 46 | } 47 | } 48 | return false; 49 | } 50 | 51 | SubscriptionModel? getByTitle(String title) { 52 | for (var s in subscriptions) { 53 | if (s.title == title) { 54 | return s; 55 | } 56 | } 57 | return null; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lib/states/subtitle.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | 4 | import 'package:anycast/api/subtitles.dart'; 5 | import 'package:anycast/models/helper.dart'; 6 | import 'package:anycast/models/subtitle.dart'; 7 | import 'package:get/get.dart'; 8 | 9 | class SubtitleController extends GetxController { 10 | // url => status 11 | final subtitleUrls = {}.obs; 12 | 13 | final helper = DatabaseHelper(); 14 | 15 | @override 16 | void onInit() { 17 | super.onInit(); 18 | helper.db.then((db) { 19 | SubtitleModel.list(db).then((urls) { 20 | subtitleUrls.addAll(urls); 21 | }); 22 | }); 23 | 24 | Timer.periodic( 25 | const Duration(seconds: 15), 26 | (timer) async { 27 | for (var url in subtitleUrls.keys) { 28 | if (subtitleUrls[url] == 'processing') { 29 | var result = await getSubtitles(url); 30 | if (result.status == 'succeeded') { 31 | subtitleUrls[url] = 'succeeded'; 32 | helper.db.then((db) { 33 | SubtitleModel.insert( 34 | db, 35 | SubtitleModel.fromMap({ 36 | 'enclosureUrl': url, 37 | 'status': 'succeeded', 38 | 'language': result.language, 39 | 'subtitle': jsonEncode(result.subtitles), 40 | })); 41 | }); 42 | } else if (result.status == 'failed') { 43 | remove(url); 44 | } 45 | } 46 | } 47 | }, 48 | ); 49 | } 50 | 51 | void add(String url) async { 52 | subtitleUrls[url] = 'processing'; 53 | await helper.db.then((db) { 54 | SubtitleModel.insert( 55 | db, 56 | SubtitleModel.fromMap({ 57 | 'enclosureUrl': url, 58 | 'status': 'processing', 59 | 'subtitle': '', 60 | })); 61 | }); 62 | 63 | getSubtitles(url).then((value) { 64 | if (value.status == 'succeeded') { 65 | subtitleUrls[url] = 'succeeded'; 66 | helper.db.then((db) { 67 | SubtitleModel.insert( 68 | db, 69 | SubtitleModel.fromMap({ 70 | 'enclosureUrl': url, 71 | 'status': value.status, 72 | 'language': value.language, 73 | 'subtitle': jsonEncode(value.subtitles), 74 | })); 75 | }); 76 | } else if (value.status == 'failed') { 77 | remove(url); 78 | } 79 | }); 80 | } 81 | 82 | void remove(String url) { 83 | subtitleUrls.remove(url); 84 | helper.db.then((db) { 85 | SubtitleModel.delete(db, url); 86 | }); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /lib/states/tab.dart: -------------------------------------------------------------------------------- 1 | import 'package:get/get.dart'; 2 | 3 | class HomeTabController extends GetxController { 4 | var selectedIndex = 0.obs; 5 | 6 | void onItemTapped(int index) { 7 | selectedIndex.value = index; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /lib/states/translation.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | 4 | import 'package:anycast/api/subtitles.dart'; 5 | import 'package:anycast/models/helper.dart'; 6 | import 'package:anycast/models/subtitle.dart'; 7 | import 'package:anycast/models/translation.dart'; 8 | import 'package:anycast/states/player.dart'; 9 | import 'package:anycast/states/subtitle.dart'; 10 | import 'package:get/get.dart'; 11 | 12 | class TranslationController extends GetxController { 13 | final translationUrls = {}.obs; 14 | final helper = DatabaseHelper(); 15 | 16 | @override 17 | void onInit() { 18 | super.onInit(); 19 | 20 | Timer.periodic(const Duration(seconds: 10), (timer) { 21 | // check enabled 22 | if (Get.find().targetLanguage.value == '') { 23 | return; 24 | } 25 | 26 | var subtitleStatus = Get.find().subtitleUrls; 27 | for (var url in subtitleStatus.keys) { 28 | if (subtitleStatus[url] == 'succeeded') { 29 | if (translationUrls[url] == 'succeeded') { 30 | continue; 31 | } 32 | loadTranslation(url); 33 | } 34 | } 35 | }); 36 | } 37 | 38 | Future loadTranslation(String url) async { 39 | if (translationUrls[url] == 'succeeded') { 40 | return; 41 | } 42 | 43 | var lang = Get.find().targetLanguage.value; 44 | if (lang == '') { 45 | return; 46 | } 47 | var detectedLanguage = await getDetectedLanguage(url); 48 | if (detectedLanguage == null || detectedLanguage == lang) { 49 | return; 50 | } 51 | 52 | var result = await helper.db.then((db) async { 53 | return await TranslationModel.get(db, url, lang); 54 | }); 55 | if (result != null) { 56 | translationUrls[url] = 'succeeded'; 57 | return; 58 | } 59 | 60 | translationUrls[url] = 'processing'; 61 | 62 | var translation = await getTranslation(url, lang); 63 | if (translation == null) { 64 | return; 65 | } 66 | 67 | await helper.db.then((db) async { 68 | await TranslationModel.insert( 69 | db, 70 | TranslationModel.fromMap({ 71 | 'enclosureUrl': url, 72 | 'status': 'succeeded', 73 | 'translation': jsonEncode(translation), 74 | 'language': lang, 75 | })); 76 | 77 | translationUrls[url] = 'succeeded'; 78 | }); 79 | } 80 | 81 | Future remove(String url) async { 82 | translationUrls.remove(url); 83 | 84 | DatabaseHelper().db.then((db) async { 85 | await TranslationModel.delete(db, url); 86 | }); 87 | } 88 | } 89 | 90 | Future getDetectedLanguage(String url) async { 91 | return DatabaseHelper().db.then((db) async { 92 | var subtitle = await SubtitleModel.get(db, url); 93 | if (subtitle.id == null) { 94 | return null; 95 | } 96 | return subtitle.language; 97 | }); 98 | } 99 | -------------------------------------------------------------------------------- /lib/styles.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:google_fonts/google_fonts.dart'; 3 | 4 | class DarkColor { 5 | static const Color primary = Color(0xFF34D399); 6 | static const Color primaryLightPlus1 = Color(0xFFA7F3D0); 7 | static const Color primaryLightMax = Color(0xFFECFDF5); 8 | static const Color primaryDark = Color(0xFF079669); 9 | static const Color primaryBackground = Color(0xFF30444E); 10 | static const Color primaryBackgroundDark = Color(0xFF111316); 11 | static const Color accentColor = Color(0xFFFFBC25); 12 | static const Color secondaryColor = Color(0xFF96A7AF); 13 | 14 | static TextStyle cardTitleBold = TextStyle( 15 | color: primaryLightMax, 16 | fontSize: 16, 17 | fontWeight: FontWeight.w700, 18 | fontFamily: GoogleFonts.notoSans().fontFamily, 19 | decoration: TextDecoration.none, 20 | ); 21 | static TextStyle cardTextLight = TextStyle( 22 | color: primary, 23 | fontSize: 12, 24 | fontFamily: GoogleFonts.inter().fontFamily, 25 | fontWeight: FontWeight.w400, 26 | decoration: TextDecoration.none, 27 | ); 28 | static TextStyle defaultText = TextStyle( 29 | color: secondaryColor, 30 | fontSize: 12, 31 | fontFamily: GoogleFonts.inter().fontFamily, 32 | fontWeight: FontWeight.w400, 33 | decoration: TextDecoration.none, 34 | ); 35 | static TextStyle defaultTitle = TextStyle( 36 | color: primaryLightMax, 37 | fontSize: 16, 38 | fontFamily: GoogleFonts.notoSans().fontFamily, 39 | fontWeight: FontWeight.w700, 40 | decoration: TextDecoration.none, 41 | ); 42 | static TextStyle defaultMainText = TextStyle( 43 | color: primaryLightMax, 44 | fontSize: 14, 45 | fontWeight: FontWeight.w400, 46 | fontFamily: GoogleFonts.comfortaa().fontFamily, 47 | decoration: TextDecoration.none, 48 | ); 49 | static TextStyle mainTitle = TextStyle( 50 | color: primaryLightMax, 51 | fontSize: 44, 52 | fontFamily: GoogleFonts.comfortaa().fontFamily, 53 | fontWeight: FontWeight.w700, 54 | height: 0, 55 | letterSpacing: 4.40, 56 | decoration: TextDecoration.none, 57 | ); 58 | static TextStyle secondaryTitle = TextStyle( 59 | color: primaryLightMax, 60 | fontSize: 24, 61 | fontFamily: GoogleFonts.comfortaa().fontFamily, 62 | fontWeight: FontWeight.w700, 63 | height: 0, 64 | letterSpacing: 2.40, 65 | decoration: TextDecoration.none, 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /lib/utils/formatters.dart: -------------------------------------------------------------------------------- 1 | import 'package:anycast/styles.dart'; 2 | import 'package:anycast/utils/rss_fetcher.dart'; 3 | import 'package:cached_network_image/cached_network_image.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; 6 | import 'package:google_fonts/google_fonts.dart'; 7 | import 'package:palette_generator/palette_generator.dart'; 8 | import 'package:sanitize_html/sanitize_html.dart'; 9 | import 'package:timeago/timeago.dart' as timeago; 10 | 11 | // my_custom_messages.dart 12 | String formatDatetime(int ts) { 13 | var dt = DateTime.fromMillisecondsSinceEpoch(ts); 14 | // in a week: use timeago; in this year: use month and day; else: use yyyy-mm-dd 15 | var now = DateTime.now(); 16 | if (dt.isAfter(now.subtract(const Duration(days: 7)))) { 17 | var ago = timeago.format(dt, locale: 'en_short'); 18 | if (ago == 'now') { 19 | return 'just now'; 20 | } 21 | return "$ago ago"; 22 | } else if (dt.year == now.year) { 23 | return '${dt.month}-${dt.day}'; 24 | } else { 25 | return '${dt.year}-${dt.month}-${dt.day}'; 26 | } 27 | } 28 | 29 | String formatDate(int ts) { 30 | var dt = DateTime.fromMillisecondsSinceEpoch(ts); 31 | 32 | var now = DateTime.now(); 33 | if (dt.year == now.year) { 34 | return '${dt.month}-${dt.day}'; 35 | } 36 | 37 | return '${dt.year}-${dt.month}-${dt.day}'; 38 | } 39 | 40 | String formatDuration(int ms) { 41 | if (ms == 0) { 42 | return ''; 43 | } 44 | var d = Duration(milliseconds: ms); 45 | // in 100m: show {n}m; else: show {n}h {m}m 46 | if (d.inMinutes < 100) { 47 | return '${d.inMinutes}m'; 48 | } else { 49 | return '${d.inHours}h ${d.inMinutes.remainder(60)}m'; 50 | } 51 | } 52 | 53 | // if played: 73m remaining, 1h 13m remaining 54 | // if not played: 73m, 1h 13m 55 | String formatRemainingTime(Duration duration, Duration playedDuration) { 56 | String remainingTime = ''; 57 | if (duration.inSeconds == 0) { 58 | return ''; 59 | } 60 | 61 | // duration and playedDuration are both in milliseconds 62 | var remainingDuration = duration - playedDuration; 63 | remainingDuration = 64 | remainingDuration > Duration.zero ? remainingDuration : Duration.zero; 65 | // if less than 100 minutes, show minutes; otherwise show hours and minutes 66 | if (remainingDuration.inMinutes < 100) { 67 | remainingTime = '${remainingDuration.inMinutes}m'; 68 | } else { 69 | remainingTime = 70 | '${remainingDuration.inHours}h ${remainingDuration.inMinutes.remainder(60)}m'; 71 | } 72 | 73 | if (playedDuration.inSeconds > 0) { 74 | return '$remainingTime remaining'; 75 | } else { 76 | return remainingTime; 77 | } 78 | } 79 | 80 | Widget renderHtml(context, String html) { 81 | if (html.isEmpty) { 82 | return const SizedBox.shrink(); 83 | } 84 | // if starts with < 85 | if (html.trim().startsWith('<')) { 86 | var sanitized = sanitizeHtml(html).trim(); 87 | if (sanitized.isEmpty) { 88 | sanitized = htmlToText(html); 89 | } 90 | return HtmlWidget( 91 | sanitized, 92 | textStyle: const TextStyle( 93 | color: Colors.white, 94 | fontSize: 14, 95 | height: 1.2, 96 | inherit: false, 97 | ), 98 | onErrorBuilder: (context, error, any) => Text( 99 | error.toString(), 100 | style: GoogleFonts.robotoMono( 101 | color: Colors.red, 102 | fontSize: 12, 103 | decoration: TextDecoration.none, 104 | ), 105 | ), 106 | ); 107 | } 108 | 109 | return Text(html, style: DarkColor.defaultMainText); 110 | } 111 | 112 | String formatCountdown(Duration duration) { 113 | if (duration.inSeconds <= 0) { 114 | return 'OFF'; 115 | } 116 | if (duration.inMinutes == 60) { 117 | return '1h'; 118 | } 119 | 120 | var minutes = duration.inMinutes.remainder(60); 121 | var seconds = duration.inSeconds.remainder(60); 122 | return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; 123 | } 124 | 125 | // hh:mm:ss 126 | String formatTime(Duration duration) { 127 | var hours = duration.inHours.remainder(60); 128 | var minutes = duration.inMinutes.remainder(60); 129 | var seconds = duration.inSeconds.remainder(60); 130 | return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; 131 | } 132 | 133 | // example: https://www.ximalaya.com/album/41563226.xml => ximalaya.com 134 | String urlToDomain(String url) { 135 | var uri = Uri.parse(url); 136 | return uri.host; 137 | } 138 | 139 | Color getTextSafeColor(Color dynamicColor) { 140 | // 定义亮度阈值,低于这个值就认为颜色太暗 141 | const double brightnessThreshold = 0.2; 142 | 143 | // 计算颜色的亮度 144 | double brightness = dynamicColor.computeLuminance(); 145 | 146 | if (brightness < brightnessThreshold) { 147 | // 如果颜色太暗,返回一个替代颜色 148 | return const Color(0xFF10B981); 149 | } else { 150 | // 如果颜色亮度足够,返回原始的动态颜色 151 | return dynamicColor; 152 | } 153 | } 154 | 155 | Color getBackgroundSafeColor(Color dynamicColor) { 156 | const double brightnessThreshold = 0.7; 157 | 158 | double brightness = dynamicColor.computeLuminance(); 159 | 160 | if (brightness > brightnessThreshold) { 161 | return const Color(0xFF113336); 162 | } else { 163 | return dynamicColor; 164 | } 165 | } 166 | 167 | Future updatePaletteGenerator(String imageUrl) async { 168 | final PaletteGenerator generator = 169 | await PaletteGenerator.fromImageProvider(CachedNetworkImageProvider( 170 | imageUrl, 171 | )); 172 | final Color dominantColor = 173 | generator.dominantColor?.color ?? const Color(0xFF111316); 174 | 175 | return dominantColor; 176 | // return getBackgroundSafeColor(dominantColor); 177 | } 178 | 179 | // seconds to mm:ss.xx 180 | String formatLrcTime(double time) { 181 | var minutes = (time / 60).floor(); 182 | var seconds = (time % 60).floor(); 183 | var milliseconds = ((time * 1000) % 1000).floor(); 184 | return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}.${milliseconds.toString().padLeft(3, '0')}'; 185 | } 186 | 187 | String? encodeQueryParameters(Map params) { 188 | return params.entries 189 | .map((MapEntry e) => 190 | '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') 191 | .join('&'); 192 | } 193 | 194 | double getTextWidth(String text, TextStyle style, 195 | {double maxWidth = double.infinity}) { 196 | final TextPainter textPainter = TextPainter( 197 | text: TextSpan(text: text, style: style), 198 | maxLines: 1, 199 | textDirection: TextDirection.ltr, 200 | )..layout(minWidth: 0, maxWidth: maxWidth); 201 | return textPainter.width; 202 | } 203 | -------------------------------------------------------------------------------- /lib/utils/http_client.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | 5 | import 'package:anycast/states/user.dart'; 6 | import 'package:get/get.dart'; 7 | import 'package:http/http.dart' as http; 8 | import 'package:retry/retry.dart'; 9 | 10 | Future myRetry( 11 | Future Function() fn, { 12 | int retryTimes = 2, 13 | }) async { 14 | return await retry( 15 | () async => await fn(), 16 | retryIf: (e) => e is SocketException || e is TimeoutException, 17 | maxAttempts: retryTimes, 18 | ); 19 | } 20 | 21 | Future reqWithAuth( 22 | String url, { 23 | String method = 'GET', 24 | Map? headers, 25 | Object? data, 26 | int timeout = 10, 27 | }) async { 28 | var token = await Get.find().getToken(); 29 | if (token == null) { 30 | return http.Response('Unauthorized', 401); 31 | } 32 | headers = headers ?? {}; 33 | headers['Authorization'] = 'Bearer $token'; 34 | if (data != null) { 35 | headers['Content-Type'] = 'application/json'; 36 | data = jsonEncode(data); 37 | } 38 | 39 | switch (method) { 40 | case 'GET': 41 | return myRetry( 42 | () => http 43 | .get(Uri.parse(url), headers: headers) 44 | .timeout(Duration(seconds: timeout)), 45 | ); 46 | case 'POST': 47 | return myRetry( 48 | () => http 49 | .post(Uri.parse(url), headers: headers, body: data) 50 | .timeout(Duration(seconds: timeout)), 51 | ); 52 | case 'PUT': 53 | return myRetry( 54 | () => http 55 | .put(Uri.parse(url), headers: headers, body: data) 56 | .timeout(Duration(seconds: timeout)), 57 | ); 58 | case 'DELETE': 59 | return myRetry( 60 | () => http 61 | .delete(Uri.parse(url), headers: headers) 62 | .timeout(Duration(seconds: timeout)), 63 | ); 64 | default: 65 | return myRetry( 66 | () => http 67 | .get(Uri.parse(url), headers: headers) 68 | .timeout(Duration(seconds: timeout)), 69 | ); 70 | } 71 | } 72 | 73 | Future fetchWithRetry(String url) async { 74 | try { 75 | var res = await myRetry( 76 | () => http.get(Uri.parse(url)).timeout(const Duration(seconds: 10)), 77 | ); 78 | 79 | return res; 80 | } catch (e) { 81 | return null; 82 | } 83 | } 84 | 85 | Future> fetchConcurrentWithRetry( 86 | List urls, { 87 | int maxConcurrent = 8, 88 | }) async { 89 | var results = {}; 90 | var futures = >[]; 91 | var tempUrls = []; 92 | for (var url in urls) { 93 | futures.add(fetchWithRetry(url)); 94 | tempUrls.add(url); 95 | 96 | if (futures.length >= maxConcurrent) { 97 | var responses = await Future.wait(futures); 98 | for (var response in responses) { 99 | results[tempUrls[responses.indexOf(response)]] = response; 100 | } 101 | 102 | futures = >[]; 103 | tempUrls = []; 104 | } 105 | } 106 | 107 | if (futures.isNotEmpty) { 108 | var responses = await Future.wait(futures); 109 | 110 | for (var response in responses) { 111 | results[tempUrls[responses.indexOf(response)]] = response; 112 | } 113 | } 114 | 115 | return results; 116 | } 117 | -------------------------------------------------------------------------------- /lib/utils/keepalive.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class KeepAliveWrapper extends StatefulWidget { 4 | final Widget child; 5 | 6 | const KeepAliveWrapper({required Key key, required this.child}) 7 | : super(key: key); 8 | 9 | @override 10 | State createState() => _KeepAliveWrapperState(); 11 | } 12 | 13 | class _KeepAliveWrapperState extends State 14 | with AutomaticKeepAliveClientMixin { 15 | @override 16 | Widget build(BuildContext context) { 17 | super.build(context); 18 | return widget.child; 19 | } 20 | 21 | @override 22 | bool get wantKeepAlive => true; 23 | } 24 | -------------------------------------------------------------------------------- /lib/utils/rss_fetcher.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:isolate'; 3 | 4 | import 'package:anycast/models/feed_episode.dart'; 5 | import 'package:anycast/models/subscription.dart'; 6 | import 'package:anycast/states/subscription.dart'; 7 | import 'package:anycast/utils/http_client.dart'; 8 | import 'package:get/get.dart'; 9 | import 'package:webfeed_plus/webfeed_plus.dart'; 10 | import 'package:html/parser.dart' as html_parser; 11 | 12 | class PodcastImportData { 13 | SubscriptionModel? subscription; 14 | List? feedEpisodes; 15 | 16 | PodcastImportData(this.subscription, this.feedEpisodes); 17 | } 18 | 19 | Future> importPodcastsByUrls( 20 | List rssFeedUrls, { 21 | Function(int, int)? onProgress, 22 | }) async { 23 | // filter exsiting subscriptions 24 | var existingSubscriptions = Get.find().subscriptions; 25 | var s = Set.from(existingSubscriptions.map((e) => e.rssFeedUrl)); 26 | s = {}; 27 | rssFeedUrls = rssFeedUrls.where((element) => !s.contains(element)).toList(); 28 | 29 | return fetchPodcastsByUrls(rssFeedUrls, onProgress: onProgress); 30 | } 31 | 32 | Future> fetchPodcastsByUrls( 33 | List rssFeedUrls, { 34 | bool onlyFistEpisode = true, 35 | Function(int, int)? onProgress, // (process, total) 36 | Function(List)? onSave, 37 | }) async { 38 | // use isolate 39 | final ReceivePort receivePort = ReceivePort(); 40 | await Isolate.spawn(_fetchPodcastsByUrls, [ 41 | rssFeedUrls, 42 | onlyFistEpisode, 43 | receivePort.sendPort, 44 | ]); 45 | 46 | List? result; 47 | 48 | await for (var o in receivePort) { 49 | if (o is TempResult) { 50 | if (onProgress != null) { 51 | onProgress(o.process, o.total); 52 | } 53 | if (onSave != null) { 54 | onSave(o.podcasts); 55 | } 56 | } 57 | 58 | if (o is List) { 59 | result = o; 60 | break; 61 | } 62 | } 63 | 64 | return result ?? []; 65 | } 66 | 67 | class TempResult { 68 | int process; 69 | int total; 70 | List podcasts; 71 | 72 | TempResult(this.process, this.total, this.podcasts); 73 | } 74 | 75 | void _fetchPodcastsByUrls(List args) async { 76 | var rssFeedUrls = args[0] as List; 77 | var onlyFistEpisode = args[1] as bool; 78 | var sendPort = args[2] as SendPort; 79 | 80 | List podcasts = []; 81 | 82 | for (var i = 0; i < rssFeedUrls.length; i += 8) { 83 | var end = i + 8 > rssFeedUrls.length ? rssFeedUrls.length : i + 8; 84 | var chunk = rssFeedUrls.sublist(i, end); 85 | var responses = await fetchConcurrentWithRetry(chunk); 86 | 87 | // var responses = await fetchConcurrentWithRetry(rssFeedUrls); 88 | var result = responses.entries.map((entry) { 89 | var rssFeedUrl = entry.key; 90 | var response = entry.value; 91 | if (response == null) { 92 | return null; 93 | } 94 | var body = utf8.decode(response.bodyBytes); 95 | RssFeed channel; 96 | try { 97 | channel = RssFeed.parse(body); 98 | } catch (error) { 99 | print(error); 100 | return null; 101 | } 102 | var subscription = SubscriptionModel.fromMap(Map.from({ 103 | 'rssFeedUrl': rssFeedUrl, 104 | 'title': channel.title?.trim(), 105 | 'description': htmlToText(channel.description).trim(), 106 | 'imageUrl': channel.image?.url ?? (channel.itunes?.image?.href ?? ''), 107 | 'link': channel.link, 108 | 'categories': channel.categories?.map((e) => e.value).join(','), 109 | 'author': channel.itunes?.author, 110 | 'email': channel.itunes?.owner?.email, 111 | })); 112 | channel.items!.sort((a, b) { 113 | return b.pubDate!.compareTo(a.pubDate!); 114 | }); 115 | List feedEpisodes = []; 116 | var length = onlyFistEpisode ? 1 : channel.items!.length; 117 | if (channel.items!.isEmpty) { 118 | length = 0; 119 | } 120 | for (var i = 0; i < length; i++) { 121 | var item = channel.items![i]; 122 | if (item.enclosure == null) { 123 | continue; 124 | } 125 | var feedEpisode = FeedEpisodeModel.fromMap(Map.from({ 126 | 'title': item.title?.trim(), 127 | 'description': 128 | item.itunes?.summary?.trim() ?? item.description?.trim(), 129 | 'duration': item.itunes?.duration?.inMilliseconds, 130 | 'enclosureUrl': item.enclosure?.url, 131 | 'pubDate': item.pubDate?.millisecondsSinceEpoch, 132 | 'imageUrl': item.itunes?.image?.href ?? subscription.imageUrl, 133 | 'channelTitle': subscription.title, 134 | 'rssFeedUrl': subscription.rssFeedUrl, 135 | })); 136 | feedEpisodes.add(feedEpisode); 137 | } 138 | if (feedEpisodes.isEmpty) { 139 | subscription.lastUpdated = DateTime.now().millisecondsSinceEpoch; 140 | } else { 141 | subscription.lastUpdated = feedEpisodes[0].pubDate; 142 | } 143 | return PodcastImportData(subscription, feedEpisodes); 144 | }).toList(); 145 | 146 | for (PodcastImportData? podcast in result) { 147 | if (podcast == null) { 148 | continue; 149 | } 150 | podcasts.add(podcast); 151 | } 152 | 153 | sendPort.send(TempResult(i, rssFeedUrls.length, result)); 154 | } 155 | 156 | Isolate.exit(sendPort, podcasts); 157 | } 158 | 159 | String htmlToText(String? html) { 160 | if (html == null) { 161 | return ''; 162 | } 163 | html = html.trim(); 164 | 165 | if (!html.startsWith('<')) { 166 | return html; 167 | } 168 | 169 | try { 170 | var document = html_parser.parse(html); 171 | if (document.body == null) { 172 | return html; 173 | } 174 | return document.body!.text.trim(); 175 | } catch (error) { 176 | print(error); 177 | return html; 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /lib/widgets/animation.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class AnimatedPlaylistIndicator extends StatefulWidget { 4 | final Offset startPosition; 5 | final Offset endPosition; 6 | final VoidCallback onAnimationComplete; 7 | 8 | const AnimatedPlaylistIndicator( 9 | {super.key, 10 | required this.startPosition, 11 | required this.endPosition, 12 | required this.onAnimationComplete}); 13 | 14 | @override 15 | AnimatedPlaylistIndicatorState createState() => 16 | AnimatedPlaylistIndicatorState(); 17 | } 18 | 19 | class AnimatedPlaylistIndicatorState extends State 20 | with SingleTickerProviderStateMixin { 21 | late AnimationController _controller; 22 | late Animation _positionAnimation; 23 | late Animation _heightAnimation; 24 | late Animation _widthAnimation; 25 | late Animation _backOpacityAnimation; 26 | 27 | @override 28 | void initState() { 29 | super.initState(); 30 | _controller = AnimationController( 31 | duration: const Duration(milliseconds: 600), 32 | vsync: this, 33 | ); 34 | 35 | _positionAnimation = Tween( 36 | begin: widget.startPosition, 37 | end: widget.endPosition, 38 | ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); 39 | 40 | _heightAnimation = Tween(begin: 48, end: 24).animate( 41 | CurvedAnimation(parent: _controller, curve: Curves.easeInOut), 42 | ); 43 | 44 | _widthAnimation = Tween(begin: 200, end: 24).animate( 45 | CurvedAnimation(parent: _controller, curve: Curves.easeInOut), 46 | ); 47 | 48 | _backOpacityAnimation = Tween(begin: 0.8, end: 0).animate( 49 | CurvedAnimation(parent: _controller, curve: Curves.easeInOut), 50 | ); 51 | 52 | _controller.forward().then((_) { 53 | widget.onAnimationComplete(); 54 | }); 55 | } 56 | 57 | @override 58 | Widget build(BuildContext context) { 59 | return Stack( 60 | children: [ 61 | AnimatedBuilder( 62 | animation: _controller, 63 | builder: (context, child) { 64 | return Positioned( 65 | left: _positionAnimation.value.dx - _widthAnimation.value / 2, 66 | top: _positionAnimation.value.dy - _heightAnimation.value / 2, 67 | child: Container( 68 | width: _widthAnimation.value, 69 | height: _heightAnimation.value, 70 | decoration: BoxDecoration( 71 | color: Colors.black.withOpacity(_backOpacityAnimation.value), 72 | borderRadius: BorderRadius.circular(24), 73 | ), 74 | child: const Icon(Icons.play_arrow, color: Colors.white), 75 | ), 76 | ); 77 | }, 78 | ), 79 | ], 80 | ); 81 | } 82 | 83 | @override 84 | void dispose() { 85 | _controller.dispose(); 86 | super.dispose(); 87 | } 88 | } 89 | 90 | class PlayPauseAnimation extends StatefulWidget { 91 | final bool isPlaying; 92 | 93 | const PlayPauseAnimation({super.key, required this.isPlaying}); 94 | 95 | @override 96 | State createState() => _PlayPauseAnimationState(); 97 | } 98 | 99 | class _PlayPauseAnimationState extends State 100 | with SingleTickerProviderStateMixin { 101 | late AnimationController _controller; 102 | late Animation _animation; 103 | 104 | @override 105 | void initState() { 106 | super.initState(); 107 | _controller = AnimationController( 108 | duration: const Duration(milliseconds: 200), 109 | vsync: this, 110 | ); 111 | _animation = CurvedAnimation(parent: _controller, curve: Curves.easeInOut); 112 | _controller.forward(); 113 | } 114 | 115 | @override 116 | void dispose() { 117 | _controller.dispose(); 118 | super.dispose(); 119 | } 120 | 121 | @override 122 | Widget build(BuildContext context) { 123 | return AnimatedBuilder( 124 | animation: _animation, 125 | builder: (context, child) { 126 | return CustomPaint( 127 | size: const Size(80, 80), 128 | painter: PlayPausePainter( 129 | progress: _animation.value, 130 | isPlaying: widget.isPlaying, 131 | ), 132 | ); 133 | }, 134 | ); 135 | } 136 | } 137 | 138 | class PlayPausePainter extends CustomPainter { 139 | final double progress; 140 | final bool isPlaying; 141 | 142 | PlayPausePainter({required this.progress, required this.isPlaying}); 143 | 144 | @override 145 | void paint(Canvas canvas, Size size) { 146 | final paint = Paint() 147 | ..color = Colors.white70 148 | ..style = PaintingStyle.fill; 149 | 150 | if (isPlaying) { 151 | // 绘制暂停图标 152 | final left = size.width * (0.3 + 0.1 * progress); 153 | final right = size.width * (0.7 - 0.1 * progress); 154 | canvas.drawRect( 155 | Rect.fromLTRB(left, size.height * 0.2, left + size.width * 0.1, 156 | size.height * 0.8), 157 | paint); 158 | canvas.drawRect( 159 | Rect.fromLTRB(right, size.height * 0.2, right + size.width * 0.1, 160 | size.height * 0.8), 161 | paint); 162 | } else { 163 | // 绘制播放图标 164 | final path = Path(); 165 | path.moveTo(size.width * 0.3, size.height * 0.2); 166 | path.lineTo(size.width * (0.3 + 0.5 * progress), size.height * 0.5); 167 | path.lineTo(size.width * 0.3, size.height * 0.8); 168 | path.close(); 169 | canvas.drawPath(path, paint); 170 | } 171 | } 172 | 173 | @override 174 | bool shouldRepaint(covariant CustomPainter oldDelegate) => true; 175 | } 176 | 177 | class CenterPlayArrow extends StatelessWidget { 178 | const CenterPlayArrow({super.key}); 179 | 180 | @override 181 | Widget build(BuildContext context) { 182 | return const Center( 183 | child: Icon(Icons.play_arrow, color: Colors.white), 184 | ); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /lib/widgets/appbar.dart: -------------------------------------------------------------------------------- 1 | import 'package:anycast/pages/discover.dart'; 2 | import 'package:anycast/pages/settings.dart'; 3 | import 'package:anycast/states/discover.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:get/get.dart'; 6 | import 'package:google_fonts/google_fonts.dart'; 7 | import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; 8 | import 'package:simple_gradient_text/simple_gradient_text.dart'; 9 | 10 | class MyAppBar extends StatelessWidget implements PreferredSizeWidget { 11 | final String title; 12 | 13 | const MyAppBar({super.key, required this.title}); 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return AppBar( 18 | title: Padding( 19 | padding: const EdgeInsets.only(left: 24, right: 24, top: 8), 20 | child: Column( 21 | children: [ 22 | Container( 23 | alignment: Alignment.centerRight, 24 | child: GestureDetector( 25 | onTap: () { 26 | showMaterialModalBottomSheet( 27 | expand: true, 28 | context: context, 29 | builder: (context) { 30 | return const SettingsPage(); 31 | }, 32 | closeProgressThreshold: 0.9, 33 | ); 34 | }, 35 | child: Container( 36 | height: 36, 37 | width: 36, 38 | alignment: Alignment.center, 39 | decoration: BoxDecoration( 40 | color: const Color(0xFF232830), 41 | borderRadius: BorderRadius.circular(18), 42 | ), 43 | child: Icon( 44 | Icons.settings_rounded, 45 | color: Colors.grey.shade400, 46 | size: 24, 47 | ), 48 | ), 49 | ), 50 | ), 51 | Container( 52 | alignment: Alignment.centerLeft, 53 | height: 48, 54 | child: GradientText( 55 | title, 56 | gradientDirection: GradientDirection.ttb, 57 | colors: const [ 58 | Color(0xFF059669), 59 | Color(0x00059669), 60 | ], 61 | style: TextStyle( 62 | fontSize: 44, 63 | fontFamily: GoogleFonts.comfortaa().fontFamily, 64 | fontWeight: FontWeight.w700, 65 | height: 0, 66 | letterSpacing: 4.40, 67 | ), 68 | ), 69 | ), 70 | const SearchBar(), 71 | ], 72 | ), 73 | ), 74 | ); 75 | } 76 | 77 | @override 78 | Size get preferredSize => const Size.fromHeight(148); 79 | } 80 | 81 | class SearchBar extends GetView { 82 | const SearchBar({super.key}); 83 | 84 | @override 85 | Widget build(BuildContext context) { 86 | var searchBar = Container( 87 | height: 56, 88 | padding: const EdgeInsets.symmetric(vertical: 8), 89 | child: TextField( 90 | onTapOutside: (event) { 91 | FocusScope.of(context).unfocus(); 92 | }, 93 | onChanged: (value) { 94 | controller.searchText.value = value; 95 | }, 96 | onSubmitted: (value) { 97 | if (value.isEmpty) { 98 | return; 99 | } 100 | controller.searchText.value = value; 101 | showMaterialModalBottomSheet( 102 | expand: true, 103 | context: context, 104 | builder: (context) => SearchPage(searchText: value), 105 | closeProgressThreshold: 0.8, 106 | ); 107 | }, 108 | controller: controller.searchController, 109 | style: const TextStyle( 110 | color: Colors.white, 111 | fontSize: 16, 112 | fontWeight: FontWeight.w400, 113 | ), 114 | decoration: InputDecoration( 115 | hintText: 'Shows,Episodes,and more', 116 | hintStyle: TextStyle( 117 | color: const Color(0xFF4B5563), 118 | fontSize: 16, 119 | fontFamily: GoogleFonts.comfortaa().fontFamily, 120 | fontWeight: FontWeight.w400, 121 | height: 0, 122 | ), 123 | prefixIcon: const Icon( 124 | Icons.search, 125 | color: Color(0xFF4B5563), 126 | size: 24, 127 | ), 128 | filled: true, 129 | fillColor: const Color(0xFF232830), 130 | border: OutlineInputBorder( 131 | borderRadius: BorderRadius.circular(12), 132 | borderSide: const BorderSide( 133 | color: Color(0xFF232830), 134 | ), 135 | ), 136 | focusedBorder: OutlineInputBorder( 137 | borderRadius: BorderRadius.circular(12), 138 | borderSide: const BorderSide( 139 | color: Color(0xFF232830), 140 | ), 141 | ), 142 | ), 143 | ), 144 | ); 145 | 146 | return Obx(() { 147 | Widget cancel = const SizedBox.shrink(); 148 | if (controller.searchText.value.isNotEmpty) { 149 | cancel = Row( 150 | children: [ 151 | const SizedBox( 152 | width: 16, 153 | ), 154 | GestureDetector( 155 | onTap: () { 156 | controller.searchController.clear(); 157 | controller.searchText.value = ''; 158 | FocusScope.of(context).requestFocus(FocusNode()); 159 | }, 160 | child: Text( 161 | 'Cancel', 162 | style: TextStyle( 163 | color: const Color(0xFF34D399), 164 | fontSize: 16, 165 | fontFamily: GoogleFonts.comfortaa().fontFamily, 166 | fontWeight: FontWeight.w400, 167 | height: 0, 168 | ), 169 | ), 170 | ), 171 | ], 172 | ); 173 | } 174 | return Row( 175 | children: [ 176 | Expanded(child: searchBar), 177 | cancel, 178 | ], 179 | ); 180 | }); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /lib/widgets/expandable_text.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:get/get.dart'; 3 | 4 | class ExpandableText extends StatelessWidget { 5 | final String text; 6 | final TextStyle style; 7 | final TextAlign textAlign; 8 | final int maxLines; 9 | 10 | const ExpandableText( 11 | this.text, { 12 | super.key, 13 | this.maxLines = 2, 14 | this.textAlign = TextAlign.left, 15 | required this.style, 16 | }); 17 | 18 | @override 19 | Widget build(BuildContext context) { 20 | return LayoutBuilder( 21 | builder: (BuildContext context, BoxConstraints constraints) { 22 | final TextSpan span = TextSpan(text: text, style: style); 23 | final TextPainter tp = TextPainter( 24 | text: span, 25 | maxLines: maxLines, 26 | textDirection: TextDirection.ltr, 27 | textAlign: textAlign, 28 | ); 29 | tp.layout(maxWidth: constraints.maxWidth - 16); 30 | 31 | if (tp.didExceedMaxLines) { 32 | return GestureDetector( 33 | onTap: () { 34 | Get.dialog( 35 | AlertDialog( 36 | content: Scrollbar( 37 | trackVisibility: true, 38 | thickness: 2, 39 | child: SingleChildScrollView( 40 | child: Text(text, style: style), 41 | ), 42 | ), 43 | actions: [ 44 | IconButton( 45 | style: IconButton.styleFrom( 46 | backgroundColor: Colors.white, 47 | foregroundColor: Colors.black, 48 | ), 49 | icon: const Icon(Icons.close), 50 | onPressed: () => Get.back(), 51 | ), 52 | ], 53 | ), 54 | ); 55 | }, 56 | child: Container( 57 | padding: const EdgeInsets.all(8.0), 58 | width: constraints.maxWidth, 59 | decoration: ShapeDecoration( 60 | shape: RoundedRectangleBorder( 61 | borderRadius: BorderRadius.circular(8)), 62 | color: Colors.blue.withOpacity(0.1), 63 | ), 64 | child: Text( 65 | text.replaceAll('\n\n', '\n'), 66 | maxLines: maxLines, 67 | overflow: TextOverflow.ellipsis, 68 | style: style, 69 | textAlign: textAlign, 70 | ), 71 | ), 72 | ); 73 | } else { 74 | return Text(text, style: style, textAlign: textAlign); 75 | } 76 | }, 77 | ); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /lib/widgets/handler.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class Handler extends StatefulWidget { 4 | const Handler({super.key}); 5 | 6 | @override 7 | HandlerState createState() => HandlerState(); 8 | } 9 | 10 | class HandlerState extends State with SingleTickerProviderStateMixin { 11 | late AnimationController _controller; 12 | late Animation _animation; 13 | 14 | // 使用 static 变量来跟踪动画是否已经显示过 15 | static bool _hasShownAnimation = false; 16 | 17 | @override 18 | void initState() { 19 | super.initState(); 20 | _controller = AnimationController( 21 | duration: const Duration(milliseconds: 600), 22 | vsync: this, 23 | ); 24 | 25 | _animation = TweenSequence([ 26 | TweenSequenceItem( 27 | tween: Tween( 28 | begin: Offset.zero, 29 | end: const Offset(0, 0.8), 30 | ).chain(CurveTween(curve: Curves.easeOutCubic)), 31 | weight: 60, 32 | ), 33 | TweenSequenceItem( 34 | tween: Tween( 35 | begin: const Offset(0, 0.8), 36 | end: Offset.zero, 37 | ).chain(CurveTween(curve: Curves.easeInQuad)), 38 | weight: 40, 39 | ), 40 | ]).animate(_controller); 41 | 42 | _checkAndPlayAnimation(); 43 | } 44 | 45 | void _checkAndPlayAnimation() { 46 | if (!_hasShownAnimation) { 47 | Future.delayed(const Duration(seconds: 3), () { 48 | if (mounted) { 49 | _playAnimation(); 50 | // _hasShownAnimation = true; 51 | } 52 | }); 53 | } 54 | } 55 | 56 | void _playAnimation() async { 57 | await _controller.forward(); 58 | await _controller.reverse(); 59 | // await Future.delayed(const Duration(milliseconds: 200)); 60 | // await _controller.forward(); 61 | } 62 | 63 | @override 64 | void dispose() { 65 | _controller.dispose(); 66 | super.dispose(); 67 | } 68 | 69 | @override 70 | Widget build(BuildContext context) { 71 | return SlideTransition( 72 | position: _animation, 73 | child: Container( 74 | width: 42, 75 | height: 6, 76 | clipBehavior: Clip.antiAlias, 77 | decoration: ShapeDecoration( 78 | color: Colors.white.withOpacity(0.2), 79 | shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), 80 | ), 81 | ), 82 | ); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /lib/widgets/import_indicator.dart: -------------------------------------------------------------------------------- 1 | import 'package:anycast/states/import_indicator.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:get/get.dart'; 4 | 5 | class ImportIndicator extends GetView { 6 | const ImportIndicator({super.key}); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | return Obx( 11 | () => Center( 12 | child: CircularProgressIndicator( 13 | value: controller.progress.toDouble(), 14 | backgroundColor: Colors.grey, 15 | strokeCap: StrokeCap.round, 16 | )), 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /lib/widgets/play_icon.dart: -------------------------------------------------------------------------------- 1 | import 'package:anycast/states/player.dart'; 2 | import 'package:anycast/states/subtitle.dart'; 3 | import 'package:flutter/material.dart'; 4 | import 'package:get/get.dart'; 5 | import 'package:iconify_flutter/iconify_flutter.dart'; 6 | import 'package:iconify_flutter/icons/ic.dart'; 7 | import 'package:lottie/lottie.dart'; 8 | 9 | class PlayIcon extends GetView { 10 | final double size; 11 | final Color color; 12 | final String enclosureUrl; 13 | 14 | const PlayIcon({ 15 | super.key, 16 | this.size = 24, 17 | this.color = Colors.black, 18 | this.enclosureUrl = '', 19 | }); 20 | 21 | @override 22 | Widget build(BuildContext context) { 23 | return Obx(() { 24 | if (enclosureUrl != '' && 25 | controller.playlistEpisode.value.enclosureUrl != enclosureUrl) { 26 | return Iconify(Ic.round_play_arrow, size: size, color: color); 27 | } 28 | 29 | var isPlaying = controller.isPlaying.value; 30 | var isLoading = controller.isLoading.value; 31 | 32 | if (isLoading) { 33 | if (color == Colors.white) { 34 | return Lottie.asset('assets/lottie/loading.json', 35 | height: size, width: size); 36 | } 37 | return Lottie.asset('assets/lottie/loading_black.json', 38 | height: size, width: size); 39 | } 40 | if (isPlaying) { 41 | return Iconify(Ic.round_pause, size: size, color: color); 42 | } 43 | return Iconify(Ic.round_play_arrow, size: size, color: color); 44 | }); 45 | } 46 | } 47 | 48 | class AIIcon extends GetView { 49 | final String enclosureUrl; 50 | final double size; 51 | final Color color; 52 | 53 | const AIIcon({ 54 | super.key, 55 | required this.enclosureUrl, 56 | this.size = 24, 57 | this.color = Colors.white, 58 | }); 59 | 60 | @override 61 | Widget build(BuildContext context) { 62 | return Obx(() { 63 | var status = controller.subtitleUrls[enclosureUrl]; 64 | 65 | switch (status) { 66 | case 'processing': 67 | return SizedBox( 68 | height: 24, 69 | child: Lottie.asset('assets/lottie/robot_loading.json'), 70 | ); 71 | case 'succeeded': 72 | return Iconify(Ic.round_check_circle, size: size, color: color); 73 | case 'failed': 74 | return Iconify(Ic.round_sms_failed, size: size, color: color); 75 | default: 76 | return Iconify(aiTranscript, size: size, color: Colors.black); 77 | } 78 | }); 79 | } 80 | } 81 | 82 | const aiTranscript = 83 | ''; 84 | -------------------------------------------------------------------------------- /lib/widgets/privacy.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:google_fonts/google_fonts.dart'; 3 | import 'package:url_launcher/url_launcher.dart'; 4 | 5 | class Privacy extends StatelessWidget { 6 | const Privacy({ 7 | super.key, 8 | }); 9 | 10 | @override 11 | Widget build(BuildContext context) { 12 | return Center( 13 | child: Column(children: [ 14 | const SizedBox(height: 8), 15 | GestureDetector( 16 | onTap: () { 17 | launchUrl( 18 | Uri(scheme: 'https', host: 'privacy.anycast.website'), 19 | mode: LaunchMode.inAppBrowserView, 20 | ); 21 | }, 22 | child: Text( 23 | 'Privacy Policy', 24 | style: GoogleFonts.comfortaa( 25 | color: Colors.blueAccent, 26 | fontSize: 12, 27 | decoration: TextDecoration.underline, 28 | ), 29 | ), 30 | ), 31 | const SizedBox(height: 8), 32 | GestureDetector( 33 | onTap: () { 34 | launchUrl( 35 | Uri( 36 | scheme: 'https', 37 | host: 'www.apple.com', 38 | path: '/legal/internet-services/itunes/dev/stdeula/', 39 | ), 40 | mode: LaunchMode.inAppBrowserView, 41 | ); 42 | }, 43 | child: Text( 44 | 'Terms of Use (EULA)', 45 | style: GoogleFonts.comfortaa( 46 | color: Colors.blueAccent, 47 | fontSize: 12, 48 | decoration: TextDecoration.underline, 49 | ), 50 | ), 51 | ), 52 | ]), 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/widgets/share.dart: -------------------------------------------------------------------------------- 1 | import 'package:anycast/states/feed_episode.dart'; 2 | import 'package:anycast/states/share.dart'; 3 | import 'package:anycast/states/subscription.dart'; 4 | import 'package:anycast/utils/rss_fetcher.dart'; 5 | import 'package:flutter/material.dart'; 6 | import 'package:get/get.dart'; 7 | import 'package:google_fonts/google_fonts.dart'; 8 | 9 | class ShareDialog extends GetView { 10 | const ShareDialog({super.key}); 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | if (controller.sharedFile == null || controller.opmls.isEmpty) { 15 | return AlertDialog( 16 | title: Text( 17 | 'Import Podcasts', 18 | style: GoogleFonts.comfortaa( 19 | fontSize: 20, 20 | color: Colors.white, 21 | decoration: TextDecoration.none, 22 | fontWeight: FontWeight.w400, 23 | ), 24 | ), 25 | content: Text( 26 | 'Oh no! Seems like there is no valid links in the file.', 27 | style: GoogleFonts.comfortaa( 28 | fontSize: 18, 29 | color: Colors.white, 30 | decoration: TextDecoration.none, 31 | ), 32 | ), 33 | actions: [ 34 | TextButton( 35 | onPressed: () { 36 | Get.back(); 37 | }, 38 | child: Text( 39 | 'OK', 40 | style: GoogleFonts.comfortaa( 41 | fontSize: 18, 42 | color: Colors.white, 43 | decoration: TextDecoration.none, 44 | ), 45 | ), 46 | ), 47 | ], 48 | ); 49 | } 50 | return AlertDialog( 51 | titleTextStyle: GoogleFonts.comfortaa( 52 | fontSize: 20, 53 | color: Colors.white, 54 | decoration: TextDecoration.none, 55 | fontWeight: FontWeight.w400, 56 | ), 57 | contentTextStyle: GoogleFonts.comfortaa( 58 | fontSize: 18, 59 | color: Colors.white, 60 | decoration: TextDecoration.none, 61 | ), 62 | title: const Text("Import Podcasts"), 63 | content: SizedBox( 64 | width: Get.width * 0.8, 65 | height: 300, 66 | child: Obx( 67 | () { 68 | // parse opml 69 | final opmls = controller.opmls; 70 | 71 | return ListView.separated( 72 | separatorBuilder: (context, index) => const Divider(height: 1), 73 | itemCount: opmls.length, 74 | itemBuilder: (context, index) { 75 | return Card( 76 | color: Colors.black, 77 | child: ListTile( 78 | title: Text( 79 | opmls[index].title, 80 | style: GoogleFonts.comfortaa( 81 | fontSize: 16, 82 | color: Colors.white, 83 | ), 84 | maxLines: 1, 85 | overflow: TextOverflow.ellipsis, 86 | ), 87 | ), 88 | ); 89 | }, 90 | ); 91 | }, 92 | ), 93 | ), 94 | actions: [ 95 | TextButton( 96 | onPressed: () { 97 | Get.back(); 98 | }, 99 | style: TextButton.styleFrom( 100 | foregroundColor: Colors.white70, 101 | ), 102 | child: const Text('Close'), 103 | ), 104 | TextButton( 105 | onPressed: () async { 106 | Get.dialog(const ImportProgressIndicator()); 107 | var result = await importPodcastsByUrls( 108 | controller.opmls.map((e) => e.url).toList(), 109 | onProgress: (progress, total) { 110 | controller.progress.value = progress / total; 111 | }, 112 | ); 113 | Get.find().addMany(result 114 | .where( 115 | (e) => e.feedEpisodes != null && e.feedEpisodes!.isNotEmpty) 116 | .map((e) => e.feedEpisodes![0]) 117 | .toList()); 118 | Get.find() 119 | .addMany(result.map((e) => e.subscription!).toList()); 120 | 121 | Get.back(); 122 | Get.back(); 123 | controller.progress.value = 0; 124 | 125 | var titles = 126 | result.map((e) => e.subscription!.title).toList().join(', '); 127 | if (titles.length > 50) { 128 | titles = '${titles.substring(0, 50)}...'; 129 | } 130 | Get.snackbar('Success', 'Import $titles successfully', 131 | snackPosition: SnackPosition.BOTTOM); 132 | }, 133 | style: TextButton.styleFrom( 134 | backgroundColor: Colors.white, 135 | foregroundColor: Colors.black, 136 | ), 137 | child: Text( 138 | 'Import', 139 | style: GoogleFonts.comfortaa( 140 | fontSize: 16, 141 | color: Colors.black, 142 | fontWeight: FontWeight.w700, 143 | ), 144 | ), 145 | ), 146 | ], 147 | ); 148 | } 149 | } 150 | 151 | class ImportProgressIndicator extends GetView { 152 | const ImportProgressIndicator({super.key}); 153 | 154 | @override 155 | Widget build(BuildContext context) { 156 | return Center( 157 | child: Obx( 158 | () { 159 | var progress = controller.progress.value; 160 | return CircularProgressIndicator( 161 | value: progress, 162 | color: Colors.green, 163 | backgroundColor: Colors.white.withOpacity(0.4), 164 | strokeWidth: 2, 165 | strokeCap: StrokeCap.round, 166 | ); 167 | }, 168 | ), 169 | ); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /test/widget_test.dart: -------------------------------------------------------------------------------- 1 | // This is a basic Flutter widget test. 2 | // 3 | // To perform an interaction with a widget in your test, use the WidgetTester 4 | // utility in the flutter_test package. For example, you can send tap and scroll 5 | // gestures. You can also use WidgetTester to find child widgets in the widget 6 | // tree, read text, and verify that the values of widget properties are correct. 7 | 8 | import 'package:flutter/material.dart'; 9 | import 'package:flutter_test/flutter_test.dart'; 10 | 11 | 12 | void main() { 13 | testWidgets('Counter increments smoke test', (WidgetTester tester) async { 14 | // Build our app and trigger a frame. 15 | await tester.pumpWidget(const MyApp()); 16 | 17 | // Verify that our counter starts at 0. 18 | expect(find.text('0'), findsOneWidget); 19 | expect(find.text('1'), findsNothing); 20 | 21 | // Tap the '+' icon and trigger a frame. 22 | await tester.tap(find.byIcon(Icons.add)); 23 | await tester.pump(); 24 | 25 | // Verify that our counter has incremented. 26 | expect(find.text('0'), findsNothing); 27 | expect(find.text('1'), findsOneWidget); 28 | }); 29 | } 30 | --------------------------------------------------------------------------------