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"