Compare commits

..

10 Commits
v1.3.1 ... main

26 changed files with 848 additions and 856 deletions

View File

@ -28,7 +28,6 @@ Shlink Manager is an app for Android to see and manage all shortened URLs create
## 🔨 To Do
- [ ] Add support for iOS (maybe in the future)
- [ ] add tags
- [ ] improve app icon
- [ ] Refactor code
- [ ] ...and more

View File

@ -0,0 +1,20 @@
import 'package:shlink_app/API/Classes/ShortURL/visits_summary.dart';
/// Tag with stats data
class TagWithStats {
/// Tag name
String tag;
/// Amount of short URLs using this tag
int shortUrlsCount;
/// visits summary for tag
VisitsSummary visitsSummary;
TagWithStats(this.tag, this.shortUrlsCount, this.visitsSummary);
TagWithStats.fromJson(Map<String, dynamic> json)
: tag = json["tag"] as String,
shortUrlsCount = json["shortUrlsCount"] as int,
visitsSummary = VisitsSummary.fromJson(json["visitsSummary"]);
}

View File

@ -0,0 +1,68 @@
import 'dart:async';
import 'dart:convert';
import 'package:dartz/dartz.dart';
import 'package:http/http.dart' as http;
import 'package:shlink_app/API/Classes/Tag/tag_with_stats.dart';
import '../server_manager.dart';
/// Gets all tags
FutureOr<Either<List<TagWithStats>, Failure>> apiGetTagsWithStats(
String? apiKey, String? serverUrl, String apiVersion) async {
var currentPage = 1;
var maxPages = 2;
List<TagWithStats> allTags = [];
Failure? error;
while (currentPage <= maxPages) {
final response =
await _getTagsWithStatsPage(currentPage, apiKey, serverUrl, apiVersion);
response.fold((l) {
allTags.addAll(l.tags);
maxPages = l.totalPages;
currentPage++;
}, (r) {
maxPages = 0;
error = r;
});
}
if (error == null) {
return left(allTags);
} else {
return right(error!);
}
}
/// Gets all tags from a specific page
FutureOr<Either<TagsWithStatsPageResponse, Failure>> _getTagsWithStatsPage(
int page, String? apiKey, String? serverUrl, String apiVersion) async {
try {
final response = await http.get(
Uri.parse("$serverUrl/rest/v$apiVersion/tags/stats?page=$page"),
headers: {
"X-Api-Key": apiKey ?? "",
});
if (response.statusCode == 200) {
var jsonResponse = jsonDecode(response.body);
var pagesCount = jsonResponse["tags"]["pagination"]["pagesCount"] as int;
List<TagWithStats> tags =
(jsonResponse["tags"]["data"] as List<dynamic>).map((e) {
return TagWithStats.fromJson(e);
}).toList();
return left(TagsWithStatsPageResponse(tags, pagesCount));
} else {
try {
var jsonBody = jsonDecode(response.body);
return right(ApiFailure(
type: jsonBody["type"],
detail: jsonBody["detail"],
title: jsonBody["title"],
status: jsonBody["status"]));
} catch (resErr) {
return right(RequestFailure(response.statusCode, resErr.toString()));
}
}
} catch (reqErr) {
return right(RequestFailure(0, reqErr.toString()));
}
}

View File

