From 06eb990eeaefefd284f3dc6fd95c0af3a8c25d8c Mon Sep 17 00:00:00 2001 From: Varakh Date: Tue, 6 Apr 2021 14:00:00 +0200 Subject: [PATCH] Added gesture detection for tab bar, disable upload when no files have been attached or upload text input is empty, added version information to about view, minor refactor regarding state management, release 1.3.1+10 --- CHANGELOG.md | 6 + assets/i18n/en.json | 1 + lib/app.dart | 50 ++-- lib/core/enums/swipe_event.dart | 1 + lib/core/services/refresh_service.dart | 6 +- lib/core/services/swipe_service.dart | 13 + lib/core/viewmodels/about_model.dart | 21 ++ lib/core/viewmodels/base_model.dart | 35 ++- lib/core/viewmodels/history_model.dart | 14 +- lib/core/viewmodels/login_model.dart | 26 +- lib/core/viewmodels/profile_model.dart | 2 +- lib/core/viewmodels/startup_model.dart | 4 +- lib/core/viewmodels/upload_model.dart | 49 ++-- lib/locator.dart | 2 + lib/ui/views/about_view.dart | 17 +- lib/ui/views/history_view.dart | 35 +-- lib/ui/views/profile_view.dart | 129 +++++----- lib/ui/views/tabbar_authenticated.dart | 29 +++ lib/ui/views/upload_view.dart | 328 +++++++++++++------------ lib/ui/widgets/centered_error_row.dart | 1 - lib/ui/widgets/swipe_navigation.dart | 37 +++ pubspec.yaml | 4 +- 22 files changed, 490 insertions(+), 320 deletions(-) create mode 100644 lib/core/enums/swipe_event.dart create mode 100644 lib/core/services/swipe_service.dart create mode 100644 lib/ui/widgets/swipe_navigation.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ff8eed..e1805fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +## 1.3.1+10 +* Added gesture detection for tab bar +* Disable upload when no files have been attached or upload text input is empty +* Added version information to about view +* Minor refactor regarding state management + ## 1.3.0+9 * Allow API key login * Revamp profile view diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 20307ed..80b589d 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -94,6 +94,7 @@ } }, "about": { + "versions": "{appName} ({packageName}) {version}+{buildNumber}", "description": "This application is a mobile client for FileBin and it's open source. It helps you to manage your pastes.\n\nIn order to use the application, you need access to a FileBin instance.", "faq_headline": "F.A.Q", "faq": "- How do I login?\nInsert your instance URL and valid credentials you also use in the web interface of FileBin.\n\n- Why is storage permission required?\nIt's not required, but highly advised to grant it. Otherwise sharing files with the app won't work correctly and you might think that sharing has no effect.\n\n- When I am logged out, sharing files via share with the app won't list all files I selected after I login.\nPlease login before you start using the app. Account information are persisted. You only need to do it once.", diff --git a/lib/app.dart b/lib/app.dart index 3a7ccb6..4d89eb1 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -5,6 +5,7 @@ import 'package:flutter_translate/localized_app.dart'; import 'package:provider/provider.dart'; import 'core/enums/refresh_event.dart'; +import 'core/enums/swipe_event.dart'; import 'core/manager/dialog_manager.dart'; import 'core/manager/lifecycle_manager.dart'; import 'core/models/session.dart'; @@ -12,6 +13,7 @@ import 'core/services/dialog_service.dart'; import 'core/services/navigation_service.dart'; import 'core/services/refresh_service.dart'; import 'core/services/session_service.dart'; +import 'core/services/swipe_service.dart'; import 'locator.dart'; import 'ui/app_router.dart'; import 'ui/shared/app_colors.dart'; @@ -24,27 +26,33 @@ class MyApp extends StatelessWidget { return LocalizationProvider( state: LocalizationProvider.of(context).state, - child: StreamProvider( + child: StreamProvider( initialData: null, - create: (context) => locator().refreshHistoryController.stream, - child: StreamProvider( - initialData: Session.initial(), - create: (context) => locator().sessionController.stream, - child: LifeCycleManager( - child: MaterialApp( - title: translate('app.title'), - builder: (context, child) => Navigator( - key: locator().dialogNavigationKey, - onGenerateRoute: (settings) => MaterialPageRoute(builder: (context) => DialogManager(child: child)), - ), - theme: ThemeData( - brightness: Brightness.light, primarySwatch: primaryAccentColor, primaryColor: primaryAccentColor), - onGenerateRoute: AppRouter.generateRoute, - navigatorKey: locator().navigationKey, - home: StartUpView(), - supportedLocales: localizationDelegate.supportedLocales, - locale: localizationDelegate.currentLocale, - )), - ))); + create: (context) => locator().swipeEventController.stream, + child: StreamProvider( + initialData: null, + create: (context) => locator().refreshEventController.stream, + child: StreamProvider( + initialData: Session.initial(), + create: (context) => locator().sessionController.stream, + child: LifeCycleManager( + child: MaterialApp( + title: translate('app.title'), + builder: (context, child) => Navigator( + key: locator().dialogNavigationKey, + onGenerateRoute: (settings) => + MaterialPageRoute(builder: (context) => DialogManager(child: child)), + ), + theme: ThemeData( + brightness: Brightness.light, + primarySwatch: primaryAccentColor, + primaryColor: primaryAccentColor), + onGenerateRoute: AppRouter.generateRoute, + navigatorKey: locator().navigationKey, + home: StartUpView(), + supportedLocales: localizationDelegate.supportedLocales, + locale: localizationDelegate.currentLocale, + )), + )))); } } diff --git a/lib/core/enums/swipe_event.dart b/lib/core/enums/swipe_event.dart new file mode 100644 index 0000000..72e3ca5 --- /dev/null +++ b/lib/core/enums/swipe_event.dart @@ -0,0 +1 @@ +enum SwipeEvent { Left, Right } diff --git a/lib/core/services/refresh_service.dart b/lib/core/services/refresh_service.dart index f3c260c..0e9e0e3 100644 --- a/lib/core/services/refresh_service.dart +++ b/lib/core/services/refresh_service.dart @@ -3,11 +3,11 @@ import 'dart:async'; import '../enums/refresh_event.dart'; class RefreshService { - StreamController refreshHistoryController = StreamController.broadcast(); + StreamController refreshEventController = StreamController.broadcast(); void addEvent(RefreshEvent event) { - if (refreshHistoryController.hasListener) { - refreshHistoryController.add(event); + if (refreshEventController.hasListener) { + refreshEventController.add(event); } } } diff --git a/lib/core/services/swipe_service.dart b/lib/core/services/swipe_service.dart new file mode 100644 index 0000000..e77f1a9 --- /dev/null +++ b/lib/core/services/swipe_service.dart @@ -0,0 +1,13 @@ +import 'dart:async'; + +import '../enums/swipe_event.dart'; + +class SwipeService { + StreamController swipeEventController = StreamController.broadcast(); + + void addEvent(SwipeEvent event) { + if (swipeEventController.hasListener) { + swipeEventController.add(event); + } + } +} diff --git a/lib/core/viewmodels/about_model.dart b/lib/core/viewmodels/about_model.dart index 4d1c88c..7f8cb8c 100644 --- a/lib/core/viewmodels/about_model.dart +++ b/lib/core/viewmodels/about_model.dart @@ -1,10 +1,31 @@ +import 'package:package_info/package_info.dart'; + import '../../core/services/link_service.dart'; import '../../locator.dart'; +import '../enums/viewstate.dart'; import 'base_model.dart'; class AboutModel extends BaseModel { final LinkService _linkService = locator(); + PackageInfo packageInfo = PackageInfo( + appName: 'Unknown', + packageName: 'Unknown', + version: 'Unknown', + buildNumber: 'Unknown', + ); + + void init() async { + await _initPackageInfo(); + } + + Future _initPackageInfo() async { + setStateView(ViewState.Busy); + final PackageInfo info = await PackageInfo.fromPlatform(); + packageInfo = info; + setStateView(ViewState.Idle); + } + void openLink(String link) { _linkService.open(link); } diff --git a/lib/core/viewmodels/base_model.dart b/lib/core/viewmodels/base_model.dart index 6cf9627..c0648c0 100644 --- a/lib/core/viewmodels/base_model.dart +++ b/lib/core/viewmodels/base_model.dart @@ -5,33 +5,48 @@ import '../../core/util/logger.dart'; import '../enums/viewstate.dart'; class BaseModel extends ChangeNotifier { + static const String STATE_VIEW = 'viewState'; + static const String STATE_MESSAGE = 'viewMessage'; + final Logger _logger = getLogger(); bool _isDisposed = false; - ViewState _state = ViewState.Idle; - String _stateMessage; + Map _stateMap = {STATE_VIEW: ViewState.Idle, STATE_MESSAGE: null}; - ViewState get state => _state; + ViewState get state => _stateMap[STATE_VIEW]; + String get stateMessage => _stateMap[STATE_MESSAGE]; - String get stateMessage => _stateMessage; + void setStateValue(String key, Object stateValue) { + if (_stateMap.containsKey(key)) { + _stateMap.update(key, (value) => stateValue); + } else { + _stateMap.putIfAbsent(key, () => stateValue); + } - void setState(ViewState viewState) { - _state = viewState; if (!_isDisposed) { notifyListeners(); - _logger.d("Notified state change '${viewState.toString()}'"); + _logger.d("Notified state value update '($key, ${stateValue.toString()})'"); } } - void setStateMessage(String stateMessage) { - _stateMessage = stateMessage; + void removeStateValue(String key) { + _stateMap.remove(key); + if (!_isDisposed) { notifyListeners(); - _logger.d("Notified state message change '$stateMessage'"); + _logger.d("Notified state removal of '$key'"); } } + void setStateView(ViewState stateView) { + setStateValue(STATE_VIEW, stateView); + } + + void setStateMessage(String stateMessage) { + setStateValue(STATE_MESSAGE, stateMessage); + } + @override void dispose() { super.dispose(); diff --git a/lib/core/viewmodels/history_model.dart b/lib/core/viewmodels/history_model.dart index c2c04fb..3e5b962 100644 --- a/lib/core/viewmodels/history_model.dart +++ b/lib/core/viewmodels/history_model.dart @@ -34,7 +34,7 @@ class HistoryModel extends BaseModel { String errorMessage; void init() { - _refreshTriggerSubscription = _refreshService.refreshHistoryController.stream.listen((event) { + _refreshTriggerSubscription = _refreshService.refreshEventController.stream.listen((event) { if (event == RefreshEvent.RefreshHistory) { _logger.d('History needs a refresh'); getHistory(); @@ -43,7 +43,7 @@ class HistoryModel extends BaseModel { } Future getHistory() async { - setState(ViewState.Busy); + setStateView(ViewState.Busy); try { pastes.clear(); @@ -103,13 +103,13 @@ class HistoryModel extends BaseModel { errorMessage = translate('api.socket_timeout'); } else { errorMessage = translate('app.unknown_error'); - setState(ViewState.Idle); + setStateView(ViewState.Idle); _logger.e('An unknown error occurred', e); throw e; } } - setState(ViewState.Idle); + setStateView(ViewState.Idle); } Future deletePaste(String id) async { @@ -123,7 +123,7 @@ class HistoryModel extends BaseModel { return; } - setState(ViewState.Busy); + setStateView(ViewState.Busy); try { await _fileService.deletePaste(id); @@ -149,13 +149,13 @@ class HistoryModel extends BaseModel { errorMessage = translate('api.socket_timeout'); } else { errorMessage = translate('app.unknown_error'); - setState(ViewState.Idle); + setStateView(ViewState.Idle); _logger.e('An unknown error occurred', e); throw e; } } - setState(ViewState.Idle); + setStateView(ViewState.Idle); } void openLink(String link) { diff --git a/lib/core/viewmodels/login_model.dart b/lib/core/viewmodels/login_model.dart index a07085e..7e7d7c9 100644 --- a/lib/core/viewmodels/login_model.dart +++ b/lib/core/viewmodels/login_model.dart @@ -43,23 +43,23 @@ class LoginModel extends BaseModel { String errorMessage; void toggleLoginMethod() { - setState(ViewState.Busy); + setStateView(ViewState.Busy); useCredentialsLogin = !useCredentialsLogin; - setState(ViewState.Idle); + setStateView(ViewState.Idle); } void init() async { bool hasLastUrl = await _storageService.hasLastUrl(); if (hasLastUrl) { - setState(ViewState.Busy); + setStateView(ViewState.Busy); var s = await _storageService.retrieveLastUrl(); if (s.isNotEmpty) { _uriController = new TextEditingController(text: s); } - setState(ViewState.Idle); + setStateView(ViewState.Idle); } } @@ -69,45 +69,45 @@ class LoginModel extends BaseModel { var password = passwordController.text; var apiKey = apiKeyController.text; - setState(ViewState.Busy); + setStateView(ViewState.Busy); url = trim(url); username = trim(username); if (url.isEmpty) { errorMessage = translate('login.errors.empty_url'); - setState(ViewState.Idle); + setStateView(ViewState.Idle); return false; } if (!url.contains("https://") && !url.contains("http://")) { errorMessage = translate('login.errors.no_protocol'); - setState(ViewState.Idle); + setStateView(ViewState.Idle); return false; } bool validUri = Uri.parse(url).isAbsolute; if (!validUri || !isURL(url)) { errorMessage = translate('login.errors.invalid_url'); - setState(ViewState.Idle); + setStateView(ViewState.Idle); return false; } if (useCredentialsLogin) { if (username.isEmpty) { errorMessage = translate('login.errors.empty_username'); - setState(ViewState.Idle); + setStateView(ViewState.Idle); return false; } if (password.isEmpty) { errorMessage = translate('login.errors.empty_password'); - setState(ViewState.Idle); + setStateView(ViewState.Idle); return false; } } else { if (apiKey.isEmpty) { errorMessage = translate('login.errors.empty_apikey'); - setState(ViewState.Idle); + setStateView(ViewState.Idle); return false; } } @@ -147,7 +147,7 @@ class LoginModel extends BaseModel { } else { errorMessage = translate('app.unknown_error'); _sessionService.logout(); - setState(ViewState.Idle); + setStateView(ViewState.Idle); _logger.e('An unknown error occurred', e); throw e; } @@ -156,7 +156,7 @@ class LoginModel extends BaseModel { _sessionService.logout(); } - setState(ViewState.Idle); + setStateView(ViewState.Idle); return success; } diff --git a/lib/core/viewmodels/profile_model.dart b/lib/core/viewmodels/profile_model.dart index 976242f..180e356 100644 --- a/lib/core/viewmodels/profile_model.dart +++ b/lib/core/viewmodels/profile_model.dart @@ -67,7 +67,7 @@ class ProfileModel extends BaseModel { } else { errorMessage = translate('app.unknown_error'); _sessionService.logout(); - setState(ViewState.Idle); + setStateView(ViewState.Idle); _logger.e('An unknown error occurred', e); throw e; } diff --git a/lib/core/viewmodels/startup_model.dart b/lib/core/viewmodels/startup_model.dart index 6680729..3ba1263 100644 --- a/lib/core/viewmodels/startup_model.dart +++ b/lib/core/viewmodels/startup_model.dart @@ -12,7 +12,7 @@ class StartUpViewModel extends BaseModel { final NavigationService _navigationService = locator(); Future handleStartUpLogic() async { - setState(ViewState.Busy); + setStateView(ViewState.Busy); setStateMessage(translate('startup.init')); await Future.delayed(Duration(milliseconds: 150)); @@ -22,6 +22,6 @@ class StartUpViewModel extends BaseModel { _navigationService.navigateAndReplaceTo(HomeView.routeName); - setState(ViewState.Idle); + setStateView(ViewState.Idle); } } diff --git a/lib/core/viewmodels/upload_model.dart b/lib/core/viewmodels/upload_model.dart index f44891d..5495649 100644 --- a/lib/core/viewmodels/upload_model.dart +++ b/lib/core/viewmodels/upload_model.dart @@ -31,6 +31,8 @@ class UploadModel extends BaseModel { final RefreshService _refreshService = locator(); TextEditingController _pasteTextController = TextEditingController(); + bool pasteTextTouched = false; + StreamSubscription _intentDataStreamSubscription; bool createMulti = false; @@ -43,10 +45,15 @@ class UploadModel extends BaseModel { TextEditingController get pasteTextController => _pasteTextController; void init() { + _pasteTextController.addListener(() { + pasteTextTouched = pasteTextController.text.isNotEmpty; + setStateValue("PASTE_TEXT_TOUCHED", pasteTextTouched); + }); + // For sharing images coming from outside the app while the app is in the memory _intentDataStreamSubscription = ReceiveSharingIntent.getMediaStream().listen((List value) { if (value != null && value.length > 0) { - setState(ViewState.Busy); + setStateView(ViewState.Busy); paths = value.map((sharedFile) { return PlatformFile.fromMap({ 'path': sharedFile.path, @@ -55,19 +62,19 @@ class UploadModel extends BaseModel { 'bytes': null }); }).toList(); - setState(ViewState.Idle); + setStateView(ViewState.Idle); } }, onError: (err) { - setState(ViewState.Busy); + setStateView(ViewState.Busy); errorMessage = translate('upload.retrieval_intent'); _logger.e('Error while retrieving shared data: $err'); - setState(ViewState.Idle); + setStateView(ViewState.Idle); }); // For sharing images coming from outside the app while the app is closed ReceiveSharingIntent.getInitialMedia().then((List value) { if (value != null && value.length > 0) { - setState(ViewState.Busy); + setStateView(ViewState.Busy); paths = value.map((sharedFile) { return PlatformFile.fromMap({ 'path': sharedFile.path, @@ -76,42 +83,42 @@ class UploadModel extends BaseModel { 'bytes': null }); }).toList(); - setState(ViewState.Idle); + setStateView(ViewState.Idle); } }); // For sharing or opening urls/text coming from outside the app while the app is in the memory _intentDataStreamSubscription = ReceiveSharingIntent.getTextStream().listen((String value) { if (value != null && value.isNotEmpty) { - setState(ViewState.Busy); + setStateView(ViewState.Busy); pasteTextController.text = value; - setState(ViewState.Idle); + setStateView(ViewState.Idle); } }, onError: (err) { - setState(ViewState.Busy); + setStateView(ViewState.Busy); errorMessage = translate('upload.retrieval_intent'); _logger.e('Error while retrieving shared data: $err'); - setState(ViewState.Idle); + setStateView(ViewState.Idle); }); // For sharing or opening urls/text coming from outside the app while the app is closed ReceiveSharingIntent.getInitialText().then((String value) { if (value != null && value.isNotEmpty) { - setState(ViewState.Busy); + setStateView(ViewState.Busy); pasteTextController.text = value; - setState(ViewState.Idle); + setStateView(ViewState.Idle); } }); } void toggleCreateMulti() { - setState(ViewState.Busy); + setStateView(ViewState.Busy); createMulti = !createMulti; - setState(ViewState.Idle); + setStateView(ViewState.Idle); } void openFileExplorer() async { - setState(ViewState.Busy); + setStateView(ViewState.Busy); setStateMessage(translate('upload.file_explorer_open')); loadingPath = true; @@ -134,20 +141,20 @@ class UploadModel extends BaseModel { fileName = paths != null ? paths.map((e) => e.name).toString() : '...'; setStateMessage(null); - setState(ViewState.Idle); + setStateView(ViewState.Idle); } void clearCachedFiles() async { - setState(ViewState.Busy); + setStateView(ViewState.Busy); await FilePicker.platform.clearTemporaryFiles(); paths = null; fileName = null; errorMessage = null; - setState(ViewState.Idle); + setStateView(ViewState.Idle); } Future> upload() async { - setState(ViewState.Busy); + setStateView(ViewState.Busy); setStateMessage(translate('upload.uploading_now')); Map uploadedPasteIds = new Map(); @@ -204,14 +211,14 @@ class UploadModel extends BaseModel { } else { errorMessage = translate('app.unknown_error'); setStateMessage(null); - setState(ViewState.Idle); + setStateView(ViewState.Idle); _logger.e('An unknown error occurred', e); throw e; } } setStateMessage(null); - setState(ViewState.Idle); + setStateView(ViewState.Idle); return null; } diff --git a/lib/locator.dart b/lib/locator.dart index 97bc207..df04114 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -11,6 +11,7 @@ import 'core/services/permission_service.dart'; import 'core/services/refresh_service.dart'; import 'core/services/session_service.dart'; import 'core/services/storage_service.dart'; +import 'core/services/swipe_service.dart'; import 'core/services/user_service.dart'; import 'core/viewmodels/about_model.dart'; import 'core/viewmodels/history_model.dart'; @@ -41,6 +42,7 @@ void setupLocator() { locator.registerLazySingleton(() => LinkService()); locator.registerLazySingleton(() => PermissionService()); locator.registerLazySingleton(() => RefreshService()); + locator.registerLazySingleton(() => SwipeService()); /// view models locator.registerFactory(() => StartUpViewModel()); diff --git a/lib/ui/views/about_view.dart b/lib/ui/views/about_view.dart index 617bb09..e8c79ef 100644 --- a/lib/ui/views/about_view.dart +++ b/lib/ui/views/about_view.dart @@ -25,6 +25,7 @@ class AboutView extends StatelessWidget { ); return BaseView( + onModelReady: (model) => model.init(), builder: (context, model, child) => Scaffold( appBar: MyAppBar( title: Text(translate('titles.about')), @@ -44,12 +45,22 @@ class AboutView extends StatelessWidget { UIHelper.verticalSpaceMedium(), Center( child: Text( - translate(('about.description')), + translate('about.versions', args: { + 'appName': model.packageInfo.appName, + 'packageName': model.packageInfo.packageName, + 'version': model.packageInfo.version, + 'buildNumber': model.packageInfo.buildNumber + }), )), UIHelper.verticalSpaceMedium(), Center( child: Text( - translate(('about.faq_headline')), + translate('about.description'), + )), + UIHelper.verticalSpaceMedium(), + Center( + child: Text( + translate('about.faq_headline'), style: subHeaderStyle, )), Center( @@ -59,7 +70,7 @@ class AboutView extends StatelessWidget { UIHelper.verticalSpaceMedium(), Center( child: Text( - translate(('about.contact_us')), + translate('about.contact_us'), style: subHeaderStyle, )), UIHelper.verticalSpaceSmall(), diff --git a/lib/ui/views/history_view.dart b/lib/ui/views/history_view.dart index 24e129a..f94ccab 100644 --- a/lib/ui/views/history_view.dart +++ b/lib/ui/views/history_view.dart @@ -12,6 +12,7 @@ import '../../core/viewmodels/history_model.dart'; import '../../ui/widgets/centered_error_row.dart'; import '../shared/app_colors.dart'; import '../widgets/my_appbar.dart'; +import '../widgets/swipe_navigation.dart'; import 'base_view.dart'; class HistoryView extends StatelessWidget { @@ -19,8 +20,6 @@ class HistoryView extends StatelessWidget { @override Widget build(BuildContext context) { - var url = Provider.of(context).url; - return BaseView( onModelReady: (model) { model.init(); @@ -29,22 +28,28 @@ class HistoryView extends StatelessWidget { builder: (context, model, child) => Scaffold( appBar: MyAppBar(title: Text(translate('titles.history'))), backgroundColor: backgroundColor, - body: model.state == ViewState.Busy - ? Center(child: CircularProgressIndicator()) - : (model.errorMessage == null - ? Container( - padding: EdgeInsets.all(0), - child: RefreshIndicator(onRefresh: () => model.getHistory(), child: _render(model, url, context))) - : Container( - padding: EdgeInsets.all(25), - child: CenteredErrorRow( - model.errorMessage, - retryCallback: () => model.getHistory(), - )))), + body: SwipeNavigation(child: _render(model, context))), ); } - Widget _render(HistoryModel model, String url, BuildContext context) { + Widget _render(HistoryModel model, BuildContext context) { + var url = Provider.of(context).url; + + return model.state == ViewState.Busy + ? Center(child: CircularProgressIndicator()) + : (model.errorMessage == null + ? Container( + padding: EdgeInsets.all(0), + child: RefreshIndicator(onRefresh: () => model.getHistory(), child: _renderItems(model, url, context))) + : Container( + padding: EdgeInsets.all(25), + child: CenteredErrorRow( + model.errorMessage, + retryCallback: () => model.getHistory(), + ))); + } + + Widget _renderItems(HistoryModel model, String url, BuildContext context) { List cards = []; if (model.pastes.length > 0) { diff --git a/lib/ui/views/profile_view.dart b/lib/ui/views/profile_view.dart index 8b62a15..fa0c211 100644 --- a/lib/ui/views/profile_view.dart +++ b/lib/ui/views/profile_view.dart @@ -10,6 +10,7 @@ import '../shared/app_colors.dart'; import '../shared/text_styles.dart'; import '../shared/ui_helpers.dart'; import '../widgets/my_appbar.dart'; +import '../widgets/swipe_navigation.dart'; import 'base_view.dart'; class ProfileView extends StatelessWidget { @@ -17,71 +18,75 @@ class ProfileView extends StatelessWidget { @override Widget build(BuildContext context) { - var url = Provider.of(context).url; - var apiKey = Provider.of(context).apiKey; - return BaseView( builder: (context, model, child) => Scaffold( appBar: MyAppBar(title: Text(translate('titles.profile'))), backgroundColor: backgroundColor, - body: model.state == ViewState.Busy - ? Center(child: CircularProgressIndicator()) - : ListView( - children: [ - UIHelper.verticalSpaceMedium(), - Padding( - padding: const EdgeInsets.only(left: 25.0), - child: Center( - child: Text( - translate('profile.instance'), - style: subHeaderStyle, - ))), - UIHelper.verticalSpaceMedium(), - Padding( - padding: const EdgeInsets.only(left: 25.0), - child: Center( - child: Linkify( - onOpen: (link) => model.openLink(link.url), - text: translate('profile.connection', args: {'url': url}), - options: LinkifyOptions(humanize: false), - ))), - UIHelper.verticalSpaceMedium(), - Padding( - padding: const EdgeInsets.only(left: 25.0, right: 25.0), - child: ElevatedButton.icon( - icon: Icon(Icons.settings, color: blueColor), - label: Text( - translate('profile.show_config'), - style: TextStyle(color: buttonForegroundColor), - ), - onPressed: () { - return model.showConfig(url); - })), - UIHelper.verticalSpaceMedium(), - Padding( - padding: const EdgeInsets.only(left: 25.0, right: 25.0), - child: ElevatedButton.icon( - icon: Icon(Icons.lock, color: orangeColor), - label: Text( - translate('profile.reveal_api_key'), - style: TextStyle(color: buttonForegroundColor), - ), - onPressed: () { - return model.revealApiKey(apiKey); - })), - UIHelper.verticalSpaceMedium(), - Padding( - padding: const EdgeInsets.only(left: 25.0, right: 25.0), - child: ElevatedButton.icon( - icon: Icon(Icons.exit_to_app, color: redColor), - label: Text( - translate('profile.logout'), - style: TextStyle(color: buttonForegroundColor), - ), - onPressed: () { - return model.logout(); - })), - ], - ))); + body: SwipeNavigation(child: _render(model, context)))); + } + + Widget _render(ProfileModel model, BuildContext context) { + var url = Provider.of(context).url; + var apiKey = Provider.of(context).apiKey; + + return model.state == ViewState.Busy + ? Center(child: CircularProgressIndicator()) + : ListView( + children: [ + UIHelper.verticalSpaceMedium(), + Padding( + padding: const EdgeInsets.only(left: 25.0), + child: Center( + child: Text( + translate('profile.instance'), + style: subHeaderStyle, + ))), + UIHelper.verticalSpaceMedium(), + Padding( + padding: const EdgeInsets.only(left: 25.0), + child: Center( + child: Linkify( + onOpen: (link) => model.openLink(link.url), + text: translate('profile.connection', args: {'url': url}), + options: LinkifyOptions(humanize: false), + ))), + UIHelper.verticalSpaceMedium(), + Padding( + padding: const EdgeInsets.only(left: 25.0, right: 25.0), + child: ElevatedButton.icon( + icon: Icon(Icons.settings, color: blueColor), + label: Text( + translate('profile.show_config'), + style: TextStyle(color: buttonForegroundColor), + ), + onPressed: () { + return model.showConfig(url); + })), + UIHelper.verticalSpaceMedium(), + Padding( + padding: const EdgeInsets.only(left: 25.0, right: 25.0), + child: ElevatedButton.icon( + icon: Icon(Icons.lock, color: orangeColor), + label: Text( + translate('profile.reveal_api_key'), + style: TextStyle(color: buttonForegroundColor), + ), + onPressed: () { + return model.revealApiKey(apiKey); + })), + UIHelper.verticalSpaceMedium(), + Padding( + padding: const EdgeInsets.only(left: 25.0, right: 25.0), + child: ElevatedButton.icon( + icon: Icon(Icons.exit_to_app, color: redColor), + label: Text( + translate('profile.logout'), + style: TextStyle(color: buttonForegroundColor), + ), + onPressed: () { + return model.logout(); + })), + ], + ); } } diff --git a/lib/ui/views/tabbar_authenticated.dart b/lib/ui/views/tabbar_authenticated.dart index 2ad8437..e15850c 100644 --- a/lib/ui/views/tabbar_authenticated.dart +++ b/lib/ui/views/tabbar_authenticated.dart @@ -1,7 +1,15 @@ +import 'dart:async'; +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_translate/flutter_translate.dart'; +import 'package:logger/logger.dart'; +import '../../core/enums/swipe_event.dart'; +import '../../core/services/swipe_service.dart'; +import '../../core/util/logger.dart'; +import '../../locator.dart'; import '../shared/app_colors.dart'; import 'history_view.dart'; import 'profile_view.dart'; @@ -13,6 +21,10 @@ class AuthenticatedTabBarView extends StatefulWidget { } class AuthenticatedTabBarState extends State with SingleTickerProviderStateMixin { + final Logger _logger = getLogger(); + final SwipeService _swipeService = locator(); + + StreamSubscription _swipeEventSubscription; TabController _tabController; int _currentTabIndex = 0; @@ -38,11 +50,28 @@ class AuthenticatedTabBarState extends State with Singl setState(() => _currentTabIndex = selectedIndex); } }); + + _swipeEventSubscription = _swipeService.swipeEventController.stream.listen((SwipeEvent event) { + _logger.d('Received an swipe event for the authenticated tab bar: $event'); + + int targetIndex = _currentTabIndex; + if (SwipeEvent.Left == event) { + targetIndex = min(_currentTabIndex + 1, _realPages.length - 1); + } + + if (SwipeEvent.Right == event) { + targetIndex = max(_currentTabIndex - 1, 0); + } + + _logger.d("Changing to tab '$targetIndex' because of a swipe event"); + _tabController.animateTo(targetIndex); + }); } @override void dispose() { _tabController.dispose(); + _swipeEventSubscription.cancel(); super.dispose(); } diff --git a/lib/ui/views/upload_view.dart b/lib/ui/views/upload_view.dart index e7f7a00..1b5c9fa 100644 --- a/lib/ui/views/upload_view.dart +++ b/lib/ui/views/upload_view.dart @@ -9,6 +9,7 @@ import '../../core/viewmodels/upload_model.dart'; import '../shared/app_colors.dart'; import '../widgets/centered_error_row.dart'; import '../widgets/my_appbar.dart'; +import '../widgets/swipe_navigation.dart'; import 'base_view.dart'; class UploadView extends StatelessWidget { @@ -16,173 +17,180 @@ class UploadView extends StatelessWidget { @override Widget build(BuildContext context) { - var url = Provider.of(context).url; - return BaseView( onModelReady: (model) => model.init(), builder: (context, model, child) => Scaffold( appBar: MyAppBar(title: Text(translate('titles.upload'))), backgroundColor: backgroundColor, - body: model.state == ViewState.Busy - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - CircularProgressIndicator(), - (model.stateMessage != null && model.stateMessage.isNotEmpty - ? Text(model.stateMessage) - : Container()) - ])) - : ListView(children: [ + body: SwipeNavigation(child: _render(model, context)))); + } + + bool _isUploadButtonEnabled(UploadModel model) { + return model.pasteTextTouched || (model.paths != null && model.paths.length > 0); + } + + Widget _render(UploadModel model, BuildContext context) { + var url = Provider.of(context).url; + + return model.state == ViewState.Busy + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CircularProgressIndicator(), + (model.stateMessage != null && model.stateMessage.isNotEmpty ? Text(model.stateMessage) : Container()) + ])) + : ListView(children: [ + Padding( + padding: const EdgeInsets.only(left: 25.0, right: 25.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ Padding( - padding: const EdgeInsets.only(left: 25.0, right: 25.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.only(top: 10.0, bottom: 10.0), - child: TextFormField( - minLines: 1, - maxLines: 7, - decoration: InputDecoration( - prefixIcon: Icon( - Icons.text_snippet, - color: buttonBackgroundColor, - ), - suffixIcon: IconButton( - onPressed: () => model.pasteTextController.clear(), - icon: Icon(Icons.clear), - ), - hintText: translate('upload.text_to_be_pasted'), - contentPadding: EdgeInsets.fromLTRB(20.0, 10.0, 20.0, 10.0), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(32.0)), - ), - controller: model.pasteTextController)), - Padding( - padding: const EdgeInsets.only(top: 10.0, bottom: 10.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.end, - children: [Text(translate('upload.and_or'))])), - Padding( - padding: const EdgeInsets.only(top: 10.0, bottom: 10.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - ElevatedButton.icon( - icon: Icon(Icons.file_copy_sharp, color: blueColor), - onPressed: () => model.openFileExplorer(), - label: Text( - translate('upload.open_file_explorer'), - style: TextStyle(color: buttonForegroundColor), - )), - ElevatedButton.icon( - icon: Icon(Icons.cancel, color: orangeColor), - onPressed: model.paths != null && model.paths.length > 0 - ? () => model.clearCachedFiles() - : null, - label: Text( - translate('upload.clear_temporary_files'), - style: TextStyle(color: buttonForegroundColor), - )), - ], - )), - Padding( - padding: const EdgeInsets.only(top: 10.0, bottom: 10.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Checkbox( - value: model.createMulti, - onChanged: (v) => model.toggleCreateMulti(), - ), - Text(translate('upload.multipaste')), - ])), - Padding( - padding: const EdgeInsets.only(top: 10.0, bottom: 10.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - ElevatedButton.icon( - onPressed: () async { - Map items = await model.upload(); - - if (items != null) { - var clipboardContent = ''; - items.forEach((id, isMulti) { - if (isMulti && model.createMulti || !isMulti && !model.createMulti) { - clipboardContent += '$url/$id\n'; - } - }); - - FlutterClipboard.copy(clipboardContent).then((value) { - final snackBar = SnackBar( - action: SnackBarAction( - label: translate('upload.dismiss'), - textColor: blueColor, - onPressed: () { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - }, - ), - content: Text(translate('upload.uploaded')), - duration: Duration(seconds: 10), - ); - ScaffoldMessenger.of(context).showSnackBar(snackBar); - }); - } - }, - icon: Icon(Icons.upload_rounded, color: greenColor), - label: Text( - translate('upload.upload'), - style: TextStyle(color: buttonForegroundColor), - )), - ])), - model.errorMessage != null && model.errorMessage.isNotEmpty - ? (Padding( - padding: const EdgeInsets.only(top: 10.0, bottom: 10.0), - child: CenteredErrorRow(model.errorMessage))) - : Container(), - Builder( - builder: (BuildContext context) => model.loadingPath - ? Padding( - padding: const EdgeInsets.only(bottom: 10.0), - child: const CircularProgressIndicator(), - ) - : model.paths != null - ? Container( - padding: const EdgeInsets.only(bottom: 30.0), - height: MediaQuery.of(context).size.height * 0.50, - child: ListView.separated( - itemCount: - model.paths != null && model.paths.isNotEmpty ? model.paths.length : 1, - itemBuilder: (BuildContext context, int index) { - final bool isMultiPath = model.paths != null && model.paths.isNotEmpty; - final String name = (isMultiPath - ? model.paths.map((e) => e.name).toList()[index] - : model.fileName ?? '...'); - final path = model.paths.length > 0 - ? model.paths.map((e) => e.path).toList()[index].toString() - : ''; - - return Card( - child: ListTile( - title: Text( - name, - ), - subtitle: Text(path), - )); - }, - separatorBuilder: (BuildContext context, int index) => const Divider(), - ), - ) - : Container(), + padding: const EdgeInsets.only(top: 10.0, bottom: 10.0), + child: TextFormField( + minLines: 1, + maxLines: 7, + decoration: InputDecoration( + prefixIcon: Icon( + Icons.text_snippet, + color: buttonBackgroundColor, + ), + suffixIcon: IconButton( + onPressed: () => model.pasteTextController.clear(), + icon: Icon(Icons.clear), + ), + hintText: translate('upload.text_to_be_pasted'), + contentPadding: EdgeInsets.fromLTRB(20.0, 10.0, 20.0, 10.0), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(32.0)), ), + controller: model.pasteTextController)), + Padding( + padding: const EdgeInsets.only(top: 10.0, bottom: 10.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.end, + children: [Text(translate('upload.and_or'))])), + Padding( + padding: const EdgeInsets.only(top: 10.0, bottom: 10.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + ElevatedButton.icon( + icon: Icon(Icons.file_copy_sharp, color: blueColor), + onPressed: () => model.openFileExplorer(), + label: Text( + translate('upload.open_file_explorer'), + style: TextStyle(color: buttonForegroundColor), + )), + ElevatedButton.icon( + icon: Icon(Icons.cancel, color: orangeColor), + onPressed: model.paths != null && model.paths.length > 0 + ? () => model.clearCachedFiles() + : null, + label: Text( + translate('upload.clear_temporary_files'), + style: TextStyle(color: buttonForegroundColor), + )), ], - )) - ]))); + )), + Padding( + padding: const EdgeInsets.only(top: 10.0, bottom: 10.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Checkbox( + value: model.createMulti, + onChanged: (v) => model.toggleCreateMulti(), + ), + Text(translate('upload.multipaste')), + ])), + Padding( + padding: const EdgeInsets.only(top: 10.0, bottom: 10.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + ElevatedButton.icon( + onPressed: !_isUploadButtonEnabled(model) + ? null + : () async { + Map items = await model.upload(); + + if (items != null) { + var clipboardContent = ''; + items.forEach((id, isMulti) { + if (isMulti && model.createMulti || !isMulti && !model.createMulti) { + clipboardContent += '$url/$id\n'; + } + }); + + FlutterClipboard.copy(clipboardContent).then((value) { + final snackBar = SnackBar( + action: SnackBarAction( + label: translate('upload.dismiss'), + textColor: blueColor, + onPressed: () { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + }, + ), + content: Text(translate('upload.uploaded')), + duration: Duration(seconds: 10), + ); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + }); + } + }, + icon: Icon(Icons.upload_rounded, color: greenColor), + label: Text( + translate('upload.upload'), + style: TextStyle(color: buttonForegroundColor), + )), + ])), + model.errorMessage != null && model.errorMessage.isNotEmpty + ? (Padding( + padding: const EdgeInsets.only(top: 10.0, bottom: 10.0), + child: CenteredErrorRow(model.errorMessage))) + : Container(), + Builder( + builder: (BuildContext context) => model.loadingPath + ? Padding( + padding: const EdgeInsets.only(bottom: 10.0), + child: const CircularProgressIndicator(), + ) + : model.paths != null + ? Container( + padding: const EdgeInsets.only(bottom: 30.0), + height: MediaQuery.of(context).size.height * 0.50, + child: ListView.separated( + itemCount: model.paths != null && model.paths.isNotEmpty ? model.paths.length : 1, + itemBuilder: (BuildContext context, int index) { + final bool isMultiPath = model.paths != null && model.paths.isNotEmpty; + final String name = (isMultiPath + ? model.paths.map((e) => e.name).toList()[index] + : model.fileName ?? '...'); + final path = model.paths.length > 0 + ? model.paths.map((e) => e.path).toList()[index].toString() + : ''; + + return Card( + child: ListTile( + title: Text( + name, + ), + subtitle: Text(path), + )); + }, + separatorBuilder: (BuildContext context, int index) => const Divider(), + ), + ) + : Container(), + ), + ], + )) + ]); } } diff --git a/lib/ui/widgets/centered_error_row.dart b/lib/ui/widgets/centered_error_row.dart index b5e6966..102e07c 100644 --- a/lib/ui/widgets/centered_error_row.dart +++ b/lib/ui/widgets/centered_error_row.dart @@ -1,4 +1,3 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import '../shared/app_colors.dart'; diff --git a/lib/ui/widgets/swipe_navigation.dart b/lib/ui/widgets/swipe_navigation.dart new file mode 100644 index 0000000..552ddcb --- /dev/null +++ b/lib/ui/widgets/swipe_navigation.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:simple_gesture_detector/simple_gesture_detector.dart'; + +import '../../core/enums/swipe_event.dart'; +import '../../core/services/swipe_service.dart'; +import '../../locator.dart'; + +class SwipeNavigation extends StatefulWidget { + /// Widget to be augmented with gesture detection. + final Widget child; + + /// Creates a [SwipeNavigation] widget. + const SwipeNavigation({ + Key key, + this.child, + }) : super(key: key); + + @override + _SwipeNavigationState createState() => _SwipeNavigationState(); +} + +class _SwipeNavigationState extends State { + final SwipeService _swipeService = locator(); + + void _onHorizontalSwipe(SwipeDirection direction) { + if (direction == SwipeDirection.left) { + _swipeService.addEvent(SwipeEvent.Left); + } else { + _swipeService.addEvent(SwipeEvent.Right); + } + } + + @override + Widget build(BuildContext context) { + return SimpleGestureDetector(onHorizontalSwipe: _onHorizontalSwipe, child: widget.child); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 7ef10d8..18a9c7e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,7 @@ description: A mobile client for FileBin. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.3.0+9 +version: 1.3.1+10 environment: sdk: ">=2.7.0 <3.0.0" @@ -39,6 +39,8 @@ dependencies: clipboard: 0.1.3 receive_sharing_intent: 1.4.5 permission_handler: 5.1.0+2 + package_info: 2.0.0 + simple_gesture_detector: 0.2.0 dev_dependencies: flutter_test: