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 ## 🔨 To Do
- [ ] Add support for iOS (maybe in the future) - [ ] Add support for iOS (maybe in the future)
- [ ] add tags
- [ ] improve app icon - [ ] improve app icon
- [ ] Refactor code - [ ] Refactor code
- [ ] ...and more - [ ] ...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/RedirectRule/redirect_rule.dart';
import 'package:shlink_app/API/Classes/ShortURL/short_url.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/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/connect.dart';
import 'package:shlink_app/API/Methods/get_recent_short_urls.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_redirect_rules.dart';
import 'package:shlink_app/API/Methods/get_server_health.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_shlink_stats.dart';
import 'package:shlink_app/API/Methods/get_short_urls.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/set_redirect_rules.dart';
import 'package:shlink_app/API/Methods/update_short_url.dart'; import 'package:shlink_app/API/Methods/update_short_url.dart';
@ -181,6 +183,11 @@ class ServerManager {
return apiGetShortUrls(apiKey, serverUrl, apiVersion); 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 /// Gets statistics about the Shlink instance
FutureOr<Either<ShlinkStats, Failure>> getShlinkStats() async { FutureOr<Either<ShlinkStats, Failure>> getShlinkStats() async {
return apiGetShlinkStats(apiKey, serverUrl, apiVersion); return apiGetShlinkStats(apiKey, serverUrl, apiVersion);
@ -234,6 +241,14 @@ class ShortURLPageResponse {
ShortURLPageResponse(this.urls, this.totalPages); 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 /// Server response data type about the health status of the server
class ServerHealthResponse { class ServerHealthResponse {
String status; 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:flutter/material.dart';
import 'package:shlink_app/global_theme.dart';
import 'package:shlink_app/views/login_view.dart'; import 'package:shlink_app/views/login_view.dart';
import 'package:shlink_app/views/navigationbar_view.dart'; import 'package:shlink_app/views/navigationbar_view.dart';
import 'globals.dart' as globals; import 'globals.dart' as globals;
@ -26,7 +27,9 @@ class MyApp extends StatelessWidget {
return MaterialApp( return MaterialApp(
title: 'Shlink', title: 'Shlink',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData( theme: GlobalTheme.lightThemeData(lightColorScheme),
darkTheme: GlobalTheme.darkThemeData(darkColorScheme),
/*theme: ThemeData(
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(
backgroundColor: Color(0xfffafafa), backgroundColor: Color(0xfffafafa),
), ),
@ -41,7 +44,7 @@ class MyApp extends StatelessWidget {
colorScheme: darkColorScheme?.copyWith(surface: Colors.black) ?? colorScheme: darkColorScheme?.copyWith(surface: Colors.black) ??
_defaultDarkColorScheme, _defaultDarkColorScheme,
useMaterial3: true, useMaterial3: true,
), ),*/
home: const InitialPage()); 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:flutter_sharing_intent/model/sharing_file.dart';
import 'package:qr_flutter/qr_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart';
import 'package:shlink_app/API/Classes/ShlinkStats/shlink_stats.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/short_url_edit_view.dart';
import 'package:shlink_app/views/url_list_view.dart'; import 'package:shlink_app/views/url_list_view.dart';
import 'package:shlink_app/widgets/available_servers_bottom_sheet.dart'; import 'package:shlink_app/widgets/available_servers_bottom_sheet.dart';
@ -68,18 +68,9 @@ class _HomeViewState extends State<HomeView> {
shlinkStats = l; shlinkStats = l;
}); });
}, (r) { }, (r) {
var text = ""; ScaffoldMessenger.of(context).showSnackBar(
if (r is RequestFailure) { buildApiErrorSnackbar(r, context)
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);
}); });
} }
@ -91,18 +82,9 @@ class _HomeViewState extends State<HomeView> {
shortUrlsLoaded = true; shortUrlsLoaded = true;
}); });
}, (r) { }, (r) {
var text = ""; ScaffoldMessenger.of(context).showSnackBar(
if (r is RequestFailure) { buildApiErrorSnackbar(r, context)
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);
}); });
} }
@ -139,7 +121,7 @@ class _HomeViewState extends State<HomeView> {
}, },
child: Text(globals.serverManager.getServerUrl(), child: Text(globals.serverManager.getServerUrl(),
style: TextStyle( 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', 'Create one by tapping the "+" button below',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: Colors.grey[600]), color: Theme.of(context).colorScheme.onSecondary),
), ),
) )
], ],
@ -251,18 +233,12 @@ class _HomeViewState extends State<HomeView> {
eyeStyle: QrEyeStyle( eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square, eyeShape: QrEyeShape.square,
color: color:
MediaQuery.of(context).platformBrightness == Theme.of(context).colorScheme.onPrimary
Brightness.dark
? Colors.white
: Colors.black,
), ),
dataModuleStyle: QrDataModuleStyle( dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square, dataModuleShape: QrDataModuleShape.square,
color: color:
MediaQuery.of(context).platformBrightness == Theme.of(context).colorScheme.onPrimary
Brightness.dark
? Colors.white
: Colors.black,
), ),
))), ))),
), ),

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shlink_app/API/server_manager.dart'; import 'package:shlink_app/API/server_manager.dart';
import 'package:shlink_app/main.dart'; import 'package:shlink_app/main.dart';
import 'package:url_launcher/url_launcher.dart';
import '../globals.dart' as globals; import '../globals.dart' as globals;
class LoginView extends StatefulWidget { class LoginView extends StatefulWidget {
@ -58,6 +59,7 @@ class _LoginViewState extends State<LoginView> {
return Scaffold( return Scaffold(
extendBody: true, extendBody: true,
body: CustomScrollView( body: CustomScrollView(
physics: const NeverScrollableScrollPhysics(),
slivers: [ slivers: [
const SliverAppBar.medium( const SliverAppBar.medium(
title: Text("Add server", title: Text("Add server",
@ -65,79 +67,107 @@ class _LoginViewState extends State<LoginView> {
SliverFillRemaining( SliverFillRemaining(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Stack(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Padding( Align(
padding: EdgeInsets.only(bottom: 8), child: Column(
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, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
FilledButton.tonal( const Padding(
onPressed: () => {_connect()}, padding: EdgeInsets.only(bottom: 8),
child: _isLoggingIn child: Text(
? Container( "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, width: 34,
height: 34, height: 34,
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
child: const CircularProgressIndicator(), child: const CircularProgressIndicator(),
) )
: const Text("Connect", : const Text("Connect",
style: TextStyle(fontSize: 20)), 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( Align(
padding: const EdgeInsets.only(top: 16), alignment: Alignment.bottomCenter,
child: Row( child: TextButton(
mainAxisAlignment: MainAxisAlignment.center, onPressed: () async {
children: [ final Uri url = Uri.parse('https://shlink.io/documentation/api-docs/authentication/');
Flexible( try {
child: Text(_errorMessage, if (!await launchUrl(url)) {
style: const TextStyle(color: Colors.red), throw Exception();
textAlign: TextAlign.center)) }
], } 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( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
color: Theme.of(context).brightness == Brightness.light color: Theme.of(context).colorScheme.surfaceContainer,
? Colors.grey[100]
: Colors.grey[900],
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
@ -54,13 +52,13 @@ class _OpenSourceLicensesViewState extends State<OpenSourceLicensesView> {
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 18)), fontWeight: FontWeight.bold, fontSize: 18)),
Text("Version: ${currentLicense.version ?? "N/A"}", 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 SizedBox(height: 8),
const Divider(), Divider(color: Theme.of(context).dividerColor),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(currentLicense.license, Text(currentLicense.license,
textAlign: TextAlign.justify, 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), }, childCount: LicenseUtil.getLicenses().length),
), ),
const SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: EdgeInsets.only(top: 8, bottom: 20), padding: EdgeInsets.only(top: 8, bottom: 20),
child: Text( child: Text(
"Thank you to all maintainers of these repositories 💝", "Thank you to all maintainers of these repositories 💝",
style: TextStyle(color: Colors.grey), style: TextStyle(color: Theme.of(context).colorScheme.onTertiary),
textAlign: TextAlign.center, 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.dart';
import 'package:shlink_app/API/Classes/ShortURL/RedirectRule/redirect_rule_condition_type.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/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 '../globals.dart' as globals;
import '../API/Classes/ShortURL/RedirectRule/redirect_rule.dart'; import '../API/Classes/ShortURL/RedirectRule/redirect_rule.dart';
@ -41,18 +41,9 @@ class _RedirectRulesDetailViewState extends State<RedirectRulesDetailView> {
_sortListByPriority(); _sortListByPriority();
return true; return true;
}, (r) { }, (r) {
var text = ""; ScaffoldMessenger.of(context).showSnackBar(
if (r is RequestFailure) { buildApiErrorSnackbar(r, context)
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; return false;
}); });
} }
@ -63,18 +54,9 @@ class _RedirectRulesDetailViewState extends State<RedirectRulesDetailView> {
response.fold((l) { response.fold((l) {
Navigator.pop(context); Navigator.pop(context);
}, (r) { }, (r) {
var text = ""; ScaffoldMessenger.of(context).showSnackBar(
if (r is RequestFailure) { buildApiErrorSnackbar(r, context)
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; return false;
}); });
} }
@ -111,7 +93,7 @@ class _RedirectRulesDetailViewState extends State<RedirectRulesDetailView> {
child: isSaving child: isSaving
? const Padding( ? const Padding(
padding: EdgeInsets.all(16), padding: EdgeInsets.all(16),
child: CircularProgressIndicator(strokeWidth: 3)) child: CircularProgressIndicator(strokeWidth: 3, color: Colors.white))
: const Icon(Icons.save)) : const Icon(Icons.save))
], ],
), ),
@ -141,7 +123,7 @@ class _RedirectRulesDetailViewState extends State<RedirectRulesDetailView> {
child: Text( child: Text(
'Adding redirect rules will be supported soon!', 'Adding redirect rules will be supported soon!',
style: TextStyle( 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( decoration: BoxDecoration(
border: Border( border: Border(
bottom: BorderSide( bottom: BorderSide(
color: MediaQuery.of(context).platformBrightness == color: Theme.of(context).dividerColor)),
Brightness.dark
? Colors.grey[800]!
: Colors.grey[300]!)),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -252,10 +231,7 @@ class _ListCellState extends State<_ListCell> {
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
color: color:
MediaQuery.of(context).platformBrightness == Theme.of(context).colorScheme.tertiary,
Brightness.dark
? Colors.grey[900]
: Colors.grey[300],
), ),
child: Text(_conditionToTagString(condition)), child: Text(_conditionToTagString(condition)),
), ),
@ -269,19 +245,13 @@ class _ListCellState extends State<_ListCell> {
children: [ children: [
IconButton( IconButton(
disabledColor: disabledColor:
MediaQuery.of(context).platformBrightness == Theme.of(context).disabledColor,
Brightness.dark
? Colors.grey[700]
: Colors.grey[400],
onPressed: widget.moveUp, onPressed: widget.moveUp,
icon: const Icon(Icons.arrow_upward), icon: const Icon(Icons.arrow_upward),
), ),
IconButton( IconButton(
disabledColor: disabledColor:
MediaQuery.of(context).platformBrightness == Theme.of(context).disabledColor,
Brightness.dark
? Colors.grey[700]
: Colors.grey[400],
onPressed: widget.moveDown, onPressed: widget.moveDown,
icon: const Icon(Icons.arrow_downward), icon: const Icon(Icons.arrow_downward),
), ),

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.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/views/opensource_licenses_view.dart';
import 'package:shlink_app/widgets/available_servers_bottom_sheet.dart'; import 'package:shlink_app/widgets/available_servers_bottom_sheet.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@ -43,19 +43,9 @@ class _SettingsViewState extends State<SettingsView> {
setState(() { setState(() {
_serverStatus = ServerStatus.disconnected; _serverStatus = ServerStatus.disconnected;
}); });
ScaffoldMessenger.of(context).showSnackBar(
var text = ""; buildApiErrorSnackbar(r, context)
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);
}); });
} }
@ -87,9 +77,7 @@ class _SettingsViewState extends State<SettingsView> {
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
color: Theme.of(context).brightness == Brightness.light color: Theme.of(context).colorScheme.surfaceContainer
? Colors.grey[100]
: Colors.grey[900],
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.all(12.0),
@ -110,29 +98,29 @@ class _SettingsViewState extends State<SettingsView> {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text("Connected to", Text("Connected to",
style: TextStyle(color: Colors.grey)), style: TextStyle(color: Theme.of(context).colorScheme.onTertiary)),
Text(globals.serverManager.getServerUrl(), Text(globals.serverManager.getServerUrl(),
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16)), fontSize: 16)),
Row( Row(
children: [ children: [
const Text("API Version: ", Text("API Version: ",
style: TextStyle( style: TextStyle(
color: Colors.grey, color: Theme.of(context).colorScheme.onTertiary,
fontWeight: FontWeight.w600)), fontWeight: FontWeight.w600)),
Text(globals.serverManager.getApiVersion(), Text(globals.serverManager.getApiVersion(),
style: const TextStyle(
color: Colors.grey)),
const SizedBox(width: 16),
const Text("Server Version: ",
style: TextStyle( 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)), fontWeight: FontWeight.w600)),
Text(_serverVersion, Text(_serverVersion,
style: 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 SizedBox(height: 8),
const Divider(), Divider(color: Theme.of(context).dividerColor),
const SizedBox(height: 8), const SizedBox(height: 8),
GestureDetector( GestureDetector(
onTap: () { onTap: () {
@ -154,9 +142,7 @@ class _SettingsViewState extends State<SettingsView> {
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
color: Theme.of(context).brightness == Brightness.light color: Theme.of(context).colorScheme.surfaceContainer
? Colors.grey[100]
: Colors.grey[900],
), ),
child: const Padding( child: const Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
@ -190,9 +176,7 @@ class _SettingsViewState extends State<SettingsView> {
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
color: Theme.of(context).brightness == Brightness.light color: Theme.of(context).colorScheme.surfaceContainer
? Colors.grey[100]
: Colors.grey[900],
), ),
child: const Padding( child: const Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
@ -226,9 +210,7 @@ class _SettingsViewState extends State<SettingsView> {
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
color: Theme.of(context).brightness == Brightness.light color: Theme.of(context).colorScheme.surfaceContainer
? Colors.grey[100]
: Colors.grey[900],
), ),
child: const Padding( child: const Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
@ -261,13 +243,11 @@ class _SettingsViewState extends State<SettingsView> {
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
color: color:
Theme.of(context).brightness == Brightness.light Theme.of(context).colorScheme.surfaceContainer
? Colors.grey[100]
: Colors.grey[900],
), ),
child: Text( child: Text(
"${packageInfo.appName}, v${packageInfo.version} (${packageInfo.buildNumber})", "${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:dartz/dartz.dart' as dartz;
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:shlink_app/API/Classes/ShortURL/short_url.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/ShortURLSubmission/short_url_submission.dart';
import 'package:shlink_app/API/server_manager.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; import '../globals.dart' as globals;
class ShortURLEditView extends StatefulWidget { class ShortURLEditView extends StatefulWidget {
@ -22,6 +27,7 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
final customSlugController = TextEditingController(); final customSlugController = TextEditingController();
final titleController = TextEditingController(); final titleController = TextEditingController();
final randomSlugLengthController = TextEditingController(text: "5"); final randomSlugLengthController = TextEditingController(text: "5");
List<String> tags = [];
bool randomSlug = true; bool randomSlug = true;
bool isCrawlable = true; bool isCrawlable = true;
@ -63,6 +69,7 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
if (widget.shortUrl != null) { if (widget.shortUrl != null) {
longUrlController.text = widget.shortUrl!.longUrl; longUrlController.text = widget.shortUrl!.longUrl;
isCrawlable = widget.shortUrl!.crawlable; isCrawlable = widget.shortUrl!.crawlable;
tags = widget.shortUrl!.tags;
// for some reason this attribute is not returned by the api // for some reason this attribute is not returned by the api
forwardQuery = true; forwardQuery = true;
titleController.text = widget.shortUrl!.title ?? ""; 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 { void _submitShortUrl() async {
var newSubmission = ShortURLSubmission( var newSubmission = ShortURLSubmission(
longUrl: longUrlController.text, longUrl: longUrlController.text,
tags: [], tags: tags,
crawlable: isCrawlable, crawlable: isCrawlable,
forwardQuery: forwardQuery, forwardQuery: forwardQuery,
findIfExists: true, findIfExists: true,
@ -119,22 +154,9 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
isSaving = false; isSaving = false;
}); });
var text = ""; ScaffoldMessenger.of(context).showSnackBar(
buildApiErrorSnackbar(r, context)
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);
return false; return false;
}); });
} }
@ -149,71 +171,71 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
style: const TextStyle(fontWeight: FontWeight.bold)), style: const TextStyle(fontWeight: FontWeight.bold)),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 16), padding: const EdgeInsets.only(top: 16, left: 8, right: 8),
child: Column( child: Wrap(
children: [ runSpacing: 16,
TextField( children: [
controller: longUrlController, TextField(
decoration: InputDecoration( controller: longUrlController,
errorText: longUrlError != "" ? longUrlError : null, decoration: InputDecoration(
border: const OutlineInputBorder(), errorText: longUrlError != "" ? longUrlError : null,
label: const Row( border: const OutlineInputBorder(),
children: [ label: const Row(
Icon(Icons.public), children: [
SizedBox(width: 8), Icon(Icons.public),
Text("Long URL") SizedBox(width: 8),
], Text("Long URL")
)), ],
), )),
const SizedBox(height: 16), ),
Row( Row(
children: [ children: [
Expanded( Expanded(
child: TextField( child: TextField(
enabled: !disableSlugEditor, enabled: !disableSlugEditor,
controller: customSlugController, controller: customSlugController,
style: TextStyle( style: TextStyle(
color: randomSlug color: randomSlug
? Colors.grey ? Theme.of(context).colorScheme.onTertiary
: Theme.of(context).brightness == : Theme.of(context).colorScheme.onPrimary),
Brightness.light onChanged: (_) {
? Colors.black if (randomSlug) {
: Colors.white), setState(() {
onChanged: (_) { randomSlug = false;
if (randomSlug) { });
setState(() { }
randomSlug = false; },
}); decoration: InputDecoration(
} border: const OutlineInputBorder(),
}, label: Row(
decoration: InputDecoration( children: [
border: const OutlineInputBorder(), const Icon(Icons.link),
label: Row( const SizedBox(width: 8),
children: [ Text(
const Icon(Icons.link), "${randomSlug ? "Random" : "Custom"} slug",
const SizedBox(width: 8), style: TextStyle(
Text( fontStyle: randomSlug
"${randomSlug ? "Random" : "Custom"} slug", ? FontStyle.italic
style: TextStyle( : FontStyle.normal),
fontStyle: randomSlug )
? FontStyle.italic ],
: FontStyle.normal), )),
) ),
],
)),
), ),
),
const SizedBox(width: 8), if (widget.shortUrl == null)
RotationTransition( Container(
turns: Tween(begin: 0.0, end: 3.0).animate( padding: const EdgeInsets.only(left: 8),
CurvedAnimation( child: RotationTransition(
parent: _customSlugDiceAnimationController, turns: Tween(begin: 0.0, end: 3.0).animate(
curve: Curves.easeInOutExpo)), CurvedAnimation(
child: IconButton( parent: _customSlugDiceAnimationController,
onPressed: disableSlugEditor curve: Curves.easeInOutExpo)),
? null child: IconButton(
: () { onPressed: disableSlugEditor
? null
: () {
if (randomSlug) { if (randomSlug) {
_customSlugDiceAnimationController.reverse( _customSlugDiceAnimationController.reverse(
from: 1); from: 1);
@ -225,132 +247,158 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
randomSlug = !randomSlug; randomSlug = !randomSlug;
}); });
}, },
icon: Icon( icon: Icon(
randomSlug ? Icons.casino : Icons.casino_outlined, randomSlug ? Icons.casino : Icons.casino_outlined,
color: randomSlug ? Colors.green : Colors.grey)), color: randomSlug ? Colors.green : Colors.grey)),
) ),
], )
), ],
if (randomSlug) const SizedBox(height: 16), ),
if (randomSlug) 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( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text("Random slug length"), const Text("Crawlable"),
SizedBox( Switch(
width: 100, value: isCrawlable,
child: TextField( onChanged: (_) {
controller: randomSlugLengthController, setState(() {
keyboardType: TextInputType.number, isCrawlable = !isCrawlable;
decoration: InputDecoration( });
errorText: },
randomSlugLengthError != "" ? "" : null, )
border: const OutlineInputBorder(),
label: const Row(
children: [
Icon(Icons.tag),
SizedBox(width: 8),
Text("Length")
],
)),
))
], ],
), ),
const SizedBox(height: 16), Row(
TextField( mainAxisAlignment: MainAxisAlignment.spaceBetween,
controller: titleController, children: [
decoration: const InputDecoration( const Text("Forward query params"),
border: OutlineInputBorder(), Switch(
label: Row( value: forwardQuery,
children: [ onChanged: (_) {
Icon(Icons.badge), setState(() {
SizedBox(width: 8), forwardQuery = !forwardQuery;
Text("Title") });
], },
)), )
), ],
const SizedBox(height: 16), ),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text("Crawlable"), const Text("Copy to clipboard"),
Switch( Switch(
value: isCrawlable, value: copyToClipboard,
onChanged: (_) { onChanged: (_) {
setState(() { setState(() {
isCrawlable = !isCrawlable; copyToClipboard = !copyToClipboard;
}); });
}, },
) )
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 150)
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;
});
},
)
],
),
],
),
))
], ],
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () { onPressed: () {
if (!isSaving) { _saveButtonPressed();
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();
}
}
}, },
child: isSaving child: isSaving
? const Padding( ? const Padding(
padding: EdgeInsets.all(16), padding: EdgeInsets.all(16),
child: CircularProgressIndicator(strokeWidth: 3)) child: CircularProgressIndicator(strokeWidth: 3,
color: Colors.white))
: const Icon(Icons.save)), : 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:flutter/material.dart';
import 'package:shlink_app/API/Classes/ShortURL/short_url.dart'; import 'package:shlink_app/API/Classes/ShortURL/short_url.dart';
import 'package:intl/intl.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/redirect_rules_detail_view.dart';
import 'package:shlink_app/views/short_url_edit_view.dart'; import 'package:shlink_app/views/short_url_edit_view.dart';
import 'package:shlink_app/widgets/url_tags_list_widget.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); ScaffoldMessenger.of(context).showSnackBar(snackBar);
return true; return true;
}, (r) { }, (r) {
var text = ""; ScaffoldMessenger.of(context).showSnackBar(
if (r is RequestFailure) { buildApiErrorSnackbar(r, context)
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; return false;
}); });
}, },
@ -207,10 +198,7 @@ class _ListCellState extends State<_ListCell> {
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(
top: BorderSide( top: BorderSide(
color: MediaQuery.of(context).platformBrightness == color: Theme.of(context).dividerColor)),
Brightness.dark
? Colors.grey[800]!
: Colors.grey[300]!)),
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
@ -226,10 +214,7 @@ class _ListCellState extends State<_ListCell> {
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
color: Theme.of(context).brightness == color: Theme.of(context).colorScheme.outline,
Brightness.dark
? Colors.grey[700]
: Colors.grey[300],
), ),
), ),
), ),

View File

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