@ -7,12 +7,14 @@ 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/Classes/Tag/tag_with_stats.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/get_tags_with_stats.dart';
import 'package:shlink_app/API/Methods/set_redirect_rules.dart';
import 'package:shlink_app/API/Methods/update_short_url.dart';
@ -181,6 +183,11 @@ class ServerManager {
return apiGetShortUrls(apiKey, serverUrl, apiVersion);
}
/// Gets all tags from the server
FutureOr<Either<List<TagWithStats>, Failure>> getTags() async {
return apiGetTagsWithStats(apiKey, serverUrl, apiVersion);
}
/// Gets statistics about the Shlink instance
FutureOr<Either<ShlinkStats, Failure>> getShlinkStats() async {
return apiGetShlinkStats(apiKey, serverUrl, apiVersion);
@ -234,6 +241,14 @@ class ShortURLPageResponse {
ShortURLPageResponse(this.urls, this.totalPages);
}
/// Server response data type about a page of tags from the server
class TagsWithStatsPageResponse {
List<TagWithStats> tags;
int totalPages;
TagsWithStatsPageResponse(this.tags, this.totalPages);
}
/// Server response data type about the health status of the server
class ServerHealthResponse {
String status;

71
lib/global_theme.dart Normal file
View File

@ -0,0 +1,71 @@
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
class GlobalTheme {
static final Color _lightFocusColor = Colors.black.withOpacity(0.12);
static final Color _darkFocusColor = Colors.white.withOpacity(0.12);
static ThemeData lightThemeData(ColorScheme? dynamicColorScheme) {
return themeData(lightColorScheme, dynamicColorScheme, _lightFocusColor);
}
static ThemeData darkThemeData(ColorScheme? dynamicColorScheme) {
return themeData(darkColorScheme, dynamicColorScheme, _darkFocusColor);
}
static ThemeData themeData(ColorScheme colorScheme, ColorScheme? dynamic,
Color focusColor) {
return ThemeData(
colorScheme: colorScheme,
canvasColor: colorScheme.surface,
scaffoldBackgroundColor: colorScheme.surface,
highlightColor: Colors.transparent,
dividerColor: colorScheme.shadow,
focusColor: focusColor,
useMaterial3: true,
appBarTheme: AppBarTheme(
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
elevation: 0
)
);
}
static ColorScheme get lightColorScheme {
return ColorScheme(
primary: Color(0xff747ab5),
onPrimary: Colors.white,
secondary: Color(0x335d63a6),// Color(0xFFDDE0E0),
onSecondary: Color(0xFF322942),
tertiary: Colors.grey[300],
onTertiary: Colors.grey[700],
surfaceContainer: (Colors.grey[100])!,
outline: (Colors.grey[500])!,
shadow: (Colors.grey[300])!,
error: (Colors.red[400])!,
onError: Colors.white,
surface: Color(0xFFFAFBFB),
onSurface: Color(0xFF241E30),
brightness: Brightness.light,
);
}
static ColorScheme get darkColorScheme {
return ColorScheme(
primary: Color(0xff5d63a6),
secondary: Colors.blue.shade500,
secondaryContainer: Color(0xff1c1c1c),
surface: Colors.black,
surfaceContainer: Color(0xff0f0f0f),
onSurfaceVariant: Colors.grey[400],
tertiary: Colors.grey[900],
onTertiary: Colors.grey,
outline: (Colors.grey[700])!,
shadow: (Colors.grey[800])!,
error: (Colors.red[400])!,
onError: Colors.white,
onPrimary: Colors.white,
onSecondary: (Colors.grey[400])!,
onSurface: Colors.white,
brightness: Brightness.dark,
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:shlink_app/global_theme.dart';
import 'package:shlink_app/views/login_view.dart';
import 'package:shlink_app/views/navigationbar_view.dart';
import 'globals.dart' as globals;
@ -26,7 +27,9 @@ class MyApp extends StatelessWidget {
return MaterialApp(
title: 'Shlink',
debugShowCheckedModeBanner: false,
theme: ThemeData(
theme: GlobalTheme.lightThemeData(lightColorScheme),
darkTheme: GlobalTheme.darkThemeData(darkColorScheme),
/*theme: ThemeData(
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xfffafafa),
),
@ -41,7 +44,7 @@ class MyApp extends StatelessWidget {
colorScheme: darkColorScheme?.copyWith(surface: Colors.black) ??
_defaultDarkColorScheme,
useMaterial3: true,
),
),*/
home: const InitialPage());
});
}

View File

@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import 'package:shlink_app/API/server_manager.dart';
SnackBar buildApiErrorSnackbar(Failure r, BuildContext context) {
var text = "";
if (r is RequestFailure) {
text = r.description;
} else {
text = (r as ApiFailure).detail;
if ((r).invalidElements != null) {
text = "$text: ${(r).invalidElements}";
}
}
final snackBar = SnackBar(
content: Text(text, style: TextStyle(color: Theme.of(context).colorScheme.onError)),
backgroundColor: Theme.of(context).colorScheme.error,
behavior: SnackBarBehavior.floating);
return snackBar;
}

View File

@ -5,7 +5,7 @@ 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:shlink_app/API/Classes/ShlinkStats/shlink_stats.dart';
import 'package:shlink_app/API/server_manager.dart';
import 'package:shlink_app/util/build_api_error_snackbar.dart';
import 'package:shlink_app/views/short_url_edit_view.dart';
import 'package:shlink_app/views/url_list_view.dart';
import 'package:shlink_app/widgets/available_servers_bottom_sheet.dart';
@ -68,18 +68,9 @@ class _HomeViewState extends State<HomeView> {
shlinkStats = l;
});
}, (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);
ScaffoldMessenger.of(context).showSnackBar(
buildApiErrorSnackbar(r, context)
);
});
}
@ -91,18 +82,9 @@ class _HomeViewState extends State<HomeView> {
shortUrlsLoaded = 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);
ScaffoldMessenger.of(context).showSnackBar(
buildApiErrorSnackbar(r, context)
);
});
}
@ -139,7 +121,7 @@ class _HomeViewState extends State<HomeView> {
},
child: Text(globals.serverManager.getServerUrl(),
style: TextStyle(
fontSize: 16, color: Colors.grey[600])),
fontSize: 16, color: Theme.of(context).colorScheme.onTertiary)),
)
],
)),
@ -189,7 +171,7 @@ class _HomeViewState extends State<HomeView> {
'Create one by tapping the "+" button below',
style: TextStyle(
fontSize: 16,
color: Colors.grey[600]),
color: Theme.of(context).colorScheme.onSecondary),
),
)
],
@ -251,18 +233,12 @@ class _HomeViewState extends State<HomeView> {
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color:
MediaQuery.of(context).platformBrightness ==
Brightness.dark
? Colors.white
: Colors.black,
Theme.of(context).colorScheme.onPrimary
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color:
MediaQuery.of(context).platformBrightness ==
Brightness.dark
? Colors.white
: Colors.black,
Theme.of(context).colorScheme.onPrimary
),
))),
),

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:shlink_app/API/server_manager.dart';
import 'package:shlink_app/main.dart';
import 'package:url_launcher/url_launcher.dart';
import '../globals.dart' as globals;
class LoginView extends StatefulWidget {
@ -58,6 +59,7 @@ class _LoginViewState extends State<LoginView> {
return Scaffold(
extendBody: true,
body: CustomScrollView(
physics: const NeverScrollableScrollPhysics(),
slivers: [
const SliverAppBar.medium(
title: Text("Add server",
@ -65,79 +67,107 @@ class _LoginViewState extends State<LoginView> {
SliverFillRemaining(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
child: Stack(
children: [
const Padding(
padding: EdgeInsets.only(bottom: 8),
child: Text(
"Server URL",
style: TextStyle(fontWeight: FontWeight.bold),
)),
Row(
children: [
const Icon(Icons.dns_outlined),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _serverUrlController,
keyboardType: TextInputType.url,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: "https://shlink.example.com"),
))
],
),
const Padding(
padding: EdgeInsets.only(top: 8, bottom: 8),
child: Text("API Key",
style: TextStyle(fontWeight: FontWeight.bold)),
),
Row(
children: [
const Icon(Icons.key),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _apiKeyController,
keyboardType: TextInputType.text,
obscureText: true,
decoration: const InputDecoration(
border: OutlineInputBorder(), labelText: "..."),
))
],
),
Padding(
padding: const EdgeInsets.only(top: 16),
child: Row(
Align(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FilledButton.tonal(
onPressed: () => {_connect()},
child: _isLoggingIn
? Container(
const Padding(
padding: EdgeInsets.only(bottom: 8),
child: Text(
"Server URL",
style: TextStyle(fontWeight: FontWeight.bold),
)),
Row(
children: [
const Icon(Icons.dns_outlined),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _serverUrlController,
keyboardType: TextInputType.url,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: "https://shlink.example.com"),
))
],
),
const Padding(
padding: EdgeInsets.only(top: 8, bottom: 8),
child: Text("API Key",
style: TextStyle(fontWeight: FontWeight.bold)),
),
Row(
children: [
const Icon(Icons.key),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _apiKeyController,
keyboardType: TextInputType.text,
obscureText: true,
decoration: const InputDecoration(
border: OutlineInputBorder(), labelText: "..."),
))
],
),
Padding(
padding: const EdgeInsets.only(top: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FilledButton.tonal(
onPressed: () => {_connect()},
child: _isLoggingIn
? Container(
width: 34,
height: 34,
padding: const EdgeInsets.all(4),
child: const CircularProgressIndicator(),
)
: const Text("Connect",
style: TextStyle(fontSize: 20)),
)
: const Text("Connect",
style: TextStyle(fontSize: 20)),
)
],
),
),
Padding(
padding: const EdgeInsets.only(top: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(_errorMessage,
style: TextStyle(color: Theme.of(context).colorScheme.onError),
textAlign: TextAlign.center))
],
),
),
],
),
),
Padding(
padding: const EdgeInsets.only(top: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(_errorMessage,
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center))
],
Align(
alignment: Alignment.bottomCenter,
child: TextButton(
onPressed: () async {
final Uri url = Uri.parse('https://shlink.io/documentation/api-docs/authentication/');
try {
if (!await launchUrl(url)) {
throw Exception();
}
} catch (e) {
final snackBar = SnackBar(
content: Text("Unable to launch url. See Shlink docs for more information.",
style: TextStyle(color: Theme.of(context).colorScheme.onError)),
backgroundColor: Theme.of(context).colorScheme.error,
behavior: SnackBarBehavior.floating);
ScaffoldMessenger.of(context).showSnackBar(
snackBar);
}
},
child: Text("How to create an API Key"),
),
)
],

