home_page.dart 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. import 'dart:async';
  2. import 'dart:io';
  3. import 'package:ente_auth/core/configuration.dart';
  4. import 'package:ente_auth/core/event_bus.dart';
  5. import 'package:ente_auth/ente_theme_data.dart';
  6. import 'package:ente_auth/events/codes_updated_event.dart';
  7. import 'package:ente_auth/events/trigger_logout_event.dart';
  8. import "package:ente_auth/l10n/l10n.dart";
  9. import 'package:ente_auth/models/code.dart';
  10. import 'package:ente_auth/onboarding/view/setup_enter_secret_key_page.dart';
  11. import 'package:ente_auth/services/preference_service.dart';
  12. import 'package:ente_auth/services/user_service.dart';
  13. import 'package:ente_auth/store/code_store.dart';
  14. import 'package:ente_auth/ui/account/logout_dialog.dart';
  15. import 'package:ente_auth/ui/code_widget.dart';
  16. import 'package:ente_auth/ui/common/loading_widget.dart';
  17. import 'package:ente_auth/ui/home/coach_mark_widget.dart';
  18. import 'package:ente_auth/ui/home/home_empty_state.dart';
  19. import 'package:ente_auth/ui/home/speed_dial_label_widget.dart';
  20. import 'package:ente_auth/ui/scanner_page.dart';
  21. import 'package:ente_auth/ui/settings_page.dart';
  22. import 'package:ente_auth/utils/dialog_util.dart';
  23. import 'package:ente_auth/utils/totp_util.dart';
  24. import 'package:flutter/material.dart';
  25. import 'package:flutter/services.dart';
  26. import 'package:flutter_speed_dial/flutter_speed_dial.dart';
  27. import 'package:logging/logging.dart';
  28. import 'package:move_to_background/move_to_background.dart';
  29. import 'package:uni_links/uni_links.dart';
  30. class HomePage extends StatefulWidget {
  31. const HomePage({Key? key}) : super(key: key);
  32. @override
  33. State<HomePage> createState() => _HomePageState();
  34. }
  35. class _HomePageState extends State<HomePage> {
  36. static final _settingsPage = SettingsPage(
  37. emailNotifier: UserService.instance.emailValueNotifier,
  38. );
  39. bool _hasLoaded = false;
  40. bool _isSettingsOpen = false;
  41. final Logger _logger = Logger("HomePage");
  42. final TextEditingController _textController = TextEditingController();
  43. bool _showSearchBox = false;
  44. String _searchText = "";
  45. List<Code> _codes = [];
  46. List<Code> _filteredCodes = [];
  47. StreamSubscription<CodesUpdatedEvent>? _streamSubscription;
  48. StreamSubscription<TriggerLogoutEvent>? _triggerLogoutEvent;
  49. @override
  50. void initState() {
  51. super.initState();
  52. _textController.addListener(_applyFilteringAndRefresh);
  53. _loadCodes();
  54. _streamSubscription = Bus.instance.on<CodesUpdatedEvent>().listen((event) {
  55. _loadCodes();
  56. });
  57. _triggerLogoutEvent =
  58. Bus.instance.on<TriggerLogoutEvent>().listen((event) async {
  59. await autoLogoutAlert(context);
  60. });
  61. _initDeepLinks();
  62. }
  63. void _loadCodes() {
  64. CodeStore.instance.getAllCodes().then((codes) {
  65. _codes = codes;
  66. _hasLoaded = true;
  67. _applyFilteringAndRefresh();
  68. });
  69. }
  70. void _applyFilteringAndRefresh() {
  71. if (_searchText.isNotEmpty && _showSearchBox) {
  72. final String val = _searchText.toLowerCase();
  73. _filteredCodes = _codes
  74. .where(
  75. (element) => (element.account.toLowerCase().contains(val) ||
  76. element.issuer.toLowerCase().contains(val)),
  77. )
  78. .toList();
  79. } else {
  80. _filteredCodes = _codes;
  81. }
  82. if (mounted) {
  83. setState(() {});
  84. }
  85. }
  86. @override
  87. void dispose() {
  88. _streamSubscription?.cancel();
  89. _triggerLogoutEvent?.cancel();
  90. _textController.removeListener(_applyFilteringAndRefresh);
  91. super.dispose();
  92. }
  93. Future<void> _redirectToScannerPage() async {
  94. final Code? code = await Navigator.of(context).push(
  95. MaterialPageRoute(
  96. builder: (BuildContext context) {
  97. return const ScannerPage();
  98. },
  99. ),
  100. );
  101. if (code != null) {
  102. CodeStore.instance.addCode(code);
  103. // Focus the new code by searching
  104. if (_codes.length > 2) {
  105. _focusNewCode(code);
  106. }
  107. }
  108. }
  109. Future<void> _redirectToManualEntryPage() async {
  110. final Code? code = await Navigator.of(context).push(
  111. MaterialPageRoute(
  112. builder: (BuildContext context) {
  113. return SetupEnterSecretKeyPage();
  114. },
  115. ),
  116. );
  117. if (code != null) {
  118. CodeStore.instance.addCode(code);
  119. }
  120. }
  121. @override
  122. Widget build(BuildContext context) {
  123. final l10n = context.l10n;
  124. return WillPopScope(
  125. onWillPop: () async {
  126. if (_isSettingsOpen) {
  127. Navigator.pop(context);
  128. return false;
  129. }
  130. if (Platform.isAndroid) {
  131. MoveToBackground.moveTaskToBack();
  132. return false;
  133. } else {
  134. return true;
  135. }
  136. },
  137. child: Scaffold(
  138. drawerEnableOpenDragGesture: true,
  139. drawer: ConstrainedBox(
  140. constraints: const BoxConstraints(maxWidth: 428),
  141. child: Drawer(
  142. width: double.infinity,
  143. child: _settingsPage,
  144. ),
  145. ),
  146. onDrawerChanged: (isOpened) => _isSettingsOpen = isOpened,
  147. body: SafeArea(
  148. bottom: false,
  149. child: Builder(
  150. builder: (context) {
  151. return _getBody();
  152. },
  153. ),
  154. ),
  155. resizeToAvoidBottomInset: false,
  156. appBar: AppBar(
  157. title: !_showSearchBox
  158. ? const Text('ente Authenticator')
  159. : TextField(
  160. autofocus: _searchText.isEmpty,
  161. controller: _textController,
  162. onChanged: (val) {
  163. _searchText = val;
  164. _applyFilteringAndRefresh();
  165. },
  166. decoration: InputDecoration(
  167. hintText: l10n.searchHint,
  168. border: InputBorder.none,
  169. ),
  170. ),
  171. actions: <Widget>[
  172. IconButton(
  173. icon: _showSearchBox
  174. ? const Icon(Icons.clear)
  175. : const Icon(Icons.search),
  176. tooltip: l10n.search,
  177. onPressed: () {
  178. setState(
  179. () {
  180. _showSearchBox = !_showSearchBox;
  181. if (!_showSearchBox) {
  182. _textController.clear();
  183. } else {
  184. _searchText = _textController.text;
  185. }
  186. _applyFilteringAndRefresh();
  187. },
  188. );
  189. },
  190. ),
  191. ],
  192. ),
  193. floatingActionButton: !_hasLoaded ||
  194. _codes.isEmpty ||
  195. !PreferenceService.instance.hasShownCoachMark()
  196. ? null
  197. : _getFab(),
  198. ),
  199. );
  200. }
  201. Widget _getBody() {
  202. final l10n = context.l10n;
  203. if (_hasLoaded) {
  204. if (_filteredCodes.isEmpty && _searchText.isEmpty) {
  205. return HomeEmptyStateWidget(
  206. onScanTap: _redirectToScannerPage,
  207. onManuallySetupTap: _redirectToManualEntryPage,
  208. );
  209. } else {
  210. final list = ListView.builder(
  211. itemBuilder: ((context, index) {
  212. try {
  213. return CodeWidget(_filteredCodes[index]);
  214. } catch(e) {
  215. return const Text("Failed");
  216. }
  217. }),
  218. itemCount: _filteredCodes.length,
  219. );
  220. if (!PreferenceService.instance.hasShownCoachMark()) {
  221. return Stack(
  222. children: [
  223. list,
  224. const CoachMarkWidget(),
  225. ],
  226. );
  227. } else if (_showSearchBox) {
  228. return Column(
  229. children: [
  230. Expanded(
  231. child: _filteredCodes.isNotEmpty
  232. ? ListView.builder(
  233. itemBuilder: ((context, index) {
  234. Code? code;
  235. try {
  236. code = _filteredCodes[index];
  237. return CodeWidget(code!);
  238. } catch (e, s) {
  239. _logger.severe("code widget error", e, s);
  240. return Center(
  241. child: Padding(
  242. padding: const EdgeInsets.all(8.0),
  243. child: Text(
  244. l10n.sorryUnableToGenCode(code?.issuer ?? ""),
  245. ),
  246. ),
  247. );
  248. }
  249. }),
  250. itemCount: _filteredCodes.length,
  251. )
  252. : Center(child: (Text(l10n.noResult))),
  253. ),
  254. ],
  255. );
  256. } else {
  257. return list;
  258. }
  259. }
  260. } else {
  261. return const EnteLoadingWidget();
  262. }
  263. }
  264. Future<bool> _initDeepLinks() async {
  265. // Platform messages may fail, so we use a try/catch PlatformException.
  266. try {
  267. final String? initialLink = await getInitialLink();
  268. // Parse the link and warn the user, if it is not correct,
  269. // but keep in mind it could be `null`.
  270. if (initialLink != null) {
  271. _handleDeeplink(context, initialLink);
  272. return true;
  273. } else {
  274. _logger.info("No initial link received.");
  275. }
  276. } on PlatformException {
  277. // Handle exception by warning the user their action did not succeed
  278. // return?
  279. _logger.severe("PlatformException thrown while getting initial link");
  280. }
  281. // Attach a listener to the stream
  282. linkStream.listen(
  283. (String? link) {
  284. _handleDeeplink(context, link);
  285. },
  286. onError: (err) {
  287. _logger.severe(err);
  288. },
  289. );
  290. return false;
  291. }
  292. void _handleDeeplink(BuildContext context, String? link) {
  293. if (!Configuration.instance.hasConfiguredAccount() || link == null) {
  294. return;
  295. }
  296. if (mounted && link.toLowerCase().startsWith("otpauth://")) {
  297. try {
  298. final newCode = Code.fromRawData(link);
  299. getNextTotp(newCode);
  300. CodeStore.instance.addCode(newCode);
  301. _focusNewCode(newCode);
  302. } catch (e, s) {
  303. showGenericErrorDialog(context: context);
  304. _logger.severe("error while handling deeplink", e, s);
  305. }
  306. }
  307. }
  308. void _focusNewCode(Code newCode) {
  309. _showSearchBox = true;
  310. _textController.text = newCode.account;
  311. _searchText = newCode.account;
  312. _applyFilteringAndRefresh();
  313. }
  314. Widget _getFab() {
  315. return SpeedDial(
  316. icon: Icons.add,
  317. activeIcon: Icons.close,
  318. spacing: 3,
  319. childPadding: const EdgeInsets.all(5),
  320. spaceBetweenChildren: 4,
  321. tooltip: context.l10n.addCode,
  322. foregroundColor: Theme.of(context).colorScheme.fabForegroundColor,
  323. backgroundColor: Theme.of(context).colorScheme.fabBackgroundColor,
  324. overlayOpacity: 0.5,
  325. overlayColor: Theme.of(context).colorScheme.background,
  326. elevation: 8.0,
  327. animationCurve: Curves.elasticInOut,
  328. children: [
  329. SpeedDialChild(
  330. child: const Icon(Icons.qr_code),
  331. foregroundColor: Theme.of(context).colorScheme.fabForegroundColor,
  332. backgroundColor: Theme.of(context).colorScheme.fabBackgroundColor,
  333. labelWidget: SpeedDialLabelWidget(context.l10n.scanAQrCode),
  334. onTap: _redirectToScannerPage,
  335. ),
  336. SpeedDialChild(
  337. child: const Icon(Icons.keyboard),
  338. foregroundColor: Theme.of(context).colorScheme.fabForegroundColor,
  339. backgroundColor: Theme.of(context).colorScheme.fabBackgroundColor,
  340. labelWidget: SpeedDialLabelWidget(context.l10n.enterDetailsManually),
  341. onTap: _redirectToManualEntryPage,
  342. ),
  343. ],
  344. );
  345. }
  346. }