Compare commits

...

13 Commits

Author SHA1 Message Date
Adrian Baumgart e1c9bc4d80
bump version to 1.3.1 2024-07-25 20:17:55 +02:00
Adrian Baumgart 413275df38
fixed bug where server list stays empty 2024-07-25 20:17:27 +02:00
Adrian Baumgart 975b2ea3d8
cleaning and bump version to 1.3.0 2024-07-25 19:23:21 +02:00
rainloreley f9c6a58db1 Apply automatic changes 2024-07-25 17:12:51 +00:00
Adrian Baumgart 69c8870997
support multiple shlink instances 2024-07-25 19:11:24 +02:00
Adrian Baumgart ce28066d29
fixed license 2024-07-25 17:40:17 +02:00
Adrian Baumgart e9f98c2171
edited README 2024-07-25 17:39:42 +02:00
rainloreley 39483dca54 Apply automatic changes 2024-07-25 15:38:31 +00:00
Adrian Baumgart ba058e2af3
formatting 2024-07-25 17:37:17 +02:00
Adrian Baumgart 0eea6ee9a2
dependency 2024-07-25 17:35:12 +02:00
Adrian Baumgart 0ade61faea
added support for redirect rules 2024-07-25 17:31:54 +02:00
rainloreley 00bb9af82e Apply automatic changes 2024-07-25 12:51:42 +00:00
Adrian Baumgart 6a5cebdb68
change view title when editing short URL 2024-07-25 14:50:16 +02:00
21 changed files with 1048 additions and 302 deletions

View File

@ -1,42 +0,0 @@
name: Fix formatting and license file
on:
push:
branches: [ "main" ]
paths:
- '**.dart'
- 'pubspec.yaml'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: 'oracle'
java-version: '21'
- name: Setup Android SDK
uses: android-actions/setup-android@v2
- name: Setup Flutter SDK
uses: flutter-actions/setup-flutter@v2
with:
channel: stable
version: 3.13.7
- name: Install dependencies
run: dart pub get
- name: Verify formatting
run: dart format --output=none .
- name: Update license file
run: flutter packages pub run license_generator generate
- name: Commit and push
uses: stefanzweifel/git-auto-commit-action@v5

View File

@ -17,20 +17,18 @@ Shlink Manager is an app for Android to see and manage all shortened URLs create
## 📱 Current Features
✅ List all short URLs<br/>
✅ Create new short URLs<br/>
✅ Delete short URLs<br/>
✅ Create, edit and delete short URLs<br/>
✅ See overall statistics<br/>
✅ Detailed statistics for each short URL<br/>
✅ Display tags<br/>
✅ Display QR code<br/>
✅ Display tags & QR code<br/>
✅ Dark mode support<br/>
✅ Edit existing short URLs<br/>
✅ Quickly create Short URL via Share Sheet<br/>
✅ View rule-based redirects (no editing yet)<br/>
✅ Use multiple Shlink instances<br/>
## 🔨 To Do
- [ ] Add support for iOS (maybe in the future)
- [ ] add tags
- [ ] Add dynamic rule-based redirects system (Shlink v4.0.0)
- [ ] improve app icon
- [ ] Refactor code
- [ ] ...and more

View File