View File

@ -40,9 +40,7 @@ class _OpenSourceLicensesViewState extends State<OpenSourceLicensesView> {
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).brightness == Brightness.light
? Colors.grey[100]
: Colors.grey[900],
color: Theme.of(context).colorScheme.surfaceContainer,
),
child: Padding(
padding: const EdgeInsets.only(
@ -54,13 +52,13 @@ class _OpenSourceLicensesViewState extends State<OpenSourceLicensesView> {
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 18)),
Text("Version: ${currentLicense.version ?? "N/A"}",
style: const TextStyle(color: Colors.grey)),
style: TextStyle(color: Theme.of(context).colorScheme.onTertiary)),
const SizedBox(height: 8),
const Divider(),
Divider(color: Theme.of(context).dividerColor),
const SizedBox(height: 8),
Text(currentLicense.license,
textAlign: TextAlign.justify,
style: const TextStyle(color: Colors.grey)),
style: TextStyle(color: Theme.of(context).colorScheme.onTertiary)),
],
),
),
@ -69,12 +67,12 @@ class _OpenSourceLicensesViewState extends State<OpenSourceLicensesView> {
);
}, childCount: LicenseUtil.getLicenses().length),
),
const SliverToBoxAdapter(
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.only(top: 8, bottom: 20),
child: Text(
"Thank you to all maintainers of these repositories 💝",
style: TextStyle(color: Colors.grey),
style: TextStyle(color: Theme.of(context).colorScheme.onTertiary),
textAlign: TextAlign.center,
),
))

View File

@ -3,7 +3,7 @@ import 'package:shlink_app/API/Classes/ShortURL/RedirectRule/condition_device_ty
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 'package:shlink_app/util/build_api_error_snackbar.dart';
import '../globals.dart' as globals;
import '../API/Classes/ShortURL/RedirectRule/redirect_rule.dart';
@ -41,18 +41,9 @@ class _RedirectRulesDetailViewState extends State<RedirectRulesDetailView> {
_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);
ScaffoldMessenger.of(context).showSnackBar(
buildApiErrorSnackbar(r, context)
);
return false;
});
}
@ -63,18 +54,9 @@ class _RedirectRulesDetailViewState extends State<RedirectRulesDetailView> {
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);
ScaffoldMessenger.of(context).showSnackBar(
buildApiErrorSnackbar(r, context)
);
return false;
});
}
@ -111,7 +93,7 @@ class _RedirectRulesDetailViewState extends State<RedirectRulesDetailView> {
child: isSaving
? const Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(strokeWidth: 3))
child: CircularProgressIndicator(strokeWidth: 3, color: Colors.white))
: const Icon(Icons.save))
],
),
@ -141,7 +123,7 @@ class _RedirectRulesDetailViewState extends State<RedirectRulesDetailView> {
child: Text(
'Adding redirect rules will be supported soon!',
style: TextStyle(
fontSize: 16, color: Colors.grey[600]),
fontSize: 16, color: Theme.of(context).colorScheme.onSecondary),
),
)
],
@ -221,10 +203,7 @@ class _ListCellState extends State<_ListCell> {
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: MediaQuery.of(context).platformBrightness ==
Brightness.dark
? Colors.grey[800]!
: Colors.grey[300]!)),
color: Theme.of(context).dividerColor)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -252,10 +231,7 @@ class _ListCellState extends State<_ListCell> {
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color:
MediaQuery.of(context).platformBrightness ==
Brightness.dark
? Colors.grey[900]
: Colors.grey[300],
Theme.of(context).colorScheme.tertiary,
),
child: Text(_conditionToTagString(condition)),
),
@ -269,19 +245,13 @@ class _ListCellState extends State<_ListCell> {
children: [
IconButton(
disabledColor:
MediaQuery.of(context).platformBrightness ==
Brightness.dark
? Colors.grey[700]
: Colors.grey[400],
Theme.of(context).disabledColor,
onPressed: widget.moveUp,
icon: const Icon(Icons.arrow_upward),
),
IconButton(
disabledColor:
MediaQuery.of(context).platformBrightness ==
Brightness.dark
? Colors.grey[700]
: Colors.grey[400],
Theme.of(context).disabledColor,
onPressed: widget.moveDown,
icon: const Icon(Icons.arrow_downward),
),

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shlink_app/API/server_manager.dart';
import 'package:shlink_app/util/build_api_error_snackbar.dart';
import 'package:shlink_app/views/opensource_licenses_view.dart';
import 'package:shlink_app/widgets/available_servers_bottom_sheet.dart';
import 'package:url_launcher/url_launcher.dart';
@ -43,19 +43,9 @@ class _SettingsViewState extends State<SettingsView> {
setState(() {
_serverStatus = ServerStatus.disconnected;
});
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);
ScaffoldMessenger.of(context).showSnackBar(
buildApiErrorSnackbar(r, context)
);
});
}
@ -87,9 +77,7 @@ class _SettingsViewState extends State<SettingsView> {
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).brightness == Brightness.light
? Colors.grey[100]
: Colors.grey[900],
color: Theme.of(context).colorScheme.surfaceContainer
),
child: Padding(
padding: const EdgeInsets.all(12.0),
@ -110,29 +98,29 @@ class _SettingsViewState extends State<SettingsView> {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Connected to",
style: TextStyle(color: Colors.grey)),
Text("Connected to",
style: TextStyle(color: Theme.of(context).colorScheme.onTertiary)),
Text(globals.serverManager.getServerUrl(),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16)),
Row(
children: [
const Text("API Version: ",
Text("API Version: ",
style: TextStyle(
color: Colors.grey,
color: Theme.of(context).colorScheme.onTertiary,
fontWeight: FontWeight.w600)),
Text(globals.serverManager.getApiVersion(),
style: const TextStyle(
color: Colors.grey)),
const SizedBox(width: 16),
const Text("Server Version: ",
style: TextStyle(
color: Colors.grey,
color: Theme.of(context).colorScheme.onTertiary)),
const SizedBox(width: 16),
Text("Server Version: ",
style: TextStyle(
color: Theme.of(context).colorScheme.onTertiary,
fontWeight: FontWeight.w600)),
Text(_serverVersion,
style:
const TextStyle(color: Colors.grey))
TextStyle(color: Theme.of(context).colorScheme.onTertiary))
],
),
],
@ -143,7 +131,7 @@ class _SettingsViewState extends State<SettingsView> {
),
),
const SizedBox(height: 8),
const Divider(),
Divider(color: Theme.of(context).dividerColor),
const SizedBox(height: 8),
GestureDetector(
onTap: () {
@ -154,9 +142,7 @@ class _SettingsViewState extends State<SettingsView> {
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).brightness == Brightness.light
? Colors.grey[100]
: Colors.grey[900],
color: Theme.of(context).colorScheme.surfaceContainer
),
child: const Padding(
padding: EdgeInsets.only(
@ -190,9 +176,7 @@ class _SettingsViewState extends State<SettingsView> {
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).brightness == Brightness.light
? Colors.grey[100]
: Colors.grey[900],
color: Theme.of(context).colorScheme.surfaceContainer
),
child: const Padding(
padding: EdgeInsets.only(
@ -226,9 +210,7 @@ class _SettingsViewState extends State<SettingsView> {
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).brightness == Brightness.light
? Colors.grey[100]
: Colors.grey[900],
color: Theme.of(context).colorScheme.surfaceContainer
),
child: const Padding(
padding: EdgeInsets.only(
@ -261,13 +243,11 @@ class _SettingsViewState extends State<SettingsView> {
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color:
Theme.of(context).brightness == Brightness.light
? Colors.grey[100]
: Colors.grey[900],
Theme.of(context).colorScheme.surfaceContainer
),
child: Text(
"${packageInfo.appName}, v${packageInfo.version} (${packageInfo.buildNumber})",
style: const TextStyle(color: Colors.grey),
style: TextStyle(color: Theme.of(context).colorScheme.onSecondary),
),
)
],

View File

@ -1,9 +1,14 @@
import 'package:dartz/dartz.dart' as dartz;
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.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/server_manager.dart';
import 'package:shlink_app/util/build_api_error_snackbar.dart';
import 'package:shlink_app/util/string_to_color.dart';
import 'package:shlink_app/views/tag_selector_view.dart';
import 'package:shlink_app/widgets/url_tags_list_widget.dart';
import '../globals.dart' as globals;
class ShortURLEditView extends StatefulWidget {
@ -22,6 +27,7 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
final customSlugController = TextEditingController();
final titleController = TextEditingController();
final randomSlugLengthController = TextEditingController(text: "5");
List<String> tags = [];
bool randomSlug = true;
bool isCrawlable = true;
@ -63,6 +69,7 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
if (widget.shortUrl != null) {
longUrlController.text = widget.shortUrl!.longUrl;
isCrawlable = widget.shortUrl!.crawlable;
tags = widget.shortUrl!.tags;
// for some reason this attribute is not returned by the api
forwardQuery = true;
titleController.text = widget.shortUrl!.title ?? "";
@ -72,10 +79,38 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
}
}
void _saveButtonPressed() {
if (!isSaving) {
setState(() {
isSaving = true;
longUrlError = "";
randomSlugLengthError = "";
});
if (longUrlController.text == "") {
setState(() {
longUrlError = "URL cannot be empty";
isSaving = false;
});
return;
} else if (int.tryParse(randomSlugLengthController.text) ==
null ||
int.tryParse(randomSlugLengthController.text)! < 1 ||
int.tryParse(randomSlugLengthController.text)! > 50) {
setState(() {
randomSlugLengthError = "invalid number";
isSaving = false;
});
return;
} else {
_submitShortUrl();
}
}
}
void _submitShortUrl() async {
var newSubmission = ShortURLSubmission(
longUrl: longUrlController.text,
tags: [],
tags: tags,
crawlable: isCrawlable,
forwardQuery: forwardQuery,
findIfExists: true,
@ -119,22 +154,9 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
isSaving = false;
});
var text = "";
if (r is RequestFailure) {
text = r.description;
} else {
text = (r as ApiFailure).detail;
if ((r).invalidElements != null) {
text = "$text: ${(r).invalidElements}";
}
}
final snackBar = SnackBar(
content: Text(text),
backgroundColor: Colors.red[400],
behavior: SnackBarBehavior.floating);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
ScaffoldMessenger.of(context).showSnackBar(
buildApiErrorSnackbar(r, context)
);
return false;
});
}
@ -149,71 +171,71 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
style: const TextStyle(fontWeight: FontWeight.bold)),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 16),
child: Column(
children: [
TextField(
controller: longUrlController,
decoration: InputDecoration(
errorText: longUrlError != "" ? longUrlError : null,
border: const OutlineInputBorder(),
label: const Row(
children: [
Icon(Icons.public),
SizedBox(width: 8),
Text("Long URL")
],
)),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextField(
enabled: !disableSlugEditor,
controller: customSlugController,
style: TextStyle(
color: randomSlug
? Colors.grey
: Theme.of(context).brightness ==
Brightness.light
? Colors.black
: Colors.white),
onChanged: (_) {
if (randomSlug) {
setState(() {
randomSlug = false;
});
}
},
decoration: InputDecoration(
border: const OutlineInputBorder(),
label: Row(
children: [
const Icon(Icons.link),
const SizedBox(width: 8),
Text(
"${randomSlug ? "Random" : "Custom"} slug",
style: TextStyle(
fontStyle: randomSlug
? FontStyle.italic
: FontStyle.normal),
)
],
)),
child: Padding(
padding: const EdgeInsets.only(top: 16, left: 8, right: 8),
child: Wrap(
runSpacing: 16,
children: [
TextField(
controller: longUrlController,
decoration: InputDecoration(
errorText: longUrlError != "" ? longUrlError : null,
border: const OutlineInputBorder(),
label: const Row(
children: [
Icon(Icons.public),
SizedBox(width: 8),
Text("Long URL")
],
)),
),
Row(
children: [
Expanded(
child: TextField(
enabled: !disableSlugEditor,
controller: customSlugController,
style: TextStyle(
color: randomSlug
? Theme.of(context).colorScheme.onTertiary
: Theme.of(context).colorScheme.onPrimary),
onChanged: (_) {
if (randomSlug) {
setState(() {
randomSlug = false;
});
}
},
decoration: InputDecoration(
border: const OutlineInputBorder(),
label: Row(
children: [
const Icon(Icons.link),
const SizedBox(width: 8),
Text(
"${randomSlug ? "Random" : "Custom"} slug",
style: TextStyle(
fontStyle: randomSlug
? FontStyle.italic
: FontStyle.normal),
)
],
)),
),
),
),
const SizedBox(width: 8),
RotationTransition(
turns: Tween(begin: 0.0, end: 3.0).animate(
CurvedAnimation(
parent: _customSlugDiceAnimationController,
curve: Curves.easeInOutExpo)),
child: IconButton(
onPressed: disableSlugEditor
? null
: () {
if (widget.shortUrl == null)
Container(
padding: const EdgeInsets.only(left: 8),
child: RotationTransition(
turns: Tween(begin: 0.0, end: 3.0).animate(
CurvedAnimation(
parent: _customSlugDiceAnimationController,
curve: Curves.easeInOutExpo)),
child: IconButton(
onPressed: disableSlugEditor
? null
: () {
if (randomSlug) {
_customSlugDiceAnimationController.reverse(
from: 1);
@ -225,132 +247,158 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
randomSlug = !randomSlug;
});
},
icon: Icon(
randomSlug ? Icons.casino : Icons.casino_outlined,
color: randomSlug ? Colors.green : Colors.grey)),
)
],
),
if (randomSlug) const SizedBox(height: 16),
if (randomSlug)
icon: Icon(
randomSlug ? Icons.casino : Icons.casino_outlined,
color: randomSlug ? Colors.green : Colors.grey)),
),
)
],
),
if (randomSlug && widget.shortUrl == null)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("Random slug length"),
SizedBox(
width: 100,
child: TextField(
controller: randomSlugLengthController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
errorText:
randomSlugLengthError != "" ? "" : null,
border: const OutlineInputBorder(),
label: const Row(
children: [
Icon(Icons.tag),
SizedBox(width: 8),
Text("Length")
],
)),
))
],
),
TextField(
controller: titleController,
decoration: const InputDecoration(
border: OutlineInputBorder(),
label: Row(
children: [
Icon(Icons.badge),
SizedBox(width: 8),
Text("Title")
],
)),
),
GestureDetector(
onTap: () async {
List<String>? selectedTags = await Navigator.of(context).
push(MaterialPageRoute(
builder: (context) =>
TagSelectorView(alreadySelectedTags: tags)));
if (selectedTags != null) {
setState(() {
tags = selectedTags;
});
}
},
child: InputDecorator(
isEmpty: tags.isEmpty,
decoration: const InputDecoration(
border: OutlineInputBorder(),
label: Row(
children: [
Icon(Icons.label_outline),
SizedBox(width: 8),
Text("Tags")
],
)),
child: Wrap(
runSpacing: 8,
spacing: 8,
children: tags.map((tag) {
var boxColor = stringToColor(tag)
.harmonizeWith(Theme.of(context).colorScheme.
primary);
var textColor = boxColor.computeLuminance() < 0.5
? Colors.white
: Colors.black;
return InputChip(
label: Text(tag, style: TextStyle(
color: textColor
)),
backgroundColor: boxColor,
deleteIcon: Icon(Icons.close,
size: 18,
color: textColor),
onDeleted: () {
setState(() {
tags.remove(tag);
});
},
);
}).toList(),
)
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("Random slug length"),
SizedBox(
width: 100,
child: TextField(
controller: randomSlugLengthController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
errorText:
randomSlugLengthError != "" ? "" : null,
border: const OutlineInputBorder(),
label: const Row(
children: [
Icon(Icons.tag),
SizedBox(width: 8),
Text("Length")
],
)),
))
const Text("Crawlable"),
Switch(
value: isCrawlable,
onChanged: (_) {
setState(() {
isCrawlable = !isCrawlable;
});
},
)
],
),
const SizedBox(height: 16),
TextField(
controller: titleController,
decoration: const InputDecoration(
border: OutlineInputBorder(),
label: Row(
children: [
Icon(Icons.badge),
SizedBox(width: 8),
Text("Title")
],
)),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("Crawlable"),
Switch(
value: isCrawlable,
onChanged: (_) {
setState(() {
isCrawlable = !isCrawlable;
});
},
)
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("Forward query params"),
Switch(
value: forwardQuery,
onChanged: (_) {
setState(() {
forwardQuery = !forwardQuery;
});
},
)
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("Copy to clipboard"),
Switch(
value: copyToClipboard,
onChanged: (_) {
setState(() {
copyToClipboard = !copyToClipboard;
});
},
)
],
),
],
),
))
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("Forward query params"),
Switch(
value: forwardQuery,
onChanged: (_) {
setState(() {
forwardQuery = !forwardQuery;
});
},
)
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("Copy to clipboard"),
Switch(
value: copyToClipboard,
onChanged: (_) {
setState(() {
copyToClipboard = !copyToClipboard;
});
},
)
],
),
const SizedBox(height: 150)
],
),
)
)
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
if (!isSaving) {
setState(() {
isSaving = true;
longUrlError = "";
randomSlugLengthError = "";
});
if (longUrlController.text == "") {
setState(() {
longUrlError = "URL cannot be empty";
isSaving = false;
});
return;
} else if (int.tryParse(randomSlugLengthController.text) ==
null ||
int.tryParse(randomSlugLengthController.text)! < 1 ||
int.tryParse(randomSlugLengthController.text)! > 50) {
setState(() {
randomSlugLengthError = "invalid number";
isSaving = false;
});
return;
} else {
_submitShortUrl();
}
}
_saveButtonPressed();
},
child: isSaving
? const Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(strokeWidth: 3))
child: CircularProgressIndicator(strokeWidth: 3,
color: Colors.white))
: const Icon(Icons.save)),
);
}

View File

@ -0,0 +1,246 @@
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:shlink_app/API/Classes/ShortURL/visits_summary.dart';
import 'package:shlink_app/API/Classes/Tag/tag_with_stats.dart';
import 'package:shlink_app/util/build_api_error_snackbar.dart';
import 'package:shlink_app/util/string_to_color.dart';
import '../globals.dart' as globals;
class TagSelectorView extends StatefulWidget {
const TagSelectorView({super.key, this.alreadySelectedTags = const []});
final List<String> alreadySelectedTags;
@override
State<TagSelectorView> createState() => _TagSelectorViewState();
}
class _TagSelectorViewState extends State<TagSelectorView> {
final FocusNode searchTagFocusNode = FocusNode();
final searchTagController = TextEditingController();
List<TagWithStats> availableTags = [];
List<TagWithStats> selectedTags = [];
List<TagWithStats> filteredTags = [];
bool tagsLoaded = false;
@override
void initState() {
super.initState();
selectedTags = [];
searchTagController.text = "";
filteredTags = [];
searchTagFocusNode.requestFocus();
WidgetsBinding.instance.addPostFrameCallback((_) => loadTags());
}
@override
void dispose() {
searchTagFocusNode.dispose();
searchTagController.dispose();
super.dispose();
}
Future<void> loadTags() async {
final response =
await globals.serverManager.getTags();
response.fold((l) {
List<TagWithStats> mappedAlreadySelectedTags =
widget.alreadySelectedTags.map((e) {
return l.firstWhere((t) => t.tag == e, orElse: () {
// account for newly created tags
return TagWithStats(e, 0, VisitsSummary(0,0,0));
});
}).toList();
setState(() {
availableTags = (l + [... mappedAlreadySelectedTags]).toSet().toList();
selectedTags = [...mappedAlreadySelectedTags];
filteredTags = availableTags;
tagsLoaded = true;
});
_sortLists();
return true;
}, (r) {
ScaffoldMessenger.of(context).showSnackBar(
buildApiErrorSnackbar(r, context)
);
return false;
});
}
void _sortLists() {
setState(() {
availableTags.sort((a, b) => a.tag.compareTo(b.tag));
filteredTags.sort((a, b) => a.tag.compareTo(b.tag));
});
}
void _searchTextChanged(String text) {
if (text == "") {
setState(() {
filteredTags = availableTags;
});
} else {
setState(() {
filteredTags = availableTags.where((t) => t.tag.toLowerCase()
.contains(text.toLowerCase())).toList();
});
}
_sortLists();
}
void _addNewTag(String tag) {
bool tagExists = availableTags.where((t) => t.tag == tag).toList().isNotEmpty;
if (tag != "" && !tagExists) {
TagWithStats tagWithStats = TagWithStats(tag, 0, VisitsSummary(0, 0, 0));
setState(() {
availableTags.add(tagWithStats);
selectedTags.add(tagWithStats);
_searchTextChanged(tag);
});
_sortLists();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: TextField(
controller: searchTagController,
focusNode: searchTagFocusNode,
onChanged: _searchTextChanged,
decoration: const InputDecoration(
hintText: "Start typing...",
border: InputBorder.none,
icon: Icon(Icons.label_outline),
),
),
actions: [
IconButton(
onPressed: () {
Navigator.pop(context, selectedTags.map((t) => t.tag).toList());
},
icon: const Icon(Icons.check),
)
],
),
body: CustomScrollView(
slivers: [
if (!tagsLoaded)
const SliverToBoxAdapter(
child: Center(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(strokeWidth: 3),
),
),
)
else if (tagsLoaded && availableTags.isEmpty)
SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 50),
child: Column(
children: [
const Text(
"No Tags",
style: TextStyle(
fontSize: 24, fontWeight: FontWeight.bold),
),
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'Start typing to add new tags!',
style: TextStyle(
fontSize: 16, color: Theme.of(context).colorScheme.onSecondary),
),
)
],
))))
else
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
bool _isSelected = selectedTags.contains(filteredTags[index]);
TagWithStats _tag = filteredTags[index];
return GestureDetector(
onTap: () {
if (_isSelected) {
setState(() {
selectedTags.remove(_tag);
});
} else {
setState(() {
selectedTags.add(_tag);
});
}
},
child: Container(
padding: const EdgeInsets.only(left: 16, right: 16,
top: 16, bottom: 16),
decoration: BoxDecoration(
color: _isSelected ? Theme.of(context).colorScheme.primary : null,
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Wrap(
spacing: 10,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: stringToColor(_tag.tag)
.harmonizeWith(Theme.of(context).colorScheme.primary),
borderRadius: BorderRadius.circular(15)
),
),
Text(_tag.tag)
],
),
Text("${_tag.shortUrlsCount} short URL"
"${_tag.shortUrlsCount == 1 ? "" : "s"}",
style: TextStyle(
color: Theme.of(context).colorScheme.onTertiary,
fontSize: 12
),)
],
)
)
);
}, childCount: filteredTags.length
),
),
if (searchTagController.text != "" &&
!availableTags.contains(searchTagController.text))
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 8, bottom: 8,
left: 16, right: 16),
child: Center(
child: TextButton(
onPressed: () {
_addNewTag(searchTagController.text);
},
child: Text('Add tag "${searchTagController.text}"'),
),
),
),
)
],
)
);
}
}

