mirror of
https://github.com/GitJournal/GitJournal.git
synced 2025-09-16 00:14:08 +08:00

It's strange, but this widget routinely gets constructed twice. So performing a long async operation (generating the ssh key + adding it as a deploy key) from initState, results in it being called twice. I'm not sure where the state is initialized twice. Even adding a unique key to the widget did not help. In the end, I've just moved the code to its parent widget.
695 lines
19 KiB
Dart
695 lines
19 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:dots_indicator/dots_indicator.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import 'package:function_types/function_types.dart';
|
|
import 'package:git_bindings/git_bindings.dart';
|
|
|
|
import 'package:gitjournal/analytics.dart';
|
|
import 'package:gitjournal/apis/githost_factory.dart';
|
|
import 'package:gitjournal/error_reporting.dart';
|
|
import 'package:gitjournal/setup/repo_selector.dart';
|
|
import 'package:gitjournal/state_container.dart';
|
|
import 'package:gitjournal/utils.dart';
|
|
import 'package:gitjournal/settings.dart';
|
|
import 'package:gitjournal/utils/logger.dart';
|
|
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:provider/provider.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
import 'autoconfigure.dart';
|
|
import 'button.dart';
|
|
import 'clone_url.dart';
|
|
import 'loading_error.dart';
|
|
import 'sshkey.dart';
|
|
|
|
class GitHostSetupScreen extends StatefulWidget {
|
|
final Func0<void> onCompletedFunction;
|
|
|
|
GitHostSetupScreen(this.onCompletedFunction);
|
|
|
|
@override
|
|
GitHostSetupScreenState createState() {
|
|
return GitHostSetupScreenState();
|
|
}
|
|
}
|
|
|
|
enum PageChoice0 { Unknown, KnownProvider, CustomProvider }
|
|
enum PageChoice1 { Unknown, Manual, Auto }
|
|
enum KeyGenerationChoice { Unknown, AutoGenerated, UserProvided }
|
|
|
|
class GitHostSetupScreenState extends State<GitHostSetupScreen> {
|
|
var _pageCount = 1;
|
|
|
|
var _pageChoice = [
|
|
PageChoice0.Unknown,
|
|
PageChoice1.Unknown,
|
|
];
|
|
var _keyGenerationChoice = KeyGenerationChoice.Unknown;
|
|
|
|
var _gitHostType = GitHostType.Unknown;
|
|
GitHost _gitHost;
|
|
GitHostRepo _gitHostRepo;
|
|
String _autoConfigureMessage = "";
|
|
String _autoConfigureErrorMessage = "";
|
|
|
|
var _gitCloneUrl = "";
|
|
var gitCloneErrorMessage = "";
|
|
var publicKey = "";
|
|
|
|
var pageController = PageController();
|
|
int _currentPageIndex = 0;
|
|
|
|
Widget _buildPage(BuildContext context, int pos) {
|
|
assert(_pageCount >= 1);
|
|
|
|
if (pos == 0) {
|
|
return GitHostChoicePage(
|
|
onKnownGitHost: (GitHostType gitHostType) {
|
|
setState(() {
|
|
_gitHostType = gitHostType;
|
|
_pageChoice[0] = PageChoice0.KnownProvider;
|
|
_pageCount = pos + 2;
|
|
_nextPage();
|
|
});
|
|
},
|
|
onCustomGitHost: () {
|
|
setState(() {
|
|
_pageChoice[0] = PageChoice0.CustomProvider;
|
|
_pageCount = pos + 2;
|
|
_nextPage();
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
if (pos == 1) {
|
|
assert(_pageChoice[0] != PageChoice0.Unknown);
|
|
|
|
if (_pageChoice[0] == PageChoice0.CustomProvider) {
|
|
return GitCloneUrlPage(
|
|
doneFunction: (String sshUrl) {
|
|
setState(() {
|
|
_gitCloneUrl = sshUrl;
|
|
|
|
_pageCount = pos + 2;
|
|
_nextPage();
|
|
});
|
|
},
|
|
initialValue: _gitCloneUrl,
|
|
);
|
|
}
|
|
|
|
return GitHostAutoConfigureChoicePage(
|
|
onDone: (GitHostSetupType setupType) {
|
|
if (setupType == GitHostSetupType.Manual) {
|
|
setState(() {
|
|
_pageCount = pos + 2;
|
|
_pageChoice[1] = PageChoice1.Manual;
|
|
_nextPage();
|
|
});
|
|
} else if (setupType == GitHostSetupType.Auto) {
|
|
setState(() {
|
|
_pageCount = pos + 2;
|
|
_pageChoice[1] = PageChoice1.Auto;
|
|
_nextPage();
|
|
});
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
if (pos == 2) {
|
|
if (_pageChoice[0] == PageChoice0.CustomProvider) {
|
|
return GitHostSetupKeyChoice(
|
|
onGenerateKeys: () {
|
|
setState(() {
|
|
_keyGenerationChoice = KeyGenerationChoice.AutoGenerated;
|
|
_pageCount = pos + 2;
|
|
|
|
_nextPage();
|
|
_generateSshKey(context);
|
|
});
|
|
},
|
|
onUserProvidedKeys: () {
|
|
setState(() {
|
|
_keyGenerationChoice = KeyGenerationChoice.UserProvided;
|
|
_pageCount = pos + 2;
|
|
_nextPage();
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
assert(_pageChoice[1] != PageChoice1.Unknown);
|
|
|
|
if (_pageChoice[1] == PageChoice1.Manual) {
|
|
return GitCloneUrlKnownProviderPage(
|
|
doneFunction: (String sshUrl) {
|
|
setState(() {
|
|
_pageCount = pos + 2;
|
|
_gitCloneUrl = sshUrl;
|
|
|
|
_nextPage();
|
|
});
|
|
},
|
|
launchCreateUrlPage: _launchCreateRepoPage,
|
|
gitHostType: _gitHostType,
|
|
initialValue: _gitCloneUrl,
|
|
);
|
|
} else if (_pageChoice[1] == PageChoice1.Auto) {
|
|
return GitHostSetupAutoConfigure(
|
|
gitHostType: _gitHostType,
|
|
onDone: (GitHost gitHost) {
|
|
setState(() {
|
|
_gitHost = gitHost;
|
|
_pageCount = pos + 2;
|
|
|
|
_nextPage();
|
|
});
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
if (pos == 3) {
|
|
if (_pageChoice[0] == PageChoice0.CustomProvider) {
|
|
assert(_keyGenerationChoice != KeyGenerationChoice.Unknown);
|
|
if (_keyGenerationChoice == KeyGenerationChoice.AutoGenerated) {
|
|
return GitHostSetupSshKeyUnknownProvider(
|
|
doneFunction: () {
|
|
setState(() {
|
|
_pageCount = pos + 2;
|
|
_nextPage();
|
|
_startGitClone(context);
|
|
});
|
|
},
|
|
regenerateFunction: () {
|
|
setState(() {
|
|
publicKey = "";
|
|
});
|
|
_generateSshKey(context);
|
|
},
|
|
publicKey: publicKey,
|
|
copyKeyFunction: _copyKeyToClipboard,
|
|
);
|
|
} else if (_keyGenerationChoice == KeyGenerationChoice.UserProvided) {
|
|
return GitHostUserProvidedKeys(
|
|
doneFunction: (String publicKey, String privateKey) async {
|
|
await setSshKeys(publicKey: publicKey, privateKey: privateKey);
|
|
setState(() {
|
|
this.publicKey = publicKey;
|
|
_pageCount = pos + 2;
|
|
_nextPage();
|
|
_startGitClone(context);
|
|
});
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
if (_pageChoice[1] == PageChoice1.Manual) {
|
|
return GitHostSetupKeyChoice(
|
|
onGenerateKeys: () {
|
|
setState(() {
|
|
_keyGenerationChoice = KeyGenerationChoice.AutoGenerated;
|
|
_pageCount = pos + 2;
|
|
|
|
_nextPage();
|
|
_generateSshKey(context);
|
|
});
|
|
},
|
|
onUserProvidedKeys: () {
|
|
setState(() {
|
|
_keyGenerationChoice = KeyGenerationChoice.UserProvided;
|
|
_pageCount = pos + 2;
|
|
_nextPage();
|
|
});
|
|
},
|
|
);
|
|
} else if (_pageChoice[1] == PageChoice1.Auto) {
|
|
return GitHostSetupRepoSelector(
|
|
gitHost: _gitHost,
|
|
onDone: (GitHostRepo repo) {
|
|
setState(() {
|
|
_gitHostRepo = repo;
|
|
_pageCount = pos + 2;
|
|
_nextPage();
|
|
_completeAutoConfigure();
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
assert(false);
|
|
}
|
|
|
|
if (pos == 4) {
|
|
if (_pageChoice[0] == PageChoice0.CustomProvider) {
|
|
return GitHostSetupLoadingErrorPage(
|
|
loadingMessage: "Cloning ...",
|
|
errorMessage: gitCloneErrorMessage,
|
|
);
|
|
}
|
|
|
|
if (_pageChoice[1] == PageChoice1.Manual) {
|
|
assert(_keyGenerationChoice != KeyGenerationChoice.Unknown);
|
|
if (_keyGenerationChoice == KeyGenerationChoice.AutoGenerated) {
|
|
return GitHostSetupSshKeyKnownProvider(
|
|
doneFunction: () {
|
|
setState(() {
|
|
_pageCount = 6;
|
|
|
|
_nextPage();
|
|
_startGitClone(context);
|
|
});
|
|
},
|
|
regenerateFunction: () {
|
|
setState(() {
|
|
publicKey = "";
|
|
});
|
|
_generateSshKey(context);
|
|
},
|
|
publicKey: publicKey,
|
|
copyKeyFunction: _copyKeyToClipboard,
|
|
openDeployKeyPage: _launchDeployKeyPage,
|
|
);
|
|
} else if (_keyGenerationChoice == KeyGenerationChoice.UserProvided) {
|
|
return GitHostUserProvidedKeys(
|
|
doneFunction: (String publicKey, String privateKey) async {
|
|
await setSshKeys(publicKey: publicKey, privateKey: privateKey);
|
|
setState(() {
|
|
this.publicKey = publicKey;
|
|
_pageCount = pos + 2;
|
|
_nextPage();
|
|
_startGitClone(context);
|
|
});
|
|
},
|
|
);
|
|
}
|
|
} else if (_pageChoice[1] == PageChoice1.Auto) {
|
|
return GitHostSetupLoadingErrorPage(
|
|
loadingMessage: _autoConfigureMessage,
|
|
errorMessage: _autoConfigureErrorMessage,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (pos == 5) {
|
|
return GitHostSetupLoadingErrorPage(
|
|
loadingMessage: "Cloning ...",
|
|
errorMessage: gitCloneErrorMessage,
|
|
);
|
|
}
|
|
|
|
assert(_pageChoice[0] != PageChoice0.CustomProvider);
|
|
|
|
assert(false, "Pos is $pos");
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
var pageView = PageView.builder(
|
|
controller: pageController,
|
|
itemBuilder: _buildPage,
|
|
itemCount: _pageCount,
|
|
onPageChanged: (int pageNum) {
|
|
setState(() {
|
|
_currentPageIndex = pageNum;
|
|
_pageCount = _currentPageIndex + 1;
|
|
});
|
|
},
|
|
);
|
|
|
|
var body = Container(
|
|
width: double.infinity,
|
|
height: double.infinity,
|
|
child: Stack(
|
|
alignment: FractionalOffset.bottomCenter,
|
|
children: <Widget>[
|
|
pageView,
|
|
DotsIndicator(
|
|
dotsCount: _pageCount,
|
|
position: _currentPageIndex,
|
|
decorator: DotsDecorator(
|
|
activeColor: Theme.of(context).primaryColorDark,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
padding: const EdgeInsets.all(16.0),
|
|
);
|
|
|
|
var scaffold = Scaffold(
|
|
body: Stack(
|
|
children: <Widget>[
|
|
body,
|
|
if (Platform.isIOS)
|
|
SafeArea(
|
|
child: InkWell(
|
|
child: Container(
|
|
child: const Icon(Icons.arrow_back, size: 32.0),
|
|
padding: const EdgeInsets.all(8.0),
|
|
),
|
|
onTap: () => Navigator.of(context).pop(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
return WillPopScope(
|
|
onWillPop: () async {
|
|
if (_currentPageIndex != 0) {
|
|
pageController.previousPage(
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.easeIn,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
child: scaffold,
|
|
);
|
|
}
|
|
|
|
void _nextPage() {
|
|
pageController.nextPage(
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.easeIn,
|
|
);
|
|
}
|
|
|
|
void _generateSshKey(BuildContext context) {
|
|
if (publicKey.isNotEmpty) {
|
|
return;
|
|
}
|
|
|
|
var comment = "GitJournal-" +
|
|
Platform.operatingSystem +
|
|
"-" +
|
|
DateTime.now().toIso8601String().substring(0, 10); // only the date
|
|
|
|
generateSSHKeys(comment: comment).then((String publicKey) {
|
|
setState(() {
|
|
this.publicKey = publicKey;
|
|
Log.d("PublicKey: " + publicKey);
|
|
_copyKeyToClipboard(context);
|
|
});
|
|
});
|
|
}
|
|
|
|
void _copyKeyToClipboard(BuildContext context) {
|
|
Clipboard.setData(ClipboardData(text: publicKey));
|
|
showSnackbar(context, "Public Key copied to Clipboard");
|
|
}
|
|
|
|
void _launchDeployKeyPage() async {
|
|
var canLaunch = _gitCloneUrl.startsWith("git@github.com:") ||
|
|
_gitCloneUrl.startsWith("git@gitlab.com:");
|
|
if (!canLaunch) {
|
|
return;
|
|
}
|
|
|
|
var lastIndex = _gitCloneUrl.lastIndexOf(".git");
|
|
if (lastIndex == -1) {
|
|
lastIndex = _gitCloneUrl.length;
|
|
}
|
|
|
|
var repoName =
|
|
_gitCloneUrl.substring(_gitCloneUrl.lastIndexOf(":") + 1, lastIndex);
|
|
|
|
final gitHubUrl = 'https://github.com/' + repoName + '/settings/keys/new';
|
|
final gitLabUrl = 'https://gitlab.com/' +
|
|
repoName +
|
|
'/settings/repository/#js-deploy-keys-settings';
|
|
|
|
try {
|
|
if (_gitCloneUrl.startsWith("git@github.com:")) {
|
|
await launch(gitHubUrl);
|
|
} else if (_gitCloneUrl.startsWith("git@gitlab.com:")) {
|
|
await launch(gitLabUrl);
|
|
}
|
|
} catch (err, stack) {
|
|
Log.d('_launchDeployKeyPage: ' + err.toString());
|
|
Log.d(stack.toString());
|
|
}
|
|
}
|
|
|
|
void _launchCreateRepoPage() async {
|
|
assert(_gitHostType != GitHostType.Unknown);
|
|
|
|
try {
|
|
if (_gitHostType == GitHostType.GitHub) {
|
|
await launch("https://github.com/new");
|
|
} else if (_gitHostType == GitHostType.GitLab) {
|
|
await launch("https://gitlab.com/projects/new");
|
|
}
|
|
} catch (err, stack) {
|
|
// FIXME: Error handling?
|
|
Log.d("_launchCreateRepoPage: " + err.toString());
|
|
Log.d(stack.toString());
|
|
}
|
|
}
|
|
|
|
void _startGitClone(BuildContext context) async {
|
|
setState(() {
|
|
gitCloneErrorMessage = "";
|
|
});
|
|
|
|
var stateContainer = Provider.of<StateContainer>(context);
|
|
var appState = stateContainer.appState;
|
|
var basePath = appState.gitBaseDirectory;
|
|
|
|
// Just in case it was half cloned because of an error
|
|
await _removeExistingClone(basePath);
|
|
|
|
String repoPath = p.join(basePath, "journal");
|
|
String error;
|
|
try {
|
|
Log.d("Cloning " + _gitCloneUrl);
|
|
await GitRepo.clone(repoPath, _gitCloneUrl);
|
|
} on GitException catch (e) {
|
|
Log.e(e.toString());
|
|
error = e.cause;
|
|
}
|
|
|
|
if (error != null && error.isNotEmpty) {
|
|
setState(() {
|
|
getAnalytics().logEvent(
|
|
name: "onboarding_gitClone_error",
|
|
parameters: <String, String>{
|
|
'error': error,
|
|
},
|
|
);
|
|
gitCloneErrorMessage = error;
|
|
});
|
|
return;
|
|
}
|
|
|
|
//
|
|
// Add a GitIgnore file. This way we always at least have one commit
|
|
// It makes doing a git pull and push easier
|
|
//
|
|
var anyFileInRepo = Directory(repoPath).list().firstWhere(
|
|
(fs) => fs.statSync().type == FileSystemEntityType.file,
|
|
orElse: () => null,
|
|
);
|
|
if (anyFileInRepo == null) {
|
|
Log.i("Adding .ignore file");
|
|
var ignoreFile = File(p.join(repoPath, ".gitignore"));
|
|
ignoreFile.createSync();
|
|
|
|
var repo = GitRepo(
|
|
folderPath: repoPath,
|
|
);
|
|
await repo.add('.gitignore');
|
|
await repo.commit(
|
|
message: "Add gitignore file",
|
|
authorEmail: Settings.instance.gitAuthorEmail,
|
|
authorName: Settings.instance.gitAuthor,
|
|
);
|
|
}
|
|
|
|
getAnalytics().logEvent(
|
|
name: "onboarding_complete",
|
|
parameters: _buildOnboardingAnalytics(),
|
|
);
|
|
Navigator.pop(context);
|
|
widget.onCompletedFunction();
|
|
}
|
|
|
|
Future<void> _completeAutoConfigure() async {
|
|
Log.d("Starting autoconfigure copletion");
|
|
|
|
try {
|
|
Log.i("Generating SSH Key");
|
|
setState(() {
|
|
_autoConfigureMessage = "Generating SSH Key";
|
|
});
|
|
var publicKey = await generateSSHKeys(comment: "GitJournal");
|
|
|
|
Log.i("Adding as a deploy key");
|
|
_autoConfigureMessage = "Adding as a Deploy Key";
|
|
|
|
await _gitHost.addDeployKey(publicKey, _gitHostRepo.fullName);
|
|
} on Exception catch (e, stacktrace) {
|
|
_handleGitHostException(e, stacktrace);
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_gitCloneUrl = _gitHostRepo.cloneUrl;
|
|
_pageCount += 1;
|
|
|
|
_nextPage();
|
|
_startGitClone(context);
|
|
});
|
|
}
|
|
|
|
void _handleGitHostException(Exception e, StackTrace stacktrace) {
|
|
Log.d("GitHostSetupAutoConfigureComplete: " + e.toString());
|
|
setState(() {
|
|
_autoConfigureErrorMessage = e.toString();
|
|
getAnalytics().logEvent(
|
|
name: "githostsetup_error",
|
|
parameters: <String, String>{
|
|
'errorMessage': _autoConfigureErrorMessage,
|
|
},
|
|
);
|
|
|
|
logException(e, stacktrace);
|
|
});
|
|
}
|
|
|
|
Map<String, String> _buildOnboardingAnalytics() {
|
|
var map = <String, String>{};
|
|
|
|
if (_gitCloneUrl.contains("github.com")) {
|
|
map["host_type"] = "GitHub";
|
|
} else if (_gitCloneUrl.contains("gitlab.org")) {
|
|
map["host_type"] = "GitLab.org";
|
|
} else if (_gitCloneUrl.contains("gitlab")) {
|
|
map["host_type"] = "GitLab";
|
|
}
|
|
|
|
var ch0 = _pageChoice[0] as PageChoice0;
|
|
map["provider_choice"] = ch0.toString().replaceFirst("PageChoice0.", "");
|
|
|
|
var ch1 = _pageChoice[1] as PageChoice1;
|
|
map["setup_manner"] = ch1.toString().replaceFirst("PageChoice1.", "");
|
|
|
|
map["key_generation"] = _keyGenerationChoice
|
|
.toString()
|
|
.replaceFirst("KeyGenerationChoice.", "");
|
|
|
|
return map;
|
|
}
|
|
|
|
Future _removeExistingClone(String baseDirPath) async {
|
|
var baseDir = Directory(p.join(baseDirPath, "journal"));
|
|
var dotGitDir = Directory(p.join(baseDir.path, ".git"));
|
|
bool exists = dotGitDir.existsSync();
|
|
if (exists) {
|
|
Log.d("Removing " + baseDir.path);
|
|
await baseDir.delete(recursive: true);
|
|
await baseDir.create();
|
|
}
|
|
}
|
|
}
|
|
|
|
class GitHostChoicePage extends StatelessWidget {
|
|
final Func1<GitHostType, void> onKnownGitHost;
|
|
final Func0<void> onCustomGitHost;
|
|
|
|
GitHostChoicePage({
|
|
@required this.onKnownGitHost,
|
|
@required this.onCustomGitHost,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
child: Column(
|
|
children: <Widget>[
|
|
Text(
|
|
"Select a Git Hosting Provider -",
|
|
style: Theme.of(context).textTheme.headline5,
|
|
),
|
|
const SizedBox(height: 16.0),
|
|
GitHostSetupButton(
|
|
text: "GitHub",
|
|
iconUrl: 'assets/icon/github-icon.png',
|
|
onPressed: () {
|
|
onKnownGitHost(GitHostType.GitHub);
|
|
},
|
|
),
|
|
const SizedBox(height: 8.0),
|
|
GitHostSetupButton(
|
|
text: "GitLab",
|
|
iconUrl: 'assets/icon/gitlab-icon.png',
|
|
onPressed: () async {
|
|
onKnownGitHost(GitHostType.GitLab);
|
|
},
|
|
),
|
|
const SizedBox(height: 8.0),
|
|
GitHostSetupButton(
|
|
text: "Custom",
|
|
onPressed: () async {
|
|
onCustomGitHost();
|
|
},
|
|
),
|
|
],
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
enum GitHostSetupType {
|
|
Auto,
|
|
Manual,
|
|
}
|
|
|
|
class GitHostAutoConfigureChoicePage extends StatelessWidget {
|
|
final Func1<GitHostSetupType, void> onDone;
|
|
|
|
GitHostAutoConfigureChoicePage({@required this.onDone});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
child: Column(
|
|
children: <Widget>[
|
|
Text(
|
|
"How do you want to do this?",
|
|
style: Theme.of(context).textTheme.headline5,
|
|
),
|
|
const SizedBox(height: 16.0),
|
|
GitHostSetupButton(
|
|
text: "Setup Automatically",
|
|
onPressed: () {
|
|
onDone(GitHostSetupType.Auto);
|
|
},
|
|
),
|
|
const SizedBox(height: 8.0),
|
|
GitHostSetupButton(
|
|
text: "Let me do it manually",
|
|
onPressed: () async {
|
|
onDone(GitHostSetupType.Manual);
|
|
},
|
|
),
|
|
],
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
),
|
|
);
|
|
}
|
|
}
|