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
## 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

View file

@ -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.",

View file

@ -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<RefreshEvent>(
child: StreamProvider<SwipeEvent>(
initialData: null,
create: (context) => locator<RefreshService>().refreshHistoryController.stream,
child: StreamProvider<Session>(
initialData: Session.initial(),
create: (context) => locator<SessionService>().sessionController.stream,
child: LifeCycleManager(
child: MaterialApp(
title: translate('app.title'),
builder: (context, child) => Navigator(
key: locator<DialogService>().dialogNavigationKey,
onGenerateRoute: (settings) => MaterialPageRoute(builder: (context) => DialogManager(child: child)),
),
theme: ThemeData(
brightness: Brightness.light, primarySwatch: primaryAccentColor, primaryColor: primaryAccentColor),
onGenerateRoute: AppRouter.generateRoute,
navigatorKey: locator<NavigationService>().navigationKey,
home: StartUpView(),
supportedLocales: localizationDelegate.supportedLocales,
locale: localizationDelegate.currentLocale,
)),
)));
create: (context) => locator<SwipeService>().swipeEventController.stream,
child: StreamProvider<RefreshEvent>(
initialData: null,
create: (context) => locator<RefreshService>().refreshEventController.stream,
child: StreamProvider<Session>(
initialData: Session.initial(),
create: (context) => locator<SessionService>().sessionController.stream,
child: LifeCycleManager(
child: MaterialApp(
title: translate('app.title'),
builder: (context, child) => Navigator(
key: locator<DialogService>().dialogNavigationKey,
onGenerateRoute: (settings) =>
MaterialPageRoute(builder: (context) => DialogManager(child: child)),
),
theme: ThemeData(
brightness: Brightness.light,
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';
class RefreshService {
StreamController<RefreshEvent> refreshHistoryController = StreamController<RefreshEvent>.broadcast();
StreamController<RefreshEvent> refreshEventController = StreamController<RefreshEvent>.broadcast();
void addEvent(RefreshEvent event) {
if (refreshHistoryController.hasListener) {
refreshHistoryController.add(event);
if (refreshEventController.hasListener) {
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 '../../locator.dart';
import '../enums/viewstate.dart';
import 'base_model.dart';
class AboutModel extends BaseModel {
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) {
_linkService.open(link);
}

View file

@ -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<String, Object> _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();

View file

@ -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) {

View file

@ -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;
}

View file

@ -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;
}

View file

@ -12,7 +12,7 @@ class StartUpViewModel extends BaseModel {
final NavigationService _navigationService = locator<NavigationService>();
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);
}
}

View file

@ -31,6 +31,8 @@ class UploadModel extends BaseModel {
final RefreshService _refreshService = locator<RefreshService>();
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<SharedMediaFile> 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<SharedMediaFile> 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<Map<String, bool>> upload() async {
setState(ViewState.Busy);
setStateView(ViewState.Busy);
setStateMessage(translate('upload.uploading_now'));
Map<String, bool> 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;
}

View file

@ -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());

View file

@ -25,6 +25,7 @@ class AboutView extends StatelessWidget {
);
return BaseView<AboutModel>(
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(),

View file

@ -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<Session>(context).url;
return BaseView<HistoryModel>(
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<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 = [];
if (model.pastes.length > 0) {

View file

@ -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<Session>(context).url;
var apiKey = Provider.of<Session>(context).apiKey;
return BaseView<ProfileModel>(
builder: (context, model, child) => Scaffold(
appBar: MyAppBar(title: Text(translate('titles.profile'))),
backgroundColor: backgroundColor,
body: model.state == ViewState.Busy
? Center(child: CircularProgressIndicator())
: ListView(
children: <Widget>[
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<Session>(context).url;
var apiKey = Provider.of<Session>(context).apiKey;
return model.state == ViewState.Busy
? Center(child: CircularProgressIndicator())
: ListView(
children: <Widget>[
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();
})),
],
);
}
}

View file

@ -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<AuthenticatedTabBarView> with SingleTickerProviderStateMixin {
final Logger _logger = getLogger();
final SwipeService _swipeService = locator<SwipeService>();
StreamSubscription _swipeEventSubscription;
TabController _tabController;
int _currentTabIndex = 0;
@ -38,11 +50,28 @@ class AuthenticatedTabBarState extends State<AuthenticatedTabBarView> 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();
}

View file

@ -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<Session>(context).url;
return BaseView<UploadModel>(
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: <Widget>[
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<Session>(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: <Widget>[
Padding(
padding: const EdgeInsets.only(left: 25.0, right: 25.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Padding(
padding: const EdgeInsets.only(left: 25.0, right: 25.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
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<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(),
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<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 '../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.
# 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: