commit 68130104da326045369c80b51be5841c3376ec0b Author: Adrian Baumgart Date: Mon Jun 26 23:40:05 2023 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24476c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..714f02b --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled. + +version: + revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf + channel: stable + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf + base_revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf + - platform: android + create_revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf + base_revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4758f5f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Adrian Baumgart + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..42ca68d --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Shlink Mobile App + +> :warning: **This app is still under development, it's not intended to be used yet** + +This is a mobile app built with Flutter to see and manage all shortened URLs created with [Shlink](https://shlink.io/). \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..61b6c4d --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,29 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at + # https://dart-lang.github.io/linter/lints/index.html. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..6f56801 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..64c58db --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,71 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +android { + compileSdkVersion flutter.compileSdkVersion + ndkVersion flutter.ndkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "dev.abmgrt.shlink_app" + // You can update the following values to match your application needs. + // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. + minSdkVersion 18 //flutter.minSdkVersion + targetSdkVersion flutter.targetSdkVersion + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..d86562d --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..410e458 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/dev/abmgrt/shlink_app/MainActivity.kt b/android/app/src/main/kotlin/dev/abmgrt/shlink_app/MainActivity.kt new file mode 100644 index 0000000..3b2032c --- /dev/null +++ b/android/app/src/main/kotlin/dev/abmgrt/shlink_app/MainActivity.kt @@ -0,0 +1,6 @@ +package dev.abmgrt.shlink_app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() { +} diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..d86562d --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..58a8c74 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,31 @@ +buildscript { + ext.kotlin_version = '1.7.10' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.2.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..94adc3a --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3c472b9 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..44e62bc --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,11 @@ +include ':app' + +def localPropertiesFile = new File(rootProject.projectDir, "local.properties") +def properties = new Properties() + +assert localPropertiesFile.exists() +localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + +def flutterSdkPath = properties.getProperty("flutter.sdk") +assert flutterSdkPath != null, "flutter.sdk not set in local.properties" +apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/lib/API/Classes/ShortURL.dart b/lib/API/Classes/ShortURL.dart new file mode 100644 index 0000000..892c903 --- /dev/null +++ b/lib/API/Classes/ShortURL.dart @@ -0,0 +1,33 @@ +import 'package:shlink_app/API/Classes/ShortURL_DeviceLongUrls.dart'; +import 'package:shlink_app/API/Classes/ShortURL_Meta.dart'; +import 'package:shlink_app/API/Classes/ShortURL_VisitsSummary.dart'; + +class ShortURL { + String shortCode; + String shortUrl; + String longUrl; + ShortURL_DeviceLongUrls deviceLongUrls; + DateTime dateCreated; + ShortURL_VisitsSummary visitsSummary; + List tags; + ShortURL_Meta meta; + String? domain; + String? title; + bool crawlable; + + ShortURL(this.shortCode, this.shortUrl, this.longUrl, this.deviceLongUrls, this.dateCreated, this.visitsSummary, this.tags, this.meta, this.domain, this.title, this.crawlable); + + ShortURL.fromJson(Map json): + shortCode = json["shortCode"], + shortUrl = json["shortUrl"], + longUrl = json["longUrl"], + deviceLongUrls = ShortURL_DeviceLongUrls.fromJson(json["deviceLongUrls"]), + dateCreated = DateTime.parse(json["dateCreated"]), + visitsSummary = ShortURL_VisitsSummary.fromJson(json["visitsSummary"]), + tags = json["tags"], + meta = ShortURL_Meta.fromJson(json["meta"]), + domain = json["domain"], + title = json["title"], + crawlable = json["crawlable"]; + +} \ No newline at end of file diff --git a/lib/API/Classes/ShortURL_DeviceLongUrls.dart b/lib/API/Classes/ShortURL_DeviceLongUrls.dart new file mode 100644 index 0000000..87a1b89 --- /dev/null +++ b/lib/API/Classes/ShortURL_DeviceLongUrls.dart @@ -0,0 +1,14 @@ +import 'dart:convert'; + +class ShortURL_DeviceLongUrls { + final String? android; + final String? ios; + final String? desktop; + + ShortURL_DeviceLongUrls(this.android, this.ios, this.desktop); + + ShortURL_DeviceLongUrls.fromJson(Map json) + : android = json["android"], + ios = json["ios"], + desktop = json["desktop"]; +} \ No newline at end of file diff --git a/lib/API/Classes/ShortURL_Meta.dart b/lib/API/Classes/ShortURL_Meta.dart new file mode 100644 index 0000000..58d203f --- /dev/null +++ b/lib/API/Classes/ShortURL_Meta.dart @@ -0,0 +1,12 @@ +class ShortURL_Meta { + DateTime? validSince; + DateTime? validUntil; + int? maxVisits; + + ShortURL_Meta(this.validSince, this.validUntil, this.maxVisits); + + ShortURL_Meta.fromJson(Map json): + validSince = json["validSince"] != null ? DateTime.parse(json["validSince"]) : null, + validUntil = json["validUntil"] != null ? DateTime.parse(json["validUntil"]) : null, + maxVisits = json["maxVisits"]; +} \ No newline at end of file diff --git a/lib/API/Classes/ShortURL_VisitsSummary.dart b/lib/API/Classes/ShortURL_VisitsSummary.dart new file mode 100644 index 0000000..32fac26 --- /dev/null +++ b/lib/API/Classes/ShortURL_VisitsSummary.dart @@ -0,0 +1,12 @@ +class ShortURL_VisitsSummary { + int total; + int nonBots; + int bots; + + ShortURL_VisitsSummary(this.total, this.nonBots, this.bots); + + ShortURL_VisitsSummary.fromJson(Map json): + total = json["total"] as int, + nonBots = json["nonBots"] as int, + bots = json["bots"] as int; +} \ No newline at end of file diff --git a/lib/API/ServerManager.dart b/lib/API/ServerManager.dart new file mode 100644 index 0000000..41421b1 --- /dev/null +++ b/lib/API/ServerManager.dart @@ -0,0 +1,156 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:dartz/dartz.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; +import 'package:shlink_app/API/Classes/ShortURL.dart'; + +class ServerManager { + + String? _server_url; + String? _api_key; + + static String apiVersion = "3"; + + String getServerUrl() { + return _server_url ?? ""; + } + + Future checkLogin() async { + await _loadCredentials(); + return (_server_url != null); + } + + Future _loadCredentials() async { + const storage = FlutterSecureStorage(); + _server_url = await storage.read(key: "shlink_url"); + _api_key = await storage.read(key: "shlink_apikey"); + } + + void _saveCredentials(String url, String apiKey) async { + const storage = FlutterSecureStorage(); + storage.write(key: "shlink_url", value: url); + storage.write(key: "shlink_apikey", value: apiKey); + } + + void _removeCredentials() async { + const storage = FlutterSecureStorage(); + storage.delete(key: "shlink_url"); + storage.delete(key: "shlink_apikey"); + } + + FutureOr> initAndConnect(String url, String apiKey) async { + // TODO: convert url to correct format + _server_url = url; + _api_key = apiKey; + _saveCredentials(url, apiKey); + final result = await connect(); + result.fold((l) => null, (r) { + _removeCredentials(); + }); + return result; + } + + FutureOr> connect() async { + _loadCredentials(); + try { + final response = await http.get(Uri.parse("${_server_url}/rest/v${apiVersion}/short-urls"), headers: { + "X-Api-Key": _api_key ?? "", + }); + if (response.statusCode == 200) { + return left(""); + } + else { + try { + var jsonBody = jsonDecode(response.body); + return right(ApiFailure(jsonBody["type"], jsonBody["detail"], jsonBody["title"], jsonBody["status"])); + } + catch(resErr) { + return right(RequestFailure(response.statusCode, resErr.toString())); + } + } + } + catch(reqErr) { + return right(RequestFailure(0, reqErr.toString())); + } + } + + FutureOr, Failure>> getShortUrls() async { + var _currentPage = 1; + var _maxPages = 2; + List _allUrls = []; + + Failure? error; + + while (_currentPage <= _maxPages) { + final response = await _getShortUrlPage(_currentPage); + response.fold((l) { + _allUrls.addAll(l.urls); + _maxPages = l.totalPages; + _currentPage++; + }, (r) { + _maxPages = 0; + error = r; + }); + } + if (error == null) { + return left(_allUrls); + } + else { + return right(error!); + } + } + + FutureOr> _getShortUrlPage(int page) async { + try { + final response = await http.get(Uri.parse("${_server_url}/rest/v${apiVersion}/short-urls?page=${page}"), headers: { + "X-Api-Key": _api_key ?? "", + }); + if (response.statusCode == 200) { + var jsonResponse = jsonDecode(response.body); + var pagesCount = jsonResponse["shortUrls"]["pagination"]["pagesCount"] as int; + List shortURLs = (jsonResponse["shortUrls"]["data"] as List).map((e) { + return ShortURL.fromJson(e); + }).toList(); + return left(ShortURLPageResponse(shortURLs, pagesCount)); + } + else { + try { + var jsonBody = jsonDecode(response.body); + return right(ApiFailure(jsonBody["type"], jsonBody["detail"], jsonBody["title"], jsonBody["status"])); + } + catch(resErr) { + return right(RequestFailure(response.statusCode, resErr.toString())); + } + } + } + catch(reqErr) { + return right(RequestFailure(0, reqErr.toString())); + } + } +} + +class ShortURLPageResponse { + List urls; + int totalPages; + + ShortURLPageResponse(this.urls, this.totalPages); +} + +abstract class Failure {} + +class RequestFailure extends Failure { + int statusCode; + String description; + + RequestFailure(this.statusCode, this.description); +} + +class ApiFailure extends Failure { + String type; + String detail; + String title; + int status; + + ApiFailure(this.type, this.detail, this.title, this.status); +} \ No newline at end of file diff --git a/lib/HomeView.dart b/lib/HomeView.dart new file mode 100644 index 0000000..993426c --- /dev/null +++ b/lib/HomeView.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'globals.dart' as globals; + +class HomeView extends StatefulWidget { + const HomeView({Key? key}) : super(key: key); + + @override + State createState() => _HomeViewState(); +} + +class _HomeViewState extends State { + + @override + void initState() { + // TODO: implement initState + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar.medium( + expandedHeight: 160, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Shlink", style: TextStyle(fontWeight: FontWeight.bold)), + Text(globals.serverManager.getServerUrl(), style: TextStyle(fontSize: 16, color: Colors.grey[600])) + ], + ), + ) + ], + ), + ); + } +} diff --git a/lib/LoginView.dart b/lib/LoginView.dart new file mode 100644 index 0000000..37fec48 --- /dev/null +++ b/lib/LoginView.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:shlink_app/API/ServerManager.dart'; +import 'package:shlink_app/main.dart'; +import 'globals.dart' as globals; + +class LoginView extends StatefulWidget { + const LoginView({Key? key}) : super(key: key); + + @override + State createState() => _LoginViewState(); +} + +class _LoginViewState extends State { + late TextEditingController _server_url_controller; + late TextEditingController _apikey_controller; + + bool _isLoggingIn = false; + String _errorMessage = ""; + + @override + void initState() { + // TODO: implement initState + super.initState(); + _server_url_controller = TextEditingController(); + _apikey_controller = TextEditingController(); + } + + void _connect() async { + setState(() { + _isLoggingIn = true; + _errorMessage = ""; + }); + final connectResult = await globals.serverManager.initAndConnect(_server_url_controller.text, _apikey_controller.text); + connectResult.fold((l) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (context) => const InitialPage()) + ); + setState(() { + _isLoggingIn = false; + }); + }, (r) { + if (r is ApiFailure) { + setState(() { + _errorMessage = r.detail; + _isLoggingIn = false; + }); + } + else if (r is RequestFailure) { + setState(() { + _errorMessage = r.description; + _isLoggingIn = false; + }); + } + }); + } + + + @override + Widget build(BuildContext context) { + return Scaffold( + extendBody: true, + body: CustomScrollView( + slivers: [ + SliverAppBar.medium( + title: const Text("Add server") + ), + SliverFillRemaining( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding(padding: EdgeInsets.only(bottom: 8), + child: Text("Server URL", style: TextStyle(fontWeight: FontWeight.bold),)), + TextField( + controller: _server_url_controller, + keyboardType: TextInputType.url, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: "https://shlink.example.com" + ), + ), + const Padding( + padding: EdgeInsets.only(top: 8, bottom: 8), + child: Text("API Key", style: TextStyle(fontWeight: FontWeight.bold)), + ), + TextField( + controller: _apikey_controller, + keyboardType: TextInputType.text, + obscureText: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: "..." + ), + ), + Padding( + padding: const EdgeInsets.only(top: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FilledButton.tonal( + onPressed: () => { + _connect() + }, + child: _isLoggingIn ? Container( + width: 34, + height: 34, + padding: const EdgeInsets.all(4), + child: const CircularProgressIndicator(), + ) : const Text("Connect", style: TextStyle(fontSize: 20)), + ) + ], + ), + ), + Padding( + padding: const EdgeInsets.only(top: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible(child: Text(_errorMessage, style: TextStyle(color: Colors.red), textAlign: TextAlign.center)) + ], + ), + ) + ], + ), + ) + ) + ], + ) + ); + } +} + diff --git a/lib/NavigationBarView.dart b/lib/NavigationBarView.dart new file mode 100644 index 0000000..2e6e60c --- /dev/null +++ b/lib/NavigationBarView.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:shlink_app/HomeView.dart'; +import 'package:shlink_app/URLListView.dart'; + +class NavigationBarView extends StatefulWidget { + const NavigationBarView({Key? key}) : super(key: key); + + @override + State createState() => _NavigationBarViewState(); +} + +class _NavigationBarViewState extends State { + + final List views = [HomeView(), URLListView()]; + int _selectedView = 0; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: views.elementAt(_selectedView), + ), + bottomNavigationBar: NavigationBar( + destinations: [ + NavigationDestination(icon: Icon(Icons.home), label: "Home"), + NavigationDestination(icon: Icon(Icons.link), label: "Short URLs") + ], + selectedIndex: _selectedView, + onDestinationSelected: (int index) { + setState(() { + _selectedView = index; + }); + }, + ), + ); + } +} diff --git a/lib/URLListView.dart b/lib/URLListView.dart new file mode 100644 index 0000000..349a99c --- /dev/null +++ b/lib/URLListView.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; +import 'package:shlink_app/API/Classes/ShortURL.dart'; +import 'package:shlink_app/API/ServerManager.dart'; +import 'globals.dart' as globals; + +class URLListView extends StatefulWidget { + const URLListView({Key? key}) : super(key: key); + + @override + State createState() => _URLListViewState(); +} + +class _URLListViewState extends State { + + List shortUrls = []; + + @override + void initState() { + // TODO: implement initState + super.initState(); + WidgetsBinding.instance + .addPostFrameCallback((_) => loadAllShortUrls()); + } + + + + void loadAllShortUrls() async { + final response = await globals.serverManager.getShortUrls(); + response.fold((l) { + setState(() { + shortUrls = l; + }); + }, (r) { + var text = ""; + if (r is RequestFailure) { + text = r.description; + } + else { + text = (r as ApiFailure).detail; + } + + final snackBar = SnackBar(content: Text(text), behavior: SnackBarBehavior.floating); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar.medium( + title: Text("All short URLs", style: TextStyle(fontWeight: FontWeight.bold)) + ), + SliverList(delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + final shortURL = shortUrls[index]; + return Padding( + padding: EdgeInsets.all(8), + child: Container( + padding: EdgeInsets.only(top: 8, bottom: 8), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: MediaQuery.of(context).platformBrightness == Brightness.dark ? Colors.grey[800]! : Colors.grey[300]!)), + ), + child: Padding( + padding: EdgeInsets.all(8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text("${shortURL.title ?? shortURL.shortCode}", textScaleFactor: 1.4, style: TextStyle(fontWeight: FontWeight.bold),), + Text("${shortURL.longUrl}",maxLines: 1, overflow: TextOverflow.ellipsis, textScaleFactor: 0.9, style: TextStyle(color: Colors.grey[600]),) + ], + ), + ), + IconButton(onPressed: () { + + }, icon: Icon(Icons.qr_code)) + ], + ) + ), + ), + ); + }, + childCount: shortUrls.length + )) + ], + ), + ); + } +} diff --git a/lib/globals.dart b/lib/globals.dart new file mode 100644 index 0000000..cce656f --- /dev/null +++ b/lib/globals.dart @@ -0,0 +1,4 @@ +library dev.abmgrt.shlink_app.globals; +import 'package:shlink_app/API/ServerManager.dart'; + +ServerManager serverManager = ServerManager(); \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..d944c95 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shlink_app/LoginView.dart'; +import 'package:shlink_app/NavigationBarView.dart'; +import 'globals.dart' as globals; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + // This widget is the root of your application. + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + debugShowCheckedModeBanner: false, + theme: ThemeData( + primarySwatch: Colors.blue, + brightness: Brightness.light, + useMaterial3: true + ), + darkTheme: ThemeData( + primarySwatch: Colors.blue, + brightness: Brightness.dark, + useMaterial3: true, + colorScheme: ColorScheme.dark( + background: Colors.black, + surface: Color(0xff0d0d0d), + secondaryContainer: Colors.grey[300] + ) + ), + themeMode: ThemeMode.system, + home: const InitialPage(), + ); + } +} + +class InitialPage extends StatefulWidget { + const InitialPage({super.key}); + + @override + State createState() => _InitialPageState(); +} + +class _InitialPageState extends State { + + @override + void initState() { + super.initState(); + checkLogin(); + } + + void checkLogin() async { + + bool result = await globals.serverManager.checkLogin(); + if (result) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (context) => const NavigationBarView()) + ); + } + else { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (context) => const LoginView()) + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Text("") + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..a402d62 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,298 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: bfe67ef28df125b7dddcea62755991f807aa39a2492a23e1550161692950bbe0 + url: "https://pub.dev" + source: hosted + version: "2.10.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: e6a326c8af69605aec75ed6c187d06b349707a27fbff8222ca9cc2cff167975c + url: "https://pub.dev" + source: hosted + version: "1.2.1" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + sha256: cfc915e6923fe5ce6e153b0723c753045de46de1b4d63771530504004a45fae0 + url: "https://pub.dev" + source: hosted + version: "1.17.0" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: e35129dc44c9118cee2a5603506d823bab99c68393879edb440e0090d07586be + url: "https://pub.dev" + source: hosted + version: "1.0.5" + dartz: + dependency: "direct main" + description: + name: dartz + sha256: e6acf34ad2e31b1eb00948692468c30ab48ac8250e0f0df661e29f12dd252168 + url: "https://pub.dev" + source: hosted + version: "0.10.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: aeb0b80a8b3709709c9cc496cdc027c5b3216796bc0af0ce1007eaf24464fd4c + url: "https://pub.dev" + source: hosted + version: "2.0.1" + flutter_process_text: + dependency: "direct main" + description: + name: flutter_process_text + sha256: "75cdff9d255ce892c766370824e28bbb95a7f93e99b6c4109ca694a6ef4d5681" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "98352186ee7ad3639ccc77ad7924b773ff6883076ab952437d20f18a61f0a7c5" + url: "https://pub.dev" + source: hosted + version: "8.0.0" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "0912ae29a572230ad52d8a4697e5518d7f0f429052fd51df7e5a7952c7efe2a3" + url: "https://pub.dev" + source: hosted + version: "1.1.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "083add01847fc1c80a07a08e1ed6927e9acd9618a35e330239d4422cd2a58c50" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: b3773190e385a3c8a382007893d678ae95462b3c2279e987b55d140d3b0cb81b + url: "https://pub.dev" + source: hosted + version: "1.0.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "42938e70d4b872e856e678c423cc0e9065d7d294f45bc41fc1981a4eb4beaffe" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: fc2910ec9b28d60598216c29ea763b3a96c401f0ce1d13cdf69ccb0e5c93c3ee + url: "https://pub.dev" + source: hosted + version: "2.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2" + url: "https://pub.dev" + source: hosted + version: "0.13.6" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + js: + dependency: transitive + description: + name: js + sha256: "5528c2f391ededb7775ec1daa69e65a2d61276f7552de2b5f7b8d34ee9fd4ab7" + url: "https://pub.dev" + source: hosted + version: "0.6.5" + lints: + dependency: transitive + description: + name: lints + sha256: "5e4a9cd06d447758280a8ac2405101e0e2094d2a1dbdd3756aec3fe7775ba593" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: "16db949ceee371e9b99d22f88fa3a73c4e59fd0afed0bd25fc336eb76c198b72" + url: "https://pub.dev" + source: hosted + version: "0.12.13" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: d92141dc6fe1dad30722f9aa826c7fbc896d021d792f80678280601aff8cf724 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + meta: + dependency: transitive + description: + name: meta + sha256: "6c268b42ed578a53088d834796959e4a1814b5e9e164f147f580a386e5decf42" + url: "https://pub.dev" + source: hosted + version: "1.8.0" + modal_bottom_sheet: + dependency: "direct main" + description: + name: modal_bottom_sheet + sha256: "3bba63c62d35c931bce7f8ae23a47f9a05836d8cb3c11122ada64e0b2f3d718f" + url: "https://pub.dev" + source: hosted + version: "3.0.0-pre" + path: + dependency: transitive + description: + name: path + sha256: db9d4f58c908a4ba5953fcee2ae317c94889433e5024c27ce74a37f94267945b + url: "https://pub.dev" + source: hosted + version: "1.8.2" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "6a2128648c854906c53fa8e33986fc0247a1116122f9534dd20e3ab9e16a32bc" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250 + url: "https://pub.dev" + source: hosted + version: "1.9.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 + url: "https://pub.dev" + source: hosted + version: "1.11.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: ad540f65f92caa91bf21dfc8ffb8c589d6e4dc0c2267818b4cc2792857706206 + url: "https://pub.dev" + source: hosted + version: "0.4.16" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" +sdks: + dart: ">=2.19.6 <3.0.0" + flutter: ">=3.7.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..38f53bc --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,95 @@ +name: shlink_app +description: A new Flutter project. +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: '>=2.19.6 <3.0.0' + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.2 + http: ^0.13.6 + flutter_process_text: ^1.1.2 + flutter_secure_storage: ^8.0.0 + dartz: ^0.10.1 + modal_bottom_sheet: ^3.0.0-pre + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^2.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..6f72122 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:shlink_app/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +}