Browse Source

Add pages and config

Sasan Salamzadeh 1 year ago
parent
commit
1499d31a52

+ 20 - 0
lib/config/routes.dart

@@ -0,0 +1,20 @@
+import 'package:flutter/cupertino.dart';
+
+import '../pages/how_it_works.dart';
+import '../pages/transfer_codes.dart';
+
+class AppRoutes {
+  static const String home = '/';
+  static const String add = '/add';
+  static const String addScan = '/add/scan';
+  static const String edit = '/edit';
+  static const String settings = '/settings';
+  static const String settingAcknowledgements = '/settings/acknowledgements';
+  static const String howItWorks = '/howItWorks';
+  static const String transferCodes = '/transferCodes';
+}
+
+final Map<String, WidgetBuilder> appRoutes = {
+  AppRoutes.howItWorks: (context) => const HowItWorksPage(),
+  AppRoutes.transferCodes: (context) => const TransferCodesPage(),
+};

+ 12 - 0
lib/helper/url.dart

@@ -0,0 +1,12 @@
+import 'package:url_launcher/url_launcher.dart' show canLaunchUrl, launchUrl;
+import 'package:url_launcher/url_launcher_string.dart';
+
+/// Launches a [url] inside the app.
+launchURL(String url) async {
+  var uri = Uri.parse(url);
+  if (await canLaunchUrl(uri)) {
+    await launchUrl(uri, mode: LaunchMode.externalApplication);
+  } else {
+    throw 'Could not launch $url';
+  }
+}

+ 52 - 0
lib/l10n/app_en.arb

@@ -0,0 +1,52 @@
+{
+    "appName": "PB Authenticator",
+    "ok": "OK",
+    "yes": "Yes",
+    "no": "No",
+    "error": "Error",
+    "save": "Save",
+    "edit": "Edit",
+    "add": "Add",
+    "addTitle": "Add Account",
+    "addScanQR": "Scan QR Code",
+    "addManualInput": "Manual Input",
+    "addMethodPrompt": "Please select input method.",
+    "cancel": "Cancel",
+    "secret": "Secret key",
+    "secretInvalidMessage": "Secret key is invalid",
+    "issuer": "Provider/Issuer",
+    "accountName": "Account Name",
+    "digits": "Number of digits",
+    "period": "Time step",
+    "settingsTitle": "Settings",
+    "source": "Source code",
+    "licenses": "Acknowledgements",
+    "clipboard": "Copied to clipboard",
+    "noAccounts": "You have no accounts,\n press the + button to add one.",
+    "loading": "Loading...",
+    "editTitle": "Edit Accounts",
+    "removeAccounts": "Remove accounts",
+    "removeConfirmation": "Please do not forget to turn off 2 factor authentication before removal.\n\nAre you sure that you want to remove the selected account(s)?",
+    "exitEditTitle": "Exit",
+    "exitEditInfo": "Do you want to exit edit mode without saving?",
+    "errDuplicateAccount": "Duplicate account",
+    "errIdCollision": "A rare ID collision has occurred. Please try again.",
+    "errNoCameraPermission": "Camera permission not granted",
+    "errIncorrectFormat": "Scanned code is of incorrect format",
+    "errUnknown": "Unknown error",
+    "howItWorksTitle": "How it works",
+    "howItWorksTitle1": "Secure your accounts",
+    "howItWorksBody1": "Add your accounts to generate time-based one-time passwords (TOTP) for secure sign-in.",
+    "howItWorksTitle2": "Scan QR codes",
+    "howItWorksBody2": "Easily add new accounts by scanning QR codes provided by your services.",
+    "howItWorksTitle3": "Backup & Restore",
+    "howItWorksBody3": "Backup your accounts securely and restore them when needed.",
+    "howItWorksTitle4": "Offline & Private",
+    "howItWorksBody4": "All data stays on your device. No internet required for generating codes.",
+    "skip": "Skip",
+    "done": "Done",
+    "transferCodesTitle": "Transfer codes",
+    "transferCodesDescription": "Transfer your accounts to another device.",
+    "exportAccounts": "Export accounts",
+    "importAccounts": "Import accounts"
+}

+ 427 - 0
lib/l10n/app_localizations.dart