View File

@ -1,7 +1,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/util/build_api_error_snackbar.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';
@ -67,18 +67,9 @@ class _URLDetailViewState extends State<URLDetailView> {
ScaffoldMessenger.of(context).showSnackBar(snackBar);
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);
ScaffoldMessenger.of(context).showSnackBar(
buildApiErrorSnackbar(r, context)
);
return false;
});
},
@ -207,10 +198,7 @@ class _ListCellState extends State<_ListCell> {
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: MediaQuery.of(context).platformBrightness ==
Brightness.dark
? Colors.grey[800]!
: Colors.grey[300]!)),
color: Theme.of(context).dividerColor)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@ -226,10 +214,7 @@ class _ListCellState extends State<_ListCell> {
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).brightness ==
Brightness.dark
? Colors.grey[700]
: Colors.grey[300],
color: Theme.of(context).colorScheme.outline,
),
),
),

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:shlink_app/API/Classes/ShortURL/short_url.dart';
import 'package:shlink_app/API/server_manager.dart';
import 'package:shlink_app/util/build_api_error_snackbar.dart';
import 'package:shlink_app/views/short_url_edit_view.dart';
import 'package:shlink_app/views/url_detail_view.dart';
import 'package:shlink_app/widgets/url_tags_list_widget.dart';
@ -38,18 +38,9 @@ class _URLListViewState extends State<URLListView> {
});
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);
ScaffoldMessenger.of(context).showSnackBar(
buildApiErrorSnackbar(r, context)
);
return false;
});
}
@ -99,7 +90,7 @@ class _URLListViewState extends State<URLListView> {
'Create one by tapping the "+" button below',
style: TextStyle(
fontSize: 16,
color: Colors.grey[600]),
color: Theme.of(context).colorScheme.onSecondary),
),
)
],
@ -151,18 +142,11 @@ class _URLListViewState extends State<URLListView> {
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color:
MediaQuery.of(context).platformBrightness ==
Brightness.dark
? Colors.white
: Colors.black,
Theme.of(context).colorScheme.onPrimary,
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color:
MediaQuery.of(context).platformBrightness ==
Brightness.dark
? Colors.white
: Colors.black,
color: Theme.of(context).colorScheme.onPrimary,
),
))),
),
@ -209,10 +193,7 @@ class _ShortURLCellState extends State<ShortURLCell> {
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: MediaQuery.of(context).platformBrightness ==
Brightness.dark
? Colors.grey[800]!
: Colors.grey[300]!)),
color: Theme.of(context).dividerColor)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@ -231,7 +212,7 @@ class _ShortURLCellState extends State<ShortURLCell> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
textScaleFactor: 0.9,
style: TextStyle(color: Colors.grey[600]),
style: TextStyle(color: Theme.of(context).colorScheme.onTertiary),
),
// List tags in a row
UrlTagsListWidget(tags: widget.shortURL.tags)

