mirror of
https://github.com/rainloreley/shlink-manager.git
synced 2024-11-23 02:13:01 +01:00
support multiple shlink instances
This commit is contained in:
parent
ce28066d29
commit
69c8870997
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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(),
|
||||
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<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..."),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
68
pubspec.lock
68
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"
|
||||
|
|
Loading…
Reference in New Issue
Block a user