@@ -0,0 +1,427 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
+import 'package:flutter_localizations/flutter_localizations.dart';
+import 'package:intl/intl.dart' as intl;
+
+import 'app_localizations_en.dart';
+
+// ignore_for_file: type=lint
+
+/// Callers can lookup localized strings with an instance of AppLocalizations
+/// returned by `AppLocalizations.of(context)`.
+///
+/// Applications need to include `AppLocalizations.delegate()` in their app's
+/// `localizationDelegates` list, and the locales they support in the app's
+/// `supportedLocales` list. For example:
+///
+/// ```dart
+/// import 'l10n/app_localizations.dart';
+///
+/// return MaterialApp(
+///   localizationsDelegates: AppLocalizations.localizationsDelegates,
+///   supportedLocales: AppLocalizations.supportedLocales,
+///   home: MyApplicationHome(),
+/// );
+/// ```
+///
+/// ## Update pubspec.yaml
+///
+/// Please make sure to update your pubspec.yaml to include the following
+/// packages:
+///
+/// ```yaml
+/// dependencies:
+///   # Internationalization support.
+///   flutter_localizations:
+///     sdk: flutter
+///   intl: any # Use the pinned version from flutter_localizations
+///
+///   # Rest of dependencies
+/// ```
+///
+/// ## iOS Applications
+///
+/// iOS applications define key application metadata, including supported
+/// locales, in an Info.plist file that is built into the application bundle.
+/// To configure the locales supported by your app, you’ll need to edit this
+/// file.
+///
+/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file.
+/// Then, in the Project Navigator, open the Info.plist file under the Runner
+/// project’s Runner folder.
+///
+/// Next, select the Information Property List item, select Add Item from the
+/// Editor menu, then select Localizations from the pop-up menu.
+///
+/// Select and expand the newly-created Localizations item then, for each
+/// locale your application supports, add a new item and select the locale
+/// you wish to add from the pop-up menu in the Value field. This list should
+/// be consistent with the languages listed in the AppLocalizations.supportedLocales
+/// property.
+abstract class AppLocalizations {
+  AppLocalizations(String locale)
+      : localeName = intl.Intl.canonicalizedLocale(locale.toString());
+
+  final String localeName;
+
+  static AppLocalizations? of(BuildContext context) {
+    return Localizations.of<AppLocalizations>(context, AppLocalizations);
+  }
+
+  static const LocalizationsDelegate<AppLocalizations> delegate =
+      _AppLocalizationsDelegate();
+
+  /// A list of this localizations delegate along with the default localizations
+  /// delegates.
+  ///
+  /// Returns a list of localizations delegates containing this delegate along with
+  /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
+  /// and GlobalWidgetsLocalizations.delegate.
+  ///
+  /// Additional delegates can be added by appending to this list in
+  /// MaterialApp. This list does not have to be used at all if a custom list
+  /// of delegates is preferred or required.
+  static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
+      <LocalizationsDelegate<dynamic>>[
+    delegate,
+    GlobalMaterialLocalizations.delegate,
+    GlobalCupertinoLocalizations.delegate,
+    GlobalWidgetsLocalizations.delegate,
+  ];
+
+  /// A list of this localizations delegate's supported locales.
+  static const List<Locale> supportedLocales = <Locale>[Locale('en')];
+
+  /// No description provided for @appName.
+  ///
+  /// In en, this message translates to:
+  /// **'PB Authenticator'**
+  String get appName;
+
+  /// No description provided for @ok.
+  ///
+  /// In en, this message translates to:
+  /// **'OK'**
+  String get ok;
+
+  /// No description provided for @yes.
+  ///
+  /// In en, this message translates to:
+  /// **'Yes'**
+  String get yes;
+
+  /// No description provided for @no.
+  ///
+  /// In en, this message translates to:
+  /// **'No'**
+  String get no;
+
+  /// No description provided for @error.
+  ///
+  /// In en, this message translates to:
+  /// **'Error'**
+  String get error;
+
+  /// No description provided for @save.
+  ///
+  /// In en, this message translates to:
+  /// **'Save'**
+  String get save;
+
+  /// No description provided for @edit.
+  ///
+  /// In en, this message translates to:
+  /// **'Edit'**
+  String get edit;
+
+  /// No description provided for @add.
+  ///
+  /// In en, this message translates to:
+  /// **'Add'**
+  String get add;
+
+  /// No description provided for @addTitle.
+  ///
+  /// In en, this message translates to:
+  /// **'Add Account'**
+  String get addTitle;
+
+  /// No description provided for @addScanQR.
+  ///
+  /// In en, this message translates to:
+  /// **'Scan QR Code'**
+  String get addScanQR;
+
+  /// No description provided for @addManualInput.
+  ///
+  /// In en, this message translates to:
+  /// **'Manual Input'**
+  String get addManualInput;
+
+  /// No description provided for @addMethodPrompt.
+  ///
+  /// In en, this message translates to:
+  /// **'Please select input method.'**
+  String get addMethodPrompt;
+
+  /// No description provided for @cancel.
+  ///
+  /// In en, this message translates to:
+  /// **'Cancel'**
+  String get cancel;
+
+  /// No description provided for @secret.
+  ///
+  /// In en, this message translates to:
+  /// **'Secret key'**
+  String get secret;
+
+  /// No description provided for @secretInvalidMessage.
+  ///
+  /// In en, this message translates to:
+  /// **'Secret key is invalid'**
+  String get secretInvalidMessage;
+
+  /// No description provided for @issuer.
+  ///
+  /// In en, this message translates to:
+  /// **'Provider/Issuer'**
+  String get issuer;
+
+  /// No description provided for @accountName.
+  ///
+  /// In en, this message translates to:
+  /// **'Account Name'**
+  String get accountName;
+
+  /// No description provided for @digits.
+  ///
+  /// In en, this message translates to:
+  /// **'Number of digits'**
+  String get digits;
+
+  /// No description provided for @period.
+  ///
+  /// In en, this message translates to:
+  /// **'Time step'**
+  String get period;
+
+  /// No description provided for @settingsTitle.
+  ///
+  /// In en, this message translates to:
+  /// **'Settings'**
+  String get settingsTitle;
+
+  /// No description provided for @source.
+  ///
+  /// In en, this message translates to:
+  /// **'Source code'**
+  String get source;
+
+  /// No description provided for @licenses.
+  ///
+  /// In en, this message translates to:
+  /// **'Acknowledgements'**
+  String get licenses;
+
+  /// No description provided for @clipboard.
+  ///
+  /// In en, this message translates to:
+  /// **'Copied to clipboard'**
+  String get clipboard;
+
+  /// No description provided for @noAccounts.
+  ///
+  /// In en, this message translates to:
+  /// **'You have no accounts,\n press the + button to add one.'**
+  String get noAccounts;
+
+  /// No description provided for @loading.
+  ///
+  /// In en, this message translates to:
+  /// **'Loading...'**
+  String get loading;
+
+  /// No description provided for @editTitle.
+  ///
+  /// In en, this message translates to:
+  /// **'Edit Accounts'**
+  String get editTitle;
+
+  /// No description provided for @removeAccounts.
+  ///
+  /// In en, this message translates to:
+  /// **'Remove accounts'**
+  String get removeAccounts;
+
+  /// No description provided for @removeConfirmation.
+  ///
+  /// In en, this message translates to:
+  /// **'Please do not forget to turn off 2 factor authentication before removal.\n\nAre you sure that you want to remove the selected account(s)?'**
+  String get removeConfirmation;
+
+  /// No description provided for @exitEditTitle.
+  ///
+  /// In en, this message translates to:
+  /// **'Exit'**
+  String get exitEditTitle;
+
+  /// No description provided for @exitEditInfo.
+  ///
+  /// In en, this message translates to:
+  /// **'Do you want to exit edit mode without saving?'**
+  String get exitEditInfo;
+
+  /// No description provided for @errDuplicateAccount.
+  ///
+  /// In en, this message translates to:
+  /// **'Duplicate account'**
+  String get errDuplicateAccount;
+
+  /// No description provided for @errIdCollision.
+  ///
+  /// In en, this message translates to:
+  /// **'A rare ID collision has occurred. Please try again.'**
+  String get errIdCollision;
+
+  /// No description provided for @errNoCameraPermission.
+  ///
+  /// In en, this message translates to:
+  /// **'Camera permission not granted'**
+  String get errNoCameraPermission;
+
+  /// No description provided for @errIncorrectFormat.
+  ///
+  /// In en, this message translates to:
+  /// **'Scanned code is of incorrect format'**
+  String get errIncorrectFormat;
+
+  /// No description provided for @errUnknown.
+  ///
+  /// In en, this message translates to:
+  /// **'Unknown error'**
+  String get errUnknown;
+
+  /// No description provided for @howItWorksTitle.
+  ///
+  /// In en, this message translates to:
+  /// **'How it works'**
+  String get howItWorksTitle;
+
+  /// No description provided for @howItWorksTitle1.
+  ///
+  /// In en, this message translates to:
+  /// **'Secure your accounts'**
+  String get howItWorksTitle1;
+
+  /// No description provided for @howItWorksBody1.
+  ///
+  /// In en, this message translates to:
+  /// **'Add your accounts to generate time-based one-time passwords (TOTP) for secure sign-in.'**
+  String get howItWorksBody1;
+
+  /// No description provided for @howItWorksTitle2.
+  ///
+  /// In en, this message translates to:
+  /// **'Scan QR codes'**
+  String get howItWorksTitle2;
+
+  /// No description provided for @howItWorksBody2.
+  ///
+  /// In en, this message translates to:
+  /// **'Easily add new accounts by scanning QR codes provided by your services.'**
+  String get howItWorksBody2;
+
+  /// No description provided for @howItWorksTitle3.
+  ///
+  /// In en, this message translates to:
+  /// **'Backup & Restore'**
+  String get howItWorksTitle3;
+
+  /// No description provided for @howItWorksBody3.
+  ///
+  /// In en, this message translates to:
+  /// **'Backup your accounts securely and restore them when needed.'**
+  String get howItWorksBody3;
+
+  /// No description provided for @howItWorksTitle4.
+  ///
+  /// In en, this message translates to:
+  /// **'Offline & Private'**
+  String get howItWorksTitle4;
+
+  /// No description provided for @howItWorksBody4.
+  ///
+  /// In en, this message translates to:
+  /// **'All data stays on your device. No internet required for generating codes.'**
+  String get howItWorksBody4;
+
+  /// No description provided for @skip.
+  ///
+  /// In en, this message translates to:
+  /// **'Skip'**
+  String get skip;
+
+  /// No description provided for @done.
+  ///
+  /// In en, this message translates to:
+  /// **'Done'**
+  String get done;
+
+  /// No description provided for @transferCodesTitle.
+  ///
+  /// In en, this message translates to:
+  /// **'Transfer codes'**
+  String get transferCodesTitle;
+
+  /// No description provided for @transferCodesDescription.
+  ///
+  /// In en, this message translates to:
+  /// **'Transfer your accounts to another device.'**
+  String get transferCodesDescription;
+
+  /// No description provided for @exportAccounts.
+  ///
+  /// In en, this message translates to:
+  /// **'Export accounts'**
+  String get exportAccounts;
+
+  /// No description provided for @importAccounts.
+  ///
+  /// In en, this message translates to:
+  /// **'Import accounts'**
+  String get importAccounts;
+}
+
+class _AppLocalizationsDelegate
+    extends LocalizationsDelegate<AppLocalizations> {
+  const _AppLocalizationsDelegate();
+
+  @override
+  Future<AppLocalizations> load(Locale locale) {
+    return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
+  }
+
+  @override
+  bool isSupported(Locale locale) =>
+      <String>['en'].contains(locale.languageCode);
+
+  @override
+  bool shouldReload(_AppLocalizationsDelegate old) => false;
+}
+
+AppLocalizations lookupAppLocalizations(Locale locale) {
+  // Lookup logic when only language code is specified.
+  switch (locale.languageCode) {
+    case 'en':
+      return AppLocalizationsEn();
+  }
+
+  throw FlutterError(
+      'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
+      'an issue with the localizations generation tool. Please file an issue '
+      'on GitHub with a reproducible sample app and the gen-l10n configuration '
+      'that was used.');
+}

+ 168 - 0
lib/l10n/app_localizations_en.dart

@@ -0,0 +1,168 @@
+// ignore: unused_import
+import 'package:intl/intl.dart' as intl;
+import 'app_localizations.dart';
+
+// ignore_for_file: type=lint
+
+/// The translations for English (`en`).
+class AppLocalizationsEn extends AppLocalizations {
+  AppLocalizationsEn([String locale = 'en']) : super(locale);
+
+  @override
+  String get appName => 'PB Authenticator';
+
+  @override
+  String get ok => 'OK';
+
+  @override
+  String get yes => 'Yes';
+
+  @override
+  String get no => 'No';
+
+  @override
+  String get error => 'Error';
+
+  @override
+  String get save => 'Save';
+
+  @override
+  String get edit => 'Edit';
+
+  @override
+  String get add => 'Add';
+
+  @override
+  String get addTitle => 'Add Account';
+
+  @override
+  String get addScanQR => 'Scan QR Code';
+
+  @override
+  String get addManualInput => 'Manual Input';
+
+  @override
+  String get addMethodPrompt => 'Please select input method.';
+
+  @override
+  String get cancel => 'Cancel';
+
+  @override
+  String get secret => 'Secret key';
+
+  @override
+  String get secretInvalidMessage => 'Secret key is invalid';
+
+  @override
+  String get issuer => 'Provider/Issuer';
+
+  @override
+  String get accountName => 'Account Name';
+
+  @override
+  String get digits => 'Number of digits';
+
+  @override
+  String get period => 'Time step';
+
+  @override
+  String get settingsTitle => 'Settings';
+
+  @override
+  String get source => 'Source code';
+
+  @override
+  String get licenses => 'Acknowledgements';
+
+  @override
+  String get clipboard => 'Copied to clipboard';
+
+  @override
+  String get noAccounts =>
+      'You have no accounts,\n press the + button to add one.';
+
+  @override
+  String get loading => 'Loading...';
+
+  @override
+  String get editTitle => 'Edit Accounts';
+
+  @override
+  String get removeAccounts => 'Remove accounts';
+
+  @override
+  String get removeConfirmation =>
+      'Please do not forget to turn off 2 factor authentication before removal.\n\nAre you sure that you want to remove the selected account(s)?';
+
+  @override
+  String get exitEditTitle => 'Exit';
+
+  @override
+  String get exitEditInfo => 'Do you want to exit edit mode without saving?';
+
+  @override
+  String get errDuplicateAccount => 'Duplicate account';
+
+  @override
+  String get errIdCollision =>
+      'A rare ID collision has occurred. Please try again.';
+
+  @override
+  String get errNoCameraPermission => 'Camera permission not granted';
+
+  @override
+  String get errIncorrectFormat => 'Scanned code is of incorrect format';
+
+  @override
+  String get errUnknown => 'Unknown error';
+
+  @override
+  String get howItWorksTitle => 'How it works';
+
+  @override
+  String get howItWorksTitle1 => 'Secure your accounts';
+
+  @override
+  String get howItWorksBody1 =>
+      'Add your accounts to generate time-based one-time passwords (TOTP) for secure sign-in.';
+
+  @override
+  String get howItWorksTitle2 => 'Scan QR codes';
+
+  @override
+  String get howItWorksBody2 =>
+      'Easily add new accounts by scanning QR codes provided by your services.';
+
+  @override
+  String get howItWorksTitle3 => 'Backup & Restore';
+
+  @override
+  String get howItWorksBody3 =>
+      'Backup your accounts securely and restore them when needed.';
+
+  @override
+  String get howItWorksTitle4 => 'Offline & Private';
+
+  @override
+  String get howItWorksBody4 =>
+      'All data stays on your device. No internet required for generating codes.';
+
+  @override
+  String get skip => 'Skip';
+
+  @override
+  String get done => 'Done';
+
+  @override
+  String get transferCodesTitle => 'Transfer codes';
+
+  @override
+  String get transferCodesDescription =>
+      'Transfer your accounts to another device.';
+
+  @override
+  String get exportAccounts => 'Export accounts';
+
+  @override
+  String get importAccounts => 'Import accounts';
+}

+ 3 - 0
lib/l10n/constants.dart

@@ -0,0 +1,3 @@
+class Constants {
+  static const String repoUrl = "https://github.com/salamzadeh/pb_authenticator";
+}

+ 220 - 0
lib/pages/acknowledgements.dart

@@ -0,0 +1,220 @@
+// Acknowledgements page
+//
+// Various code from:
+// https://github.com/flutter/flutter/blob/efb1346767e67d2577461baf671eb2d5d2c4bb90/packages/flutter/lib/src/material/about.dart
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
+import 'package:flutter/material.dart'
+    show
+        CircularProgressIndicator,
+        Divider,
+        ListTile,
+        Material,
+        MaterialPageRoute,
+        Theme;
+
+import '../ui/adaptive.dart' show AppScaffold;
+import '../l10n/app_localizations.dart';
+
+class AcknowledgementsPage extends StatefulWidget {
+  const AcknowledgementsPage({
+    super.key,
+  });
+
+  @override
+  State<AcknowledgementsPage> createState() => _AcknowledgementsPageState();
+}
+
+/// Acknowledgements page for used libraries.
+class _AcknowledgementsPageState extends State<AcknowledgementsPage> {
+  // Adapted from flutter/flutter
+  final Future<_LicenseData> licenses = LicenseRegistry.licenses
+      .fold<_LicenseData>(
+        _LicenseData(),
+        (_LicenseData prev, LicenseEntry license) => prev..addLicense(license),
+      )
+      .then((_LicenseData licenseData) => licenseData..sortPackages());
+
+  @override
+  Widget build(BuildContext context) {
+    return AppScaffold(
+      title: Text(AppLocalizations.of(context)!.licenses),
+      body: FutureBuilder<_LicenseData>(
+        future: licenses,
+        builder: (BuildContext context, AsyncSnapshot<_LicenseData> snapshot) {
+          return LayoutBuilder(
+            key: ValueKey<ConnectionState>(snapshot.connectionState),
+            builder: (BuildContext context, BoxConstraints constraints) {
+              switch (snapshot.connectionState) {
+                case ConnectionState.done:
+                  if (snapshot.hasError) {
+                    return Center(child: Text(snapshot.error.toString()));
+                  }
+                  return Material(
+                    child: ListView.separated(
+                      itemCount: snapshot.data!.count(),
+                      separatorBuilder: (BuildContext context, int index) =>
+                          const SizedBox(height: 0, width: 0),
+                      itemBuilder: (BuildContext context, int index) {
+                        return ListTile(
+                          dense: true,
+                          title: Text(snapshot.data!.getTitle(index)),
+                          onTap: () {
+                            Navigator.of(context).push(
+                              MaterialPageRoute(
+                                builder: (context) => FullScreenLicense(
+                                  snapshot.data!.getTitle(index),
+                                  snapshot.data!.getLicenses(index),
+                                ),
+                              ),
+                            );
+                          },
+                        );
+                      },
+                    ),
+                  );
+                case ConnectionState.none:
+                case ConnectionState.active:
+                case ConnectionState.waiting:
+                  return Material(
+                    color: Theme.of(context).cardColor,
+                    child: const Center(child: CircularProgressIndicator()),
+                  );
+              }
+            },
+          );
+        },
+      ),
+    );
+  }
+}
+
+/// Full screen license widget
+class FullScreenLicense extends StatelessWidget {
+  /// The name
+  final String title;
+
+  /// License
+  final List<LicenseEntry> licenseEntries;
+
+  const FullScreenLicense(this.title, this.licenseEntries, {super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    List<Widget> licenses = <Widget>[];
+    for (var (index, licenseEntry) in licenseEntries.indexed) {
+      if (index != 0) {
+        licenses.add(const Divider());
+      }
+      licenses.addAll(licenseEntry.paragraphs.map((p) {
+        // Adapted from flutter/flutter
+        if (p.indent == LicenseParagraph.centeredIndent) {
+          return Padding(
+            padding: const EdgeInsets.only(top: 16.0),
+            child: Text(
+              p.text,
+              style: const TextStyle(fontWeight: FontWeight.bold),
+              textAlign: TextAlign.center,
+            ),
+          );
+        } else {
+          return Padding(
+            padding:
+                EdgeInsetsDirectional.only(top: 8.0, start: 16.0 * p.indent),
+            child: Text(p.text),
+          );
+        }
+      }));
+    }
+
+    return AppScaffold(
+      title: Text(title),
+      body: Material(
+        child: SingleChildScrollView(
+          child: Padding(
+            padding: const EdgeInsets.only(left: 12, right: 12, bottom: 12),
+            child: Column(children: licenses),
+          ),
+        ),
+      ),
+    );
+  }
+}
+
+/// Adapted from flutter/flutter
+/// With renames and addition of helper methods.
+///
+/// This is a collection of licenses and the packages to which they apply.
+/// [_packageLicenseBindings] records the m+:n+ relationship between the license
+/// and packages as a map of package names to license indexes.
+class _LicenseData {
+  final List<LicenseEntry> _licenses = <LicenseEntry>[];
+  final Map<String, List<int>> _packageLicenseBindings = <String, List<int>>{};
+  final List<String> _packages = <String>[];
+
+  // Special treatment for the first package since it should be the package
+  // for delivered application.
+  String? firstPackage;
+
+  void addLicense(LicenseEntry entry) {
+    // Before the license can be added, we must first record the packages to
+    // which it belongs.
+    for (final String package in entry.packages) {
+      _addPackage(package);
+      // Bind this license to the package using the next index value. This
+      // creates a contract that this license must be inserted at this same
+      // index value.
+      _packageLicenseBindings[package]!.add(_licenses.length);
+    }
+    _licenses.add(entry); // Completion of the contract above.
+  }
+
+  /// Add a package and initialize package license binding. This is a no-op if
+  /// the package has been seen before.
+  void _addPackage(String package) {
+    if (!_packageLicenseBindings.containsKey(package)) {
+      _packageLicenseBindings[package] = <int>[];
+      firstPackage ??= package;
+      _packages.add(package);
+    }
+  }
+
+  /// Sort the packages using some comparison method, or by the default manner,
+  /// which is to put the application package first, followed by every other
+  /// package in case-insensitive alphabetical order.
+  void sortPackages([int Function(String a, String b)? compare]) {
+    _packages.sort(compare ??
+        (String a, String b) {
+          // Based on how LicenseRegistry currently behaves, the first package
+          // returned is the end user application license. This should be
+          // presented first in the list. So here we make sure that first package
+          // remains at the front regardless of alphabetical sorting.
+          if (a == firstPackage) {
+            return -1;
+          }
+          if (b == firstPackage) {
+            return 1;
+          }
+          return a.toLowerCase().compareTo(b.toLowerCase());
+        });
+  }
+
+  int count() {
+    return _packages.length;
+  }
+
+  String getTitle(int index) {
+    return _packages[index];
+  }
+
+  List<LicenseEntry> getLicenses(int index) {
+    var package = _packages[index];
+    return _getLicensesForPackage(package);
+  }
+
+  List<LicenseEntry> _getLicensesForPackage(String package) {
+    var bindings = _packageLicenseBindings[package]!;
+    return bindings.map((b) => _licenses[b]).toList();
+  }
+}

+ 335 - 0
lib/pages/add.dart

@@ -0,0 +1,335 @@
+import '../state/app_state.dart';
+import '../ui/adaptive.dart'
+    show AdaptiveDialogAction, AppScaffold, isPlatformAndroid;
+import 'package:pb_authenticator_totp/totp_algorithm.dart';
+import 'package:pb_authenticator_totp/totp.dart' show Base32, TotpItem;
+import 'package:flutter/material.dart';
+import 'package:flutter/cupertino.dart';
+import '../l10n/app_localizations.dart';
+import 'package:provider/provider.dart';
+
+/// Page for adding accounts.
+class AddPage extends StatefulWidget {
+  const AddPage({super.key});
+
+  @override
+  State<AddPage> createState() => _AddPageState();
+}
+
+class _AddPageState extends State<AddPage> {
+  // Uniquely identify Form widget for validation
+  final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
+
+  // Text field controllers
+  final _issuerController = TextEditingController();
+  final _accountNameController = TextEditingController();
+  final _secretController = TextEditingController();
+
+  // Value of dropdown fields
+  int _digits = 6;
+  int _period = 30;
+
+  @override
+  void initState() {
+    super.initState();
+
+    // Add listener to secret controller
+    _secretController.addListener(_enableSecretAutoValidation);
+  }
+
+  // Enable validation when text is changed (but don't validate initially)
+  bool _secretAutoValidation = false;
+  void _enableSecretAutoValidation() {
+    if (_secretController.text.isEmpty) return;
+    _secretController.removeListener(_enableSecretAutoValidation);
+    setState(() {
+      _secretAutoValidation = true;
+    });
+  }
+
+  @override
+  dispose() {
+    _issuerController.dispose();
+    _accountNameController.dispose();
+    _secretController.dispose();
+    super.dispose();
+  }
+
+  // Adds item or display errors
+  void addItem(TotpItem item) {
+    try {
+      Provider.of<AppState>(context, listen: false).addItem(item).then((_) {
+        Navigator.pop(context);
+      });
+    } catch (e) {
+      // TODO: Fix exception handling
+      var errMessage = e.toString();
+      if (e.toString().contains('DUPLICATE_ACCOUNT')) {
+        errMessage = AppLocalizations.of(context)!.errDuplicateAccount;
+      } else if (e.toString().contains('ID_COLLISION')) {
+        errMessage = AppLocalizations.of(context)!.errIdCollision;
+      }
+
+      showAdaptiveDialog(
+          context: context,
+          builder: (BuildContext context) {
+            return AlertDialog.adaptive(
+                title: Text(AppLocalizations.of(context)!.error),
+                content: Text(errMessage),
+                actions: [
+                  AdaptiveDialogAction(
+                      onPressed: Navigator.of(context).pop,
+                      child: Text(AppLocalizations.of(context)!.ok))
+                ]);
+          });
+    }
+  }
+
+  /// Whether the secret field is valid
+  bool secretFieldValid() {
+    return _secretController.text.isNotEmpty &&
+        Base32.isBase32(_secretController.text);
+  }
+
+  // Handle add action
+  void handleAdd() {
+    // Validate
+    if (!_formKey.currentState!.validate()) {
+      return;
+    }
+
+    // No iOS validator, so may need to recheck and fail
+    if (!secretFieldValid()) {
+      showCupertinoDialog(
+        context: context,
+        builder: (BuildContext context) {
+          return CupertinoAlertDialog(
+            title: Text(AppLocalizations.of(context)!.error),
+            content: Text(AppLocalizations.of(context)!.secretInvalidMessage),
+            actions: [
+              CupertinoDialogAction(
+                  onPressed: Navigator.of(context).pop,
+                  child: Text(AppLocalizations.of(context)!.ok))
+            ],
+          );
+        },
+      );
+      return;
+    }
+
+    // Initialise and add TOTP item
+    var item = TotpItem(
+        _secretController.text,
+        _digits,
+        _period,
+        OtpHashAlgorithm.sha1,
+        _issuerController.text,
+        _accountNameController.text);
+
+    addItem(item);
+  }
+
+  // Separates fields
+  // Adapted from flutter examples
+  static const BorderSide _greyBorder = BorderSide(
+    color: CupertinoColors.lightBackgroundGray,
+    style: BorderStyle.solid,
+    width: 0.0,
+  );
+  static const _boxDecoration = BoxDecoration(
+      border: Border(
+          bottom: _greyBorder,
+          top: _greyBorder,
+          left: BorderSide.none,
+          right: BorderSide.none));
+
+  @override
+  Widget build(BuildContext context) {
+    return AppScaffold(
+      title: Text(AppLocalizations.of(context)!.addTitle),
+      cupertinoNavigationBar: CupertinoNavigationBar(
+          middle: Text(AppLocalizations.of(context)!.addTitle),
+          trailing: CupertinoButton(
+              padding: const EdgeInsets.all(0),
+              onPressed: handleAdd,
+              child: Text(AppLocalizations.of(context)!.add))),
+      body: Form(
+        key: _formKey,
+        child: Padding(
+          padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 15),
+          child: ListView(
+            children: isPlatformAndroid()
+                ? <Widget>[
+                    // Key
+                    TextFormField(
+                      controller: _secretController,
+                      textCapitalization: TextCapitalization.characters,
+                      autocorrect: false,
+                      autovalidateMode: _secretAutoValidation
+                          ? AutovalidateMode.onUserInteraction
+                          : AutovalidateMode.disabled,
+                      validator: (value) => secretFieldValid()
+                          ? null
+                          : AppLocalizations.of(context)!.secretInvalidMessage,
+                      decoration: InputDecoration(
+                          labelText: AppLocalizations.of(context)!.secret),
+                    ),
+                    // Provider
+                    TextFormField(
+                      controller: _issuerController,
+                      textCapitalization: TextCapitalization.words,
+                      decoration: InputDecoration(
+                          labelText: AppLocalizations.of(context)!.issuer,
+                          hintText: 'Example Service'),
+                    ),
+
+                    // Account name
+                    TextFormField(
+                      controller: _accountNameController,
+                      keyboardType: TextInputType.emailAddress,
+                      decoration: InputDecoration(
+                          labelText: AppLocalizations.of(context)!.accountName,
+                          hintText: 'user@example.com'),
+                    ),
+
+                    // # of digits
+                    InputDecorator(
+                      decoration: InputDecoration(
+                        contentPadding: const EdgeInsets.only(top: 15),
+                        labelText: AppLocalizations.of(context)!.digits,
+                        border: InputBorder.none,
+                        labelStyle: const TextStyle(fontSize: 20),
+                      ),
+                      child: DropdownButton(
+                        value: _digits,
+                        onChanged: (value) {
+                          setState(() {
+                            _digits = value;
+                          });
+                        },
+                        isExpanded: true,
+                        items: const <DropdownMenuItem>[
+                          DropdownMenuItem(value: 6, child: Text("6")),
+                          DropdownMenuItem(value: 8, child: Text("8")),
+                        ],
+                      ),
+                    ),
+
+                    // Time step
+                    InputDecorator(
+                      decoration: InputDecoration(
+                          contentPadding: const EdgeInsets.only(top: 10),
+                          labelText: AppLocalizations.of(context)!.period,
+                          border: InputBorder.none,
+                          labelStyle: const TextStyle(fontSize: 20)),
+                      child: DropdownButton(
+                        value: _period,
+                        onChanged: (value) {
+                          setState(() {
+                            _period = value;
+                          });
+                        },
+                        isExpanded: true,
+                        items: const <DropdownMenuItem>[
+                          DropdownMenuItem(value: 30, child: Text("30")),
+                          DropdownMenuItem(value: 60, child: Text("60")),
+                        ],
+                      ),
+                    ),
+
+                    // Add button
+                    Padding(
+                      padding: const EdgeInsets.symmetric(vertical: 5),
+                      child: ElevatedButton(
+                        onPressed: handleAdd,
+                        child: Text(AppLocalizations.of(context)!.add),
+                      ),
+                    )
+                  ]
+                : <Widget>[
+                    // Key
+                    CupertinoTextField(
+                        controller: _secretController,
+                        textCapitalization: TextCapitalization.characters,
+                        autocorrect: false,
+                        decoration: _boxDecoration,
+                        padding: const EdgeInsets.symmetric(vertical: 15),
+                        prefix: Text(AppLocalizations.of(context)!.secret),
+                        textAlign: TextAlign.right),
+
+                    // Provider
+                    CupertinoTextField(
+                        controller: _issuerController,
+                        textCapitalization: TextCapitalization.words,
+                        decoration: _boxDecoration,
+                        padding: const EdgeInsets.symmetric(vertical: 15),
+                        prefix: Text(AppLocalizations.of(context)!.issuer),
+                        textAlign: TextAlign.right),
+
+                    // Account name
+                    CupertinoTextField(
+                        controller: _accountNameController,
+                        keyboardType: TextInputType.emailAddress,
+                        decoration: _boxDecoration,
+                        padding: const EdgeInsets.symmetric(vertical: 15),
+                        prefix: Text(AppLocalizations.of(context)!.accountName),
+                        textAlign: TextAlign.right),
+
+                    // # of digits
+                    Container(
+                      padding: const EdgeInsets.symmetric(vertical: 15),
+                      decoration: _boxDecoration,
+                      child: ListBody(
+                        children: [
+                          Padding(
+                              padding: const EdgeInsets.only(bottom: 8),
+                              child:
+                                  Text(AppLocalizations.of(context)!.digits)),
+                          CupertinoSegmentedControl(
+                            groupValue: _digits.toString(),
+                            children: const {'6': Text('6'), '8': Text('8')},
+                            onValueChanged: (v) {
+                              setState(() {
+                                _digits = int.parse(v);
+                              });
+                            },
+                          )
+                        ],
+                      ),
+                    ),
+
+                    // Time step
+                    Container(
+                      padding: const EdgeInsets.symmetric(vertical: 15),
+                      decoration: _boxDecoration,
+                      child: ListBody(
+                        children: [
+                          Padding(
+                              padding: const EdgeInsets.only(bottom: 8),
+                              child:
+                                  Text(AppLocalizations.of(context)!.period)),
+                          CupertinoSegmentedControl(
+                            groupValue: _period.toString(),
+                            children: const {
+                              '30': Text('30'),
+                              '60': Text('60')
+                            },
+                            onValueChanged: (v) {
+                              setState(() {
+                                _period = int.parse(v);
+                              });
+                            },
+                          )
+                        ],
+                      ),
+                    ),
+
+                    // Add button
+                    Container()
+                  ],
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 229 - 0
lib/pages/android/edit.dart

@@ -0,0 +1,229 @@
+import '../../config/routes.dart';
+import '../../state/app_state.dart';
+import 'package:flutter/material.dart';
+import '/l10n/app_localizations.dart';
+import 'package:provider/provider.dart';
+import './edit_list_item.dart' show EditListItem;
+
+/// Android edit page.
+class AndroidEditPage extends StatefulWidget {
+  const AndroidEditPage({super.key});
+
+  @override
+  State<AndroidEditPage> createState() => _EditPageState();
+}
+
+class _EditPageState extends State<AndroidEditPage> {
+  List<BaseItemType>? items;
+
+  @override
+  void initState() {
+    super.initState();
+
+    WidgetsBinding.instance.addPostFrameCallback((_) {
+      setState(() =>
+          items = List<BaseItemType>.from(context.read<AppState>().items!));
+    });
+  }
+
+  // Handles reorder of elements
+  void _handleReorder(int oldIndex, int newIndex) {
+    setState(() {
+      if (newIndex > oldIndex) {
+        newIndex -= 1;
+      }
+      items!.insert(newIndex, items!.removeAt(oldIndex));
+      _refreshHide();
+    });
+  }
+
+  // Hide bottom bar
+  final ValueNotifier<bool> _hideTrash = ValueNotifier(true);
+  // Whether to hide save icon
+  final ValueNotifier<bool> _hideSave = ValueNotifier(true);
+
+  // List of selected items (to be removed)
+  final List<String> _pendingRemovalList = [];
+
+  // Handles item check
+  void addRemovalItem(String id) {
+    _pendingRemovalList.add(id);
+    _refreshHide();
+  }
+
+  // Handles item uncheck
+  void removeRemovalItem(String id) {
+    _pendingRemovalList.remove(id);
+    _refreshHide();
+  }
+
+  // Remove items with given ids
+  void removeItems(List<String> itemIDs) {
+    items!.removeWhere((item) => itemIDs.contains(item.id));
+  }
+
+  // Refreshes value of _hide
+  void _refreshHide() {
+    _hideTrash.value = _pendingRemovalList.isEmpty;
+    _hideSave.value =
+        !Provider.of<AppState>(context, listen: false).itemsChanged(items);
+  }
+
+  // Remove dialog
+  void showRemoveDialog(BuildContext context) {
+    showDialog(
+      context: context,
+      builder: (BuildContext context) {
+        return AlertDialog(
+          title: Text(AppLocalizations.of(context)!.removeAccounts),
+          content: Text(AppLocalizations.of(context)!.removeConfirmation),
+          actions: [
+            TextButton(
+                child: Text(AppLocalizations.of(context)!.no),
+                onPressed: () {
+                  Navigator.of(context).pop();
+                }),
+            TextButton(
+              child: Text(AppLocalizations.of(context)!.yes,
+                  style: const TextStyle(color: Colors.red)),
+              onPressed: () {
+                setState(() {
+                  removeItems(_pendingRemovalList);
+                  _pendingRemovalList.clear();
+                  _refreshHide();
+                });
+                Navigator.of(context).pop();
+              },
+            )
+          ],
+        );
+      },
+    );
+  }
+
+  // Handle history pop
+  void _popCallback(bool didPop) async {
+    if (didPop) return;
+
+    // No change to items
+    if (!Provider.of<AppState>(context, listen: false).itemsChanged(items)) {
+      Navigator.of(context).pop();
+      return;
+    }
+
+    await showDialog(
+      context: context,
+      builder: (BuildContext context) {
+        return AlertDialog(
+          title: Text(AppLocalizations.of(context)!.exitEditTitle),
+          content: Text(AppLocalizations.of(context)!.exitEditInfo),
+          actions: [
+            TextButton(
+              child: Text(AppLocalizations.of(context)!.no),
+              onPressed: () {
+                Navigator.of(context).pop();
+              },
+            ),
+            TextButton(
+              child: Text(AppLocalizations.of(context)!.yes,
+                  style: const TextStyle(color: Colors.red)),
+              onPressed: () {
+                Navigator.of(context)
+                    .popUntil(ModalRoute.withName(AppRoutes.home));
+              },
+            )
+          ],
+        );
+      },
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return PopScope(
+      canPop: false,
+      onPopInvoked: _popCallback,
+      child: Theme(
+        data: Theme.of(context).copyWith(
+          bottomSheetTheme: const BottomSheetThemeData(
+            surfaceTintColor: Colors.transparent,
+          ),
+        ),
+        child: Scaffold(
+          appBar: AppBar(
+            title: Text(AppLocalizations.of(context)!.editTitle),
+            actions: <Widget>[
+              // Save
+              ValueListenableBuilder<bool>(
+                valueListenable: _hideSave,
+                builder: (context, hideSave, child) {
+                  return hideSave
+                      ? Container()
+                      : IconButton(
+                          icon: const Icon(Icons.check),
+                          tooltip: AppLocalizations.of(context)!.save,
+                          onPressed: () {
+                            if (items != null) {
+                              Provider.of<AppState>(context, listen: false)
+                                  .replaceItems(items!);
+                            }
+                            Navigator.pop(context);
+                          },
+                        );
+                },
+              )
+            ],
+          ),
+
+          body: items == null
+              ? Center(child: Text(AppLocalizations.of(context)!.loading))
+              : Container(
+                  margin: const EdgeInsets.all(10),
+                  child: ReorderableListView(
+                      padding: const EdgeInsets.all(0),
+                      scrollDirection: Axis.vertical,
+                      onReorder: _handleReorder,
+                      children: items!
+                          .map((item) => EditListItem(
+                              item, addRemovalItem, removeRemovalItem,
+                              key: Key(item.id)))
+                          .toList())),
+
+          // Delete button
+          bottomSheet: ValueListenableBuilder<bool>(
+            valueListenable: _hideTrash,
+            builder: (context, value, child) {
+              return value
+                  ? const SizedBox.shrink()
+                  : Row(
+                      mainAxisSize: MainAxisSize.max,
+                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                      children: <Widget>[
+                        Expanded(
+                          child: TextButton(
+                            onPressed: () {
+                              showRemoveDialog(context);
+                            },
+                            style: TextButton.styleFrom(
+                              shape: const RoundedRectangleBorder(
+                                borderRadius:
+                                    BorderRadius.all(Radius.circular(2)),
+                              ),
+                              padding: const EdgeInsets.symmetric(vertical: 10),
+                              side: BorderSide.none,
+                            ),
+                            child: Text(
+                              AppLocalizations.of(context)!.removeAccounts,
+                              style: const TextStyle(color: Colors.redAccent),
+                            ),
+                          ),
+                        ),
+                      ],
+                    );
+            },
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 72 - 0
lib/pages/android/edit_list_item.dart

@@ -0,0 +1,72 @@
+import '../../state/app_state.dart';
+import 'package:flutter/material.dart';
+
+/// Item for edit page (Android).
+class EditListItem extends StatefulWidget {
+  const EditListItem(this.item, this.addRemovalItem, this.removeRemovalItem,
+      {super.key});
+
+  final BaseItemType item;
+
+  // Adds/removes the item from the removal list
+  final Function addRemovalItem;
+  final Function removeRemovalItem;
+
+  @override
+  State<StatefulWidget> createState() {
+    return _EditListItem();
+  }
+}
+
+class _EditListItem extends State<EditListItem> {
+  // Whether item is currently selected
+  bool? _value = false;
+
+  @override
+  Widget build(BuildContext context) {
+    return Hero(
+      key: widget.key,
+      tag: widget.item.id,
+      child: Card(
+        child: CheckboxListTile(
+          secondary: const Icon(Icons.drag_handle),
+          onChanged: (e) {
+            // Add/remove items
+            if (e == true) {
+              widget.addRemovalItem(widget.item.id);
+            } else {
+              widget.removeRemovalItem(widget.item.id);
+            }
+
+            // Set checkbox state
+            setState(() {
+              _value = e;
+            });
+          },
+          value: _value,
+          title: Padding(
+            padding: const EdgeInsets.symmetric(vertical: 22, horizontal: 25),
+            child: Column(
+              mainAxisSize: MainAxisSize.min,
+              // Align to start (left)
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: <Widget>[
+                // Issuer
+                Text(widget.item.totp.issuer,
+                    style: Theme.of(context).textTheme.titleMedium),
+                // Generated code
+                Padding(
+                    padding: const EdgeInsets.symmetric(vertical: 10),
+                    child: Text(widget.item.totp.placeholder,
+                        style: Theme.of(context).textTheme.displaySmall)),
+                // Account name
+                Text(widget.item.totp.accountName,
+                    style: Theme.of(context).textTheme.bodyMedium)
+              ],
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 194 - 0
lib/pages/android/home.dart

@@ -0,0 +1,194 @@
+import '../../state/app_state.dart';
+import 'package:pb_authenticator_state/state.dart';
+import 'package:flutter/material.dart';
+import '../../helper/url.dart' show launchURL;
+import '/l10n/app_localizations.dart';
+import 'package:provider/provider.dart';
+import '../../config/routes.dart';
+import '../../l10n/constants.dart';
+import './list_item.dart' show HomeListItem;
+
+/// Android version of home page.
+class AndroidHomePage extends StatelessWidget {
+  const AndroidHomePage({super.key});
+
+  /// Builds list of items.
+  Widget _buildList() {
+    return Consumer<AppState>(
+      builder: (context, state, child) {
+        if (state.items == null) {
+          // Items loading
+          return Center(
+              child: Padding(
+                  padding: const EdgeInsets.all(10),
+                  child: Text(AppLocalizations.of(context)!.loading)));
+        } else if (state.items!.isEmpty) {
+          // No Items
+          return Center(
+              child: Padding(
+                  padding: const EdgeInsets.all(10),
+                  child: Text(AppLocalizations.of(context)!.noAccounts,
+                      textAlign: TextAlign.center,
+                      style: const TextStyle(height: 1.2))));
+        }
+
+        return Container(
+          margin: const EdgeInsets.all(10),
+          child: ListView.builder(
+            itemCount: state.items!.length,
+            itemBuilder: (BuildContext context, int index) {
+              var item = state.items![index];
+              return HomeListItem(item, key: Key(item.id));
+            },
+          ),
+        );
+      },
+    );
+  }
+
+  /// Shows add modal
+  void showAddModal(BuildContext parentContext) {
+    // Show bottom sheet
+    showModalBottomSheet<void>(
+      context: parentContext,
+      builder: (BuildContext context) {
+        return Container(
+          padding: const EdgeInsets.all(8.0),
+          child: ListView(
+            shrinkWrap: true,
+            children: <Widget>[
+              // Scan QR
+              ListTile(
+                title: Text(AppLocalizations.of(context)!.addScanQR),
+                leading: const Icon(Icons.camera_alt),
+                onTap: () async {
+                  Navigator.pop(context);
+
+                  var result =
+                      await Navigator.pushNamed(context, AppRoutes.addScan);
+                  if (result == null) {
+                    return;
+                  }
+                  // TODO: Transition to new type
+                  var item = result as LegacyAuthenticatorItem;
+                  await Provider.of<AppState>(parentContext, listen: false)
+                      .addItem(item.totp);
+                },
+              ),
+              // Add account manually
+              ListTile(
+                title: Text(AppLocalizations.of(context)!.addManualInput),
+                leading: const Icon(Icons.keyboard_return),
+                onTap: () {
+                  Navigator.pop(context);
+                  Navigator.pushNamed(context, AppRoutes.add);
+                },
+              ),
+            ],
+          ),
+        );
+      },
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      drawer: Drawer(
+        child: ListView(
+          padding: EdgeInsets.zero,
+          children: <Widget>[
+            DrawerHeader(
+              decoration: BoxDecoration(
+                color: Theme.of(context).primaryColor,
+              ),
+              child: Row(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: [
+                  Text(
+                    AppLocalizations.of(context)!.appName,
+                    style: TextStyle(
+                        color: Colors.white,
+                        fontSize:
+                            Theme.of(context).textTheme.titleLarge!.fontSize),
+                  ),
+                ],
+              ),
+            ),
+            // Settings
+            ListTile(
+                title: Text(AppLocalizations.of(context)!.settingsTitle),
+                leading: const Icon(Icons.settings),
+                dense: true,
+                onTap: () {
+                  Navigator.pop(context);
+                  Navigator.pushNamed(context, AppRoutes.settings);
+                }),
+            // How it works
+            ListTile(
+              title: Text(AppLocalizations.of(context)!.howItWorksTitle ?? 'How it works'),
+              leading: const Icon(Icons.info_outline),
+              dense: true,
+              onTap: () {
+                Navigator.pop(context);
+                Navigator.pushNamed(context, AppRoutes.howItWorks);
+              },
+            ),
+            // Transfer codes
+            ListTile(
+              title: Text(AppLocalizations.of(context)!.transferCodesTitle ?? 'Transfer codes'),
+              leading: const Icon(Icons.sync_alt),
+              dense: true,
+              onTap: () {
+                Navigator.pop(context);
+                Navigator.pushNamed(context, AppRoutes.transferCodes);
+              },
+            ),
+            const Divider(height: 10),
+            // Source code (in-app browser link)
+            ListTile(
+                title: Text(AppLocalizations.of(context)!.source),
+                leading: const Icon(Icons.code),
+                dense: true,
+                onTap: () {
+                  Navigator.pop(context);
+                  launchURL(Constants.repoUrl);
+                }),
+            // License
+            ListTile(
+              title: Text(AppLocalizations.of(context)!.licenses),
+              leading: const Icon(Icons.book),
+              dense: true,
+              onTap: () {
+                Navigator.pop(context);
+                Navigator.pushNamed(context, AppRoutes.settingAcknowledgements);
+              },
+            ),
+          ],
+        ),
+      ),
+      appBar: AppBar(
+        title: Text(AppLocalizations.of(context)!.appName),
+        actions: <Widget>[
+          // Edit
+          IconButton(
+            icon: const Icon(Icons.edit),
+            tooltip: AppLocalizations.of(context)!.edit,
+            onPressed: () {
+              Navigator.pushNamed(context, AppRoutes.edit);
+            },
+          ),
+          // Add
+          IconButton(
+            icon: const Icon(Icons.add),
+            tooltip: AppLocalizations.of(context)!.add,
+            onPressed: () {
+              showAddModal(context);
+            },
+          )
+        ],
+      ),
+      body: _buildList(),
+    );
+  }
+}

+ 122 - 0
lib/pages/android/list_item.dart

@@ -0,0 +1,122 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart' show Clipboard, ClipboardData;
+import '/l10n/app_localizations.dart';
+import '../shared/list_item_base.dart' show TotpListItemBase;
+
+/// Home page list item (Android).
+class HomeListItem extends TotpListItemBase {
+  const HomeListItem(super.item, {required Key super.key});
+
+  @override
+  State<StatefulWidget> createState() => _TOTPListItemState();
+}
+
+class _TOTPListItemState extends State<HomeListItem>
+    with SingleTickerProviderStateMixin {
+  // Dimension of progress indicator
+  static const double _progressIndicatorDimension = 25;
+
+  // Animation for indicator/code
+  late AnimationController _controller;
+  late Animation<double> _animation;
+
+  // Code that is being displayed
+  String _code = "";
+
+  @override
+  void dispose() {
+    _controller.dispose();
+    super.dispose();
+  }
+
+  @override
+  void initState() {
+    super.initState();
+
+    // Set initial code
+    setState(() {
+      _code = widget.code;
+    });
+
+    // Define animation
+    // Adapted from progress_indicator_demo.dart from flutter examples
+    _controller = AnimationController(
+      duration: Duration(seconds: widget.item.totp.period),
+      lowerBound: 0.0,
+      upperBound: 1.0,
+      vsync: this,
+      animationBehavior: AnimationBehavior.preserve,
+    );
+    _controller.forward(from: widget.indicatorValue);
+    _animation = Tween(begin: 0.0, end: 1.0).animate(_controller)
+      ..addStatusListener(
+        (AnimationStatus status) {
+          if (status == AnimationStatus.completed) {
+            // Set code
+            setState(() {
+              _code = widget.code;
+            });
+            // Reset animation
+            _controller.forward(from: widget.indicatorValue);
+          }
+        },
+      );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Hero(
+      tag: widget.item.id,
+      child: Card(
+        child: InkWell(
+          onTap: () {
+            Clipboard.setData(ClipboardData(text: widget.codeUnformatted));
+            ScaffoldMessenger.of(context).removeCurrentSnackBar();
+            ScaffoldMessenger.of(context).showSnackBar(SnackBar(
+                content: Text(AppLocalizations.of(context)!.clipboard),
+                duration: const Duration(seconds: 1)));
+          },
+          child: Padding(
+            padding: const EdgeInsets.all(25),
+            child: SingleChildScrollView(
+              padding: const EdgeInsets.all(5),
+              child: Stack(
+                children: <Widget>[
+                  Column(
+                    // Align to start (left)
+                    crossAxisAlignment: CrossAxisAlignment.start,
+                    children: <Widget>[
+                      // Issuer
+                      Text(widget.item.totp.issuer,
+                          style: Theme.of(context).textTheme.titleMedium),
+                      // Generated code
+                      Padding(
+                          padding: const EdgeInsets.symmetric(vertical: 10),
+                          child: Text(_code,
+                              style: Theme.of(context).textTheme.displaySmall)),
+                      // Account name
+                      Text(widget.item.totp.accountName,
+                          style: Theme.of(context).textTheme.bodyMedium)
+                    ],
+                  ),
+                  Positioned(
+                      right: 0,
+                      bottom: 0,
+                      height: _progressIndicatorDimension,
+                      width: _progressIndicatorDimension,
+                      child: AnimatedBuilder(
+                          animation: _animation,
+                          builder: (BuildContext context, Widget? child) =>
+                              CircularProgressIndicator(
+                                value: _animation.value,
+                                strokeWidth: 2.5,
+                              )))
+                ],
+              ),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 54 - 0
lib/pages/how_it_works.dart

@@ -0,0 +1,54 @@
+import 'package:flutter/material.dart';
+import 'package:introduction_screen/introduction_screen.dart';
+import '../l10n/app_localizations.dart';
+
+class HowItWorksPage extends StatelessWidget {
+  const HowItWorksPage({super.key});
+
+  List<PageViewModel> _getPages(BuildContext context) {
+    return [
+      PageViewModel(
+        title: AppLocalizations.of(context)?.howItWorksTitle1 ?? 'Secure your accounts',
+        body: AppLocalizations.of(context)?.howItWorksBody1 ?? 'Add your accounts to generate time-based one-time passwords (TOTP) for secure sign-in.',
+        image: const Icon(Icons.security, size: 120, color: Colors.blue),
+      ),
+      PageViewModel(
+        title: AppLocalizations.of(context)?.howItWorksTitle2 ?? 'Scan QR codes',
+        body: AppLocalizations.of(context)?.howItWorksBody2 ?? 'Easily add new accounts by scanning QR codes provided by your services.',
+        image: const Icon(Icons.qr_code_scanner, size: 120, color: Colors.green),
+      ),
+      PageViewModel(
+        title: AppLocalizations.of(context)?.howItWorksTitle3 ?? 'Backup & Restore',
+        body: AppLocalizations.of(context)?.howItWorksBody3 ?? 'Backup your accounts securely and restore them when needed.',
+        image: const Icon(Icons.backup, size: 120, color: Colors.orange),
+      ),
+      PageViewModel(
+        title: AppLocalizations.of(context)?.howItWorksTitle4 ?? 'Offline & Private',
+        body: AppLocalizations.of(context)?.howItWorksBody4 ?? 'All data stays on your device. No internet required for generating codes.',
+        image: const Icon(Icons.lock, size: 120, color: Colors.purple),
+      ),
+    ];
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return IntroductionScreen(
+      pages: _getPages(context),
+      showSkipButton: true,
+      skip: Text(AppLocalizations.of(context)?.skip ?? 'Skip'),
+      next: const Icon(Icons.arrow_forward),
+      done: Text(AppLocalizations.of(context)?.done ?? 'Done', style: const TextStyle(fontWeight: FontWeight.w600)),
+      onDone: () => Navigator.of(context).pop(),
+      onSkip: () => Navigator.of(context).pop(),
+      dotsDecorator: const DotsDecorator(
+        size: Size(10.0, 10.0),
+        color: Colors.black26,
+        activeSize: Size(22.0, 10.0),
+        activeColor: Colors.blue,
+        activeShape: RoundedRectangleBorder(
+          borderRadius: BorderRadius.all(Radius.circular(25.0)),
+        ),
+      ),
+    );
+  }
+} 

+ 6 - 0
lib/pages/pages.dart

@@ -0,0 +1,6 @@
+export './add.dart';
+export './qr.dart';
+export './acknowledgements.dart';
+export './settings.dart';
+export './android/home.dart';
+export './android/edit.dart';

+ 88 - 0
lib/pages/qr.dart

@@ -0,0 +1,88 @@
+import 'dart:async' show Future;
+import '../config/routes.dart';
+import '../state/app_state.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart' show PlatformException;
+import '../l10n/app_localizations.dart';
+import '../ui/adaptive.dart' show AppScaffold, AdaptiveDialogAction;
+import 'package:barcode_scan2/barcode_scan2.dart' show BarcodeScanner;
+
+/// Page for adding accounts by scanning QR.
+class ScanQRPage extends StatefulWidget {
+  const ScanQRPage({super.key});
+
+  @override
+  State<ScanQRPage> createState() => _ScanQRPageState();
+}
+
+class _ScanQRPageState extends State<ScanQRPage> {
+  @override
+  void initState() {
+    super.initState();
+
+    // Trigger scan
+    WidgetsBinding.instance.addPostFrameCallback(
+      (_) async {
+        try {
+          final value = await _scan();
+          // Parse scanned value into item and pop
+          var item = BaseItemType.newAuthenticatorItemFromUri(value);
+          // Pop until scan page
+          Navigator.of(context)
+              .popUntil(ModalRoute.withName(AppRoutes.addScan));
+          // Pop with scanned item
+          Navigator.of(context).pop(item);
+        } catch (e) {
+          await showAdaptiveDialog(
+              context: context,
+              builder: (BuildContext context) {
+                return AlertDialog.adaptive(
+                  title: Text(AppLocalizations.of(context)!.error),
+                  content: Text(e.toString()),
+                  actions: [
+                    AdaptiveDialogAction(
+                      child: Text(AppLocalizations.of(context)!.ok),
+                      onPressed: () {
+                        // Pop dialog
+                        Navigator.of(context).pop();
+                      },
+                    )
+                  ],
+                );
+              });
+          Navigator.of(context)
+              .popUntil(ModalRoute.withName(AppRoutes.addScan));
+          Navigator.of(context).pop();
+        }
+      },
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return AppScaffold(
+      title: Text(AppLocalizations.of(context)!.addScanQR),
+      body: const Center(),
+    );
+  }
+
+  // Scans and returns scanned QR code
+  // Adapted from documentation of flutter_barcode_reader
+  Future _scan() async {
+    try {
+      var barcode = await BarcodeScanner.scan();
+      return barcode.rawContent;
+    } on PlatformException catch (e) {
+      if (e.code == BarcodeScanner.cameraAccessDenied) {
+        return Future.error(
+            AppLocalizations.of(context)!.errNoCameraPermission);
+      } else {
+        return Future.error('${AppLocalizations.of(context)!.errUnknown} $e');
+      }
+    } on FormatException {
+      return Future.error(AppLocalizations.of(context)!.errIncorrectFormat);
+    } catch (e) {
+      return Future.error('${AppLocalizations.of(context)!.errUnknown} $e');
+    }
+  }
+}

+ 108 - 0
lib/pages/settings.dart

@@ -0,0 +1,108 @@
+import 'package:flutter/widgets.dart';
+import 'package:flutter/material.dart' show Icons, ListTile, Material;
+import 'package:flutter/cupertino.dart' show CupertinoIcons;
+import '../l10n/app_localizations.dart';
+import '../ui/adaptive.dart' show AppScaffold, isPlatformAndroid;
+import '../helper/url.dart' show launchURL;
+import 'package:package_info_plus/package_info_plus.dart' show PackageInfo;
+import '../config/routes.dart';
+import '../l10n/constants.dart';
+
+/// Settings page.
+class SettingsPage extends StatelessWidget {
+  const SettingsPage({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return AppScaffold(
+      title: Text(AppLocalizations.of(context)!.settingsTitle),
+      body: Column(
+        children: <Widget>[
+          // App Info
+          Padding(
+            padding: const EdgeInsets.symmetric(vertical: 15, horizontal: 15),
+            child: Row(
+              mainAxisAlignment: MainAxisAlignment.center,
+              children: <Widget>[
+                // App image
+                Container(
+                  height: 125,
+                  width: 150,
+                  padding: const EdgeInsets.symmetric(horizontal: 10),
+                  child: const DecoratedBox(
+                    decoration: BoxDecoration(
+                      image: DecorationImage(
+                        image: AssetImage('graphics/icon.png'),
+                      ),
+                    ),
+                  ),
+                ),
+                SizedBox(
+                  width: 160,
+                  child: Column(
+                    crossAxisAlignment: CrossAxisAlignment.start,
+                    children: <Widget>[
+                      // Name
+                      Padding(
+                          padding: const EdgeInsets.symmetric(vertical: 7),
+                          child: Text(AppLocalizations.of(context)!.appName,
+                              style: const TextStyle(fontSize: 25))),
+                      // Version of app
+                      FutureBuilder(
+                        future: PackageInfo.fromPlatform(),
+                        builder: (BuildContext context,
+                            AsyncSnapshot<PackageInfo> snapshot) {
+                          if (snapshot.hasData) {
+                            return Text(snapshot.data!.version,
+                                style: const TextStyle(fontSize: 14));
+                          }
+                          return Text(AppLocalizations.of(context)!.loading);
+                        },
+                      ),
+                    ],
+                  ),
+                )
+              ],
+            ),
+          ),
+
+          const SizedBox(height: 5),
+
+          // List of options
+          Expanded(
+            child: Material(
+              child: ListView(
+                physics: const NeverScrollableScrollPhysics(),
+                children: [
+                  // Source
+                  ListTile(
+                    dense: true,
+                    leading: const Icon(Icons.code),
+                    title: Text(AppLocalizations.of(context)!.source,
+                        style: const TextStyle(fontSize: 15)),
+                    onTap: () {
+                      launchURL(Constants.repoUrl);
+                    },
+                  ),
+                  // Acknowledgements
+                  ListTile(
+                    dense: true,
+                    leading: isPlatformAndroid()
+                        ? const Icon(Icons.book)
+                        : const Icon(CupertinoIcons.book),
+                    title: Text(AppLocalizations.of(context)!.licenses,
+                        style: const TextStyle(fontSize: 15)),
+                    onTap: () {
+                      Navigator.of(context)
+                          .pushNamed(AppRoutes.settingAcknowledgements);
+                    },
+                  ),
+                ],
+              ),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 35 - 0
lib/pages/shared/list_item_base.dart

@@ -0,0 +1,35 @@
+import '../../state/app_state.dart';
+import 'package:flutter/widgets.dart';
+
+/// Abstract base class for TOTP list item.
+///
+/// Contains useful methods which are used to display the item.
+abstract class TotpListItemBase extends StatefulWidget {
+  const TotpListItemBase(this.item, {super.key});
+
+  /// The item/account that is displayed.
+  final BaseItemType item;
+
+  /// Seconds since epoch.
+  static int get _secondsSinceEpoch {
+    return DateTime.now().millisecondsSinceEpoch ~/ 1000;
+  }
+
+  /// Progress indicator value.
+  double get indicatorValue {
+    return (_secondsSinceEpoch % item.totp.period) / item.totp.period;
+  }
+
+  /// Returns the code of the item for the current time.
+  String get codeUnformatted {
+    return item.totp.getCode(_secondsSinceEpoch);
+  }
+
+  /// Returns the code of the item for the current time in formatted form.
+  String get code {
+    return item.totp.getPrettyCode(_secondsSinceEpoch);
+  }
+
+  @override
+  State<StatefulWidget> createState();
+}

+ 64 - 0
lib/pages/transfer_codes.dart

@@ -0,0 +1,64 @@
+import 'package:flutter/material.dart';
+import '../l10n/app_localizations.dart';
+
+class TransferCodesPage extends StatelessWidget {
+  const TransferCodesPage({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      appBar: AppBar(
+        title: Text(AppLocalizations.of(context)?.transferCodesTitle ?? 'Transfer codes'),
+      ),
+      body: Padding(
+        padding: const EdgeInsets.all(24.0),
+        child: Column(
+          mainAxisAlignment: MainAxisAlignment.center,
+          children: [
+            Text(
+              AppLocalizations.of(context)?.transferCodesDescription ?? 'Transfer your accounts to another device.',
+              style: Theme.of(context).textTheme.titleMedium,
+              textAlign: TextAlign.center,
+            ),
+            const SizedBox(height: 40),
+            ElevatedButton.icon(
+              icon: const Icon(Icons.upload_file, size: 32),
+              label: Padding(
+                padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8.0),
+                child: Text(
+                  AppLocalizations.of(context)?.exportAccounts ?? 'Export accounts',
+                  style: const TextStyle(fontSize: 18),
+                ),
+              ),
+              style: ElevatedButton.styleFrom(
+                minimumSize: const Size.fromHeight(60),
+                shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+              ),
+              onPressed: () {
+                // TODO: Navigate to export page
+              },
+            ),
+            const SizedBox(height: 24),
+            ElevatedButton.icon(
+              icon: const Icon(Icons.download, size: 32),
+              label: Padding(
+                padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8.0),
+                child: Text(
+                  AppLocalizations.of(context)?.importAccounts ?? 'Import accounts',
+                  style: const TextStyle(fontSize: 18),
+                ),
+              ),
+              style: ElevatedButton.styleFrom(
+                minimumSize: const Size.fromHeight(60),
+                shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+              ),
+              onPressed: () {
+                // TODO: Navigate to import page
+              },
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+} 

+ 46 - 0
lib/state/app_state.dart

@@ -0,0 +1,46 @@
+import 'package:pb_authenticator_state/state.dart';
+import 'package:pb_authenticator_totp/totp.dart';
+import 'package:flutter/widgets.dart';
+import 'package:collection/collection.dart' show ListEquality;
+
+// TODO: Transition to new type
+typedef BaseItemType = LegacyAuthenticatorItem;
+
+/// Represents app state.
+class AppState extends ChangeNotifier {
+  final RepositoryBase<BaseItemType> _repository;
+
+  AppState(this._repository);
+
+  /// List of TOTP items (internal implementation).
+  List<BaseItemType>? _items;
+
+  /// TOTP items as list.
+  List<BaseItemType>? get items {
+    if (_items == null) {
+      loadItems();
+    }
+    return _items;
+  }
+
+  Future loadItems() async {
+    _items = await _repository.loadItems();
+    notifyListeners();
+  }
+
+  /// Adds a TOTP item to the list.
+  Future addItem(TotpItem item) async {
+    await _repository.addItem(item);
+    await loadItems();
+  }
+
+  /// Replace list of TOTP items.
+  Future replaceItems(List<BaseItemType> items) async {
+    await _repository.replaceItems(items);
+    await loadItems();
+  }
+
+  bool itemsChanged(List<BaseItemType>? newItems) {
+    return !const ListEquality().equals(items, newItems);
+  }
+}

+ 50 - 0
lib/state/file_storage.dart

@@ -0,0 +1,50 @@
+import 'dart:async' show Future;
+import 'dart:io' show File;
+import 'package:pb_authenticator_state/file_storage_base.dart';
+import 'package:path_provider/path_provider.dart'
+    show getApplicationDocumentsDirectory;
+
+/// Loads/stores file to application storage directory
+///
+/// From:
+/// * https://flutter.io/docs/cookbook/persistence/reading-writing-files
+class FileStorage extends FileStorageBase {
+  /// Instantiates instance of FileStorage
+  FileStorage();
+
+  /// Gets path of Application Documents Directory.
+  @override
+  Future<String> getDataPath() async {
+    final directory = await getApplicationDocumentsDirectory();
+    return directory.path;
+  }
+
+  Future<File> _filePath(String filename) async {
+    var path = await getDataPath();
+    return File('$path/$filename');
+  }
+
+  /// Whether file is present.
+  @override
+  Future<bool> hasFile(String filename) async {
+    final file = await _filePath(filename);
+    return file.exists();
+  }
+
+  /// Reads from file into String.
+  @override
+  Future<String?> readFile(String filename) async {
+    final file = await _filePath(filename);
+    if (!await file.exists()) {
+      return null;
+    }
+    return file.readAsString();
+  }
+
+  /// Writes to file.
+  @override
+  Future writeFile(String filename, String contents) async {
+    final file = await _filePath(filename);
+    await file.writeAsString(contents, flush: true);
+  }
+}