1
linux/.gitignore vendored
View File

@ -1 +0,0 @@
flutter/ephemeral

View File

@ -1,139 +0,0 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.10)
project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "shlink_manager")
# The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "dev.abmgrt.shlink_manager")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
cmake_policy(SET CMP0063 NEW)
# Load bundled libraries from the lib/ directory relative to the binary.
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
# Root filesystem for cross-building.
if(FLUTTER_TARGET_PLATFORM_SYSROOT)
set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
endif()
# Define build configuration options.
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE "Debug" CACHE
STRING "Flutter build mode" FORCE)
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
"Debug" "Profile" "Release")
endif()
# Compilation settings that should be applied to most targets.
#
# Be cautious about adding new options here, as plugins use this function by
# default. In most cases, you should add new options to specific targets instead
# of modifying this function.
function(APPLY_STANDARD_SETTINGS TARGET)
target_compile_features(${TARGET} PUBLIC cxx_std_14)
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
endfunction()
# Flutter library and tool build rules.
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
add_subdirectory(${FLUTTER_MANAGED_DIR})
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
# Define the application target. To change its name, change BINARY_NAME above,
# not the value here, or `flutter run` will no longer work.
#
# Any new source files that you add to the application should be added here.
add_executable(${BINARY_NAME}
"main.cc"
"my_application.cc"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
)
# Apply the standard set of build settings. This can be removed for applications
# that need different build settings.
apply_standard_settings(${BINARY_NAME})
# Add dependency libraries. Add any application-specific dependencies here.
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
# Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble)
# Only the install-generated bundle's copy of the executable will launch
# correctly, since the resources must in the right relative locations. To avoid
# people trying to run the unbundled copy, put it in a subdirectory instead of
# the default top-level location.
set_target_properties(${BINARY_NAME}
PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
)
# Generated plugin build rules, which manage building the plugins and adding
# them to the application.
include(flutter/generated_plugins.cmake)
# === Installation ===
# By default, "installing" just makes a relocatable bundle in the build
# directory.
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
endif()
# Start with a clean build bundle directory every time.
install(CODE "
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
" COMPONENT Runtime)
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
COMPONENT Runtime)
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
COMPONENT Runtime)
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
install(FILES "${bundled_library}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endforeach(bundled_library)
# Fully re-copy the assets directory on each build to avoid having stale files
# from a previous install.
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
install(CODE "
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
" COMPONENT Runtime)
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
# Install the AOT library on non-Debug builds only.
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()

View File

@ -1,88 +0,0 @@
# This file controls Flutter-level build steps. It should not be edited.
cmake_minimum_required(VERSION 3.10)
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
# Configuration provided via flutter tool.
include(${EPHEMERAL_DIR}/generated_config.cmake)
# TODO: Move the rest of this into files in ephemeral. See
# https://github.com/flutter/flutter/issues/57146.
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
# which isn't available in 3.10.
function(list_prepend LIST_NAME PREFIX)
set(NEW_LIST "")
foreach(element ${${LIST_NAME}})
list(APPEND NEW_LIST "${PREFIX}${element}")
endforeach(element)
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
endfunction()
# === Flutter Library ===
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
# Published to parent scope for install step.
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
list(APPEND FLUTTER_LIBRARY_HEADERS
"fl_basic_message_channel.h"
"fl_binary_codec.h"
"fl_binary_messenger.h"
"fl_dart_project.h"
"fl_engine.h"
"fl_json_message_codec.h"
"fl_json_method_codec.h"
"fl_message_codec.h"
"fl_method_call.h"
"fl_method_channel.h"
"fl_method_codec.h"
"fl_method_response.h"
"fl_plugin_registrar.h"
"fl_plugin_registry.h"
"fl_standard_message_codec.h"
"fl_standard_method_codec.h"
"fl_string_codec.h"
"fl_value.h"
"fl_view.h"
"flutter_linux.h"
)
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
add_library(flutter INTERFACE)
target_include_directories(flutter INTERFACE
"${EPHEMERAL_DIR}"
)
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
target_link_libraries(flutter INTERFACE
PkgConfig::GTK
PkgConfig::GLIB
PkgConfig::GIO
)
add_dependencies(flutter flutter_assemble)
# === Flutter tool backend ===
# _phony_ is a non-existent file to force this command to run every time,
# since currently there's no way to get a full input/output list from the
# flutter tool.
add_custom_command(
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
${CMAKE_CURRENT_BINARY_DIR}/_phony_
COMMAND ${CMAKE_COMMAND} -E env
${FLUTTER_TOOL_ENVIRONMENT}
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
VERBATIM
)
add_custom_target(flutter_assemble DEPENDS
"${FLUTTER_LIBRARY}"
${FLUTTER_LIBRARY_HEADERS}
)

View File

@ -1,23 +0,0 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
#include <dynamic_color/dynamic_color_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) dynamic_color_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin");
dynamic_color_plugin_register_with_registrar(dynamic_color_registrar);
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}

View File

@ -1,15 +0,0 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter_linux/flutter_linux.h>
// Registers Flutter plugins.
void fl_register_plugins(FlPluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_

View File

@ -1,26 +0,0 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
dynamic_color
flutter_secure_storage_linux
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)

View File

@ -1,6 +0,0 @@
#include "my_application.h"
int main(int argc, char** argv) {
g_autoptr(MyApplication) app = my_application_new();
return g_application_run(G_APPLICATION(app), argc, argv);
}

View File

@ -1,104 +0,0 @@
#include "my_application.h"
#include <flutter_linux/flutter_linux.h>
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
#endif
#include "flutter/generated_plugin_registrant.h"
struct _MyApplication {
GtkApplication parent_instance;
char** dart_entrypoint_arguments;
};
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
// Implements GApplication::activate.
static void my_application_activate(GApplication* application) {
MyApplication* self = MY_APPLICATION(application);
GtkWindow* window =
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
// Use a header bar when running in GNOME as this is the common style used
// by applications and is the setup most users will be using (e.g. Ubuntu
// desktop).
// If running on X and not using GNOME then just use a traditional title bar
// in case the window manager does more exotic layout, e.g. tiling.
// If running on Wayland assume the header bar will work (may need changing
// if future cases occur).
gboolean use_header_bar = TRUE;
#ifdef GDK_WINDOWING_X11
GdkScreen* screen = gtk_window_get_screen(window);
if (GDK_IS_X11_SCREEN(screen)) {
const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
use_header_bar = FALSE;
}
}
#endif
if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "shlink_manager");
gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else {
gtk_window_set_title(window, "shlink_manager");
}
gtk_window_set_default_size(window, 1280, 720);
gtk_widget_show(GTK_WIDGET(window));
g_autoptr(FlDartProject) project = fl_dart_project_new();
fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments);
FlView* view = fl_view_new(project);
gtk_widget_show(GTK_WIDGET(view));
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
gtk_widget_grab_focus(GTK_WIDGET(view));
}
// Implements GApplication::local_command_line.
static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) {
MyApplication* self = MY_APPLICATION(application);
// Strip out the first argument as it is the binary name.
self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
g_autoptr(GError) error = nullptr;
if (!g_application_register(application, nullptr, &error)) {
g_warning("Failed to register: %s", error->message);
*exit_status = 1;
return TRUE;
}
g_application_activate(application);
*exit_status = 0;
return TRUE;
}
// Implements GObject::dispose.
static void my_application_dispose(GObject* object) {
MyApplication* self = MY_APPLICATION(object);
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
}
static void my_application_class_init(MyApplicationClass* klass) {
G_APPLICATION_CLASS(klass)->activate = my_application_activate;
G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line;
G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
}
static void my_application_init(MyApplication* self) {}
MyApplication* my_application_new() {
return MY_APPLICATION(g_object_new(my_application_get_type(),
"application-id", APPLICATION_ID,
"flags", G_APPLICATION_NON_UNIQUE,
nullptr));
}

View File

@ -1,18 +0,0 @@
#ifndef FLUTTER_MY_APPLICATION_H_
#define FLUTTER_MY_APPLICATION_H_
#include <gtk/gtk.h>
G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION,
GtkApplication)
/**
* my_application_new:
*
* Creates a new Flutter-based application.
*
* Returns: a new #MyApplication.
*/
MyApplication* my_application_new();
#endif // FLUTTER_MY_APPLICATION_H_

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.3.1+10
version: 1.4.0+11
environment:
sdk: ^3.0.0