Compare commits

...

57 Commits
v0.9.1 ... main

Author SHA1 Message Date
d11d0cfe5c
bump version to 1.4.0 2024-08-01 04:49:21 +02:00
21042fa2b7
fixed bug with newly created tags 2024-07-27 00:24:00 +02:00
8507aaa8bd
added link to shlink docs (create api key) 2024-07-26 23:59:48 +02:00
e673dd7b64
deleted linux/ 2024-07-26 23:36:15 +02:00
5c318c6237
added back spacing (some weird bug) 2024-07-26 23:09:02 +02:00
d33f5757c9
changed tag spacing 2024-07-26 23:04:47 +02:00
c5ab724a9e
minor fixes 2024-07-26 23:00:03 +02:00
50ec0cb49f
FEATURE: add and remove tags from short URLs 2024-07-26 22:52:44 +02:00
202ab20747
a bit of refactoring 2024-07-26 20:30:13 +02:00
4f8cc9e4b6
added global theme 2024-07-26 19:32:25 +02:00
e1c9bc4d80
bump version to 1.3.1 2024-07-25 20:17:55 +02:00
413275df38
fixed bug where server list stays empty 2024-07-25 20:17:27 +02:00
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
69c8870997
support multiple shlink instances 2024-07-25 19:11:24 +02:00
ce28066d29
fixed license 2024-07-25 17:40:17 +02:00
e9f98c2171
edited README 2024-07-25 17:39:42 +02:00
rainloreley
39483dca54 Apply automatic changes 2024-07-25 15:38:31 +00:00
ba058e2af3
formatting 2024-07-25 17:37:17 +02:00
0eea6ee9a2
dependency 2024-07-25 17:35:12 +02:00
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
6a5cebdb68
change view title when editing short URL 2024-07-25 14:50:16 +02:00
6d654210fa
Update README.md 2024-04-01 19:46:01 +02:00
a3dc9e130e
Update fix-formatting-and-license.yml 2024-04-01 19:45:10 +02:00
a1b0f37fa1
update gradle 2024-03-31 22:59:22 +02:00
99e1b317ad
update gradle 2024-03-31 22:50:16 +02:00
8590fbb89a
bump version to 1.2.0 2024-03-31 22:21:07 +02:00
rainloreley
ec4820a1d1 Apply automatic changes 2024-03-31 19:59:49 +00:00
65ee8d1879
formatting 2024-03-31 21:58:31 +02:00
rainloreley
e1a7235a90 Apply automatic changes 2024-03-31 19:56:51 +00:00
b29429c6b2
Merge remote-tracking branch 'origin/main' 2024-03-31 21:55:36 +02:00
07db92643a
changed README.md 2024-03-31 21:55:27 +02:00
0cb9debd88
Update fix-formatting-and-license.yml 2024-03-31 21:52:13 +02:00
508a392a94
Update fix-formatting-and-license.yml 2024-03-31 21:46:07 +02:00
3849ae09ed
Update fix-formatting-and-license.yml 2024-03-31 21:41:11 +02:00
cfa47371e7
Update fix-formatting-and-license.yml 2024-03-31 21:38:12 +02:00
9a31ea73df
Update fix-formatting-and-license.yml 2024-03-31 21:35:48 +02:00
0af6d188e3
Create fix-formatting-and-license.yml 2024-03-31 21:29:41 +02:00
1ee99f367d
added explicit type annotation to variables 2024-03-31 21:17:12 +02:00
0594452584
made urls in short url detail view clickable 2024-03-31 21:08:58 +02:00
b0988fcb3d
fixed missing version info 2024-03-31 21:08:41 +02:00
dc03457397
tiny code change 2024-03-31 21:08:23 +02:00
4dcce26caf
added share sheet intent 2024-03-31 21:07:30 +02:00
591713a444
Revert "added core files with l10n"
This reverts commit 9a2d3a712503c7bf15073ce5226ebd638a0924dc.
2024-03-31 19:25:49 +02:00
9a2d3a7125
added core files with l10n 2024-03-31 17:55:01 +02:00
86dcba28e4
updated dependency 2024-03-31 16:19:31 +02:00
fd77e6d2e3
bump version to 1.1.1 2024-03-31 16:07:48 +02:00
e04e490d44
Merge remote-tracking branch 'origin/main' 2024-03-31 16:03:03 +02:00
a9c51da135
fixed #2 2024-03-31 16:02:38 +02:00
b99d8a934d
Update README.md 2024-03-19 16:45:50 +01:00
e477c027a9
updated privacy policy url 2024-02-23 16:33:00 +01:00
0a9e5d22df
release 1.1.0 2024-01-28 23:14:11 +01:00
4172c968c4
edit existing short urls 2024-01-28 22:55:28 +01:00
d00ba4b4e9
generate tag color based on text 2024-01-28 01:05:35 +01:00
086ca47fc0
formatting 2024-01-28 00:32:09 +01:00
7b16683d10
a ton of refactoring and commenting 2024-01-27 23:07:06 +01:00
72 changed files with 4365 additions and 2376 deletions

View File

@ -1,11 +1,11 @@
# 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.
# This file should be version controlled and should not be manually edited.
version:
revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf
channel: stable
revision: "2f708eb8396e362e280fac22cf171c2cb467343c"
channel: "stable"
project_type: app
@ -13,11 +13,11 @@ project_type: app
migration:
platforms:
- platform: root
create_revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf
base_revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf
- platform: android
create_revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf
base_revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf
create_revision: 2f708eb8396e362e280fac22cf171c2cb467343c
base_revision: 2f708eb8396e362e280fac22cf171c2cb467343c
- platform: linux
create_revision: 2f708eb8396e362e280fac22cf171c2cb467343c
base_revision: 2f708eb8396e362e280fac22cf171c2cb467343c
# User provided section

View File

@ -12,23 +12,24 @@ Shlink Manager is an app for Android to see and manage all shortened URLs create
<img src="https://raw.githubusercontent.com/steverichey/google-play-badge-svg/master/img/en_get.svg" alt="Play Store download" width="30%"/>
</a>
[![Codemagic build status](https://api.codemagic.io/apps/66096ec96d57699debb805f8/66096ec96d57699debb805f7/status_badge.svg)](https://codemagic.io/apps/66096ec96d57699debb805f8/66096ec96d57699debb805f7/latest_build)
## 📱 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/>
✅ Quickly create Short URL via Share Sheet<br/>
✅ View rule-based redirects (no editing yet)<br/>
✅ Use multiple Shlink instances<br/>
## 🔨 To Do
- [ ] Edit existing short URLs
- [ ] Add support for iOS (maybe in the future)
- [ ] add tags
- [ ] specify individual long URLs per device
- [ ] improve app icon
- [ ] Refactor code
- [ ] ...and more
## 💻 Development

View File

@ -9,7 +9,24 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
rules:
# Style rules
- camel_case_types
- library_names
- avoid_catching_errors
- avoid_empty_else
- unnecessary_brace_in_string_interps
- avoid_redundant_argument_values
- leading_newlines_in_multiline_strings
# formatting
- lines_longer_than_80_chars
- curly_braces_in_flow_control_structures
# doc comments
- slash_for_doc_comments
- package_api_docs
# 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
@ -21,7 +38,6 @@ linter:
# 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

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

@ -8,6 +8,7 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
</intent>
</queries>
<application
android:label="Shlink Manager"
@ -33,17 +34,11 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity
android:name=".ProcessURLActivity"
android:label="Shorten URL"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay"
tools:targetApi="cupcake">
<intent-filter>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain"/>
<intent-filter
android:label="Create Short URL">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
</intent-filter>
</activity>

View File

@ -0,0 +1,25 @@
// Generated file.
//
// If you wish to remove Flutter's multidex support, delete this entire file.
//
// Modifications to this file should be done in a copy under a different name
// as this file may be regenerated.
package io.flutter.app;
import android.app.Application;
import android.content.Context;
import androidx.annotation.CallSuper;
import androidx.multidex.MultiDex;
/**
* Extension of {@link android.app.Application}, adding multidex support.
*/
public class FlutterMultiDexApplication extends Application {
@Override
@CallSuper
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
MultiDex.install(this);
}
}

View File

@ -6,7 +6,7 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:7.2.0'
classpath 'com.android.tools.build:gradle:7.3.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}

View File

@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip

View File

@ -1,10 +0,0 @@
import 'package:shlink_app/API/Classes/ShlinkStats/ShlinkStats_Visits.dart';
class ShlinkStats {
ShlinkStats_Visits nonOrphanVisits;
ShlinkStats_Visits orphanVisits;
int shortUrlsCount;
int tagsCount;
ShlinkStats(this.nonOrphanVisits, this.orphanVisits, this.shortUrlsCount, this.tagsCount);
}

View File

@ -1,12 +0,0 @@
class ShlinkStats_Visits {
int total;
int nonBots;
int bots;
ShlinkStats_Visits(this.total, this.nonBots, this.bots);
ShlinkStats_Visits.fromJson(Map<String, dynamic> json)
: total = json["total"],
nonBots = json["nonBots"],
bots = json["bots"];
}

View File

@ -0,0 +1,19 @@
import 'package:shlink_app/API/Classes/ShortURL/visits_summary.dart';
/// Includes data about the statistics of a Shlink instance
class ShlinkStats {
/// Data about non-orphan visits
VisitsSummary nonOrphanVisits;
/// Data about orphan visits (without any valid slug assigned)
VisitsSummary orphanVisits;
/// Total count of all short URLs
int shortUrlsCount;
/// Total count all all tags
int tagsCount;
ShlinkStats(this.nonOrphanVisits, this.orphanVisits, this.shortUrlsCount,
this.tagsCount);
}

View File

@ -0,0 +1,19 @@
/// Visitor data
class ShlinkStatsVisits {
/// Count of URL visits
int total;
/// Count of URL visits from humans
int nonBots;
/// Count of URL visits from bots/crawlers
int bots;
ShlinkStatsVisits(this.total, this.nonBots, this.bots);
/// Converts the JSON data from the API to an instance of [ShlinkStatsVisits]
ShlinkStatsVisits.fromJson(Map<String, dynamic> json)
: total = json["total"],
nonBots = json["nonBots"],
bots = json["bots"];
}

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,33 +0,0 @@
import 'package:shlink_app/API/Classes/ShortURL/ShortURL_DeviceLongUrls.dart';
import 'package:shlink_app/API/Classes/ShortURL/ShortURL_Meta.dart';
import 'package:shlink_app/API/Classes/ShortURL/ShortURL_VisitsSummary.dart';
class ShortURL {
String shortCode;
String shortUrl;
String longUrl;
ShortURL_DeviceLongUrls deviceLongUrls;
DateTime dateCreated;
ShortURL_VisitsSummary visitsSummary;
List<dynamic> 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<String, dynamic> 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"];
}

View File

@ -1,18 +0,0 @@
class ShortURL_DeviceLongUrls {
final String? android;
final String? ios;
final String? desktop;
ShortURL_DeviceLongUrls(this.android, this.ios, this.desktop);
ShortURL_DeviceLongUrls.fromJson(Map<String, dynamic> json)
: android = json["android"],
ios = json["ios"],
desktop = json["desktop"];
Map<String, dynamic> toJson() => {
"android": android,
"ios": ios,
"desktop": desktop
};
}

View File

@ -1,12 +0,0 @@
class ShortURL_Meta {
DateTime? validSince;
DateTime? validUntil;
int? maxVisits;
ShortURL_Meta(this.validSince, this.validUntil, this.maxVisits);
ShortURL_Meta.fromJson(Map<String, dynamic> json):
validSince = json["validSince"] != null ? DateTime.parse(json["validSince"]) : null,
validUntil = json["validUntil"] != null ? DateTime.parse(json["validUntil"]) : null,
maxVisits = json["maxVisits"];
}

View File

@ -1,12 +0,0 @@
class ShortURL_VisitsSummary {
int total;
int nonBots;
int bots;
ShortURL_VisitsSummary(this.total, this.nonBots, this.bots);
ShortURL_VisitsSummary.fromJson(Map<String, dynamic> json):
total = json["total"] as int,
nonBots = json["nonBots"] as int,
bots = json["bots"] as int;
}

View File

@ -0,0 +1,78 @@
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
String shortCode;
/// Entire short URL
String shortUrl;
/// Long URL where the user gets redirected to
String longUrl;
/// Creation date of the short URL
DateTime dateCreated;
/// Visitor data
VisitsSummary visitsSummary;
/// List of tags assigned to this short URL
List<String> tags;
/// Metadata
ShortURLMeta meta;
/// Associated domain
String? domain;
/// Optional title
String? title;
/// Whether the short URL is crawlable by a web crawler
bool crawlable;
List<RedirectRule>? redirectRules;
ShortURL(
this.shortCode,
this.shortUrl,
this.longUrl,
this.dateCreated,
this.visitsSummary,
this.tags,
this.meta,
this.domain,
this.title,
this.crawlable);
/// Converts the JSON data from the API to an instance of [ShortURL]
ShortURL.fromJson(Map<String, dynamic> json)
: shortCode = json["shortCode"],
shortUrl = json["shortUrl"],
longUrl = json["longUrl"],
dateCreated = DateTime.parse(json["dateCreated"]),
visitsSummary = VisitsSummary.fromJson(json["visitsSummary"]),
tags =
(json["tags"] as List<dynamic>).map((e) => e.toString()).toList(),
meta = ShortURLMeta.fromJson(json["meta"]),
domain = json["domain"],
title = json["title"],
crawlable = json["crawlable"];
/// Returns an empty class of [ShortURL]
ShortURL.empty()
: shortCode = "",
shortUrl = "",
longUrl = "",
dateCreated = DateTime.now(),
visitsSummary = VisitsSummary(0, 0, 0),
tags = [],
meta = ShortURLMeta(DateTime.now(), DateTime.now(), 0),
domain = "",
title = "",
crawlable = false;
}

View File

@ -0,0 +1,23 @@
/// Metadata for a short URL
class ShortURLMeta {
/// The date since when this short URL has been valid
DateTime? validSince;
/// The data when this short URL expires
DateTime? validUntil;
/// Amount of maximum visits allowed to this short URL
int? maxVisits;
ShortURLMeta(this.validSince, this.validUntil, this.maxVisits);
/// Converts JSON data from the API to an instance of [ShortURLMeta]
ShortURLMeta.fromJson(Map<String, dynamic> json)
: validSince = json["validSince"] != null
? DateTime.parse(json["validSince"])
: null,
validUntil = json["validUntil"] != null
? DateTime.parse(json["validUntil"])
: null,
maxVisits = json["maxVisits"];
}

View File

@ -0,0 +1,19 @@
/// Visitor data
class VisitsSummary {
/// Count of total visits
int total;
/// Count of visits from humans
int nonBots;
/// Count of visits from bots/crawlers
int bots;
VisitsSummary(this.total, this.nonBots, this.bots);
/// Converts JSON data from the API to an instance of [VisitsSummary]
VisitsSummary.fromJson(Map<String, dynamic> json)
: total = json["total"] as int,
nonBots = json["nonBots"] as int,
bots = json["bots"] as int;
}

View File

@ -1,37 +0,0 @@
import '../ShortURL/ShortURL_DeviceLongUrls.dart';
class ShortURLSubmission {
String longUrl;
ShortURL_DeviceLongUrls? deviceLongUrls;
String? validSince;
String? validUntil;
int? maxVisits;
List<String> tags;
String? title;
bool crawlable;
bool forwardQuery;
String? customSlug;
bool findIfExists;
String? domain;
int? shortCodeLength;
ShortURLSubmission({required this.longUrl, required this.deviceLongUrls, this.validSince, this.validUntil, this.maxVisits, required this.tags, this.title, required this.crawlable, required this.forwardQuery, this.customSlug, required this.findIfExists, this.domain, this.shortCodeLength});
Map<String, dynamic> toJson() {
return {
"longUrl": longUrl,
"deviceLongUrls": deviceLongUrls?.toJson(),
"validSince": validSince,
"validUntil": validUntil,
"maxVisits": maxVisits,
"tags": tags,
"title": title,
"crawlable": crawlable,
"forwardQuery": forwardQuery,
"customSlug": customSlug,
"findIfExists": findIfExists,
"domain": domain,
"shortCodeLength": shortCodeLength
};
}
}

View File

@ -0,0 +1,70 @@
/// Data for a short URL which can be submitted to the server
class ShortURLSubmission {
/// Long URL to redirect to
String longUrl;
/// Date since when this short URL is valid in ISO8601 format
String? validSince;
/// Date until when this short URL is valid in ISO8601 format
String? validUntil;
/// Amount of maximum visits allowed to this short URLs
int? maxVisits;
/// List of tags assigned to this short URL
List<String> tags;
/// Title of the page
String? title;
/// Whether the short URL is crawlable by web crawlers
bool crawlable;
/// Whether to forward query parameters
bool forwardQuery;
/// Custom slug (if not provided a random one will be generated)
String? customSlug;
/// Whether to use an existing short URL if the slug matches
bool findIfExists;
/// Domain to use
String? domain;
/// Length of the slug if a custom one is not provided
int? shortCodeLength;
ShortURLSubmission(
{required this.longUrl,
this.validSince,
this.validUntil,
this.maxVisits,
required this.tags,
this.title,
required this.crawlable,
required this.forwardQuery,
this.customSlug,
required this.findIfExists,
this.domain,
this.shortCodeLength});
/// Converts class data to a JSON object
Map<String, dynamic> toJson() {
return {
"longUrl": longUrl,
"validSince": validSince,
"validUntil": validUntil,
"maxVisits": maxVisits,
"tags": tags,
"title": title,
"crawlable": crawlable,
"forwardQuery": forwardQuery,
"customSlug": customSlug,
"findIfExists": findIfExists,
"domain": domain,
"shortCodeLength": shortCodeLength
};
}
}

View File

@ -0,0 +1,20 @@
import 'package:shlink_app/API/Classes/ShortURL/visits_summary.dart';
/// Tag with stats data
class TagWithStats {
/// Tag name
String tag;
/// Amount of short URLs using this tag
int shortUrlsCount;
/// visits summary for tag
VisitsSummary visitsSummary;
TagWithStats(this.tag, this.shortUrlsCount, this.visitsSummary);
TagWithStats.fromJson(Map<String, dynamic> json)
: tag = json["tag"] as String,
shortUrlsCount = json["shortUrlsCount"] as int,
visitsSummary = VisitsSummary.fromJson(json["visitsSummary"]);
}

View File

@ -2,27 +2,31 @@ import 'dart:async';
import 'dart:convert';
import 'package:dartz/dartz.dart';
import 'package:http/http.dart' as http;
import '../ServerManager.dart';
import '../server_manager.dart';
FutureOr<Either<String, Failure>> API_connect(String? api_key, String? server_url, String apiVersion) async {
/// Tries to connect to the Shlink server
FutureOr<Either<String, Failure>> apiConnect(
String? apiKey, String? serverUrl, String apiVersion) async {
try {
final response = await http.get(Uri.parse("${server_url}/rest/v${apiVersion}/short-urls"), headers: {
"X-Api-Key": api_key ?? "",
final response = await http
.get(Uri.parse("$serverUrl/rest/v$apiVersion/short-urls"), headers: {
"X-Api-Key": apiKey ?? "",
});
if (response.statusCode == 200) {
return left("");
}
else {
} else {
try {
var jsonBody = jsonDecode(response.body);
return right(ApiFailure(type: jsonBody["type"], detail: jsonBody["detail"], title: jsonBody["title"], status: jsonBody["status"]));
}
catch(resErr) {
return right(ApiFailure(
type: jsonBody["type"],
detail: jsonBody["detail"],
title: jsonBody["title"],
status: jsonBody["status"]));
} catch (resErr) {
return right(RequestFailure(response.statusCode, resErr.toString()));
}
}
}
catch(reqErr) {
} catch (reqErr) {
return right(RequestFailure(0, reqErr.toString()));
}
}
}

View File

@ -1,29 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:dartz/dartz.dart';
import 'package:http/http.dart' as http;
import '../ServerManager.dart';
FutureOr<Either<String, Failure>> API_deleteShortUrl(String shortCode, String? api_key, String? server_url, String apiVersion) async {
try {
final response = await http.delete(Uri.parse("${server_url}/rest/v${apiVersion}/short-urls/${shortCode}"), headers: {
"X-Api-Key": api_key ?? "",
});
if (response.statusCode == 204) {
// get returned short url
return left("");
}
else {
try {
var jsonBody = jsonDecode(response.body);
return right(ApiFailure(type: jsonBody["type"], detail: jsonBody["detail"], title: jsonBody["title"], status: jsonBody["status"]));
}
catch(resErr) {
return right(RequestFailure(response.statusCode, resErr.toString()));
}
}
}
catch(reqErr) {
return right(RequestFailure(0, reqErr.toString()));
}
}

View File

@ -0,0 +1,34 @@
import 'dart:async';
import 'dart:convert';
import 'package:dartz/dartz.dart';
import 'package:http/http.dart' as http;
import '../server_manager.dart';
/// Deletes a short URL from the server
FutureOr<Either<String, Failure>> apiDeleteShortUrl(String shortCode,
String? apiKey, String? serverUrl, String apiVersion) async {
try {
final response = await http.delete(
Uri.parse("$serverUrl/rest/v$apiVersion/short-urls/$shortCode"),
headers: {
"X-Api-Key": apiKey ?? "",
});
if (response.statusCode == 204) {
// get returned short url
return left("");
} else {
try {
var jsonBody = jsonDecode(response.body);
return right(ApiFailure(
type: jsonBody["type"],
detail: jsonBody["detail"],
title: jsonBody["title"],
status: jsonBody["status"]));
} catch (resErr) {
return right(RequestFailure(response.statusCode, resErr.toString()));
}
}
} catch (reqErr) {
return right(RequestFailure(0, reqErr.toString()));
}
}

View File

@ -1,33 +0,0 @@
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/ShortURL.dart';
import '../ServerManager.dart';
FutureOr<Either<List<ShortURL>, Failure>> API_getRecentShortUrls(String? api_key, String? server_url, String apiVersion) async {
try {
final response = await http.get(Uri.parse("${server_url}/rest/v${apiVersion}/short-urls?itemsPerPage=5&orderBy=dateCreated-DESC"), headers: {
"X-Api-Key": api_key ?? "",
});
if (response.statusCode == 200) {
var jsonResponse = jsonDecode(response.body);
List<ShortURL> shortURLs = (jsonResponse["shortUrls"]["data"] as List<dynamic>).map((e) {
return ShortURL.fromJson(e);
}).toList();
return left(shortURLs);
}
else {
try {
var jsonBody = jsonDecode(response.body);
return right(ApiFailure(type: jsonBody["type"], detail: jsonBody["detail"], title: jsonBody["title"], status: jsonBody["status"]));
}
catch(resErr) {
return right(RequestFailure(response.statusCode, resErr.toString()));
}
}
}
catch(reqErr) {
return right(RequestFailure(0, reqErr.toString()));
}
}

View File

@ -1,29 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:dartz/dartz.dart';
import 'package:http/http.dart' as http;
import '../ServerManager.dart';
FutureOr<Either<ServerHealthResponse, Failure>> API_getServerHealth(String? api_key, String? server_url, String apiVersion) async {
try {
final response = await http.get(Uri.parse("${server_url}/rest/v${apiVersion}/health"), headers: {
"X-Api-Key": api_key ?? "",
});
if (response.statusCode == 200) {
var jsonData = jsonDecode(response.body);
return left(ServerHealthResponse(status: jsonData["status"], version: jsonData["version"]));
}
else {
try {
var jsonBody = jsonDecode(response.body);
return right(ApiFailure(type: jsonBody["type"], detail: jsonBody["detail"], title: jsonBody["title"], status: jsonBody["status"]));
}
catch(resErr) {
return right(RequestFailure(response.statusCode, resErr.toString()));
}
}
}
catch(reqErr) {
return right(RequestFailure(0, reqErr.toString()));
}
}

View File

@ -1,149 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:dartz/dartz.dart';
import 'package:http/http.dart' as http;
import 'package:shlink_app/API/Classes/ShlinkStats/ShlinkStats_Visits.dart';
import '../Classes/ShlinkStats/ShlinkStats.dart';
import '../ServerManager.dart';
FutureOr<Either<ShlinkStats, Failure>> API_getShlinkStats(String? api_key, String? server_url, String apiVersion) async {
var nonOrphanVisits;
var orphanVisits;
var shortUrlsCount;
var tagsCount;
var failure;
var visitStatsResponse = await _getVisitStats(api_key, server_url, apiVersion);
visitStatsResponse.fold((l) {
nonOrphanVisits = l.nonOrphanVisits;
orphanVisits = l.orphanVisits;
}, (r) {
failure = r;
return right(r);
});
var shortUrlsCountResponse = await _getShortUrlsCount(api_key, server_url, apiVersion);
shortUrlsCountResponse.fold((l) {
shortUrlsCount = l;
}, (r) {
failure = r;
return right(r);
});
var tagsCountResponse = await _getTagsCount(api_key, server_url, apiVersion);
tagsCountResponse.fold((l) {
tagsCount = l;
}, (r) {
failure = r;
return right(r);
});
while(failure == null && (nonOrphanVisits == null || orphanVisits == null || shortUrlsCount == null || tagsCount == null)) {
await Future.delayed(Duration(milliseconds: 100));
}
if (failure != null) {
return right(failure);
}
return left(ShlinkStats(nonOrphanVisits, orphanVisits, shortUrlsCount, tagsCount));
}
/*Future<tuple.Tuple3<FutureOr<Either<_ShlinkVisitStats, Failure>>, FutureOr<Either<int, Failure>>, FutureOr<Either<int, Failure>>>> waiterFunction(String? api_key, String? server_url, String apiVersion) async {
late FutureOr<Either<_ShlinkVisitStats, Failure>> visits;
late FutureOr<Either<int, Failure>> shortUrlsCount;
late FutureOr<Either<int, Failure>> tagsCount;
await Future.wait([
_getVisitStats(api_key, server_url, apiVersion).then((value) => visits = value),
_getShortUrlsCount(api_key, server_url, apiVersion).then((value) => shortUrlsCount = value),
_getTagsCount(api_key, server_url, apiVersion).then((value) => tagsCount = value),
]);
return Future.value(tuple.Tuple3(visits, shortUrlsCount, tagsCount));
}*/
class _ShlinkVisitStats {
ShlinkStats_Visits nonOrphanVisits;
ShlinkStats_Visits orphanVisits;
_ShlinkVisitStats(this.nonOrphanVisits, this.orphanVisits);
}
FutureOr<Either<_ShlinkVisitStats, Failure>> _getVisitStats(String? api_key, String? server_url, String apiVersion) async {
try {
final response = await http.get(Uri.parse("${server_url}/rest/v${apiVersion}/visits"), headers: {
"X-Api-Key": api_key ?? "",
});
if (response.statusCode == 200) {
var jsonResponse = jsonDecode(response.body);
var nonOrphanVisits = ShlinkStats_Visits.fromJson(jsonResponse["visits"]["nonOrphanVisits"]);
var orphanVisits = ShlinkStats_Visits.fromJson(jsonResponse["visits"]["orphanVisits"]);
return left(_ShlinkVisitStats(nonOrphanVisits, orphanVisits));
}
else {
try {
var jsonBody = jsonDecode(response.body);
return right(ApiFailure(type: jsonBody["type"], detail: jsonBody["detail"], title: jsonBody["title"], status: jsonBody["status"]));
}
catch(resErr) {
return right(RequestFailure(response.statusCode, resErr.toString()));
}
}
}
catch(reqErr) {
return right(RequestFailure(0, reqErr.toString()));
}
}
// get short urls count
FutureOr<Either<int, Failure>> _getShortUrlsCount(String? api_key, String? server_url, String apiVersion) async {
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) {
var jsonResponse = jsonDecode(response.body);
return left(jsonResponse["shortUrls"]["pagination"]["totalItems"]);
}
else {
try {
var jsonBody = jsonDecode(response.body);
return right(ApiFailure(type: jsonBody["type"], detail: jsonBody["detail"], title: jsonBody["title"], status: jsonBody["status"]));
}
catch(resErr) {
return right(RequestFailure(response.statusCode, resErr.toString()));
}
}
}
catch(reqErr) {
return right(RequestFailure(0, reqErr.toString()));
}
}
// get tags count
FutureOr<Either<int, Failure>> _getTagsCount(String? api_key, String? server_url, String apiVersion) async {
try {
final response = await http.get(Uri.parse("${server_url}/rest/v${apiVersion}/tags"), headers: {
"X-Api-Key": api_key ?? "",
});
if (response.statusCode == 200) {
var jsonResponse = jsonDecode(response.body);
return left(jsonResponse["tags"]["pagination"]["totalItems"]);
}
else {
try {
var jsonBody = jsonDecode(response.body);
return right(ApiFailure(type: jsonBody["type"], detail: jsonBody["detail"], title: jsonBody["title"], status: jsonBody["status"]));
}
catch(resErr) {
return right(RequestFailure(response.statusCode, resErr.toString()));
}
}
}
catch(reqErr) {
return right(RequestFailure(0, reqErr.toString()));
}
}

View File

@ -1,60 +0,0 @@
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/ShortURL.dart';
import '../ServerManager.dart';
FutureOr<Either<List<ShortURL>, Failure>> API_getShortUrls(String? api_key, String? server_url, String apiVersion) async {
var _currentPage = 1;
var _maxPages = 2;
List<ShortURL> _allUrls = [];
Failure? error;
while (_currentPage <= _maxPages) {
final response = await _getShortUrlPage(_currentPage, api_key, server_url, apiVersion);
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<Either<ShortURLPageResponse, Failure>> _getShortUrlPage(int page, String? api_key, String? server_url, String apiVersion) 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<ShortURL> shortURLs = (jsonResponse["shortUrls"]["data"] as List<dynamic>).map((e) {
return ShortURL.fromJson(e);
}).toList();
return left(ShortURLPageResponse(shortURLs, pagesCount));
}
else {
try {
var jsonBody = jsonDecode(response.body);
return right(ApiFailure(type: jsonBody["type"], detail: jsonBody["detail"], title: jsonBody["title"], status: jsonBody["status"]));
}
catch(resErr) {
return right(RequestFailure(response.statusCode, resErr.toString()));
}
}
}
catch(reqErr) {
return right(RequestFailure(0, reqErr.toString()));
}
}

View File

@ -0,0 +1,40 @@
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/short_url.dart';
import '../server_manager.dart';
/// Gets recently created short URLs from the server
FutureOr<Either<List<ShortURL>, Failure>> apiGetRecentShortUrls(
String? apiKey, String? serverUrl, String apiVersion) async {
try {
final response = await http.get(
Uri.parse(
"$serverUrl/rest/v$apiVersion/short-urls?itemsPerPage=5&orderBy=dateCreated-DESC"),
headers: {
"X-Api-Key": apiKey ?? "",
});
if (response.statusCode == 200) {
var jsonResponse = jsonDecode(response.body);
List<ShortURL> shortURLs =
(jsonResponse["shortUrls"]["data"] as List<dynamic>).map((e) {
return ShortURL.fromJson(e);
}).toList();
return left(shortURLs);
} else {
try {
var jsonBody = jsonDecode(response.body);
return right(ApiFailure(
type: jsonBody["type"],
detail: jsonBody["detail"],
title: jsonBody["title"],
status: jsonBody["status"]));
} catch (resErr) {
return right(RequestFailure(response.statusCode, resErr.toString()));
}
}
} catch (reqErr) {
return right(RequestFailure(0, reqErr.toString()));
}
}

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,34 @@
import 'dart:async';
import 'dart:convert';
import 'package:dartz/dartz.dart';
import 'package:http/http.dart' as http;
import '../server_manager.dart';
/// Gets the status of the server and health information
FutureOr<Either<ServerHealthResponse, Failure>> apiGetServerHealth(
String? apiKey, String? serverUrl, String apiVersion) async {
try {
final response = await http
.get(Uri.parse("$serverUrl/rest/v$apiVersion/health"), headers: {
"X-Api-Key": apiKey ?? "",
});
if (response.statusCode == 200) {
var jsonData = jsonDecode(response.body);
return left(ServerHealthResponse(
status: jsonData["status"], version: jsonData["version"]));
} else {
try {
var jsonBody = jsonDecode(response.body);
return right(ApiFailure(
type: jsonBody["type"],
detail: jsonBody["detail"],
title: jsonBody["title"],
status: jsonBody["status"]));
} catch (resErr) {
return right(RequestFailure(response.statusCode, resErr.toString()));
}
}
} catch (reqErr) {
return right(RequestFailure(0, reqErr.toString()));
}
}

View File

@ -0,0 +1,148 @@
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/visits_summary.dart';
import '../Classes/ShlinkStats/shlink_stats.dart';
import '../server_manager.dart';
/// Gets statistics about the Shlink server
FutureOr<Either<ShlinkStats, Failure>> apiGetShlinkStats(
String? apiKey, String? serverUrl, String apiVersion) async {
VisitsSummary? nonOrphanVisits;
VisitsSummary? orphanVisits;
int shortUrlsCount = 0;
int tagsCount = 0;
Failure? failure;
var visitStatsResponse = await _getVisitStats(apiKey, serverUrl, apiVersion);
visitStatsResponse.fold((l) {
nonOrphanVisits = l.nonOrphanVisits;
orphanVisits = l.orphanVisits;
}, (r) {
failure = r;
return right(r);
});
var shortUrlsCountResponse =
await _getShortUrlsCount(apiKey, serverUrl, apiVersion);
shortUrlsCountResponse.fold((l) {
shortUrlsCount = l;
}, (r) {
failure = r;
return right(r);
});
var tagsCountResponse = await _getTagsCount(apiKey, serverUrl, apiVersion);
tagsCountResponse.fold((l) {
tagsCount = l;
}, (r) {
failure = r;
return right(r);
});
while (failure == null && (orphanVisits == null)) {
await Future.delayed(const Duration(milliseconds: 100));
}
if (failure != null) {
return right(failure!);
}
return left(
ShlinkStats(nonOrphanVisits!, orphanVisits!, shortUrlsCount, tagsCount));
}
class _ShlinkVisitStats {
VisitsSummary nonOrphanVisits;
VisitsSummary orphanVisits;
_ShlinkVisitStats(this.nonOrphanVisits, this.orphanVisits);
}
/// Gets visitor statistics about the entire server
FutureOr<Either<_ShlinkVisitStats, Failure>> _getVisitStats(
String? apiKey, String? serverUrl, String apiVersion) async {
try {
final response = await http
.get(Uri.parse("$serverUrl/rest/v$apiVersion/visits"), headers: {
"X-Api-Key": apiKey ?? "",
});
if (response.statusCode == 200) {
var jsonResponse = jsonDecode(response.body);
var nonOrphanVisits =
VisitsSummary.fromJson(jsonResponse["visits"]["nonOrphanVisits"]);
var orphanVisits =
VisitsSummary.fromJson(jsonResponse["visits"]["orphanVisits"]);
return left(_ShlinkVisitStats(nonOrphanVisits, orphanVisits));
} else {
try {
var jsonBody = jsonDecode(response.body);
return right(ApiFailure(
type: jsonBody["type"],
detail: jsonBody["detail"],
title: jsonBody["title"],
status: jsonBody["status"]));
} catch (resErr) {
return right(RequestFailure(response.statusCode, resErr.toString()));
}
}
} catch (reqErr) {
return right(RequestFailure(0, reqErr.toString()));
}
}
/// Gets amount of short URLs
FutureOr<Either<int, Failure>> _getShortUrlsCount(
String? apiKey, String? serverUrl, String apiVersion) async {
try {
final response = await http
.get(Uri.parse("$serverUrl/rest/v$apiVersion/short-urls"), headers: {
"X-Api-Key": apiKey ?? "",
});
if (response.statusCode == 200) {
var jsonResponse = jsonDecode(response.body);
return left(jsonResponse["shortUrls"]["pagination"]["totalItems"]);
} else {
try {
var jsonBody = jsonDecode(response.body);
return right(ApiFailure(
type: jsonBody["type"],
detail: jsonBody["detail"],
title: jsonBody["title"],
status: jsonBody["status"]));
} catch (resErr) {
return right(RequestFailure(response.statusCode, resErr.toString()));
}
}
} catch (reqErr) {
return right(RequestFailure(0, reqErr.toString()));
}
}
/// Gets amount of tags
FutureOr<Either<int, Failure>> _getTagsCount(
String? apiKey, String? serverUrl, String apiVersion) async {
try {
final response = await http
.get(Uri.parse("$serverUrl/rest/v$apiVersion/tags"), headers: {
"X-Api-Key": apiKey ?? "",
});
if (response.statusCode == 200) {
var jsonResponse = jsonDecode(response.body);
return left(jsonResponse["tags"]["pagination"]["totalItems"]);
} else {
try {
var jsonBody = jsonDecode(response.body);
return right(ApiFailure(
type: jsonBody["type"],
detail: jsonBody["detail"],
title: jsonBody["title"],
status: jsonBody["status"]));
} catch (resErr) {
return right(RequestFailure(response.statusCode, resErr.toString()));
}
}
} catch (reqErr) {
return right(RequestFailure(0, reqErr.toString()));
}
}

