diff --git a/README.md b/README.md
index 5dd0139..ce189fc 100644
--- a/README.md
+++ b/README.md
@@ -17,16 +17,14 @@ Shlink Manager is an app for Android to see and manage all shortened URLs create
## 📱 Current Features
✅ List all short URLs
-✅ Create new short URLs
-✅ Delete short URLs
+✅ Create, edit and delete short URLs
✅ See overall statistics
✅ Detailed statistics for each short URL
-✅ Display tags
-✅ Display QR code
+✅ Display tags & QR code
✅ Dark mode support
-✅ Edit existing short URLs
✅ Quickly create Short URL via Share Sheet
✅ View rule-based redirects (no editing yet)
+✅ Use multiple Shlink instances
## 🔨 To Do
- [ ] Add support for iOS (maybe in the future)
diff --git a/lib/API/server_manager.dart b/lib/API/server_manager.dart
index 9642a6e..78726de 100644
--- a/lib/API/server_manager.dart
+++ b/lib/API/server_manager.dart
@@ -1,4 +1,5 @@
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';
@@ -44,10 +45,39 @@ class ServerManager {
}
/// Logs out the user and removes data about the Shlink server
- Future logOut() async {
+ Future logOut(String url) async {
const storage = FlutterSecureStorage();
- await storage.delete(key: "shlink_url");
- await storage.delete(key: "shlink_apikey");
+ final prefs = await SharedPreferences.getInstance();
+
+ String? serverMapSerialized = await storage.read(key: "server_map");
+
+ if (serverMapSerialized != null) {
+ Map 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> getAvailableServers() async {
+ const storage = FlutterSecureStorage();
+ String? serverMapSerialized = await storage.read(key: "server_map");
+
+ if (serverMapSerialized != null) {
+ Map serverMap = Map.castFrom(jsonDecode(serverMapSerialized));
+ return serverMap.keys.toList();
+ } else {
+ return [];
+ }
}
/// Loads the server credentials from [FlutterSecureStorage]
@@ -60,16 +90,69 @@ class ServerManager {
prefs.setBool('first_run', false);
} else {
- serverUrl = await storage.read(key: "shlink_url");
- apiKey = await storage.read(key: "shlink_apikey");
+
+ if (await _replaceDeprecatedStorageMethod()) {
+ _loadCredentials();
+ return;
+ }
+
+ String? serverMapSerialized = await storage.read(key: "server_map");
+ String? lastUsedServer = prefs.getString("lastusedserver");
+
+ if (serverMapSerialized != null) {
+ Map serverMap = Map.castFrom(jsonDecode(serverMapSerialized));
+ if (lastUsedServer != null) {
+ serverUrl = lastUsedServer;
+ apiKey = serverMap[lastUsedServer]!;
+ } else {
+ List availableServers = serverMap.keys.toList();
+ if (!availableServers.isEmpty) {
+ serverUrl = availableServers.first;
+ apiKey = serverMap[serverUrl];
+ prefs.setString("lastusedserver", serverUrl!);
+ }
+ }
+ }
+ }
+ }
+
+ Future _replaceDeprecatedStorageMethod() async {
+ const storage = FlutterSecureStorage();
+ // deprecated data storage method, replaced because of multi-server support
+ var v1_data_serverUrl = await storage.read(key: "shlink_url");
+ var v1_data_apiKey = await storage.read(key: "shlink_apikey");
+
+ if (v1_data_serverUrl != null && v1_data_apiKey != null) {
+
+ // conversion to new data storage method
+ Map serverMap = {};
+ serverMap[v1_data_serverUrl] = v1_data_apiKey;
+
+ storage.write(key: "server_map", value: jsonEncode(serverMap));
+
+ storage.delete(key: "shlink_url");
+ storage.delete(key: "shlink_apikey");
+
+ return true;
+ } else {
+ return false;
}
}
/// Saves the provided server credentials to [FlutterSecureStorage]
void _saveCredentials(String url, String apiKey) async {
const storage = FlutterSecureStorage();
- storage.write(key: "shlink_url", value: url);
- storage.write(key: "shlink_apikey", value: apiKey);
+ final prefs = await SharedPreferences.getInstance();
+ String? serverMapSerialized = await storage.read(key: "server_map");
+ Map 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
@@ -81,7 +164,7 @@ class ServerManager {
_saveCredentials(url, apiKey);
final result = await connect();
result.fold((l) => null, (r) {
- logOut();
+ logOut(url);
});
return result;
}
diff --git a/lib/main.dart b/lib/main.dart
index 6c3a2e8..7ad5019 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -64,11 +64,13 @@ class _InitialPageState extends State {
void checkLogin() async {
bool result = await globals.serverManager.checkLogin();
if (result) {
- Navigator.of(context).pushReplacement(
- MaterialPageRoute(builder: (context) => const NavigationBarView()));
+ Navigator.of(context).pushAndRemoveUntil(
+ MaterialPageRoute(builder: (context) => const NavigationBarView()),
+ (Route route) => false);
} else {
- Navigator.of(context).pushReplacement(
- MaterialPageRoute(builder: (context) => const LoginView()));
+ Navigator.of(context).pushAndRemoveUntil(
+ MaterialPageRoute(builder: (context) => const LoginView()),
+ (Route route) => false);
}
}
diff --git a/lib/views/home_view.dart b/lib/views/home_view.dart
index 0fb4593..4969cfe 100644
--- a/lib/views/home_view.dart
+++ b/lib/views/home_view.dart
@@ -1,11 +1,15 @@
import 'dart:async';
import 'package:flutter/material.dart';
+import 'package:flutter_secure_storage/flutter_secure_storage.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:shared_preferences/shared_preferences.dart';
import 'package:shlink_app/API/Classes/ShlinkStats/shlink_stats.dart';
import 'package:shlink_app/API/server_manager.dart';
+import 'package:shlink_app/main.dart';
+import 'package:shlink_app/views/login_view.dart';
import 'package:shlink_app/views/short_url_edit_view.dart';
import 'package:shlink_app/views/url_list_view.dart';
import 'package:url_launcher/url_launcher_string.dart';
@@ -121,15 +125,25 @@ class _HomeViewState extends State {
child: CustomScrollView(
slivers: [
SliverAppBar.medium(
+ automaticallyImplyLeading: false,
expandedHeight: 160,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Shlink",
style: TextStyle(fontWeight: FontWeight.bold)),
- Text(globals.serverManager.getServerUrl(),
- style: TextStyle(
- fontSize: 16, color: Colors.grey[600]))
+ GestureDetector(
+ onTap: () {
+ showModalBottomSheet(
+ context: context,
+ builder: (BuildContext context) {
+ return AvailableServerBottomSheet();
+ });
+ },
+ child: Text(globals.serverManager.getServerUrl(),
+ style: TextStyle(
+ fontSize: 16, color: Colors.grey[600])),
+ )
],
)),
SliverToBoxAdapter(
@@ -308,3 +322,131 @@ class _ShlinkStatsCardWidgetState extends State<_ShlinkStatsCardWidget> {
);
}
}
+
+class AvailableServerBottomSheet extends StatefulWidget {
+ const AvailableServerBottomSheet({super.key});
+
+ @override
+ State createState() => _AvailableServerBottomSheetState();
+}
+
+class _AvailableServerBottomSheetState extends State {
+
+ List availableServers = [];
+
+ @override
+ void initState() {
+ super.initState();
+ _loadServers();
+ }
+
+ Future _loadServers() async {
+ List _availableServers = await globals.serverManager.getAvailableServers();
+ setState(() {
+ availableServers = _availableServers;
+ });
+ }
+
+ @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) =>
+ InitialPage()),
+ (Route route) => false);
+ },
+ child: Padding(
+ padding: EdgeInsets.only(left: 8, right: 8),
+ child: Container(
+ padding: 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: [
+ 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) =>
+ InitialPage()),
+ (Route route) => false);
+ } else {
+ Navigator.pop(context);
+ }
+ },
+ icon: Icon(Icons.logout, color: Colors.red),
+ )
+ ],
+ )
+ ],
+ )
+ )),
+ );
+ }, childCount: availableServers.length
+ )),
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: EdgeInsets.only(top: 8),
+ child: Center(
+ child: ElevatedButton(
+ onPressed: () async {
+ await Navigator.of(context)
+ .push(MaterialPageRoute(
+ builder: (context) =>
+ LoginView()));
+ },
+ child: Text("Add server..."),
+ ),
+ ),
+ )
+ )
+ ],
+ );
+ }
+}
diff --git a/lib/views/settings_view.dart b/lib/views/settings_view.dart
index 4f47de6..f6df81c 100644
--- a/lib/views/settings_view.dart
+++ b/lib/views/settings_view.dart
@@ -70,25 +70,6 @@ class _SettingsViewState extends State {
"Settings",
style: TextStyle(fontWeight: FontWeight.bold),
),
- actions: [
- PopupMenuButton(
- itemBuilder: (context) {
- return [
- const PopupMenuItem(
- value: 0,
- child: Text("Log out", style: TextStyle(color: Colors.red)),
- )
- ];
- },
- onSelected: (value) {
- if (value == 0) {
- globals.serverManager.logOut().then((value) =>
- Navigator.of(context).pushReplacement(MaterialPageRoute(
- builder: (context) => const LoginView())));
- }
- },
- )
- ],
),
SliverToBoxAdapter(
child: Padding(
diff --git a/pubspec.lock b/pubspec.lock
index 9cd2447..c44fa26 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -69,10 +69,10 @@ packages:
dependency: transitive
description:
name: collection
- sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687
+ sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
url: "https://pub.dev"
source: hosted
- version: "1.17.2"
+ version: "1.18.0"
convert:
dependency: transitive
description:
@@ -280,6 +280,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.8.1"
+ 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:
@@ -300,26 +324,26 @@ packages:
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:
@@ -340,10 +364,10 @@ packages:
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:
@@ -513,18 +537,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:
@@ -545,10 +569,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:
@@ -637,14 +661,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
- web:
+ vm_service:
dependency: transitive
description:
- name: web
- sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10
+ name: vm_service
+ sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec"
url: "https://pub.dev"
source: hosted
- version: "0.1.4-beta"
+ version: "14.2.1"
win32:
dependency: transitive
description:
@@ -678,5 +702,5 @@ packages:
source: hosted
version: "3.1.2"
sdks:
- dart: ">=3.1.0 <4.0.0"
- flutter: ">=3.13.0"
+ dart: ">=3.3.0 <4.0.0"
+ flutter: ">=3.18.0-18.0.pre.54"