diff --git a/lib/API/Classes/ShortURL/RedirectRule/condition_device_type.dart b/lib/API/Classes/ShortURL/RedirectRule/condition_device_type.dart new file mode 100644 index 0000000..f05ef08 --- /dev/null +++ b/lib/API/Classes/ShortURL/RedirectRule/condition_device_type.dart @@ -0,0 +1,42 @@ +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"; + } + } +} \ No newline at end of file diff --git a/lib/API/Classes/ShortURL/RedirectRule/redirect_rule.dart b/lib/API/Classes/ShortURL/RedirectRule/redirect_rule.dart new file mode 100644 index 0000000..9b15f38 --- /dev/null +++ b/lib/API/Classes/ShortURL/RedirectRule/redirect_rule.dart @@ -0,0 +1,23 @@ +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 conditions; + + RedirectRule(this.longUrl, this.priority, this.conditions); + + RedirectRule.fromJson(Map json) + : longUrl = json["longUrl"], + priority = json["priority"], + conditions = (json["conditions"] as List).map((e) + => RedirectRuleCondition.fromJson(e)).toList(); + + Map toJson() { + return { + "longUrl": longUrl, + "conditions": conditions.map((e) => e.toJson()).toList() + }; + } +} \ No newline at end of file diff --git a/lib/API/Classes/ShortURL/RedirectRule/redirect_rule_condition.dart b/lib/API/Classes/ShortURL/RedirectRule/redirect_rule_condition.dart new file mode 100644 index 0000000..bd14ad2 --- /dev/null +++ b/lib/API/Classes/ShortURL/RedirectRule/redirect_rule_condition.dart @@ -0,0 +1,23 @@ +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 json) + : type = RedirectRuleConditionType.fromApi(json["type"]), + matchValue = json["matchValue"], + matchKey = json["matchKey"]; + + Map toJson() { + return { + "type": type.api, + "matchValue": matchValue, + "matchKey": matchKey + }; + } +} \ No newline at end of file diff --git a/lib/API/Classes/ShortURL/RedirectRule/redirect_rule_condition_type.dart b/lib/API/Classes/ShortURL/RedirectRule/redirect_rule_condition_type.dart new file mode 100644 index 0000000..a5081f2 --- /dev/null +++ b/lib/API/Classes/ShortURL/RedirectRule/redirect_rule_condition_type.dart @@ -0,0 +1,42 @@ +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"; + } + } +} \ No newline at end of file diff --git a/lib/API/Classes/ShortURL/short_url.dart b/lib/API/Classes/ShortURL/short_url.dart index 01d0a5c..c6ea28b 100644 --- a/lib/API/Classes/ShortURL/short_url.dart +++ b/lib/API/Classes/ShortURL/short_url.dart @@ -1,6 +1,8 @@ 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 @@ -33,6 +35,8 @@ class ShortURL { /// Whether the short URL is crawlable by a web crawler bool crawlable; + List? redirectRules; + ShortURL( this.shortCode, this.shortUrl, diff --git a/lib/API/Methods/get_redirect_rules.dart b/lib/API/Methods/get_redirect_rules.dart new file mode 100644 index 0000000..de83516 --- /dev/null +++ b/lib/API/Methods/get_redirect_rules.dart @@ -0,0 +1,46 @@ +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, 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; + + // convert json array to object array + List redirectRules = (jsonBody["redirectRules"] + as List).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())); + } +} \ No newline at end of file diff --git a/lib/API/Methods/set_redirect_rules.dart b/lib/API/Methods/set_redirect_rules.dart new file mode 100644 index 0000000..1035d27 --- /dev/null +++ b/lib/API/Methods/set_redirect_rules.dart @@ -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/RedirectRule/redirect_rule.dart'; +import '../server_manager.dart'; + +/// Saves the redirect rules for a given short URL (code). +FutureOr> apiSetRedirectRules( + String shortCode, + List redirectRules, + String? apiKey, + String? serverUrl, + String apiVersion) async { + try { + Map body = {}; + List> 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())); + } +} \ No newline at end of file diff --git a/lib/API/server_manager.dart b/lib/API/server_manager.dart index 83bbfe6..74d516a 100644 --- a/lib/API/server_manager.dart +++ b/lib/API/server_manager.dart @@ -3,13 +3,16 @@ 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/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/set_redirect_rules.dart'; import 'package:shlink_app/API/Methods/update_short_url.dart'; import 'Methods/delete_short_url.dart'; @@ -124,6 +127,17 @@ class ServerManager { FutureOr, Failure>> getRecentShortUrls() async { return apiGetRecentShortUrls(apiKey, serverUrl, apiVersion); } + /// Gets redirect rules for a given short URL (code) + FutureOr, Failure>> getRedirectRules( + String shortCode) async { + return apiGetRedirectRules(shortCode, apiKey, serverUrl, apiVersion); + } + + /// Sets redirect rules for a given short URL (code) + FutureOr> setRedirectRules( + String shortCode, List redirectRules) async { + return apiSetRedirectRules(shortCode, redirectRules, apiKey, serverUrl, apiVersion); + } } /// Server response data type about a page of short URLs from the server diff --git a/lib/views/redirect_rules_detail_view.dart b/lib/views/redirect_rules_detail_view.dart new file mode 100644 index 0000000..a8c446f --- /dev/null +++ b/lib/views/redirect_rules_detail_view.dart @@ -0,0 +1,285 @@ +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/API/server_manager.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 createState() => _RedirectRulesDetailViewState(); +} + +class _RedirectRulesDetailViewState extends State { + List redirectRules = []; + + bool redirectRulesLoaded = false; + + bool isSaving = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => loadRedirectRules()); + } + + Future loadRedirectRules() async { + final response = await globals.serverManager.getRedirectRules(widget.shortURL.shortCode); + response.fold((l) { + setState(() { + redirectRules = l; + redirectRulesLoaded = true; + }); + _sortListByPriority(); + 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; + }); + } + + void _saveRedirectRules() async { + final response = await globals.serverManager.setRedirectRules(widget.shortURL.shortCode, redirectRules); + response.fold((l) { + Navigator.pop(context); + }, (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; + }); + } + + 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)) + : 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: Colors.grey[600]), + ), + ) + ], + )))) + 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({super.key, + 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: 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: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text("Long URL ", style: TextStyle(fontWeight: FontWeight.bold)), + Text(widget.redirectRule.longUrl) + ], + ), + 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: MediaQuery.of(context).platformBrightness == Brightness.dark ? + Colors.grey[900] : Colors.grey[300], + ), + child: Text( + _conditionToTagString(condition) + ), + ), + ); + }).toList(), + ), + ) + ], + ), + Wrap( + children: [ + IconButton( + disabledColor: MediaQuery.of(context).platformBrightness + == Brightness.dark ? Colors.grey[700] : Colors.grey[400], + onPressed: widget.moveUp, + icon: Icon(Icons.arrow_upward), + ), + IconButton( + disabledColor: MediaQuery.of(context).platformBrightness + == Brightness.dark ? Colors.grey[700] : Colors.grey[400], + onPressed: widget.moveDown, + icon: Icon(Icons.arrow_downward), + ), + IconButton( + onPressed: widget.delete, + icon: Icon(Icons.delete, color: Colors.red), + ) + + ], + ) + ], + ) + )); + } +} diff --git a/lib/views/url_detail_view.dart b/lib/views/url_detail_view.dart index 1b555a9..353f639 100644 --- a/lib/views/url_detail_view.dart +++ b/lib/views/url_detail_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:shlink_app/API/Classes/ShortURL/short_url.dart'; import 'package:intl/intl.dart'; import 'package:shlink_app/API/server_manager.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'; @@ -129,6 +130,8 @@ class _URLDetailViewState extends State { 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), @@ -163,13 +166,15 @@ class _ListCell extends StatefulWidget { required this.content, this.sub = false, this.last = false, - this.isUrl = false}); + this.isUrl = false, + this.clickableDetailView = null}); final String title; final dynamic content; final bool sub; final bool last; final bool isUrl; + final Widget? clickableDetailView; @override State<_ListCell> createState() => _ListCellState(); @@ -183,11 +188,18 @@ class _ListCellState extends State<_ListCell> { padding: EdgeInsets.only(top: 16, bottom: widget.last ? 30 : 0), child: GestureDetector( onTap: () async { - Uri? parsedUrl = Uri.tryParse(widget.content); - if (widget.isUrl && - parsedUrl != null && - await canLaunchUrl(parsedUrl)) { - launchUrl(parsedUrl); + 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( @@ -245,6 +257,8 @@ class _ListCellState extends State<_ListCell> { 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") ],