View File

@ -0,0 +1,69 @@
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/short_url.dart';
import '../server_manager.dart';
/// Gets all short URLs
FutureOr<Either<List<ShortURL>, Failure>> apiGetShortUrls(
String? apiKey, String? serverUrl, String apiVersion) async {
var currentPage = 1;
var maxPages = 2;
List<ShortURL> allUrls = [];
Failure? error;
while (currentPage <= maxPages) {
final response =
await _getShortUrlPage(currentPage, apiKey, serverUrl, apiVersion);
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!);
}
}
/// Gets all short URLs from a specific page
FutureOr<Either<ShortURLPageResponse, Failure>> _getShortUrlPage(
int page, String? apiKey, String? serverUrl, String apiVersion) async {
try {
final response = await http.get(
Uri.parse("$serverUrl/rest/v$apiVersion/short-urls?page=$page"),
headers: {
"X-Api-Key": apiKey ?? "",
});
if (response.statusCode == 200) {
var jsonResponse = jsonDecode(response.body);
var pagesCount =
jsonResponse["shortUrls"]["pagination"]["pagesCount"] as int;
List<ShortURL> shortURLs =
(jsonResponse["shortUrls"]["data"] as List<dynamic>).map((e) {
return ShortURL.fromJson(e);
}).toList();
return left(ShortURLPageResponse(shortURLs, pagesCount));
} else {
try {
var jsonBody = jsonDecode(response.body);
return right(ApiFailure(
type: jsonBody["type"],
detail: jsonBody["detail"],
title: jsonBody["title"],
status: jsonBody["status"]));
} catch (resErr) {
return right(RequestFailure(response.statusCode, resErr.toString()));
}
}
} catch (reqErr) {
return right(RequestFailure(0, reqErr.toString()));
}
}

View File

@ -0,0 +1,68 @@
import 'dart:async';
import 'dart:convert';
import 'package:dartz/dartz.dart';
import 'package:http/http.dart' as http;
import 'package:shlink_app/API/Classes/Tag/tag_with_stats.dart';
import '../server_manager.dart';
/// Gets all tags
FutureOr<Either<List<TagWithStats>, Failure>> apiGetTagsWithStats(
String? apiKey, String? serverUrl, String apiVersion) async {
var currentPage = 1;
var maxPages = 2;
List<TagWithStats> allTags = [];
Failure? error;
while (currentPage <= maxPages) {
final response =
await _getTagsWithStatsPage(currentPage, apiKey, serverUrl, apiVersion);
response.fold((l) {
allTags.addAll(l.tags);
maxPages = l.totalPages;
currentPage++;
}, (r) {
maxPages = 0;
error = r;
});
}
if (error == null) {
return left(allTags);
} else {
return right(error!);
}
}
/// Gets all tags from a specific page
FutureOr<Either<TagsWithStatsPageResponse, Failure>> _getTagsWithStatsPage(
int page, String? apiKey, String? serverUrl, String apiVersion) async {
try {
final response = await http.get(
Uri.parse("$serverUrl/rest/v$apiVersion/tags/stats?page=$page"),
headers: {
"X-Api-Key": apiKey ?? "",
});
if (response.statusCode == 200) {
var jsonResponse = jsonDecode(response.body);
var pagesCount = jsonResponse["tags"]["pagination"]["pagesCount"] as int;
List<TagWithStats> tags =
(jsonResponse["tags"]["data"] as List<dynamic>).map((e) {
return TagWithStats.fromJson(e);
}).toList();
return left(TagsWithStatsPageResponse(tags, pagesCount));
} else {
try {
var jsonBody = jsonDecode(response.body);
return right(ApiFailure(
type: jsonBody["type"],
detail: jsonBody["detail"],
title: jsonBody["title"],
status: jsonBody["status"]));
} 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,31 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:dartz/dartz.dart';
import 'package:http/http.dart' as http;
import 'package:shlink_app/API/Classes/ShortURLSubmission/ShortURLSubmission.dart';
import '../ServerManager.dart';
FutureOr<Either<String, Failure>> API_submitShortUrl(ShortURLSubmission shortUrl, String? api_key, String? server_url, String apiVersion) async {
try {
final response = await http.post(Uri.parse("${server_url}/rest/v${apiVersion}/short-urls"), headers: {
"X-Api-Key": api_key ?? "",
}, body: jsonEncode(shortUrl.toJson()));
if (response.statusCode == 200) {
// get returned short url
var jsonBody = jsonDecode(response.body);
return left(jsonBody["shortUrl"]);
}
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"] ?? null));
}
catch(resErr) {
return right(RequestFailure(response.statusCode, resErr.toString()));
}
}
}
catch(reqErr) {
return right(RequestFailure(0, reqErr.toString()));
}
}

View File

@ -0,0 +1,42 @@
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/short_url.dart';
import 'package:shlink_app/API/Classes/ShortURLSubmission/short_url_submission.dart';
import '../server_manager.dart';
/// Submits a short URL to a server for it to be added
FutureOr<Either<ShortURL, Failure>> apiSubmitShortUrl(
ShortURLSubmission shortUrl,
String? apiKey,
String? serverUrl,
String apiVersion) async {
try {
final response =
await http.post(Uri.parse("$serverUrl/rest/v$apiVersion/short-urls"),
headers: {
"X-Api-Key": apiKey ?? "",
},
body: jsonEncode(shortUrl.toJson()));
if (response.statusCode == 200) {
// get returned short url
var jsonBody = jsonDecode(response.body);
return left(ShortURL.fromJson(jsonBody));
} 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,50 @@
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/short_url.dart';
import 'package:shlink_app/API/Classes/ShortURLSubmission/short_url_submission.dart';
import '../server_manager.dart';
/// Updates an existing short URL
FutureOr<Either<ShortURL, Failure>> apiUpdateShortUrl(
ShortURLSubmission shortUrl,
String? apiKey,
String? serverUrl,
String apiVersion) async {
String shortCode = shortUrl.customSlug ?? "";
if (shortCode == "") {
return right(RequestFailure(0, "Missing short code"));
}
Map<String, dynamic> shortUrlData = shortUrl.toJson();
shortUrlData.remove("shortCode");
shortUrlData.remove("shortUrl");
try {
final response = await http.patch(
Uri.parse("$serverUrl/rest/v$apiVersion/short-urls/$shortCode"),
headers: {
"X-Api-Key": apiKey ?? "",
},
body: jsonEncode(shortUrlData));
if (response.statusCode == 200) {
// get returned short url
var jsonBody = jsonDecode(response.body);
return left(ShortURL.fromJson(jsonBody));
} 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,143 +0,0 @@
import 'dart:async';
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/ShlinkStats.dart';
import 'package:shlink_app/API/Classes/ShortURL/ShortURL.dart';
import 'package:shlink_app/API/Classes/ShortURLSubmission/ShortURLSubmission.dart';
import 'package:shlink_app/API/Methods/connect.dart';
import 'package:shlink_app/API/Methods/getRecentShortUrls.dart';
import 'package:shlink_app/API/Methods/getServerHealth.dart';
import 'package:shlink_app/API/Methods/getShlinkStats.dart';
import 'package:shlink_app/API/Methods/getShortUrls.dart';
import 'Methods/deleteShortUrl.dart';
import 'Methods/submitShortUrl.dart';
class ServerManager {
String? _server_url;
String? _api_key;
static String apiVersion = "3";
String getServerUrl() {
return _server_url ?? "";
}
String getApiVersion() {
return apiVersion;
}
Future<bool> checkLogin() async {
await _loadCredentials();
return (_server_url != null);
}
Future<void> logOut() async {
const storage = FlutterSecureStorage();
await storage.delete(key: "shlink_url");
await storage.delete(key: "shlink_apikey");
}
Future<void> _loadCredentials() async {
const storage = FlutterSecureStorage();
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool('first_run') ?? true) {
FlutterSecureStorage storage = FlutterSecureStorage();
await storage.deleteAll();
prefs.setBool('first_run', false);
}
_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<Either<String, Failure>> 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<Either<String, Failure>> connect() async {
_loadCredentials();
return API_connect(_api_key, _server_url, apiVersion);
}
FutureOr<Either<List<ShortURL>, Failure>> getShortUrls() async {
return API_getShortUrls(_api_key, _server_url, apiVersion);
}
FutureOr<Either<ShlinkStats, Failure>> getShlinkStats() async {
return API_getShlinkStats(_api_key, _server_url, apiVersion);
}
FutureOr<Either<String, Failure>> submitShortUrl(ShortURLSubmission shortUrl) async {
return API_submitShortUrl(shortUrl, _api_key, _server_url, apiVersion);
}
FutureOr<Either<String, Failure>> deleteShortUrl(String shortCode) async {
return API_deleteShortUrl(shortCode, _api_key, _server_url, apiVersion);
}
FutureOr<Either<ServerHealthResponse, Failure>> getServerHealth() async {
return API_getServerHealth(_api_key, _server_url, apiVersion);
}
FutureOr<Either<List<ShortURL>, Failure>> getRecentShortUrls() async {
return API_getRecentShortUrls(_api_key, _server_url, apiVersion);
}
}
class ShortURLPageResponse {
List<ShortURL> urls;
int totalPages;
ShortURLPageResponse(this.urls, this.totalPages);
}
class ServerHealthResponse {
String status;
String version;
ServerHealthResponse({required this.status, required this.version});
}
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;
List<dynamic>? invalidElements;
ApiFailure({required this.type, required this.detail, required this.title, required this.status, this.invalidElements});
}

286
lib/API/server_manager.dart Normal file
View File

@ -0,0 +1,286 @@
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/Classes/Tag/tag_with_stats.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/get_tags_with_stats.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';
import 'Methods/submit_short_url.dart';
class ServerManager {
/// The URL of the Shlink server
String? serverUrl;
/// The API key to access the server
String? apiKey;
/// Current Shlink API Version used by the app
static String apiVersion = "3";
String getServerUrl() {
return serverUrl ?? "";
}
String getApiVersion() {
return apiVersion;
}
/// Checks whether the user provided information about the server
/// (url and apikey)
Future<bool> checkLogin() async {
await _loadCredentials();
return (serverUrl != null);
}
/// Logs out the user and removes data about the Shlink server
Future<void> logOut(String url) async {
const storage = FlutterSecureStorage();
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]
Future<void> _loadCredentials() async {
const storage = FlutterSecureStorage();
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool('first_run') ?? true) {
await storage.deleteAll();
prefs.setBool('first_run', false);
} else {
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();
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
FutureOr<Either<String, Failure>> initAndConnect(
String url, String apiKey) async {
// TODO: convert url to correct format
serverUrl = url;
this.apiKey = apiKey;
_saveCredentials(url, apiKey);
final result = await connect();
result.fold((l) => null, (r) {
logOut(url);
});
return result;
}
/// Establishes a connection to the server
FutureOr<Either<String, Failure>> connect() async {
_loadCredentials();
return apiConnect(apiKey, serverUrl, apiVersion);
}
/// Gets all short URLs from the server
FutureOr<Either<List<ShortURL>, Failure>> getShortUrls() async {
return apiGetShortUrls(apiKey, serverUrl, apiVersion);
}
/// Gets all tags from the server
FutureOr<Either<List<TagWithStats>, Failure>> getTags() async {
return apiGetTagsWithStats(apiKey, serverUrl, apiVersion);
}
/// Gets statistics about the Shlink instance
FutureOr<Either<ShlinkStats, Failure>> getShlinkStats() async {
return apiGetShlinkStats(apiKey, serverUrl, apiVersion);
}
/// Saves a new short URL to the server
FutureOr<Either<ShortURL, Failure>> submitShortUrl(
ShortURLSubmission shortUrl) async {
return apiSubmitShortUrl(shortUrl, apiKey, serverUrl, apiVersion);
}
FutureOr<Either<ShortURL, Failure>> updateShortUrl(
ShortURLSubmission shortUrl) async {
return apiUpdateShortUrl(shortUrl, apiKey, serverUrl, apiVersion);
}
/// Deletes a short URL from the server, identified by its slug
FutureOr<Either<String, Failure>> deleteShortUrl(String shortCode) async {
return apiDeleteShortUrl(shortCode, apiKey, serverUrl, apiVersion);
}
/// Gets health data about the server
FutureOr<Either<ServerHealthResponse, Failure>> getServerHealth() async {
return apiGetServerHealth(apiKey, serverUrl, apiVersion);
}
/// Gets recently created/used short URLs from the server
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
class ShortURLPageResponse {
List<ShortURL> urls;
int totalPages;
ShortURLPageResponse(this.urls, this.totalPages);
}
/// Server response data type about a page of tags from the server
class TagsWithStatsPageResponse {
List<TagWithStats> tags;
int totalPages;
TagsWithStatsPageResponse(this.tags, this.totalPages);
}
/// Server response data type about the health status of the server
class ServerHealthResponse {
String status;
String version;
ServerHealthResponse({required this.status, required this.version});
}
/// Failure class, used for the API
abstract class Failure {}
/// Used when a request to a server fails
/// (due to networking issues or an unexpected response)
class RequestFailure extends Failure {
int statusCode;
String description;
RequestFailure(this.statusCode, this.description);
}
/// Contains information about an error returned by the Shlink API
class ApiFailure extends Failure {
String type;
String detail;
String title;
int status;
List<dynamic>? invalidElements;
ApiFailure(
{required this.type,
required this.detail,
required this.title,
required this.status,
this.invalidElements});
}

View File

@ -1,250 +0,0 @@
import 'package:flutter/material.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:shlink_app/API/Classes/ShlinkStats/ShlinkStats.dart';
import 'package:shlink_app/API/ServerManager.dart';
import 'package:shlink_app/LoginView.dart';
import 'package:shlink_app/ShortURLEditView.dart';
import 'package:shlink_app/URLListView.dart';
import 'API/Classes/ShortURL/ShortURL.dart';
import 'globals.dart' as globals;
class HomeView extends StatefulWidget {
const HomeView({Key? key}) : super(key: key);
@override
State<HomeView> createState() => _HomeViewState();
}
class _HomeViewState extends State<HomeView> {
ShlinkStats? shlinkStats;
List<ShortURL> shortUrls = [];
bool shortUrlsLoaded = false;
bool _qrCodeShown = false;
String _qrUrl = "";
@override
void initState() {
// TODO: implement initState
super.initState();
WidgetsBinding.instance
.addPostFrameCallback((_) {
loadAllData();
});
}
Future<void> loadAllData() async {
var resultStats = await loadShlinkStats();
var resultShortUrls = await loadRecentShortUrls();
return;
}
Future<void> loadShlinkStats() async {
final response = await globals.serverManager.getShlinkStats();
response.fold((l) {
setState(() {
shlinkStats = l;
});
}, (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);
});
}
Future<void> loadRecentShortUrls() async {
final response = await globals.serverManager.getRecentShortUrls();
response.fold((l) {
setState(() {
shortUrls = l;
shortUrlsLoaded = 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);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
ColorFiltered(
colorFilter: ColorFilter.mode(Colors.black.withOpacity(_qrCodeShown ? 0.4 : 0), BlendMode.srcOver),
child: RefreshIndicator(
onRefresh: () async {
return loadAllData();
},
child: 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]))
],
)
),
SliverToBoxAdapter(
child: Wrap(
alignment: WrapAlignment.spaceEvenly,
children: [
_ShlinkStatsCardWidget(icon: Icons.link, text: "${shlinkStats?.shortUrlsCount.toString() ?? "0"} Short URLs", borderColor: Colors.blue),
_ShlinkStatsCardWidget(icon: Icons.remove_red_eye, text: "${shlinkStats?.nonOrphanVisits.total ?? "0"} Visits", borderColor: Colors.green),
_ShlinkStatsCardWidget(icon: Icons.warning, text: "${shlinkStats?.orphanVisits.total ?? "0"} Orphan Visits", borderColor: Colors.red),
_ShlinkStatsCardWidget(icon: Icons.sell, text: "${shlinkStats?.tagsCount.toString() ?? "0"} Tags", borderColor: Colors.purple),
],
),
),
if (shortUrlsLoaded && shortUrls.isEmpty)
SliverToBoxAdapter(
child: Center(
child: Padding(
padding: EdgeInsets.only(top: 50),
child: Column(
children: [
Text("No Short URLs", style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),),
Padding(
padding: EdgeInsets.only(top: 8),
child: Text('Create one by tapping the "+" button below', style: TextStyle(fontSize: 16, color: Colors.grey[600]),),
)
],
)
)
)
)
else
SliverList(delegate: SliverChildBuilderDelegate(
(BuildContext _context, int index) {
if (index == 0) {
return Padding(
padding: EdgeInsets.only(top: 16, left: 12, right: 12),
child: Text("Recent Short URLs", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
);
}
else {
final shortURL = shortUrls[index - 1];
return ShortURLCell(shortURL: shortURL, reload: () {
loadRecentShortUrls();
}, showQRCode: (String url) {
setState(() {
_qrUrl = url;
_qrCodeShown = true;
});
}, isLast: index == shortUrls.length);
}
},
childCount: shortUrls.length + 1
))
],
),
),
),
if (_qrCodeShown)
GestureDetector(
onTap: () {
setState(() {
_qrCodeShown = false;
});
},
child: Container(
color: Colors.black.withOpacity(0),
),
),
if (_qrCodeShown)
Center(
child: SizedBox(
width: MediaQuery.of(context).size.width / 1.7,
height: MediaQuery.of(context).size.width / 1.7,
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: QrImageView(
data: _qrUrl,
version: QrVersions.auto,
size: 200.0,
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color: MediaQuery.of(context).platformBrightness == Brightness.dark ? Colors.white : Colors.black,
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: MediaQuery.of(context).platformBrightness == Brightness.dark ? Colors.white : Colors.black,
),
)
)
),
),
)
],
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
final result = await Navigator.of(context).push(MaterialPageRoute(builder: (context) => const ShortURLEditView()));
loadRecentShortUrls();
},
child: Icon(Icons.add),
)
);
}
}
// stats card widget
class _ShlinkStatsCardWidget extends StatefulWidget {
const _ShlinkStatsCardWidget({this.text, this.icon, this.borderColor});
final icon;
final borderColor;
final text;
@override
State<_ShlinkStatsCardWidget> createState() => _ShlinkStatsCardWidgetState();
}
class _ShlinkStatsCardWidgetState extends State<_ShlinkStatsCardWidget> {
@override
Widget build(BuildContext context) {
var randomColor = ([...Colors.primaries]..shuffle()).first;
return Padding(
padding: EdgeInsets.all(4),
child: Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: widget.borderColor ?? randomColor),
borderRadius: BorderRadius.circular(8)
),
child: SizedBox(
child: Wrap(
children: [
Icon(widget.icon),
Padding(
padding: EdgeInsets.only(left: 4),
child: Text(widget.text, style: TextStyle(fontWeight: FontWeight.bold)),
)
],
),
)
),
);
}
}

View File

@ -1,146 +0,0 @@
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<LoginView> createState() => _LoginViewState();
}
class _LoginViewState extends State<LoginView> {
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", style: TextStyle(fontWeight: FontWeight.bold))
),
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),)),
Row(
children: [
Icon(Icons.dns_outlined),
SizedBox(width: 8),
Expanded(child: 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)),
),
Row(
children: [
Icon(Icons.key),
SizedBox(width: 8),
Expanded(child: 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))
],
),
)
],
),
)
)
],
)
);
}
}

View File

@ -1,72 +0,0 @@
import 'package:flutter/material.dart';
import 'package:shlink_app/util/license.dart';
import 'package:url_launcher/url_launcher.dart';
class OpenSourceLicensesView extends StatefulWidget {
const OpenSourceLicensesView({super.key});
@override
State<OpenSourceLicensesView> createState() => _OpenSourceLicensesViewState();
}
class _OpenSourceLicensesViewState extends State<OpenSourceLicensesView> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar.medium(
expandedHeight: 120,
title: const Text("Open Source Licenses", style: TextStyle(fontWeight: FontWeight.bold),)
),
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
final currentLicense = LicenseUtil.getLicenses()[index];
return GestureDetector(
onTap: () async {
if (currentLicense.repository != null) {
if (await canLaunchUrl(Uri.parse(currentLicense.repository ?? ""))) {
launchUrl(Uri.parse(currentLicense.repository ?? ""), mode: LaunchMode.externalApplication);
}
}
},
child: Padding(
padding: EdgeInsets.all(12),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).brightness == Brightness.light ? Colors.grey[100] : Colors.grey[900],
),
child: Padding(
padding: EdgeInsets.only(left: 12, right: 12, top: 20, bottom: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("${currentLicense.name}", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
Text("Version: ${currentLicense.version ?? "N/A"}", style: TextStyle(color: Colors.grey)),
SizedBox(height: 8),
Divider(),
SizedBox(height: 8),
Text("${currentLicense.license}", textAlign: TextAlign.justify, style: TextStyle(color: Colors.grey)),
],
),
),
),
),
);
},
childCount: LicenseUtil.getLicenses().length
),
),
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.only(top: 8, bottom: 20),
child: Text("Thank you to all maintainers of these repositories 💝", style: TextStyle(color: Colors.grey), textAlign: TextAlign.center,),
)
)
],
),
);
}
}

View File

@ -1,247 +0,0 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shlink_app/API/ServerManager.dart';
import 'package:shlink_app/LoginView.dart';
import 'package:shlink_app/OpenSourceLicensesView.dart';
import 'package:url_launcher/url_launcher.dart';
import 'globals.dart' as globals;
class SettingsView extends StatefulWidget {
const SettingsView({super.key});
@override
State<SettingsView> createState() => _SettingsViewState();
}
enum ServerStatus {
connected,
connecting,
disconnected
}
class _SettingsViewState extends State<SettingsView> {
var _server_version = "---";
ServerStatus _server_status = ServerStatus.connecting;
var packageInfo = null;
@override
void initState() {
// TODO: implement initState
super.initState();
WidgetsBinding.instance
.addPostFrameCallback((_) => getServerHealth());
}
void getServerHealth() async {
var _packageInfo = await PackageInfo.fromPlatform();
setState(() {
packageInfo = _packageInfo;
});
final response = await globals.serverManager.getServerHealth();
response.fold((l) {
setState(() {
_server_version = l.version;
_server_status = ServerStatus.connected;
});
}, (r) {
setState(() {
_server_status = ServerStatus.disconnected;
});
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);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar.medium(
expandedHeight: 120,
title: const Text("Settings", style: TextStyle(fontWeight: FontWeight.bold),),
actions: [
PopupMenuButton(
itemBuilder: (context) {
return [
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 (_server_status) {
case ServerStatus.connected:
return Colors.green;
case ServerStatus.connecting:
return Colors.orange;
case ServerStatus.disconnected:
return Colors.red;
}
}())),
SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Connected to", style: TextStyle(color: Colors.grey)),
Text(globals.serverManager.getServerUrl(), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
Row(
children: [
Text("API Version: ", style: TextStyle(color: Colors.grey, fontWeight: FontWeight.w600)),
Text("${globals.serverManager.getApiVersion()}", style: TextStyle(color: Colors.grey)),
SizedBox(width: 16),
Text("Server Version: ", style: TextStyle(color: Colors.grey, fontWeight: FontWeight.w600)),
Text("${_server_version}", style: TextStyle(color: Colors.grey))
],
),
],
)
],
),
),
),
SizedBox(height: 8),
Divider(),
SizedBox(height: 8),
GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const OpenSourceLicensesView())
);
},
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).brightness == Brightness.light ? Colors.grey[100] : Colors.grey[900],
),
child: Padding(
padding: EdgeInsets.only(left: 12, right: 12, top: 20, bottom: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.policy_outlined),
SizedBox(width: 8),
Text("Open Source Licenses", style: TextStyle(fontWeight: FontWeight.w500)),
],
),
Icon(Icons.chevron_right)
]
),
),
),
),
SizedBox(height: 16),
GestureDetector(
onTap: () async {
var url = Uri.parse("https://github.com/rainloreley/shlink-mobile-app");
if (await canLaunchUrl(url)) {
launchUrl(url, mode: LaunchMode.externalApplication);
}
},
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).brightness == Brightness.light ? Colors.grey[100] : Colors.grey[900],
),
child: Padding(
padding: EdgeInsets.only(left: 12, right: 12, top: 20, bottom: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.code),
SizedBox(width: 8),
Text("GitHub", style: TextStyle(fontWeight: FontWeight.w500)),
],
),
Icon(Icons.chevron_right)
]
),
),
),
),
SizedBox(height: 16),
GestureDetector(
onTap: () async {
var url = Uri.parse("https://abmgrt.dev/shlink-manager/privacy");
if (await canLaunchUrl(url)) {
launchUrl(url, mode: LaunchMode.externalApplication);
}
},
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).brightness == Brightness.light ? Colors.grey[100] : Colors.grey[900],
),
child: Padding(
padding: EdgeInsets.only(left: 12, right: 12, top: 20, bottom: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.lock),
SizedBox(width: 8),
Text("Privacy Policy", style: TextStyle(fontWeight: FontWeight.w500)),
],
),
Icon(Icons.chevron_right)
]
),
),
),
),
SizedBox(height: 16),
if (packageInfo != null)
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text("${packageInfo.appName}, v${packageInfo.version} (${packageInfo.buildNumber})", style: TextStyle(color: Colors.grey),),],
)
],
)
),
)
],
)
);
}
}

View File

@ -1,300 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shlink_app/API/Classes/ShortURLSubmission/ShortURLSubmission.dart';
import 'package:shlink_app/API/ServerManager.dart';
import 'globals.dart' as globals;
class ShortURLEditView extends StatefulWidget {
const ShortURLEditView({super.key});
@override
State<ShortURLEditView> createState() => _ShortURLEditViewState();
}
class _ShortURLEditViewState extends State<ShortURLEditView> with SingleTickerProviderStateMixin {
final longUrlController = TextEditingController();
final customSlugController = TextEditingController();
final titleController = TextEditingController();
final randomSlugLengthController = TextEditingController(text: "5");
bool randomSlug = true;
bool isCrawlable = true;
bool forwardQuery = true;
bool copyToClipboard = true;
String longUrlError = "";
String randomSlugLengthError = "";
bool isSaving = false;
late AnimationController _customSlugDiceAnimationController;
@override
void initState() {
_customSlugDiceAnimationController = AnimationController(
vsync: this,
duration: Duration(milliseconds: 500),
);
super.initState();
}
@override
void dispose() {
longUrlController.dispose();
customSlugController.dispose();
titleController.dispose();
randomSlugLengthController.dispose();
super.dispose();
}
void _submitShortUrl() async {
var newSubmission = ShortURLSubmission(
longUrl: longUrlController.text,
deviceLongUrls: null, tags: [],
crawlable: isCrawlable,
forwardQuery: forwardQuery,
findIfExists: true,
title: titleController.text != "" ? titleController.text : null,
customSlug: customSlugController.text != "" && !randomSlug ? customSlugController.text : null,
shortCodeLength: randomSlug ? int.parse(randomSlugLengthController.text) : null);
var response = await globals.serverManager.submitShortUrl(newSubmission);
response.fold((l) async {
setState(() {
isSaving = false;
});
if (copyToClipboard) {
await Clipboard.setData(ClipboardData(text: l));
final snackBar = SnackBar(content: Text("Copied to clipboard!"), backgroundColor: Colors.green[400], behavior: SnackBarBehavior.floating);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
else {
final snackBar = SnackBar(content: Text("Short URL created!"), backgroundColor: Colors.green[400], behavior: SnackBarBehavior.floating);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
Navigator.pop(context);
return true;
}, (r) {
setState(() {
isSaving = false;
});
var text = "";
if (r is RequestFailure) {
text = r.description;
}
else {
text = (r as ApiFailure).detail;
if ((r as ApiFailure).invalidElements != null) {
text = text + ": " + (r as ApiFailure).invalidElements.toString();
}
}
final snackBar = SnackBar(content: Text(text), backgroundColor: Colors.red[400], behavior: SnackBarBehavior.floating);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
return false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar.medium(
title: Text("New Short URL", style: TextStyle(fontWeight: FontWeight.bold)),
),
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.only(left: 16, right: 16, top: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
TextField(
controller: longUrlController,
decoration: InputDecoration(
errorText: longUrlError != "" ? longUrlError : null,
border: OutlineInputBorder(),
label: Row(
children: [
Icon(Icons.public),
SizedBox(width: 8),
Text("Long URL")
],
)
),
),
SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextField(
controller: customSlugController,
style: TextStyle(color: randomSlug ? Colors.grey : Theme.of(context).brightness == Brightness.light ? Colors.black : Colors.white),
onChanged: (_) {
if (randomSlug) setState(() {
randomSlug = false;
});
},
decoration: InputDecoration(
border: OutlineInputBorder(),
label: Row(
children: [
Icon(Icons.link),
SizedBox(width: 8),
Text("${randomSlug ? "Random" : "Custom"} slug", style: TextStyle(fontStyle: randomSlug ? FontStyle.italic : FontStyle.normal),)
],
)
),
),
),
SizedBox(width: 8),
RotationTransition(
turns: Tween(begin: 0.0, end: 3.0).animate(CurvedAnimation(parent: _customSlugDiceAnimationController, curve: Curves.easeInOutExpo)),
child: IconButton(
onPressed: () {
if (randomSlug) {
_customSlugDiceAnimationController.reverse(from: 1);
}
else {
_customSlugDiceAnimationController.forward(from: 0);
}
setState(() {
randomSlug = !randomSlug;
});
},
icon: Icon(randomSlug ? Icons.casino : Icons.casino_outlined, color: randomSlug ? Colors.green : Colors.grey)
),
)
],
),
if (randomSlug)
SizedBox(height: 16),
if (randomSlug)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("Random slug length"),
SizedBox(
width: 100,
child: TextField(
controller: randomSlugLengthController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
errorText: randomSlugLengthError != "" ? "" : null,
border: OutlineInputBorder(),
label: Row(
children: [
Icon(Icons.tag),
SizedBox(width: 8),
Text("Length")
],
)
),
))
],
),
SizedBox(height: 16),
TextField(
controller: titleController,
decoration: InputDecoration(
border: OutlineInputBorder(),
label: Row(
children: [
Icon(Icons.badge),
SizedBox(width: 8),
Text("Title")
],
)
),
),
SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("Crawlable"),
Switch(
value: isCrawlable,
onChanged: (_) {
setState(() {
isCrawlable = !isCrawlable;
});
},
)
],
),
SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("Forward query params"),
Switch(
value: forwardQuery,
onChanged: (_) {
setState(() {
forwardQuery = !forwardQuery;
});
},
)
],
),
SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("Copy to clipboard"),
Switch(
value: copyToClipboard,
onChanged: (_) {
setState(() {
copyToClipboard = !copyToClipboard;
});
},
)
],
),
],
),
)
)
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
if (!isSaving) {
setState(() {
isSaving = true;
longUrlError = "";
randomSlugLengthError = "";
});
if (longUrlController.text == "") {
setState(() {
longUrlError = "URL cannot be empty";
isSaving = false;
});
return;
}
else if (int.tryParse(randomSlugLengthController.text) == null || int.tryParse(randomSlugLengthController.text)! < 1 || int.tryParse(randomSlugLengthController.text)! > 50) {
setState(() {
randomSlugLengthError = "invalid number";
isSaving = false;
});
return;
}
else {
_submitShortUrl();
}
}
},
child: isSaving ? Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator(strokeWidth: 3)) : Icon(Icons.save)
),
);
}
}

View File

@ -1,193 +0,0 @@
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:shlink_app/API/Classes/ShortURL/ShortURL.dart';
import 'package:intl/intl.dart';
import 'package:shlink_app/API/ServerManager.dart';
import 'globals.dart' as globals;
class URLDetailView extends StatefulWidget {
const URLDetailView({super.key, required this.shortURL});
final ShortURL shortURL;
@override
State<URLDetailView> createState() => _URLDetailViewState();
}
class _URLDetailViewState extends State<URLDetailView> {
Future showDeletionConfirmation() {
return showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text("Delete Short URL"),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text("You're about to delete"),
SizedBox(height: 4),
Text("${widget.shortURL.title ?? widget.shortURL.shortCode}", style: TextStyle(fontStyle: FontStyle.italic),),
SizedBox(height: 4),
const Text("It'll be gone forever! (a very long time)")
],
),
),
actions: [
TextButton(onPressed: () => { Navigator.of(context).pop() }, child: const Text("Cancel")),
TextButton(
onPressed: () async {
var response = await globals.serverManager.deleteShortUrl(widget.shortURL.shortCode);
response.fold((l) {
Navigator.pop(context);
Navigator.pop(context, "reload");
final snackBar = SnackBar(content: Text("Short URL deleted!"), backgroundColor: Colors.green[400], behavior: SnackBarBehavior.floating);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
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;
});
},
child: Text("Delete", style: TextStyle(color: Colors.red)),
)
],
);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar.medium(
title: Text(widget.shortURL.title ?? widget.shortURL.shortCode, style: TextStyle(fontWeight: FontWeight.bold)),
actions: [
IconButton(onPressed: () {
showDeletionConfirmation();
}, icon: Icon(Icons.delete, color: Colors.red,))
],
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0),
child: Wrap(
children: widget.shortURL.tags.map((tag) {
var randomColor = ([...Colors.primaries]..shuffle()).first.harmonizeWith(Theme.of(context).colorScheme.primary);
return Padding(
padding: EdgeInsets.only(right: 4, top: 4),
child: Container(
padding: EdgeInsets.only(top: 4, bottom: 4, left: 12, right: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: randomColor,
),
child: Text(tag, style: TextStyle(color: randomColor.computeLuminance() < 0.5 ? Colors.white : Colors.black),),
),
);
}).toList()
),
),
),
_ListCell(title: "Short Code", content: widget.shortURL.shortCode),
_ListCell(title: "Short URL", content: widget.shortURL.shortUrl),
_ListCell(title: "Long URL", content: widget.shortURL.longUrl),
_ListCell(title: "iOS", content: widget.shortURL.deviceLongUrls.ios, sub: true),
_ListCell(title: "Android", content: widget.shortURL.deviceLongUrls.android, sub: true),
_ListCell(title: "Desktop", content: widget.shortURL.deviceLongUrls.desktop, sub: true),
_ListCell(title: "Creation Date", content: widget.shortURL.dateCreated),
_ListCell(title: "Visits", content: ""),
_ListCell(title: "Total", content: widget.shortURL.visitsSummary.total, sub: true),
_ListCell(title: "Non-Bots", content: widget.shortURL.visitsSummary.nonBots, sub: true),
_ListCell(title: "Bots", content: widget.shortURL.visitsSummary.bots, sub: true),
_ListCell(title: "Meta", content: ""),
_ListCell(title: "Valid Since", content: widget.shortURL.meta.validSince, sub: true),
_ListCell(title: "Valid Until", content: widget.shortURL.meta.validUntil, sub: true),
_ListCell(title: "Max Visits", content: widget.shortURL.meta.maxVisits, sub: true),
_ListCell(title: "Domain", content: widget.shortURL.domain),
_ListCell(title: "Crawlable", content: widget.shortURL.crawlable, last: true)
],
),
);
}
}
class _ListCell extends StatefulWidget {
const _ListCell({required this.title, required this.content, this.sub = false, this.last = false});
final String title;
final dynamic content;
final bool sub;
final bool last;
@override
State<_ListCell> createState() => _ListCellState();
}
class _ListCellState extends State<_ListCell> {
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.only(top: 16, bottom: widget.last ? 30 : 0),
child: Container(
padding: EdgeInsets.only(top: 16, left: 8, right: 8),
decoration: BoxDecoration(
border: Border(top: BorderSide(width: 1, color: MediaQuery.of(context).platformBrightness == Brightness.dark ? Colors.grey[800]! : Colors.grey[300]!)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
if (widget.sub)
Padding(
padding: EdgeInsets.only(right: 4),
child: SizedBox(
width: 20,
height: 6,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).brightness == Brightness.dark ? Colors.grey[700] : Colors.grey[300],
),
),
),
),
Text(widget.title, style: TextStyle(fontWeight: FontWeight.bold),)],
),
if (widget.content is bool)
Icon(widget.content ? Icons.check : Icons.close, color: widget.content ? Colors.green : Colors.red)
else if (widget.content is int)
Text(widget.content.toString())
else if (widget.content is String)
Expanded(
child: Text(widget.content, textAlign: TextAlign.end, overflow: TextOverflow.ellipsis, maxLines: 1,),
)
else if (widget.content is DateTime)
Text(DateFormat('yyyy-MM-dd - HH:mm').format(widget.content))
else
const Text("N/A")
],
),
),
)
);
}
}

View File

@ -1,234 +0,0 @@
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:shlink_app/API/Classes/ShortURL/ShortURL.dart';
import 'package:shlink_app/API/ServerManager.dart';
import 'package:shlink_app/ShortURLEditView.dart';
import 'package:shlink_app/URLDetailView.dart';
import 'globals.dart' as globals;
import 'package:flutter/services.dart';
class URLListView extends StatefulWidget {
const URLListView({Key? key}) : super(key: key);
@override
State<URLListView> createState() => _URLListViewState();
}
class _URLListViewState extends State<URLListView> {
List<ShortURL> shortUrls = [];
bool _qrCodeShown = false;
String _qrUrl = "";
bool shortUrlsLoaded = false;
@override
void initState() {
// TODO: implement initState
super.initState();
WidgetsBinding.instance
.addPostFrameCallback((_) => loadAllShortUrls());
}
Future<void> loadAllShortUrls() async {
final response = await globals.serverManager.getShortUrls();
response.fold((l) {
setState(() {
shortUrls = l;
shortUrlsLoaded = true;
});
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;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () async {
final result = await Navigator.of(context).push(MaterialPageRoute(builder: (context) => ShortURLEditView()));
loadAllShortUrls();
},
child: Icon(Icons.add),
),
body: Stack(
children: [
ColorFiltered(
colorFilter: ColorFilter.mode(Colors.black.withOpacity(_qrCodeShown ? 0.4 : 0), BlendMode.srcOver),
child: RefreshIndicator(
onRefresh: () async {
return loadAllShortUrls();
},
child: CustomScrollView(
slivers: [
SliverAppBar.medium(
title: Text("Short URLs", style: TextStyle(fontWeight: FontWeight.bold))
),
if (shortUrlsLoaded && shortUrls.length == 0)
SliverToBoxAdapter(
child: Center(
child: Padding(
padding: EdgeInsets.only(top: 50),
child: Column(
children: [
Text("No Short URLs", style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),),
Padding(
padding: EdgeInsets.only(top: 8),
child: Text('Create one by tapping the "+" button below', style: TextStyle(fontSize: 16, color: Colors.grey[600]),),
)
],
)
)
)
)
else
SliverList(delegate: SliverChildBuilderDelegate(
(BuildContext _context, int index) {
final shortURL = shortUrls[index];
return ShortURLCell(shortURL: shortURL, reload: () {
loadAllShortUrls();
}, showQRCode: (String url) {
setState(() {
_qrUrl = url;
_qrCodeShown = true;
});
}, isLast: index == shortUrls.length - 1);
},
childCount: shortUrls.length
))
],
),
),
),
if (_qrCodeShown)
GestureDetector(
onTap: () {
setState(() {
_qrCodeShown = false;
});
},
child: Container(
color: Colors.black.withOpacity(0),
),
),
if (_qrCodeShown)
Center(
child: SizedBox(
width: MediaQuery.of(context).size.width / 1.7,
height: MediaQuery.of(context).size.width / 1.7,
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: QrImageView(
data: _qrUrl,
version: QrVersions.auto,
size: 200.0,
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color: MediaQuery.of(context).platformBrightness == Brightness.dark ? Colors.white : Colors.black,
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: MediaQuery.of(context).platformBrightness == Brightness.dark ? Colors.white : Colors.black,
),
)
)
),
),
)
],
)
);
}
}
class ShortURLCell extends StatefulWidget {
const ShortURLCell({super.key, required this.shortURL, required this.reload, required this.showQRCode, required this.isLast});
final ShortURL shortURL;
final Function() reload;
final Function(String url) showQRCode;
final bool isLast;
@override
State<ShortURLCell> createState() => _ShortURLCellState();
}
class _ShortURLCellState extends State<ShortURLCell> {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () async {
final result = await Navigator.of(context).push(MaterialPageRoute(builder: (context) => URLDetailView(shortURL: widget.shortURL)));
if (result == "reload") {
widget.reload();
}
},
child: Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: widget.isLast ? 90 : 0),
child: Container(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 16, top: 16),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: MediaQuery.of(context).platformBrightness == Brightness.dark ? Colors.grey[800]! : Colors.grey[300]!)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text("${widget.shortURL.title ?? widget.shortURL.shortCode}", textScaleFactor: 1.4, style: TextStyle(fontWeight: FontWeight.bold),),
Text("${widget.shortURL.longUrl}",maxLines: 1, overflow: TextOverflow.ellipsis, textScaleFactor: 0.9, style: TextStyle(color: Colors.grey[600]),),
// List tags in a row
Wrap(
children: widget.shortURL.tags.map((tag) {
var randomColor = ([...Colors.primaries]..shuffle()).first.harmonizeWith(Theme.of(context).colorScheme.primary);
return Padding(
padding: EdgeInsets.only(right: 4, top: 4),
child: Container(
padding: EdgeInsets.only(top: 4, bottom: 4, left: 12, right: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: randomColor,
),
child: Text(tag, style: TextStyle(color: randomColor.computeLuminance() < 0.5 ? Colors.white : Colors.black),),
),
);
}).toList()
)
],
),
),
IconButton(onPressed: () async {
await Clipboard.setData(ClipboardData(text: widget.shortURL.shortUrl));
final snackBar = SnackBar(content: Text("Copied to clipboard!"), behavior: SnackBarBehavior.floating, backgroundColor: Colors.green[400]);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}, icon: Icon(Icons.copy)),
IconButton(onPressed: () {
widget.showQRCode(widget.shortURL.shortUrl);
}, icon: Icon(Icons.qr_code))
],
)
),
)
);
}
}

71
lib/global_theme.dart Normal file
View File

@ -0,0 +1,71 @@
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
class GlobalTheme {
static final Color _lightFocusColor = Colors.black.withOpacity(0.12);
static final Color _darkFocusColor = Colors.white.withOpacity(0.12);
static ThemeData lightThemeData(ColorScheme? dynamicColorScheme) {
return themeData(lightColorScheme, dynamicColorScheme, _lightFocusColor);
}
static ThemeData darkThemeData(ColorScheme? dynamicColorScheme) {
return themeData(darkColorScheme, dynamicColorScheme, _darkFocusColor);
}
static ThemeData themeData(ColorScheme colorScheme, ColorScheme? dynamic,
Color focusColor) {
return ThemeData(
colorScheme: colorScheme,
canvasColor: colorScheme.surface,
scaffoldBackgroundColor: colorScheme.surface,
highlightColor: Colors.transparent,
dividerColor: colorScheme.shadow,
focusColor: focusColor,
useMaterial3: true,
appBarTheme: AppBarTheme(
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
elevation: 0
)
);
}
static ColorScheme get lightColorScheme {
return ColorScheme(
primary: Color(0xff747ab5),
onPrimary: Colors.white,
secondary: Color(0x335d63a6),// Color(0xFFDDE0E0),
onSecondary: Color(0xFF322942),
tertiary: Colors.grey[300],
onTertiary: Colors.grey[700],
surfaceContainer: (Colors.grey[100])!,
outline: (Colors.grey[500])!,
shadow: (Colors.grey[300])!,
error: (Colors.red[400])!,
onError: Colors.white,
surface: Color(0xFFFAFBFB),
onSurface: Color(0xFF241E30),
brightness: Brightness.light,
);
}
static ColorScheme get darkColorScheme {
return ColorScheme(
primary: Color(0xff5d63a6),
secondary: Colors.blue.shade500,
secondaryContainer: Color(0xff1c1c1c),
surface: Colors.black,
surfaceContainer: Color(0xff0f0f0f),
onSurfaceVariant: Colors.grey[400],
tertiary: Colors.grey[900],
onTertiary: Colors.grey,
outline: (Colors.grey[700])!,
shadow: (Colors.grey[800])!,
error: (Colors.red[400])!,
onError: Colors.white,
onPrimary: Colors.white,
onSecondary: (Colors.grey[400])!,
onSurface: Colors.white,
brightness: Brightness.dark,
);
}
}

View File

@ -1,4 +1,5 @@
library dev.abmgrt.shlink_app.globals;
import 'package:shlink_app/API/ServerManager.dart';
ServerManager serverManager = ServerManager();
import 'package:shlink_app/API/server_manager.dart';
ServerManager serverManager = ServerManager();

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:shlink_app/LoginView.dart';
import 'package:shlink_app/NavigationBarView.dart';
import 'package:shlink_app/global_theme.dart';
import 'package:shlink_app/views/login_view.dart';
import 'package:shlink_app/views/navigationbar_view.dart';
import 'globals.dart' as globals;
import 'package:dynamic_color/dynamic_color.dart';
@ -11,11 +12,13 @@ void main() {
class MyApp extends StatelessWidget {
const MyApp({super.key});
static final _defaultLightColorScheme =
ColorScheme.light();//.fromSwatch(primarySwatch: Colors.blue, backgroundColor: Colors.white);
static final ColorScheme _defaultLightColorScheme =
ColorScheme.fromSeed(seedColor: Colors.blue);
static final _defaultDarkColorScheme = ColorScheme.fromSwatch(
primarySwatch: Colors.blue, brightness: Brightness.dark);
static final _defaultDarkColorScheme = ColorScheme.fromSeed(
brightness: Brightness.dark,
seedColor: Colors.blue,
background: Colors.black);
// This widget is the root of your application.
@override
@ -24,43 +27,25 @@ class MyApp extends StatelessWidget {
return MaterialApp(
title: 'Shlink',
debugShowCheckedModeBanner: false,
theme: ThemeData(
appBarTheme: AppBarTheme(
backgroundColor: Color(0xfffafafa),
),
theme: GlobalTheme.lightThemeData(lightColorScheme),
darkTheme: GlobalTheme.darkThemeData(darkColorScheme),
/*theme: ThemeData(
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xfffafafa),
),
colorScheme: lightColorScheme ?? _defaultLightColorScheme,
useMaterial3: true
),
useMaterial3: true),
darkTheme: ThemeData(
appBarTheme: AppBarTheme(
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xff0d0d0d),
foregroundColor: Colors.white,
elevation: 0,
),
colorScheme: darkColorScheme?.copyWith(background: Colors.black) ?? _defaultDarkColorScheme,
colorScheme: darkColorScheme?.copyWith(surface: Colors.black) ??
_defaultDarkColorScheme,
useMaterial3: true,
),
/*theme: ThemeData(
primarySwatch: Colors.blue,
brightness: Brightness.light,
useMaterial3: true,
colorScheme: ColorScheme.fromSwatch().copyWith(
secondary: Colors.orange
)
),*/
/*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: InitialPage()
);
),*/
home: const InitialPage());
});
}
}
@ -73,7 +58,6 @@ class InitialPage extends StatefulWidget {
}
class _InitialPageState extends State<InitialPage> {
@override
void initState() {
super.initState();
@ -81,26 +65,22 @@ 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())
);
}
else {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const LoginView())
);
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (context) => const NavigationBarView()),
(Route<dynamic> route) => false);
} else {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (context) => const LoginView()),
(Route<dynamic> route) => false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text("")
),
return const Scaffold(
body: Center(child: Text("")),
);
}
}

View File

@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import 'package:shlink_app/API/server_manager.dart';
SnackBar buildApiErrorSnackbar(Failure r, BuildContext context) {
var text = "";
if (r is RequestFailure) {
text = r.description;
} else {
text = (r as ApiFailure).detail;
if ((r).invalidElements != null) {
text = "$text: ${(r).invalidElements}";
}
}
final snackBar = SnackBar(
content: Text(text, style: TextStyle(color: Theme.of(context).colorScheme.onError)),
backgroundColor: Theme.of(context).colorScheme.error,
behavior: SnackBarBehavior.floating);
return snackBar;
}

View File

@ -26,9 +26,10 @@ class LicenseUtil {
static List<License> getLicenses() {
return [
License(
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',
),
License(
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,
),
License(
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',
),
License(
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',
),
License(
const License(
name: r'flutter_launcher_icons',
license: r'''MIT License
license: r'''
MIT License
Copyright (c) 2019 Mark O'Sullivan
@ -344,13 +345,14 @@ 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'0.13.1',
version: r'^0.13.1',
homepage: r'https://github.com/fluttercommunity/flutter_launcher_icons',
repository: r'https://github.com/fluttercommunity/flutter_launcher_icons/',
),
License(
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:
@ -376,46 +378,46 @@ 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,
version: r'^3.0.1',
repository: r'https://github.com/flutter/packages/tree/main/packages/flutter_lints',
),
License(
const License(
name: r'flutter_process_text',
license: r'''BSD 3-Clause License
(c) Copyright 2021 divshekhar (Divyanshu Shekhar)
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT
OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
HOWEVER 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,
license: r'''
BSD 3-Clause License
(c) Copyright 2021 divshekhar (Divyanshu Shekhar)
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT
OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
HOWEVER 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'^1.1.2',
homepage: null,
repository: r'https://github.com/DevsOnFlutter/flutter_process_text',
),
License(
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.
@ -444,13 +446,220 @@ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
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'^8.0.0',
homepage: null,
version: r'^9.0.0',
repository: r'https://github.com/mogol/flutter_secure_storage/tree/develop/flutter_secure_storage',
),
License(
const License(
name: r'flutter_sharing_intent',
license: r'''
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.''',
version: r'^1.1.1',
homepage: r'https://github.com/bhagat-techind/flutter_sharing_intent.git',
),
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:
@ -476,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',
),
License(
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
@ -510,13 +719,13 @@ 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'^0.13.6',
homepage: null,
version: r'^1.1.0',
repository: r'https://github.com/dart-lang/http/tree/master/pkgs/http',
),
License(
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
@ -544,13 +753,13 @@ 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'^0.18.1',
homepage: null,
version: r'^0.19.0',
repository: r'https://github.com/dart-lang/i18n/tree/main/pkgs/intl',
),
License(
const License(
name: r'license_generator',
license: r'''MIT License
license: r'''
MIT License
Copyright (c) 2022 icapps
@ -572,13 +781,13 @@ 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',
version: r'^2.0.0',
homepage: r'https://github.com/icapps/flutter-icapps-license',
repository: null,
),
License(
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
@ -608,11 +817,12 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
''',
version: r'^4.0.2',
homepage: r'https://plus.fluttercommunity.dev/',
repository: r'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/',
repository: r'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/package_info_plus/package_info_plus',
),
License(
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.
@ -644,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,
),
License(
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:
@ -675,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',
),
License(
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
@ -703,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',
),
License(
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:
@ -734,9 +944,8 @@ 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'6.1.9',
homepage: null,
repository: r'https://github.com/flutter/plugins/tree/main/packages/url_launcher/url_launcher',
version: r'^6.2.4',
repository: r'https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher',
),
];
}

View File

@ -0,0 +1,19 @@
import 'dart:ui';
import 'package:flutter/widgets.dart';
Color stringToColor(String string) {
int hash = 0;
string.split('').forEach((char) {
hash = char.codeUnitAt(0) + ((hash << 5) - hash);
});
var rgb = [];
for (int i = 0; i < 3; i++) {
var value = (hash >> (i * 8)) & 0xff;
rgb.add(int.parse(value.toRadixString(16).padLeft(2, '0'), radix: 16));
}
if (rgb.length != 3) {
return const Color(0xff000000);
}
return Color.fromARGB(1, rgb[0], rgb[1], rgb[2]);
}

297
lib/views/home_view.dart Normal file
View File

@ -0,0 +1,297 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_sharing_intent/flutter_sharing_intent.dart';
import 'package:flutter_sharing_intent/model/sharing_file.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:shlink_app/API/Classes/ShlinkStats/shlink_stats.dart';
import 'package:shlink_app/util/build_api_error_snackbar.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;
class HomeView extends StatefulWidget {
const HomeView({super.key});
@override
State<HomeView> createState() => _HomeViewState();
}
class _HomeViewState extends State<HomeView> {
ShlinkStats? shlinkStats;
List<ShortURL> shortUrls = [];
bool shortUrlsLoaded = false;
bool _qrCodeShown = false;
String _qrUrl = "";
late StreamSubscription _intentDataStreamSubscription;
@override
void initState() {
super.initState();
initializeActionProcessText();
WidgetsBinding.instance.addPostFrameCallback((_) {
loadAllData();
});
}
Future<void> initializeActionProcessText() async {
_intentDataStreamSubscription =
FlutterSharingIntent.instance.getMediaStream().listen(_handleIntentUrl);
FlutterSharingIntent.instance.getInitialSharing().then(_handleIntentUrl);
}
Future<void> _handleIntentUrl(List<SharedFile> value) async {
String inputUrlText = value.firstOrNull?.value ?? "";
if (await canLaunchUrlString(inputUrlText)) {
await Navigator.of(context).push(MaterialPageRoute(
builder: (context) => ShortURLEditView(longUrl: inputUrlText)));
await loadAllData();
}
}
Future<void> loadAllData() async {
await loadShlinkStats();
await loadRecentShortUrls();
return;
}
Future<void> loadShlinkStats() async {
final response = await globals.serverManager.getShlinkStats();
response.fold((l) {
setState(() {
shlinkStats = l;
});
}, (r) {
ScaffoldMessenger.of(context).showSnackBar(
buildApiErrorSnackbar(r, context)
);
});
}
Future<void> loadRecentShortUrls() async {
final response = await globals.serverManager.getRecentShortUrls();
response.fold((l) {
setState(() {
shortUrls = l;
shortUrlsLoaded = true;
});
}, (r) {
ScaffoldMessenger.of(context).showSnackBar(
buildApiErrorSnackbar(r, context)
);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
ColorFiltered(
colorFilter: ColorFilter.mode(
Colors.black.withOpacity(_qrCodeShown ? 0.4 : 0),
BlendMode.srcOver),
child: RefreshIndicator(
onRefresh: () async {
return loadAllData();
},
child: CustomScrollView(
slivers: [
SliverAppBar.medium(
automaticallyImplyLeading: false,
expandedHeight: 160,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Shlink",
style: TextStyle(fontWeight: FontWeight.bold)),
GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
builder: (BuildContext context) {
return const AvailableServerBottomSheet();
});
},
child: Text(globals.serverManager.getServerUrl(),
style: TextStyle(
fontSize: 16, color: Theme.of(context).colorScheme.onTertiary)),
)
],
)),
SliverToBoxAdapter(
child: Wrap(
alignment: WrapAlignment.spaceEvenly,
children: [
_ShlinkStatsCardWidget(
icon: Icons.link,
text:
"${shlinkStats?.shortUrlsCount.toString() ?? "0"} Short URLs",
borderColor: Colors.blue),
_ShlinkStatsCardWidget(
icon: Icons.remove_red_eye,
text:
"${shlinkStats?.nonOrphanVisits.total ?? "0"} Visits",
borderColor: Colors.green),
_ShlinkStatsCardWidget(
icon: Icons.warning,
text:
"${shlinkStats?.orphanVisits.total ?? "0"} Orphan Visits",
borderColor: Colors.red),
_ShlinkStatsCardWidget(
icon: Icons.sell,
text:
"${shlinkStats?.tagsCount.toString() ?? "0"} Tags",
borderColor: Colors.purple),
],
),
),
if (shortUrlsLoaded && shortUrls.isEmpty)
SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 50),
child: Column(
children: [
const Text(
"No Short URLs",
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold),
),
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'Create one by tapping the "+" button below',
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSecondary),
),
)
],
))))
else
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
if (index == 0) {
return const Padding(
padding:
EdgeInsets.only(top: 16, left: 12, right: 12),
child: Text("Recent Short URLs",
style: TextStyle(
fontSize: 20, fontWeight: FontWeight.bold)),
);
} else {
final shortURL = shortUrls[index - 1];
return ShortURLCell(
shortURL: shortURL,
reload: () {
loadRecentShortUrls();
},
showQRCode: (String url) {
setState(() {
_qrUrl = url;
_qrCodeShown = true;
});
},
isLast: index == shortUrls.length);
}
}, childCount: shortUrls.length + 1))
],
),
),
),
if (_qrCodeShown)
GestureDetector(
onTap: () {
setState(() {
_qrCodeShown = false;
});
},
child: Container(
color: Colors.black.withOpacity(0),
),
),
if (_qrCodeShown)
Center(
child: SizedBox(
width: MediaQuery.of(context).size.width / 1.7,
height: MediaQuery.of(context).size.width / 1.7,
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: QrImageView(
data: _qrUrl,
size: 200.0,
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color:
Theme.of(context).colorScheme.onPrimary
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color:
Theme.of(context).colorScheme.onPrimary
),
))),
),
)
],
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
await Navigator.of(context).push(MaterialPageRoute(
builder: (context) => const ShortURLEditView()));
loadRecentShortUrls();
},
child: const Icon(Icons.add),
));
}
}
// stats card widget
class _ShlinkStatsCardWidget extends StatefulWidget {
const _ShlinkStatsCardWidget(
{required this.text, required this.icon, this.borderColor});
final IconData icon;
final Color? borderColor;
final String text;
@override
State<_ShlinkStatsCardWidget> createState() => _ShlinkStatsCardWidgetState();
}
class _ShlinkStatsCardWidgetState extends State<_ShlinkStatsCardWidget> {
@override
Widget build(BuildContext context) {
var randomColor = ([...Colors.primaries]..shuffle()).first;
return Padding(
padding: const EdgeInsets.all(4),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: widget.borderColor ?? randomColor),
borderRadius: BorderRadius.circular(8)),
child: SizedBox(
child: Wrap(
children: [
Icon(widget.icon),
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(widget.text,
style: const TextStyle(fontWeight: FontWeight.bold)),
)
],
),
)),
);
}
}

179
lib/views/login_view.dart Normal file
View File

@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import 'package:shlink_app/API/server_manager.dart';
import 'package:shlink_app/main.dart';
import 'package:url_launcher/url_launcher.dart';
import '../globals.dart' as globals;
class LoginView extends StatefulWidget {
const LoginView({super.key});
@override
State<LoginView> createState() => _LoginViewState();
}
class _LoginViewState extends State<LoginView> {
late TextEditingController _serverUrlController;
late TextEditingController _apiKeyController;
bool _isLoggingIn = false;
String _errorMessage = "";
@override
void initState() {
// TODO: implement initState
super.initState();
_serverUrlController = TextEditingController();
_apiKeyController = TextEditingController();
}
void _connect() async {
setState(() {
_isLoggingIn = true;
_errorMessage = "";
});
final connectResult = await globals.serverManager
.initAndConnect(_serverUrlController.text, _apiKeyController.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(
physics: const NeverScrollableScrollPhysics(),
slivers: [
const SliverAppBar.medium(
title: Text("Add server",
style: TextStyle(fontWeight: FontWeight.bold))),
SliverFillRemaining(
child: Padding(
padding: const EdgeInsets.all(16),
child: Stack(
children: [
Align(
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),
)),
Row(
children: [
const Icon(Icons.dns_outlined),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _serverUrlController,
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)),
),
Row(
children: [
const Icon(Icons.key),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _apiKeyController,
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: Theme.of(context).colorScheme.onError),
textAlign: TextAlign.center))
],
),
),
],
),
),
Align(
alignment: Alignment.bottomCenter,
child: TextButton(
onPressed: () async {
final Uri url = Uri.parse('https://shlink.io/documentation/api-docs/authentication/');
try {
if (!await launchUrl(url)) {
throw Exception();
}
} catch (e) {
final snackBar = SnackBar(
content: Text("Unable to launch url. See Shlink docs for more information.",
style: TextStyle(color: Theme.of(context).colorScheme.onError)),
backgroundColor: Theme.of(context).colorScheme.error,
behavior: SnackBarBehavior.floating);
ScaffoldMessenger.of(context).showSnackBar(
snackBar);
}
},
child: Text("How to create an API Key"),
),
)
],
),
))
],
));
}
}

View File

@ -1,18 +1,21 @@
import 'package:flutter/material.dart';
import 'package:shlink_app/SettingsView.dart';
import 'package:shlink_app/HomeView.dart';
import 'package:shlink_app/URLListView.dart';
import 'package:shlink_app/views/settings_view.dart';
import 'package:shlink_app/views/home_view.dart';
import 'package:shlink_app/views/url_list_view.dart';
class NavigationBarView extends StatefulWidget {
const NavigationBarView({Key? key}) : super(key: key);
const NavigationBarView({super.key});
@override
State<NavigationBarView> createState() => _NavigationBarViewState();
}
class _NavigationBarViewState extends State<NavigationBarView> {
final List<Widget> views = [HomeView(), URLListView(), SettingsView()];
final List<Widget> views = [
const HomeView(),
const URLListView(),
const SettingsView()
];
int _selectedView = 0;
@override
@ -20,7 +23,7 @@ class _NavigationBarViewState extends State<NavigationBarView> {
return Scaffold(
body: views.elementAt(_selectedView),
bottomNavigationBar: NavigationBar(
destinations: [
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: "Home"),
NavigationDestination(icon: Icon(Icons.link), label: "Short URLs"),
NavigationDestination(icon: Icon(Icons.settings), label: "Settings")

View File

@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:shlink_app/util/license.dart';
import 'package:url_launcher/url_launcher.dart';
class OpenSourceLicensesView extends StatefulWidget {
const OpenSourceLicensesView({super.key});
@override
State<OpenSourceLicensesView> createState() => _OpenSourceLicensesViewState();
}
class _OpenSourceLicensesViewState extends State<OpenSourceLicensesView> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
const SliverAppBar.medium(
expandedHeight: 120,
title: Text(
"Open Source Licenses",
style: TextStyle(fontWeight: FontWeight.bold),
)),
SliverList(
delegate:
SliverChildBuilderDelegate((BuildContext context, int index) {
final currentLicense = LicenseUtil.getLicenses()[index];
return GestureDetector(
onTap: () async {
if (currentLicense.repository != null) {
if (await canLaunchUrl(
Uri.parse(currentLicense.repository ?? ""))) {
launchUrl(Uri.parse(currentLicense.repository ?? ""),
mode: LaunchMode.externalApplication);
}
}
},
child: Padding(
padding: const EdgeInsets.all(12),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.surfaceContainer,
),
child: Padding(
padding: const EdgeInsets.only(
left: 12, right: 12, top: 20, bottom: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(currentLicense.name,
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 18)),
Text("Version: ${currentLicense.version ?? "N/A"}",
style: TextStyle(color: Theme.of(context).colorScheme.onTertiary)),
const SizedBox(height: 8),
Divider(color: Theme.of(context).dividerColor),
const SizedBox(height: 8),
Text(currentLicense.license,
textAlign: TextAlign.justify,
style: TextStyle(color: Theme.of(context).colorScheme.onTertiary)),
],
),
),
),
),
);
}, childCount: LicenseUtil.getLicenses().length),
),
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.only(top: 8, bottom: 20),
child: Text(
"Thank you to all maintainers of these repositories 💝",
style: TextStyle(color: Theme.of(context).colorScheme.onTertiary),
textAlign: TextAlign.center,
),
))
],
),
);
}
}

View File

@ -0,0 +1,267 @@
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/util/build_api_error_snackbar.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) {
ScaffoldMessenger.of(context).showSnackBar(
buildApiErrorSnackbar(r, context)
);
return false;
});
}
void _saveRedirectRules() async {
final response = await globals.serverManager
.setRedirectRules(widget.shortURL.shortCode, redirectRules);
response.fold((l) {
Navigator.pop(context);
}, (r) {
ScaffoldMessenger.of(context).showSnackBar(
buildApiErrorSnackbar(r, context)
);
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, color: Colors.white))
: 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: Theme.of(context).colorScheme.onSecondary),
),
)
],
))))
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: Theme.of(context).dividerColor)),
),
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:
Theme.of(context).colorScheme.tertiary,
),
child: Text(_conditionToTagString(condition)),
),
);
}).toList(),
),
)
],
),
Wrap(
children: [
IconButton(
disabledColor:
Theme.of(context).disabledColor,
onPressed: widget.moveUp,
icon: const Icon(Icons.arrow_upward),
),
IconButton(
disabledColor:
Theme.of(context).disabledColor,
onPressed: widget.moveDown,
icon: const Icon(Icons.arrow_downward),
),
IconButton(
onPressed: widget.delete,
icon: const Icon(Icons.delete, color: Colors.red),
)
],
)
],
)));
}
}

View File

@ -0,0 +1,261 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shlink_app/util/build_api_error_snackbar.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;
class SettingsView extends StatefulWidget {
const SettingsView({super.key});
@override
State<SettingsView> createState() => _SettingsViewState();
}
enum ServerStatus { connected, connecting, disconnected }
class _SettingsViewState extends State<SettingsView> {
var _serverVersion = "---";
ServerStatus _serverStatus = ServerStatus.connecting;
PackageInfo packageInfo =
PackageInfo(appName: "", packageName: "", version: "", buildNumber: "");
@override
void initState() {
// TODO: implement initState
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => getServerHealth());
}
void getServerHealth() async {
var packageInfo = await PackageInfo.fromPlatform();
setState(() {
this.packageInfo = packageInfo;
});
final response = await globals.serverManager.getServerHealth();
response.fold((l) {
setState(() {
_serverVersion = l.version;
_serverStatus = ServerStatus.connected;
});
}, (r) {
setState(() {
_serverStatus = ServerStatus.disconnected;
});
ScaffoldMessenger.of(context).showSnackBar(
buildApiErrorSnackbar(r, context)
);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
const SliverAppBar.medium(
expandedHeight: 120,
title: Text(
"Settings",
style: TextStyle(fontWeight: FontWeight.bold),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
children: [
GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
builder: (BuildContext context) {
return const AvailableServerBottomSheet();
});
},
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.surfaceContainer
),
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: [
Text("Connected to",
style: TextStyle(color: Theme.of(context).colorScheme.onTertiary)),
Text(globals.serverManager.getServerUrl(),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16)),
Row(
children: [
Text("API Version: ",
style: TextStyle(
color: Theme.of(context).colorScheme.onTertiary,
fontWeight: FontWeight.w600)),
Text(globals.serverManager.getApiVersion(),
style: TextStyle(
color: Theme.of(context).colorScheme.onTertiary)),
const SizedBox(width: 16),
Text("Server Version: ",
style: TextStyle(
color: Theme.of(context).colorScheme.onTertiary,
fontWeight: FontWeight.w600)),
Text(_serverVersion,
style:
TextStyle(color: Theme.of(context).colorScheme.onTertiary))
],
),
],
)
],
),
),
),
),
const SizedBox(height: 8),
Divider(color: Theme.of(context).dividerColor),
const SizedBox(height: 8),
GestureDetector(
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) =>
const OpenSourceLicensesView()));
},
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.surfaceContainer
),
child: const Padding(
padding: EdgeInsets.only(
left: 12, right: 12, top: 20, bottom: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.policy_outlined),
SizedBox(width: 8),
Text("Open Source Licenses",
style: TextStyle(
fontWeight: FontWeight.w500)),
],
),
Icon(Icons.chevron_right)
]),
),
),
),
const SizedBox(height: 16),
GestureDetector(
onTap: () async {
var url = Uri.parse(
"https://github.com/rainloreley/shlink-mobile-app");
if (await canLaunchUrl(url)) {
launchUrl(url, mode: LaunchMode.externalApplication);
}
},
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.surfaceContainer
),
child: const Padding(
padding: EdgeInsets.only(
left: 12, right: 12, top: 20, bottom: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.code),
SizedBox(width: 8),
Text("GitHub",
style: TextStyle(
fontWeight: FontWeight.w500)),
],
),
Icon(Icons.chevron_right)
]),
),
),
),
const SizedBox(height: 16),
GestureDetector(
onTap: () async {
var url = Uri.parse(
"https://wiki.abmgrt.dev/de/projects/shlink-manager/privacy");
if (await canLaunchUrl(url)) {
launchUrl(url, mode: LaunchMode.externalApplication);
}
},
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.surfaceContainer
),
child: const Padding(
padding: EdgeInsets.only(
left: 12, right: 12, top: 20, bottom: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.lock),
SizedBox(width: 8),
Text("Privacy Policy",
style: TextStyle(
fontWeight: FontWeight.w500)),
],
),
Icon(Icons.chevron_right)
]),
),
),
),
const SizedBox(height: 16),
if (packageInfo.appName != "")
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Container(
padding: const EdgeInsets.only(
left: 8, right: 8, top: 4, bottom: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color:
Theme.of(context).colorScheme.surfaceContainer
),
child: Text(
"${packageInfo.appName}, v${packageInfo.version} (${packageInfo.buildNumber})",
style: TextStyle(color: Theme.of(context).colorScheme.onSecondary),
),
)
],
)
],
)),
)
],
));
}
}

View File

@ -0,0 +1,405 @@
import 'package:dartz/dartz.dart' as dartz;
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.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/server_manager.dart';
import 'package:shlink_app/util/build_api_error_snackbar.dart';
import 'package:shlink_app/util/string_to_color.dart';
import 'package:shlink_app/views/tag_selector_view.dart';
import 'package:shlink_app/widgets/url_tags_list_widget.dart';
import '../globals.dart' as globals;
class ShortURLEditView extends StatefulWidget {
const ShortURLEditView({super.key, this.shortUrl, this.longUrl});
final ShortURL? shortUrl;
final String? longUrl;
@override
State<ShortURLEditView> createState() => _ShortURLEditViewState();
}
class _ShortURLEditViewState extends State<ShortURLEditView>
with SingleTickerProviderStateMixin {
final longUrlController = TextEditingController();
final customSlugController = TextEditingController();
final titleController = TextEditingController();
final randomSlugLengthController = TextEditingController(text: "5");
List<String> tags = [];
bool randomSlug = true;
bool isCrawlable = true;
bool forwardQuery = true;
bool copyToClipboard = true;
bool disableSlugEditor = false;
String longUrlError = "";
String randomSlugLengthError = "";
bool isSaving = false;
late AnimationController _customSlugDiceAnimationController;
@override
void initState() {
_customSlugDiceAnimationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
loadExistingUrl();
if (widget.longUrl != null) {
longUrlController.text = widget.longUrl!;
}
super.initState();
}
@override
void dispose() {
longUrlController.dispose();
customSlugController.dispose();
titleController.dispose();
randomSlugLengthController.dispose();
super.dispose();
}
void loadExistingUrl() {
if (widget.shortUrl != null) {
longUrlController.text = widget.shortUrl!.longUrl;
isCrawlable = widget.shortUrl!.crawlable;
tags = widget.shortUrl!.tags;
// for some reason this attribute is not returned by the api
forwardQuery = true;
titleController.text = widget.shortUrl!.title ?? "";
customSlugController.text = widget.shortUrl!.shortCode;
disableSlugEditor = true;
randomSlug = false;
}
}
void _saveButtonPressed() {
if (!isSaving) {
setState(() {
isSaving = true;
longUrlError = "";
randomSlugLengthError = "";
});
if (longUrlController.text == "") {
setState(() {
longUrlError = "URL cannot be empty";
isSaving = false;
});
return;
} else if (int.tryParse(randomSlugLengthController.text) ==
null ||
int.tryParse(randomSlugLengthController.text)! < 1 ||
int.tryParse(randomSlugLengthController.text)! > 50) {
setState(() {
randomSlugLengthError = "invalid number";
isSaving = false;
});
return;
} else {
_submitShortUrl();
}
}
}
void _submitShortUrl() async {
var newSubmission = ShortURLSubmission(
longUrl: longUrlController.text,
tags: tags,
crawlable: isCrawlable,
forwardQuery: forwardQuery,
findIfExists: true,
title: titleController.text != "" ? titleController.text : null,
customSlug: customSlugController.text != "" && !randomSlug
? customSlugController.text
: null,
shortCodeLength:
randomSlug ? int.parse(randomSlugLengthController.text) : null);
dartz.Either<ShortURL, Failure> response;
if (widget.shortUrl != null) {
response = await globals.serverManager.updateShortUrl(newSubmission);
} else {
response = await globals.serverManager.submitShortUrl(newSubmission);
}
response.fold((l) async {
setState(() {
isSaving = false;
});
if (copyToClipboard) {
await Clipboard.setData(ClipboardData(text: l.shortUrl));
final snackBar = SnackBar(
content: const Text("Copied to clipboard!"),
backgroundColor: Colors.green[400],
behavior: SnackBarBehavior.floating);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
} else {
final snackBar = SnackBar(
content: const Text("Short URL created!"),
backgroundColor: Colors.green[400],
behavior: SnackBarBehavior.floating);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
Navigator.pop(context, l);
return true;
}, (r) {
setState(() {
isSaving = false;
});
ScaffoldMessenger.of(context).showSnackBar(
buildApiErrorSnackbar(r, context)
);
return false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar.medium(
title: Text("${disableSlugEditor ? "Edit" : "New"} Short URL",
style: const TextStyle(fontWeight: FontWeight.bold)),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 16, left: 8, right: 8),
child: Wrap(
runSpacing: 16,
children: [
TextField(
controller: longUrlController,
decoration: InputDecoration(
errorText: longUrlError != "" ? longUrlError : null,
border: const OutlineInputBorder(),
label: const Row(
children: [
Icon(Icons.public),
SizedBox(width: 8),
Text("Long URL")
],
)),
),
Row(
children: [
Expanded(
child: TextField(
enabled: !disableSlugEditor,
controller: customSlugController,
style: TextStyle(
color: randomSlug
? Theme.of(context).colorScheme.onTertiary
: Theme.of(context).colorScheme.onPrimary),
onChanged: (_) {
if (randomSlug) {
setState(() {
randomSlug = false;
});
}
},
decoration: InputDecoration(
border: const OutlineInputBorder(),
label: Row(
children: [
const Icon(Icons.link),
const SizedBox(width: 8),
Text(
"${randomSlug ? "Random" : "Custom"} slug",
style: TextStyle(
fontStyle: randomSlug
? FontStyle.italic
: FontStyle.normal),
)
],
)),
),
),
if (widget.shortUrl == null)
Container(
padding: const EdgeInsets.only(left: 8),
child: RotationTransition(
turns: Tween(begin: 0.0, end: 3.0).animate(
CurvedAnimation(
parent: _customSlugDiceAnimationController,
curve: Curves.easeInOutExpo)),
child: IconButton(
onPressed: disableSlugEditor
? null
: () {
if (randomSlug) {
_customSlugDiceAnimationController.reverse(
from: 1);
} else {
_customSlugDiceAnimationController.forward(
from: 0);
}
setState(() {
randomSlug = !randomSlug;
});
},
icon: Icon(
randomSlug ? Icons.casino : Icons.casino_outlined,
color: randomSlug ? Colors.green : Colors.grey)),
),
)
],
),
if (randomSlug && widget.shortUrl == null)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("Random slug length"),
SizedBox(
width: 100,
child: TextField(
controller: randomSlugLengthController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
errorText:
randomSlugLengthError != "" ? "" : null,
border: const OutlineInputBorder(),
label: const Row(
children: [
Icon(Icons.tag),
SizedBox(width: 8),
Text("Length")
],
)),
))
],
),
TextField(
controller: titleController,
decoration: const InputDecoration(
border: OutlineInputBorder(),
label: Row(
children: [
Icon(Icons.badge),
SizedBox(width: 8),
Text("Title")
],
)),
),
GestureDetector(
onTap: () async {
List<String>? selectedTags = await Navigator.of(context).
push(MaterialPageRoute(
builder: (context) =>
TagSelectorView(alreadySelectedTags: tags)));
if (selectedTags != null) {
setState(() {
tags = selectedTags;
});
}
},
child: InputDecorator(
isEmpty: tags.isEmpty,
decoration: const InputDecoration(
border: OutlineInputBorder(),
label: Row(
children: [
Icon(Icons.label_outline),
SizedBox(width: 8),
Text("Tags")
],
)),
child: Wrap(
runSpacing: 8,
spacing: 8,
children: tags.map((tag) {
var boxColor = stringToColor(tag)
.harmonizeWith(Theme.of(context).colorScheme.
primary);
var textColor = boxColor.computeLuminance() < 0.5
? Colors.white
: Colors.black;
return InputChip(
label: Text(tag, style: TextStyle(
color: textColor
)),
backgroundColor: boxColor,
deleteIcon: Icon(Icons.close,
size: 18,
color: textColor),
onDeleted: () {
setState(() {
tags.remove(tag);
});
},
);
}).toList(),
)
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("Crawlable"),
Switch(
value: isCrawlable,
onChanged: (_) {
setState(() {
isCrawlable = !isCrawlable;
});
},
)
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("Forward query params"),
Switch(
value: forwardQuery,
onChanged: (_) {
setState(() {
forwardQuery = !forwardQuery;
});
},
)
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("Copy to clipboard"),
Switch(
value: copyToClipboard,
onChanged: (_) {
setState(() {
copyToClipboard = !copyToClipboard;
});
},
)
],
),
const SizedBox(height: 150)
],
),
)
)
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
_saveButtonPressed();
},
child: isSaving
? const Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(strokeWidth: 3,
color: Colors.white))
: const Icon(Icons.save)),
);
}
}

View File

@ -0,0 +1,246 @@
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:shlink_app/API/Classes/ShortURL/visits_summary.dart';
import 'package:shlink_app/API/Classes/Tag/tag_with_stats.dart';
import 'package:shlink_app/util/build_api_error_snackbar.dart';
import 'package:shlink_app/util/string_to_color.dart';
import '../globals.dart' as globals;
class TagSelectorView extends StatefulWidget {
const TagSelectorView({super.key, this.alreadySelectedTags = const []});
final List<String> alreadySelectedTags;
@override
State<TagSelectorView> createState() => _TagSelectorViewState();
}
class _TagSelectorViewState extends State<TagSelectorView> {
final FocusNode searchTagFocusNode = FocusNode();
final searchTagController = TextEditingController();
List<TagWithStats> availableTags = [];
List<TagWithStats> selectedTags = [];
List<TagWithStats> filteredTags = [];
bool tagsLoaded = false;
@override
void initState() {
super.initState();
selectedTags = [];
searchTagController.text = "";
filteredTags = [];
searchTagFocusNode.requestFocus();
WidgetsBinding.instance.addPostFrameCallback((_) => loadTags());
}
@override
void dispose() {
searchTagFocusNode.dispose();
searchTagController.dispose();
super.dispose();
}
Future<void> loadTags() async {
final response =
await globals.serverManager.getTags();
response.fold((l) {
List<TagWithStats> mappedAlreadySelectedTags =
widget.alreadySelectedTags.map((e) {
return l.firstWhere((t) => t.tag == e, orElse: () {
// account for newly created tags
return TagWithStats(e, 0, VisitsSummary(0,0,0));
});
}).toList();
setState(() {
availableTags = (l + [... mappedAlreadySelectedTags]).toSet().toList();
selectedTags = [...mappedAlreadySelectedTags];
filteredTags = availableTags;
tagsLoaded = true;
});
_sortLists();
return true;
}, (r) {
ScaffoldMessenger.of(context).showSnackBar(
buildApiErrorSnackbar(r, context)
);
return false;
});
}
void _sortLists() {
setState(() {
availableTags.sort((a, b) => a.tag.compareTo(b.tag));
filteredTags.sort((a, b) => a.tag.compareTo(b.tag));
});
}
void _searchTextChanged(String text) {
if (text == "") {
setState(() {
filteredTags = availableTags;
});
} else {
setState(() {
filteredTags = availableTags.where((t) => t.tag.toLowerCase()
.contains(text.toLowerCase())).toList();
});
}
_sortLists();
}
void _addNewTag(String tag) {
bool tagExists = availableTags.where((t) => t.tag == tag).toList().isNotEmpty;
if (tag != "" && !tagExists) {
TagWithStats tagWithStats = TagWithStats(tag, 0, VisitsSummary(0, 0, 0));
setState(() {
availableTags.add(tagWithStats);
selectedTags.add(tagWithStats);
_searchTextChanged(tag);
});
_sortLists();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: TextField(
controller: searchTagController,
focusNode: searchTagFocusNode,
onChanged: _searchTextChanged,
decoration: const InputDecoration(
hintText: "Start typing...",
border: InputBorder.none,
icon: Icon(Icons.label_outline),
),
),
actions: [
IconButton(
onPressed: () {
Navigator.pop(context, selectedTags.map((t) => t.tag).toList());
},
icon: const Icon(Icons.check),
)
],
),
body: CustomScrollView(
slivers: [
if (!tagsLoaded)
const SliverToBoxAdapter(
child: Center(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(strokeWidth: 3),
),
),
)
else if (tagsLoaded && availableTags.isEmpty)
SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 50),
child: Column(
children: [
const Text(
"No Tags",
style: TextStyle(
fontSize: 24, fontWeight: FontWeight.bold),
),
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'Start typing to add new tags!',
style: TextStyle(
fontSize: 16, color: Theme.of(context).colorScheme.onSecondary),
),
)
],
))))
else
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
bool _isSelected = selectedTags.contains(filteredTags[index]);
TagWithStats _tag = filteredTags[index];
return GestureDetector(
onTap: () {
if (_isSelected) {
setState(() {
selectedTags.remove(_tag);
});
} else {
setState(() {
selectedTags.add(_tag);
});
}
},
child: Container(
padding: const EdgeInsets.only(left: 16, right: 16,
top: 16, bottom: 16),
decoration: BoxDecoration(
color: _isSelected ? Theme.of(context).colorScheme.primary : null,
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Wrap(
spacing: 10,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: stringToColor(_tag.tag)
.harmonizeWith(Theme.of(context).colorScheme.primary),
borderRadius: BorderRadius.circular(15)
),
),
Text(_tag.tag)
],
),
Text("${_tag.shortUrlsCount} short URL"
"${_tag.shortUrlsCount == 1 ? "" : "s"}",
style: TextStyle(
color: Theme.of(context).colorScheme.onTertiary,
fontSize: 12
),)
],
)
)
);
}, childCount: filteredTags.length
),
),
if (searchTagController.text != "" &&
!availableTags.contains(searchTagController.text))
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 8, bottom: 8,
left: 16, right: 16),
child: Center(
child: TextButton(
onPressed: () {
_addNewTag(searchTagController.text);
},
child: Text('Add tag "${searchTagController.text}"'),
),
),
),
)
],
)
);
}
}

View File

@ -0,0 +1,254 @@
import 'package:flutter/material.dart';
import 'package:shlink_app/API/Classes/ShortURL/short_url.dart';
import 'package:intl/intl.dart';
import 'package:shlink_app/util/build_api_error_snackbar.dart';
import 'package:shlink_app/views/redirect_rules_detail_view.dart';
import 'package:shlink_app/views/short_url_edit_view.dart';
import 'package:shlink_app/widgets/url_tags_list_widget.dart';
import 'package:url_launcher/url_launcher.dart';
import '../globals.dart' as globals;
class URLDetailView extends StatefulWidget {
const URLDetailView({super.key, required this.shortURL});
final ShortURL shortURL;
@override
State<URLDetailView> createState() => _URLDetailViewState();
}
class _URLDetailViewState extends State<URLDetailView> {
ShortURL shortURL = ShortURL.empty();
@override
void initState() {
super.initState();
setState(() {
shortURL = widget.shortURL;
});
}
Future showDeletionConfirmation() {
return showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text("Delete Short URL"),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text("You're about to delete"),
const SizedBox(height: 4),
Text(
shortURL.title ?? shortURL.shortCode,
style: const TextStyle(fontStyle: FontStyle.italic),
),
const SizedBox(height: 4),
const Text("It'll be gone forever! (a very long time)")
],
),
),
actions: [
TextButton(
onPressed: () => {Navigator.of(context).pop()},
child: const Text("Cancel")),
TextButton(
onPressed: () async {
var response = await globals.serverManager
.deleteShortUrl(shortURL.shortCode);
response.fold((l) {
Navigator.pop(context);
Navigator.pop(context);
final snackBar = SnackBar(
content: const Text("Short URL deleted!"),
backgroundColor: Colors.green[400],
behavior: SnackBarBehavior.floating);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
return true;
}, (r) {
ScaffoldMessenger.of(context).showSnackBar(
buildApiErrorSnackbar(r, context)
);
return false;
});
},
child:
const Text("Delete", style: TextStyle(color: Colors.red)),
)
],
);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar.medium(
title: Text(shortURL.title ?? shortURL.shortCode,
style: const TextStyle(fontWeight: FontWeight.bold)),
actions: [
IconButton(
onPressed: () async {
ShortURL updatedUrl = await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) =>
ShortURLEditView(shortUrl: shortURL)));
setState(() {
shortURL = updatedUrl;
});
},
icon: const Icon(Icons.edit)),
IconButton(
onPressed: () {
showDeletionConfirmation();
},
icon: const Icon(
Icons.delete,
color: Colors.red,
))
],
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0),
child: UrlTagsListWidget(tags: shortURL.tags)),
),
_ListCell(title: "Short Code", content: shortURL.shortCode),
_ListCell(
title: "Short URL", content: shortURL.shortUrl, isUrl: true),
_ListCell(title: "Long URL", content: shortURL.longUrl, isUrl: true),
_ListCell(title: "Creation Date", content: shortURL.dateCreated),
_ListCell(
title: "Redirect Rules",
content: null,
clickableDetailView: RedirectRulesDetailView(shortURL: shortURL)),
const _ListCell(title: "Visits", content: ""),
_ListCell(
title: "Total", content: shortURL.visitsSummary.total, sub: true),
_ListCell(
title: "Non-Bots",
content: shortURL.visitsSummary.nonBots,
sub: true),
_ListCell(
title: "Bots", content: shortURL.visitsSummary.bots, sub: true),
const _ListCell(title: "Meta", content: ""),
_ListCell(
title: "Valid Since",
content: shortURL.meta.validSince,
sub: true),
_ListCell(
title: "Valid Until",
content: shortURL.meta.validUntil,
sub: true),
_ListCell(
title: "Max Visits", content: shortURL.meta.maxVisits, sub: true),
_ListCell(title: "Domain", content: shortURL.domain),
_ListCell(title: "Crawlable", content: shortURL.crawlable, last: true)
],
),
);
}
}
class _ListCell extends StatefulWidget {
const _ListCell(
{required this.title,
required this.content,
this.sub = false,
this.last = false,
this.isUrl = false,
this.clickableDetailView});
final String title;
final dynamic content;
final bool sub;
final bool last;
final bool isUrl;
final Widget? clickableDetailView;
@override
State<_ListCell> createState() => _ListCellState();
}
class _ListCellState extends State<_ListCell> {
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.only(top: 16, bottom: widget.last ? 30 : 0),
child: GestureDetector(
onTap: () async {
if (widget.clickableDetailView != null) {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => widget.clickableDetailView!));
} else if (widget.content is String) {
Uri? parsedUrl = Uri.tryParse(widget.content);
if (widget.isUrl &&
parsedUrl != null &&
await canLaunchUrl(parsedUrl)) {
launchUrl(parsedUrl);
}
}
},
child: Container(
padding: const EdgeInsets.only(top: 16, left: 8, right: 8),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: Theme.of(context).dividerColor)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
if (widget.sub)
Padding(
padding: const EdgeInsets.only(right: 4),
child: SizedBox(
width: 20,
height: 6,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.outline,
),
),
),
),
Text(
widget.title,
style: const TextStyle(fontWeight: FontWeight.bold),
)
],
),
if (widget.content is bool)
Icon(widget.content ? Icons.check : Icons.close,
color: widget.content ? Colors.green : Colors.red)
else if (widget.content is int)
Text(widget.content.toString())
else if (widget.content is String)
Expanded(
child: Text(
widget.content,
textAlign: TextAlign.end,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
)
else if (widget.content is DateTime)
Text(DateFormat('yyyy-MM-dd - HH:mm')
.format(widget.content))
else if (widget.clickableDetailView != null)
const Icon(Icons.chevron_right)
else
const Text("N/A")
],
),
),
)));
}
}

View File

@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:shlink_app/API/Classes/ShortURL/short_url.dart';
import 'package:shlink_app/util/build_api_error_snackbar.dart';
import 'package:shlink_app/views/short_url_edit_view.dart';
import 'package:shlink_app/views/url_detail_view.dart';
import 'package:shlink_app/widgets/url_tags_list_widget.dart';
import '../globals.dart' as globals;
import 'package:flutter/services.dart';
class URLListView extends StatefulWidget {
const URLListView({super.key});
@override
State<URLListView> createState() => _URLListViewState();
}
class _URLListViewState extends State<URLListView> {
List<ShortURL> shortUrls = [];
bool _qrCodeShown = false;
String _qrUrl = "";
bool shortUrlsLoaded = false;
@override
void initState() {
// TODO: implement initState
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => loadAllShortUrls());
}
Future<void> loadAllShortUrls() async {
final response = await globals.serverManager.getShortUrls();
response.fold((l) {
setState(() {
shortUrls = l;
shortUrlsLoaded = true;
});
return true;
}, (r) {
ScaffoldMessenger.of(context).showSnackBar(
buildApiErrorSnackbar(r, context)
);
return false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () async {
await Navigator.of(context).push(MaterialPageRoute(
builder: (context) => const ShortURLEditView()));
loadAllShortUrls();
},
child: const Icon(Icons.add),
),
body: Stack(
children: [
ColorFiltered(
colorFilter: ColorFilter.mode(
Colors.black.withOpacity(_qrCodeShown ? 0.4 : 0),
BlendMode.srcOver),
child: RefreshIndicator(
onRefresh: () async {
return loadAllShortUrls();
},
child: CustomScrollView(
slivers: [
const SliverAppBar.medium(
title: Text("Short URLs",
style: TextStyle(fontWeight: FontWeight.bold))),
if (shortUrlsLoaded && shortUrls.isEmpty)
SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 50),
child: Column(
children: [
const Text(
"No Short URLs",
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold),
),
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'Create one by tapping the "+" button below',
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSecondary),
),
)
],
))))
else
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
final shortURL = shortUrls[index];
return ShortURLCell(
shortURL: shortURL,
reload: () {
loadAllShortUrls();
},
showQRCode: (String url) {
setState(() {
_qrUrl = url;
_qrCodeShown = true;
});
},
isLast: index == shortUrls.length - 1);
}, childCount: shortUrls.length))
],
),
),
),
if (_qrCodeShown)
GestureDetector(
onTap: () {
setState(() {
_qrCodeShown = false;
});
},
child: Container(
color: Colors.black.withOpacity(0),
),
),
if (_qrCodeShown)
Center(
child: SizedBox(
width: MediaQuery.of(context).size.width / 1.7,
height: MediaQuery.of(context).size.width / 1.7,
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: QrImageView(
data: _qrUrl,
size: 200.0,
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color:
Theme.of(context).colorScheme.onPrimary,
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: Theme.of(context).colorScheme.onPrimary,
),
))),
),
)
],
));
}
}
class ShortURLCell extends StatefulWidget {
const ShortURLCell(
{super.key,
required this.shortURL,
required this.reload,
required this.showQRCode,
required this.isLast});
final ShortURL shortURL;
final Function() reload;
final Function(String url) showQRCode;
final bool isLast;
@override
State<ShortURLCell> createState() => _ShortURLCellState();
}
class _ShortURLCellState extends State<ShortURLCell> {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () async {
await Navigator.of(context)
.push(MaterialPageRoute(
builder: (context) =>
URLDetailView(shortURL: widget.shortURL)))
.then((a) => {widget.reload()});
},
child: Padding(
padding: EdgeInsets.only(
left: 8, right: 8, bottom: widget.isLast ? 90 : 0),
child: Container(
padding:
const EdgeInsets.only(left: 8, right: 8, bottom: 16, top: 16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.shortURL.title ?? widget.shortURL.shortCode,
textScaleFactor: 1.4,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
widget.shortURL.longUrl,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textScaleFactor: 0.9,
style: TextStyle(color: Theme.of(context).colorScheme.onTertiary),
),
// List tags in a row
UrlTagsListWidget(tags: widget.shortURL.tags)
],
),
),
IconButton(
onPressed: () async {
await Clipboard.setData(
ClipboardData(text: widget.shortURL.shortUrl));
final snackBar = SnackBar(
content: const Text("Copied to clipboard!"),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.green[400]);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
},
icon: const Icon(Icons.copy)),
IconButton(
onPressed: () {
widget.showQRCode(widget.shortURL.shortUrl);
},
icon: const Icon(Icons.qr_code))
],
)),
));
}
}

View File

@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shlink_app/main.dart';
import 'package:shlink_app/views/login_view.dart';
import '../globals.dart' as globals;
class AvailableServerBottomSheet extends StatefulWidget {
const AvailableServerBottomSheet({super.key});
@override
State<AvailableServerBottomSheet> createState() =>
_AvailableServerBottomSheetState();
}
class _AvailableServerBottomSheetState
extends State<AvailableServerBottomSheet> {
List<String> availableServers = [];
@override
void initState() {
super.initState();
_loadServers();
}
Future<void> _loadServers() async {
List<String> savedServers =
await globals.serverManager.getAvailableServers();
setState(() {
availableServers = savedServers;
});
}
@override
Widget build(BuildContext context) {
return CustomScrollView(
slivers: [
const SliverAppBar.medium(
expandedHeight: 120,
automaticallyImplyLeading: false,
title: Text(
"Available Servers",
style: TextStyle(fontWeight: FontWeight.bold),
),
),
SliverList(
delegate:
SliverChildBuilderDelegate((BuildContext context, int index) {
return GestureDetector(
onTap: () async {
final prefs = await SharedPreferences.getInstance();
prefs.setString("lastusedserver", availableServers[index]);
await Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (context) => const InitialPage()),
(Route<dynamic> route) => false);
},
child: 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: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Wrap(
spacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
const Icon(Icons.dns_outlined),
Text(availableServers[index])
],
),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (availableServers[index] ==
globals.serverManager.serverUrl)
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(4)),
),
IconButton(
onPressed: () async {
globals.serverManager
.logOut(availableServers[index]);
if (availableServers[index] ==
globals.serverManager.serverUrl) {
await Navigator.of(context)
.pushAndRemoveUntil(
MaterialPageRoute(
builder: (context) =>
const InitialPage()),
(Route<dynamic> route) => false);
} else {
Navigator.pop(context);
}
},
icon: const Icon(Icons.logout, color: Colors.red),
)
],
)
],
))),
);
}, childCount: availableServers.length)),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Center(
child: ElevatedButton(
onPressed: () async {
await Navigator.of(context)
.push(MaterialPageRoute(builder: (context) => const LoginView()));
},
child: const Text("Add server..."),
),
),
))
],
);
}
}

View File

@ -0,0 +1,41 @@
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:shlink_app/util/string_to_color.dart';
class UrlTagsListWidget extends StatefulWidget {
const UrlTagsListWidget({super.key, required this.tags});
final List<String> tags;
@override
State<UrlTagsListWidget> createState() => _UrlTagsListWidgetState();
}
class _UrlTagsListWidgetState extends State<UrlTagsListWidget> {
@override
Widget build(BuildContext context) {
return Wrap(
children: widget.tags.map((tag) {
var boxColor = stringToColor(tag)
.harmonizeWith(Theme.of(context).colorScheme.primary);
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: boxColor,
),
child: Text(
tag,
style: TextStyle(
color: boxColor.computeLuminance() < 0.5
? Colors.white
: Colors.black),
),
),
);
}).toList());
}
}

View File

@ -5,18 +5,18 @@ packages:
dependency: transitive
description:
name: archive
sha256: "0c8368c9b3f0abbc193b9d6133649a614204b528982bebc7026372d61677ce3a"
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
url: "https://pub.dev"
source: hosted
version: "3.3.7"
version: "3.6.1"
args:
dependency: transitive
description:
name: args
sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596
sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
version: "2.5.0"
async:
dependency: transitive
description:
@ -53,10 +53,10 @@ packages:
dependency: transitive
description:
name: cli_util
sha256: b8db3080e59b2503ca9e7922c3df2072cf13992354d5e944074ffa836fba43b7
sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19
url: "https://pub.dev"
source: hosted
version: "0.4.0"
version: "0.4.1"
clock:
dependency: transitive
description:
@ -69,18 +69,10 @@ packages:
dependency: transitive
description:
name: collection
sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
url: "https://pub.dev"
source: hosted
version: "1.17.2"
convert:
dependency: transitive
description:
name: convert
sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
version: "1.18.0"
crypto:
dependency: transitive
description:
@ -93,10 +85,10 @@ packages:
dependency: "direct main"
description:
name: cupertino_icons
sha256: e35129dc44c9118cee2a5603506d823bab99c68393879edb440e0090d07586be
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
url: "https://pub.dev"
source: hosted
version: "1.0.5"
version: "1.0.8"
dartz:
dependency: "direct main"
description:
@ -109,10 +101,10 @@ packages:
dependency: "direct main"
description:
name: dynamic_color
sha256: de4798a7069121aee12d5895315680258415de9b00e717723a1bd73d58f0126d
sha256: eae98052fa6e2826bdac3dd2e921c6ce2903be15c6b7f8b6d8a5d49b5086298d
url: "https://pub.dev"
source: hosted
version: "1.6.6"
version: "1.7.0"
fake_async:
dependency: transitive
description:
@ -125,10 +117,10 @@ packages:
dependency: transitive
description:
name: ffi
sha256: ed5337a5660c506388a9f012be0288fb38b49020ce2b45fe1f8b8323fe429f99
sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
version: "2.1.2"
file:
dependency: transitive
description:
@ -145,20 +137,19 @@ packages:
flutter_launcher_icons:
dependency: "direct dev"
description:
path: "."
ref: "feat/monochrome-icons-support"
resolved-ref: "1902eba83da89b0350a70672ac7c963cd995e017"
url: "https://github.com/OutdatedGuy/flutter_launcher_icons.git"
source: git
name: flutter_launcher_icons
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
url: "https://pub.dev"
source: hosted
version: "0.13.1"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "2118df84ef0c3ca93f96123a616ae8540879991b8b57af2f81b76a7ada49b2a4"
sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
version: "4.0.0"
flutter_process_text:
dependency: "direct main"
description:
@ -171,50 +162,58 @@ packages:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "98352186ee7ad3639ccc77ad7924b773ff6883076ab952437d20f18a61f0a7c5"
sha256: "165164745e6afb5c0e3e3fcc72a012fb9e58496fb26ffb92cf22e16a821e85d0"
url: "https://pub.dev"
source: hosted
version: "8.0.0"
version: "9.2.2"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: "0912ae29a572230ad52d8a4697e5518d7f0f429052fd51df7e5a7952c7efe2a3"
sha256: "4d91bfc23047422cbcd73ac684bc169859ee766482517c22172c86596bf1464b"
url: "https://pub.dev"
source: hosted
version: "1.1.3"
version: "1.2.1"
flutter_secure_storage_macos:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: "083add01847fc1c80a07a08e1ed6927e9acd9618a35e330239d4422cd2a58c50"
sha256: "1693ab11121a5f925bbea0be725abfcfbbcf36c1e29e571f84a0c0f436147a81"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
version: "3.1.2"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: b3773190e385a3c8a382007893d678ae95462b3c2279e987b55d140d3b0cb81b
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
url: "https://pub.dev"
source: hosted
version: "1.0.1"
version: "1.1.2"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "42938e70d4b872e856e678c423cc0e9065d7d294f45bc41fc1981a4eb4beaffe"
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
url: "https://pub.dev"
source: hosted
version: "1.1.1"
version: "1.2.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: fc2910ec9b28d60598216c29ea763b3a96c401f0ce1d13cdf69ccb0e5c93c3ee
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
url: "https://pub.dev"
source: hosted
version: "2.0.0"
version: "3.1.2"
flutter_sharing_intent:
dependency: "direct main"
description:
name: flutter_sharing_intent
sha256: "785ffc391822641457f930eb477c91c2f598a888f50b8fbb40d481ee01c7e719"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter_test:
dependency: "direct dev"
description: flutter
@ -229,10 +228,10 @@ packages:
dependency: "direct main"
description:
name: http
sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2"
sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010
url: "https://pub.dev"
source: hosted
version: "0.13.6"
version: "1.2.2"
http_parser:
dependency: transitive
description:
@ -245,98 +244,146 @@ packages:
dependency: transitive
description:
name: image
sha256: a72242c9a0ffb65d03de1b7113bc4e189686fc07c7147b8b41811d0dd0e0d9bf
sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8"
url: "https://pub.dev"
source: hosted
version: "4.0.17"
version: "4.2.0"
intl:
dependency: "direct main"
description:
name: intl
sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d"
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
url: "https://pub.dev"
source: hosted
version: "0.18.1"
version: "0.19.0"
js:
dependency: transitive
description:
name: js
sha256: "5528c2f391ededb7775ec1daa69e65a2d61276f7552de2b5f7b8d34ee9fd4ab7"
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev"
source: hosted
version: "0.6.5"
version: "0.6.7"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.8.1"
version: "4.9.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a"
url: "https://pub.dev"
source: hosted
version: "10.0.4"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
license_generator:
dependency: "direct dev"
description:
name: license_generator
sha256: "147605cac9b1ca9ab7f52ed235498927fe7a9edaef56d1b4687f22b9b03f6bad"
sha256: "0b111c03cbccfa36a68a8738e3b2a54392a269673b5258d5fc6a83302d675a9e"
url: "https://pub.dev"
source: hosted
version: "1.0.5"
version: "2.0.0"
lints:
dependency: transitive
description:
name: lints
sha256: "5e4a9cd06d447758280a8ac2405101e0e2094d2a1dbdd3756aec3fe7775ba593"
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
version: "4.0.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e"
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
url: "https://pub.dev"
source: hosted
version: "0.12.16"
version: "0.12.16+1"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41"
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
version: "0.8.0"
meta:
dependency: transitive
description:
name: meta
sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3"
sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
version: "1.12.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: ceb027f6bc6a60674a233b4a90a7658af1aebdea833da0b5b53c1e9821a78c7b
sha256: b93d8b4d624b4ea19b0a5a208b2d6eff06004bc3ce74c06040b120eeadd00ce0
url: "https://pub.dev"
source: hosted
version: "4.0.2"
version: "8.0.0"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "9bc8ba46813a4cc42c66ab781470711781940780fd8beddd0c3da62506d3a6c6"
sha256: f49918f3433a3146047372f9d4f1f847511f2acd5cd030e1f44fe5a50036b70e
url: "https://pub.dev"
source: hosted
version: "2.0.1"
version: "3.0.0"
path:
dependency: transitive
description:
name: path
sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917"
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
url: "https://pub.dev"
source: hosted
version: "1.8.3"
version: "1.9.0"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161
url: "https://pub.dev"
source: hosted
version: "2.1.3"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "30c5aa827a6ae95ce2853cdc5fe3971daaac00f6f081c419c013f7f57bff2f5e"
url: "https://pub.dev"
source: hosted
version: "2.2.7"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16
url: "https://pub.dev"
source: hosted
version: "2.4.0"
path_provider_linux:
dependency: transitive
description:
@ -349,58 +396,50 @@ packages:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c"
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170"
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.2.1"
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "49392a45ced973e8d94a85fdb21293fbb40ba805fc49f2965101ae748a3683b4"
sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27
url: "https://pub.dev"
source: hosted
version: "5.1.0"
version: "6.0.2"
platform:
dependency: transitive
description:
name: platform
sha256: "0a279f0707af40c890e80b1e9df8bb761694c074ba7e1d4ab1bc4b728e200b59"
sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
version: "3.1.5"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "6a2128648c854906c53fa8e33986fc0247a1116122f9534dd20e3ab9e16a32bc"
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
pointycastle:
dependency: transitive
description:
name: pointycastle
sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c"
url: "https://pub.dev"
source: hosted
version: "3.7.3"
version: "2.1.8"
qr:
dependency: transitive
description:
name: qr
sha256: "64957a3930367bf97cc211a5af99551d630f2f4625e38af10edd6b19131b64b3"
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.0.2"
qr_flutter:
dependency: "direct main"
description:
@ -413,26 +452,26 @@ packages:
dependency: "direct main"
description:
name: shared_preferences
sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02"
sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180
url: "https://pub.dev"
source: hosted
version: "2.2.2"
version: "2.2.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06"
sha256: "93d0ec9dd902d85f326068e6a899487d1f65ffcd5798721a95330b26c8131577"
url: "https://pub.dev"
source: hosted
version: "2.2.1"
version: "2.2.3"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "7bf53a9f2d007329ee6f3df7268fd498f8373602f943c975598bbb34649b62a7"
sha256: "0a8a893bf4fd1152f93fec03a415d11c27c74454d96e2318a7ac38dd18683ab7"
url: "https://pub.dev"
source: hosted
version: "2.3.4"
version: "2.4.0"
shared_preferences_linux:
dependency: transitive
description:
@ -445,18 +484,18 @@ packages:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: d4ec5fc9ebb2f2e056c617112aa75dcf92fc2e4faaf2ae999caa297473f75d8a
sha256: "034650b71e73629ca08a0bd789fd1d83cc63c2d1e405946f7cef7bc37432f93a"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
version: "2.4.0"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: d762709c2bbe80626ecc819143013cc820fa49ca5e363620ee20a8b15a3e3daf
sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a"
url: "https://pub.dev"
source: hosted
version: "2.2.1"
version: "2.3.0"
shared_preferences_windows:
dependency: transitive
description:
@ -482,18 +521,18 @@ packages:
dependency: transitive
description:
name: stack_trace
sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
url: "https://pub.dev"
source: hosted
version: "1.11.0"
version: "1.11.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8"
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
url: "https://pub.dev"
source: hosted
version: "2.1.1"
version: "2.1.2"
string_scanner:
dependency: transitive
description:
@ -514,10 +553,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8"
sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
version: "0.7.0"
tuple:
dependency: "direct main"
description:
@ -538,66 +577,66 @@ packages:
dependency: "direct main"
description:
name: url_launcher
sha256: e8f2efc804810c0f2f5b485f49e7942179f56eabcfe81dce3387fec4bb55876b
sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3"
url: "https://pub.dev"
source: hosted
version: "6.1.9"
version: "6.3.0"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "15f5acbf0dce90146a0f5a2c4a002b1814a6303c4c5c075aa2623b2d16156f03"
sha256: "95d8027db36a0e52caf55680f91e33ea6aa12a3ce608c90b06f4e429a21067ac"
url: "https://pub.dev"
source: hosted
version: "6.0.36"
version: "6.3.5"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "9af7ea73259886b92199f9e42c116072f05ff9bea2dcb339ab935dfc957392c2"
sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e
url: "https://pub.dev"
source: hosted
version: "6.1.4"
version: "6.3.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "207f4ddda99b95b4d4868320a352d374b0b7e05eefad95a4a26f57da413443f5"
sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811
url: "https://pub.dev"
source: hosted
version: "3.0.5"
version: "3.1.1"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "91ee3e75ea9dadf38036200c5d3743518f4a5eb77a8d13fda1ee5764373f185e"
sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de"
url: "https://pub.dev"
source: hosted
version: "3.0.5"
version: "3.2.0"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: bfdfa402f1f3298637d71ca8ecfe840b4696698213d5346e9d12d4ab647ee2ea
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: cc26720eefe98c1b71d85f9dc7ef0cada5132617046369d9dc296b3ecaa5cbb4
sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a"
url: "https://pub.dev"
source: hosted
version: "2.0.18"
version: "2.3.1"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "7967065dd2b5fccc18c653b97958fdf839c5478c28e767c61ee879f4e7882422"
sha256: "49c10f879746271804767cb45551ec5592cdab00ee105c06dddde1a98f73b185"
url: "https://pub.dev"
source: hosted
version: "3.0.7"
version: "3.1.2"
vector_math:
dependency: transitive
description:
@ -606,38 +645,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec"
url: "https://pub.dev"
source: hosted
version: "14.2.1"
web:
dependency: transitive
description:
name: web
sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10
sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27"
url: "https://pub.dev"
source: hosted
version: "0.1.4-beta"
version: "0.5.1"
win32:
dependency: transitive
description:
name: win32
sha256: "5a751eddf9db89b3e5f9d50c20ab8612296e4e8db69009788d6c8b060a84191c"
sha256: a79dbe579cb51ecd6d30b17e0cae4e0ea15e2c0e66f69ad4198f22a6789e94f4
url: "https://pub.dev"
source: hosted
version: "4.1.4"
version: "5.5.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "589ada45ba9e39405c198fe34eb0f607cddb2108527e658136120892beac46d2"
sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d
url: "https://pub.dev"
source: hosted
version: "1.0.3"
version: "1.0.4"
xml:
dependency: transitive
description:
name: xml
sha256: "979ee37d622dec6365e2efa4d906c37470995871fe9ae080d967e192d88286b5"
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
url: "https://pub.dev"
source: hosted
version: "6.2.2"
version: "6.5.0"
yaml:
dependency: transitive
description:
@ -647,5 +694,5 @@ packages:
source: hosted
version: "3.1.2"
sdks:
dart: ">=3.1.0-185.0.dev <4.0.0"
flutter: ">=3.7.0"
dart: ">=3.4.0 <4.0.0"
flutter: ">=3.22.0"

View File

@ -1,5 +1,5 @@
name: shlink_app
description: A new Flutter project.
description: App to manage a Shlink instance
# 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
@ -16,10 +16,10 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# 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: 0.9.0+4
version: 1.4.0+11
environment:
sdk: ^2.19.0
sdk: ^3.0.0
dependencies:
flutter:
sdk: flutter
@ -31,28 +31,26 @@ dependencies:
#
cupertino_icons: ^1.0.5
http: ^0.13.6
http: ^1.1.0
flutter_process_text: ^1.1.2
flutter_secure_storage: ^8.0.0
flutter_secure_storage: ^9.0.0
dartz: ^0.10.1
qr_flutter: ^4.1.0
tuple: ^2.0.2
intl: ^0.18.1
intl: ^0.19.0
dynamic_color: ^1.6.6
url_launcher: 6.1.9
package_info_plus: ^4.0.2
url_launcher: ^6.2.4
package_info_plus: ^8.0.0
shared_preferences: ^2.2.2
flutter_sharing_intent: ^1.1.1
dev_dependencies:
flutter_test:
sdk: flutter
license_generator: ^1.0.5
flutter_launcher_icons:
git:
url: https://github.com/OutdatedGuy/flutter_launcher_icons.git
ref: feat/monochrome-icons-support
license_generator: ^2.0.0
flutter_launcher_icons: ^0.13.1
flutter_lints: ^2.0.2
flutter_lints: ^4.0.0
flutter:
uses-material-design: true