@ -52,10 +52,12 @@ android {
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 19 //flutter.minSdkVersion
minSdkVersion flutter.minSdkVersion //flutter.minSdkVersion
targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
multiDexEnabled true
}
signingConfigs {
@ -79,4 +81,5 @@ flutter {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.multidex:multidex:2.0.1'
}

View File

@ -0,0 +1,41 @@
enum ConditionDeviceType {
IOS,
ANDROID,
DESKTOP;
static ConditionDeviceType fromApi(String api) {
switch (api) {
case "ios":
return ConditionDeviceType.IOS;
case "android":
return ConditionDeviceType.ANDROID;
case "desktop":
return ConditionDeviceType.DESKTOP;
}
throw ArgumentError("Invalid type $api");
}
}
extension ConditionTypeExtension on ConditionDeviceType {
String get api {
switch (this) {
case ConditionDeviceType.IOS:
return "ios";
case ConditionDeviceType.ANDROID:
return "android";
case ConditionDeviceType.DESKTOP:
return "desktop";
}
}
String get humanReadable {
switch (this) {
case ConditionDeviceType.IOS:
return "iOS";
case ConditionDeviceType.ANDROID:
return "Android";
case ConditionDeviceType.DESKTOP:
return "Desktop";
}
}
}

View File

@ -0,0 +1,24 @@
import 'package:shlink_app/API/Classes/ShortURL/RedirectRule/redirect_rule_condition.dart';
/// Single redirect rule for a short URL.
class RedirectRule {
String longUrl;
int priority;
List<RedirectRuleCondition> conditions;
RedirectRule(this.longUrl, this.priority, this.conditions);
RedirectRule.fromJson(Map<String, dynamic> json)
: longUrl = json["longUrl"],
priority = json["priority"],
conditions = (json["conditions"] as List<dynamic>)
.map((e) => RedirectRuleCondition.fromJson(e))
.toList();
Map<String, dynamic> toJson() {
return {
"longUrl": longUrl,
"conditions": conditions.map((e) => e.toJson()).toList()
};
}
}

View File

@ -0,0 +1,19 @@
import 'package:shlink_app/API/Classes/ShortURL/RedirectRule/redirect_rule_condition_type.dart';
class RedirectRuleCondition {
RedirectRuleConditionType type;
String matchValue;
String? matchKey;
RedirectRuleCondition(String type, this.matchValue, this.matchKey)
: type = RedirectRuleConditionType.fromApi(type);
RedirectRuleCondition.fromJson(Map<String, dynamic> json)
: type = RedirectRuleConditionType.fromApi(json["type"]),
matchValue = json["matchValue"],
matchKey = json["matchKey"];
Map<String, dynamic> toJson() {
return {"type": type.api, "matchValue": matchValue, "matchKey": matchKey};
}
}

View File

@ -0,0 +1,41 @@
enum RedirectRuleConditionType {
DEVICE,
LANGUAGE,
QUERY_PARAM;
static RedirectRuleConditionType fromApi(String api) {
switch (api) {
case "device":
return RedirectRuleConditionType.DEVICE;
case "language":
return RedirectRuleConditionType.LANGUAGE;
case "query-param":
return RedirectRuleConditionType.QUERY_PARAM;
}
throw ArgumentError("Invalid type $api");
}
}
extension ConditionTypeExtension on RedirectRuleConditionType {
String get api {
switch (this) {
case RedirectRuleConditionType.DEVICE:
return "device";
case RedirectRuleConditionType.LANGUAGE:
return "language";
case RedirectRuleConditionType.QUERY_PARAM:
return "query-param";
}
}
String get humanReadable {
switch (this) {
case RedirectRuleConditionType.DEVICE:
return "Device";
case RedirectRuleConditionType.LANGUAGE:
return "Language";
case RedirectRuleConditionType.QUERY_PARAM:
return "Query parameter";
}
}
}

View File

@ -1,6 +1,8 @@
import 'package:shlink_app/API/Classes/ShortURL/short_url_meta.dart';
import 'package:shlink_app/API/Classes/ShortURL/visits_summary.dart';
import 'RedirectRule/redirect_rule.dart';
/// Data about a short URL
class ShortURL {
/// Slug of the short URL used in the URL
@ -33,6 +35,8 @@ class ShortURL {
/// Whether the short URL is crawlable by a web crawler
bool crawlable;
List<RedirectRule>? redirectRules;
ShortURL(
this.shortCode,
this.shortUrl,

View File

@ -0,0 +1,48 @@
import 'dart:async';
import 'dart:convert';
import 'package:dartz/dartz.dart';
import 'package:http/http.dart' as http;
import 'package:shlink_app/API/Classes/ShortURL/RedirectRule/redirect_rule.dart';
import '../server_manager.dart';
/// Gets redirect rules for a given short URL (code).
FutureOr<Either<List<RedirectRule>, Failure>> apiGetRedirectRules(
String shortCode,
String? apiKey,
String? serverUrl,
String apiVersion) async {
try {
final response = await http.get(
Uri.parse(
"$serverUrl/rest/v$apiVersion/short-urls/$shortCode/redirect-rules"),
headers: {
"X-Api-Key": apiKey ?? "",
});
if (response.statusCode == 200) {
// get returned redirect rules
var jsonBody = jsonDecode(response.body) as Map<String, dynamic>;
// convert json array to object array
List<RedirectRule> redirectRules =
(jsonBody["redirectRules"] as List<dynamic>)
.map((e) => RedirectRule.fromJson(e))
.toList();
return left(redirectRules);
} else {
try {
var jsonBody = jsonDecode(response.body);
return right(ApiFailure(
type: jsonBody["type"],
detail: jsonBody["detail"],
title: jsonBody["title"],
status: jsonBody["status"],
invalidElements: jsonBody["invalidElements"]));
} catch (resErr) {
return right(RequestFailure(response.statusCode, resErr.toString()));
}
}
} catch (reqErr) {
return right(RequestFailure(0, reqErr.toString()));
}
}

View File

@ -0,0 +1,45 @@
import 'dart:async';
import 'dart:convert';
import 'package:dartz/dartz.dart';
import 'package:http/http.dart' as http;
import 'package:shlink_app/API/Classes/ShortURL/RedirectRule/redirect_rule.dart';
import '../server_manager.dart';
/// Saves the redirect rules for a given short URL (code).
FutureOr<Either<bool, Failure>> apiSetRedirectRules(
String shortCode,
List<RedirectRule> redirectRules,
String? apiKey,
String? serverUrl,
String apiVersion) async {
try {
Map<String, dynamic> body = {};
List<Map<String, dynamic>> redirectRulesJson =
redirectRules.map((e) => e.toJson()).toList();
body["redirectRules"] = redirectRulesJson;
final response = await http.post(
Uri.parse(
"$serverUrl/rest/v$apiVersion/short-urls/$shortCode/redirect-rules"),
headers: {
"X-Api-Key": apiKey ?? "",
},
body: jsonEncode(body));
if (response.statusCode == 200) {
return left(true);
} else {
try {
var jsonBody = jsonDecode(response.body);
return right(ApiFailure(
type: jsonBody["type"],
detail: jsonBody["detail"],
title: jsonBody["title"],
status: jsonBody["status"],
invalidElements: jsonBody["invalidElements"]));
} catch (resErr) {
return right(RequestFailure(response.statusCode, resErr.toString()));
}
}
} catch (reqErr) {
return right(RequestFailure(0, reqErr.toString()));
}
}

View File

@ -1,15 +1,19 @@
import 'dart:async';
import 'dart:convert';
import 'package:dartz/dartz.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shlink_app/API/Classes/ShlinkStats/shlink_stats.dart';
import 'package:shlink_app/API/Classes/ShortURL/RedirectRule/redirect_rule.dart';
import 'package:shlink_app/API/Classes/ShortURL/short_url.dart';
import 'package:shlink_app/API/Classes/ShortURLSubmission/short_url_submission.dart';
import 'package:shlink_app/API/Methods/connect.dart';
import 'package:shlink_app/API/Methods/get_recent_short_urls.dart';
import 'package:shlink_app/API/Methods/get_redirect_rules.dart';
import 'package:shlink_app/API/Methods/get_server_health.dart';
import 'package:shlink_app/API/Methods/get_shlink_stats.dart';
import 'package:shlink_app/API/Methods/get_short_urls.dart';
import 'package:shlink_app/API/Methods/set_redirect_rules.dart';
import 'package:shlink_app/API/Methods/update_short_url.dart';
import 'Methods/delete_short_url.dart';
@ -41,10 +45,41 @@ class ServerManager {
}
/// Logs out the user and removes data about the Shlink server
Future<void> logOut() async {
Future<void> logOut(String url) async {
const storage = FlutterSecureStorage();
await storage.delete(key: "shlink_url");
await storage.delete(key: "shlink_apikey");
final prefs = await SharedPreferences.getInstance();
String? serverMapSerialized = await storage.read(key: "server_map");
if (serverMapSerialized != null) {
Map<String, String> serverMap =
Map.castFrom(jsonDecode(serverMapSerialized));
serverMap.remove(url);
if (serverMap.isEmpty) {
storage.delete(key: "server_map");
} else {
storage.write(key: "server_map", value: jsonEncode(serverMap));
}
if (serverUrl == url) {
serverUrl = null;
apiKey = null;
prefs.remove("lastusedserver");
}
}
}
/// Returns all servers saved in the app
Future<List<String>> getAvailableServers() async {
const storage = FlutterSecureStorage();
String? serverMapSerialized = await storage.read(key: "server_map");
if (serverMapSerialized != null) {
Map<String, String> serverMap =
Map.castFrom(jsonDecode(serverMapSerialized));
return serverMap.keys.toList();
} else {
return [];
}
}
/// Loads the server credentials from [FlutterSecureStorage]
@ -57,16 +92,68 @@ class ServerManager {
prefs.setBool('first_run', false);
} else {
serverUrl = await storage.read(key: "shlink_url");
apiKey = await storage.read(key: "shlink_apikey");
if (await _replaceDeprecatedStorageMethod()) {
_loadCredentials();
return;
}
String? serverMapSerialized = await storage.read(key: "server_map");
String? lastUsedServer = prefs.getString("lastusedserver");
if (serverMapSerialized != null) {
Map<String, String> serverMap =
Map.castFrom(jsonDecode(serverMapSerialized));
if (lastUsedServer != null) {
serverUrl = lastUsedServer;
apiKey = serverMap[lastUsedServer]!;
} else {
List<String> availableServers = serverMap.keys.toList();
if (availableServers.isNotEmpty) {
serverUrl = availableServers.first;
apiKey = serverMap[serverUrl];
prefs.setString("lastusedserver", serverUrl!);
}
}
}
}
}
Future<bool> _replaceDeprecatedStorageMethod() async {
const storage = FlutterSecureStorage();
// deprecated data storage method, replaced because of multi-server support
var v1DataServerurl = await storage.read(key: "shlink_url");
var v1DataApikey = await storage.read(key: "shlink_apikey");
if (v1DataServerurl != null && v1DataApikey != null) {
// conversion to new data storage method
Map<String, String> serverMap = {};
serverMap[v1DataServerurl] = v1DataApikey;
storage.write(key: "server_map", value: jsonEncode(serverMap));
storage.delete(key: "shlink_url");
storage.delete(key: "shlink_apikey");
return true;
} else {
return false;
}
}
/// Saves the provided server credentials to [FlutterSecureStorage]
void _saveCredentials(String url, String apiKey) async {
const storage = FlutterSecureStorage();
storage.write(key: "shlink_url", value: url);
storage.write(key: "shlink_apikey", value: apiKey);
final prefs = await SharedPreferences.getInstance();
String? serverMapSerialized = await storage.read(key: "server_map");
Map<String, String> serverMap;
if (serverMapSerialized != null) {
serverMap = Map.castFrom(jsonDecode(serverMapSerialized));
} else {
serverMap = {};
}
serverMap[url] = apiKey;
storage.write(key: "server_map", value: jsonEncode(serverMap));
prefs.setString("lastusedserver", url);
}
/// Saves provided server credentials and tries to establish a connection
@ -78,7 +165,7 @@ class ServerManager {
_saveCredentials(url, apiKey);
final result = await connect();
result.fold((l) => null, (r) {
logOut();
logOut(url);
});
return result;
}
@ -124,6 +211,19 @@ class ServerManager {
FutureOr<Either<List<ShortURL>, Failure>> getRecentShortUrls() async {
return apiGetRecentShortUrls(apiKey, serverUrl, apiVersion);
}
/// Gets redirect rules for a given short URL (code)
FutureOr<Either<List<RedirectRule>, Failure>> getRedirectRules(
String shortCode) async {
return apiGetRedirectRules(shortCode, apiKey, serverUrl, apiVersion);
}
/// Sets redirect rules for a given short URL (code)
FutureOr<Either<bool, Failure>> setRedirectRules(
String shortCode, List<RedirectRule> redirectRules) async {
return apiSetRedirectRules(
shortCode, redirectRules, apiKey, serverUrl, apiVersion);
}
}
/// Server response data type about a page of short URLs from the server

View File

@ -11,13 +11,13 @@ void main() {
class MyApp extends StatelessWidget {
const MyApp({super.key});
static final ColorScheme _defaultLightColorScheme = ColorScheme
.fromSeed(seedColor: Colors.blue);
static final ColorScheme _defaultLightColorScheme =
ColorScheme.fromSeed(seedColor: Colors.blue);
static final _defaultDarkColorScheme = ColorScheme.fromSeed(
brightness: Brightness.dark,
seedColor: Colors.blue,
background: Colors.black);
brightness: Brightness.dark,
seedColor: Colors.blue,
background: Colors.black);
// This widget is the root of your application.
@override
@ -38,7 +38,7 @@ class MyApp extends StatelessWidget {
foregroundColor: Colors.white,
elevation: 0,
),
colorScheme: darkColorScheme?.copyWith(background: Colors.black) ??
colorScheme: darkColorScheme?.copyWith(surface: Colors.black) ??
_defaultDarkColorScheme,
useMaterial3: true,
),
@ -64,11 +64,13 @@ class _InitialPageState extends State<InitialPage> {
void checkLogin() async {
bool result = await globals.serverManager.checkLogin();
if (result) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const NavigationBarView()));
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (context) => const NavigationBarView()),
(Route<dynamic> route) => false);
} else {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const LoginView()));
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (context) => const LoginView()),
(Route<dynamic> route) => false);
}
}

