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

This commit is contained in:
Varakh 2021-04-06 14:00:00 +02:00
parent 230de7fe40
commit 06eb990eea
22 changed files with 490 additions and 320 deletions

View file

@ -1,5 +1,11 @@
# CHANGELOG # 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 ## 1.3.0+9
* Allow API key login * Allow API key login
* Revamp profile view * Revamp profile view

View file

@ -94,6 +94,7 @@
} }
}, },
"about": { "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.", "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_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.", "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.",

View file

@ -5,6 +5,7 @@ import 'package:flutter_translate/localized_app.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'core/enums/refresh_event.dart'; import 'core/enums/refresh_event.dart';
import 'core/enums/swipe_event.dart';
import 'core/manager/dialog_manager.dart'; import 'core/manager/dialog_manager.dart';
import 'core/manager/lifecycle_manager.dart'; import 'core/manager/lifecycle_manager.dart';
import 'core/models/session.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/navigation_service.dart';
import 'core/services/refresh_service.dart'; import 'core/services/refresh_service.dart';
import 'core/services/session_service.dart'; import 'core/services/session_service.dart';
import 'core/services/swipe_service.dart';
import 'locator.dart'; import 'locator.dart';
import 'ui/app_router.dart'; import 'ui/app_router.dart';
import 'ui/shared/app_colors.dart'; import 'ui/shared/app_colors.dart';
@ -24,27 +26,33 @@ class MyApp extends StatelessWidget {
return LocalizationProvider( return LocalizationProvider(
state: LocalizationProvider.of(context).state, state: LocalizationProvider.of(context).state,
child: StreamProvider<RefreshEvent>( child: StreamProvider<SwipeEvent>(
initialData: null, initialData: null,
create: (context) => locator<RefreshService>().refreshHistoryController.stream, create: (context) => locator<SwipeService>().swipeEventController.stream,
child: StreamProvider<Session>( child: StreamProvider<RefreshEvent>(
initialData: Session.initial(), initialData: null,
create: (context) => locator<SessionService>().sessionController.stream, create: (context) => locator<RefreshService>().refreshEventController.stream,
child: LifeCycleManager( child: StreamProvider<Session>(
child: MaterialApp( initialData: Session.initial(),
title: translate('app.title'), create: (context) => locator<SessionService>().sessionController.stream,
builder: (context, child) => Navigator( child: LifeCycleManager(
key: locator<DialogService>().dialogNavigationKey, child: MaterialApp(
onGenerateRoute: (settings) => MaterialPageRoute(builder: (context) => DialogManager(child: child)), title: translate('app.title'),
), builder: (context, child) => Navigator(
theme: ThemeData( key: locator<DialogService>().dialogNavigationKey,
brightness: Brightness.light, primarySwatch: primaryAccentColor, primaryColor: primaryAccentColor), onGenerateRoute: (settings) =>
onGenerateRoute: AppRouter.generateRoute, MaterialPageRoute(builder: (context) => DialogManager(child: child)),
navigatorKey: locator<NavigationService>().navigationKey, ),
home: StartUpView(), theme: ThemeData(
supportedLocales: localizationDelegate.supportedLocales, brightness: Brightness.light,
locale: localizationDelegate.currentLocale, primarySwatch: primaryAccentColor,
)), primaryColor: primaryAccentColor),
))); onGenerateRoute: AppRouter.generateRoute,
navigatorKey: locator<NavigationService>().navigationKey,
home: StartUpView(),
supportedLocales: localizationDelegate.supportedLocales,
locale: localizationDelegate.currentLocale,
)),
))));
} }
} }

View file

@ -0,0 +1 @@
enum SwipeEvent { Left, Right }

View file

@ -3,11 +3,11 @@ import 'dart:async';
import '../enums/refresh_event.dart'; import '../enums/refresh_event.dart';
class RefreshService { class RefreshService {
StreamController<RefreshEvent> refreshHistoryController = StreamController<RefreshEvent>.broadcast(); StreamController<RefreshEvent> refreshEventController = StreamController<RefreshEvent>.broadcast();
void addEvent(RefreshEvent event) { void addEvent(RefreshEvent event) {
if (refreshHistoryController.hasListener) { if (refreshEventController.hasListener) {
refreshHistoryController.add(event); refreshEventController.add(event);
} }
} }
} }

View file

@ -0,0 +1,13 @@
import 'dart:async';
import '../enums/swipe_event.dart';
class SwipeService {
StreamController<SwipeEvent> swipeEventController = StreamController<SwipeEvent>.broadcast();
void addEvent(SwipeEvent event) {
if (swipeEventController.hasListener) {
swipeEventController.add(event);
}
}
}

View file

@ -1,10 +1,31 @@
import 'package:package_info/package_info.dart';
import '../../core/services/link_service.dart'; import '../../core/services/link_service.dart';
import '../../locator.dart'; import '../../locator.dart';
import '../enums/viewstate.dart';
import 'base_model.dart'; import 'base_model.dart';
class AboutModel extends BaseModel { class AboutModel extends BaseModel {
final LinkService _linkService = locator<LinkService>(); final LinkService _linkService = locator<LinkService>();
PackageInfo packageInfo = PackageInfo(
appName: 'Unknown',
packageName: 'Unknown',
version: 'Unknown',
buildNumber: 'Unknown',
);
void init() async {
await _initPackageInfo();
}
Future<void> _initPackageInfo() async {
setStateView(ViewState.Busy);
final PackageInfo info = await PackageInfo.fromPlatform();
packageInfo = info;
setStateView(ViewState.Idle);
}
void openLink(String link) { void openLink(String link) {
_linkService.open(link); _linkService.open(link);
} }

View file

@ -5,33 +5,48 @@ import '../../core/util/logger.dart';
import '../enums/viewstate.dart'; import '../enums/viewstate.dart';
class BaseModel extends ChangeNotifier { class BaseModel extends ChangeNotifier {
static const String STATE_VIEW = 'viewState';
static const String STATE_MESSAGE = 'viewMessage';
final Logger _logger = getLogger(); final Logger _logger = getLogger();
bool _isDisposed = false; bool _isDisposed = false;
ViewState _state = ViewState.Idle; Map<String, Object> _stateMap = {STATE_VIEW: ViewState.Idle, STATE_MESSAGE: null};
String _stateMessage;
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) { if (!_isDisposed) {
notifyListeners(); notifyListeners();
_logger.d("Notified state change '${viewState.toString()}'"); _logger.d("Notified state value update '($key, ${stateValue.toString()})'");
} }
} }
void setStateMessage(String stateMessage) { void removeStateValue(String key) {
_stateMessage = stateMessage; _stateMap.remove(key);
if (!_isDisposed) { if (!_isDisposed) {
notifyListeners(); 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 @override
void dispose() { void dispose() {
super.dispose(); super.dispose();

View file

@ -34,7 +34,7 @@ class HistoryModel extends BaseModel {
String errorMessage; String errorMessage;
void init() { void init() {
_refreshTriggerSubscription = _refreshService.refreshHistoryController.stream.listen((event) { _refreshTriggerSubscription = _refreshService.refreshEventController.stream.listen((event) {
if (event == RefreshEvent.RefreshHistory) { if (event == RefreshEvent.RefreshHistory) {
_logger.d('History needs a refresh'); _logger.d('History needs a refresh');
getHistory(); getHistory();
@ -43,7 +43,7 @@ class HistoryModel extends BaseModel {
} }
Future getHistory() async { Future getHistory() async {
setState(ViewState.Busy); setStateView(ViewState.Busy);
try { try {
pastes.clear(); pastes.clear();
@ -103,13 +103,13 @@ class HistoryModel extends BaseModel {
errorMessage = translate('api.socket_timeout'); errorMessage = translate('api.socket_timeout');
} else { } else {
errorMessage = translate('app.unknown_error'); errorMessage = translate('app.unknown_error');
setState(ViewState.Idle); setStateView(ViewState.Idle);
_logger.e('An unknown error occurred', e); _logger.e('An unknown error occurred', e);
throw e; throw e;
} }
} }
setState(ViewState.Idle); setStateView(ViewState.Idle);
} }
Future deletePaste(String id) async { Future deletePaste(String id) async {
@ -123,7 +123,7 @@ class HistoryModel extends BaseModel {
return; return;
} }
setState(ViewState.Busy); setStateView(ViewState.Busy);
try { try {
await _fileService.deletePaste(id); await _fileService.deletePaste(id);
@ -149,13 +149,13 @@ class HistoryModel extends BaseModel {
errorMessage = translate('api.socket_timeout'); errorMessage = translate('api.socket_timeout');
} else { } else {
errorMessage = translate('app.unknown_error'); errorMessage = translate('app.unknown_error');
setState(ViewState.Idle); setStateView(ViewState.Idle);
_logger.e('An unknown error occurred', e); _logger.e('An unknown error occurred', e);
throw e; throw e;
} }
} }
setState(ViewState.Idle); setStateView(ViewState.Idle);
} }
void openLink(String link) { void openLink(String link) {

View file

@ -43,23 +43,23 @@ class LoginModel extends BaseModel {
String errorMessage; String errorMessage;
void toggleLoginMethod() { void toggleLoginMethod() {
setState(ViewState.Busy); setStateView(ViewState.Busy);
useCredentialsLogin = !useCredentialsLogin; useCredentialsLogin = !useCredentialsLogin;
setState(ViewState.Idle); setStateView(ViewState.Idle);
} }
void init() async { void init() async {
bool hasLastUrl = await _storageService.hasLastUrl(); bool hasLastUrl = await _storageService.hasLastUrl();
if (hasLastUrl) { if (hasLastUrl) {
setState(ViewState.Busy); setStateView(ViewState.Busy);
var s = await _storageService.retrieveLastUrl(); var s = await _storageService.retrieveLastUrl();
if (s.isNotEmpty) { if (s.isNotEmpty) {
_uriController = new TextEditingController(text: s); _uriController = new TextEditingController(text: s);
} }
setState(ViewState.Idle); setStateView(ViewState.Idle);
} }
} }
@ -69,45 +69,45 @@ class LoginModel extends BaseModel {
var password = passwordController.text; var password = passwordController.text;
var apiKey = apiKeyController.text; var apiKey = apiKeyController.text;
setState(ViewState.Busy); setStateView(ViewState.Busy);
url = trim(url); url = trim(url);
username = trim(username); username = trim(username);
if (url.isEmpty) { if (url.isEmpty) {
errorMessage = translate('login.errors.empty_url'); errorMessage = translate('login.errors.empty_url');
setState(ViewState.Idle); setStateView(ViewState.Idle);
return false; return false;
} }
if (!url.contains("https://") && !url.contains("http://")) { if (!url.contains("https://") && !url.contains("http://")) {
errorMessage = translate('login.errors.no_protocol'); errorMessage = translate('login.errors.no_protocol');
setState(ViewState.Idle); setStateView(ViewState.Idle);
return false; return false;
} }
bool validUri = Uri.parse(url).isAbsolute; bool validUri = Uri.parse(url).isAbsolute;
if (!validUri || !isURL(url)) { if (!validUri || !isURL(url)) {
errorMessage = translate('login.errors.invalid_url'); errorMessage = translate('login.errors.invalid_url');
setState(ViewState.Idle); setStateView(ViewState.Idle);
return false; return false;
} }
if (useCredentialsLogin) { if (useCredentialsLogin) {
if (username.isEmpty) { if (username.isEmpty) {
errorMessage = translate('login.errors.empty_username'); errorMessage = translate('login.errors.empty_username');
setState(ViewState.Idle); setStateView(ViewState.Idle);
return false; return false;
} }
if (password.isEmpty) { if (password.isEmpty) {
errorMessage = translate('login.errors.empty_password'); errorMessage = translate('login.errors.empty_password');
setState(ViewState.Idle); setStateView(ViewState.Idle);
return false; return false;
} }
} else { } else {
if (apiKey.isEmpty) { if (apiKey.isEmpty) {
errorMessage = translate('login.errors.empty_apikey'); errorMessage = translate('login.errors.empty_apikey');
setState(ViewState.Idle); setStateView(ViewState.Idle);
return false; return false;
} }
} }
@ -147,7 +147,7 @@ class LoginModel extends BaseModel {
} else { } else {
errorMessage = translate('app.unknown_error'); errorMessage = translate('app.unknown_error');
_sessionService.logout(); _sessionService.logout();
setState(ViewState.Idle); setStateView(ViewState.Idle);
_logger.e('An unknown error occurred', e); _logger.e('An unknown error occurred', e);
throw e; throw e;
} }
@ -156,7 +156,7 @@ class LoginModel extends BaseModel {
_sessionService.logout(); _sessionService.logout();
} }
setState(ViewState.Idle); setStateView(ViewState.Idle);
return success; return success;
} }

View file

@ -67,7 +67,7 @@ class ProfileModel extends BaseModel {
} else { } else {
errorMessage = translate('app.unknown_error'); errorMessage = translate('app.unknown_error');
_sessionService.logout(); _sessionService.logout();
setState(ViewState.Idle); setStateView(ViewState.Idle);
_logger.e('An unknown error occurred', e); _logger.e('An unknown error occurred', e);
throw e; throw e;
} }

View file

@ -12,7 +12,7 @@ class StartUpViewModel extends BaseModel {
final NavigationService _navigationService = locator<NavigationService>(); final NavigationService _navigationService = locator<NavigationService>();
Future handleStartUpLogic() async { Future handleStartUpLogic() async {
setState(ViewState.Busy); setStateView(ViewState.Busy);
setStateMessage(translate('startup.init')); setStateMessage(translate('startup.init'));
await Future.delayed(Duration(milliseconds: 150)); await Future.delayed(Duration(milliseconds: 150));
@ -22,6 +22,6 @@ class StartUpViewModel extends BaseModel {
_navigationService.navigateAndReplaceTo(HomeView.routeName); _navigationService.navigateAndReplaceTo(HomeView.routeName);
setState(ViewState.Idle); setStateView(ViewState.Idle);
} }
} }

View file

@ -31,6 +31,8 @@ class UploadModel extends BaseModel {
final RefreshService _refreshService = locator<RefreshService>(); final RefreshService _refreshService = locator<RefreshService>();
TextEditingController _pasteTextController = TextEditingController(); TextEditingController _pasteTextController = TextEditingController();
bool pasteTextTouched = false;
StreamSubscription _intentDataStreamSubscription; StreamSubscription _intentDataStreamSubscription;
bool createMulti = false; bool createMulti = false;
@ -43,10 +45,15 @@ class UploadModel extends BaseModel {
TextEditingController get pasteTextController => _pasteTextController; TextEditingController get pasteTextController => _pasteTextController;
void init() { 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 // For sharing images coming from outside the app while the app is in the memory
_intentDataStreamSubscription = ReceiveSharingIntent.getMediaStream().listen((List<SharedMediaFile> value) { _intentDataStreamSubscription = ReceiveSharingIntent.getMediaStream().listen((List<SharedMediaFile> value) {
if (value != null && value.length > 0) { if (value != null && value.length > 0) {
setState(ViewState.Busy); setStateView(ViewState.Busy);
paths = value.map((sharedFile) { paths = value.map((sharedFile) {
return PlatformFile.fromMap({ return PlatformFile.fromMap({
'path': sharedFile.path, 'path': sharedFile.path,
@ -55,19 +62,19 @@ class UploadModel extends BaseModel {
'bytes': null 'bytes': null
}); });
}).toList(); }).toList();
setState(ViewState.Idle); setStateView(ViewState.Idle);
} }
}, onError: (err) { }, onError: (err) {
setState(ViewState.Busy); setStateView(ViewState.Busy);
errorMessage = translate('upload.retrieval_intent'); errorMessage = translate('upload.retrieval_intent');
_logger.e('Error while retrieving shared data: $err'); _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 // For sharing images coming from outside the app while the app is closed
ReceiveSharingIntent.getInitialMedia().then((List<SharedMediaFile> value) { ReceiveSharingIntent.getInitialMedia().then((List<SharedMediaFile> value) {
if (value != null && value.length > 0) { if (value != null && value.length > 0) {
setState(ViewState.Busy); setStateView(ViewState.Busy);
paths = value.map((sharedFile) { paths = value.map((sharedFile) {
return PlatformFile.fromMap({ return PlatformFile.fromMap({
'path': sharedFile.path, 'path': sharedFile.path,
@ -76,42 +83,42 @@ class UploadModel extends BaseModel {
'bytes': null 'bytes': null
}); });
}).toList(); }).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 // For sharing or opening urls/text coming from outside the app while the app is in the memory
_intentDataStreamSubscription = ReceiveSharingIntent.getTextStream().listen((String value) { _intentDataStreamSubscription = ReceiveSharingIntent.getTextStream().listen((String value) {
if (value != null && value.isNotEmpty) { if (value != null && value.isNotEmpty) {
setState(ViewState.Busy); setStateView(ViewState.Busy);
pasteTextController.text = value; pasteTextController.text = value;
setState(ViewState.Idle); setStateView(ViewState.Idle);
} }
}, onError: (err) { }, onError: (err) {
setState(ViewState.Busy); setStateView(ViewState.Busy);
errorMessage = translate('upload.retrieval_intent'); errorMessage = translate('upload.retrieval_intent');
_logger.e('Error while retrieving shared data: $err'); _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 // For sharing or opening urls/text coming from outside the app while the app is closed
ReceiveSharingIntent.getInitialText().then((String value) { ReceiveSharingIntent.getInitialText().then((String value) {
if (value != null && value.isNotEmpty) { if (value != null && value.isNotEmpty) {
setState(ViewState.Busy); setStateView(ViewState.Busy);
pasteTextController.text = value; pasteTextController.text = value;
setState(ViewState.Idle); setStateView(ViewState.Idle);
} }
}); });
} }
void toggleCreateMulti() { void toggleCreateMulti() {
setState(ViewState.Busy); setStateView(ViewState.Busy);
createMulti = !createMulti; createMulti = !createMulti;
setState(ViewState.Idle); setStateView(ViewState.Idle);
} }
void openFileExplorer() async { void openFileExplorer() async {
setState(ViewState.Busy); setStateView(ViewState.Busy);
setStateMessage(translate('upload.file_explorer_open')); setStateMessage(translate('upload.file_explorer_open'));
loadingPath = true; loadingPath = true;
@ -134,20 +141,20 @@ class UploadModel extends BaseModel {
fileName = paths != null ? paths.map((e) => e.name).toString() : '...'; fileName = paths != null ? paths.map((e) => e.name).toString() : '...';
setStateMessage(null); setStateMessage(null);
setState(ViewState.Idle); setStateView(ViewState.Idle);
} }
void clearCachedFiles() async { void clearCachedFiles() async {
setState(ViewState.Busy); setStateView(ViewState.Busy);
await FilePicker.platform.clearTemporaryFiles(); await FilePicker.platform.clearTemporaryFiles();
paths = null; paths = null;
fileName = null; fileName = null;
errorMessage = null; errorMessage = null;
setState(ViewState.Idle); setStateView(ViewState.Idle);
} }
Future<Map<String, bool>> upload() async { Future<Map<String, bool>> upload() async {
setState(ViewState.Busy); setStateView(ViewState.Busy);
setStateMessage(translate('upload.uploading_now')); setStateMessage(translate('upload.uploading_now'));
Map<String, bool> uploadedPasteIds = new Map(); Map<String, bool> uploadedPasteIds = new Map();
@ -204,14 +211,14 @@ class UploadModel extends BaseModel {
} else { } else {
errorMessage = translate('app.unknown_error'); errorMessage = translate('app.unknown_error');
setStateMessage(null); setStateMessage(null);
setState(ViewState.Idle); setStateView(ViewState.Idle);
_logger.e('An unknown error occurred', e); _logger.e('An unknown error occurred', e);
throw e; throw e;
} }
} }
setStateMessage(null); setStateMessage(null);
setState(ViewState.Idle); setStateView(ViewState.Idle);
return null; return null;
} }

View file

@ -11,6 +11,7 @@ import 'core/services/permission_service.dart';
import 'core/services/refresh_service.dart'; import 'core/services/refresh_service.dart';
import 'core/services/session_service.dart'; import 'core/services/session_service.dart';
import 'core/services/storage_service.dart'; import 'core/services/storage_service.dart';
import 'core/services/swipe_service.dart';
import 'core/services/user_service.dart'; import 'core/services/user_service.dart';
import 'core/viewmodels/about_model.dart'; import 'core/viewmodels/about_model.dart';
import 'core/viewmodels/history_model.dart'; import 'core/viewmodels/history_model.dart';
@ -41,6 +42,7 @@ void setupLocator() {
locator.registerLazySingleton(() => LinkService()); locator.registerLazySingleton(() => LinkService());
locator.registerLazySingleton(() => PermissionService()); locator.registerLazySingleton(() => PermissionService());
locator.registerLazySingleton(() => RefreshService()); locator.registerLazySingleton(() => RefreshService());
locator.registerLazySingleton(() => SwipeService());
/// view models /// view models
locator.registerFactory(() => StartUpViewModel()); locator.registerFactory(() => StartUpViewModel());

View file

@ -25,6 +25,7 @@ class AboutView extends StatelessWidget {
); );
return BaseView<AboutModel>( return BaseView<AboutModel>(
onModelReady: (model) => model.init(),
builder: (context, model, child) => Scaffold( builder: (context, model, child) => Scaffold(
appBar: MyAppBar( appBar: MyAppBar(
title: Text(translate('titles.about')), title: Text(translate('titles.about')),
@ -44,12 +45,22 @@ class AboutView extends StatelessWidget {
UIHelper.verticalSpaceMedium(), UIHelper.verticalSpaceMedium(),
Center( Center(
child: Text( 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(), UIHelper.verticalSpaceMedium(),
Center( Center(
child: Text( child: Text(
translate(('about.faq_headline')), translate('about.description'),
)),
UIHelper.verticalSpaceMedium(),
Center(
child: Text(
translate('about.faq_headline'),
style: subHeaderStyle, style: subHeaderStyle,
)), )),
Center( Center(
@ -59,7 +70,7 @@ class AboutView extends StatelessWidget {
UIHelper.verticalSpaceMedium(), UIHelper.verticalSpaceMedium(),
Center( Center(
child: Text( child: Text(
translate(('about.contact_us')), translate('about.contact_us'),
style: subHeaderStyle, style: subHeaderStyle,
)), )),
UIHelper.verticalSpaceSmall(), UIHelper.verticalSpaceSmall(),

View file

@ -12,6 +12,7 @@ import '../../core/viewmodels/history_model.dart';
import '../../ui/widgets/centered_error_row.dart'; import '../../ui/widgets/centered_error_row.dart';
import '../shared/app_colors.dart'; import '../shared/app_colors.dart';
import '../widgets/my_appbar.dart'; import '../widgets/my_appbar.dart';
import '../widgets/swipe_navigation.dart';
import 'base_view.dart'; import 'base_view.dart';
class HistoryView extends StatelessWidget { class HistoryView extends StatelessWidget {
@ -19,8 +20,6 @@ class HistoryView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var url = Provider.of<Session>(context).url;
return BaseView<HistoryModel>( return BaseView<HistoryModel>(
onModelReady: (model) { onModelReady: (model) {
model.init(); model.init();
@ -29,22 +28,28 @@ class HistoryView extends StatelessWidget {
builder: (context, model, child) => Scaffold( builder: (context, model, child) => Scaffold(
appBar: MyAppBar(title: Text(translate('titles.history'))), appBar: MyAppBar(title: Text(translate('titles.history'))),
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
body: model.state == ViewState.Busy body: SwipeNavigation(child: _render(model, context))),
? 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(),
)))),
); );
} }
Widget _render(HistoryModel model, String url, BuildContext context) { Widget _render(HistoryModel model, BuildContext context) {
var url = Provider.of<Session>(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<Widget> cards = []; List<Widget> cards = [];
if (model.pastes.length > 0) { if (model.pastes.length > 0) {

View file

@ -10,6 +10,7 @@ import '../shared/app_colors.dart';
import '../shared/text_styles.dart'; import '../shared/text_styles.dart';
import '../shared/ui_helpers.dart'; import '../shared/ui_helpers.dart';
import '../widgets/my_appbar.dart'; import '../widgets/my_appbar.dart';
import '../widgets/swipe_navigation.dart';
import 'base_view.dart'; import 'base_view.dart';
class ProfileView extends StatelessWidget { class ProfileView extends StatelessWidget {
@ -17,71 +18,75 @@ class ProfileView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var url = Provider.of<Session>(context).url;
var apiKey = Provider.of<Session>(context).apiKey;
return BaseView<ProfileModel>( return BaseView<ProfileModel>(
builder: (context, model, child) => Scaffold( builder: (context, model, child) => Scaffold(
appBar: MyAppBar(title: Text(translate('titles.profile'))), appBar: MyAppBar(title: Text(translate('titles.profile'))),
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
body: model.state == ViewState.Busy body: SwipeNavigation(child: _render(model, context))));
? Center(child: CircularProgressIndicator()) }
: ListView(
children: <Widget>[ Widget _render(ProfileModel model, BuildContext context) {
UIHelper.verticalSpaceMedium(), var url = Provider.of<Session>(context).url;
Padding( var apiKey = Provider.of<Session>(context).apiKey;
padding: const EdgeInsets.only(left: 25.0),
child: Center( return model.state == ViewState.Busy
child: Text( ? Center(child: CircularProgressIndicator())
translate('profile.instance'), : ListView(
style: subHeaderStyle, children: <Widget>[
))), UIHelper.verticalSpaceMedium(),
UIHelper.verticalSpaceMedium(), Padding(
Padding( padding: const EdgeInsets.only(left: 25.0),
padding: const EdgeInsets.only(left: 25.0), child: Center(
child: Center( child: Text(
child: Linkify( translate('profile.instance'),
onOpen: (link) => model.openLink(link.url), style: subHeaderStyle,
text: translate('profile.connection', args: {'url': url}), ))),
options: LinkifyOptions(humanize: false), UIHelper.verticalSpaceMedium(),
))), Padding(
UIHelper.verticalSpaceMedium(), padding: const EdgeInsets.only(left: 25.0),
Padding( child: Center(
padding: const EdgeInsets.only(left: 25.0, right: 25.0), child: Linkify(
child: ElevatedButton.icon( onOpen: (link) => model.openLink(link.url),
icon: Icon(Icons.settings, color: blueColor), text: translate('profile.connection', args: {'url': url}),
label: Text( options: LinkifyOptions(humanize: false),
translate('profile.show_config'), ))),
style: TextStyle(color: buttonForegroundColor), UIHelper.verticalSpaceMedium(),
), Padding(
onPressed: () { padding: const EdgeInsets.only(left: 25.0, right: 25.0),
return model.showConfig(url); child: ElevatedButton.icon(
})), icon: Icon(Icons.settings, color: blueColor),
UIHelper.verticalSpaceMedium(), label: Text(
Padding( translate('profile.show_config'),
padding: const EdgeInsets.only(left: 25.0, right: 25.0), style: TextStyle(color: buttonForegroundColor),
child: ElevatedButton.icon( ),
icon: Icon(Icons.lock, color: orangeColor), onPressed: () {
label: Text( return model.showConfig(url);
translate('profile.reveal_api_key'), })),
style: TextStyle(color: buttonForegroundColor), UIHelper.verticalSpaceMedium(),
), Padding(
onPressed: () { padding: const EdgeInsets.only(left: 25.0, right: 25.0),
return model.revealApiKey(apiKey); child: ElevatedButton.icon(
})), icon: Icon(Icons.lock, color: orangeColor),
UIHelper.verticalSpaceMedium(), label: Text(
Padding( translate('profile.reveal_api_key'),
padding: const EdgeInsets.only(left: 25.0, right: 25.0), style: TextStyle(color: buttonForegroundColor),
child: ElevatedButton.icon( ),
icon: Icon(Icons.exit_to_app, color: redColor), onPressed: () {
label: Text( return model.revealApiKey(apiKey);
translate('profile.logout'), })),
style: TextStyle(color: buttonForegroundColor), UIHelper.verticalSpaceMedium(),
), Padding(
onPressed: () { padding: const EdgeInsets.only(left: 25.0, right: 25.0),
return model.logout(); child: ElevatedButton.icon(
})), icon: Icon(Icons.exit_to_app, color: redColor),
], label: Text(
))); translate('profile.logout'),
style: TextStyle(color: buttonForegroundColor),
),
onPressed: () {
return model.logout();
})),
],
);
} }
} }

View file

@ -1,7 +1,15 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_translate/flutter_translate.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 '../shared/app_colors.dart';
import 'history_view.dart'; import 'history_view.dart';
import 'profile_view.dart'; import 'profile_view.dart';
@ -13,6 +21,10 @@ class AuthenticatedTabBarView extends StatefulWidget {
} }
class AuthenticatedTabBarState extends State<AuthenticatedTabBarView> with SingleTickerProviderStateMixin { class AuthenticatedTabBarState extends State<AuthenticatedTabBarView> with SingleTickerProviderStateMixin {
final Logger _logger = getLogger();
final SwipeService _swipeService = locator<SwipeService>();
StreamSubscription _swipeEventSubscription;
TabController _tabController; TabController _tabController;
int _currentTabIndex = 0; int _currentTabIndex = 0;
@ -38,11 +50,28 @@ class AuthenticatedTabBarState extends State<AuthenticatedTabBarView> with Singl
setState(() => _currentTabIndex = selectedIndex); 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 @override
void dispose() { void dispose() {
_tabController.dispose(); _tabController.dispose();
_swipeEventSubscription.cancel();
super.dispose(); super.dispose();
} }

View file

@ -9,6 +9,7 @@ import '../../core/viewmodels/upload_model.dart';
import '../shared/app_colors.dart'; import '../shared/app_colors.dart';
import '../widgets/centered_error_row.dart'; import '../widgets/centered_error_row.dart';
import '../widgets/my_appbar.dart'; import '../widgets/my_appbar.dart';
import '../widgets/swipe_navigation.dart';
import 'base_view.dart'; import 'base_view.dart';
class UploadView extends StatelessWidget { class UploadView extends StatelessWidget {
@ -16,173 +17,180 @@ class UploadView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var url = Provider.of<Session>(context).url;
return BaseView<UploadModel>( return BaseView<UploadModel>(
onModelReady: (model) => model.init(), onModelReady: (model) => model.init(),
builder: (context, model, child) => Scaffold( builder: (context, model, child) => Scaffold(
appBar: MyAppBar(title: Text(translate('titles.upload'))), appBar: MyAppBar(title: Text(translate('titles.upload'))),
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
body: model.state == ViewState.Busy body: SwipeNavigation(child: _render(model, context))));
? Center( }
child: Column(
mainAxisAlignment: MainAxisAlignment.center, bool _isUploadButtonEnabled(UploadModel model) {
crossAxisAlignment: CrossAxisAlignment.center, return model.pasteTextTouched || (model.paths != null && model.paths.length > 0);
children: [ }
CircularProgressIndicator(),
(model.stateMessage != null && model.stateMessage.isNotEmpty Widget _render(UploadModel model, BuildContext context) {
? Text(model.stateMessage) var url = Provider.of<Session>(context).url;
: Container())
])) return model.state == ViewState.Busy
: ListView(children: <Widget>[ ? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CircularProgressIndicator(),
(model.stateMessage != null && model.stateMessage.isNotEmpty ? Text(model.stateMessage) : Container())
]))
: ListView(children: <Widget>[
Padding(
padding: const EdgeInsets.only(left: 25.0, right: 25.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Padding( Padding(
padding: const EdgeInsets.only(left: 25.0, right: 25.0), padding: const EdgeInsets.only(top: 10.0, bottom: 10.0),
child: Column( child: TextFormField(
mainAxisAlignment: MainAxisAlignment.center, minLines: 1,
children: <Widget>[ maxLines: 7,
Padding( decoration: InputDecoration(
padding: const EdgeInsets.only(top: 10.0, bottom: 10.0), prefixIcon: Icon(
child: TextFormField( Icons.text_snippet,
minLines: 1, color: buttonBackgroundColor,
maxLines: 7, ),
decoration: InputDecoration( suffixIcon: IconButton(
prefixIcon: Icon( onPressed: () => model.pasteTextController.clear(),
Icons.text_snippet, icon: Icon(Icons.clear),
color: buttonBackgroundColor, ),
), hintText: translate('upload.text_to_be_pasted'),
suffixIcon: IconButton( contentPadding: EdgeInsets.fromLTRB(20.0, 10.0, 20.0, 10.0),
onPressed: () => model.pasteTextController.clear(), border: OutlineInputBorder(borderRadius: BorderRadius.circular(32.0)),
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<String, bool> 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(),
), ),
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<String, bool> 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(),
),
],
))
]);
} }
} }

View file

@ -1,4 +1,3 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../shared/app_colors.dart'; import '../shared/app_colors.dart';

View file

@ -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<SwipeNavigation> {
final SwipeService _swipeService = locator<SwipeService>();
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);
}
}

View file

@ -11,7 +11,7 @@ description: A mobile client for FileBin.
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at # Read more about iOS versioning at
# 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
version: 1.3.0+9 version: 1.3.1+10
environment: environment:
sdk: ">=2.7.0 <3.0.0" sdk: ">=2.7.0 <3.0.0"
@ -39,6 +39,8 @@ dependencies:
clipboard: 0.1.3 clipboard: 0.1.3
receive_sharing_intent: 1.4.5 receive_sharing_intent: 1.4.5
permission_handler: 5.1.0+2 permission_handler: 5.1.0+2
package_info: 2.0.0
simple_gesture_detector: 0.2.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: