support multiple shlink instances

This commit is contained in:
Adrian Baumgart 2024-07-25 19:11:24 +02:00
parent ce28066d29
commit 69c8870997
No known key found for this signature in database
6 changed files with 291 additions and 61 deletions

View File

@ -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<br/>
✅ Create new short URLs<br/>
✅ Delete short URLs<br/>
✅ Create, edit and delete short URLs<br/>
✅ See overall statistics<br/>
✅ Detailed statistics for each short URL<br/>
✅ Display tags<br/>
✅ Display QR code<br/>
✅ Display tags & QR code<br/>
✅ Dark mode support<br/>
✅ Edit existing short URLs<br/>
✅ Quickly create Short URL via Share Sheet<br/>
✅ View rule-based redirects (no editing yet)<br/>
✅ Use multiple Shlink instances<br/>
## 🔨 To Do
- [ ] Add support for iOS (maybe in the future)

View File

@ -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<void> logOut() async {
Future<void> logOut(String url) async {
const storage = FlutterSecureStorage();
await storage.delete(key: "shlink_url");
await storage.delete(key: "shlink_apikey");
final prefs = await SharedPreferences.getInstance();
String? serverMapSerialized = await storage.read(key: "server_map");
if (serverMapSerialized != null) {
Map<String, String> serverMap = Map.castFrom(jsonDecode(serverMapSerialized));
serverMap.remove(url);
if (serverMap.isEmpty) {
storage.delete(key: "server_map");
} else {
storage.write(key: "server_map", value: jsonEncode(serverMap));
}
if (serverUrl == url) {
serverUrl = null;
apiKey = null;
prefs.remove("lastusedserver");
}
}
}
/// Returns all servers saved in the app
Future<List<String>> getAvailableServers() async {
const storage = FlutterSecureStorage();
String? serverMapSerialized = await storage.read(key: "server_map");
if (serverMapSerialized != null) {
Map<String, String> serverMap = Map.castFrom(jsonDecode(serverMapSerialized));
return serverMap.keys.toList();
} else {
return [];
}
}
/// Loads the server credentials from [FlutterSecureStorage]
@ -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<String, String> serverMap = Map.castFrom(jsonDecode(serverMapSerialized));
if (lastUsedServer != null) {
serverUrl = lastUsedServer;
apiKey = serverMap[lastUsedServer]!;
} else {
List<String> availableServers = serverMap.keys.toList();
if (!availableServers.isEmpty) {
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 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<String, String> 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<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
@ -81,7 +164,7 @@ class ServerManager {
_saveCredentials(url, apiKey);
final result = await connect();
result.fold((l) => null, (r) {
logOut();
logOut(url);
});
return result;
}

View File

@ -64,11 +64,13 @@ class _InitialPageState extends State<InitialPage> {
void checkLogin() async {
bool result = await globals.serverManager.checkLogin();
if (result) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const NavigationBarView()));
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (context) => const NavigationBarView()),
(Route<dynamic> route) => false);
} else {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const LoginView()));
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (context) => const LoginView()),
(Route<dynamic> route) => false);
}
}

View File

@ -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<HomeView> {
child: CustomScrollView(
slivers: [
SliverAppBar.medium(
automaticallyImplyLeading: false,
expandedHeight: 160,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Shlink",
style: TextStyle(fontWeight: FontWeight.bold)),
Text(globals.serverManager.getServerUrl(),
GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
builder: (BuildContext context) {
return AvailableServerBottomSheet();
});
},
child: Text(globals.serverManager.getServerUrl(),
style: TextStyle(
fontSize: 16, color: Colors.grey[600]))
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<AvailableServerBottomSheet> createState() => _AvailableServerBottomSheetState();
}
class _AvailableServerBottomSheetState extends State<AvailableServerBottomSheet> {
List<String> availableServers = [];
@override
void initState() {
super.initState();
_loadServers();
}
Future<void> _loadServers() async {
List<String> _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<dynamic> 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<dynamic> 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..."),
),
),
)
)
],
);
}
}

View File

@ -70,25 +70,6 @@ class _SettingsViewState extends State<SettingsView> {
"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(

View File

@ -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"