View File

@ -28,7 +28,8 @@ class LicenseUtil {
return [
const License(
name: r'cupertino_icons',
license: r'''The MIT License (MIT)
license: r'''
The MIT License (MIT)
Copyright (c) 2016 Vladimir Kharlampidi
@ -49,12 +50,12 @@ 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.''',
version: r'^1.0.5',
homepage: null,
repository: r'https://github.com/flutter/packages/tree/main/third_party/packages/cupertino_icons',
),
const License(
name: r'dartz',
license: r'''The MIT License (MIT)
license: r'''
The MIT License (MIT)
Copyright (c) 2016, 2017, 2018, 2019, 2020, 2021 Björn Sperber
@ -78,11 +79,11 @@ SOFTWARE.
''',
version: r'^0.10.1',
homepage: r'https://github.com/spebbe/dartz',
repository: null,
),
const License(
name: r'dynamic_color',
license: r''' Apache License
license: r'''
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
@ -285,12 +286,12 @@ SOFTWARE.
limitations under the License.
''',
version: r'^1.6.6',
homepage: null,
repository: r'https://github.com/material-foundation/flutter-packages/tree/main/packages/dynamic_color',
),
const License(
name: r'flutter',
license: r'''Copyright 2014 The Flutter Authors. All rights reserved.
license: r'''
Copyright 2014 The Flutter Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
@ -316,13 +317,13 @@ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
''',
version: null,
homepage: r'https://flutter.dev/',
repository: r'https://github.com/flutter/flutter',
),
const License(
name: r'flutter_launcher_icons',
license: r'''MIT License
license: r'''
MIT License
Copyright (c) 2019 Mark O'Sullivan
@ -350,7 +351,8 @@ SOFTWARE.
),
const License(
name: r'flutter_lints',
license: r'''Copyright 2013 The Flutter Authors. All rights reserved.
license: r'''
Copyright 2013 The Flutter Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
@ -377,12 +379,12 @@ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
''',
version: r'^3.0.1',
homepage: null,
repository: r'https://github.com/flutter/packages/tree/main/packages/flutter_lints',
),
const License(
name: r'flutter_process_text',
license: r'''BSD 3-Clause License
license: r'''
BSD 3-Clause License
(c) Copyright 2021 divshekhar (Divyanshu Shekhar)
@ -410,12 +412,12 @@ HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABI
TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''',
version: r'^1.1.2',
homepage: null,
repository: r'https://github.com/DevsOnFlutter/flutter_process_text',
),
const License(
name: r'flutter_secure_storage',
license: r'''BSD 3-Clause License
license: r'''
BSD 3-Clause License
Copyright 2017 German Saprykin
All rights reserved.
@ -445,12 +447,12 @@ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''',
version: r'^9.0.0',
homepage: null,
repository: r'https://github.com/mogol/flutter_secure_storage/tree/develop/flutter_secure_storage',
),
const License(
name: r'flutter_sharing_intent',
license: r''' Apache License
license: r'''
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
@ -653,11 +655,11 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''',
limitations under the License.''',
version: r'^1.1.1',
homepage: r'https://github.com/bhagat-techind/flutter_sharing_intent.git',
repository: null,
),
const License(
name: r'flutter_test',
license: r'''Copyright 2014 The Flutter Authors. All rights reserved.
license: r'''
Copyright 2014 The Flutter Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
@ -683,13 +685,13 @@ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
''',
version: null,
homepage: r'https://flutter.dev/',
repository: r'https://github.com/flutter/flutter',
),
const License(
name: r'http',
license: r'''Copyright 2014, the Dart project authors.
license: r'''
Copyright 2014, the Dart project authors.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
@ -718,12 +720,12 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
''',
version: r'^1.1.0',
homepage: null,
repository: r'https://github.com/dart-lang/http/tree/master/pkgs/http',
),
const License(
name: r'intl',
license: r'''Copyright 2013, the Dart project authors.
license: r'''
Copyright 2013, the Dart project authors.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
@ -752,12 +754,12 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
''',
version: r'^0.19.0',
homepage: null,
repository: r'https://github.com/dart-lang/i18n/tree/main/pkgs/intl',
),
const License(
name: r'license_generator',
license: r'''MIT License
license: r'''
MIT License
Copyright (c) 2022 icapps
@ -781,11 +783,11 @@ SOFTWARE.
''',
version: r'^2.0.0',
homepage: r'https://github.com/icapps/flutter-icapps-license',
repository: null,
),
const License(
name: r'package_info_plus',
license: r'''Copyright 2017 The Chromium Authors. All rights reserved.
license: r'''
Copyright 2017 The Chromium Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
@ -819,7 +821,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
),
const License(
name: r'qr_flutter',
license: r'''BSD 3-Clause License
license: r'''
BSD 3-Clause License
Copyright (c) 2020, Luke Freeman.
All rights reserved.
@ -851,11 +854,11 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
''',
version: r'^4.1.0',
homepage: r'https://github.com/theyakka/qr.flutter',
repository: null,
),
const License(
name: r'shared_preferences',
license: r'''Copyright 2013 The Flutter Authors. All rights reserved.
license: r'''
Copyright 2013 The Flutter Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
@ -882,12 +885,12 @@ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
''',
version: r'^2.2.2',
homepage: null,
repository: r'https://github.com/flutter/packages/tree/main/packages/shared_preferences/shared_preferences',
),
const License(
name: r'tuple',
license: r'''Copyright (c) 2014, the tuple project authors.
license: r'''
Copyright (c) 2014, the tuple project authors.
All rights reserved.
Redistribution and use in source and binary forms, with or without
@ -910,12 +913,12 @@ ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.''',
version: r'^2.0.2',
homepage: null,
repository: r'https://github.com/google/tuple.dart',
),
const License(
name: r'url_launcher',
license: r'''Copyright 2013 The Flutter Authors. All rights reserved.
license: r'''
Copyright 2013 The Flutter Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
@ -942,7 +945,6 @@ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
''',
version: r'^6.2.4',
homepage: null,
repository: r'https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher',
),
];

View File

@ -8,6 +8,7 @@ import 'package:shlink_app/API/Classes/ShlinkStats/shlink_stats.dart';
import 'package:shlink_app/API/server_manager.dart';
import 'package:shlink_app/views/short_url_edit_view.dart';
import 'package:shlink_app/views/url_list_view.dart';
import 'package:shlink_app/widgets/available_servers_bottom_sheet.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../API/Classes/ShortURL/short_url.dart';
import '../globals.dart' as globals;
@ -121,15 +122,25 @@ class _HomeViewState extends State<HomeView> {
child: CustomScrollView(
slivers: [
SliverAppBar.medium(
automaticallyImplyLeading: false,
expandedHeight: 160,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Shlink",
style: TextStyle(fontWeight: FontWeight.bold)),
Text(globals.serverManager.getServerUrl(),
style: TextStyle(
fontSize: 16, color: Colors.grey[600]))
GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
builder: (BuildContext context) {
return const AvailableServerBottomSheet();
});
},
child: Text(globals.serverManager.getServerUrl(),
style: TextStyle(
fontSize: 16, color: Colors.grey[600])),
)
],
)),
SliverToBoxAdapter(

View File

@ -0,0 +1,297 @@
import 'package:flutter/material.dart';
import 'package:shlink_app/API/Classes/ShortURL/RedirectRule/condition_device_type.dart';
import 'package:shlink_app/API/Classes/ShortURL/RedirectRule/redirect_rule_condition.dart';
import 'package:shlink_app/API/Classes/ShortURL/RedirectRule/redirect_rule_condition_type.dart';
import 'package:shlink_app/API/Classes/ShortURL/short_url.dart';
import 'package:shlink_app/API/server_manager.dart';
import '../globals.dart' as globals;
import '../API/Classes/ShortURL/RedirectRule/redirect_rule.dart';
class RedirectRulesDetailView extends StatefulWidget {
const RedirectRulesDetailView({super.key, required this.shortURL});
final ShortURL shortURL;
@override
State<RedirectRulesDetailView> createState() =>
_RedirectRulesDetailViewState();
}
class _RedirectRulesDetailViewState extends State<RedirectRulesDetailView> {
List<RedirectRule> redirectRules = [];
bool redirectRulesLoaded = false;
bool isSaving = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => loadRedirectRules());
}
Future<void> loadRedirectRules() async {
final response =
await globals.serverManager.getRedirectRules(widget.shortURL.shortCode);
response.fold((l) {
setState(() {
redirectRules = l;
redirectRulesLoaded = true;
});
_sortListByPriority();
return true;
}, (r) {
var text = "";
if (r is RequestFailure) {
text = r.description;
} else {
text = (r as ApiFailure).detail;
}
final snackBar = SnackBar(
content: Text(text),
backgroundColor: Colors.red[400],
behavior: SnackBarBehavior.floating);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
return false;
});
}
void _saveRedirectRules() async {
final response = await globals.serverManager
.setRedirectRules(widget.shortURL.shortCode, redirectRules);
response.fold((l) {
Navigator.pop(context);
}, (r) {
var text = "";
if (r is RequestFailure) {
text = r.description;
} else {
text = (r as ApiFailure).detail;
}
final snackBar = SnackBar(
content: Text(text),
backgroundColor: Colors.red[400],
behavior: SnackBarBehavior.floating);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
return false;
});
}
void _sortListByPriority() {
setState(() {
redirectRules.sort((a, b) => a.priority - b.priority);
});
}
void _fixPriorities() {
for (int i = 0; i < redirectRules.length; i++) {
setState(() {
redirectRules[i].priority = i + 1;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: Wrap(
spacing: 16,
children: [
FloatingActionButton(
onPressed: () {
if (!isSaving & redirectRulesLoaded) {
setState(() {
isSaving = true;
});
_saveRedirectRules();
}
},
child: isSaving
? const Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(strokeWidth: 3))
: const Icon(Icons.save))
],
),
body: CustomScrollView(
slivers: [
const SliverAppBar.medium(
expandedHeight: 120,
title: Text(
"Redirect Rules",
style: TextStyle(fontWeight: FontWeight.bold),
),
),
if (redirectRulesLoaded && redirectRules.isEmpty)
SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 50),
child: Column(
children: [
const Text(
"No Redirect Rules",
style: TextStyle(
fontSize: 24, fontWeight: FontWeight.bold),
),
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'Adding redirect rules will be supported soon!',
style: TextStyle(
fontSize: 16, color: Colors.grey[600]),
),
)
],
))))
else
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return _ListCell(
redirectRule: redirectRules[index],
moveUp: index == 0
? null
: () {
setState(() {
redirectRules[index].priority -= 1;
redirectRules[index - 1].priority += 1;
});
_sortListByPriority();
},
moveDown: index == (redirectRules.length - 1)
? null
: () {
setState(() {
redirectRules[index].priority += 1;
redirectRules[index + 1].priority -= 1;
});
_sortListByPriority();
},
delete: () {
setState(() {
redirectRules.removeAt(index);
});
_fixPriorities();
},
);
}, childCount: redirectRules.length))
],
),
);
}
}
class _ListCell extends StatefulWidget {
const _ListCell(
{required this.redirectRule,
required this.moveUp,
required this.moveDown,
required this.delete});
final VoidCallback? moveUp;
final VoidCallback? moveDown;
final VoidCallback delete;
final RedirectRule redirectRule;
@override
State<_ListCell> createState() => _ListCellState();
}
class _ListCellState extends State<_ListCell> {
String _conditionToTagString(RedirectRuleCondition condition) {
switch (condition.type) {
case RedirectRuleConditionType.DEVICE:
return "Device is ${ConditionDeviceType.fromApi(condition.matchValue).humanReadable}";
case RedirectRuleConditionType.LANGUAGE:
return "Language is ${condition.matchValue}";
case RedirectRuleConditionType.QUERY_PARAM:
return "Query string contains ${condition.matchKey}=${condition.matchValue}";
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 8, right: 8),
child: Container(
padding: const EdgeInsets.only(left: 8, right: 8, top: 16, bottom: 16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: MediaQuery.of(context).platformBrightness ==
Brightness.dark
? Colors.grey[800]!
: Colors.grey[300]!)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text("Long URL ",
style: TextStyle(fontWeight: FontWeight.bold)),
Text(widget.redirectRule.longUrl)
],
),
const Text("Conditions:",
style: TextStyle(fontWeight: FontWeight.bold)),
Row(
children: [
Expanded(
child: Wrap(
children:
widget.redirectRule.conditions.map((condition) {
return Padding(
padding: const EdgeInsets.only(right: 4, top: 4),
child: Container(
padding: const EdgeInsets.only(
top: 4, bottom: 4, left: 12, right: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color:
MediaQuery.of(context).platformBrightness ==
Brightness.dark
? Colors.grey[900]
: Colors.grey[300],
),
child: Text(_conditionToTagString(condition)),
),
);
}).toList(),
),
)
],
),
Wrap(
children: [
IconButton(
disabledColor:
MediaQuery.of(context).platformBrightness ==
Brightness.dark
? Colors.grey[700]
: Colors.grey[400],
onPressed: widget.moveUp,
icon: const Icon(Icons.arrow_upward),
),
IconButton(
disabledColor:
MediaQuery.of(context).platformBrightness ==
Brightness.dark
? Colors.grey[700]
: Colors.grey[400],
onPressed: widget.moveDown,
icon: const Icon(Icons.arrow_downward),
),
IconButton(
onPressed: widget.delete,
icon: const Icon(Icons.delete, color: Colors.red),
)
],
)
],
)));
}
}

View File

@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shlink_app/API/server_manager.dart';
import 'package:shlink_app/views/login_view.dart';
import 'package:shlink_app/views/opensource_licenses_view.dart';
import 'package:shlink_app/widgets/available_servers_bottom_sheet.dart';
import 'package:url_launcher/url_launcher.dart';
import '../globals.dart' as globals;
@ -64,91 +64,81 @@ class _SettingsViewState extends State<SettingsView> {
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar.medium(
const SliverAppBar.medium(
expandedHeight: 120,
title: const Text(
title: Text(
"Settings",
style: TextStyle(fontWeight: FontWeight.bold),
),
actions: [
PopupMenuButton(
itemBuilder: (context) {
return [
const PopupMenuItem(
value: 0,
child: Text("Log out", style: TextStyle(color: Colors.red)),
)
];
},
onSelected: (value) {
if (value == 0) {
globals.serverManager.logOut().then((value) =>
Navigator.of(context).pushReplacement(MaterialPageRoute(
builder: (context) => const LoginView())));
}
},
)
],
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).brightness == Brightness.light
? Colors.grey[100]
: Colors.grey[900],
),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
Icon(Icons.dns_outlined,
color: (() {
switch (_serverStatus) {
case ServerStatus.connected:
return Colors.green;
case ServerStatus.connecting:
return Colors.orange;
case ServerStatus.disconnected:
return Colors.red;
}
}())),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Connected to",
style: TextStyle(color: Colors.grey)),
Text(globals.serverManager.getServerUrl(),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16)),
Row(
children: [
const Text("API Version: ",
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w600)),
Text(globals.serverManager.getApiVersion(),
style:
const TextStyle(color: Colors.grey)),
const SizedBox(width: 16),
const Text("Server Version: ",
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w600)),
Text(_serverVersion,
style:
const TextStyle(color: Colors.grey))
],
),
],
)
],
GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
builder: (BuildContext context) {
return const AvailableServerBottomSheet();
});
},
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).brightness == Brightness.light
? Colors.grey[100]
: Colors.grey[900],
),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
Icon(Icons.dns_outlined,
color: (() {
switch (_serverStatus) {
case ServerStatus.connected:
return Colors.green;
case ServerStatus.connecting:
return Colors.orange;
case ServerStatus.disconnected:
return Colors.red;
}
}())),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Connected to",
style: TextStyle(color: Colors.grey)),
Text(globals.serverManager.getServerUrl(),
style: const TextStyle(
fontWeight: FontWeight.bold,