diff --git a/lib/API/Methods/getRecentShortUrls.dart b/lib/API/Methods/getRecentShortUrls.dart new file mode 100644 index 0000000..63d8420 --- /dev/null +++ b/lib/API/Methods/getRecentShortUrls.dart @@ -0,0 +1,33 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:dartz/dartz.dart'; +import 'package:http/http.dart' as http; +import 'package:shlink_app/API/Classes/ShortURL/ShortURL.dart'; +import '../ServerManager.dart'; + +FutureOr, Failure>> API_getRecentShortUrls(String? api_key, String? server_url, String apiVersion) async { + try { + final response = await http.get(Uri.parse("${server_url}/rest/v${apiVersion}/short-urls?itemsPerPage=5&orderBy=dateCreated-DESC"), headers: { + "X-Api-Key": api_key ?? "", + }); + if (response.statusCode == 200) { + var jsonResponse = jsonDecode(response.body); + List shortURLs = (jsonResponse["shortUrls"]["data"] as List).map((e) { + return ShortURL.fromJson(e); + }).toList(); + return left(shortURLs); + } + 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())); + } +} \ No newline at end of file diff --git a/lib/HomeView.dart b/lib/HomeView.dart index 6c52fe6..f0f9d11 100644 --- a/lib/HomeView.dart +++ b/lib/HomeView.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:qr_flutter/qr_flutter.dart'; import 'package:shlink_app/API/Classes/ShlinkStats/ShlinkStats.dart'; import 'package:shlink_app/API/ServerManager.dart'; import 'package:shlink_app/LoginView.dart'; import 'package:shlink_app/ShortURLEditView.dart'; +import 'package:shlink_app/URLListView.dart'; +import 'API/Classes/ShortURL/ShortURL.dart'; import 'globals.dart' as globals; class HomeView extends StatefulWidget { @@ -16,15 +19,28 @@ class _HomeViewState extends State { ShlinkStats? shlinkStats; + List shortUrls = []; + bool shortUrlsLoaded = false; + bool _qrCodeShown = false; + String _qrUrl = ""; + @override void initState() { // TODO: implement initState super.initState(); WidgetsBinding.instance - .addPostFrameCallback((_) => loadShlinkStats()); + .addPostFrameCallback((_) { + loadAllData(); + }); } - void loadShlinkStats() async { + Future loadAllData() async { + var resultStats = await loadShlinkStats(); + var resultShortUrls = await loadRecentShortUrls(); + return; + } + + Future loadShlinkStats() async { final response = await globals.serverManager.getShlinkStats(); response.fold((l) { setState(() { @@ -44,56 +60,148 @@ class _HomeViewState extends State { }); } + Future loadRecentShortUrls() async { + final response = await globals.serverManager.getRecentShortUrls(); + response.fold((l) { + setState(() { + shortUrls = l; + 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); + }); + } + @override Widget build(BuildContext context) { return Scaffold( - body: CustomScrollView( - slivers: [ - SliverAppBar.medium( - expandedHeight: 160, - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("Shlink", style: TextStyle(fontWeight: FontWeight.bold)), - Text(globals.serverManager.getServerUrl(), style: TextStyle(fontSize: 16, color: Colors.grey[600])) - ], - ), - actions: [ - PopupMenuButton( - itemBuilder: (context) { - return [ - PopupMenuItem( - value: 0, - child: Text("Log out...", style: TextStyle(color: Colors.red)), + body: Stack( + children: [ + ColorFiltered( + colorFilter: ColorFilter.mode(Colors.black.withOpacity(_qrCodeShown ? 0.4 : 0), BlendMode.srcOver), + child: RefreshIndicator( + onRefresh: () async { + return loadAllData(); + }, + child: CustomScrollView( + slivers: [ + SliverAppBar.medium( + expandedHeight: 160, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Shlink", style: TextStyle(fontWeight: FontWeight.bold)), + Text(globals.serverManager.getServerUrl(), style: TextStyle(fontSize: 16, color: Colors.grey[600])) + ], + ) + ), + SliverToBoxAdapter( + child: Wrap( + alignment: WrapAlignment.spaceEvenly, + children: [ + _ShlinkStatsCardWidget(icon: Icons.link, text: "${shlinkStats?.shortUrlsCount.toString() ?? "0"} Short URLs", borderColor: Colors.blue), + _ShlinkStatsCardWidget(icon: Icons.remove_red_eye, text: "${shlinkStats?.nonOrphanVisits.total ?? "0"} Visits", borderColor: Colors.green), + _ShlinkStatsCardWidget(icon: Icons.warning, text: "${shlinkStats?.orphanVisits.total ?? "0"} Orphan Visits", borderColor: Colors.red), + _ShlinkStatsCardWidget(icon: Icons.sell, text: "${shlinkStats?.tagsCount.toString() ?? "0"} Tags", borderColor: Colors.purple), + ], + ), + ), + if (shortUrlsLoaded && shortUrls.isEmpty) + SliverToBoxAdapter( + child: Center( + child: Padding( + padding: EdgeInsets.only(top: 50), + child: Column( + children: [ + Text("No Short URLs", style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),), + Padding( + padding: EdgeInsets.only(top: 8), + child: Text('Create one by tapping the "+" button below', style: TextStyle(fontSize: 16, color: Colors.grey[600]),), + ) + ], + ) + ) + ) ) - ]; - }, - onSelected: (value) { - if (value == 0) { - globals.serverManager.logOut().then((value) => Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (context) => const LoginView()) - )); - } - }, - ) - ], - ), - SliverToBoxAdapter( - child: Wrap( - alignment: WrapAlignment.spaceEvenly, - children: [ - _ShlinkStatsCardWidget(icon: Icons.link, text: "${shlinkStats?.shortUrlsCount.toString() ?? "0"} Short URLs", borderColor: Colors.blue), - _ShlinkStatsCardWidget(icon: Icons.remove_red_eye, text: "${shlinkStats?.nonOrphanVisits.total ?? "0"} Visits", borderColor: Colors.green), - _ShlinkStatsCardWidget(icon: Icons.warning, text: "${shlinkStats?.orphanVisits.total ?? "0"} Orphan Visits", borderColor: Colors.red), - _ShlinkStatsCardWidget(icon: Icons.sell, text: "${shlinkStats?.tagsCount.toString() ?? "0"} Tags", borderColor: Colors.purple), - ], + else + SliverList(delegate: SliverChildBuilderDelegate( + (BuildContext _context, int index) { + if (index == 0) { + return Padding( + padding: EdgeInsets.only(top: 16, left: 12, right: 12), + child: Text("Recent Short URLs", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + ); + } + else { + final shortURL = shortUrls[index - 1]; + return ShortURLCell(shortURL: shortURL, reload: () { + loadRecentShortUrls(); + }, showQRCode: (String url) { + setState(() { + _qrUrl = url; + _qrCodeShown = true; + }); + }, isLast: index == shortUrls.length); + } + }, + childCount: shortUrls.length + 1 + )) + + ], + ), ), - ) + ), + if (_qrCodeShown) + GestureDetector( + onTap: () { + setState(() { + _qrCodeShown = false; + }); + }, + child: Container( + color: Colors.black.withOpacity(0), + ), + ), + if (_qrCodeShown) + Center( + child: SizedBox( + width: MediaQuery.of(context).size.width / 1.7, + height: MediaQuery.of(context).size.width / 1.7, + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: QrImageView( + data: _qrUrl, + version: QrVersions.auto, + size: 200.0, + eyeStyle: QrEyeStyle( + eyeShape: QrEyeShape.square, + color: MediaQuery.of(context).platformBrightness == Brightness.dark ? Colors.white : Colors.black, + ), + dataModuleStyle: QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: MediaQuery.of(context).platformBrightness == Brightness.dark ? Colors.white : Colors.black, + ), + ) + ) + ), + ), + ) ], ), floatingActionButton: FloatingActionButton( - onPressed: () { - Navigator.of(context).push(MaterialPageRoute(builder: (context) => ShortURLEditView())); + onPressed: () async { + final result = await Navigator.of(context).push(MaterialPageRoute(builder: (context) => const ShortURLEditView())); + loadRecentShortUrls(); }, child: Icon(Icons.add), ) diff --git a/lib/URLListView.dart b/lib/URLListView.dart index 97f3ba4..93b20d8 100644 --- a/lib/URLListView.dart +++ b/lib/URLListView.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:shlink_app/API/Classes/ShortURL/ShortURL.dart'; import 'package:shlink_app/API/ServerManager.dart'; +import 'package:shlink_app/ShortURLEditView.dart'; import 'package:shlink_app/URLDetailView.dart'; import 'globals.dart' as globals; import 'package:flutter/services.dart'; @@ -19,6 +20,8 @@ class _URLListViewState extends State { List shortUrls = []; bool _qrCodeShown = false; String _qrUrl = ""; + + bool shortUrlsLoaded = false; @override void initState() { @@ -33,6 +36,7 @@ class _URLListViewState extends State { response.fold((l) { setState(() { shortUrls = l; + shortUrlsLoaded = true; }); return true; }, (r) { @@ -53,92 +57,58 @@ class _URLListViewState extends State { @override Widget build(BuildContext context) { return Scaffold( + floatingActionButton: FloatingActionButton( + onPressed: () async { + final result = await Navigator.of(context).push(MaterialPageRoute(builder: (context) => ShortURLEditView())); + loadAllShortUrls(); + }, + child: Icon(Icons.add), + ), body: Stack( children: [ ColorFiltered( colorFilter: ColorFilter.mode(Colors.black.withOpacity(_qrCodeShown ? 0.4 : 0), BlendMode.srcOver), child: RefreshIndicator( onRefresh: () async { - //loadAllShortUrls(); return loadAllShortUrls(); - //Future.value(true); }, child: CustomScrollView( slivers: [ SliverAppBar.medium( title: Text("Short URLs", style: TextStyle(fontWeight: FontWeight.bold)) ), - SliverList(delegate: SliverChildBuilderDelegate( - (BuildContext _context, int index) { - final shortURL = shortUrls[index]; - return GestureDetector( - onTap: () async { - final result = await Navigator.of(context).push(MaterialPageRoute(builder: (context) => URLDetailView(shortURL: shortURL))); - - if (result == "reload") { - loadAllShortUrls(); - } - }, + if (shortUrlsLoaded && shortUrls.length == 0) + SliverToBoxAdapter( + child: Center( child: Padding( - padding: EdgeInsets.all(8), - child: Container( - padding: EdgeInsets.only(top: 8, bottom: 8), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: MediaQuery.of(context).platformBrightness == Brightness.dark ? Colors.grey[800]! : Colors.grey[300]!)), - ), - child: Padding( - padding: EdgeInsets.all(8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Text("${shortURL.title ?? shortURL.shortCode}", textScaleFactor: 1.4, style: TextStyle(fontWeight: FontWeight.bold),), - Text("${shortURL.longUrl}",maxLines: 1, overflow: TextOverflow.ellipsis, textScaleFactor: 0.9, style: TextStyle(color: Colors.grey[600]),), - // List tags in a row - Wrap( - children: shortURL.tags.map((tag) { - var randomColor = ([...Colors.primaries]..shuffle()).first.harmonizeWith(Theme.of(context).colorScheme.primary); - return Padding( - padding: EdgeInsets.only(right: 4, top: 4), - child: Container( - padding: EdgeInsets.only(top: 4, bottom: 4, left: 12, right: 12), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4), - color: randomColor, - ), - child: Text(tag, style: TextStyle(color: randomColor.computeLuminance() < 0.5 ? Colors.white : Colors.black),), - ), - ); - }).toList() - - ) - ], - ), - ), - IconButton(onPressed: () async { - await Clipboard.setData(ClipboardData(text: shortURL.shortUrl)); - final snackBar = SnackBar(content: Text("Copied to clipboard!"), behavior: SnackBarBehavior.floating, backgroundColor: Colors.green[400]); - ScaffoldMessenger.of(context).showSnackBar(snackBar); - }, icon: Icon(Icons.copy)), - IconButton(onPressed: () { - setState(() { - _qrUrl = shortURL.shortUrl; - _qrCodeShown = true; - }); - }, icon: Icon(Icons.qr_code)) - ], + padding: EdgeInsets.only(top: 50), + child: Column( + children: [ + Text("No Short URLs", style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),), + Padding( + padding: EdgeInsets.only(top: 8), + child: Text('Create one by tapping the "+" button below', style: TextStyle(fontSize: 16, color: Colors.grey[600]),), ) - ), - ), + ], + ) ) - ); - }, - childCount: shortUrls.length - )) + ) + ) + else + SliverList(delegate: SliverChildBuilderDelegate( + (BuildContext _context, int index) { + final shortURL = shortUrls[index]; + return ShortURLCell(shortURL: shortURL, reload: () { + loadAllShortUrls(); + }, showQRCode: (String url) { + setState(() { + _qrUrl = url; + _qrCodeShown = true; + }); + }, isLast: index == shortUrls.length - 1); + }, + childCount: shortUrls.length + )) ], ), ), @@ -184,3 +154,81 @@ class _URLListViewState extends State { ); } } + +class ShortURLCell extends StatefulWidget { + const ShortURLCell({super.key, required this.shortURL, required this.reload, required this.showQRCode, required this.isLast}); + + final ShortURL shortURL; + final Function() reload; + final Function(String url) showQRCode; + final bool isLast; + + @override + State createState() => _ShortURLCellState(); +} + +class _ShortURLCellState extends State { + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () async { + final result = await Navigator.of(context).push(MaterialPageRoute(builder: (context) => URLDetailView(shortURL: widget.shortURL))); + + if (result == "reload") { + widget.reload(); + } + }, + child: Padding( + padding: EdgeInsets.only(left: 8, right: 8, bottom: widget.isLast ? 90 : 0), + child: Container( + padding: EdgeInsets.only(left: 8, right: 8, bottom: 16, top: 16), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: MediaQuery.of(context).platformBrightness == Brightness.dark ? Colors.grey[800]! : Colors.grey[300]!)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text("${widget.shortURL.title ?? widget.shortURL.shortCode}", textScaleFactor: 1.4, style: TextStyle(fontWeight: FontWeight.bold),), + Text("${widget.shortURL.longUrl}",maxLines: 1, overflow: TextOverflow.ellipsis, textScaleFactor: 0.9, style: TextStyle(color: Colors.grey[600]),), + // List tags in a row + Wrap( + children: widget.shortURL.tags.map((tag) { + var randomColor = ([...Colors.primaries]..shuffle()).first.harmonizeWith(Theme.of(context).colorScheme.primary); + return Padding( + padding: EdgeInsets.only(right: 4, top: 4), + child: Container( + padding: EdgeInsets.only(top: 4, bottom: 4, left: 12, right: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: randomColor, + ), + child: Text(tag, style: TextStyle(color: randomColor.computeLuminance() < 0.5 ? Colors.white : Colors.black),), + ), + ); + }).toList() + + ) + ], + ), + ), + IconButton(onPressed: () async { + await Clipboard.setData(ClipboardData(text: widget.shortURL.shortUrl)); + final snackBar = SnackBar(content: Text("Copied to clipboard!"), behavior: SnackBarBehavior.floating, backgroundColor: Colors.green[400]); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + }, icon: Icon(Icons.copy)), + IconButton(onPressed: () { + widget.showQRCode(widget.shortURL.shortUrl); + }, icon: Icon(Icons.qr_code)) + ], + ) + ), + ) + ); + } +} +