├── .gitignore ├── .metadata ├── LICENSE ├── README.md ├── analysis_options.yaml ├── android ├── .gitignore ├── app │ ├── build.gradle │ └── src │ │ ├── debug │ │ └── AndroidManifest.xml │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── java │ │ │ └── io │ │ │ │ └── flutter │ │ │ │ └── app │ │ │ │ └── FlutterMultiDexApplication.java │ │ ├── kotlin │ │ │ └── dev │ │ │ │ └── abmgrt │ │ │ │ └── shlink_app │ │ │ │ └── MainActivity.kt │ │ └── res │ │ │ ├── drawable-hdpi │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── drawable-mdpi │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── drawable-v21 │ │ │ └── launch_background.xml │ │ │ ├── drawable-xhdpi │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── drawable-xxhdpi │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── drawable-xxxhdpi │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── drawable │ │ │ └── launch_background.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ └── ic_launcher.xml │ │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── values-night │ │ │ └── styles.xml │ │ │ └── values │ │ │ ├── colors.xml │ │ │ └── styles.xml │ │ └── profile │ │ └── AndroidManifest.xml ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties └── settings.gradle ├── assets └── icon │ ├── icon.png │ ├── icon_background.png │ ├── icon_foreground.png │ └── icon_monochrome.png ├── lib ├── API │ ├── Classes │ │ ├── ShlinkStats │ │ │ ├── shlink_stats.dart │ │ │ └── shlink_stats_visits.dart │ │ ├── ShortURL │ │ │ ├── RedirectRule │ │ │ │ ├── condition_device_type.dart │ │ │ │ ├── redirect_rule.dart │ │ │ │ ├── redirect_rule_condition.dart │ │ │ │ └── redirect_rule_condition_type.dart │ │ │ ├── short_url.dart │ │ │ ├── short_url_meta.dart │ │ │ └── visits_summary.dart │ │ ├── ShortURLSubmission │ │ │ └── short_url_submission.dart │ │ └── Tag │ │ │ └── tag_with_stats.dart │ ├── Methods │ │ ├── connect.dart │ │ ├── delete_short_url.dart │ │ ├── get_recent_short_urls.dart │ │ ├── get_redirect_rules.dart │ │ ├── get_server_health.dart │ │ ├── get_shlink_stats.dart │ │ ├── get_short_urls.dart │ │ ├── get_tags_with_stats.dart │ │ ├── set_redirect_rules.dart │ │ ├── submit_short_url.dart │ │ └── update_short_url.dart │ └── server_manager.dart ├── global_theme.dart ├── globals.dart ├── main.dart ├── util │ ├── build_api_error_snackbar.dart │ ├── license.dart │ └── string_to_color.dart ├── views │ ├── home_view.dart │ ├── login_view.dart │ ├── navigationbar_view.dart │ ├── opensource_licenses_view.dart │ ├── redirect_rules_detail_view.dart │ ├── settings_view.dart │ ├── short_url_edit_view.dart │ ├── tag_selector_view.dart │ ├── url_detail_view.dart │ └── url_list_view.dart └── widgets │ ├── available_servers_bottom_sheet.dart │ └── url_tags_list_widget.dart ├── pubspec.lock ├── pubspec.yaml └── test └── widget_test.dart /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | migrate_working_dir/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # The .vscode folder contains launch configuration and tasks you configure in 20 | # VS Code which you may wish to be included in version control, so this line 21 | # is commented out by default. 22 | #.vscode/ 23 | 24 | # Flutter/Dart/Pub related 25 | **/doc/api/ 26 | **/ios/Flutter/.last_build_id 27 | .dart_tool/ 28 | .flutter-plugins 29 | .flutter-plugins-dependencies 30 | .packages 31 | .pub-cache/ 32 | .pub/ 33 | /build/ 34 | 35 | # Symbolication related 36 | app.*.symbols 37 | 38 | # Obfuscation related 39 | app.*.map.json 40 | 41 | # Android Studio will place build artifacts here 42 | /android/app/debug 43 | /android/app/profile 44 | /android/app/release 45 | -------------------------------------------------------------------------------- /.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: "2f708eb8396e362e280fac22cf171c2cb467343c" 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: 2f708eb8396e362e280fac22cf171c2cb467343c 17 | base_revision: 2f708eb8396e362e280fac22cf171c2cb467343c 18 | - platform: linux 19 | create_revision: 2f708eb8396e362e280fac22cf171c2cb467343c 20 | base_revision: 2f708eb8396e362e280fac22cf171c2cb467343c 21 | 22 | # User provided section 23 | 24 | # List of Local paths (relative to this file) that should be 25 | # ignored by the migrate tool. 26 | # 27 | # Files that are not part of the templates will be ignored by default. 28 | unmanaged_files: 29 | - 'lib/main.dart' 30 | - 'ios/Runner.xcodeproj/project.pbxproj' 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Adrian Baumgart 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 | Shlink Manager logo 4 | 5 | # Shlink Manager 6 | 7 |
8 | 9 | Shlink Manager is an app for Android to see and manage all shortened URLs created with [Shlink](https://shlink.io/) 10 | 11 | 12 | Play Store download 13 | 14 | 15 | [![Codemagic build status](https://api.codemagic.io/apps/66096ec96d57699debb805f8/66096ec96d57699debb805f7/status_badge.svg)](https://codemagic.io/apps/66096ec96d57699debb805f8/66096ec96d57699debb805f7/latest_build) 16 | 17 | ## 📱 Current Features 18 | 19 | ✅ List all short URLs
20 | ✅ Create, edit and delete short URLs
21 | ✅ See overall statistics
22 | ✅ Detailed statistics for each short URL
23 | ✅ Display tags & QR code
24 | ✅ Dark mode support
25 | ✅ Quickly create Short URL via Share Sheet
26 | ✅ View rule-based redirects (no editing yet)
27 | ✅ Use multiple Shlink instances
28 | 29 | ## 🔨 To Do 30 | - [ ] Add support for iOS (maybe in the future) 31 | - [ ] improve app icon 32 | - [ ] Refactor code 33 | - [ ] ...and more 34 | 35 | ## 💻 Development 36 | 37 | First make sure you've installed [Flutter](https://flutter.dev/) and the necessary tools to build for Android/iOS (Android Studio/Xcode). 38 | 39 | ```bash 40 | $ git clone https://github.com/rainloreley/shlink-manager.git 41 | $ cd shlink-manager 42 | $ flutter pub get 43 | $ flutter run 44 | ``` 45 | 46 | ## 📄 License 47 | 48 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details 49 | -------------------------------------------------------------------------------- /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 | 13 | 14 | linter: 15 | rules: 16 | # Style rules 17 | - camel_case_types 18 | - library_names 19 | - avoid_catching_errors 20 | - avoid_empty_else 21 | - unnecessary_brace_in_string_interps 22 | - avoid_redundant_argument_values 23 | - leading_newlines_in_multiline_strings 24 | # formatting 25 | - lines_longer_than_80_chars 26 | - curly_braces_in_flow_control_structures 27 | # doc comments 28 | - slash_for_doc_comments 29 | - package_api_docs 30 | # The lint rules applied to this project can be customized in the 31 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 32 | # included above or to enable additional rules. A list of all available lints 33 | # and their documentation is published at 34 | # https://dart-lang.github.io/linter/lints/index.html. 35 | # 36 | # Instead of disabling a lint rule for the entire project in the 37 | # section below, it can also be suppressed for a single line of code 38 | # or a specific dart file by using the `// ignore: name_of_lint` and 39 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 40 | # producing the lint. 41 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 42 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 43 | 44 | # Additional information about this file can be found at 45 | # https://dart.dev/guides/language/analysis-options 46 | -------------------------------------------------------------------------------- /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 | def localProperties = new Properties() 2 | def localPropertiesFile = rootProject.file('local.properties') 3 | if (localPropertiesFile.exists()) { 4 | localPropertiesFile.withReader('UTF-8') { reader -> 5 | localProperties.load(reader) 6 | } 7 | } 8 | 9 | def flutterRoot = localProperties.getProperty('flutter.sdk') 10 | if (flutterRoot == null) { 11 | throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") 12 | } 13 | 14 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 15 | if (flutterVersionCode == null) { 16 | flutterVersionCode = '1' 17 | } 18 | 19 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 20 | if (flutterVersionName == null) { 21 | flutterVersionName = '1.0' 22 | } 23 | 24 | apply plugin: 'com.android.application' 25 | apply plugin: 'kotlin-android' 26 | apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" 27 | def keystoreProperties = new Properties() 28 | def keystorePropertiesFile = rootProject.file('key.properties') 29 | if (keystorePropertiesFile.exists()) { 30 | keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) 31 | } 32 | 33 | android { 34 | compileSdkVersion flutter.compileSdkVersion 35 | ndkVersion flutter.ndkVersion 36 | 37 | compileOptions { 38 | sourceCompatibility JavaVersion.VERSION_1_8 39 | targetCompatibility JavaVersion.VERSION_1_8 40 | } 41 | 42 | kotlinOptions { 43 | jvmTarget = '1.8' 44 | } 45 | 46 | sourceSets { 47 | main.java.srcDirs += 'src/main/kotlin' 48 | } 49 | 50 | defaultConfig { 51 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 52 | applicationId "dev.abmgrt.shlink_app" 53 | // You can update the following values to match your application needs. 54 | // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. 55 | minSdkVersion flutter.minSdkVersion //flutter.minSdkVersion 56 | targetSdkVersion flutter.targetSdkVersion 57 | versionCode flutterVersionCode.toInteger() 58 | versionName flutterVersionName 59 | 60 | multiDexEnabled true 61 | } 62 | 63 | signingConfigs { 64 | release { 65 | keyAlias keystoreProperties['keyAlias'] 66 | keyPassword keystoreProperties['keyPassword'] 67 | storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null 68 | storePassword keystoreProperties['storePassword'] 69 | } 70 | } 71 | buildTypes { 72 | release { 73 | signingConfig signingConfigs.release 74 | } 75 | } 76 | } 77 | 78 | flutter { 79 | source '../..' 80 | } 81 | 82 | dependencies { 83 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 84 | implementation 'androidx.multidex:multidex:2.0.1' 85 | } 86 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 17 | 25 | 29 | 33 | 34 | 35 | 36 | 37 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 47 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java: -------------------------------------------------------------------------------- 1 | // Generated file. 2 | // 3 | // If you wish to remove Flutter's multidex support, delete this entire file. 4 | // 5 | // Modifications to this file should be done in a copy under a different name 6 | // as this file may be regenerated. 7 | 8 | package io.flutter.app; 9 | 10 | import android.app.Application; 11 | import android.content.Context; 12 | import androidx.annotation.CallSuper; 13 | import androidx.multidex.MultiDex; 14 | 15 | /** 16 | * Extension of {@link android.app.Application}, adding multidex support. 17 | */ 18 | public class FlutterMultiDexApplication extends Application { 19 | @Override 20 | @CallSuper 21 | protected void attachBaseContext(Context base) { 22 | super.attachBaseContext(base); 23 | MultiDex.install(this); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/dev/abmgrt/shlink_app/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package dev.abmgrt.shlink_app 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() { 6 | } 7 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-hdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-mdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /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-xhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #ffffff 4 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext.kotlin_version = '1.7.10' 3 | repositories { 4 | google() 5 | mavenCentral() 6 | } 7 | 8 | dependencies { 9 | classpath 'com.android.tools.build:gradle:7.3.0' 10 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 11 | } 12 | } 13 | 14 | allprojects { 15 | repositories { 16 | google() 17 | mavenCentral() 18 | } 19 | } 20 | 21 | rootProject.buildDir = '../build' 22 | subprojects { 23 | project.buildDir = "${rootProject.buildDir}/${project.name}" 24 | } 25 | subprojects { 26 | project.evaluationDependsOn(':app') 27 | } 28 | 29 | tasks.register("clean", Delete) { 30 | delete rootProject.buildDir 31 | } 32 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | 3 | def localPropertiesFile = new File(rootProject.projectDir, "local.properties") 4 | def properties = new Properties() 5 | 6 | assert localPropertiesFile.exists() 7 | localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } 8 | 9 | def flutterSdkPath = properties.getProperty("flutter.sdk") 10 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 11 | apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" 12 | -------------------------------------------------------------------------------- /assets/icon/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/assets/icon/icon.png -------------------------------------------------------------------------------- /assets/icon/icon_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/assets/icon/icon_background.png -------------------------------------------------------------------------------- /assets/icon/icon_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/assets/icon/icon_foreground.png -------------------------------------------------------------------------------- /assets/icon/icon_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainloreley/shlink-manager/d11d0cfe5c589ef0f395f3ee74dc42a187c1955e/assets/icon/icon_monochrome.png -------------------------------------------------------------------------------- /lib/API/Classes/ShlinkStats/shlink_stats.dart: -------------------------------------------------------------------------------- 1 | import 'package:shlink_app/API/Classes/ShortURL/visits_summary.dart'; 2 | 3 | /// Includes data about the statistics of a Shlink instance 4 | class ShlinkStats { 5 | /// Data about non-orphan visits 6 | VisitsSummary nonOrphanVisits; 7 | 8 | /// Data about orphan visits (without any valid slug assigned) 9 | VisitsSummary orphanVisits; 10 | 11 | /// Total count of all short URLs 12 | int shortUrlsCount; 13 | 14 | /// Total count all all tags 15 | int tagsCount; 16 | 17 | ShlinkStats(this.nonOrphanVisits, this.orphanVisits, this.shortUrlsCount, 18 | this.tagsCount); 19 | } 20 | -------------------------------------------------------------------------------- /lib/API/Classes/ShlinkStats/shlink_stats_visits.dart: -------------------------------------------------------------------------------- 1 | /// Visitor data 2 | class ShlinkStatsVisits { 3 | /// Count of URL visits 4 | int total; 5 | 6 | /// Count of URL visits from humans 7 | int nonBots; 8 | 9 | /// Count of URL visits from bots/crawlers 10 | int bots; 11 | 12 | ShlinkStatsVisits(this.total, this.nonBots, this.bots); 13 | 14 | /// Converts the JSON data from the API to an instance of [ShlinkStatsVisits] 15 | ShlinkStatsVisits.fromJson(Map json) 16 | : total = json["total"], 17 | nonBots = json["nonBots"], 18 | bots = json["bots"]; 19 | } 20 | -------------------------------------------------------------------------------- /lib/API/Classes/ShortURL/RedirectRule/condition_device_type.dart: -------------------------------------------------------------------------------- 1 | enum ConditionDeviceType { 2 | IOS, 3 | ANDROID, 4 | DESKTOP; 5 | 6 | static ConditionDeviceType fromApi(String api) { 7 | switch (api) { 8 | case "ios": 9 | return ConditionDeviceType.IOS; 10 | case "android": 11 | return ConditionDeviceType.ANDROID; 12 | case "desktop": 13 | return ConditionDeviceType.DESKTOP; 14 | } 15 | throw ArgumentError("Invalid type $api"); 16 | } 17 | } 18 | 19 | extension ConditionTypeExtension on ConditionDeviceType { 20 | String get api { 21 | switch (this) { 22 | case ConditionDeviceType.IOS: 23 | return "ios"; 24 | case ConditionDeviceType.ANDROID: 25 | return "android"; 26 | case ConditionDeviceType.DESKTOP: 27 | return "desktop"; 28 | } 29 | } 30 | 31 | String get humanReadable { 32 | switch (this) { 33 | case ConditionDeviceType.IOS: 34 | return "iOS"; 35 | case ConditionDeviceType.ANDROID: 36 | return "Android"; 37 | case ConditionDeviceType.DESKTOP: 38 | return "Desktop"; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/API/Classes/ShortURL/RedirectRule/redirect_rule.dart: -------------------------------------------------------------------------------- 1 | import 'package:shlink_app/API/Classes/ShortURL/RedirectRule/redirect_rule_condition.dart'; 2 | 3 | /// Single redirect rule for a short URL. 4 | class RedirectRule { 5 | String longUrl; 6 | int priority; 7 | List conditions; 8 | 9 | RedirectRule(this.longUrl, this.priority, this.conditions); 10 | 11 | RedirectRule.fromJson(Map json) 12 | : longUrl = json["longUrl"], 13 | priority = json["priority"], 14 | conditions = (json["conditions"] as List) 15 | .map((e) => RedirectRuleCondition.fromJson(e)) 16 | .toList(); 17 | 18 | Map toJson() { 19 | return { 20 | "longUrl": longUrl, 21 | "conditions": conditions.map((e) => e.toJson()).toList() 22 | }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/API/Classes/ShortURL/RedirectRule/redirect_rule_condition.dart: -------------------------------------------------------------------------------- 1 | import 'package:shlink_app/API/Classes/ShortURL/RedirectRule/redirect_rule_condition_type.dart'; 2 | 3 | class RedirectRuleCondition { 4 | RedirectRuleConditionType type; 5 | String matchValue; 6 | String? matchKey; 7 | 8 | RedirectRuleCondition(String type, this.matchValue, this.matchKey) 9 | : type = RedirectRuleConditionType.fromApi(type); 10 | 11 | RedirectRuleCondition.fromJson(Map json) 12 | : type = RedirectRuleConditionType.fromApi(json["type"]), 13 | matchValue = json["matchValue"], 14 | matchKey = json["matchKey"]; 15 | 16 | Map toJson() { 17 | return {"type": type.api, "matchValue": matchValue, "matchKey": matchKey}; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /lib/API/Classes/ShortURL/RedirectRule/redirect_rule_condition_type.dart: -------------------------------------------------------------------------------- 1 | enum RedirectRuleConditionType { 2 | DEVICE, 3 | LANGUAGE, 4 | QUERY_PARAM; 5 | 6 | static RedirectRuleConditionType fromApi(String api) { 7 | switch (api) { 8 | case "device": 9 | return RedirectRuleConditionType.DEVICE; 10 | case "language": 11 | return RedirectRuleConditionType.LANGUAGE; 12 | case "query-param": 13 | return RedirectRuleConditionType.QUERY_PARAM; 14 | } 15 | throw ArgumentError("Invalid type $api"); 16 | } 17 | } 18 | 19 | extension ConditionTypeExtension on RedirectRuleConditionType { 20 | String get api { 21 | switch (this) { 22 | case RedirectRuleConditionType.DEVICE: 23 | return "device"; 24 | case RedirectRuleConditionType.LANGUAGE: 25 | return "language"; 26 | case RedirectRuleConditionType.QUERY_PARAM: 27 | return "query-param"; 28 | } 29 | } 30 | 31 | String get humanReadable { 32 | switch (this) { 33 | case RedirectRuleConditionType.DEVICE: 34 | return "Device"; 35 | case RedirectRuleConditionType.LANGUAGE: 36 | return "Language"; 37 | case RedirectRuleConditionType.QUERY_PARAM: 38 | return "Query parameter"; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/API/Classes/ShortURL/short_url.dart: -------------------------------------------------------------------------------- 1 | import 'package:shlink_app/API/Classes/ShortURL/short_url_meta.dart'; 2 | import 'package:shlink_app/API/Classes/ShortURL/visits_summary.dart'; 3 | 4 | import 'RedirectRule/redirect_rule.dart'; 5 | 6 | /// Data about a short URL 7 | class ShortURL { 8 | /// Slug of the short URL used in the URL 9 | String shortCode; 10 | 11 | /// Entire short URL 12 | String shortUrl; 13 | 14 | /// Long URL where the user gets redirected to 15 | String longUrl; 16 | 17 | /// Creation date of the short URL 18 | DateTime dateCreated; 19 | 20 | /// Visitor data 21 | VisitsSummary visitsSummary; 22 | 23 | /// List of tags assigned to this short URL 24 | List tags; 25 | 26 | /// Metadata 27 | ShortURLMeta meta; 28 | 29 | /// Associated domain 30 | String? domain; 31 | 32 | /// Optional title 33 | String? title; 34 | 35 | /// Whether the short URL is crawlable by a web crawler 36 | bool crawlable; 37 | 38 | List? redirectRules; 39 | 40 | ShortURL( 41 | this.shortCode, 42 | this.shortUrl, 43 | this.longUrl, 44 | this.dateCreated, 45 | this.visitsSummary, 46 | this.tags, 47 | this.meta, 48 | this.domain, 49 | this.title, 50 | this.crawlable); 51 | 52 | /// Converts the JSON data from the API to an instance of [ShortURL] 53 | ShortURL.fromJson(Map json) 54 | : shortCode = json["shortCode"], 55 | shortUrl = json["shortUrl"], 56 | longUrl = json["longUrl"], 57 | dateCreated = DateTime.parse(json["dateCreated"]), 58 | visitsSummary = VisitsSummary.fromJson(json["visitsSummary"]), 59 | tags = 60 | (json["tags"] as List).map((e) => e.toString()).toList(), 61 | meta = ShortURLMeta.fromJson(json["meta"]), 62 | domain = json["domain"], 63 | title = json["title"], 64 | crawlable = json["crawlable"]; 65 | 66 | /// Returns an empty class of [ShortURL] 67 | ShortURL.empty() 68 | : shortCode = "", 69 | shortUrl = "", 70 | longUrl = "", 71 | dateCreated = DateTime.now(), 72 | visitsSummary = VisitsSummary(0, 0, 0), 73 | tags = [], 74 | meta = ShortURLMeta(DateTime.now(), DateTime.now(), 0), 75 | domain = "", 76 | title = "", 77 | crawlable = false; 78 | } 79 | -------------------------------------------------------------------------------- /lib/API/Classes/ShortURL/short_url_meta.dart: -------------------------------------------------------------------------------- 1 | /// Metadata for a short URL 2 | class ShortURLMeta { 3 | /// The date since when this short URL has been valid 4 | DateTime? validSince; 5 | 6 | /// The data when this short URL expires 7 | DateTime? validUntil; 8 | 9 | /// Amount of maximum visits allowed to this short URL 10 | int? maxVisits; 11 | 12 | ShortURLMeta(this.validSince, this.validUntil, this.maxVisits); 13 | 14 | /// Converts JSON data from the API to an instance of [ShortURLMeta] 15 | ShortURLMeta.fromJson(Map json) 16 | : validSince = json["validSince"] != null 17 | ? DateTime.parse(json["validSince"]) 18 | : null, 19 | validUntil = json["validUntil"] != null 20 | ? DateTime.parse(json["validUntil"]) 21 | : null, 22 | maxVisits = json["maxVisits"]; 23 | } 24 | -------------------------------------------------------------------------------- /lib/API/Classes/ShortURL/visits_summary.dart: -------------------------------------------------------------------------------- 1 | /// Visitor data 2 | class VisitsSummary { 3 | /// Count of total visits 4 | int total; 5 | 6 | /// Count of visits from humans 7 | int nonBots; 8 | 9 | /// Count of visits from bots/crawlers 10 | int bots; 11 | 12 | VisitsSummary(this.total, this.nonBots, this.bots); 13 | 14 | /// Converts JSON data from the API to an instance of [VisitsSummary] 15 | VisitsSummary.fromJson(Map json) 16 | : total = json["total"] as int, 17 | nonBots = json["nonBots"] as int, 18 | bots = json["bots"] as int; 19 | } 20 | -------------------------------------------------------------------------------- /lib/API/Classes/ShortURLSubmission/short_url_submission.dart: -------------------------------------------------------------------------------- 1 | /// Data for a short URL which can be submitted to the server 2 | class ShortURLSubmission { 3 | /// Long URL to redirect to 4 | String longUrl; 5 | 6 | /// Date since when this short URL is valid in ISO8601 format 7 | String? validSince; 8 | 9 | /// Date until when this short URL is valid in ISO8601 format 10 | String? validUntil; 11 | 12 | /// Amount of maximum visits allowed to this short URLs 13 | int? maxVisits; 14 | 15 | /// List of tags assigned to this short URL 16 | List tags; 17 | 18 | /// Title of the page 19 | String? title; 20 | 21 | /// Whether the short URL is crawlable by web crawlers 22 | bool crawlable; 23 | 24 | /// Whether to forward query parameters 25 | bool forwardQuery; 26 | 27 | /// Custom slug (if not provided a random one will be generated) 28 | String? customSlug; 29 | 30 | /// Whether to use an existing short URL if the slug matches 31 | bool findIfExists; 32 | 33 | /// Domain to use 34 | String? domain; 35 | 36 | /// Length of the slug if a custom one is not provided 37 | int? shortCodeLength; 38 | 39 | ShortURLSubmission( 40 | {required this.longUrl, 41 | this.validSince, 42 | this.validUntil, 43 | this.maxVisits, 44 | required this.tags, 45 | this.title, 46 | required this.crawlable, 47 | required this.forwardQuery, 48 | this.customSlug, 49 | required this.findIfExists, 50 | this.domain, 51 | this.shortCodeLength}); 52 | 53 | /// Converts class data to a JSON object 54 | Map toJson() { 55 | return { 56 | "longUrl": longUrl, 57 | "validSince": validSince, 58 | "validUntil": validUntil, 59 | "maxVisits": maxVisits, 60 | "tags": tags, 61 | "title": title, 62 | "crawlable": crawlable, 63 | "forwardQuery": forwardQuery, 64 | "customSlug": customSlug, 65 | "findIfExists": findIfExists, 66 | "domain": domain, 67 | "shortCodeLength": shortCodeLength 68 | }; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lib/API/Classes/Tag/tag_with_stats.dart: -------------------------------------------------------------------------------- 1 | import 'package:shlink_app/API/Classes/ShortURL/visits_summary.dart'; 2 | 3 | /// Tag with stats data 4 | class TagWithStats { 5 | /// Tag name 6 | String tag; 7 | 8 | /// Amount of short URLs using this tag 9 | int shortUrlsCount; 10 | 11 | /// visits summary for tag 12 | VisitsSummary visitsSummary; 13 | 14 | TagWithStats(this.tag, this.shortUrlsCount, this.visitsSummary); 15 | 16 | TagWithStats.fromJson(Map json) 17 | : tag = json["tag"] as String, 18 | shortUrlsCount = json["shortUrlsCount"] as int, 19 | visitsSummary = VisitsSummary.fromJson(json["visitsSummary"]); 20 | } -------------------------------------------------------------------------------- /lib/API/Methods/connect.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'package:dartz/dartz.dart'; 4 | import 'package:http/http.dart' as http; 5 | import '../server_manager.dart'; 6 | 7 | /// Tries to connect to the Shlink server 8 | FutureOr> apiConnect( 9 | String? apiKey, String? serverUrl, String apiVersion) async { 10 | try { 11 | final response = await http 12 | .get(Uri.parse("$serverUrl/rest/v$apiVersion/short-urls"), headers: { 13 | "X-Api-Key": apiKey ?? "", 14 | }); 15 | if (response.statusCode == 200) { 16 | return left(""); 17 | } else { 18 | try { 19 | var jsonBody = jsonDecode(response.body); 20 | return right(ApiFailure( 21 | type: jsonBody["type"], 22 | detail: jsonBody["detail"], 23 | title: jsonBody["title"], 24 | status: jsonBody["status"])); 25 | } catch (resErr) { 26 | return right(RequestFailure(response.statusCode, resErr.toString())); 27 | } 28 | } 29 | } catch (reqErr) { 30 | return right(RequestFailure(0, reqErr.toString())); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/API/Methods/delete_short_url.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'package:dartz/dartz.dart'; 4 | import 'package:http/http.dart' as http; 5 | import '../server_manager.dart'; 6 | 7 | /// Deletes a short URL from the server 8 | FutureOr> apiDeleteShortUrl(String shortCode, 9 | String? apiKey, String? serverUrl, String apiVersion) async { 10 | try { 11 | final response = await http.delete( 12 | Uri.parse("$serverUrl/rest/v$apiVersion/short-urls/$shortCode"), 13 | headers: { 14 | "X-Api-Key": apiKey ?? "", 15 | }); 16 | if (response.statusCode == 204) { 17 | // get returned short url 18 | return left(""); 19 | } else { 20 | try { 21 | var jsonBody = jsonDecode(response.body); 22 | return right(ApiFailure( 23 | type: jsonBody["type"], 24 | detail: jsonBody["detail"], 25 | title: jsonBody["title"], 26 | status: jsonBody["status"])); 27 | } catch (resErr) { 28 | return right(RequestFailure(response.statusCode, resErr.toString())); 29 | } 30 | } 31 | } catch (reqErr) { 32 | return right(RequestFailure(0, reqErr.toString())); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/API/Methods/get_recent_short_urls.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'package:dartz/dartz.dart'; 4 | import 'package:http/http.dart' as http; 5 | import 'package:shlink_app/API/Classes/ShortURL/short_url.dart'; 6 | import '../server_manager.dart'; 7 | 8 | /// Gets recently created short URLs from the server 9 | FutureOr, Failure>> apiGetRecentShortUrls( 10 | String? apiKey, String? serverUrl, String apiVersion) async { 11 | try { 12 | final response = await http.get( 13 | Uri.parse( 14 | "$serverUrl/rest/v$apiVersion/short-urls?itemsPerPage=5&orderBy=dateCreated-DESC"), 15 | headers: { 16 | "X-Api-Key": apiKey ?? "", 17 | }); 18 | if (response.statusCode == 200) { 19 | var jsonResponse = jsonDecode(response.body); 20 | List shortURLs = 21 | (jsonResponse["shortUrls"]["data"] as List).map((e) { 22 | return ShortURL.fromJson(e); 23 | }).toList(); 24 | return left(shortURLs); 25 | } else { 26 | try { 27 | var jsonBody = jsonDecode(response.body); 28 | return right(ApiFailure( 29 | type: jsonBody["type"], 30 | detail: jsonBody["detail"], 31 | title: jsonBody["title"], 32 | status: jsonBody["status"])); 33 | } catch (resErr) { 34 | return right(RequestFailure(response.statusCode, resErr.toString())); 35 | } 36 | } 37 | } catch (reqErr) { 38 | return right(RequestFailure(0, reqErr.toString())); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/API/Methods/get_redirect_rules.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'package:dartz/dartz.dart'; 4 | import 'package:http/http.dart' as http; 5 | import 'package:shlink_app/API/Classes/ShortURL/RedirectRule/redirect_rule.dart'; 6 | import '../server_manager.dart'; 7 | 8 | /// Gets redirect rules for a given short URL (code). 9 | FutureOr, Failure>> apiGetRedirectRules( 10 | String shortCode, 11 | String? apiKey, 12 | String? serverUrl, 13 | String apiVersion) async { 14 | try { 15 | final response = await http.get( 16 | Uri.parse( 17 | "$serverUrl/rest/v$apiVersion/short-urls/$shortCode/redirect-rules"), 18 | headers: { 19 | "X-Api-Key": apiKey ?? "", 20 | }); 21 | if (response.statusCode == 200) { 22 | // get returned redirect rules 23 | var jsonBody = jsonDecode(response.body) as Map; 24 | 25 | // convert json array to object array 26 | List redirectRules = 27 | (jsonBody["redirectRules"] as List) 28 | .map((e) => RedirectRule.fromJson(e)) 29 | .toList(); 30 | 31 | return left(redirectRules); 32 | } else { 33 | try { 34 | var jsonBody = jsonDecode(response.body); 35 | return right(ApiFailure( 36 | type: jsonBody["type"], 37 | detail: jsonBody["detail"], 38 | title: jsonBody["title"], 39 | status: jsonBody["status"], 40 | invalidElements: jsonBody["invalidElements"])); 41 | } catch (resErr) { 42 | return right(RequestFailure(response.statusCode, resErr.toString())); 43 | } 44 | } 45 | } catch (reqErr) { 46 | return right(RequestFailure(0, reqErr.toString())); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /lib/API/Methods/get_server_health.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'package:dartz/dartz.dart'; 4 | import 'package:http/http.dart' as http; 5 | import '../server_manager.dart'; 6 | 7 | /// Gets the status of the server and health information 8 | FutureOr> apiGetServerHealth( 9 | String? apiKey, String? serverUrl, String apiVersion) async { 10 | try { 11 | final response = await http 12 | .get(Uri.parse("$serverUrl/rest/v$apiVersion/health"), headers: { 13 | "X-Api-Key": apiKey ?? "", 14 | }); 15 | if (response.statusCode == 200) { 16 | var jsonData = jsonDecode(response.body); 17 | return left(ServerHealthResponse( 18 | status: jsonData["status"], version: jsonData["version"])); 19 | } else { 20 | try { 21 | var jsonBody = jsonDecode(response.body); 22 | return right(ApiFailure( 23 | type: jsonBody["type"], 24 | detail: jsonBody["detail"], 25 | title: jsonBody["title"], 26 | status: jsonBody["status"])); 27 | } catch (resErr) { 28 | return right(RequestFailure(response.statusCode, resErr.toString())); 29 | } 30 | } 31 | } catch (reqErr) { 32 | return right(RequestFailure(0, reqErr.toString())); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/API/Methods/get_shlink_stats.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'package:dartz/dartz.dart'; 4 | import 'package:http/http.dart' as http; 5 | import 'package:shlink_app/API/Classes/ShortURL/visits_summary.dart'; 6 | import '../Classes/ShlinkStats/shlink_stats.dart'; 7 | import '../server_manager.dart'; 8 | 9 | /// Gets statistics about the Shlink server 10 | FutureOr> apiGetShlinkStats( 11 | String? apiKey, String? serverUrl, String apiVersion) async { 12 | VisitsSummary? nonOrphanVisits; 13 | VisitsSummary? orphanVisits; 14 | int shortUrlsCount = 0; 15 | int tagsCount = 0; 16 | Failure? failure; 17 | 18 | var visitStatsResponse = await _getVisitStats(apiKey, serverUrl, apiVersion); 19 | visitStatsResponse.fold((l) { 20 | nonOrphanVisits = l.nonOrphanVisits; 21 | orphanVisits = l.orphanVisits; 22 | }, (r) { 23 | failure = r; 24 | return right(r); 25 | }); 26 | 27 | var shortUrlsCountResponse = 28 | await _getShortUrlsCount(apiKey, serverUrl, apiVersion); 29 | shortUrlsCountResponse.fold((l) { 30 | shortUrlsCount = l; 31 | }, (r) { 32 | failure = r; 33 | return right(r); 34 | }); 35 | 36 | var tagsCountResponse = await _getTagsCount(apiKey, serverUrl, apiVersion); 37 | tagsCountResponse.fold((l) { 38 | tagsCount = l; 39 | }, (r) { 40 | failure = r; 41 | return right(r); 42 | }); 43 | 44 | while (failure == null && (orphanVisits == null)) { 45 | await Future.delayed(const Duration(milliseconds: 100)); 46 | } 47 | 48 | if (failure != null) { 49 | return right(failure!); 50 | } 51 | return left( 52 | ShlinkStats(nonOrphanVisits!, orphanVisits!, shortUrlsCount, tagsCount)); 53 | } 54 | 55 | class _ShlinkVisitStats { 56 | VisitsSummary nonOrphanVisits; 57 | VisitsSummary orphanVisits; 58 | 59 | _ShlinkVisitStats(this.nonOrphanVisits, this.orphanVisits); 60 | } 61 | 62 | /// Gets visitor statistics about the entire server 63 | FutureOr> _getVisitStats( 64 | String? apiKey, String? serverUrl, String apiVersion) async { 65 | try { 66 | final response = await http 67 | .get(Uri.parse("$serverUrl/rest/v$apiVersion/visits"), headers: { 68 | "X-Api-Key": apiKey ?? "", 69 | }); 70 | if (response.statusCode == 200) { 71 | var jsonResponse = jsonDecode(response.body); 72 | var nonOrphanVisits = 73 | VisitsSummary.fromJson(jsonResponse["visits"]["nonOrphanVisits"]); 74 | var orphanVisits = 75 | VisitsSummary.fromJson(jsonResponse["visits"]["orphanVisits"]); 76 | return left(_ShlinkVisitStats(nonOrphanVisits, orphanVisits)); 77 | } else { 78 | try { 79 | var jsonBody = jsonDecode(response.body); 80 | return right(ApiFailure( 81 | type: jsonBody["type"], 82 | detail: jsonBody["detail"], 83 | title: jsonBody["title"], 84 | status: jsonBody["status"])); 85 | } catch (resErr) { 86 | return right(RequestFailure(response.statusCode, resErr.toString())); 87 | } 88 | } 89 | } catch (reqErr) { 90 | return right(RequestFailure(0, reqErr.toString())); 91 | } 92 | } 93 | 94 | /// Gets amount of short URLs 95 | FutureOr> _getShortUrlsCount( 96 | String? apiKey, String? serverUrl, String apiVersion) async { 97 | try { 98 | final response = await http 99 | .get(Uri.parse("$serverUrl/rest/v$apiVersion/short-urls"), headers: { 100 | "X-Api-Key": apiKey ?? "", 101 | }); 102 | if (response.statusCode == 200) { 103 | var jsonResponse = jsonDecode(response.body); 104 | return left(jsonResponse["shortUrls"]["pagination"]["totalItems"]); 105 | } else { 106 | try { 107 | var jsonBody = jsonDecode(response.body); 108 | return right(ApiFailure( 109 | type: jsonBody["type"], 110 | detail: jsonBody["detail"], 111 | title: jsonBody["title"], 112 | status: jsonBody["status"])); 113 | } catch (resErr) { 114 | return right(RequestFailure(response.statusCode, resErr.toString())); 115 | } 116 | } 117 | } catch (reqErr) { 118 | return right(RequestFailure(0, reqErr.toString())); 119 | } 120 | } 121 | 122 | /// Gets amount of tags 123 | FutureOr> _getTagsCount( 124 | String? apiKey, String? serverUrl, String apiVersion) async { 125 | try { 126 | final response = await http 127 | .get(Uri.parse("$serverUrl/rest/v$apiVersion/tags"), headers: { 128 | "X-Api-Key": apiKey ?? "", 129 | }); 130 | if (response.statusCode == 200) { 131 | var jsonResponse = jsonDecode(response.body); 132 | return left(jsonResponse["tags"]["pagination"]["totalItems"]); 133 | } else { 134 | try { 135 | var jsonBody = jsonDecode(response.body); 136 | return right(ApiFailure( 137 | type: jsonBody["type"], 138 | detail: jsonBody["detail"], 139 | title: jsonBody["title"], 140 | status: jsonBody["status"])); 141 | } catch (resErr) { 142 | return right(RequestFailure(response.statusCode, resErr.toString())); 143 | } 144 | } 145 | } catch (reqErr) { 146 | return right(RequestFailure(0, reqErr.toString())); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /lib/API/Methods/get_short_urls.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'package:dartz/dartz.dart'; 4 | import 'package:http/http.dart' as http; 5 | import 'package:shlink_app/API/Classes/ShortURL/short_url.dart'; 6 | import '../server_manager.dart'; 7 | 8 | /// Gets all short URLs 9 | FutureOr, Failure>> apiGetShortUrls( 10 | String? apiKey, String? serverUrl, String apiVersion) async { 11 | var currentPage = 1; 12 | var maxPages = 2; 13 | List allUrls = []; 14 | 15 | Failure? error; 16 | 17 | while (currentPage <= maxPages) { 18 | final response = 19 | await _getShortUrlPage(currentPage, apiKey, serverUrl, apiVersion); 20 | response.fold((l) { 21 | allUrls.addAll(l.urls); 22 | maxPages = l.totalPages; 23 | currentPage++; 24 | }, (r) { 25 | maxPages = 0; 26 | error = r; 27 | }); 28 | } 29 | if (error == null) { 30 | return left(allUrls); 31 | } else { 32 | return right(error!); 33 | } 34 | } 35 | 36 | /// Gets all short URLs from a specific page 37 | FutureOr> _getShortUrlPage( 38 | int page, String? apiKey, String? serverUrl, String apiVersion) async { 39 | try { 40 | final response = await http.get( 41 | Uri.parse("$serverUrl/rest/v$apiVersion/short-urls?page=$page"), 42 | headers: { 43 | "X-Api-Key": apiKey ?? "", 44 | }); 45 | if (response.statusCode == 200) { 46 | var jsonResponse = jsonDecode(response.body); 47 | var pagesCount = 48 | jsonResponse["shortUrls"]["pagination"]["pagesCount"] as int; 49 | List shortURLs = 50 | (jsonResponse["shortUrls"]["data"] as List).map((e) { 51 | return ShortURL.fromJson(e); 52 | }).toList(); 53 | return left(ShortURLPageResponse(shortURLs, pagesCount)); 54 | } else { 55 | try { 56 | var jsonBody = jsonDecode(response.body); 57 | return right(ApiFailure( 58 | type: jsonBody["type"], 59 | detail: jsonBody["detail"], 60 | title: jsonBody["title"], 61 | status: jsonBody["status"])); 62 | } catch (resErr) { 63 | return right(RequestFailure(response.statusCode, resErr.toString())); 64 | } 65 | } 66 | } catch (reqErr) { 67 | return right(RequestFailure(0, reqErr.toString())); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /lib/API/Methods/get_tags_with_stats.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'package:dartz/dartz.dart'; 4 | import 'package:http/http.dart' as http; 5 | import 'package:shlink_app/API/Classes/Tag/tag_with_stats.dart'; 6 | import '../server_manager.dart'; 7 | 8 | /// Gets all tags 9 | FutureOr, Failure>> apiGetTagsWithStats( 10 | String? apiKey, String? serverUrl, String apiVersion) async { 11 | var currentPage = 1; 12 | var maxPages = 2; 13 | List allTags = []; 14 | 15 | Failure? error; 16 | 17 | while (currentPage <= maxPages) { 18 | final response = 19 | await _getTagsWithStatsPage(currentPage, apiKey, serverUrl, apiVersion); 20 | response.fold((l) { 21 | allTags.addAll(l.tags); 22 | maxPages = l.totalPages; 23 | currentPage++; 24 | }, (r) { 25 | maxPages = 0; 26 | error = r; 27 | }); 28 | } 29 | if (error == null) { 30 | return left(allTags); 31 | } else { 32 | return right(error!); 33 | } 34 | } 35 | 36 | /// Gets all tags from a specific page 37 | FutureOr> _getTagsWithStatsPage( 38 | int page, String? apiKey, String? serverUrl, String apiVersion) async { 39 | try { 40 | final response = await http.get( 41 | Uri.parse("$serverUrl/rest/v$apiVersion/tags/stats?page=$page"), 42 | headers: { 43 | "X-Api-Key": apiKey ?? "", 44 | }); 45 | if (response.statusCode == 200) { 46 | var jsonResponse = jsonDecode(response.body); 47 | var pagesCount = jsonResponse["tags"]["pagination"]["pagesCount"] as int; 48 | List tags = 49 | (jsonResponse["tags"]["data"] as List).map((e) { 50 | return TagWithStats.fromJson(e); 51 | }).toList(); 52 | return left(TagsWithStatsPageResponse(tags, pagesCount)); 53 | } else { 54 | try { 55 | var jsonBody = jsonDecode(response.body); 56 | return right(ApiFailure( 57 | type: jsonBody["type"], 58 | detail: jsonBody["detail"], 59 | title: jsonBody["title"], 60 | status: jsonBody["status"])); 61 | } catch (resErr) { 62 | return right(RequestFailure(response.statusCode, resErr.toString())); 63 | } 64 | } 65 | } catch (reqErr) { 66 | return right(RequestFailure(0, reqErr.toString())); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /lib/API/Methods/set_redirect_rules.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'package:dartz/dartz.dart'; 4 | import 'package:http/http.dart' as http; 5 | import 'package:shlink_app/API/Classes/ShortURL/RedirectRule/redirect_rule.dart'; 6 | import '../server_manager.dart'; 7 | 8 | /// Saves the redirect rules for a given short URL (code). 9 | FutureOr> apiSetRedirectRules( 10 | String shortCode, 11 | List redirectRules, 12 | String? apiKey, 13 | String? serverUrl, 14 | String apiVersion) async { 15 | try { 16 | Map body = {}; 17 | List> redirectRulesJson = 18 | redirectRules.map((e) => e.toJson()).toList(); 19 | body["redirectRules"] = redirectRulesJson; 20 | final response = await http.post( 21 | Uri.parse( 22 | "$serverUrl/rest/v$apiVersion/short-urls/$shortCode/redirect-rules"), 23 | headers: { 24 | "X-Api-Key": apiKey ?? "", 25 | }, 26 | body: jsonEncode(body)); 27 | if (response.statusCode == 200) { 28 | return left(true); 29 | } else { 30 | try { 31 | var jsonBody = jsonDecode(response.body); 32 | return right(ApiFailure( 33 | type: jsonBody["type"], 34 | detail: jsonBody["detail"], 35 | title: jsonBody["title"], 36 | status: jsonBody["status"], 37 | invalidElements: jsonBody["invalidElements"])); 38 | } catch (resErr) { 39 | return right(RequestFailure(response.statusCode, resErr.toString())); 40 | } 41 | } 42 | } catch (reqErr) { 43 | return right(RequestFailure(0, reqErr.toString())); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/API/Methods/submit_short_url.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'package:dartz/dartz.dart'; 4 | import 'package:http/http.dart' as http; 5 | import 'package:shlink_app/API/Classes/ShortURL/short_url.dart'; 6 | import 'package:shlink_app/API/Classes/ShortURLSubmission/short_url_submission.dart'; 7 | import '../server_manager.dart'; 8 | 9 | /// Submits a short URL to a server for it to be added 10 | FutureOr> apiSubmitShortUrl( 11 | ShortURLSubmission shortUrl, 12 | String? apiKey, 13 | String? serverUrl, 14 | String apiVersion) async { 15 | try { 16 | final response = 17 | await http.post(Uri.parse("$serverUrl/rest/v$apiVersion/short-urls"), 18 | headers: { 19 | "X-Api-Key": apiKey ?? "", 20 | }, 21 | body: jsonEncode(shortUrl.toJson())); 22 | if (response.statusCode == 200) { 23 | // get returned short url 24 | var jsonBody = jsonDecode(response.body); 25 | return left(ShortURL.fromJson(jsonBody)); 26 | } else { 27 | try { 28 | var jsonBody = jsonDecode(response.body); 29 | return right(ApiFailure( 30 | type: jsonBody["type"], 31 | detail: jsonBody["detail"], 32 | title: jsonBody["title"], 33 | status: jsonBody["status"], 34 | invalidElements: jsonBody["invalidElements"])); 35 | } catch (resErr) { 36 | return right(RequestFailure(response.statusCode, resErr.toString())); 37 | } 38 | } 39 | } catch (reqErr) { 40 | return right(RequestFailure(0, reqErr.toString())); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /lib/API/Methods/update_short_url.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'package:dartz/dartz.dart'; 4 | import 'package:http/http.dart' as http; 5 | import 'package:shlink_app/API/Classes/ShortURL/short_url.dart'; 6 | import 'package:shlink_app/API/Classes/ShortURLSubmission/short_url_submission.dart'; 7 | import '../server_manager.dart'; 8 | 9 | /// Updates an existing short URL 10 | FutureOr> apiUpdateShortUrl( 11 | ShortURLSubmission shortUrl, 12 | String? apiKey, 13 | String? serverUrl, 14 | String apiVersion) async { 15 | String shortCode = shortUrl.customSlug ?? ""; 16 | if (shortCode == "") { 17 | return right(RequestFailure(0, "Missing short code")); 18 | } 19 | Map shortUrlData = shortUrl.toJson(); 20 | shortUrlData.remove("shortCode"); 21 | shortUrlData.remove("shortUrl"); 22 | try { 23 | final response = await http.patch( 24 | Uri.parse("$serverUrl/rest/v$apiVersion/short-urls/$shortCode"), 25 | headers: { 26 | "X-Api-Key": apiKey ?? "", 27 | }, 28 | body: jsonEncode(shortUrlData)); 29 | 30 | if (response.statusCode == 200) { 31 | // get returned short url 32 | var jsonBody = jsonDecode(response.body); 33 | return left(ShortURL.fromJson(jsonBody)); 34 | } else { 35 | try { 36 | var jsonBody = jsonDecode(response.body); 37 | return right(ApiFailure( 38 | type: jsonBody["type"], 39 | detail: jsonBody["detail"], 40 | title: jsonBody["title"], 41 | status: jsonBody["status"], 42 | invalidElements: jsonBody["invalidElements"])); 43 | } catch (resErr) { 44 | return right(RequestFailure(response.statusCode, resErr.toString())); 45 | } 46 | } 47 | } catch (reqErr) { 48 | return right(RequestFailure(0, reqErr.toString())); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/API/server_manager.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'package:dartz/dartz.dart'; 4 | import 'package:flutter_secure_storage/flutter_secure_storage.dart'; 5 | import 'package:shared_preferences/shared_preferences.dart'; 6 | import 'package:shlink_app/API/Classes/ShlinkStats/shlink_stats.dart'; 7 | import 'package:shlink_app/API/Classes/ShortURL/RedirectRule/redirect_rule.dart'; 8 | import 'package:shlink_app/API/Classes/ShortURL/short_url.dart'; 9 | import 'package:shlink_app/API/Classes/ShortURLSubmission/short_url_submission.dart'; 10 | import 'package:shlink_app/API/Classes/Tag/tag_with_stats.dart'; 11 | import 'package:shlink_app/API/Methods/connect.dart'; 12 | import 'package:shlink_app/API/Methods/get_recent_short_urls.dart'; 13 | import 'package:shlink_app/API/Methods/get_redirect_rules.dart'; 14 | import 'package:shlink_app/API/Methods/get_server_health.dart'; 15 | import 'package:shlink_app/API/Methods/get_shlink_stats.dart'; 16 | import 'package:shlink_app/API/Methods/get_short_urls.dart'; 17 | import 'package:shlink_app/API/Methods/get_tags_with_stats.dart'; 18 | import 'package:shlink_app/API/Methods/set_redirect_rules.dart'; 19 | import 'package:shlink_app/API/Methods/update_short_url.dart'; 20 | 21 | import 'Methods/delete_short_url.dart'; 22 | import 'Methods/submit_short_url.dart'; 23 | 24 | class ServerManager { 25 | /// The URL of the Shlink server 26 | String? serverUrl; 27 | 28 | /// The API key to access the server 29 | String? apiKey; 30 | 31 | /// Current Shlink API Version used by the app 32 | static String apiVersion = "3"; 33 | 34 | String getServerUrl() { 35 | return serverUrl ?? ""; 36 | } 37 | 38 | String getApiVersion() { 39 | return apiVersion; 40 | } 41 | 42 | /// Checks whether the user provided information about the server 43 | /// (url and apikey) 44 | Future checkLogin() async { 45 | await _loadCredentials(); 46 | return (serverUrl != null); 47 | } 48 | 49 | /// Logs out the user and removes data about the Shlink server 50 | Future logOut(String url) async { 51 | const storage = FlutterSecureStorage(); 52 | final prefs = await SharedPreferences.getInstance(); 53 | 54 | String? serverMapSerialized = await storage.read(key: "server_map"); 55 | 56 | if (serverMapSerialized != null) { 57 | Map serverMap = 58 | Map.castFrom(jsonDecode(serverMapSerialized)); 59 | serverMap.remove(url); 60 | if (serverMap.isEmpty) { 61 | storage.delete(key: "server_map"); 62 | } else { 63 | storage.write(key: "server_map", value: jsonEncode(serverMap)); 64 | } 65 | if (serverUrl == url) { 66 | serverUrl = null; 67 | apiKey = null; 68 | prefs.remove("lastusedserver"); 69 | } 70 | } 71 | } 72 | 73 | /// Returns all servers saved in the app 74 | Future> getAvailableServers() async { 75 | const storage = FlutterSecureStorage(); 76 | String? serverMapSerialized = await storage.read(key: "server_map"); 77 | 78 | if (serverMapSerialized != null) { 79 | Map serverMap = 80 | Map.castFrom(jsonDecode(serverMapSerialized)); 81 | return serverMap.keys.toList(); 82 | } else { 83 | return []; 84 | } 85 | } 86 | 87 | /// Loads the server credentials from [FlutterSecureStorage] 88 | Future _loadCredentials() async { 89 | const storage = FlutterSecureStorage(); 90 | final prefs = await SharedPreferences.getInstance(); 91 | 92 | if (prefs.getBool('first_run') ?? true) { 93 | await storage.deleteAll(); 94 | 95 | prefs.setBool('first_run', false); 96 | } else { 97 | if (await _replaceDeprecatedStorageMethod()) { 98 | _loadCredentials(); 99 | return; 100 | } 101 | 102 | String? serverMapSerialized = await storage.read(key: "server_map"); 103 | String? lastUsedServer = prefs.getString("lastusedserver"); 104 | 105 | if (serverMapSerialized != null) { 106 | Map serverMap = 107 | Map.castFrom(jsonDecode(serverMapSerialized)); 108 | if (lastUsedServer != null) { 109 | serverUrl = lastUsedServer; 110 | apiKey = serverMap[lastUsedServer]!; 111 | } else { 112 | List availableServers = serverMap.keys.toList(); 113 | if (availableServers.isNotEmpty) { 114 | serverUrl = availableServers.first; 115 | apiKey = serverMap[serverUrl]; 116 | prefs.setString("lastusedserver", serverUrl!); 117 | } 118 | } 119 | } 120 | } 121 | } 122 | 123 | Future _replaceDeprecatedStorageMethod() async { 124 | const storage = FlutterSecureStorage(); 125 | // deprecated data storage method, replaced because of multi-server support 126 | var v1DataServerurl = await storage.read(key: "shlink_url"); 127 | var v1DataApikey = await storage.read(key: "shlink_apikey"); 128 | 129 | if (v1DataServerurl != null && v1DataApikey != null) { 130 | // conversion to new data storage method 131 | Map serverMap = {}; 132 | serverMap[v1DataServerurl] = v1DataApikey; 133 | 134 | storage.write(key: "server_map", value: jsonEncode(serverMap)); 135 | 136 | storage.delete(key: "shlink_url"); 137 | storage.delete(key: "shlink_apikey"); 138 | 139 | return true; 140 | } else { 141 | return false; 142 | } 143 | } 144 | 145 | /// Saves the provided server credentials to [FlutterSecureStorage] 146 | void _saveCredentials(String url, String apiKey) async { 147 | const storage = FlutterSecureStorage(); 148 | final prefs = await SharedPreferences.getInstance(); 149 | String? serverMapSerialized = await storage.read(key: "server_map"); 150 | Map serverMap; 151 | if (serverMapSerialized != null) { 152 | serverMap = Map.castFrom(jsonDecode(serverMapSerialized)); 153 | } else { 154 | serverMap = {}; 155 | } 156 | serverMap[url] = apiKey; 157 | storage.write(key: "server_map", value: jsonEncode(serverMap)); 158 | prefs.setString("lastusedserver", url); 159 | } 160 | 161 | /// Saves provided server credentials and tries to establish a connection 162 | FutureOr> initAndConnect( 163 | String url, String apiKey) async { 164 | // TODO: convert url to correct format 165 | serverUrl = url; 166 | this.apiKey = apiKey; 167 | _saveCredentials(url, apiKey); 168 | final result = await connect(); 169 | result.fold((l) => null, (r) { 170 | logOut(url); 171 | }); 172 | return result; 173 | } 174 | 175 | /// Establishes a connection to the server 176 | FutureOr> connect() async { 177 | _loadCredentials(); 178 | return apiConnect(apiKey, serverUrl, apiVersion); 179 | } 180 | 181 | /// Gets all short URLs from the server 182 | FutureOr, Failure>> getShortUrls() async { 183 | return apiGetShortUrls(apiKey, serverUrl, apiVersion); 184 | } 185 | 186 | /// Gets all tags from the server 187 | FutureOr, Failure>> getTags() async { 188 | return apiGetTagsWithStats(apiKey, serverUrl, apiVersion); 189 | } 190 | 191 | /// Gets statistics about the Shlink instance 192 | FutureOr> getShlinkStats() async { 193 | return apiGetShlinkStats(apiKey, serverUrl, apiVersion); 194 | } 195 | 196 | /// Saves a new short URL to the server 197 | FutureOr> submitShortUrl( 198 | ShortURLSubmission shortUrl) async { 199 | return apiSubmitShortUrl(shortUrl, apiKey, serverUrl, apiVersion); 200 | } 201 | 202 | FutureOr> updateShortUrl( 203 | ShortURLSubmission shortUrl) async { 204 | return apiUpdateShortUrl(shortUrl, apiKey, serverUrl, apiVersion); 205 | } 206 | 207 | /// Deletes a short URL from the server, identified by its slug 208 | FutureOr> deleteShortUrl(String shortCode) async { 209 | return apiDeleteShortUrl(shortCode, apiKey, serverUrl, apiVersion); 210 | } 211 | 212 | /// Gets health data about the server 213 | FutureOr> getServerHealth() async { 214 | return apiGetServerHealth(apiKey, serverUrl, apiVersion); 215 | } 216 | 217 | /// Gets recently created/used short URLs from the server 218 | FutureOr, Failure>> getRecentShortUrls() async { 219 | return apiGetRecentShortUrls(apiKey, serverUrl, apiVersion); 220 | } 221 | 222 | /// Gets redirect rules for a given short URL (code) 223 | FutureOr, Failure>> getRedirectRules( 224 | String shortCode) async { 225 | return apiGetRedirectRules(shortCode, apiKey, serverUrl, apiVersion); 226 | } 227 | 228 | /// Sets redirect rules for a given short URL (code) 229 | FutureOr> setRedirectRules( 230 | String shortCode, List redirectRules) async { 231 | return apiSetRedirectRules( 232 | shortCode, redirectRules, apiKey, serverUrl, apiVersion); 233 | } 234 | } 235 | 236 | /// Server response data type about a page of short URLs from the server 237 | class ShortURLPageResponse { 238 | List urls; 239 | int totalPages; 240 | 241 | ShortURLPageResponse(this.urls, this.totalPages); 242 | } 243 | 244 | /// Server response data type about a page of tags from the server 245 | class TagsWithStatsPageResponse { 246 | List tags; 247 | int totalPages; 248 | 249 | TagsWithStatsPageResponse(this.tags, this.totalPages); 250 | } 251 | 252 | /// Server response data type about the health status of the server 253 | class ServerHealthResponse { 254 | String status; 255 | String version; 256 | 257 | ServerHealthResponse({required this.status, required this.version}); 258 | } 259 | 260 | /// Failure class, used for the API 261 | abstract class Failure {} 262 | 263 | /// Used when a request to a server fails 264 | /// (due to networking issues or an unexpected response) 265 | class RequestFailure extends Failure { 266 | int statusCode; 267 | String description; 268 | 269 | RequestFailure(this.statusCode, this.description); 270 | } 271 | 272 | /// Contains information about an error returned by the Shlink API 273 | class ApiFailure extends Failure { 274 | String type; 275 | String detail; 276 | String title; 277 | int status; 278 | List? invalidElements; 279 | 280 | ApiFailure( 281 | {required this.type, 282 | required this.detail, 283 | required this.title, 284 | required this.status, 285 | this.invalidElements}); 286 | } 287 | -------------------------------------------------------------------------------- /lib/global_theme.dart: -------------------------------------------------------------------------------- 1 | import 'package:dynamic_color/dynamic_color.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | class GlobalTheme { 5 | static final Color _lightFocusColor = Colors.black.withOpacity(0.12); 6 | static final Color _darkFocusColor = Colors.white.withOpacity(0.12); 7 | static ThemeData lightThemeData(ColorScheme? dynamicColorScheme) { 8 | return themeData(lightColorScheme, dynamicColorScheme, _lightFocusColor); 9 | } 10 | static ThemeData darkThemeData(ColorScheme? dynamicColorScheme) { 11 | return themeData(darkColorScheme, dynamicColorScheme, _darkFocusColor); 12 | } 13 | 14 | static ThemeData themeData(ColorScheme colorScheme, ColorScheme? dynamic, 15 | Color focusColor) { 16 | return ThemeData( 17 | colorScheme: colorScheme, 18 | canvasColor: colorScheme.surface, 19 | scaffoldBackgroundColor: colorScheme.surface, 20 | highlightColor: Colors.transparent, 21 | dividerColor: colorScheme.shadow, 22 | focusColor: focusColor, 23 | useMaterial3: true, 24 | appBarTheme: AppBarTheme( 25 | backgroundColor: colorScheme.surface, 26 | foregroundColor: colorScheme.onSurface, 27 | elevation: 0 28 | ) 29 | ); 30 | } 31 | 32 | static ColorScheme get lightColorScheme { 33 | return ColorScheme( 34 | primary: Color(0xff747ab5), 35 | onPrimary: Colors.white, 36 | secondary: Color(0x335d63a6),// Color(0xFFDDE0E0), 37 | onSecondary: Color(0xFF322942), 38 | tertiary: Colors.grey[300], 39 | onTertiary: Colors.grey[700], 40 | surfaceContainer: (Colors.grey[100])!, 41 | outline: (Colors.grey[500])!, 42 | shadow: (Colors.grey[300])!, 43 | error: (Colors.red[400])!, 44 | onError: Colors.white, 45 | surface: Color(0xFFFAFBFB), 46 | onSurface: Color(0xFF241E30), 47 | brightness: Brightness.light, 48 | ); 49 | } 50 | 51 | static ColorScheme get darkColorScheme { 52 | return ColorScheme( 53 | primary: Color(0xff5d63a6), 54 | secondary: Colors.blue.shade500, 55 | secondaryContainer: Color(0xff1c1c1c), 56 | surface: Colors.black, 57 | surfaceContainer: Color(0xff0f0f0f), 58 | onSurfaceVariant: Colors.grey[400], 59 | tertiary: Colors.grey[900], 60 | onTertiary: Colors.grey, 61 | outline: (Colors.grey[700])!, 62 | shadow: (Colors.grey[800])!, 63 | error: (Colors.red[400])!, 64 | onError: Colors.white, 65 | onPrimary: Colors.white, 66 | onSecondary: (Colors.grey[400])!, 67 | onSurface: Colors.white, 68 | brightness: Brightness.dark, 69 | ); 70 | } 71 | } -------------------------------------------------------------------------------- /lib/globals.dart: -------------------------------------------------------------------------------- 1 | library dev.abmgrt.shlink_app.globals; 2 | 3 | import 'package:shlink_app/API/server_manager.dart'; 4 | 5 | ServerManager serverManager = ServerManager(); 6 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:shlink_app/global_theme.dart'; 3 | import 'package:shlink_app/views/login_view.dart'; 4 | import 'package:shlink_app/views/navigationbar_view.dart'; 5 | import 'globals.dart' as globals; 6 | import 'package:dynamic_color/dynamic_color.dart'; 7 | 8 | void main() { 9 | runApp(const MyApp()); 10 | } 11 | 12 | class MyApp extends StatelessWidget { 13 | const MyApp({super.key}); 14 | 15 | static final ColorScheme _defaultLightColorScheme = 16 | ColorScheme.fromSeed(seedColor: Colors.blue); 17 | 18 | static final _defaultDarkColorScheme = ColorScheme.fromSeed( 19 | brightness: Brightness.dark, 20 | seedColor: Colors.blue, 21 | background: Colors.black); 22 | 23 | // This widget is the root of your application. 24 | @override 25 | Widget build(BuildContext context) { 26 | return DynamicColorBuilder(builder: (lightColorScheme, darkColorScheme) { 27 | return MaterialApp( 28 | title: 'Shlink', 29 | debugShowCheckedModeBanner: false, 30 | theme: GlobalTheme.lightThemeData(lightColorScheme), 31 | darkTheme: GlobalTheme.darkThemeData(darkColorScheme), 32 | /*theme: ThemeData( 33 | appBarTheme: const AppBarTheme( 34 | backgroundColor: Color(0xfffafafa), 35 | ), 36 | colorScheme: lightColorScheme ?? _defaultLightColorScheme, 37 | useMaterial3: true), 38 | darkTheme: ThemeData( 39 | appBarTheme: const AppBarTheme( 40 | backgroundColor: Color(0xff0d0d0d), 41 | foregroundColor: Colors.white, 42 | elevation: 0, 43 | ), 44 | colorScheme: darkColorScheme?.copyWith(surface: Colors.black) ?? 45 | _defaultDarkColorScheme, 46 | useMaterial3: true, 47 | ),*/ 48 | home: const InitialPage()); 49 | }); 50 | } 51 | } 52 | 53 | class InitialPage extends StatefulWidget { 54 | const InitialPage({super.key}); 55 | 56 | @override 57 | State createState() => _InitialPageState(); 58 | } 59 | 60 | class _InitialPageState extends State { 61 | @override 62 | void initState() { 63 | super.initState(); 64 | checkLogin(); 65 | } 66 | 67 | void checkLogin() async { 68 | bool result = await globals.serverManager.checkLogin(); 69 | if (result) { 70 | Navigator.of(context).pushAndRemoveUntil( 71 | MaterialPageRoute(builder: (context) => const NavigationBarView()), 72 | (Route route) => false); 73 | } else { 74 | Navigator.of(context).pushAndRemoveUntil( 75 | MaterialPageRoute(builder: (context) => const LoginView()), 76 | (Route route) => false); 77 | } 78 | } 79 | 80 | @override 81 | Widget build(BuildContext context) { 82 | return const Scaffold( 83 | body: Center(child: Text("")), 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /lib/util/build_api_error_snackbar.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:shlink_app/API/server_manager.dart'; 3 | 4 | SnackBar buildApiErrorSnackbar(Failure r, BuildContext context) { 5 | var text = ""; 6 | 7 | if (r is RequestFailure) { 8 | text = r.description; 9 | } else { 10 | text = (r as ApiFailure).detail; 11 | if ((r).invalidElements != null) { 12 | text = "$text: ${(r).invalidElements}"; 13 | } 14 | } 15 | 16 | final snackBar = SnackBar( 17 | content: Text(text, style: TextStyle(color: Theme.of(context).colorScheme.onError)), 18 | backgroundColor: Theme.of(context).colorScheme.error, 19 | behavior: SnackBarBehavior.floating); 20 | 21 | return snackBar; 22 | } -------------------------------------------------------------------------------- /lib/util/string_to_color.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:flutter/widgets.dart'; 4 | 5 | Color stringToColor(String string) { 6 | int hash = 0; 7 | string.split('').forEach((char) { 8 | hash = char.codeUnitAt(0) + ((hash << 5) - hash); 9 | }); 10 | var rgb = []; 11 | for (int i = 0; i < 3; i++) { 12 | var value = (hash >> (i * 8)) & 0xff; 13 | rgb.add(int.parse(value.toRadixString(16).padLeft(2, '0'), radix: 16)); 14 | } 15 | if (rgb.length != 3) { 16 | return const Color(0xff000000); 17 | } 18 | return Color.fromARGB(1, rgb[0], rgb[1], rgb[2]); 19 | } 20 | -------------------------------------------------------------------------------- /lib/views/home_view.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_sharing_intent/flutter_sharing_intent.dart'; 5 | import 'package:flutter_sharing_intent/model/sharing_file.dart'; 6 | import 'package:qr_flutter/qr_flutter.dart'; 7 | import 'package:shlink_app/API/Classes/ShlinkStats/shlink_stats.dart'; 8 | import 'package:shlink_app/util/build_api_error_snackbar.dart'; 9 | import 'package:shlink_app/views/short_url_edit_view.dart'; 10 | import 'package:shlink_app/views/url_list_view.dart'; 11 | import 'package:shlink_app/widgets/available_servers_bottom_sheet.dart'; 12 | import 'package:url_launcher/url_launcher_string.dart'; 13 | import '../API/Classes/ShortURL/short_url.dart'; 14 | import '../globals.dart' as globals; 15 | 16 | class HomeView extends StatefulWidget { 17 | const HomeView({super.key}); 18 | 19 | @override 20 | State createState() => _HomeViewState(); 21 | } 22 | 23 | class _HomeViewState extends State { 24 | ShlinkStats? shlinkStats; 25 | 26 | List shortUrls = []; 27 | bool shortUrlsLoaded = false; 28 | bool _qrCodeShown = false; 29 | String _qrUrl = ""; 30 | 31 | late StreamSubscription _intentDataStreamSubscription; 32 | 33 | @override 34 | void initState() { 35 | super.initState(); 36 | initializeActionProcessText(); 37 | WidgetsBinding.instance.addPostFrameCallback((_) { 38 | loadAllData(); 39 | }); 40 | } 41 | 42 | Future initializeActionProcessText() async { 43 | _intentDataStreamSubscription = 44 | FlutterSharingIntent.instance.getMediaStream().listen(_handleIntentUrl); 45 | 46 | FlutterSharingIntent.instance.getInitialSharing().then(_handleIntentUrl); 47 | } 48 | 49 | Future _handleIntentUrl(List value) async { 50 | String inputUrlText = value.firstOrNull?.value ?? ""; 51 | if (await canLaunchUrlString(inputUrlText)) { 52 | await Navigator.of(context).push(MaterialPageRoute( 53 | builder: (context) => ShortURLEditView(longUrl: inputUrlText))); 54 | await loadAllData(); 55 | } 56 | } 57 | 58 | Future loadAllData() async { 59 | await loadShlinkStats(); 60 | await loadRecentShortUrls(); 61 | return; 62 | } 63 | 64 | Future loadShlinkStats() async { 65 | final response = await globals.serverManager.getShlinkStats(); 66 | response.fold((l) { 67 | setState(() { 68 | shlinkStats = l; 69 | }); 70 | }, (r) { 71 | ScaffoldMessenger.of(context).showSnackBar( 72 | buildApiErrorSnackbar(r, context) 73 | ); 74 | }); 75 | } 76 | 77 | Future loadRecentShortUrls() async { 78 | final response = await globals.serverManager.getRecentShortUrls(); 79 | response.fold((l) { 80 | setState(() { 81 | shortUrls = l; 82 | shortUrlsLoaded = true; 83 | }); 84 | }, (r) { 85 | ScaffoldMessenger.of(context).showSnackBar( 86 | buildApiErrorSnackbar(r, context) 87 | ); 88 | }); 89 | } 90 | 91 | @override 92 | Widget build(BuildContext context) { 93 | return Scaffold( 94 | body: Stack( 95 | children: [ 96 | ColorFiltered( 97 | colorFilter: ColorFilter.mode( 98 | Colors.black.withOpacity(_qrCodeShown ? 0.4 : 0), 99 | BlendMode.srcOver), 100 | child: RefreshIndicator( 101 | onRefresh: () async { 102 | return loadAllData(); 103 | }, 104 | child: CustomScrollView( 105 | slivers: [ 106 | SliverAppBar.medium( 107 | automaticallyImplyLeading: false, 108 | expandedHeight: 160, 109 | title: Column( 110 | crossAxisAlignment: CrossAxisAlignment.start, 111 | children: [ 112 | const Text("Shlink", 113 | style: TextStyle(fontWeight: FontWeight.bold)), 114 | GestureDetector( 115 | onTap: () { 116 | showModalBottomSheet( 117 | context: context, 118 | builder: (BuildContext context) { 119 | return const AvailableServerBottomSheet(); 120 | }); 121 | }, 122 | child: Text(globals.serverManager.getServerUrl(), 123 | style: TextStyle( 124 | fontSize: 16, color: Theme.of(context).colorScheme.onTertiary)), 125 | ) 126 | ], 127 | )), 128 | SliverToBoxAdapter( 129 | child: Wrap( 130 | alignment: WrapAlignment.spaceEvenly, 131 | children: [ 132 | _ShlinkStatsCardWidget( 133 | icon: Icons.link, 134 | text: 135 | "${shlinkStats?.shortUrlsCount.toString() ?? "0"} Short URLs", 136 | borderColor: Colors.blue), 137 | _ShlinkStatsCardWidget( 138 | icon: Icons.remove_red_eye, 139 | text: 140 | "${shlinkStats?.nonOrphanVisits.total ?? "0"} Visits", 141 | borderColor: Colors.green), 142 | _ShlinkStatsCardWidget( 143 | icon: Icons.warning, 144 | text: 145 | "${shlinkStats?.orphanVisits.total ?? "0"} Orphan Visits", 146 | borderColor: Colors.red), 147 | _ShlinkStatsCardWidget( 148 | icon: Icons.sell, 149 | text: 150 | "${shlinkStats?.tagsCount.toString() ?? "0"} Tags", 151 | borderColor: Colors.purple), 152 | ], 153 | ), 154 | ), 155 | if (shortUrlsLoaded && shortUrls.isEmpty) 156 | SliverToBoxAdapter( 157 | child: Center( 158 | child: Padding( 159 | padding: const EdgeInsets.only(top: 50), 160 | child: Column( 161 | children: [ 162 | const Text( 163 | "No Short URLs", 164 | style: TextStyle( 165 | fontSize: 24, 166 | fontWeight: FontWeight.bold), 167 | ), 168 | Padding( 169 | padding: const EdgeInsets.only(top: 8), 170 | child: Text( 171 | 'Create one by tapping the "+" button below', 172 | style: TextStyle( 173 | fontSize: 16, 174 | color: Theme.of(context).colorScheme.onSecondary), 175 | ), 176 | ) 177 | ], 178 | )))) 179 | else 180 | SliverList( 181 | delegate: SliverChildBuilderDelegate( 182 | (BuildContext context, int index) { 183 | if (index == 0) { 184 | return const Padding( 185 | padding: 186 | EdgeInsets.only(top: 16, left: 12, right: 12), 187 | child: Text("Recent Short URLs", 188 | style: TextStyle( 189 | fontSize: 20, fontWeight: FontWeight.bold)), 190 | ); 191 | } else { 192 | final shortURL = shortUrls[index - 1]; 193 | return ShortURLCell( 194 | shortURL: shortURL, 195 | reload: () { 196 | loadRecentShortUrls(); 197 | }, 198 | showQRCode: (String url) { 199 | setState(() { 200 | _qrUrl = url; 201 | _qrCodeShown = true; 202 | }); 203 | }, 204 | isLast: index == shortUrls.length); 205 | } 206 | }, childCount: shortUrls.length + 1)) 207 | ], 208 | ), 209 | ), 210 | ), 211 | if (_qrCodeShown) 212 | GestureDetector( 213 | onTap: () { 214 | setState(() { 215 | _qrCodeShown = false; 216 | }); 217 | }, 218 | child: Container( 219 | color: Colors.black.withOpacity(0), 220 | ), 221 | ), 222 | if (_qrCodeShown) 223 | Center( 224 | child: SizedBox( 225 | width: MediaQuery.of(context).size.width / 1.7, 226 | height: MediaQuery.of(context).size.width / 1.7, 227 | child: Card( 228 | child: Padding( 229 | padding: const EdgeInsets.all(16), 230 | child: QrImageView( 231 | data: _qrUrl, 232 | size: 200.0, 233 | eyeStyle: QrEyeStyle( 234 | eyeShape: QrEyeShape.square, 235 | color: 236 | Theme.of(context).colorScheme.onPrimary 237 | ), 238 | dataModuleStyle: QrDataModuleStyle( 239 | dataModuleShape: QrDataModuleShape.square, 240 | color: 241 | Theme.of(context).colorScheme.onPrimary 242 | ), 243 | ))), 244 | ), 245 | ) 246 | ], 247 | ), 248 | floatingActionButton: FloatingActionButton( 249 | onPressed: () async { 250 | await Navigator.of(context).push(MaterialPageRoute( 251 | builder: (context) => const ShortURLEditView())); 252 | loadRecentShortUrls(); 253 | }, 254 | child: const Icon(Icons.add), 255 | )); 256 | } 257 | } 258 | 259 | // stats card widget 260 | class _ShlinkStatsCardWidget extends StatefulWidget { 261 | const _ShlinkStatsCardWidget( 262 | {required this.text, required this.icon, this.borderColor}); 263 | 264 | final IconData icon; 265 | final Color? borderColor; 266 | final String text; 267 | 268 | @override 269 | State<_ShlinkStatsCardWidget> createState() => _ShlinkStatsCardWidgetState(); 270 | } 271 | 272 | class _ShlinkStatsCardWidgetState extends State<_ShlinkStatsCardWidget> { 273 | @override 274 | Widget build(BuildContext context) { 275 | var randomColor = ([...Colors.primaries]..shuffle()).first; 276 | return Padding( 277 | padding: const EdgeInsets.all(4), 278 | child: Container( 279 | padding: const EdgeInsets.all(12), 280 | decoration: BoxDecoration( 281 | border: Border.all(color: widget.borderColor ?? randomColor), 282 | borderRadius: BorderRadius.circular(8)), 283 | child: SizedBox( 284 | child: Wrap( 285 | children: [ 286 | Icon(widget.icon), 287 | Padding( 288 | padding: const EdgeInsets.only(left: 4), 289 | child: Text(widget.text, 290 | style: const TextStyle(fontWeight: FontWeight.bold)), 291 | ) 292 | ], 293 | ), 294 | )), 295 | ); 296 | } 297 | } 298 | -------------------------------------------------------------------------------- /lib/views/login_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:shlink_app/API/server_manager.dart'; 3 | import 'package:shlink_app/main.dart'; 4 | import 'package:url_launcher/url_launcher.dart'; 5 | import '../globals.dart' as globals; 6 | 7 | class LoginView extends StatefulWidget { 8 | const LoginView({super.key}); 9 | 10 | @override 11 | State createState() => _LoginViewState(); 12 | } 13 | 14 | class _LoginViewState extends State { 15 | late TextEditingController _serverUrlController; 16 | late TextEditingController _apiKeyController; 17 | 18 | bool _isLoggingIn = false; 19 | String _errorMessage = ""; 20 | 21 | @override 22 | void initState() { 23 | // TODO: implement initState 24 | super.initState(); 25 | _serverUrlController = TextEditingController(); 26 | _apiKeyController = TextEditingController(); 27 | } 28 | 29 | void _connect() async { 30 | setState(() { 31 | _isLoggingIn = true; 32 | _errorMessage = ""; 33 | }); 34 | final connectResult = await globals.serverManager 35 | .initAndConnect(_serverUrlController.text, _apiKeyController.text); 36 | connectResult.fold((l) { 37 | Navigator.of(context).pushReplacement( 38 | MaterialPageRoute(builder: (context) => const InitialPage())); 39 | setState(() { 40 | _isLoggingIn = false; 41 | }); 42 | }, (r) { 43 | if (r is ApiFailure) { 44 | setState(() { 45 | _errorMessage = r.detail; 46 | _isLoggingIn = false; 47 | }); 48 | } else if (r is RequestFailure) { 49 | setState(() { 50 | _errorMessage = r.description; 51 | _isLoggingIn = false; 52 | }); 53 | } 54 | }); 55 | } 56 | 57 | @override 58 | Widget build(BuildContext context) { 59 | return Scaffold( 60 | extendBody: true, 61 | body: CustomScrollView( 62 | physics: const NeverScrollableScrollPhysics(), 63 | slivers: [ 64 | const SliverAppBar.medium( 65 | title: Text("Add server", 66 | style: TextStyle(fontWeight: FontWeight.bold))), 67 | SliverFillRemaining( 68 | child: Padding( 69 | padding: const EdgeInsets.all(16), 70 | child: Stack( 71 | children: [ 72 | Align( 73 | child: Column( 74 | mainAxisAlignment: MainAxisAlignment.center, 75 | crossAxisAlignment: CrossAxisAlignment.start, 76 | children: [ 77 | const Padding( 78 | padding: EdgeInsets.only(bottom: 8), 79 | child: Text( 80 | "Server URL", 81 | style: TextStyle(fontWeight: FontWeight.bold), 82 | )), 83 | Row( 84 | children: [ 85 | const Icon(Icons.dns_outlined), 86 | const SizedBox(width: 8), 87 | Expanded( 88 | child: TextField( 89 | controller: _serverUrlController, 90 | keyboardType: TextInputType.url, 91 | decoration: const InputDecoration( 92 | border: OutlineInputBorder(), 93 | labelText: "https://shlink.example.com"), 94 | )) 95 | ], 96 | ), 97 | const Padding( 98 | padding: EdgeInsets.only(top: 8, bottom: 8), 99 | child: Text("API Key", 100 | style: TextStyle(fontWeight: FontWeight.bold)), 101 | ), 102 | Row( 103 | children: [ 104 | const Icon(Icons.key), 105 | const SizedBox(width: 8), 106 | Expanded( 107 | child: TextField( 108 | controller: _apiKeyController, 109 | keyboardType: TextInputType.text, 110 | obscureText: true, 111 | decoration: const InputDecoration( 112 | border: OutlineInputBorder(), labelText: "..."), 113 | )) 114 | ], 115 | ), 116 | Padding( 117 | padding: const EdgeInsets.only(top: 16), 118 | child: Row( 119 | mainAxisAlignment: MainAxisAlignment.center, 120 | children: [ 121 | FilledButton.tonal( 122 | onPressed: () => {_connect()}, 123 | child: _isLoggingIn 124 | ? Container( 125 | width: 34, 126 | height: 34, 127 | padding: const EdgeInsets.all(4), 128 | child: const CircularProgressIndicator(), 129 | ) 130 | : const Text("Connect", 131 | style: TextStyle(fontSize: 20)), 132 | ) 133 | ], 134 | ), 135 | ), 136 | Padding( 137 | padding: const EdgeInsets.only(top: 16), 138 | child: Row( 139 | mainAxisAlignment: MainAxisAlignment.center, 140 | children: [ 141 | Flexible( 142 | child: Text(_errorMessage, 143 | style: TextStyle(color: Theme.of(context).colorScheme.onError), 144 | textAlign: TextAlign.center)) 145 | ], 146 | ), 147 | ), 148 | ], 149 | ), 150 | ), 151 | Align( 152 | alignment: Alignment.bottomCenter, 153 | child: TextButton( 154 | onPressed: () async { 155 | final Uri url = Uri.parse('https://shlink.io/documentation/api-docs/authentication/'); 156 | try { 157 | if (!await launchUrl(url)) { 158 | throw Exception(); 159 | } 160 | } catch (e) { 161 | final snackBar = SnackBar( 162 | content: Text("Unable to launch url. See Shlink docs for more information.", 163 | style: TextStyle(color: Theme.of(context).colorScheme.onError)), 164 | backgroundColor: Theme.of(context).colorScheme.error, 165 | behavior: SnackBarBehavior.floating); 166 | ScaffoldMessenger.of(context).showSnackBar( 167 | snackBar); 168 | } 169 | }, 170 | child: Text("How to create an API Key"), 171 | ), 172 | ) 173 | ], 174 | ), 175 | )) 176 | ], 177 | )); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /lib/views/navigationbar_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:shlink_app/views/settings_view.dart'; 3 | import 'package:shlink_app/views/home_view.dart'; 4 | import 'package:shlink_app/views/url_list_view.dart'; 5 | 6 | class NavigationBarView extends StatefulWidget { 7 | const NavigationBarView({super.key}); 8 | 9 | @override 10 | State createState() => _NavigationBarViewState(); 11 | } 12 | 13 | class _NavigationBarViewState extends State { 14 | final List views = [ 15 | const HomeView(), 16 | const URLListView(), 17 | const SettingsView() 18 | ]; 19 | int _selectedView = 0; 20 | 21 | @override 22 | Widget build(BuildContext context) { 23 | return Scaffold( 24 | body: views.elementAt(_selectedView), 25 | bottomNavigationBar: NavigationBar( 26 | destinations: const [ 27 | NavigationDestination(icon: Icon(Icons.home), label: "Home"), 28 | NavigationDestination(icon: Icon(Icons.link), label: "Short URLs"), 29 | NavigationDestination(icon: Icon(Icons.settings), label: "Settings") 30 | ], 31 | selectedIndex: _selectedView, 32 | onDestinationSelected: (int index) { 33 | setState(() { 34 | _selectedView = index; 35 | }); 36 | }, 37 | ), 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/views/opensource_licenses_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:shlink_app/util/license.dart'; 3 | import 'package:url_launcher/url_launcher.dart'; 4 | 5 | class OpenSourceLicensesView extends StatefulWidget { 6 | const OpenSourceLicensesView({super.key}); 7 | 8 | @override 9 | State createState() => _OpenSourceLicensesViewState(); 10 | } 11 | 12 | class _OpenSourceLicensesViewState extends State { 13 | @override 14 | Widget build(BuildContext context) { 15 | return Scaffold( 16 | body: CustomScrollView( 17 | slivers: [ 18 | const SliverAppBar.medium( 19 | expandedHeight: 120, 20 | title: Text( 21 | "Open Source Licenses", 22 | style: TextStyle(fontWeight: FontWeight.bold), 23 | )), 24 | SliverList( 25 | delegate: 26 | SliverChildBuilderDelegate((BuildContext context, int index) { 27 | final currentLicense = LicenseUtil.getLicenses()[index]; 28 | return GestureDetector( 29 | onTap: () async { 30 | if (currentLicense.repository != null) { 31 | if (await canLaunchUrl( 32 | Uri.parse(currentLicense.repository ?? ""))) { 33 | launchUrl(Uri.parse(currentLicense.repository ?? ""), 34 | mode: LaunchMode.externalApplication); 35 | } 36 | } 37 | }, 38 | child: Padding( 39 | padding: const EdgeInsets.all(12), 40 | child: Container( 41 | decoration: BoxDecoration( 42 | borderRadius: BorderRadius.circular(8), 43 | color: Theme.of(context).colorScheme.surfaceContainer, 44 | ), 45 | child: Padding( 46 | padding: const EdgeInsets.only( 47 | left: 12, right: 12, top: 20, bottom: 20), 48 | child: Column( 49 | crossAxisAlignment: CrossAxisAlignment.start, 50 | children: [ 51 | Text(currentLicense.name, 52 | style: const TextStyle( 53 | fontWeight: FontWeight.bold, fontSize: 18)), 54 | Text("Version: ${currentLicense.version ?? "N/A"}", 55 | style: TextStyle(color: Theme.of(context).colorScheme.onTertiary)), 56 | const SizedBox(height: 8), 57 | Divider(color: Theme.of(context).dividerColor), 58 | const SizedBox(height: 8), 59 | Text(currentLicense.license, 60 | textAlign: TextAlign.justify, 61 | style: TextStyle(color: Theme.of(context).colorScheme.onTertiary)), 62 | ], 63 | ), 64 | ), 65 | ), 66 | ), 67 | ); 68 | }, childCount: LicenseUtil.getLicenses().length), 69 | ), 70 | SliverToBoxAdapter( 71 | child: Padding( 72 | padding: EdgeInsets.only(top: 8, bottom: 20), 73 | child: Text( 74 | "Thank you to all maintainers of these repositories 💝", 75 | style: TextStyle(color: Theme.of(context).colorScheme.onTertiary), 76 | textAlign: TextAlign.center, 77 | ), 78 | )) 79 | ], 80 | ), 81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /lib/views/redirect_rules_detail_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:shlink_app/API/Classes/ShortURL/RedirectRule/condition_device_type.dart'; 3 | import 'package:shlink_app/API/Classes/ShortURL/RedirectRule/redirect_rule_condition.dart'; 4 | import 'package:shlink_app/API/Classes/ShortURL/RedirectRule/redirect_rule_condition_type.dart'; 5 | import 'package:shlink_app/API/Classes/ShortURL/short_url.dart'; 6 | import 'package:shlink_app/util/build_api_error_snackbar.dart'; 7 | import '../globals.dart' as globals; 8 | import '../API/Classes/ShortURL/RedirectRule/redirect_rule.dart'; 9 | 10 | class RedirectRulesDetailView extends StatefulWidget { 11 | const RedirectRulesDetailView({super.key, required this.shortURL}); 12 | 13 | final ShortURL shortURL; 14 | 15 | @override 16 | State createState() => 17 | _RedirectRulesDetailViewState(); 18 | } 19 | 20 | class _RedirectRulesDetailViewState extends State { 21 | List redirectRules = []; 22 | 23 | bool redirectRulesLoaded = false; 24 | 25 | bool isSaving = false; 26 | 27 | @override 28 | void initState() { 29 | super.initState(); 30 | WidgetsBinding.instance.addPostFrameCallback((_) => loadRedirectRules()); 31 | } 32 | 33 | Future loadRedirectRules() async { 34 | final response = 35 | await globals.serverManager.getRedirectRules(widget.shortURL.shortCode); 36 | response.fold((l) { 37 | setState(() { 38 | redirectRules = l; 39 | redirectRulesLoaded = true; 40 | }); 41 | _sortListByPriority(); 42 | return true; 43 | }, (r) { 44 | ScaffoldMessenger.of(context).showSnackBar( 45 | buildApiErrorSnackbar(r, context) 46 | ); 47 | return false; 48 | }); 49 | } 50 | 51 | void _saveRedirectRules() async { 52 | final response = await globals.serverManager 53 | .setRedirectRules(widget.shortURL.shortCode, redirectRules); 54 | response.fold((l) { 55 | Navigator.pop(context); 56 | }, (r) { 57 | ScaffoldMessenger.of(context).showSnackBar( 58 | buildApiErrorSnackbar(r, context) 59 | ); 60 | return false; 61 | }); 62 | } 63 | 64 | void _sortListByPriority() { 65 | setState(() { 66 | redirectRules.sort((a, b) => a.priority - b.priority); 67 | }); 68 | } 69 | 70 | void _fixPriorities() { 71 | for (int i = 0; i < redirectRules.length; i++) { 72 | setState(() { 73 | redirectRules[i].priority = i + 1; 74 | }); 75 | } 76 | } 77 | 78 | @override 79 | Widget build(BuildContext context) { 80 | return Scaffold( 81 | floatingActionButton: Wrap( 82 | spacing: 16, 83 | children: [ 84 | FloatingActionButton( 85 | onPressed: () { 86 | if (!isSaving & redirectRulesLoaded) { 87 | setState(() { 88 | isSaving = true; 89 | }); 90 | _saveRedirectRules(); 91 | } 92 | }, 93 | child: isSaving 94 | ? const Padding( 95 | padding: EdgeInsets.all(16), 96 | child: CircularProgressIndicator(strokeWidth: 3, color: Colors.white)) 97 | : const Icon(Icons.save)) 98 | ], 99 | ), 100 | body: CustomScrollView( 101 | slivers: [ 102 | const SliverAppBar.medium( 103 | expandedHeight: 120, 104 | title: Text( 105 | "Redirect Rules", 106 | style: TextStyle(fontWeight: FontWeight.bold), 107 | ), 108 | ), 109 | if (redirectRulesLoaded && redirectRules.isEmpty) 110 | SliverToBoxAdapter( 111 | child: Center( 112 | child: Padding( 113 | padding: const EdgeInsets.only(top: 50), 114 | child: Column( 115 | children: [ 116 | const Text( 117 | "No Redirect Rules", 118 | style: TextStyle( 119 | fontSize: 24, fontWeight: FontWeight.bold), 120 | ), 121 | Padding( 122 | padding: const EdgeInsets.only(top: 8), 123 | child: Text( 124 | 'Adding redirect rules will be supported soon!', 125 | style: TextStyle( 126 | fontSize: 16, color: Theme.of(context).colorScheme.onSecondary), 127 | ), 128 | ) 129 | ], 130 | )))) 131 | else 132 | SliverList( 133 | delegate: SliverChildBuilderDelegate( 134 | (BuildContext context, int index) { 135 | return _ListCell( 136 | redirectRule: redirectRules[index], 137 | moveUp: index == 0 138 | ? null 139 | : () { 140 | setState(() { 141 | redirectRules[index].priority -= 1; 142 | redirectRules[index - 1].priority += 1; 143 | }); 144 | _sortListByPriority(); 145 | }, 146 | moveDown: index == (redirectRules.length - 1) 147 | ? null 148 | : () { 149 | setState(() { 150 | redirectRules[index].priority += 1; 151 | redirectRules[index + 1].priority -= 1; 152 | }); 153 | _sortListByPriority(); 154 | }, 155 | delete: () { 156 | setState(() { 157 | redirectRules.removeAt(index); 158 | }); 159 | _fixPriorities(); 160 | }, 161 | ); 162 | }, childCount: redirectRules.length)) 163 | ], 164 | ), 165 | ); 166 | } 167 | } 168 | 169 | class _ListCell extends StatefulWidget { 170 | const _ListCell( 171 | {required this.redirectRule, 172 | required this.moveUp, 173 | required this.moveDown, 174 | required this.delete}); 175 | 176 | final VoidCallback? moveUp; 177 | final VoidCallback? moveDown; 178 | final VoidCallback delete; 179 | final RedirectRule redirectRule; 180 | 181 | @override 182 | State<_ListCell> createState() => _ListCellState(); 183 | } 184 | 185 | class _ListCellState extends State<_ListCell> { 186 | String _conditionToTagString(RedirectRuleCondition condition) { 187 | switch (condition.type) { 188 | case RedirectRuleConditionType.DEVICE: 189 | return "Device is ${ConditionDeviceType.fromApi(condition.matchValue).humanReadable}"; 190 | case RedirectRuleConditionType.LANGUAGE: 191 | return "Language is ${condition.matchValue}"; 192 | case RedirectRuleConditionType.QUERY_PARAM: 193 | return "Query string contains ${condition.matchKey}=${condition.matchValue}"; 194 | } 195 | } 196 | 197 | @override 198 | Widget build(BuildContext context) { 199 | return Padding( 200 | padding: const EdgeInsets.only(left: 8, right: 8), 201 | child: Container( 202 | padding: const EdgeInsets.only(left: 8, right: 8, top: 16, bottom: 16), 203 | decoration: BoxDecoration( 204 | border: Border( 205 | bottom: BorderSide( 206 | color: Theme.of(context).dividerColor)), 207 | ), 208 | child: Column( 209 | crossAxisAlignment: CrossAxisAlignment.start, 210 | children: [ 211 | Row( 212 | children: [ 213 | const Text("Long URL ", 214 | style: TextStyle(fontWeight: FontWeight.bold)), 215 | Text(widget.redirectRule.longUrl) 216 | ], 217 | ), 218 | const Text("Conditions:", 219 | style: TextStyle(fontWeight: FontWeight.bold)), 220 | Row( 221 | children: [ 222 | Expanded( 223 | child: Wrap( 224 | children: 225 | widget.redirectRule.conditions.map((condition) { 226 | return Padding( 227 | padding: const EdgeInsets.only(right: 4, top: 4), 228 | child: Container( 229 | padding: const EdgeInsets.only( 230 | top: 4, bottom: 4, left: 12, right: 12), 231 | decoration: BoxDecoration( 232 | borderRadius: BorderRadius.circular(4), 233 | color: 234 | Theme.of(context).colorScheme.tertiary, 235 | ), 236 | child: Text(_conditionToTagString(condition)), 237 | ), 238 | ); 239 | }).toList(), 240 | ), 241 | ) 242 | ], 243 | ), 244 | Wrap( 245 | children: [ 246 | IconButton( 247 | disabledColor: 248 | Theme.of(context).disabledColor, 249 | onPressed: widget.moveUp, 250 | icon: const Icon(Icons.arrow_upward), 251 | ), 252 | IconButton( 253 | disabledColor: 254 | Theme.of(context).disabledColor, 255 | onPressed: widget.moveDown, 256 | icon: const Icon(Icons.arrow_downward), 257 | ), 258 | IconButton( 259 | onPressed: widget.delete, 260 | icon: const Icon(Icons.delete, color: Colors.red), 261 | ) 262 | ], 263 | ) 264 | ], 265 | ))); 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /lib/views/settings_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:package_info_plus/package_info_plus.dart'; 3 | import 'package:shlink_app/util/build_api_error_snackbar.dart'; 4 | import 'package:shlink_app/views/opensource_licenses_view.dart'; 5 | import 'package:shlink_app/widgets/available_servers_bottom_sheet.dart'; 6 | import 'package:url_launcher/url_launcher.dart'; 7 | import '../globals.dart' as globals; 8 | 9 | class SettingsView extends StatefulWidget { 10 | const SettingsView({super.key}); 11 | 12 | @override 13 | State createState() => _SettingsViewState(); 14 | } 15 | 16 | enum ServerStatus { connected, connecting, disconnected } 17 | 18 | class _SettingsViewState extends State { 19 | var _serverVersion = "---"; 20 | ServerStatus _serverStatus = ServerStatus.connecting; 21 | PackageInfo packageInfo = 22 | PackageInfo(appName: "", packageName: "", version: "", buildNumber: ""); 23 | 24 | @override 25 | void initState() { 26 | // TODO: implement initState 27 | super.initState(); 28 | WidgetsBinding.instance.addPostFrameCallback((_) => getServerHealth()); 29 | } 30 | 31 | void getServerHealth() async { 32 | var packageInfo = await PackageInfo.fromPlatform(); 33 | setState(() { 34 | this.packageInfo = packageInfo; 35 | }); 36 | final response = await globals.serverManager.getServerHealth(); 37 | response.fold((l) { 38 | setState(() { 39 | _serverVersion = l.version; 40 | _serverStatus = ServerStatus.connected; 41 | }); 42 | }, (r) { 43 | setState(() { 44 | _serverStatus = ServerStatus.disconnected; 45 | }); 46 | ScaffoldMessenger.of(context).showSnackBar( 47 | buildApiErrorSnackbar(r, context) 48 | ); 49 | }); 50 | } 51 | 52 | @override 53 | Widget build(BuildContext context) { 54 | return Scaffold( 55 | body: CustomScrollView( 56 | slivers: [ 57 | const SliverAppBar.medium( 58 | expandedHeight: 120, 59 | title: Text( 60 | "Settings", 61 | style: TextStyle(fontWeight: FontWeight.bold), 62 | ), 63 | ), 64 | SliverToBoxAdapter( 65 | child: Padding( 66 | padding: const EdgeInsets.all(12.0), 67 | child: Column( 68 | children: [ 69 | GestureDetector( 70 | onTap: () { 71 | showModalBottomSheet( 72 | context: context, 73 | builder: (BuildContext context) { 74 | return const AvailableServerBottomSheet(); 75 | }); 76 | }, 77 | child: Container( 78 | decoration: BoxDecoration( 79 | borderRadius: BorderRadius.circular(8), 80 | color: Theme.of(context).colorScheme.surfaceContainer 81 | ), 82 | child: Padding( 83 | padding: const EdgeInsets.all(12.0), 84 | child: Row( 85 | children: [ 86 | Icon(Icons.dns_outlined, 87 | color: (() { 88 | switch (_serverStatus) { 89 | case ServerStatus.connected: 90 | return Colors.green; 91 | case ServerStatus.connecting: 92 | return Colors.orange; 93 | case ServerStatus.disconnected: 94 | return Colors.red; 95 | } 96 | }())), 97 | const SizedBox(width: 8), 98 | Column( 99 | crossAxisAlignment: CrossAxisAlignment.start, 100 | children: [ 101 | Text("Connected to", 102 | style: TextStyle(color: Theme.of(context).colorScheme.onTertiary)), 103 | Text(globals.serverManager.getServerUrl(), 104 | style: const TextStyle( 105 | fontWeight: FontWeight.bold, 106 | fontSize: 16)), 107 | Row( 108 | children: [ 109 | Text("API Version: ", 110 | style: TextStyle( 111 | color: Theme.of(context).colorScheme.onTertiary, 112 | fontWeight: FontWeight.w600)), 113 | Text(globals.serverManager.getApiVersion(), 114 | style: TextStyle( 115 | color: Theme.of(context).colorScheme.onTertiary)), 116 | const SizedBox(width: 16), 117 | Text("Server Version: ", 118 | style: TextStyle( 119 | color: Theme.of(context).colorScheme.onTertiary, 120 | fontWeight: FontWeight.w600)), 121 | Text(_serverVersion, 122 | style: 123 | TextStyle(color: Theme.of(context).colorScheme.onTertiary)) 124 | ], 125 | ), 126 | ], 127 | ) 128 | ], 129 | ), 130 | ), 131 | ), 132 | ), 133 | const SizedBox(height: 8), 134 | Divider(color: Theme.of(context).dividerColor), 135 | const SizedBox(height: 8), 136 | GestureDetector( 137 | onTap: () { 138 | Navigator.of(context).push(MaterialPageRoute( 139 | builder: (context) => 140 | const OpenSourceLicensesView())); 141 | }, 142 | child: Container( 143 | decoration: BoxDecoration( 144 | borderRadius: BorderRadius.circular(8), 145 | color: Theme.of(context).colorScheme.surfaceContainer 146 | ), 147 | child: const Padding( 148 | padding: EdgeInsets.only( 149 | left: 12, right: 12, top: 20, bottom: 20), 150 | child: Row( 151 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 152 | children: [ 153 | Row( 154 | children: [ 155 | Icon(Icons.policy_outlined), 156 | SizedBox(width: 8), 157 | Text("Open Source Licenses", 158 | style: TextStyle( 159 | fontWeight: FontWeight.w500)), 160 | ], 161 | ), 162 | Icon(Icons.chevron_right) 163 | ]), 164 | ), 165 | ), 166 | ), 167 | const SizedBox(height: 16), 168 | GestureDetector( 169 | onTap: () async { 170 | var url = Uri.parse( 171 | "https://github.com/rainloreley/shlink-mobile-app"); 172 | if (await canLaunchUrl(url)) { 173 | launchUrl(url, mode: LaunchMode.externalApplication); 174 | } 175 | }, 176 | child: Container( 177 | decoration: BoxDecoration( 178 | borderRadius: BorderRadius.circular(8), 179 | color: Theme.of(context).colorScheme.surfaceContainer 180 | ), 181 | child: const Padding( 182 | padding: EdgeInsets.only( 183 | left: 12, right: 12, top: 20, bottom: 20), 184 | child: Row( 185 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 186 | children: [ 187 | Row( 188 | children: [ 189 | Icon(Icons.code), 190 | SizedBox(width: 8), 191 | Text("GitHub", 192 | style: TextStyle( 193 | fontWeight: FontWeight.w500)), 194 | ], 195 | ), 196 | Icon(Icons.chevron_right) 197 | ]), 198 | ), 199 | ), 200 | ), 201 | const SizedBox(height: 16), 202 | GestureDetector( 203 | onTap: () async { 204 | var url = Uri.parse( 205 | "https://wiki.abmgrt.dev/de/projects/shlink-manager/privacy"); 206 | if (await canLaunchUrl(url)) { 207 | launchUrl(url, mode: LaunchMode.externalApplication); 208 | } 209 | }, 210 | child: Container( 211 | decoration: BoxDecoration( 212 | borderRadius: BorderRadius.circular(8), 213 | color: Theme.of(context).colorScheme.surfaceContainer 214 | ), 215 | child: const Padding( 216 | padding: EdgeInsets.only( 217 | left: 12, right: 12, top: 20, bottom: 20), 218 | child: Row( 219 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 220 | children: [ 221 | Row( 222 | children: [ 223 | Icon(Icons.lock), 224 | SizedBox(width: 8), 225 | Text("Privacy Policy", 226 | style: TextStyle( 227 | fontWeight: FontWeight.w500)), 228 | ], 229 | ), 230 | Icon(Icons.chevron_right) 231 | ]), 232 | ), 233 | ), 234 | ), 235 | const SizedBox(height: 16), 236 | if (packageInfo.appName != "") 237 | Row( 238 | mainAxisAlignment: MainAxisAlignment.end, 239 | children: [ 240 | Container( 241 | padding: const EdgeInsets.only( 242 | left: 8, right: 8, top: 4, bottom: 4), 243 | decoration: BoxDecoration( 244 | borderRadius: BorderRadius.circular(8), 245 | color: 246 | Theme.of(context).colorScheme.surfaceContainer 247 | ), 248 | child: Text( 249 | "${packageInfo.appName}, v${packageInfo.version} (${packageInfo.buildNumber})", 250 | style: TextStyle(color: Theme.of(context).colorScheme.onSecondary), 251 | ), 252 | ) 253 | ], 254 | ) 255 | ], 256 | )), 257 | ) 258 | ], 259 | )); 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /lib/views/short_url_edit_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart' as dartz; 2 | import 'package:dynamic_color/dynamic_color.dart'; 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter/services.dart'; 5 | import 'package:shlink_app/API/Classes/ShortURL/short_url.dart'; 6 | import 'package:shlink_app/API/Classes/ShortURLSubmission/short_url_submission.dart'; 7 | import 'package:shlink_app/API/server_manager.dart'; 8 | import 'package:shlink_app/util/build_api_error_snackbar.dart'; 9 | import 'package:shlink_app/util/string_to_color.dart'; 10 | import 'package:shlink_app/views/tag_selector_view.dart'; 11 | import 'package:shlink_app/widgets/url_tags_list_widget.dart'; 12 | import '../globals.dart' as globals; 13 | 14 | class ShortURLEditView extends StatefulWidget { 15 | const ShortURLEditView({super.key, this.shortUrl, this.longUrl}); 16 | 17 | final ShortURL? shortUrl; 18 | final String? longUrl; 19 | 20 | @override 21 | State createState() => _ShortURLEditViewState(); 22 | } 23 | 24 | class _ShortURLEditViewState extends State 25 | with SingleTickerProviderStateMixin { 26 | final longUrlController = TextEditingController(); 27 | final customSlugController = TextEditingController(); 28 | final titleController = TextEditingController(); 29 | final randomSlugLengthController = TextEditingController(text: "5"); 30 | List tags = []; 31 | 32 | bool randomSlug = true; 33 | bool isCrawlable = true; 34 | bool forwardQuery = true; 35 | bool copyToClipboard = true; 36 | 37 | bool disableSlugEditor = false; 38 | 39 | String longUrlError = ""; 40 | String randomSlugLengthError = ""; 41 | 42 | bool isSaving = false; 43 | 44 | late AnimationController _customSlugDiceAnimationController; 45 | 46 | @override 47 | void initState() { 48 | _customSlugDiceAnimationController = AnimationController( 49 | vsync: this, 50 | duration: const Duration(milliseconds: 500), 51 | ); 52 | loadExistingUrl(); 53 | if (widget.longUrl != null) { 54 | longUrlController.text = widget.longUrl!; 55 | } 56 | super.initState(); 57 | } 58 | 59 | @override 60 | void dispose() { 61 | longUrlController.dispose(); 62 | customSlugController.dispose(); 63 | titleController.dispose(); 64 | randomSlugLengthController.dispose(); 65 | super.dispose(); 66 | } 67 | 68 | void loadExistingUrl() { 69 | if (widget.shortUrl != null) { 70 | longUrlController.text = widget.shortUrl!.longUrl; 71 | isCrawlable = widget.shortUrl!.crawlable; 72 | tags = widget.shortUrl!.tags; 73 | // for some reason this attribute is not returned by the api 74 | forwardQuery = true; 75 | titleController.text = widget.shortUrl!.title ?? ""; 76 | customSlugController.text = widget.shortUrl!.shortCode; 77 | disableSlugEditor = true; 78 | randomSlug = false; 79 | } 80 | } 81 | 82 | void _saveButtonPressed() { 83 | if (!isSaving) { 84 | setState(() { 85 | isSaving = true; 86 | longUrlError = ""; 87 | randomSlugLengthError = ""; 88 | }); 89 | if (longUrlController.text == "") { 90 | setState(() { 91 | longUrlError = "URL cannot be empty"; 92 | isSaving = false; 93 | }); 94 | return; 95 | } else if (int.tryParse(randomSlugLengthController.text) == 96 | null || 97 | int.tryParse(randomSlugLengthController.text)! < 1 || 98 | int.tryParse(randomSlugLengthController.text)! > 50) { 99 | setState(() { 100 | randomSlugLengthError = "invalid number"; 101 | isSaving = false; 102 | }); 103 | return; 104 | } else { 105 | _submitShortUrl(); 106 | } 107 | } 108 | } 109 | 110 | void _submitShortUrl() async { 111 | var newSubmission = ShortURLSubmission( 112 | longUrl: longUrlController.text, 113 | tags: tags, 114 | crawlable: isCrawlable, 115 | forwardQuery: forwardQuery, 116 | findIfExists: true, 117 | title: titleController.text != "" ? titleController.text : null, 118 | customSlug: customSlugController.text != "" && !randomSlug 119 | ? customSlugController.text 120 | : null, 121 | shortCodeLength: 122 | randomSlug ? int.parse(randomSlugLengthController.text) : null); 123 | dartz.Either response; 124 | if (widget.shortUrl != null) { 125 | response = await globals.serverManager.updateShortUrl(newSubmission); 126 | } else { 127 | response = await globals.serverManager.submitShortUrl(newSubmission); 128 | } 129 | 130 | response.fold((l) async { 131 | setState(() { 132 | isSaving = false; 133 | }); 134 | 135 | if (copyToClipboard) { 136 | await Clipboard.setData(ClipboardData(text: l.shortUrl)); 137 | final snackBar = SnackBar( 138 | content: const Text("Copied to clipboard!"), 139 | backgroundColor: Colors.green[400], 140 | behavior: SnackBarBehavior.floating); 141 | ScaffoldMessenger.of(context).showSnackBar(snackBar); 142 | } else { 143 | final snackBar = SnackBar( 144 | content: const Text("Short URL created!"), 145 | backgroundColor: Colors.green[400], 146 | behavior: SnackBarBehavior.floating); 147 | ScaffoldMessenger.of(context).showSnackBar(snackBar); 148 | } 149 | Navigator.pop(context, l); 150 | 151 | return true; 152 | }, (r) { 153 | setState(() { 154 | isSaving = false; 155 | }); 156 | 157 | ScaffoldMessenger.of(context).showSnackBar( 158 | buildApiErrorSnackbar(r, context) 159 | ); 160 | return false; 161 | }); 162 | } 163 | 164 | @override 165 | Widget build(BuildContext context) { 166 | return Scaffold( 167 | body: CustomScrollView( 168 | slivers: [ 169 | SliverAppBar.medium( 170 | title: Text("${disableSlugEditor ? "Edit" : "New"} Short URL", 171 | style: const TextStyle(fontWeight: FontWeight.bold)), 172 | ), 173 | SliverToBoxAdapter( 174 | child: Padding( 175 | padding: const EdgeInsets.only(top: 16, left: 8, right: 8), 176 | child: Wrap( 177 | runSpacing: 16, 178 | children: [ 179 | TextField( 180 | controller: longUrlController, 181 | decoration: InputDecoration( 182 | errorText: longUrlError != "" ? longUrlError : null, 183 | border: const OutlineInputBorder(), 184 | label: const Row( 185 | children: [ 186 | Icon(Icons.public), 187 | SizedBox(width: 8), 188 | Text("Long URL") 189 | ], 190 | )), 191 | ), 192 | Row( 193 | children: [ 194 | Expanded( 195 | child: TextField( 196 | enabled: !disableSlugEditor, 197 | controller: customSlugController, 198 | style: TextStyle( 199 | color: randomSlug 200 | ? Theme.of(context).colorScheme.onTertiary 201 | : Theme.of(context).colorScheme.onPrimary), 202 | onChanged: (_) { 203 | if (randomSlug) { 204 | setState(() { 205 | randomSlug = false; 206 | }); 207 | } 208 | }, 209 | decoration: InputDecoration( 210 | border: const OutlineInputBorder(), 211 | label: Row( 212 | children: [ 213 | const Icon(Icons.link), 214 | const SizedBox(width: 8), 215 | Text( 216 | "${randomSlug ? "Random" : "Custom"} slug", 217 | style: TextStyle( 218 | fontStyle: randomSlug 219 | ? FontStyle.italic 220 | : FontStyle.normal), 221 | ) 222 | ], 223 | )), 224 | ), 225 | ), 226 | 227 | if (widget.shortUrl == null) 228 | Container( 229 | padding: const EdgeInsets.only(left: 8), 230 | child: RotationTransition( 231 | turns: Tween(begin: 0.0, end: 3.0).animate( 232 | CurvedAnimation( 233 | parent: _customSlugDiceAnimationController, 234 | curve: Curves.easeInOutExpo)), 235 | child: IconButton( 236 | onPressed: disableSlugEditor 237 | ? null 238 | : () { 239 | if (randomSlug) { 240 | _customSlugDiceAnimationController.reverse( 241 | from: 1); 242 | } else { 243 | _customSlugDiceAnimationController.forward( 244 | from: 0); 245 | } 246 | setState(() { 247 | randomSlug = !randomSlug; 248 | }); 249 | }, 250 | icon: Icon( 251 | randomSlug ? Icons.casino : Icons.casino_outlined, 252 | color: randomSlug ? Colors.green : Colors.grey)), 253 | ), 254 | ) 255 | ], 256 | ), 257 | if (randomSlug && widget.shortUrl == null) 258 | Row( 259 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 260 | children: [ 261 | const Text("Random slug length"), 262 | SizedBox( 263 | width: 100, 264 | child: TextField( 265 | controller: randomSlugLengthController, 266 | keyboardType: TextInputType.number, 267 | decoration: InputDecoration( 268 | errorText: 269 | randomSlugLengthError != "" ? "" : null, 270 | border: const OutlineInputBorder(), 271 | label: const Row( 272 | children: [ 273 | Icon(Icons.tag), 274 | SizedBox(width: 8), 275 | Text("Length") 276 | ], 277 | )), 278 | )) 279 | ], 280 | ), 281 | TextField( 282 | controller: titleController, 283 | decoration: const InputDecoration( 284 | border: OutlineInputBorder(), 285 | label: Row( 286 | children: [ 287 | Icon(Icons.badge), 288 | SizedBox(width: 8), 289 | Text("Title") 290 | ], 291 | )), 292 | ), 293 | GestureDetector( 294 | onTap: () async { 295 | List? selectedTags = await Navigator.of(context). 296 | push(MaterialPageRoute( 297 | builder: (context) => 298 | TagSelectorView(alreadySelectedTags: tags))); 299 | if (selectedTags != null) { 300 | setState(() { 301 | tags = selectedTags; 302 | }); 303 | } 304 | }, 305 | child: InputDecorator( 306 | isEmpty: tags.isEmpty, 307 | decoration: const InputDecoration( 308 | border: OutlineInputBorder(), 309 | label: Row( 310 | children: [ 311 | Icon(Icons.label_outline), 312 | SizedBox(width: 8), 313 | Text("Tags") 314 | ], 315 | )), 316 | child: Wrap( 317 | runSpacing: 8, 318 | spacing: 8, 319 | children: tags.map((tag) { 320 | var boxColor = stringToColor(tag) 321 | .harmonizeWith(Theme.of(context).colorScheme. 322 | primary); 323 | var textColor = boxColor.computeLuminance() < 0.5 324 | ? Colors.white 325 | : Colors.black; 326 | return InputChip( 327 | label: Text(tag, style: TextStyle( 328 | color: textColor 329 | )), 330 | backgroundColor: boxColor, 331 | deleteIcon: Icon(Icons.close, 332 | size: 18, 333 | color: textColor), 334 | onDeleted: () { 335 | setState(() { 336 | tags.remove(tag); 337 | }); 338 | }, 339 | ); 340 | }).toList(), 341 | ) 342 | ), 343 | ), 344 | Row( 345 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 346 | children: [ 347 | const Text("Crawlable"), 348 | Switch( 349 | value: isCrawlable, 350 | onChanged: (_) { 351 | setState(() { 352 | isCrawlable = !isCrawlable; 353 | }); 354 | }, 355 | ) 356 | ], 357 | ), 358 | Row( 359 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 360 | children: [ 361 | const Text("Forward query params"), 362 | Switch( 363 | value: forwardQuery, 364 | onChanged: (_) { 365 | setState(() { 366 | forwardQuery = !forwardQuery; 367 | }); 368 | }, 369 | ) 370 | ], 371 | ), 372 | Row( 373 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 374 | children: [ 375 | const Text("Copy to clipboard"), 376 | Switch( 377 | value: copyToClipboard, 378 | onChanged: (_) { 379 | setState(() { 380 | copyToClipboard = !copyToClipboard; 381 | }); 382 | }, 383 | ) 384 | ], 385 | ), 386 | const SizedBox(height: 150) 387 | ], 388 | ), 389 | ) 390 | ) 391 | ], 392 | ), 393 | floatingActionButton: FloatingActionButton( 394 | onPressed: () { 395 | _saveButtonPressed(); 396 | }, 397 | child: isSaving 398 | ? const Padding( 399 | padding: EdgeInsets.all(16), 400 | child: CircularProgressIndicator(strokeWidth: 3, 401 | color: Colors.white)) 402 | : const Icon(Icons.save)), 403 | ); 404 | } 405 | } 406 | -------------------------------------------------------------------------------- /lib/views/tag_selector_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:dynamic_color/dynamic_color.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:shlink_app/API/Classes/ShortURL/visits_summary.dart'; 4 | import 'package:shlink_app/API/Classes/Tag/tag_with_stats.dart'; 5 | import 'package:shlink_app/util/build_api_error_snackbar.dart'; 6 | import 'package:shlink_app/util/string_to_color.dart'; 7 | import '../globals.dart' as globals; 8 | 9 | class TagSelectorView extends StatefulWidget { 10 | const TagSelectorView({super.key, this.alreadySelectedTags = const []}); 11 | 12 | final List alreadySelectedTags; 13 | 14 | @override 15 | State createState() => _TagSelectorViewState(); 16 | } 17 | 18 | class _TagSelectorViewState extends State { 19 | 20 | final FocusNode searchTagFocusNode = FocusNode(); 21 | final searchTagController = TextEditingController(); 22 | 23 | List availableTags = []; 24 | List selectedTags = []; 25 | List filteredTags = []; 26 | 27 | bool tagsLoaded = false; 28 | 29 | @override 30 | void initState() { 31 | super.initState(); 32 | selectedTags = []; 33 | searchTagController.text = ""; 34 | filteredTags = []; 35 | searchTagFocusNode.requestFocus(); 36 | WidgetsBinding.instance.addPostFrameCallback((_) => loadTags()); 37 | } 38 | 39 | @override 40 | void dispose() { 41 | searchTagFocusNode.dispose(); 42 | searchTagController.dispose(); 43 | super.dispose(); 44 | } 45 | 46 | Future loadTags() async { 47 | final response = 48 | await globals.serverManager.getTags(); 49 | response.fold((l) { 50 | 51 | List mappedAlreadySelectedTags = 52 | widget.alreadySelectedTags.map((e) { 53 | return l.firstWhere((t) => t.tag == e, orElse: () { 54 | // account for newly created tags 55 | return TagWithStats(e, 0, VisitsSummary(0,0,0)); 56 | }); 57 | }).toList(); 58 | 59 | setState(() { 60 | availableTags = (l + [... mappedAlreadySelectedTags]).toSet().toList(); 61 | selectedTags = [...mappedAlreadySelectedTags]; 62 | filteredTags = availableTags; 63 | tagsLoaded = true; 64 | }); 65 | 66 | _sortLists(); 67 | return true; 68 | }, (r) { 69 | ScaffoldMessenger.of(context).showSnackBar( 70 | buildApiErrorSnackbar(r, context) 71 | ); 72 | return false; 73 | }); 74 | } 75 | 76 | void _sortLists() { 77 | setState(() { 78 | availableTags.sort((a, b) => a.tag.compareTo(b.tag)); 79 | filteredTags.sort((a, b) => a.tag.compareTo(b.tag)); 80 | }); 81 | } 82 | 83 | void _searchTextChanged(String text) { 84 | if (text == "") { 85 | setState(() { 86 | filteredTags = availableTags; 87 | }); 88 | } else { 89 | setState(() { 90 | filteredTags = availableTags.where((t) => t.tag.toLowerCase() 91 | .contains(text.toLowerCase())).toList(); 92 | }); 93 | } 94 | _sortLists(); 95 | } 96 | 97 | void _addNewTag(String tag) { 98 | bool tagExists = availableTags.where((t) => t.tag == tag).toList().isNotEmpty; 99 | if (tag != "" && !tagExists) { 100 | TagWithStats tagWithStats = TagWithStats(tag, 0, VisitsSummary(0, 0, 0)); 101 | setState(() { 102 | availableTags.add(tagWithStats); 103 | selectedTags.add(tagWithStats); 104 | _searchTextChanged(tag); 105 | }); 106 | _sortLists(); 107 | } 108 | } 109 | 110 | @override 111 | Widget build(BuildContext context) { 112 | return Scaffold( 113 | appBar: AppBar( 114 | title: TextField( 115 | controller: searchTagController, 116 | focusNode: searchTagFocusNode, 117 | onChanged: _searchTextChanged, 118 | decoration: const InputDecoration( 119 | hintText: "Start typing...", 120 | border: InputBorder.none, 121 | icon: Icon(Icons.label_outline), 122 | ), 123 | ), 124 | actions: [ 125 | IconButton( 126 | onPressed: () { 127 | Navigator.pop(context, selectedTags.map((t) => t.tag).toList()); 128 | }, 129 | icon: const Icon(Icons.check), 130 | ) 131 | ], 132 | ), 133 | body: CustomScrollView( 134 | slivers: [ 135 | if (!tagsLoaded) 136 | const SliverToBoxAdapter( 137 | child: Center( 138 | child: Padding( 139 | padding: EdgeInsets.all(16), 140 | child: CircularProgressIndicator(strokeWidth: 3), 141 | ), 142 | ), 143 | ) 144 | else if (tagsLoaded && availableTags.isEmpty) 145 | SliverToBoxAdapter( 146 | child: Center( 147 | child: Padding( 148 | padding: const EdgeInsets.only(top: 50), 149 | child: Column( 150 | children: [ 151 | const Text( 152 | "No Tags", 153 | style: TextStyle( 154 | fontSize: 24, fontWeight: FontWeight.bold), 155 | ), 156 | Padding( 157 | padding: const EdgeInsets.only(top: 8), 158 | child: Text( 159 | 'Start typing to add new tags!', 160 | style: TextStyle( 161 | fontSize: 16, color: Theme.of(context).colorScheme.onSecondary), 162 | ), 163 | ) 164 | ], 165 | )))) 166 | else 167 | SliverList( 168 | delegate: SliverChildBuilderDelegate( 169 | (BuildContext context, int index) { 170 | bool _isSelected = selectedTags.contains(filteredTags[index]); 171 | TagWithStats _tag = filteredTags[index]; 172 | return GestureDetector( 173 | onTap: () { 174 | if (_isSelected) { 175 | setState(() { 176 | selectedTags.remove(_tag); 177 | }); 178 | } else { 179 | setState(() { 180 | selectedTags.add(_tag); 181 | }); 182 | } 183 | }, 184 | child: Container( 185 | padding: const EdgeInsets.only(left: 16, right: 16, 186 | top: 16, bottom: 16), 187 | decoration: BoxDecoration( 188 | color: _isSelected ? Theme.of(context).colorScheme.primary : null, 189 | border: Border( 190 | bottom: BorderSide( 191 | color: Theme.of(context).dividerColor)), 192 | ), 193 | child: Row( 194 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 195 | children: [ 196 | Wrap( 197 | spacing: 10, 198 | crossAxisAlignment: WrapCrossAlignment.center, 199 | children: [ 200 | Container( 201 | width: 30, 202 | height: 30, 203 | decoration: BoxDecoration( 204 | color: stringToColor(_tag.tag) 205 | .harmonizeWith(Theme.of(context).colorScheme.primary), 206 | borderRadius: BorderRadius.circular(15) 207 | ), 208 | 209 | ), 210 | Text(_tag.tag) 211 | ], 212 | ), 213 | Text("${_tag.shortUrlsCount} short URL" 214 | "${_tag.shortUrlsCount == 1 ? "" : "s"}", 215 | style: TextStyle( 216 | color: Theme.of(context).colorScheme.onTertiary, 217 | fontSize: 12 218 | ),) 219 | ], 220 | ) 221 | ) 222 | ); 223 | }, childCount: filteredTags.length 224 | ), 225 | ), 226 | if (searchTagController.text != "" && 227 | !availableTags.contains(searchTagController.text)) 228 | SliverToBoxAdapter( 229 | child: Padding( 230 | padding: const EdgeInsets.only(top: 8, bottom: 8, 231 | left: 16, right: 16), 232 | child: Center( 233 | child: TextButton( 234 | onPressed: () { 235 | _addNewTag(searchTagController.text); 236 | }, 237 | child: Text('Add tag "${searchTagController.text}"'), 238 | ), 239 | ), 240 | ), 241 | ) 242 | ], 243 | ) 244 | ); 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /lib/views/url_detail_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:shlink_app/API/Classes/ShortURL/short_url.dart'; 3 | import 'package:intl/intl.dart'; 4 | import 'package:shlink_app/util/build_api_error_snackbar.dart'; 5 | import 'package:shlink_app/views/redirect_rules_detail_view.dart'; 6 | import 'package:shlink_app/views/short_url_edit_view.dart'; 7 | import 'package:shlink_app/widgets/url_tags_list_widget.dart'; 8 | import 'package:url_launcher/url_launcher.dart'; 9 | import '../globals.dart' as globals; 10 | 11 | class URLDetailView extends StatefulWidget { 12 | const URLDetailView({super.key, required this.shortURL}); 13 | 14 | final ShortURL shortURL; 15 | 16 | @override 17 | State createState() => _URLDetailViewState(); 18 | } 19 | 20 | class _URLDetailViewState extends State { 21 | ShortURL shortURL = ShortURL.empty(); 22 | @override 23 | void initState() { 24 | super.initState(); 25 | setState(() { 26 | shortURL = widget.shortURL; 27 | }); 28 | } 29 | 30 | Future showDeletionConfirmation() { 31 | return showDialog( 32 | context: context, 33 | builder: (BuildContext context) { 34 | return AlertDialog( 35 | title: const Text("Delete Short URL"), 36 | content: SingleChildScrollView( 37 | child: ListBody( 38 | children: [ 39 | const Text("You're about to delete"), 40 | const SizedBox(height: 4), 41 | Text( 42 | shortURL.title ?? shortURL.shortCode, 43 | style: const TextStyle(fontStyle: FontStyle.italic), 44 | ), 45 | const SizedBox(height: 4), 46 | const Text("It'll be gone forever! (a very long time)") 47 | ], 48 | ), 49 | ), 50 | actions: [ 51 | TextButton( 52 | onPressed: () => {Navigator.of(context).pop()}, 53 | child: const Text("Cancel")), 54 | TextButton( 55 | onPressed: () async { 56 | var response = await globals.serverManager 57 | .deleteShortUrl(shortURL.shortCode); 58 | 59 | response.fold((l) { 60 | Navigator.pop(context); 61 | Navigator.pop(context); 62 | 63 | final snackBar = SnackBar( 64 | content: const Text("Short URL deleted!"), 65 | backgroundColor: Colors.green[400], 66 | behavior: SnackBarBehavior.floating); 67 | ScaffoldMessenger.of(context).showSnackBar(snackBar); 68 | return true; 69 | }, (r) { 70 | ScaffoldMessenger.of(context).showSnackBar( 71 | buildApiErrorSnackbar(r, context) 72 | ); 73 | return false; 74 | }); 75 | }, 76 | child: 77 | const Text("Delete", style: TextStyle(color: Colors.red)), 78 | ) 79 | ], 80 | ); 81 | }); 82 | } 83 | 84 | @override 85 | Widget build(BuildContext context) { 86 | return Scaffold( 87 | body: CustomScrollView( 88 | slivers: [ 89 | SliverAppBar.medium( 90 | title: Text(shortURL.title ?? shortURL.shortCode, 91 | style: const TextStyle(fontWeight: FontWeight.bold)), 92 | actions: [ 93 | IconButton( 94 | onPressed: () async { 95 | ShortURL updatedUrl = await Navigator.of(context).push( 96 | MaterialPageRoute( 97 | builder: (context) => 98 | ShortURLEditView(shortUrl: shortURL))); 99 | setState(() { 100 | shortURL = updatedUrl; 101 | }); 102 | }, 103 | icon: const Icon(Icons.edit)), 104 | IconButton( 105 | onPressed: () { 106 | showDeletionConfirmation(); 107 | }, 108 | icon: const Icon( 109 | Icons.delete, 110 | color: Colors.red, 111 | )) 112 | ], 113 | ), 114 | SliverToBoxAdapter( 115 | child: Padding( 116 | padding: const EdgeInsets.only(left: 16.0, right: 16.0), 117 | child: UrlTagsListWidget(tags: shortURL.tags)), 118 | ), 119 | _ListCell(title: "Short Code", content: shortURL.shortCode), 120 | _ListCell( 121 | title: "Short URL", content: shortURL.shortUrl, isUrl: true), 122 | _ListCell(title: "Long URL", content: shortURL.longUrl, isUrl: true), 123 | _ListCell(title: "Creation Date", content: shortURL.dateCreated), 124 | _ListCell( 125 | title: "Redirect Rules", 126 | content: null, 127 | clickableDetailView: RedirectRulesDetailView(shortURL: shortURL)), 128 | const _ListCell(title: "Visits", content: ""), 129 | _ListCell( 130 | title: "Total", content: shortURL.visitsSummary.total, sub: true), 131 | _ListCell( 132 | title: "Non-Bots", 133 | content: shortURL.visitsSummary.nonBots, 134 | sub: true), 135 | _ListCell( 136 | title: "Bots", content: shortURL.visitsSummary.bots, sub: true), 137 | const _ListCell(title: "Meta", content: ""), 138 | _ListCell( 139 | title: "Valid Since", 140 | content: shortURL.meta.validSince, 141 | sub: true), 142 | _ListCell( 143 | title: "Valid Until", 144 | content: shortURL.meta.validUntil, 145 | sub: true), 146 | _ListCell( 147 | title: "Max Visits", content: shortURL.meta.maxVisits, sub: true), 148 | _ListCell(title: "Domain", content: shortURL.domain), 149 | _ListCell(title: "Crawlable", content: shortURL.crawlable, last: true) 150 | ], 151 | ), 152 | ); 153 | } 154 | } 155 | 156 | class _ListCell extends StatefulWidget { 157 | const _ListCell( 158 | {required this.title, 159 | required this.content, 160 | this.sub = false, 161 | this.last = false, 162 | this.isUrl = false, 163 | this.clickableDetailView}); 164 | 165 | final String title; 166 | final dynamic content; 167 | final bool sub; 168 | final bool last; 169 | final bool isUrl; 170 | final Widget? clickableDetailView; 171 | 172 | @override 173 | State<_ListCell> createState() => _ListCellState(); 174 | } 175 | 176 | class _ListCellState extends State<_ListCell> { 177 | @override 178 | Widget build(BuildContext context) { 179 | return SliverToBoxAdapter( 180 | child: Padding( 181 | padding: EdgeInsets.only(top: 16, bottom: widget.last ? 30 : 0), 182 | child: GestureDetector( 183 | onTap: () async { 184 | if (widget.clickableDetailView != null) { 185 | Navigator.of(context).push(MaterialPageRoute( 186 | builder: (context) => widget.clickableDetailView!)); 187 | } else if (widget.content is String) { 188 | Uri? parsedUrl = Uri.tryParse(widget.content); 189 | if (widget.isUrl && 190 | parsedUrl != null && 191 | await canLaunchUrl(parsedUrl)) { 192 | launchUrl(parsedUrl); 193 | } 194 | } 195 | }, 196 | child: Container( 197 | padding: const EdgeInsets.only(top: 16, left: 8, right: 8), 198 | decoration: BoxDecoration( 199 | border: Border( 200 | top: BorderSide( 201 | color: Theme.of(context).dividerColor)), 202 | ), 203 | child: Row( 204 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 205 | children: [ 206 | Row( 207 | children: [ 208 | if (widget.sub) 209 | Padding( 210 | padding: const EdgeInsets.only(right: 4), 211 | child: SizedBox( 212 | width: 20, 213 | height: 6, 214 | child: Container( 215 | decoration: BoxDecoration( 216 | borderRadius: BorderRadius.circular(8), 217 | color: Theme.of(context).colorScheme.outline, 218 | ), 219 | ), 220 | ), 221 | ), 222 | Text( 223 | widget.title, 224 | style: const TextStyle(fontWeight: FontWeight.bold), 225 | ) 226 | ], 227 | ), 228 | if (widget.content is bool) 229 | Icon(widget.content ? Icons.check : Icons.close, 230 | color: widget.content ? Colors.green : Colors.red) 231 | else if (widget.content is int) 232 | Text(widget.content.toString()) 233 | else if (widget.content is String) 234 | Expanded( 235 | child: Text( 236 | widget.content, 237 | textAlign: TextAlign.end, 238 | overflow: TextOverflow.ellipsis, 239 | maxLines: 1, 240 | ), 241 | ) 242 | else if (widget.content is DateTime) 243 | Text(DateFormat('yyyy-MM-dd - HH:mm') 244 | .format(widget.content)) 245 | else if (widget.clickableDetailView != null) 246 | const Icon(Icons.chevron_right) 247 | else 248 | const Text("N/A") 249 | ], 250 | ), 251 | ), 252 | ))); 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /lib/views/url_list_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:qr_flutter/qr_flutter.dart'; 3 | import 'package:shlink_app/API/Classes/ShortURL/short_url.dart'; 4 | import 'package:shlink_app/util/build_api_error_snackbar.dart'; 5 | import 'package:shlink_app/views/short_url_edit_view.dart'; 6 | import 'package:shlink_app/views/url_detail_view.dart'; 7 | import 'package:shlink_app/widgets/url_tags_list_widget.dart'; 8 | import '../globals.dart' as globals; 9 | import 'package:flutter/services.dart'; 10 | 11 | class URLListView extends StatefulWidget { 12 | const URLListView({super.key}); 13 | 14 | @override 15 | State createState() => _URLListViewState(); 16 | } 17 | 18 | class _URLListViewState extends State { 19 | List shortUrls = []; 20 | bool _qrCodeShown = false; 21 | String _qrUrl = ""; 22 | 23 | bool shortUrlsLoaded = false; 24 | 25 | @override 26 | void initState() { 27 | // TODO: implement initState 28 | super.initState(); 29 | WidgetsBinding.instance.addPostFrameCallback((_) => loadAllShortUrls()); 30 | } 31 | 32 | Future loadAllShortUrls() async { 33 | final response = await globals.serverManager.getShortUrls(); 34 | response.fold((l) { 35 | setState(() { 36 | shortUrls = l; 37 | shortUrlsLoaded = true; 38 | }); 39 | return true; 40 | }, (r) { 41 | ScaffoldMessenger.of(context).showSnackBar( 42 | buildApiErrorSnackbar(r, context) 43 | ); 44 | return false; 45 | }); 46 | } 47 | 48 | @override 49 | Widget build(BuildContext context) { 50 | return Scaffold( 51 | floatingActionButton: FloatingActionButton( 52 | onPressed: () async { 53 | await Navigator.of(context).push(MaterialPageRoute( 54 | builder: (context) => const ShortURLEditView())); 55 | loadAllShortUrls(); 56 | }, 57 | child: const Icon(Icons.add), 58 | ), 59 | body: Stack( 60 | children: [ 61 | ColorFiltered( 62 | colorFilter: ColorFilter.mode( 63 | Colors.black.withOpacity(_qrCodeShown ? 0.4 : 0), 64 | BlendMode.srcOver), 65 | child: RefreshIndicator( 66 | onRefresh: () async { 67 | return loadAllShortUrls(); 68 | }, 69 | child: CustomScrollView( 70 | slivers: [ 71 | const SliverAppBar.medium( 72 | title: Text("Short URLs", 73 | style: TextStyle(fontWeight: FontWeight.bold))), 74 | if (shortUrlsLoaded && shortUrls.isEmpty) 75 | SliverToBoxAdapter( 76 | child: Center( 77 | child: Padding( 78 | padding: const EdgeInsets.only(top: 50), 79 | child: Column( 80 | children: [ 81 | const Text( 82 | "No Short URLs", 83 | style: TextStyle( 84 | fontSize: 24, 85 | fontWeight: FontWeight.bold), 86 | ), 87 | Padding( 88 | padding: const EdgeInsets.only(top: 8), 89 | child: Text( 90 | 'Create one by tapping the "+" button below', 91 | style: TextStyle( 92 | fontSize: 16, 93 | color: Theme.of(context).colorScheme.onSecondary), 94 | ), 95 | ) 96 | ], 97 | )))) 98 | else 99 | SliverList( 100 | delegate: SliverChildBuilderDelegate( 101 | (BuildContext context, int index) { 102 | final shortURL = shortUrls[index]; 103 | return ShortURLCell( 104 | shortURL: shortURL, 105 | reload: () { 106 | loadAllShortUrls(); 107 | }, 108 | showQRCode: (String url) { 109 | setState(() { 110 | _qrUrl = url; 111 | _qrCodeShown = true; 112 | }); 113 | }, 114 | isLast: index == shortUrls.length - 1); 115 | }, childCount: shortUrls.length)) 116 | ], 117 | ), 118 | ), 119 | ), 120 | if (_qrCodeShown) 121 | GestureDetector( 122 | onTap: () { 123 | setState(() { 124 | _qrCodeShown = false; 125 | }); 126 | }, 127 | child: Container( 128 | color: Colors.black.withOpacity(0), 129 | ), 130 | ), 131 | if (_qrCodeShown) 132 | Center( 133 | child: SizedBox( 134 | width: MediaQuery.of(context).size.width / 1.7, 135 | height: MediaQuery.of(context).size.width / 1.7, 136 | child: Card( 137 | child: Padding( 138 | padding: const EdgeInsets.all(16), 139 | child: QrImageView( 140 | data: _qrUrl, 141 | size: 200.0, 142 | eyeStyle: QrEyeStyle( 143 | eyeShape: QrEyeShape.square, 144 | color: 145 | Theme.of(context).colorScheme.onPrimary, 146 | ), 147 | dataModuleStyle: QrDataModuleStyle( 148 | dataModuleShape: QrDataModuleShape.square, 149 | color: Theme.of(context).colorScheme.onPrimary, 150 | ), 151 | ))), 152 | ), 153 | ) 154 | ], 155 | )); 156 | } 157 | } 158 | 159 | class ShortURLCell extends StatefulWidget { 160 | const ShortURLCell( 161 | {super.key, 162 | required this.shortURL, 163 | required this.reload, 164 | required this.showQRCode, 165 | required this.isLast}); 166 | 167 | final ShortURL shortURL; 168 | final Function() reload; 169 | final Function(String url) showQRCode; 170 | final bool isLast; 171 | 172 | @override 173 | State createState() => _ShortURLCellState(); 174 | } 175 | 176 | class _ShortURLCellState extends State { 177 | @override 178 | Widget build(BuildContext context) { 179 | return GestureDetector( 180 | onTap: () async { 181 | await Navigator.of(context) 182 | .push(MaterialPageRoute( 183 | builder: (context) => 184 | URLDetailView(shortURL: widget.shortURL))) 185 | .then((a) => {widget.reload()}); 186 | }, 187 | child: Padding( 188 | padding: EdgeInsets.only( 189 | left: 8, right: 8, bottom: widget.isLast ? 90 : 0), 190 | child: Container( 191 | padding: 192 | const EdgeInsets.only(left: 8, right: 8, bottom: 16, top: 16), 193 | decoration: BoxDecoration( 194 | border: Border( 195 | bottom: BorderSide( 196 | color: Theme.of(context).dividerColor)), 197 | ), 198 | child: Row( 199 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 200 | children: [ 201 | Expanded( 202 | child: Column( 203 | crossAxisAlignment: CrossAxisAlignment.start, 204 | children: [ 205 | Text( 206 | widget.shortURL.title ?? widget.shortURL.shortCode, 207 | textScaleFactor: 1.4, 208 | style: const TextStyle(fontWeight: FontWeight.bold), 209 | ), 210 | Text( 211 | widget.shortURL.longUrl, 212 | maxLines: 1, 213 | overflow: TextOverflow.ellipsis, 214 | textScaleFactor: 0.9, 215 | style: TextStyle(color: Theme.of(context).colorScheme.onTertiary), 216 | ), 217 | // List tags in a row 218 | UrlTagsListWidget(tags: widget.shortURL.tags) 219 | ], 220 | ), 221 | ), 222 | IconButton( 223 | onPressed: () async { 224 | await Clipboard.setData( 225 | ClipboardData(text: widget.shortURL.shortUrl)); 226 | final snackBar = SnackBar( 227 | content: const Text("Copied to clipboard!"), 228 | behavior: SnackBarBehavior.floating, 229 | backgroundColor: Colors.green[400]); 230 | ScaffoldMessenger.of(context).showSnackBar(snackBar); 231 | }, 232 | icon: const Icon(Icons.copy)), 233 | IconButton( 234 | onPressed: () { 235 | widget.showQRCode(widget.shortURL.shortUrl); 236 | }, 237 | icon: const Icon(Icons.qr_code)) 238 | ], 239 | )), 240 | )); 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /lib/widgets/available_servers_bottom_sheet.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:shared_preferences/shared_preferences.dart'; 3 | import 'package:shlink_app/main.dart'; 4 | import 'package:shlink_app/views/login_view.dart'; 5 | import '../globals.dart' as globals; 6 | 7 | class AvailableServerBottomSheet extends StatefulWidget { 8 | const AvailableServerBottomSheet({super.key}); 9 | 10 | @override 11 | State createState() => 12 | _AvailableServerBottomSheetState(); 13 | } 14 | 15 | class _AvailableServerBottomSheetState 16 | extends State { 17 | List availableServers = []; 18 | 19 | @override 20 | void initState() { 21 | super.initState(); 22 | _loadServers(); 23 | } 24 | 25 | Future _loadServers() async { 26 | List savedServers = 27 | await globals.serverManager.getAvailableServers(); 28 | setState(() { 29 | availableServers = savedServers; 30 | }); 31 | } 32 | 33 | @override 34 | Widget build(BuildContext context) { 35 | return CustomScrollView( 36 | slivers: [ 37 | const SliverAppBar.medium( 38 | expandedHeight: 120, 39 | automaticallyImplyLeading: false, 40 | title: Text( 41 | "Available Servers", 42 | style: TextStyle(fontWeight: FontWeight.bold), 43 | ), 44 | ), 45 | SliverList( 46 | delegate: 47 | SliverChildBuilderDelegate((BuildContext context, int index) { 48 | return GestureDetector( 49 | onTap: () async { 50 | final prefs = await SharedPreferences.getInstance(); 51 | prefs.setString("lastusedserver", availableServers[index]); 52 | await Navigator.of(context).pushAndRemoveUntil( 53 | MaterialPageRoute(builder: (context) => const InitialPage()), 54 | (Route route) => false); 55 | }, 56 | child: Padding( 57 | padding: const EdgeInsets.only(left: 8, right: 8), 58 | child: Container( 59 | padding: 60 | const EdgeInsets.only(left: 8, right: 8, top: 16, bottom: 16), 61 | decoration: BoxDecoration( 62 | border: Border( 63 | bottom: BorderSide( 64 | color: 65 | MediaQuery.of(context).platformBrightness == 66 | Brightness.dark 67 | ? Colors.grey[800]! 68 | : Colors.grey[300]!)), 69 | ), 70 | child: Row( 71 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 72 | children: [ 73 | Wrap( 74 | spacing: 8, 75 | crossAxisAlignment: WrapCrossAlignment.center, 76 | children: [ 77 | const Icon(Icons.dns_outlined), 78 | Text(availableServers[index]) 79 | ], 80 | ), 81 | Wrap( 82 | crossAxisAlignment: WrapCrossAlignment.center, 83 | children: [ 84 | if (availableServers[index] == 85 | globals.serverManager.serverUrl) 86 | Container( 87 | width: 8, 88 | height: 8, 89 | decoration: BoxDecoration( 90 | color: Colors.green, 91 | borderRadius: BorderRadius.circular(4)), 92 | ), 93 | IconButton( 94 | onPressed: () async { 95 | globals.serverManager 96 | .logOut(availableServers[index]); 97 | if (availableServers[index] == 98 | globals.serverManager.serverUrl) { 99 | await Navigator.of(context) 100 | .pushAndRemoveUntil( 101 | MaterialPageRoute( 102 | builder: (context) => 103 | const InitialPage()), 104 | (Route route) => false); 105 | } else { 106 | Navigator.pop(context); 107 | } 108 | }, 109 | icon: const Icon(Icons.logout, color: Colors.red), 110 | ) 111 | ], 112 | ) 113 | ], 114 | ))), 115 | ); 116 | }, childCount: availableServers.length)), 117 | SliverToBoxAdapter( 118 | child: Padding( 119 | padding: const EdgeInsets.only(top: 8), 120 | child: Center( 121 | child: ElevatedButton( 122 | onPressed: () async { 123 | await Navigator.of(context) 124 | .push(MaterialPageRoute(builder: (context) => const LoginView())); 125 | }, 126 | child: const Text("Add server..."), 127 | ), 128 | ), 129 | )) 130 | ], 131 | ); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /lib/widgets/url_tags_list_widget.dart: -------------------------------------------------------------------------------- 1 | import 'package:dynamic_color/dynamic_color.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:shlink_app/util/string_to_color.dart'; 4 | 5 | class UrlTagsListWidget extends StatefulWidget { 6 | const UrlTagsListWidget({super.key, required this.tags}); 7 | 8 | final List tags; 9 | 10 | @override 11 | State createState() => _UrlTagsListWidgetState(); 12 | } 13 | 14 | class _UrlTagsListWidgetState extends State { 15 | @override 16 | Widget build(BuildContext context) { 17 | return Wrap( 18 | children: widget.tags.map((tag) { 19 | var boxColor = stringToColor(tag) 20 | .harmonizeWith(Theme.of(context).colorScheme.primary); 21 | return Padding( 22 | padding: const EdgeInsets.only(right: 4, top: 4), 23 | child: Container( 24 | padding: 25 | const EdgeInsets.only(top: 4, bottom: 4, left: 12, right: 12), 26 | decoration: BoxDecoration( 27 | borderRadius: BorderRadius.circular(4), 28 | color: boxColor, 29 | ), 30 | child: Text( 31 | tag, 32 | style: TextStyle( 33 | color: boxColor.computeLuminance() < 0.5 34 | ? Colors.white 35 | : Colors.black), 36 | ), 37 | ), 38 | ); 39 | }).toList()); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /pubspec.lock: -------------------------------------------------------------------------------- 1 | # Generated by pub 2 | # See https://dart.dev/tools/pub/glossary#lockfile 3 | packages: 4 | archive: 5 | dependency: transitive 6 | description: 7 | name: archive 8 | sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d 9 | url: "https://pub.dev" 10 | source: hosted 11 | version: "3.6.1" 12 | args: 13 | dependency: transitive 14 | description: 15 | name: args 16 | sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" 17 | url: "https://pub.dev" 18 | source: hosted 19 | version: "2.5.0" 20 | async: 21 | dependency: transitive 22 | description: 23 | name: async 24 | sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" 25 | url: "https://pub.dev" 26 | source: hosted 27 | version: "2.11.0" 28 | boolean_selector: 29 | dependency: transitive 30 | description: 31 | name: boolean_selector 32 | sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" 33 | url: "https://pub.dev" 34 | source: hosted 35 | version: "2.1.1" 36 | characters: 37 | dependency: transitive 38 | description: 39 | name: characters 40 | sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" 41 | url: "https://pub.dev" 42 | source: hosted 43 | version: "1.3.0" 44 | checked_yaml: 45 | dependency: transitive 46 | description: 47 | name: checked_yaml 48 | sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff 49 | url: "https://pub.dev" 50 | source: hosted 51 | version: "2.0.3" 52 | cli_util: 53 | dependency: transitive 54 | description: 55 | name: cli_util 56 | sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 57 | url: "https://pub.dev" 58 | source: hosted 59 | version: "0.4.1" 60 | clock: 61 | dependency: transitive 62 | description: 63 | name: clock 64 | sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf 65 | url: "https://pub.dev" 66 | source: hosted 67 | version: "1.1.1" 68 | collection: 69 | dependency: transitive 70 | description: 71 | name: collection 72 | sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a 73 | url: "https://pub.dev" 74 | source: hosted 75 | version: "1.18.0" 76 | crypto: 77 | dependency: transitive 78 | description: 79 | name: crypto 80 | sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab 81 | url: "https://pub.dev" 82 | source: hosted 83 | version: "3.0.3" 84 | cupertino_icons: 85 | dependency: "direct main" 86 | description: 87 | name: cupertino_icons 88 | sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 89 | url: "https://pub.dev" 90 | source: hosted 91 | version: "1.0.8" 92 | dartz: 93 | dependency: "direct main" 94 | description: 95 | name: dartz 96 | sha256: e6acf34ad2e31b1eb00948692468c30ab48ac8250e0f0df661e29f12dd252168 97 | url: "https://pub.dev" 98 | source: hosted 99 | version: "0.10.1" 100 | dynamic_color: 101 | dependency: "direct main" 102 | description: 103 | name: dynamic_color 104 | sha256: eae98052fa6e2826bdac3dd2e921c6ce2903be15c6b7f8b6d8a5d49b5086298d 105 | url: "https://pub.dev" 106 | source: hosted 107 | version: "1.7.0" 108 | fake_async: 109 | dependency: transitive 110 | description: 111 | name: fake_async 112 | sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" 113 | url: "https://pub.dev" 114 | source: hosted 115 | version: "1.3.1" 116 | ffi: 117 | dependency: transitive 118 | description: 119 | name: ffi 120 | sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" 121 | url: "https://pub.dev" 122 | source: hosted 123 | version: "2.1.2" 124 | file: 125 | dependency: transitive 126 | description: 127 | name: file 128 | sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" 129 | url: "https://pub.dev" 130 | source: hosted 131 | version: "7.0.0" 132 | flutter: 133 | dependency: "direct main" 134 | description: flutter 135 | source: sdk 136 | version: "0.0.0" 137 | flutter_launcher_icons: 138 | dependency: "direct dev" 139 | description: 140 | name: flutter_launcher_icons 141 | sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" 142 | url: "https://pub.dev" 143 | source: hosted 144 | version: "0.13.1" 145 | flutter_lints: 146 | dependency: "direct dev" 147 | description: 148 | name: flutter_lints 149 | sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" 150 | url: "https://pub.dev" 151 | source: hosted 152 | version: "4.0.0" 153 | flutter_process_text: 154 | dependency: "direct main" 155 | description: 156 | name: flutter_process_text 157 | sha256: "75cdff9d255ce892c766370824e28bbb95a7f93e99b6c4109ca694a6ef4d5681" 158 | url: "https://pub.dev" 159 | source: hosted 160 | version: "1.1.2" 161 | flutter_secure_storage: 162 | dependency: "direct main" 163 | description: 164 | name: flutter_secure_storage 165 | sha256: "165164745e6afb5c0e3e3fcc72a012fb9e58496fb26ffb92cf22e16a821e85d0" 166 | url: "https://pub.dev" 167 | source: hosted 168 | version: "9.2.2" 169 | flutter_secure_storage_linux: 170 | dependency: transitive 171 | description: 172 | name: flutter_secure_storage_linux 173 | sha256: "4d91bfc23047422cbcd73ac684bc169859ee766482517c22172c86596bf1464b" 174 | url: "https://pub.dev" 175 | source: hosted 176 | version: "1.2.1" 177 | flutter_secure_storage_macos: 178 | dependency: transitive 179 | description: 180 | name: flutter_secure_storage_macos 181 | sha256: "1693ab11121a5f925bbea0be725abfcfbbcf36c1e29e571f84a0c0f436147a81" 182 | url: "https://pub.dev" 183 | source: hosted 184 | version: "3.1.2" 185 | flutter_secure_storage_platform_interface: 186 | dependency: transitive 187 | description: 188 | name: flutter_secure_storage_platform_interface 189 | sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 190 | url: "https://pub.dev" 191 | source: hosted 192 | version: "1.1.2" 193 | flutter_secure_storage_web: 194 | dependency: transitive 195 | description: 196 | name: flutter_secure_storage_web 197 | sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 198 | url: "https://pub.dev" 199 | source: hosted 200 | version: "1.2.1" 201 | flutter_secure_storage_windows: 202 | dependency: transitive 203 | description: 204 | name: flutter_secure_storage_windows 205 | sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 206 | url: "https://pub.dev" 207 | source: hosted 208 | version: "3.1.2" 209 | flutter_sharing_intent: 210 | dependency: "direct main" 211 | description: 212 | name: flutter_sharing_intent 213 | sha256: "785ffc391822641457f930eb477c91c2f598a888f50b8fbb40d481ee01c7e719" 214 | url: "https://pub.dev" 215 | source: hosted 216 | version: "1.1.1" 217 | flutter_test: 218 | dependency: "direct dev" 219 | description: flutter 220 | source: sdk 221 | version: "0.0.0" 222 | flutter_web_plugins: 223 | dependency: transitive 224 | description: flutter 225 | source: sdk 226 | version: "0.0.0" 227 | http: 228 | dependency: "direct main" 229 | description: 230 | name: http 231 | sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 232 | url: "https://pub.dev" 233 | source: hosted 234 | version: "1.2.2" 235 | http_parser: 236 | dependency: transitive 237 | description: 238 | name: http_parser 239 | sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" 240 | url: "https://pub.dev" 241 | source: hosted 242 | version: "4.0.2" 243 | image: 244 | dependency: transitive 245 | description: 246 | name: image 247 | sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8" 248 | url: "https://pub.dev" 249 | source: hosted 250 | version: "4.2.0" 251 | intl: 252 | dependency: "direct main" 253 | description: 254 | name: intl 255 | sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf 256 | url: "https://pub.dev" 257 | source: hosted 258 | version: "0.19.0" 259 | js: 260 | dependency: transitive 261 | description: 262 | name: js 263 | sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 264 | url: "https://pub.dev" 265 | source: hosted 266 | version: "0.6.7" 267 | json_annotation: 268 | dependency: transitive 269 | description: 270 | name: json_annotation 271 | sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" 272 | url: "https://pub.dev" 273 | source: hosted 274 | version: "4.9.0" 275 | leak_tracker: 276 | dependency: transitive 277 | description: 278 | name: leak_tracker 279 | sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" 280 | url: "https://pub.dev" 281 | source: hosted 282 | version: "10.0.4" 283 | leak_tracker_flutter_testing: 284 | dependency: transitive 285 | description: 286 | name: leak_tracker_flutter_testing 287 | sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" 288 | url: "https://pub.dev" 289 | source: hosted 290 | version: "3.0.3" 291 | leak_tracker_testing: 292 | dependency: transitive 293 | description: 294 | name: leak_tracker_testing 295 | sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" 296 | url: "https://pub.dev" 297 | source: hosted 298 | version: "3.0.1" 299 | license_generator: 300 | dependency: "direct dev" 301 | description: 302 | name: license_generator 303 | sha256: "0b111c03cbccfa36a68a8738e3b2a54392a269673b5258d5fc6a83302d675a9e" 304 | url: "https://pub.dev" 305 | source: hosted 306 | version: "2.0.0" 307 | lints: 308 | dependency: transitive 309 | description: 310 | name: lints 311 | sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" 312 | url: "https://pub.dev" 313 | source: hosted 314 | version: "4.0.0" 315 | matcher: 316 | dependency: transitive 317 | description: 318 | name: matcher 319 | sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb 320 | url: "https://pub.dev" 321 | source: hosted 322 | version: "0.12.16+1" 323 | material_color_utilities: 324 | dependency: transitive 325 | description: 326 | name: material_color_utilities 327 | sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" 328 | url: "https://pub.dev" 329 | source: hosted 330 | version: "0.8.0" 331 | meta: 332 | dependency: transitive 333 | description: 334 | name: meta 335 | sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" 336 | url: "https://pub.dev" 337 | source: hosted 338 | version: "1.12.0" 339 | package_info_plus: 340 | dependency: "direct main" 341 | description: 342 | name: package_info_plus 343 | sha256: b93d8b4d624b4ea19b0a5a208b2d6eff06004bc3ce74c06040b120eeadd00ce0 344 | url: "https://pub.dev" 345 | source: hosted 346 | version: "8.0.0" 347 | package_info_plus_platform_interface: 348 | dependency: transitive 349 | description: 350 | name: package_info_plus_platform_interface 351 | sha256: f49918f3433a3146047372f9d4f1f847511f2acd5cd030e1f44fe5a50036b70e 352 | url: "https://pub.dev" 353 | source: hosted 354 | version: "3.0.0" 355 | path: 356 | dependency: transitive 357 | description: 358 | name: path 359 | sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" 360 | url: "https://pub.dev" 361 | source: hosted 362 | version: "1.9.0" 363 | path_provider: 364 | dependency: transitive 365 | description: 366 | name: path_provider 367 | sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 368 | url: "https://pub.dev" 369 | source: hosted 370 | version: "2.1.3" 371 | path_provider_android: 372 | dependency: transitive 373 | description: 374 | name: path_provider_android 375 | sha256: "30c5aa827a6ae95ce2853cdc5fe3971daaac00f6f081c419c013f7f57bff2f5e" 376 | url: "https://pub.dev" 377 | source: hosted 378 | version: "2.2.7" 379 | path_provider_foundation: 380 | dependency: transitive 381 | description: 382 | name: path_provider_foundation 383 | sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 384 | url: "https://pub.dev" 385 | source: hosted 386 | version: "2.4.0" 387 | path_provider_linux: 388 | dependency: transitive 389 | description: 390 | name: path_provider_linux 391 | sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 392 | url: "https://pub.dev" 393 | source: hosted 394 | version: "2.2.1" 395 | path_provider_platform_interface: 396 | dependency: transitive 397 | description: 398 | name: path_provider_platform_interface 399 | sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" 400 | url: "https://pub.dev" 401 | source: hosted 402 | version: "2.1.2" 403 | path_provider_windows: 404 | dependency: transitive 405 | description: 406 | name: path_provider_windows 407 | sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 408 | url: "https://pub.dev" 409 | source: hosted 410 | version: "2.3.0" 411 | petitparser: 412 | dependency: transitive 413 | description: 414 | name: petitparser 415 | sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 416 | url: "https://pub.dev" 417 | source: hosted 418 | version: "6.0.2" 419 | platform: 420 | dependency: transitive 421 | description: 422 | name: platform 423 | sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" 424 | url: "https://pub.dev" 425 | source: hosted 426 | version: "3.1.5" 427 | plugin_platform_interface: 428 | dependency: transitive 429 | description: 430 | name: plugin_platform_interface 431 | sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" 432 | url: "https://pub.dev" 433 | source: hosted 434 | version: "2.1.8" 435 | qr: 436 | dependency: transitive 437 | description: 438 | name: qr 439 | sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" 440 | url: "https://pub.dev" 441 | source: hosted 442 | version: "3.0.2" 443 | qr_flutter: 444 | dependency: "direct main" 445 | description: 446 | name: qr_flutter 447 | sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" 448 | url: "https://pub.dev" 449 | source: hosted 450 | version: "4.1.0" 451 | shared_preferences: 452 | dependency: "direct main" 453 | description: 454 | name: shared_preferences 455 | sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180 456 | url: "https://pub.dev" 457 | source: hosted 458 | version: "2.2.3" 459 | shared_preferences_android: 460 | dependency: transitive 461 | description: 462 | name: shared_preferences_android 463 | sha256: "93d0ec9dd902d85f326068e6a899487d1f65ffcd5798721a95330b26c8131577" 464 | url: "https://pub.dev" 465 | source: hosted 466 | version: "2.2.3" 467 | shared_preferences_foundation: 468 | dependency: transitive 469 | description: 470 | name: shared_preferences_foundation 471 | sha256: "0a8a893bf4fd1152f93fec03a415d11c27c74454d96e2318a7ac38dd18683ab7" 472 | url: "https://pub.dev" 473 | source: hosted 474 | version: "2.4.0" 475 | shared_preferences_linux: 476 | dependency: transitive 477 | description: 478 | name: shared_preferences_linux 479 | sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" 480 | url: "https://pub.dev" 481 | source: hosted 482 | version: "2.3.2" 483 | shared_preferences_platform_interface: 484 | dependency: transitive 485 | description: 486 | name: shared_preferences_platform_interface 487 | sha256: "034650b71e73629ca08a0bd789fd1d83cc63c2d1e405946f7cef7bc37432f93a" 488 | url: "https://pub.dev" 489 | source: hosted 490 | version: "2.4.0" 491 | shared_preferences_web: 492 | dependency: transitive 493 | description: 494 | name: shared_preferences_web 495 | sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a" 496 | url: "https://pub.dev" 497 | source: hosted 498 | version: "2.3.0" 499 | shared_preferences_windows: 500 | dependency: transitive 501 | description: 502 | name: shared_preferences_windows 503 | sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" 504 | url: "https://pub.dev" 505 | source: hosted 506 | version: "2.3.2" 507 | sky_engine: 508 | dependency: transitive 509 | description: flutter 510 | source: sdk 511 | version: "0.0.99" 512 | source_span: 513 | dependency: transitive 514 | description: 515 | name: source_span 516 | sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" 517 | url: "https://pub.dev" 518 | source: hosted 519 | version: "1.10.0" 520 | stack_trace: 521 | dependency: transitive 522 | description: 523 | name: stack_trace 524 | sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" 525 | url: "https://pub.dev" 526 | source: hosted 527 | version: "1.11.1" 528 | stream_channel: 529 | dependency: transitive 530 | description: 531 | name: stream_channel 532 | sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 533 | url: "https://pub.dev" 534 | source: hosted 535 | version: "2.1.2" 536 | string_scanner: 537 | dependency: transitive 538 | description: 539 | name: string_scanner 540 | sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" 541 | url: "https://pub.dev" 542 | source: hosted 543 | version: "1.2.0" 544 | term_glyph: 545 | dependency: transitive 546 | description: 547 | name: term_glyph 548 | sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 549 | url: "https://pub.dev" 550 | source: hosted 551 | version: "1.2.1" 552 | test_api: 553 | dependency: transitive 554 | description: 555 | name: test_api 556 | sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" 557 | url: "https://pub.dev" 558 | source: hosted 559 | version: "0.7.0" 560 | tuple: 561 | dependency: "direct main" 562 | description: 563 | name: tuple 564 | sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 565 | url: "https://pub.dev" 566 | source: hosted 567 | version: "2.0.2" 568 | typed_data: 569 | dependency: transitive 570 | description: 571 | name: typed_data 572 | sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c 573 | url: "https://pub.dev" 574 | source: hosted 575 | version: "1.3.2" 576 | url_launcher: 577 | dependency: "direct main" 578 | description: 579 | name: url_launcher 580 | sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3" 581 | url: "https://pub.dev" 582 | source: hosted 583 | version: "6.3.0" 584 | url_launcher_android: 585 | dependency: transitive 586 | description: 587 | name: url_launcher_android 588 | sha256: "95d8027db36a0e52caf55680f91e33ea6aa12a3ce608c90b06f4e429a21067ac" 589 | url: "https://pub.dev" 590 | source: hosted 591 | version: "6.3.5" 592 | url_launcher_ios: 593 | dependency: transitive 594 | description: 595 | name: url_launcher_ios 596 | sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e 597 | url: "https://pub.dev" 598 | source: hosted 599 | version: "6.3.1" 600 | url_launcher_linux: 601 | dependency: transitive 602 | description: 603 | name: url_launcher_linux 604 | sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 605 | url: "https://pub.dev" 606 | source: hosted 607 | version: "3.1.1" 608 | url_launcher_macos: 609 | dependency: transitive 610 | description: 611 | name: url_launcher_macos 612 | sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de" 613 | url: "https://pub.dev" 614 | source: hosted 615 | version: "3.2.0" 616 | url_launcher_platform_interface: 617 | dependency: transitive 618 | description: 619 | name: url_launcher_platform_interface 620 | sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" 621 | url: "https://pub.dev" 622 | source: hosted 623 | version: "2.3.2" 624 | url_launcher_web: 625 | dependency: transitive 626 | description: 627 | name: url_launcher_web 628 | sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a" 629 | url: "https://pub.dev" 630 | source: hosted 631 | version: "2.3.1" 632 | url_launcher_windows: 633 | dependency: transitive 634 | description: 635 | name: url_launcher_windows 636 | sha256: "49c10f879746271804767cb45551ec5592cdab00ee105c06dddde1a98f73b185" 637 | url: "https://pub.dev" 638 | source: hosted 639 | version: "3.1.2" 640 | vector_math: 641 | dependency: transitive 642 | description: 643 | name: vector_math 644 | sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" 645 | url: "https://pub.dev" 646 | source: hosted 647 | version: "2.1.4" 648 | vm_service: 649 | dependency: transitive 650 | description: 651 | name: vm_service 652 | sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" 653 | url: "https://pub.dev" 654 | source: hosted 655 | version: "14.2.1" 656 | web: 657 | dependency: transitive 658 | description: 659 | name: web 660 | sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" 661 | url: "https://pub.dev" 662 | source: hosted 663 | version: "0.5.1" 664 | win32: 665 | dependency: transitive 666 | description: 667 | name: win32 668 | sha256: a79dbe579cb51ecd6d30b17e0cae4e0ea15e2c0e66f69ad4198f22a6789e94f4 669 | url: "https://pub.dev" 670 | source: hosted 671 | version: "5.5.1" 672 | xdg_directories: 673 | dependency: transitive 674 | description: 675 | name: xdg_directories 676 | sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d 677 | url: "https://pub.dev" 678 | source: hosted 679 | version: "1.0.4" 680 | xml: 681 | dependency: transitive 682 | description: 683 | name: xml 684 | sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 685 | url: "https://pub.dev" 686 | source: hosted 687 | version: "6.5.0" 688 | yaml: 689 | dependency: transitive 690 | description: 691 | name: yaml 692 | sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" 693 | url: "https://pub.dev" 694 | source: hosted 695 | version: "3.1.2" 696 | sdks: 697 | dart: ">=3.4.0 <4.0.0" 698 | flutter: ">=3.22.0" 699 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: shlink_app 2 | description: App to manage a Shlink instance 3 | # The following line prevents the package from being accidentally published to 4 | # pub.dev using `flutter pub publish`. This is preferred for private packages. 5 | publish_to: 'none' # Remove this line if you wish to publish to pub.dev 6 | 7 | # The following defines the version and build number for your application. 8 | # A version number is three numbers separated by dots, like 1.2.43 9 | # followed by an optional build number separated by a +. 10 | # Both the version and the builder number may be overridden in flutter 11 | # build by specifying --build-name and --build-number, respectively. 12 | # In Android, build-name is used as versionName while build-number used as versionCode. 13 | # Read more about Android versioning at https://developer.android.com/studio/publish/versioning 14 | # In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. 15 | # Read more about iOS versioning at 16 | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 17 | # In Windows, build-name is used as the major, minor, and patch parts 18 | # of the product and file versions while build-number is used as the build suffix. 19 | version: 1.4.0+11 20 | 21 | environment: 22 | sdk: ^3.0.0 23 | dependencies: 24 | flutter: 25 | sdk: flutter 26 | 27 | # 28 | # RUN THE FOLLOWING COMMANDS AFTER EVERY PUB-GET 29 | # $ flutter packages pub run license_generator check 30 | # $ flutter packages pub run license_generator generate 31 | # 32 | 33 | cupertino_icons: ^1.0.5 34 | http: ^1.1.0 35 | flutter_process_text: ^1.1.2 36 | flutter_secure_storage: ^9.0.0 37 | dartz: ^0.10.1 38 | qr_flutter: ^4.1.0 39 | tuple: ^2.0.2 40 | intl: ^0.19.0 41 | dynamic_color: ^1.6.6 42 | url_launcher: ^6.2.4 43 | package_info_plus: ^8.0.0 44 | shared_preferences: ^2.2.2 45 | flutter_sharing_intent: ^1.1.1 46 | 47 | dev_dependencies: 48 | flutter_test: 49 | sdk: flutter 50 | license_generator: ^2.0.0 51 | flutter_launcher_icons: ^0.13.1 52 | 53 | flutter_lints: ^4.0.0 54 | 55 | flutter: 56 | uses-material-design: true 57 | 58 | # flutter pub run flutter_launcher_icons 59 | flutter_launcher_icons: 60 | android: true 61 | image_path: "assets/icon/icon.png" 62 | adaptive_icon_background: "assets/icon/icon_background.png" 63 | adaptive_icon_foreground: "assets/icon/icon_foreground.png" 64 | adaptive_icon_monochrome: "assets/icon/icon_monochrome.png" -------------------------------------------------------------------------------- /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 | import 'package:shlink_app/main.dart'; 12 | 13 | void main() { 14 | testWidgets('Counter increments smoke test', (WidgetTester tester) async { 15 | // Build our app and trigger a frame. 16 | await tester.pumpWidget(const MyApp()); 17 | 18 | // Verify that our counter starts at 0. 19 | expect(find.text('0'), findsOneWidget); 20 | expect(find.text('1'), findsNothing); 21 | 22 | // Tap the '+' icon and trigger a frame. 23 | await tester.tap(find.byIcon(Icons.add)); 24 | await tester.pump(); 25 | 26 | // Verify that our counter has incremented. 27 | expect(find.text('0'), findsNothing); 28 | expect(find.text('1'), findsOneWidget); 29 | }); 30 | } 31 | --------------------------------------------------------------------------------