mirror of
				https://github.com/ImranR98/Obtainium.git
				synced 2025-11-04 07:13:28 +01:00 
			
		
		
		
	Compare commits
	
		
			28 Commits
		
	
	
		
			v0.1.7-bet
			...
			v0.2.2-bet
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					2272f8b4e6 | ||
| 
						 | 
					9514062a3a | ||
| 
						 | 
					da57018b90 | ||
| 
						 | 
					87e31c37aa | ||
| 
						 | 
					cb4dfff1b9 | ||
| 
						 | 
					911b06bfb6 | ||
| 
						 | 
					53513bfdd1 | ||
| 
						 | 
					681092d895 | ||
| 
						 | 
					0f6b6253de | ||
| 
						 | 
					c724b276ab | ||
| 
						 | 
					35369273bd | ||
| 
						 | 
					0b1863a227 | ||
| 
						 | 
					9e21f2d6e6 | ||
| 
						 | 
					6f11f850e0 | ||
| 
						 | 
					5e96b91029 | ||
| 
						 | 
					5fc79af960 | ||
| 
						 | 
					05f5590e7d | ||
| 
						 | 
					50f8caeb47 | ||
| 
						 | 
					f966a9e626 | ||
| 
						 | 
					02a5749ba7 | ||
| 
						 | 
					4ccf7cbc92 | ||
| 
						 | 
					ab4efd85ce | ||
| 
						 | 
					42bba0f64c | ||
| 
						 | 
					294327bde4 | ||
| 
						 | 
					52b97662c6 | ||
| 
						 | 
					f63da4b538 | ||
| 
						 | 
					c30c692d87 | ||
| 
						 | 
					d643d5a474 | 
@@ -10,13 +10,14 @@ Currently supported App sources:
 | 
			
		||||
- [GitHub](https://github.com/)
 | 
			
		||||
- [GitLab](https://gitlab.com/)
 | 
			
		||||
- [F-Droid](https://f-droid.org/)
 | 
			
		||||
- [IzzyOnDroid](https://android.izzysoft.de/)
 | 
			
		||||
- [Mullvad](https://mullvad.net/en/)
 | 
			
		||||
- [Signal](https://signal.org/)
 | 
			
		||||
 | 
			
		||||
## Limitations
 | 
			
		||||
- App installs are assumed to have succeeded; failures and cancelled installs cannot be detected.
 | 
			
		||||
- Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin.
 | 
			
		||||
- For some sources, data is gathered using Web scraping and can easily break due to changes in website design. In such cases, more reliable methods are either unavailable (e.g. Mullvad), insufficient (e.g. GitHub RSS) or subject to rate limits (e.g. GitHub API).
 | 
			
		||||
- For some sources, data is gathered using Web scraping and can easily break due to changes in website design. In such cases, more reliable methods may be unavailable.
 | 
			
		||||
 | 
			
		||||
## Screenshots
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										29
									
								
								lib/components/custom_app_bar.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								lib/components/custom_app_bar.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,29 @@
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
 | 
			
		||||
class CustomAppBar extends StatefulWidget {
 | 
			
		||||
  const CustomAppBar({super.key, required this.title});
 | 
			
		||||
 | 
			
		||||
  final String title;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<CustomAppBar> createState() => _CustomAppBarState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _CustomAppBarState extends State<CustomAppBar> {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return SliverAppBar(
 | 
			
		||||
      pinned: true,
 | 
			
		||||
      automaticallyImplyLeading: false,
 | 
			
		||||
      expandedHeight: 100,
 | 
			
		||||
      flexibleSpace: FlexibleSpaceBar(
 | 
			
		||||
        titlePadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
 | 
			
		||||
        title: Text(
 | 
			
		||||
          widget.title,
 | 
			
		||||
          style:
 | 
			
		||||
              TextStyle(color: Theme.of(context).textTheme.bodyMedium!.color),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										80
									
								
								lib/components/generated_form_modal.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								lib/components/generated_form_modal.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,80 @@
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
 | 
			
		||||
class GeneratedFormItem {
 | 
			
		||||
  late String message;
 | 
			
		||||
  late bool required;
 | 
			
		||||
  late int lines;
 | 
			
		||||
 | 
			
		||||
  GeneratedFormItem(this.message, this.required, this.lines);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class GeneratedFormModal extends StatefulWidget {
 | 
			
		||||
  const GeneratedFormModal(
 | 
			
		||||
      {super.key, required this.title, required this.items});
 | 
			
		||||
 | 
			
		||||
  final String title;
 | 
			
		||||
  final List<GeneratedFormItem> items;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<GeneratedFormModal> createState() => _GeneratedFormModalState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _GeneratedFormModalState extends State<GeneratedFormModal> {
 | 
			
		||||
  final _formKey = GlobalKey<FormState>();
 | 
			
		||||
 | 
			
		||||
  final urlInputController = TextEditingController();
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final formInputs = widget.items.map((e) {
 | 
			
		||||
      final controller = TextEditingController();
 | 
			
		||||
      return [
 | 
			
		||||
        controller,
 | 
			
		||||
        TextFormField(
 | 
			
		||||
          decoration: InputDecoration(helperText: e.message),
 | 
			
		||||
          controller: controller,
 | 
			
		||||
          minLines: e.lines <= 1 ? null : e.lines,
 | 
			
		||||
          maxLines: e.lines <= 1 ? 1 : e.lines,
 | 
			
		||||
          validator: e.required
 | 
			
		||||
              ? (value) {
 | 
			
		||||
                  if (value == null || value.isEmpty) {
 | 
			
		||||
                    return '${e.message} (required)';
 | 
			
		||||
                  }
 | 
			
		||||
                  return null;
 | 
			
		||||
                }
 | 
			
		||||
              : null,
 | 
			
		||||
        )
 | 
			
		||||
      ];
 | 
			
		||||
    }).toList();
 | 
			
		||||
    return AlertDialog(
 | 
			
		||||
      scrollable: true,
 | 
			
		||||
      title: Text(widget.title),
 | 
			
		||||
      content: Form(
 | 
			
		||||
          key: _formKey,
 | 
			
		||||
          child: Column(
 | 
			
		||||
            crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
            children: [...formInputs.map((e) => e[1] as Widget)],
 | 
			
		||||
          )),
 | 
			
		||||
      actions: [
 | 
			
		||||
        TextButton(
 | 
			
		||||
            onPressed: () {
 | 
			
		||||
              Navigator.of(context).pop(null);
 | 
			
		||||
            },
 | 
			
		||||
            child: const Text('Cancel')),
 | 
			
		||||
        TextButton(
 | 
			
		||||
            onPressed: () {
 | 
			
		||||
              if (_formKey.currentState?.validate() == true) {
 | 
			
		||||
                HapticFeedback.selectionClick();
 | 
			
		||||
                Navigator.of(context).pop(formInputs
 | 
			
		||||
                    .map((e) => (e[0] as TextEditingController).value.text)
 | 
			
		||||
                    .toList());
 | 
			
		||||
              }
 | 
			
		||||
            },
 | 
			
		||||
            child: const Text('Continue'))
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TODO: Add support for larger textarea so this can be used for text/json imports
 | 
			
		||||
@@ -9,9 +9,10 @@ import 'package:permission_handler/permission_handler.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:workmanager/workmanager.dart';
 | 
			
		||||
import 'package:dynamic_color/dynamic_color.dart';
 | 
			
		||||
import 'package:device_info_plus/device_info_plus.dart';
 | 
			
		||||
 | 
			
		||||
const String currentReleaseTag =
 | 
			
		||||
    'v0.1.7-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
 | 
			
		||||
    'v0.2.2-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
 | 
			
		||||
 | 
			
		||||
@pragma('vm:entry-point')
 | 
			
		||||
void bgTaskCallback() {
 | 
			
		||||
@@ -43,10 +44,12 @@ void bgTaskCallback() {
 | 
			
		||||
 | 
			
		||||
void main() async {
 | 
			
		||||
  WidgetsFlutterBinding.ensureInitialized();
 | 
			
		||||
  SystemChrome.setSystemUIOverlayStyle(
 | 
			
		||||
    const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
 | 
			
		||||
  );
 | 
			
		||||
  SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
 | 
			
		||||
  if ((await DeviceInfoPlugin().androidInfo).version.sdkInt! >= 29) {
 | 
			
		||||
    SystemChrome.setSystemUIOverlayStyle(
 | 
			
		||||
      const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
 | 
			
		||||
    );
 | 
			
		||||
    SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
 | 
			
		||||
  }
 | 
			
		||||
  Workmanager().initialize(
 | 
			
		||||
    bgTaskCallback,
 | 
			
		||||
  );
 | 
			
		||||
@@ -78,11 +81,15 @@ class MyApp extends StatelessWidget {
 | 
			
		||||
      settingsProvider.initializeSettings();
 | 
			
		||||
    } else {
 | 
			
		||||
      // Register the background update task according to the user's setting
 | 
			
		||||
      Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check',
 | 
			
		||||
          frequency: Duration(minutes: settingsProvider.updateInterval),
 | 
			
		||||
          initialDelay: Duration(minutes: settingsProvider.updateInterval),
 | 
			
		||||
          constraints: Constraints(networkType: NetworkType.connected),
 | 
			
		||||
          existingWorkPolicy: ExistingWorkPolicy.replace);
 | 
			
		||||
      if (settingsProvider.updateInterval > 0) {
 | 
			
		||||
        Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check',
 | 
			
		||||
            frequency: Duration(minutes: settingsProvider.updateInterval),
 | 
			
		||||
            initialDelay: Duration(minutes: settingsProvider.updateInterval),
 | 
			
		||||
            constraints: Constraints(networkType: NetworkType.connected),
 | 
			
		||||
            existingWorkPolicy: ExistingWorkPolicy.replace);
 | 
			
		||||
      } else {
 | 
			
		||||
        Workmanager().cancelByUniqueName('bg-update-check');
 | 
			
		||||
      }
 | 
			
		||||
      bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
 | 
			
		||||
      if (isFirstRun) {
 | 
			
		||||
        // If this is the first run, ask for notification permissions and add Obtainium to the Apps list
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:obtainium/components/custom_app_bar.dart';
 | 
			
		||||
import 'package:obtainium/pages/app.dart';
 | 
			
		||||
import 'package:obtainium/providers/apps_provider.dart';
 | 
			
		||||
import 'package:obtainium/providers/settings_provider.dart';
 | 
			
		||||
@@ -22,113 +23,125 @@ class _AddAppPageState extends State<AddAppPage> {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    SourceProvider sourceProvider = SourceProvider();
 | 
			
		||||
    return Center(
 | 
			
		||||
      child: Form(
 | 
			
		||||
          key: _formKey,
 | 
			
		||||
          child: Column(
 | 
			
		||||
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
 | 
			
		||||
            crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
            children: [
 | 
			
		||||
              Container(),
 | 
			
		||||
              Padding(
 | 
			
		||||
                padding: const EdgeInsets.all(16),
 | 
			
		||||
    return CustomScrollView(slivers: <Widget>[
 | 
			
		||||
      const CustomAppBar(title: 'Add App'),
 | 
			
		||||
      SliverFillRemaining(
 | 
			
		||||
          hasScrollBody: false,
 | 
			
		||||
          child: Center(
 | 
			
		||||
            child: Form(
 | 
			
		||||
                key: _formKey,
 | 
			
		||||
                child: Column(
 | 
			
		||||
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
 | 
			
		||||
                  crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    TextFormField(
 | 
			
		||||
                      decoration: const InputDecoration(
 | 
			
		||||
                          hintText: 'https://github.com/Author/Project',
 | 
			
		||||
                          helperText: 'Enter the App source URL'),
 | 
			
		||||
                      controller: urlInputController,
 | 
			
		||||
                      validator: (value) {
 | 
			
		||||
                        if (value == null ||
 | 
			
		||||
                            value.isEmpty ||
 | 
			
		||||
                            Uri.tryParse(value) == null) {
 | 
			
		||||
                          return 'Please enter a supported source URL';
 | 
			
		||||
                        }
 | 
			
		||||
                        return null;
 | 
			
		||||
                      },
 | 
			
		||||
                    ),
 | 
			
		||||
                    Container(),
 | 
			
		||||
                    Padding(
 | 
			
		||||
                      padding: const EdgeInsets.symmetric(vertical: 16.0),
 | 
			
		||||
                      child: ElevatedButton(
 | 
			
		||||
                        onPressed: gettingAppInfo
 | 
			
		||||
                            ? null
 | 
			
		||||
                            : () {
 | 
			
		||||
                                HapticFeedback.mediumImpact();
 | 
			
		||||
                                if (_formKey.currentState!.validate()) {
 | 
			
		||||
                                  setState(() {
 | 
			
		||||
                                    gettingAppInfo = true;
 | 
			
		||||
                                  });
 | 
			
		||||
                                  sourceProvider
 | 
			
		||||
                                      .getApp(urlInputController.value.text)
 | 
			
		||||
                                      .then((app) {
 | 
			
		||||
                                    var appsProvider =
 | 
			
		||||
                                        context.read<AppsProvider>();
 | 
			
		||||
                                    var settingsProvider =
 | 
			
		||||
                                        context.read<SettingsProvider>();
 | 
			
		||||
                                    if (appsProvider.apps.containsKey(app.id)) {
 | 
			
		||||
                                      throw 'App already added';
 | 
			
		||||
                                    }
 | 
			
		||||
                                    settingsProvider
 | 
			
		||||
                                        .getInstallPermission()
 | 
			
		||||
                                        .then((_) {
 | 
			
		||||
                                      appsProvider.saveApp(app).then((_) {
 | 
			
		||||
                                        urlInputController.clear();
 | 
			
		||||
                                        Navigator.push(
 | 
			
		||||
                                            context,
 | 
			
		||||
                                            MaterialPageRoute(
 | 
			
		||||
                                                builder: (context) =>
 | 
			
		||||
                                                    AppPage(appId: app.id)));
 | 
			
		||||
                                      });
 | 
			
		||||
                                    });
 | 
			
		||||
                                  }).catchError((e) {
 | 
			
		||||
                                    ScaffoldMessenger.of(context).showSnackBar(
 | 
			
		||||
                                      SnackBar(content: Text(e.toString())),
 | 
			
		||||
                                    );
 | 
			
		||||
                                  }).whenComplete(() {
 | 
			
		||||
                                    setState(() {
 | 
			
		||||
                                      gettingAppInfo = false;
 | 
			
		||||
                                    });
 | 
			
		||||
                                  });
 | 
			
		||||
                                }
 | 
			
		||||
                              },
 | 
			
		||||
                        child: const Text('Add'),
 | 
			
		||||
                      padding: const EdgeInsets.all(16),
 | 
			
		||||
                      child: Column(
 | 
			
		||||
                        crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
                        children: [
 | 
			
		||||
                          TextFormField(
 | 
			
		||||
                            decoration: const InputDecoration(
 | 
			
		||||
                                hintText: 'https://github.com/Author/Project',
 | 
			
		||||
                                helperText: 'Enter the App source URL'),
 | 
			
		||||
                            controller: urlInputController,
 | 
			
		||||
                            validator: (value) {
 | 
			
		||||
                              if (value == null ||
 | 
			
		||||
                                  value.isEmpty ||
 | 
			
		||||
                                  Uri.tryParse(value) == null) {
 | 
			
		||||
                                return 'Please enter a supported source URL';
 | 
			
		||||
                              }
 | 
			
		||||
                              return null;
 | 
			
		||||
                            },
 | 
			
		||||
                          ),
 | 
			
		||||
                          Padding(
 | 
			
		||||
                            padding: const EdgeInsets.symmetric(vertical: 16.0),
 | 
			
		||||
                            child: ElevatedButton(
 | 
			
		||||
                              onPressed: gettingAppInfo
 | 
			
		||||
                                  ? null
 | 
			
		||||
                                  : () {
 | 
			
		||||
                                      HapticFeedback.selectionClick();
 | 
			
		||||
                                      if (_formKey.currentState!.validate()) {
 | 
			
		||||
                                        setState(() {
 | 
			
		||||
                                          gettingAppInfo = true;
 | 
			
		||||
                                        });
 | 
			
		||||
                                        sourceProvider
 | 
			
		||||
                                            .getApp(
 | 
			
		||||
                                                urlInputController.value.text)
 | 
			
		||||
                                            .then((app) {
 | 
			
		||||
                                          var appsProvider =
 | 
			
		||||
                                              context.read<AppsProvider>();
 | 
			
		||||
                                          var settingsProvider =
 | 
			
		||||
                                              context.read<SettingsProvider>();
 | 
			
		||||
                                          if (appsProvider.apps
 | 
			
		||||
                                              .containsKey(app.id)) {
 | 
			
		||||
                                            throw 'App already added';
 | 
			
		||||
                                          }
 | 
			
		||||
                                          settingsProvider
 | 
			
		||||
                                              .getInstallPermission()
 | 
			
		||||
                                              .then((_) {
 | 
			
		||||
                                            appsProvider.saveApp(app).then((_) {
 | 
			
		||||
                                              urlInputController.clear();
 | 
			
		||||
                                              Navigator.push(
 | 
			
		||||
                                                  context,
 | 
			
		||||
                                                  MaterialPageRoute(
 | 
			
		||||
                                                      builder: (context) =>
 | 
			
		||||
                                                          AppPage(
 | 
			
		||||
                                                              appId: app.id)));
 | 
			
		||||
                                            });
 | 
			
		||||
                                          });
 | 
			
		||||
                                        }).catchError((e) {
 | 
			
		||||
                                          ScaffoldMessenger.of(context)
 | 
			
		||||
                                              .showSnackBar(
 | 
			
		||||
                                            SnackBar(
 | 
			
		||||
                                                content: Text(e.toString())),
 | 
			
		||||
                                          );
 | 
			
		||||
                                        }).whenComplete(() {
 | 
			
		||||
                                          setState(() {
 | 
			
		||||
                                            gettingAppInfo = false;
 | 
			
		||||
                                          });
 | 
			
		||||
                                        });
 | 
			
		||||
                                      }
 | 
			
		||||
                                    },
 | 
			
		||||
                              child: const Text('Add'),
 | 
			
		||||
                            ),
 | 
			
		||||
                          ),
 | 
			
		||||
                        ],
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                    Column(
 | 
			
		||||
                        crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                        children: [
 | 
			
		||||
                          const Text(
 | 
			
		||||
                            'Supported Sources:',
 | 
			
		||||
                            // style: TextStyle(fontWeight: FontWeight.bold),
 | 
			
		||||
                            // style: Theme.of(context).textTheme.bodySmall,
 | 
			
		||||
                          ),
 | 
			
		||||
                          const SizedBox(
 | 
			
		||||
                            height: 8,
 | 
			
		||||
                          ),
 | 
			
		||||
                          ...sourceProvider
 | 
			
		||||
                              .getSourceHosts()
 | 
			
		||||
                              .map((e) => GestureDetector(
 | 
			
		||||
                                  onTap: () {
 | 
			
		||||
                                    launchUrlString('https://$e',
 | 
			
		||||
                                        mode: LaunchMode.externalApplication);
 | 
			
		||||
                                  },
 | 
			
		||||
                                  child: Text(
 | 
			
		||||
                                    e,
 | 
			
		||||
                                    style: const TextStyle(
 | 
			
		||||
                                        decoration: TextDecoration.underline,
 | 
			
		||||
                                        fontStyle: FontStyle.italic),
 | 
			
		||||
                                  )))
 | 
			
		||||
                              .toList()
 | 
			
		||||
                        ]),
 | 
			
		||||
                    if (gettingAppInfo)
 | 
			
		||||
                      const LinearProgressIndicator()
 | 
			
		||||
                    else
 | 
			
		||||
                      Container(),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              Column(crossAxisAlignment: CrossAxisAlignment.center, children: [
 | 
			
		||||
                const Text(
 | 
			
		||||
                  'Supported Sources:',
 | 
			
		||||
                  // style: TextStyle(fontWeight: FontWeight.bold),
 | 
			
		||||
                  // style: Theme.of(context).textTheme.bodySmall,
 | 
			
		||||
                ),
 | 
			
		||||
                const SizedBox(
 | 
			
		||||
                  height: 8,
 | 
			
		||||
                ),
 | 
			
		||||
                ...sourceProvider
 | 
			
		||||
                    .getSourceHosts()
 | 
			
		||||
                    .map((e) => GestureDetector(
 | 
			
		||||
                        onTap: () {
 | 
			
		||||
                          launchUrlString('https://$e',
 | 
			
		||||
                              mode: LaunchMode.externalApplication);
 | 
			
		||||
                        },
 | 
			
		||||
                        child: Text(
 | 
			
		||||
                          e,
 | 
			
		||||
                          style: const TextStyle(
 | 
			
		||||
                              decoration: TextDecoration.underline,
 | 
			
		||||
                              fontStyle: FontStyle.italic),
 | 
			
		||||
                        )))
 | 
			
		||||
                    .toList()
 | 
			
		||||
              ]),
 | 
			
		||||
              if (gettingAppInfo)
 | 
			
		||||
                const LinearProgressIndicator()
 | 
			
		||||
              else
 | 
			
		||||
                Container(),
 | 
			
		||||
            ],
 | 
			
		||||
          )),
 | 
			
		||||
    );
 | 
			
		||||
                )),
 | 
			
		||||
          ))
 | 
			
		||||
    ]);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,9 @@
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:obtainium/components/custom_app_bar.dart';
 | 
			
		||||
import 'package:obtainium/providers/apps_provider.dart';
 | 
			
		||||
import 'package:obtainium/providers/settings_provider.dart';
 | 
			
		||||
import 'package:url_launcher/url_launcher_string.dart';
 | 
			
		||||
import 'package:webview_flutter/webview_flutter.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
 | 
			
		||||
@@ -17,18 +20,70 @@ class _AppPageState extends State<AppPage> {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    var appsProvider = context.watch<AppsProvider>();
 | 
			
		||||
    var settingsProvider = context.watch<SettingsProvider>();
 | 
			
		||||
    AppInMemory? app = appsProvider.apps[widget.appId];
 | 
			
		||||
    if (app?.app.installedVersion != null) {
 | 
			
		||||
      appsProvider.getUpdate(app!.app.id);
 | 
			
		||||
    }
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        title: Text('${app?.app.author}/${app?.app.name}'),
 | 
			
		||||
      ),
 | 
			
		||||
      body: WebView(
 | 
			
		||||
        initialUrl: app?.app.url,
 | 
			
		||||
        javascriptMode: JavascriptMode.unrestricted,
 | 
			
		||||
      ),
 | 
			
		||||
      backgroundColor: Theme.of(context).colorScheme.surface,
 | 
			
		||||
      body: CustomScrollView(slivers: <Widget>[
 | 
			
		||||
        CustomAppBar(title: '${app?.app.name}'),
 | 
			
		||||
        SliverFillRemaining(
 | 
			
		||||
          child: settingsProvider.showAppWebpage
 | 
			
		||||
              ? WebView(
 | 
			
		||||
                  initialUrl: app?.app.url,
 | 
			
		||||
                  javascriptMode: JavascriptMode.unrestricted,
 | 
			
		||||
                )
 | 
			
		||||
              : Column(
 | 
			
		||||
                  mainAxisAlignment: MainAxisAlignment.center,
 | 
			
		||||
                  crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    Text(
 | 
			
		||||
                      app?.app.name ?? 'App',
 | 
			
		||||
                      textAlign: TextAlign.center,
 | 
			
		||||
                      style: Theme.of(context).textTheme.displayLarge,
 | 
			
		||||
                    ),
 | 
			
		||||
                    Text(
 | 
			
		||||
                      'By ${app?.app.author ?? 'Unknown'}',
 | 
			
		||||
                      textAlign: TextAlign.center,
 | 
			
		||||
                      style: Theme.of(context).textTheme.headlineMedium,
 | 
			
		||||
                    ),
 | 
			
		||||
                    const SizedBox(
 | 
			
		||||
                      height: 32,
 | 
			
		||||
                    ),
 | 
			
		||||
                    GestureDetector(
 | 
			
		||||
                        onTap: () {
 | 
			
		||||
                          if (app?.app.url != null) {
 | 
			
		||||
                            launchUrlString(app?.app.url ?? '',
 | 
			
		||||
                                mode: LaunchMode.externalApplication);
 | 
			
		||||
                          }
 | 
			
		||||
                        },
 | 
			
		||||
                        child: Text(
 | 
			
		||||
                          app?.app.url ?? '',
 | 
			
		||||
                          textAlign: TextAlign.center,
 | 
			
		||||
                          style: const TextStyle(
 | 
			
		||||
                              decoration: TextDecoration.underline,
 | 
			
		||||
                              fontStyle: FontStyle.italic,
 | 
			
		||||
                              fontSize: 12),
 | 
			
		||||
                        )),
 | 
			
		||||
                    const SizedBox(
 | 
			
		||||
                      height: 32,
 | 
			
		||||
                    ),
 | 
			
		||||
                    Text(
 | 
			
		||||
                      'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}',
 | 
			
		||||
                      textAlign: TextAlign.center,
 | 
			
		||||
                      style: Theme.of(context).textTheme.bodyLarge,
 | 
			
		||||
                    ),
 | 
			
		||||
                    Text(
 | 
			
		||||
                      'Installed Version: ${app?.app.installedVersion ?? 'None'}',
 | 
			
		||||
                      textAlign: TextAlign.center,
 | 
			
		||||
                      style: Theme.of(context).textTheme.bodyLarge,
 | 
			
		||||
                    ),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
        ),
 | 
			
		||||
      ]),
 | 
			
		||||
      bottomSheet: Padding(
 | 
			
		||||
          padding: EdgeInsets.fromLTRB(
 | 
			
		||||
              0, 0, 0, MediaQuery.of(context).padding.bottom),
 | 
			
		||||
@@ -40,6 +95,76 @@ class _AppPageState extends State<AppPage> {
 | 
			
		||||
                  child: Row(
 | 
			
		||||
                      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
 | 
			
		||||
                      children: [
 | 
			
		||||
                        if (app?.app.installedVersion != app?.app.latestVersion)
 | 
			
		||||
                          IconButton(
 | 
			
		||||
                              onPressed: () {
 | 
			
		||||
                                showDialog(
 | 
			
		||||
                                    context: context,
 | 
			
		||||
                                    builder: (BuildContext ctx) {
 | 
			
		||||
                                      return AlertDialog(
 | 
			
		||||
                                        title: Text(
 | 
			
		||||
                                            'App Already ${app?.app.installedVersion == null ? 'Installed' : 'Updated'}?'),
 | 
			
		||||
                                        actions: [
 | 
			
		||||
                                          TextButton(
 | 
			
		||||
                                              onPressed: () {
 | 
			
		||||
                                                Navigator.of(context).pop();
 | 
			
		||||
                                              },
 | 
			
		||||
                                              child: const Text('No')),
 | 
			
		||||
                                          TextButton(
 | 
			
		||||
                                              onPressed: () {
 | 
			
		||||
                                                HapticFeedback.selectionClick();
 | 
			
		||||
                                                var updatedApp = app?.app;
 | 
			
		||||
                                                if (updatedApp != null) {
 | 
			
		||||
                                                  updatedApp.installedVersion =
 | 
			
		||||
                                                      updatedApp.latestVersion;
 | 
			
		||||
                                                  appsProvider
 | 
			
		||||
                                                      .saveApp(updatedApp);
 | 
			
		||||
                                                }
 | 
			
		||||
                                                Navigator.of(context).pop();
 | 
			
		||||
                                              },
 | 
			
		||||
                                              child: const Text(
 | 
			
		||||
                                                  'Yes, Mark as Installed'))
 | 
			
		||||
                                        ],
 | 
			
		||||
                                      );
 | 
			
		||||
                                    });
 | 
			
		||||
                              },
 | 
			
		||||
                              tooltip: 'Mark as Installed',
 | 
			
		||||
                              icon: const Icon(Icons.done))
 | 
			
		||||
                        else
 | 
			
		||||
                          IconButton(
 | 
			
		||||
                              onPressed: () {
 | 
			
		||||
                                showDialog(
 | 
			
		||||
                                    context: context,
 | 
			
		||||
                                    builder: (BuildContext ctx) {
 | 
			
		||||
                                      return AlertDialog(
 | 
			
		||||
                                        title: const Text('App Not Installed?'),
 | 
			
		||||
                                        actions: [
 | 
			
		||||
                                          TextButton(
 | 
			
		||||
                                              onPressed: () {
 | 
			
		||||
                                                Navigator.of(context).pop();
 | 
			
		||||
                                              },
 | 
			
		||||
                                              child: const Text('No')),
 | 
			
		||||
                                          TextButton(
 | 
			
		||||
                                              onPressed: () {
 | 
			
		||||
                                                HapticFeedback.selectionClick();
 | 
			
		||||
                                                var updatedApp = app?.app;
 | 
			
		||||
                                                if (updatedApp != null) {
 | 
			
		||||
                                                  updatedApp.installedVersion =
 | 
			
		||||
                                                      null;
 | 
			
		||||
                                                  appsProvider
 | 
			
		||||
                                                      .saveApp(updatedApp);
 | 
			
		||||
                                                }
 | 
			
		||||
                                                Navigator.of(context).pop();
 | 
			
		||||
                                              },
 | 
			
		||||
                                              child: const Text(
 | 
			
		||||
                                                  'Yes, Mark as Not Installed'))
 | 
			
		||||
                                        ],
 | 
			
		||||
                                      );
 | 
			
		||||
                                    });
 | 
			
		||||
                              },
 | 
			
		||||
                              tooltip: 'Mark as Not Installed',
 | 
			
		||||
                              icon: const Icon(Icons.no_cell_outlined)),
 | 
			
		||||
                        const SizedBox(width: 16.0),
 | 
			
		||||
                        Expanded(
 | 
			
		||||
                            child: ElevatedButton(
 | 
			
		||||
                                onPressed: (app?.app.installedVersion == null ||
 | 
			
		||||
@@ -67,7 +192,6 @@ class _AppPageState extends State<AppPage> {
 | 
			
		||||
                          onPressed: app?.downloadProgress != null
 | 
			
		||||
                              ? null
 | 
			
		||||
                              : () {
 | 
			
		||||
                                  HapticFeedback.lightImpact();
 | 
			
		||||
                                  showDialog(
 | 
			
		||||
                                      context: context,
 | 
			
		||||
                                      builder: (BuildContext ctx) {
 | 
			
		||||
@@ -78,7 +202,8 @@ class _AppPageState extends State<AppPage> {
 | 
			
		||||
                                          actions: [
 | 
			
		||||
                                            TextButton(
 | 
			
		||||
                                                onPressed: () {
 | 
			
		||||
                                                  HapticFeedback.heavyImpact();
 | 
			
		||||
                                                  HapticFeedback
 | 
			
		||||
                                                      .selectionClick();
 | 
			
		||||
                                                  appsProvider
 | 
			
		||||
                                                      .removeApp(app!.app.id)
 | 
			
		||||
                                                      .then((_) {
 | 
			
		||||
@@ -91,7 +216,6 @@ class _AppPageState extends State<AppPage> {
 | 
			
		||||
                                                child: const Text('Remove')),
 | 
			
		||||
                                            TextButton(
 | 
			
		||||
                                                onPressed: () {
 | 
			
		||||
                                                  HapticFeedback.lightImpact();
 | 
			
		||||
                                                  Navigator.of(context).pop();
 | 
			
		||||
                                                },
 | 
			
		||||
                                                child: const Text('Cancel'))
 | 
			
		||||
@@ -100,8 +224,10 @@ class _AppPageState extends State<AppPage> {
 | 
			
		||||
                                      });
 | 
			
		||||
                                },
 | 
			
		||||
                          style: TextButton.styleFrom(
 | 
			
		||||
                              foregroundColor: Theme.of(context).errorColor,
 | 
			
		||||
                              surfaceTintColor: Theme.of(context).errorColor),
 | 
			
		||||
                              foregroundColor:
 | 
			
		||||
                                  Theme.of(context).colorScheme.error,
 | 
			
		||||
                              surfaceTintColor:
 | 
			
		||||
                                  Theme.of(context).colorScheme.error),
 | 
			
		||||
                          child: const Text('Remove'),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ])),
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:obtainium/components/custom_app_bar.dart';
 | 
			
		||||
import 'package:obtainium/pages/app.dart';
 | 
			
		||||
import 'package:obtainium/providers/apps_provider.dart';
 | 
			
		||||
import 'package:obtainium/providers/settings_provider.dart';
 | 
			
		||||
@@ -16,9 +17,26 @@ class _AppsPageState extends State<AppsPage> {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    var appsProvider = context.watch<AppsProvider>();
 | 
			
		||||
    var settingsProvider = context.watch<SettingsProvider>();
 | 
			
		||||
    var existingUpdateAppIds = appsProvider.getExistingUpdates();
 | 
			
		||||
    var sortedApps = appsProvider.apps.values.toList();
 | 
			
		||||
    sortedApps.sort((a, b) {
 | 
			
		||||
      int result = 0;
 | 
			
		||||
      if (settingsProvider.sortColumn == SortColumnSettings.authorName) {
 | 
			
		||||
        result =
 | 
			
		||||
            (a.app.author + a.app.name).compareTo(b.app.author + b.app.name);
 | 
			
		||||
      } else if (settingsProvider.sortColumn == SortColumnSettings.nameAuthor) {
 | 
			
		||||
        result =
 | 
			
		||||
            (a.app.name + a.app.author).compareTo(b.app.name + b.app.author);
 | 
			
		||||
      }
 | 
			
		||||
      return result;
 | 
			
		||||
    });
 | 
			
		||||
    if (settingsProvider.sortOrder == SortOrderSettings.ascending) {
 | 
			
		||||
      sortedApps = sortedApps.reversed.toList();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
        backgroundColor: Theme.of(context).colorScheme.surface,
 | 
			
		||||
        floatingActionButton: existingUpdateAppIds.isEmpty
 | 
			
		||||
            ? null
 | 
			
		||||
            : ElevatedButton.icon(
 | 
			
		||||
@@ -26,57 +44,56 @@ class _AppsPageState extends State<AppsPage> {
 | 
			
		||||
                    ? null
 | 
			
		||||
                    : () {
 | 
			
		||||
                        HapticFeedback.heavyImpact();
 | 
			
		||||
                        context
 | 
			
		||||
                            .read<SettingsProvider>()
 | 
			
		||||
                            .getInstallPermission()
 | 
			
		||||
                            .then((_) {
 | 
			
		||||
                        settingsProvider.getInstallPermission().then((_) {
 | 
			
		||||
                          appsProvider.downloadAndInstallLatestApp(
 | 
			
		||||
                              existingUpdateAppIds, context);
 | 
			
		||||
                        });
 | 
			
		||||
                      },
 | 
			
		||||
                icon: const Icon(Icons.update),
 | 
			
		||||
                label: const Text('Update All')),
 | 
			
		||||
        body: Center(
 | 
			
		||||
          child: appsProvider.loadingApps
 | 
			
		||||
              ? const CircularProgressIndicator()
 | 
			
		||||
              : appsProvider.apps.isEmpty
 | 
			
		||||
                  ? Text(
 | 
			
		||||
                      'No Apps',
 | 
			
		||||
                      style: Theme.of(context).textTheme.headline4,
 | 
			
		||||
                    )
 | 
			
		||||
                  : RefreshIndicator(
 | 
			
		||||
                      onRefresh: () {
 | 
			
		||||
                        HapticFeedback.lightImpact();
 | 
			
		||||
                        return appsProvider.checkUpdates();
 | 
			
		||||
                      },
 | 
			
		||||
                      child: ListView(
 | 
			
		||||
                        children: appsProvider.apps.values
 | 
			
		||||
                            .map(
 | 
			
		||||
                              (e) => ListTile(
 | 
			
		||||
                                title: Text('${e.app.author}/${e.app.name}'),
 | 
			
		||||
                                subtitle: Text(
 | 
			
		||||
                                    e.app.installedVersion ?? 'Not Installed'),
 | 
			
		||||
                                trailing: e.downloadProgress != null
 | 
			
		||||
                                    ? Text(
 | 
			
		||||
                                        'Downloading - ${e.downloadProgress?.toInt()}%')
 | 
			
		||||
                                    : (e.app.installedVersion != null &&
 | 
			
		||||
                                            e.app.installedVersion !=
 | 
			
		||||
                                                e.app.latestVersion
 | 
			
		||||
                                        ? const Text('Update Available')
 | 
			
		||||
                                        : null),
 | 
			
		||||
                                onTap: () {
 | 
			
		||||
                                  Navigator.push(
 | 
			
		||||
                                    context,
 | 
			
		||||
                                    MaterialPageRoute(
 | 
			
		||||
                                        builder: (context) =>
 | 
			
		||||
                                            AppPage(appId: e.app.id)),
 | 
			
		||||
                                  );
 | 
			
		||||
                                },
 | 
			
		||||
                              ),
 | 
			
		||||
                            )
 | 
			
		||||
                            .toList(),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
        ));
 | 
			
		||||
                icon: const Icon(Icons.install_mobile_outlined),
 | 
			
		||||
                label: const Text('Install All')),
 | 
			
		||||
        body: RefreshIndicator(
 | 
			
		||||
            onRefresh: () {
 | 
			
		||||
              HapticFeedback.lightImpact();
 | 
			
		||||
              return appsProvider.checkUpdates();
 | 
			
		||||
            },
 | 
			
		||||
            child: CustomScrollView(slivers: <Widget>[
 | 
			
		||||
              const CustomAppBar(title: 'Apps'),
 | 
			
		||||
              if (appsProvider.loadingApps || appsProvider.apps.isEmpty)
 | 
			
		||||
                SliverFillRemaining(
 | 
			
		||||
                    child: Center(
 | 
			
		||||
                        child: appsProvider.loadingApps
 | 
			
		||||
                            ? const CircularProgressIndicator()
 | 
			
		||||
                            : Text(
 | 
			
		||||
                                'No Apps',
 | 
			
		||||
                                style:
 | 
			
		||||
                                    Theme.of(context).textTheme.headlineMedium,
 | 
			
		||||
                              ))),
 | 
			
		||||
              SliverList(
 | 
			
		||||
                  delegate: SliverChildBuilderDelegate(
 | 
			
		||||
                      (BuildContext context, int index) {
 | 
			
		||||
                return ListTile(
 | 
			
		||||
                  title: Text(
 | 
			
		||||
                      '${sortedApps[index].app.author}/${sortedApps[index].app.name}'),
 | 
			
		||||
                  subtitle: Text(sortedApps[index].app.installedVersion ??
 | 
			
		||||
                      'Not Installed'),
 | 
			
		||||
                  trailing: sortedApps[index].downloadProgress != null
 | 
			
		||||
                      ? Text(
 | 
			
		||||
                          'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%')
 | 
			
		||||
                      : (sortedApps[index].app.installedVersion != null &&
 | 
			
		||||
                              sortedApps[index].app.installedVersion !=
 | 
			
		||||
                                  sortedApps[index].app.latestVersion
 | 
			
		||||
                          ? const Text('Update Available')
 | 
			
		||||
                          : null),
 | 
			
		||||
                  onTap: () {
 | 
			
		||||
                    Navigator.push(
 | 
			
		||||
                      context,
 | 
			
		||||
                      MaterialPageRoute(
 | 
			
		||||
                          builder: (context) =>
 | 
			
		||||
                              AppPage(appId: sortedApps[index].app.id)),
 | 
			
		||||
                    );
 | 
			
		||||
                  },
 | 
			
		||||
                );
 | 
			
		||||
              }, childCount: sortedApps.length))
 | 
			
		||||
            ])));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,9 @@
 | 
			
		||||
import 'package:animations/animations.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:obtainium/pages/add_app.dart';
 | 
			
		||||
import 'package:obtainium/pages/apps.dart';
 | 
			
		||||
import 'package:obtainium/pages/import_export.dart';
 | 
			
		||||
import 'package:obtainium/pages/settings.dart';
 | 
			
		||||
 | 
			
		||||
class HomePage extends StatefulWidget {
 | 
			
		||||
@@ -11,40 +13,78 @@ class HomePage extends StatefulWidget {
 | 
			
		||||
  State<HomePage> createState() => _HomePageState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class NavigationPageItem {
 | 
			
		||||
  late String title;
 | 
			
		||||
  late IconData icon;
 | 
			
		||||
  late Widget widget;
 | 
			
		||||
 | 
			
		||||
  NavigationPageItem(this.title, this.icon, this.widget);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _HomePageState extends State<HomePage> {
 | 
			
		||||
  int selectedIndex = 1;
 | 
			
		||||
  List<Widget> pages = [
 | 
			
		||||
    const SettingsPage(),
 | 
			
		||||
    const AppsPage(),
 | 
			
		||||
    const AddAppPage()
 | 
			
		||||
  List<int> selectedIndexHistory = [];
 | 
			
		||||
 | 
			
		||||
  List<NavigationPageItem> pages = [
 | 
			
		||||
    NavigationPageItem('Apps', Icons.apps, const AppsPage()),
 | 
			
		||||
    NavigationPageItem('Add App', Icons.add, const AddAppPage()),
 | 
			
		||||
    NavigationPageItem(
 | 
			
		||||
        'Import/Export', Icons.import_export, const ImportExportPage()),
 | 
			
		||||
    NavigationPageItem('Settings', Icons.settings, const SettingsPage())
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return WillPopScope(
 | 
			
		||||
        child: Scaffold(
 | 
			
		||||
          appBar: AppBar(title: const Text('Obtainium')),
 | 
			
		||||
          body: pages.elementAt(selectedIndex),
 | 
			
		||||
          backgroundColor: Theme.of(context).colorScheme.surface,
 | 
			
		||||
          body: PageTransitionSwitcher(
 | 
			
		||||
            transitionBuilder: (
 | 
			
		||||
              Widget child,
 | 
			
		||||
              Animation<double> animation,
 | 
			
		||||
              Animation<double> secondaryAnimation,
 | 
			
		||||
            ) {
 | 
			
		||||
              return SharedAxisTransition(
 | 
			
		||||
                animation: animation,
 | 
			
		||||
                secondaryAnimation: secondaryAnimation,
 | 
			
		||||
                transitionType: SharedAxisTransitionType.horizontal,
 | 
			
		||||
                child: child,
 | 
			
		||||
              );
 | 
			
		||||
            },
 | 
			
		||||
            child: pages
 | 
			
		||||
                .elementAt(selectedIndexHistory.isEmpty
 | 
			
		||||
                    ? 0
 | 
			
		||||
                    : selectedIndexHistory.last)
 | 
			
		||||
                .widget,
 | 
			
		||||
          ),
 | 
			
		||||
          bottomNavigationBar: NavigationBar(
 | 
			
		||||
            destinations: const [
 | 
			
		||||
              NavigationDestination(
 | 
			
		||||
                  icon: Icon(Icons.settings), label: 'Settings'),
 | 
			
		||||
              NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'),
 | 
			
		||||
              NavigationDestination(icon: Icon(Icons.add), label: 'Add App'),
 | 
			
		||||
            ],
 | 
			
		||||
            destinations: pages
 | 
			
		||||
                .map((e) =>
 | 
			
		||||
                    NavigationDestination(icon: Icon(e.icon), label: e.title))
 | 
			
		||||
                .toList(),
 | 
			
		||||
            onDestinationSelected: (int index) {
 | 
			
		||||
              HapticFeedback.lightImpact();
 | 
			
		||||
              HapticFeedback.selectionClick();
 | 
			
		||||
              setState(() {
 | 
			
		||||
                selectedIndex = index;
 | 
			
		||||
                if (index == 0) {
 | 
			
		||||
                  selectedIndexHistory.clear();
 | 
			
		||||
                } else if (selectedIndexHistory.isEmpty ||
 | 
			
		||||
                    (selectedIndexHistory.isNotEmpty &&
 | 
			
		||||
                        selectedIndexHistory.last != index)) {
 | 
			
		||||
                  int existingInd = selectedIndexHistory.indexOf(index);
 | 
			
		||||
                  if (existingInd >= 0) {
 | 
			
		||||
                    selectedIndexHistory.removeAt(existingInd);
 | 
			
		||||
                  }
 | 
			
		||||
                  selectedIndexHistory.add(index);
 | 
			
		||||
                }
 | 
			
		||||
              });
 | 
			
		||||
            },
 | 
			
		||||
            selectedIndex: selectedIndex,
 | 
			
		||||
            selectedIndex:
 | 
			
		||||
                selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last,
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
        onWillPop: () async {
 | 
			
		||||
          if (selectedIndex != 1) {
 | 
			
		||||
          if (selectedIndexHistory.isNotEmpty) {
 | 
			
		||||
            setState(() {
 | 
			
		||||
              selectedIndex = 1;
 | 
			
		||||
              selectedIndexHistory.removeLast();
 | 
			
		||||
            });
 | 
			
		||||
            return false;
 | 
			
		||||
          }
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										333
									
								
								lib/pages/import_export.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										333
									
								
								lib/pages/import_export.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,333 @@
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:obtainium/components/custom_app_bar.dart';
 | 
			
		||||
import 'package:obtainium/components/generated_form_modal.dart';
 | 
			
		||||
import 'package:obtainium/providers/apps_provider.dart';
 | 
			
		||||
import 'package:obtainium/providers/settings_provider.dart';
 | 
			
		||||
import 'package:obtainium/providers/source_provider.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:file_picker/file_picker.dart';
 | 
			
		||||
 | 
			
		||||
class ImportExportPage extends StatefulWidget {
 | 
			
		||||
  const ImportExportPage({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<ImportExportPage> createState() => _ImportExportPageState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ImportExportPageState extends State<ImportExportPage> {
 | 
			
		||||
  bool importInProgress = false;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    SourceProvider sourceProvider = SourceProvider();
 | 
			
		||||
    var settingsProvider = context.read<SettingsProvider>();
 | 
			
		||||
    var appsProvider = context.read<AppsProvider>();
 | 
			
		||||
    var outlineButtonStyle = ButtonStyle(
 | 
			
		||||
      shape: MaterialStateProperty.all(
 | 
			
		||||
        StadiumBorder(
 | 
			
		||||
          side: BorderSide(
 | 
			
		||||
            width: 1,
 | 
			
		||||
            color: Theme.of(context).colorScheme.primary,
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    Future<List<List<String>>> addApps(List<String> urls) async {
 | 
			
		||||
      await settingsProvider.getInstallPermission();
 | 
			
		||||
      List<dynamic> results = await sourceProvider.getApps(urls);
 | 
			
		||||
      List<App> apps = results[0];
 | 
			
		||||
      Map<String, dynamic> errorsMap = results[1];
 | 
			
		||||
      for (var app in apps) {
 | 
			
		||||
        if (appsProvider.apps.containsKey(app.id)) {
 | 
			
		||||
          errorsMap.addAll({app.id: 'App already added'});
 | 
			
		||||
        } else {
 | 
			
		||||
          await appsProvider.saveApp(app);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      List<List<String>> errors =
 | 
			
		||||
          errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
 | 
			
		||||
      return errors;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return CustomScrollView(slivers: <Widget>[
 | 
			
		||||
      const CustomAppBar(title: 'Import/Export'),
 | 
			
		||||
      SliverFillRemaining(
 | 
			
		||||
          hasScrollBody: false,
 | 
			
		||||
          child: Padding(
 | 
			
		||||
              padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
 | 
			
		||||
              child: Column(
 | 
			
		||||
                crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
                children: [
 | 
			
		||||
                  Row(
 | 
			
		||||
                    children: [
 | 
			
		||||
                      Expanded(
 | 
			
		||||
                          child: TextButton(
 | 
			
		||||
                              style: outlineButtonStyle,
 | 
			
		||||
                              onPressed: appsProvider.apps.isEmpty ||
 | 
			
		||||
                                      importInProgress
 | 
			
		||||
                                  ? null
 | 
			
		||||
                                  : () {
 | 
			
		||||
                                      HapticFeedback.selectionClick();
 | 
			
		||||
                                      appsProvider
 | 
			
		||||
                                          .exportApps()
 | 
			
		||||
                                          .then((String path) {
 | 
			
		||||
                                        ScaffoldMessenger.of(context)
 | 
			
		||||
                                            .showSnackBar(
 | 
			
		||||
                                          SnackBar(
 | 
			
		||||
                                              content:
 | 
			
		||||
                                                  Text('Exported to $path')),
 | 
			
		||||
                                        );
 | 
			
		||||
                                      });
 | 
			
		||||
                                    },
 | 
			
		||||
                              child: const Text('Obtainium Export'))),
 | 
			
		||||
                      const SizedBox(
 | 
			
		||||
                        width: 16,
 | 
			
		||||
                      ),
 | 
			
		||||
                      Expanded(
 | 
			
		||||
                          child: TextButton(
 | 
			
		||||
                              style: outlineButtonStyle,
 | 
			
		||||
                              onPressed: importInProgress
 | 
			
		||||
                                  ? null
 | 
			
		||||
                                  : () {
 | 
			
		||||
                                      HapticFeedback.selectionClick();
 | 
			
		||||
                                      FilePicker.platform
 | 
			
		||||
                                          .pickFiles()
 | 
			
		||||
                                          .then((result) {
 | 
			
		||||
                                        setState(() {
 | 
			
		||||
                                          importInProgress = true;
 | 
			
		||||
                                        });
 | 
			
		||||
                                        if (result != null) {
 | 
			
		||||
                                          String data =
 | 
			
		||||
                                              File(result.files.single.path!)
 | 
			
		||||
                                                  .readAsStringSync();
 | 
			
		||||
                                          try {
 | 
			
		||||
                                            jsonDecode(data);
 | 
			
		||||
                                          } catch (e) {
 | 
			
		||||
                                            throw 'Invalid input';
 | 
			
		||||
                                          }
 | 
			
		||||
                                          appsProvider
 | 
			
		||||
                                              .importApps(data)
 | 
			
		||||
                                              .then((value) {
 | 
			
		||||
                                            ScaffoldMessenger.of(context)
 | 
			
		||||
                                                .showSnackBar(
 | 
			
		||||
                                              SnackBar(
 | 
			
		||||
                                                  content: Text(
 | 
			
		||||
                                                      '$value App${value == 1 ? '' : 's'} Imported')),
 | 
			
		||||
                                            );
 | 
			
		||||
                                          });
 | 
			
		||||
                                        } else {
 | 
			
		||||
                                          // User canceled the picker
 | 
			
		||||
                                        }
 | 
			
		||||
                                      }).catchError((e) {
 | 
			
		||||
                                        ScaffoldMessenger.of(context)
 | 
			
		||||
                                            .showSnackBar(
 | 
			
		||||
                                          SnackBar(content: Text(e.toString())),
 | 
			
		||||
                                        );
 | 
			
		||||
                                      }).whenComplete(() {
 | 
			
		||||
                                        setState(() {
 | 
			
		||||
                                          importInProgress = false;
 | 
			
		||||
                                        });
 | 
			
		||||
                                      });
 | 
			
		||||
                                    },
 | 
			
		||||
                              child: const Text('Obtainium Import')))
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
                  if (importInProgress)
 | 
			
		||||
                    Column(
 | 
			
		||||
                      children: const [
 | 
			
		||||
                        SizedBox(
 | 
			
		||||
                          height: 14,
 | 
			
		||||
                        ),
 | 
			
		||||
                        LinearProgressIndicator(),
 | 
			
		||||
                        SizedBox(
 | 
			
		||||
                          height: 14,
 | 
			
		||||
                        ),
 | 
			
		||||
                      ],
 | 
			
		||||
                    )
 | 
			
		||||
                  else
 | 
			
		||||
                    const Divider(
 | 
			
		||||
                      height: 32,
 | 
			
		||||
                    ),
 | 
			
		||||
                  TextButton(
 | 
			
		||||
                      onPressed: importInProgress
 | 
			
		||||
                          ? null
 | 
			
		||||
                          : () {
 | 
			
		||||
                              showDialog(
 | 
			
		||||
                                  context: context,
 | 
			
		||||
                                  builder: (BuildContext ctx) {
 | 
			
		||||
                                    return GeneratedFormModal(
 | 
			
		||||
                                      title: 'Import from URL List',
 | 
			
		||||
                                      items: [
 | 
			
		||||
                                        GeneratedFormItem(
 | 
			
		||||
                                            'App URL List', true, 7)
 | 
			
		||||
                                      ],
 | 
			
		||||
                                    );
 | 
			
		||||
                                  }).then((values) {
 | 
			
		||||
                                if (values != null) {
 | 
			
		||||
                                  var urls = (values[0] as String).split('\n');
 | 
			
		||||
                                  setState(() {
 | 
			
		||||
                                    importInProgress = true;
 | 
			
		||||
                                  });
 | 
			
		||||
                                  addApps(urls).then((errors) {
 | 
			
		||||
                                    if (errors.isEmpty) {
 | 
			
		||||
                                      ScaffoldMessenger.of(context)
 | 
			
		||||
                                          .showSnackBar(
 | 
			
		||||
                                        SnackBar(
 | 
			
		||||
                                            content: Text(
 | 
			
		||||
                                                'Imported ${urls.length} Apps')),
 | 
			
		||||
                                      );
 | 
			
		||||
                                    } else {
 | 
			
		||||
                                      showDialog(
 | 
			
		||||
                                          context: context,
 | 
			
		||||
                                          builder: (BuildContext ctx) {
 | 
			
		||||
                                            return ImportErrorDialog(
 | 
			
		||||
                                                urlsLength: urls.length,
 | 
			
		||||
                                                errors: errors);
 | 
			
		||||
                                          });
 | 
			
		||||
                                    }
 | 
			
		||||
                                  }).catchError((e) {
 | 
			
		||||
                                    ScaffoldMessenger.of(context).showSnackBar(
 | 
			
		||||
                                      SnackBar(content: Text(e.toString())),
 | 
			
		||||
                                    );
 | 
			
		||||
                                  }).whenComplete(() {
 | 
			
		||||
                                    setState(() {
 | 
			
		||||
                                      importInProgress = false;
 | 
			
		||||
                                    });
 | 
			
		||||
                                  });
 | 
			
		||||
                                }
 | 
			
		||||
                              });
 | 
			
		||||
                            },
 | 
			
		||||
                      child: const Text(
 | 
			
		||||
                        'Import from URL List',
 | 
			
		||||
                      )),
 | 
			
		||||
                  ...sourceProvider.massSources
 | 
			
		||||
                      .map((source) => Column(
 | 
			
		||||
                              crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
                              children: [
 | 
			
		||||
                                const SizedBox(height: 8),
 | 
			
		||||
                                TextButton(
 | 
			
		||||
                                    onPressed: importInProgress
 | 
			
		||||
                                        ? null
 | 
			
		||||
                                        : () {
 | 
			
		||||
                                            showDialog(
 | 
			
		||||
                                                context: context,
 | 
			
		||||
                                                builder: (BuildContext ctx) {
 | 
			
		||||
                                                  return GeneratedFormModal(
 | 
			
		||||
                                                      title:
 | 
			
		||||
                                                          'Import ${source.name}',
 | 
			
		||||
                                                      items: source.requiredArgs
 | 
			
		||||
                                                          .map((e) =>
 | 
			
		||||
                                                              GeneratedFormItem(
 | 
			
		||||
                                                                  e, true, 1))
 | 
			
		||||
                                                          .toList());
 | 
			
		||||
                                                }).then((values) {
 | 
			
		||||
                                              if (values != null) {
 | 
			
		||||
                                                source
 | 
			
		||||
                                                    .getUrls(values)
 | 
			
		||||
                                                    .then((urls) {
 | 
			
		||||
                                                  setState(() {
 | 
			
		||||
                                                    importInProgress = true;
 | 
			
		||||
                                                  });
 | 
			
		||||
                                                  addApps(urls).then((errors) {
 | 
			
		||||
                                                    if (errors.isEmpty) {
 | 
			
		||||
                                                      ScaffoldMessenger.of(
 | 
			
		||||
                                                              context)
 | 
			
		||||
                                                          .showSnackBar(
 | 
			
		||||
                                                        SnackBar(
 | 
			
		||||
                                                            content: Text(
 | 
			
		||||
                                                                'Imported ${urls.length} Apps')),
 | 
			
		||||
                                                      );
 | 
			
		||||
                                                    } else {
 | 
			
		||||
                                                      showDialog(
 | 
			
		||||
                                                          context: context,
 | 
			
		||||
                                                          builder: (BuildContext
 | 
			
		||||
                                                              ctx) {
 | 
			
		||||
                                                            return ImportErrorDialog(
 | 
			
		||||
                                                                urlsLength:
 | 
			
		||||
                                                                    urls.length,
 | 
			
		||||
                                                                errors: errors);
 | 
			
		||||
                                                          });
 | 
			
		||||
                                                    }
 | 
			
		||||
                                                  }).whenComplete(() {
 | 
			
		||||
                                                    setState(() {
 | 
			
		||||
                                                      importInProgress = false;
 | 
			
		||||
                                                    });
 | 
			
		||||
                                                  });
 | 
			
		||||
                                                }).catchError((e) {
 | 
			
		||||
                                                  ScaffoldMessenger.of(context)
 | 
			
		||||
                                                      .showSnackBar(
 | 
			
		||||
                                                    SnackBar(
 | 
			
		||||
                                                        content:
 | 
			
		||||
                                                            Text(e.toString())),
 | 
			
		||||
                                                  );
 | 
			
		||||
                                                });
 | 
			
		||||
                                              }
 | 
			
		||||
                                            });
 | 
			
		||||
                                          },
 | 
			
		||||
                                    child: Text('Import ${source.name}'))
 | 
			
		||||
                              ]))
 | 
			
		||||
                      .toList()
 | 
			
		||||
                ],
 | 
			
		||||
              )))
 | 
			
		||||
    ]);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class ImportErrorDialog extends StatefulWidget {
 | 
			
		||||
  const ImportErrorDialog(
 | 
			
		||||
      {super.key, required this.urlsLength, required this.errors});
 | 
			
		||||
 | 
			
		||||
  final int urlsLength;
 | 
			
		||||
  final List<List<String>> errors;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<ImportErrorDialog> createState() => _ImportErrorDialogState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ImportErrorDialogState extends State<ImportErrorDialog> {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AlertDialog(
 | 
			
		||||
      scrollable: true,
 | 
			
		||||
      title: const Text('Import Errors'),
 | 
			
		||||
      content:
 | 
			
		||||
          Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
 | 
			
		||||
        Text(
 | 
			
		||||
          '${widget.urlsLength - widget.errors.length} of ${widget.urlsLength} Apps imported.',
 | 
			
		||||
          style: Theme.of(context).textTheme.bodyLarge,
 | 
			
		||||
        ),
 | 
			
		||||
        const SizedBox(height: 16),
 | 
			
		||||
        Text(
 | 
			
		||||
          'The following URLs had errors:',
 | 
			
		||||
          style: Theme.of(context).textTheme.bodyLarge,
 | 
			
		||||
        ),
 | 
			
		||||
        ...widget.errors.map((e) {
 | 
			
		||||
          return Column(
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
              children: [
 | 
			
		||||
                const SizedBox(
 | 
			
		||||
                  height: 16,
 | 
			
		||||
                ),
 | 
			
		||||
                Text(e[0]),
 | 
			
		||||
                Text(
 | 
			
		||||
                  e[1],
 | 
			
		||||
                  style: const TextStyle(fontStyle: FontStyle.italic),
 | 
			
		||||
                )
 | 
			
		||||
              ]);
 | 
			
		||||
        }).toList()
 | 
			
		||||
      ]),
 | 
			
		||||
      actions: [
 | 
			
		||||
        TextButton(
 | 
			
		||||
            onPressed: () {
 | 
			
		||||
              Navigator.of(context).pop(null);
 | 
			
		||||
            },
 | 
			
		||||
            child: const Text('Okay'))
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,8 +1,6 @@
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:obtainium/providers/apps_provider.dart';
 | 
			
		||||
import 'package:obtainium/components/custom_app_bar.dart';
 | 
			
		||||
import 'package:obtainium/providers/settings_provider.dart';
 | 
			
		||||
import 'package:provider/provider.dart';
 | 
			
		||||
import 'package:url_launcher/url_launcher_string.dart';
 | 
			
		||||
@@ -17,230 +15,218 @@ class SettingsPage extends StatefulWidget {
 | 
			
		||||
class _SettingsPageState extends State<SettingsPage> {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    AppsProvider appsProvider = context.read<AppsProvider>();
 | 
			
		||||
    SettingsProvider settingsProvider = context.watch<SettingsProvider>();
 | 
			
		||||
    if (settingsProvider.prefs == null) {
 | 
			
		||||
      settingsProvider.initializeSettings();
 | 
			
		||||
    }
 | 
			
		||||
    return Padding(
 | 
			
		||||
        padding: const EdgeInsets.all(16),
 | 
			
		||||
        child: settingsProvider.prefs == null
 | 
			
		||||
            ? Container()
 | 
			
		||||
            : Column(
 | 
			
		||||
                children: [
 | 
			
		||||
                  DropdownButtonFormField(
 | 
			
		||||
                      decoration: const InputDecoration(labelText: 'Theme'),
 | 
			
		||||
                      value: settingsProvider.theme,
 | 
			
		||||
                      items: const [
 | 
			
		||||
                        DropdownMenuItem(
 | 
			
		||||
                          value: ThemeSettings.dark,
 | 
			
		||||
                          child: Text('Dark'),
 | 
			
		||||
    return CustomScrollView(slivers: <Widget>[
 | 
			
		||||
      const CustomAppBar(title: 'Add App'),
 | 
			
		||||
      SliverFillRemaining(
 | 
			
		||||
          hasScrollBody: true,
 | 
			
		||||
          child: Padding(
 | 
			
		||||
              padding: const EdgeInsets.all(16),
 | 
			
		||||
              child: settingsProvider.prefs == null
 | 
			
		||||
                  ? Container()
 | 
			
		||||
                  : Column(
 | 
			
		||||
                      crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                      children: [
 | 
			
		||||
                        Text(
 | 
			
		||||
                          'Appearance',
 | 
			
		||||
                          style: TextStyle(
 | 
			
		||||
                              color: Theme.of(context).colorScheme.primary),
 | 
			
		||||
                        ),
 | 
			
		||||
                        DropdownMenuItem(
 | 
			
		||||
                          value: ThemeSettings.light,
 | 
			
		||||
                          child: Text('Light'),
 | 
			
		||||
                        DropdownButtonFormField(
 | 
			
		||||
                            decoration:
 | 
			
		||||
                                const InputDecoration(labelText: 'Theme'),
 | 
			
		||||
                            value: settingsProvider.theme,
 | 
			
		||||
                            items: const [
 | 
			
		||||
                              DropdownMenuItem(
 | 
			
		||||
                                value: ThemeSettings.dark,
 | 
			
		||||
                                child: Text('Dark'),
 | 
			
		||||
                              ),
 | 
			
		||||
                              DropdownMenuItem(
 | 
			
		||||
                                value: ThemeSettings.light,
 | 
			
		||||
                                child: Text('Light'),
 | 
			
		||||
                              ),
 | 
			
		||||
                              DropdownMenuItem(
 | 
			
		||||
                                value: ThemeSettings.system,
 | 
			
		||||
                                child: Text('Follow System'),
 | 
			
		||||
                              )
 | 
			
		||||
                            ],
 | 
			
		||||
                            onChanged: (value) {
 | 
			
		||||
                              if (value != null) {
 | 
			
		||||
                                settingsProvider.theme = value;
 | 
			
		||||
                              }
 | 
			
		||||
                            }),
 | 
			
		||||
                        const SizedBox(
 | 
			
		||||
                          height: 16,
 | 
			
		||||
                        ),
 | 
			
		||||
                        DropdownMenuItem(
 | 
			
		||||
                          value: ThemeSettings.system,
 | 
			
		||||
                          child: Text('Follow System'),
 | 
			
		||||
                        )
 | 
			
		||||
                      ],
 | 
			
		||||
                      onChanged: (value) {
 | 
			
		||||
                        if (value != null) {
 | 
			
		||||
                          settingsProvider.theme = value;
 | 
			
		||||
                        }
 | 
			
		||||
                      }),
 | 
			
		||||
                  const SizedBox(
 | 
			
		||||
                    height: 16,
 | 
			
		||||
                  ),
 | 
			
		||||
                  DropdownButtonFormField(
 | 
			
		||||
                      decoration: const InputDecoration(labelText: 'Colour'),
 | 
			
		||||
                      value: settingsProvider.colour,
 | 
			
		||||
                      items: const [
 | 
			
		||||
                        DropdownMenuItem(
 | 
			
		||||
                          value: ColourSettings.basic,
 | 
			
		||||
                          child: Text('Obtainium'),
 | 
			
		||||
                        DropdownButtonFormField(
 | 
			
		||||
                            decoration:
 | 
			
		||||
                                const InputDecoration(labelText: 'Colour'),
 | 
			
		||||
                            value: settingsProvider.colour,
 | 
			
		||||
                            items: const [
 | 
			
		||||
                              DropdownMenuItem(
 | 
			
		||||
                                value: ColourSettings.basic,
 | 
			
		||||
                                child: Text('Obtainium'),
 | 
			
		||||
                              ),
 | 
			
		||||
                              DropdownMenuItem(
 | 
			
		||||
                                value: ColourSettings.materialYou,
 | 
			
		||||
                                child: Text('Material You'),
 | 
			
		||||
                              )
 | 
			
		||||
                            ],
 | 
			
		||||
                            onChanged: (value) {
 | 
			
		||||
                              if (value != null) {
 | 
			
		||||
                                settingsProvider.colour = value;
 | 
			
		||||
                              }
 | 
			
		||||
                            }),
 | 
			
		||||
                        const SizedBox(
 | 
			
		||||
                          height: 16,
 | 
			
		||||
                        ),
 | 
			
		||||
                        DropdownMenuItem(
 | 
			
		||||
                          value: ColourSettings.materialYou,
 | 
			
		||||
                          child: Text('Material You'),
 | 
			
		||||
                        )
 | 
			
		||||
                      ],
 | 
			
		||||
                      onChanged: (value) {
 | 
			
		||||
                        if (value != null) {
 | 
			
		||||
                          settingsProvider.colour = value;
 | 
			
		||||
                        }
 | 
			
		||||
                      }),
 | 
			
		||||
                  const SizedBox(
 | 
			
		||||
                    height: 16,
 | 
			
		||||
                  ),
 | 
			
		||||
                  DropdownButtonFormField(
 | 
			
		||||
                      decoration: const InputDecoration(
 | 
			
		||||
                          labelText: 'Background Update Checking Interval'),
 | 
			
		||||
                      value: settingsProvider.updateInterval,
 | 
			
		||||
                      items: const [
 | 
			
		||||
                        DropdownMenuItem(
 | 
			
		||||
                          value: 15,
 | 
			
		||||
                          child: Text('15 Minutes'),
 | 
			
		||||
                        ),
 | 
			
		||||
                        DropdownMenuItem(
 | 
			
		||||
                          value: 30,
 | 
			
		||||
                          child: Text('30 Minutes'),
 | 
			
		||||
                        ),
 | 
			
		||||
                        DropdownMenuItem(
 | 
			
		||||
                          value: 60,
 | 
			
		||||
                          child: Text('1 Hour'),
 | 
			
		||||
                        ),
 | 
			
		||||
                        DropdownMenuItem(
 | 
			
		||||
                          value: 360,
 | 
			
		||||
                          child: Text('6 Hours'),
 | 
			
		||||
                        ),
 | 
			
		||||
                        DropdownMenuItem(
 | 
			
		||||
                          value: 720,
 | 
			
		||||
                          child: Text('12 Hours'),
 | 
			
		||||
                        ),
 | 
			
		||||
                        DropdownMenuItem(
 | 
			
		||||
                          value: 1440,
 | 
			
		||||
                          child: Text('1 Day'),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ],
 | 
			
		||||
                      onChanged: (value) {
 | 
			
		||||
                        if (value != null) {
 | 
			
		||||
                          settingsProvider.updateInterval = value;
 | 
			
		||||
                        }
 | 
			
		||||
                      }),
 | 
			
		||||
                  const SizedBox(
 | 
			
		||||
                    height: 32,
 | 
			
		||||
                  ),
 | 
			
		||||
                  Row(
 | 
			
		||||
                    mainAxisAlignment: MainAxisAlignment.spaceAround,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      ElevatedButton(
 | 
			
		||||
                          onPressed: appsProvider.apps.isEmpty
 | 
			
		||||
                              ? null
 | 
			
		||||
                              : () {
 | 
			
		||||
                                  HapticFeedback.lightImpact();
 | 
			
		||||
                                  appsProvider.exportApps().then((String path) {
 | 
			
		||||
                                    ScaffoldMessenger.of(context).showSnackBar(
 | 
			
		||||
                                      SnackBar(
 | 
			
		||||
                                          content: Text('Exported to $path')),
 | 
			
		||||
                                    );
 | 
			
		||||
                                  });
 | 
			
		||||
                                },
 | 
			
		||||
                          child: const Text('Export Apps')),
 | 
			
		||||
                      ElevatedButton(
 | 
			
		||||
                          onPressed: () {
 | 
			
		||||
                            HapticFeedback.lightImpact();
 | 
			
		||||
                            showDialog(
 | 
			
		||||
                                context: context,
 | 
			
		||||
                                builder: (BuildContext ctx) {
 | 
			
		||||
                                  final formKey = GlobalKey<FormState>();
 | 
			
		||||
                                  final jsonInputController =
 | 
			
		||||
                                      TextEditingController();
 | 
			
		||||
 | 
			
		||||
                                  return AlertDialog(
 | 
			
		||||
                                    scrollable: true,
 | 
			
		||||
                                    title: const Text('Import Apps'),
 | 
			
		||||
                                    content: Column(children: [
 | 
			
		||||
                                      const Text(
 | 
			
		||||
                                          'Copy the contents of the Obtainium export file and paste them into the field below:'),
 | 
			
		||||
                                      Form(
 | 
			
		||||
                                        key: formKey,
 | 
			
		||||
                                        child: TextFormField(
 | 
			
		||||
                                          minLines: 7,
 | 
			
		||||
                                          maxLines: 7,
 | 
			
		||||
                                          decoration: const InputDecoration(
 | 
			
		||||
                                              helperText:
 | 
			
		||||
                                                  'Obtainium export data'),
 | 
			
		||||
                                          controller: jsonInputController,
 | 
			
		||||
                                          validator: (value) {
 | 
			
		||||
                                            if (value == null ||
 | 
			
		||||
                                                value.isEmpty) {
 | 
			
		||||
                                              return 'Please enter your Obtainium export data';
 | 
			
		||||
                                            }
 | 
			
		||||
                                            bool isJSON = true;
 | 
			
		||||
                                            try {
 | 
			
		||||
                                              jsonDecode(value);
 | 
			
		||||
                                            } catch (e) {
 | 
			
		||||
                                              isJSON = false;
 | 
			
		||||
                                            }
 | 
			
		||||
                                            if (!isJSON) {
 | 
			
		||||
                                              return 'Invalid input';
 | 
			
		||||
                                            }
 | 
			
		||||
                                            return null;
 | 
			
		||||
                                          },
 | 
			
		||||
                                        ),
 | 
			
		||||
                        Row(
 | 
			
		||||
                          mainAxisAlignment: MainAxisAlignment.start,
 | 
			
		||||
                          crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            Expanded(
 | 
			
		||||
                                child: DropdownButtonFormField(
 | 
			
		||||
                                    decoration: const InputDecoration(
 | 
			
		||||
                                        labelText: 'App Sort By'),
 | 
			
		||||
                                    value: settingsProvider.sortColumn,
 | 
			
		||||
                                    items: const [
 | 
			
		||||
                                      DropdownMenuItem(
 | 
			
		||||
                                        value: SortColumnSettings.authorName,
 | 
			
		||||
                                        child: Text('Author/Name'),
 | 
			
		||||
                                      ),
 | 
			
		||||
                                      DropdownMenuItem(
 | 
			
		||||
                                        value: SortColumnSettings.nameAuthor,
 | 
			
		||||
                                        child: Text('Name/Author'),
 | 
			
		||||
                                      ),
 | 
			
		||||
                                      DropdownMenuItem(
 | 
			
		||||
                                        value: SortColumnSettings.added,
 | 
			
		||||
                                        child: Text('As Added'),
 | 
			
		||||
                                      )
 | 
			
		||||
                                    ]),
 | 
			
		||||
                                    actions: [
 | 
			
		||||
                                      TextButton(
 | 
			
		||||
                                          onPressed: () {
 | 
			
		||||
                                            HapticFeedback.lightImpact();
 | 
			
		||||
                                            Navigator.of(context).pop();
 | 
			
		||||
                                          },
 | 
			
		||||
                                          child: const Text('Cancel')),
 | 
			
		||||
                                      TextButton(
 | 
			
		||||
                                          onPressed: () {
 | 
			
		||||
                                            HapticFeedback.heavyImpact();
 | 
			
		||||
                                            if (formKey.currentState!
 | 
			
		||||
                                                .validate()) {
 | 
			
		||||
                                              appsProvider
 | 
			
		||||
                                                  .importApps(
 | 
			
		||||
                                                      jsonInputController
 | 
			
		||||
                                                          .value.text)
 | 
			
		||||
                                                  .then((value) {
 | 
			
		||||
                                                ScaffoldMessenger.of(context)
 | 
			
		||||
                                                    .showSnackBar(
 | 
			
		||||
                                                  SnackBar(
 | 
			
		||||
                                                      content: Text(
 | 
			
		||||
                                                          '$value Apps Imported')),
 | 
			
		||||
                                                );
 | 
			
		||||
                                              }).catchError((e) {
 | 
			
		||||
                                                ScaffoldMessenger.of(context)
 | 
			
		||||
                                                    .showSnackBar(
 | 
			
		||||
                                                  SnackBar(
 | 
			
		||||
                                                      content:
 | 
			
		||||
                                                          Text(e.toString())),
 | 
			
		||||
                                                );
 | 
			
		||||
                                              }).whenComplete(() {
 | 
			
		||||
                                                Navigator.of(context).pop();
 | 
			
		||||
                                              });
 | 
			
		||||
                                            }
 | 
			
		||||
                                          },
 | 
			
		||||
                                          child: const Text('Import')),
 | 
			
		||||
                                    ],
 | 
			
		||||
                                  );
 | 
			
		||||
                                });
 | 
			
		||||
                          },
 | 
			
		||||
                          child: const Text('Import Apps'))
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
                  const Spacer(),
 | 
			
		||||
                  Row(
 | 
			
		||||
                    mainAxisAlignment: MainAxisAlignment.center,
 | 
			
		||||
                    children: [
 | 
			
		||||
                      TextButton.icon(
 | 
			
		||||
                        style: ButtonStyle(
 | 
			
		||||
                          foregroundColor:
 | 
			
		||||
                              MaterialStateProperty.resolveWith<Color>(
 | 
			
		||||
                                  (Set<MaterialState> states) {
 | 
			
		||||
                            return Colors.grey;
 | 
			
		||||
                          }),
 | 
			
		||||
                                    onChanged: (value) {
 | 
			
		||||
                                      if (value != null) {
 | 
			
		||||
                                        settingsProvider.sortColumn = value;
 | 
			
		||||
                                      }
 | 
			
		||||
                                    })),
 | 
			
		||||
                            const SizedBox(
 | 
			
		||||
                              width: 16,
 | 
			
		||||
                            ),
 | 
			
		||||
                            Expanded(
 | 
			
		||||
                                child: DropdownButtonFormField(
 | 
			
		||||
                                    decoration: const InputDecoration(
 | 
			
		||||
                                        labelText: 'App Sort Order'),
 | 
			
		||||
                                    value: settingsProvider.sortOrder,
 | 
			
		||||
                                    items: const [
 | 
			
		||||
                                      DropdownMenuItem(
 | 
			
		||||
                                        value: SortOrderSettings.ascending,
 | 
			
		||||
                                        child: Text('Ascending'),
 | 
			
		||||
                                      ),
 | 
			
		||||
                                      DropdownMenuItem(
 | 
			
		||||
                                        value: SortOrderSettings.descending,
 | 
			
		||||
                                        child: Text('Descending'),
 | 
			
		||||
                                      ),
 | 
			
		||||
                                    ],
 | 
			
		||||
                                    onChanged: (value) {
 | 
			
		||||
                                      if (value != null) {
 | 
			
		||||
                                        settingsProvider.sortOrder = value;
 | 
			
		||||
                                      }
 | 
			
		||||
                                    })),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                        onPressed: () {
 | 
			
		||||
                          HapticFeedback.lightImpact();
 | 
			
		||||
                          launchUrlString(settingsProvider.sourceUrl,
 | 
			
		||||
                              mode: LaunchMode.externalApplication);
 | 
			
		||||
                        },
 | 
			
		||||
                        icon: const Icon(Icons.code),
 | 
			
		||||
                        label: Text(
 | 
			
		||||
                          'Source',
 | 
			
		||||
                          style: Theme.of(context).textTheme.caption,
 | 
			
		||||
                        const SizedBox(
 | 
			
		||||
                          height: 16,
 | 
			
		||||
                        ),
 | 
			
		||||
                      )
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
                ],
 | 
			
		||||
              ));
 | 
			
		||||
                        Row(
 | 
			
		||||
                          mainAxisAlignment: MainAxisAlignment.spaceBetween,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            const Text('Show Source Webpage in App View'),
 | 
			
		||||
                            Switch(
 | 
			
		||||
                                value: settingsProvider.showAppWebpage,
 | 
			
		||||
                                onChanged: (value) {
 | 
			
		||||
                                  settingsProvider.showAppWebpage = value;
 | 
			
		||||
                                })
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                        const Divider(
 | 
			
		||||
                          height: 16,
 | 
			
		||||
                        ),
 | 
			
		||||
                        const SizedBox(
 | 
			
		||||
                          height: 16,
 | 
			
		||||
                        ),
 | 
			
		||||
                        Text(
 | 
			
		||||
                          'More',
 | 
			
		||||
                          style: TextStyle(
 | 
			
		||||
                              color: Theme.of(context).colorScheme.primary),
 | 
			
		||||
                        ),
 | 
			
		||||
                        DropdownButtonFormField(
 | 
			
		||||
                            decoration: const InputDecoration(
 | 
			
		||||
                                labelText:
 | 
			
		||||
                                    'Background Update Checking Interval'),
 | 
			
		||||
                            value: settingsProvider.updateInterval,
 | 
			
		||||
                            items: const [
 | 
			
		||||
                              DropdownMenuItem(
 | 
			
		||||
                                value: 15,
 | 
			
		||||
                                child: Text('15 Minutes'),
 | 
			
		||||
                              ),
 | 
			
		||||
                              DropdownMenuItem(
 | 
			
		||||
                                value: 30,
 | 
			
		||||
                                child: Text('30 Minutes'),
 | 
			
		||||
                              ),
 | 
			
		||||
                              DropdownMenuItem(
 | 
			
		||||
                                value: 60,
 | 
			
		||||
                                child: Text('1 Hour'),
 | 
			
		||||
                              ),
 | 
			
		||||
                              DropdownMenuItem(
 | 
			
		||||
                                value: 360,
 | 
			
		||||
                                child: Text('6 Hours'),
 | 
			
		||||
                              ),
 | 
			
		||||
                              DropdownMenuItem(
 | 
			
		||||
                                value: 720,
 | 
			
		||||
                                child: Text('12 Hours'),
 | 
			
		||||
                              ),
 | 
			
		||||
                              DropdownMenuItem(
 | 
			
		||||
                                value: 1440,
 | 
			
		||||
                                child: Text('1 Day'),
 | 
			
		||||
                              ),
 | 
			
		||||
                              DropdownMenuItem(
 | 
			
		||||
                                value: 0,
 | 
			
		||||
                                child: Text('Never - Manual Only'),
 | 
			
		||||
                              ),
 | 
			
		||||
                            ],
 | 
			
		||||
                            onChanged: (value) {
 | 
			
		||||
                              if (value != null) {
 | 
			
		||||
                                settingsProvider.updateInterval = value;
 | 
			
		||||
                              }
 | 
			
		||||
                            }),
 | 
			
		||||
                        const Spacer(),
 | 
			
		||||
                        Row(
 | 
			
		||||
                          mainAxisAlignment: MainAxisAlignment.center,
 | 
			
		||||
                          children: [
 | 
			
		||||
                            TextButton.icon(
 | 
			
		||||
                              style: ButtonStyle(
 | 
			
		||||
                                foregroundColor:
 | 
			
		||||
                                    MaterialStateProperty.resolveWith<Color>(
 | 
			
		||||
                                        (Set<MaterialState> states) {
 | 
			
		||||
                                  return Colors.grey;
 | 
			
		||||
                                }),
 | 
			
		||||
                              ),
 | 
			
		||||
                              onPressed: () {
 | 
			
		||||
                                launchUrlString(settingsProvider.sourceUrl,
 | 
			
		||||
                                    mode: LaunchMode.externalApplication);
 | 
			
		||||
                              },
 | 
			
		||||
                              icon: const Icon(Icons.code),
 | 
			
		||||
                              label: Text(
 | 
			
		||||
                                'Source',
 | 
			
		||||
                                style: Theme.of(context).textTheme.bodySmall,
 | 
			
		||||
                              ),
 | 
			
		||||
                            )
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                      ],
 | 
			
		||||
                    )))
 | 
			
		||||
    ]);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -108,6 +108,7 @@ class AppsProvider with ChangeNotifier {
 | 
			
		||||
      if (apps[id] == null) {
 | 
			
		||||
        throw 'App not found';
 | 
			
		||||
      }
 | 
			
		||||
      // If the App has more than one APK, the user should pick one
 | 
			
		||||
      String? apkUrl = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex];
 | 
			
		||||
      if (apps[id]!.app.apkUrls.length > 1) {
 | 
			
		||||
        apkUrl = await showDialog(
 | 
			
		||||
@@ -116,6 +117,19 @@ class AppsProvider with ChangeNotifier {
 | 
			
		||||
              return APKPicker(app: apps[id]!.app, initVal: apkUrl);
 | 
			
		||||
            });
 | 
			
		||||
      }
 | 
			
		||||
      // If the picked APK comes from an origin different from the source, get user confirmation
 | 
			
		||||
      if (apkUrl != null &&
 | 
			
		||||
          Uri.parse(apkUrl).origin != Uri.parse(apps[id]!.app.url).origin) {
 | 
			
		||||
        if (await showDialog(
 | 
			
		||||
                context: context,
 | 
			
		||||
                builder: (BuildContext ctx) {
 | 
			
		||||
                  return APKOriginWarningDialog(
 | 
			
		||||
                      sourceUrl: apps[id]!.app.url, apkUrl: apkUrl!);
 | 
			
		||||
                }) !=
 | 
			
		||||
            true) {
 | 
			
		||||
          apkUrl = null;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      if (apkUrl != null) {
 | 
			
		||||
        int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
 | 
			
		||||
        if (urlInd != apps[id]!.app.preferredApkIndex) {
 | 
			
		||||
@@ -325,13 +339,12 @@ class _APKPickerState extends State<APKPicker> {
 | 
			
		||||
      actions: [
 | 
			
		||||
        TextButton(
 | 
			
		||||
            onPressed: () {
 | 
			
		||||
              HapticFeedback.lightImpact();
 | 
			
		||||
              Navigator.of(context).pop(null);
 | 
			
		||||
            },
 | 
			
		||||
            child: const Text('Cancel')),
 | 
			
		||||
        TextButton(
 | 
			
		||||
            onPressed: () {
 | 
			
		||||
              HapticFeedback.mediumImpact();
 | 
			
		||||
              HapticFeedback.selectionClick();
 | 
			
		||||
              Navigator.of(context).pop(apkUrl);
 | 
			
		||||
            },
 | 
			
		||||
            child: const Text('Continue'))
 | 
			
		||||
@@ -339,3 +352,39 @@ class _APKPickerState extends State<APKPicker> {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class APKOriginWarningDialog extends StatefulWidget {
 | 
			
		||||
  const APKOriginWarningDialog(
 | 
			
		||||
      {super.key, required this.sourceUrl, required this.apkUrl});
 | 
			
		||||
 | 
			
		||||
  final String sourceUrl;
 | 
			
		||||
  final String apkUrl;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<APKOriginWarningDialog> createState() => _APKOriginWarningDialogState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _APKOriginWarningDialogState extends State<APKOriginWarningDialog> {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return AlertDialog(
 | 
			
		||||
      scrollable: true,
 | 
			
		||||
      title: const Text('Warning'),
 | 
			
		||||
      content: Text(
 | 
			
		||||
          'The App source is \'${Uri.parse(widget.sourceUrl).host}\' but the release package comes from \'${Uri.parse(widget.apkUrl).host}\'. Continue?'),
 | 
			
		||||
      actions: [
 | 
			
		||||
        TextButton(
 | 
			
		||||
            onPressed: () {
 | 
			
		||||
              Navigator.of(context).pop(null);
 | 
			
		||||
            },
 | 
			
		||||
            child: const Text('Cancel')),
 | 
			
		||||
        TextButton(
 | 
			
		||||
            onPressed: () {
 | 
			
		||||
              HapticFeedback.selectionClick();
 | 
			
		||||
              Navigator.of(context).pop(true);
 | 
			
		||||
            },
 | 
			
		||||
            child: const Text('Continue'))
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -9,6 +9,10 @@ enum ThemeSettings { system, light, dark }
 | 
			
		||||
 | 
			
		||||
enum ColourSettings { basic, materialYou }
 | 
			
		||||
 | 
			
		||||
enum SortColumnSettings { added, nameAuthor, authorName }
 | 
			
		||||
 | 
			
		||||
enum SortOrderSettings { ascending, descending }
 | 
			
		||||
 | 
			
		||||
class SettingsProvider with ChangeNotifier {
 | 
			
		||||
  SharedPreferences? prefs;
 | 
			
		||||
 | 
			
		||||
@@ -45,7 +49,27 @@ class SettingsProvider with ChangeNotifier {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  set updateInterval(int min) {
 | 
			
		||||
    prefs?.setInt('updateInterval', min < 15 ? 15 : min);
 | 
			
		||||
    prefs?.setInt('updateInterval', (min < 15 && min != 0) ? 15 : min);
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  SortColumnSettings get sortColumn {
 | 
			
		||||
    return SortColumnSettings
 | 
			
		||||
        .values[prefs?.getInt('sortColumn') ?? SortColumnSettings.added.index];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  set sortColumn(SortColumnSettings s) {
 | 
			
		||||
    prefs?.setInt('sortColumn', s.index);
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  SortOrderSettings get sortOrder {
 | 
			
		||||
    return SortOrderSettings.values[
 | 
			
		||||
        prefs?.getInt('sortOrder') ?? SortOrderSettings.descending.index];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  set sortOrder(SortOrderSettings s) {
 | 
			
		||||
    prefs?.setInt('sortOrder', s.index);
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -69,4 +93,13 @@ class SettingsProvider with ChangeNotifier {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  bool get showAppWebpage {
 | 
			
		||||
    return prefs?.getBool('showAppWebpage') ?? true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  set showAppWebpage(bool show) {
 | 
			
		||||
    prefs?.setBool('showAppWebpage', show);
 | 
			
		||||
    notifyListeners();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -71,6 +71,12 @@ escapeRegEx(String s) {
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const String couldNotFindReleases = 'Unable to fetch release info';
 | 
			
		||||
const String couldNotFindLatestVersion =
 | 
			
		||||
    'Could not determine latest release version';
 | 
			
		||||
const String notValidURL = 'Not a valid URL';
 | 
			
		||||
const String noAPKFound = 'No APK found';
 | 
			
		||||
 | 
			
		||||
List<String> getLinksFromParsedHTML(
 | 
			
		||||
        Document dom, RegExp hrefPattern, String prependToLinks) =>
 | 
			
		||||
    dom
 | 
			
		||||
@@ -98,44 +104,53 @@ class GitHub implements AppSource {
 | 
			
		||||
    RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
 | 
			
		||||
    RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
 | 
			
		||||
    if (match == null) {
 | 
			
		||||
      throw 'Not a valid URL';
 | 
			
		||||
      throw notValidURL;
 | 
			
		||||
    }
 | 
			
		||||
    return url.substring(0, match.end);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
 | 
			
		||||
    Response res = await get(Uri.parse('$standardUrl/releases/latest'));
 | 
			
		||||
    Response res = await get(Uri.parse(
 | 
			
		||||
        'https://api.$host/repos${standardUrl.substring('https://$host'.length)}/releases'));
 | 
			
		||||
    if (res.statusCode == 200) {
 | 
			
		||||
      var standardUri = Uri.parse(standardUrl);
 | 
			
		||||
      var parsedHtml = parse(res.body);
 | 
			
		||||
      var apkUrlList = getLinksFromParsedHTML(
 | 
			
		||||
          parsedHtml,
 | 
			
		||||
          RegExp(
 | 
			
		||||
              '^${escapeRegEx(standardUri.path)}/releases/download/[^/]+/[^/]+\\.apk\$',
 | 
			
		||||
              caseSensitive: false),
 | 
			
		||||
          standardUri.origin);
 | 
			
		||||
      if (apkUrlList.isEmpty) {
 | 
			
		||||
        throw 'No APK found';
 | 
			
		||||
      var releases = jsonDecode(res.body) as List<dynamic>;
 | 
			
		||||
      // Right now, the latest non-prerelease version is picked
 | 
			
		||||
      // If none exists, the latest prerelease version is picked
 | 
			
		||||
      // In the future, the user could be given a choice
 | 
			
		||||
      var nonPrereleaseReleases =
 | 
			
		||||
          releases.where((element) => element['prerelease'] != true).toList();
 | 
			
		||||
      var latestRelease = nonPrereleaseReleases.isNotEmpty
 | 
			
		||||
          ? nonPrereleaseReleases[0]
 | 
			
		||||
          : releases.isNotEmpty
 | 
			
		||||
              ? releases[0]
 | 
			
		||||
              : null;
 | 
			
		||||
      if (latestRelease == null) {
 | 
			
		||||
        throw couldNotFindReleases;
 | 
			
		||||
      }
 | 
			
		||||
      String getTag(String url) {
 | 
			
		||||
        List<String> parts = url.split('/');
 | 
			
		||||
        return parts[parts.length - 2];
 | 
			
		||||
      List<dynamic>? assets = latestRelease['assets'];
 | 
			
		||||
      List<String>? apkUrlList = assets
 | 
			
		||||
          ?.map((e) {
 | 
			
		||||
            return e['browser_download_url'] != null
 | 
			
		||||
                ? e['browser_download_url'] as String
 | 
			
		||||
                : '';
 | 
			
		||||
          })
 | 
			
		||||
          .where((element) => element.toLowerCase().endsWith('.apk'))
 | 
			
		||||
          .toList();
 | 
			
		||||
      if (apkUrlList == null || apkUrlList.isEmpty) {
 | 
			
		||||
        throw noAPKFound;
 | 
			
		||||
      }
 | 
			
		||||
      String? version = latestRelease['tag_name'];
 | 
			
		||||
      if (version == null) {
 | 
			
		||||
        throw couldNotFindLatestVersion;
 | 
			
		||||
      }
 | 
			
		||||
      return APKDetails(version, apkUrlList);
 | 
			
		||||
    } else {
 | 
			
		||||
      if (res.headers['x-ratelimit-remaining'] == '0') {
 | 
			
		||||
        throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes';
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      String latestTag = getTag(apkUrlList[0]);
 | 
			
		||||
      String? version = parsedHtml
 | 
			
		||||
          .querySelector('.octicon-tag')
 | 
			
		||||
          ?.nextElementSibling
 | 
			
		||||
          ?.innerHtml
 | 
			
		||||
          .trim();
 | 
			
		||||
      if (version == null) {
 | 
			
		||||
        throw 'Could not determine latest release version';
 | 
			
		||||
      }
 | 
			
		||||
      return APKDetails(version,
 | 
			
		||||
          apkUrlList.where((element) => getTag(element) == latestTag).toList());
 | 
			
		||||
    } else {
 | 
			
		||||
      throw 'Unable to fetch release info';
 | 
			
		||||
      throw couldNotFindReleases;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -156,7 +171,7 @@ class GitLab implements AppSource {
 | 
			
		||||
    RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
 | 
			
		||||
    RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
 | 
			
		||||
    if (match == null) {
 | 
			
		||||
      throw 'Not a valid URL';
 | 
			
		||||
      throw notValidURL;
 | 
			
		||||
    }
 | 
			
		||||
    return url.substring(0, match.end);
 | 
			
		||||
  }
 | 
			
		||||
@@ -170,25 +185,32 @@ class GitLab implements AppSource {
 | 
			
		||||
      var entry = parsedHtml.querySelector('entry');
 | 
			
		||||
      var entryContent =
 | 
			
		||||
          parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
 | 
			
		||||
      var apkUrlList = getLinksFromParsedHTML(
 | 
			
		||||
          entryContent,
 | 
			
		||||
          RegExp(
 | 
			
		||||
              '^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$',
 | 
			
		||||
              caseSensitive: false),
 | 
			
		||||
          standardUri.origin);
 | 
			
		||||
      var apkUrlList = [
 | 
			
		||||
        ...getLinksFromParsedHTML(
 | 
			
		||||
            entryContent,
 | 
			
		||||
            RegExp(
 | 
			
		||||
                '^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$',
 | 
			
		||||
                caseSensitive: false),
 | 
			
		||||
            standardUri.origin),
 | 
			
		||||
        // GitLab releases may contain links to externally hosted APKs
 | 
			
		||||
        ...getLinksFromParsedHTML(entryContent,
 | 
			
		||||
                RegExp('/[^/]+\\.apk\$', caseSensitive: false), '')
 | 
			
		||||
            .where((element) => Uri.parse(element).host != '')
 | 
			
		||||
            .toList()
 | 
			
		||||
      ];
 | 
			
		||||
      if (apkUrlList.isEmpty) {
 | 
			
		||||
        throw 'No APK found';
 | 
			
		||||
        throw noAPKFound;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      var entryId = entry?.querySelector('id')?.innerHtml;
 | 
			
		||||
      var version =
 | 
			
		||||
          entryId == null ? null : Uri.parse(entryId).pathSegments.last;
 | 
			
		||||
      if (version == null) {
 | 
			
		||||
        throw 'Could not determine latest release version';
 | 
			
		||||
        throw couldNotFindLatestVersion;
 | 
			
		||||
      }
 | 
			
		||||
      return APKDetails(version, apkUrlList);
 | 
			
		||||
    } else {
 | 
			
		||||
      throw 'Unable to fetch release info';
 | 
			
		||||
      throw couldNotFindReleases;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -216,15 +238,15 @@ class Signal implements AppSource {
 | 
			
		||||
      var json = jsonDecode(res.body);
 | 
			
		||||
      String? apkUrl = json['url'];
 | 
			
		||||
      if (apkUrl == null) {
 | 
			
		||||
        throw 'No APK found';
 | 
			
		||||
        throw noAPKFound;
 | 
			
		||||
      }
 | 
			
		||||
      String? version = json['versionName'];
 | 
			
		||||
      if (version == null) {
 | 
			
		||||
        throw 'Could not determine latest release version';
 | 
			
		||||
        throw couldNotFindLatestVersion;
 | 
			
		||||
      }
 | 
			
		||||
      return APKDetails(version, [apkUrl]);
 | 
			
		||||
    } else {
 | 
			
		||||
      throw 'Unable to fetch release info';
 | 
			
		||||
      throw couldNotFindReleases;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -241,7 +263,7 @@ class FDroid implements AppSource {
 | 
			
		||||
    RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+');
 | 
			
		||||
    RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
 | 
			
		||||
    if (match == null) {
 | 
			
		||||
      throw 'Not a valid URL';
 | 
			
		||||
      throw notValidURL;
 | 
			
		||||
    }
 | 
			
		||||
    return url.substring(0, match.end);
 | 
			
		||||
  }
 | 
			
		||||
@@ -256,7 +278,7 @@ class FDroid implements AppSource {
 | 
			
		||||
          ?.querySelector('.package-version-download a')
 | 
			
		||||
          ?.attributes['href'];
 | 
			
		||||
      if (apkUrl == null) {
 | 
			
		||||
        throw 'No APK found';
 | 
			
		||||
        throw noAPKFound;
 | 
			
		||||
      }
 | 
			
		||||
      var version = latestReleaseDiv
 | 
			
		||||
          ?.querySelector('.package-version-header b')
 | 
			
		||||
@@ -264,18 +286,17 @@ class FDroid implements AppSource {
 | 
			
		||||
          .split(' ')
 | 
			
		||||
          .last;
 | 
			
		||||
      if (version == null) {
 | 
			
		||||
        throw 'Could not determine latest release version';
 | 
			
		||||
        throw couldNotFindLatestVersion;
 | 
			
		||||
      }
 | 
			
		||||
      return APKDetails(version, [apkUrl]);
 | 
			
		||||
    } else {
 | 
			
		||||
      throw 'Unable to fetch release info';
 | 
			
		||||
      throw couldNotFindReleases;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  AppNames getAppNames(String standardUrl) {
 | 
			
		||||
    var name = Uri.parse(standardUrl).pathSegments.last;
 | 
			
		||||
    return AppNames(name, name);
 | 
			
		||||
    return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -288,7 +309,7 @@ class Mullvad implements AppSource {
 | 
			
		||||
    RegExp standardUrlRegEx = RegExp('^https?://$host');
 | 
			
		||||
    RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
 | 
			
		||||
    if (match == null) {
 | 
			
		||||
      throw 'Not a valid URL';
 | 
			
		||||
      throw notValidURL;
 | 
			
		||||
    }
 | 
			
		||||
    return url.substring(0, match.end);
 | 
			
		||||
  }
 | 
			
		||||
@@ -304,12 +325,12 @@ class Mullvad implements AppSource {
 | 
			
		||||
          ?.split('/')
 | 
			
		||||
          .last;
 | 
			
		||||
      if (version == null) {
 | 
			
		||||
        throw 'Could not determine the latest release version';
 | 
			
		||||
        throw couldNotFindLatestVersion;
 | 
			
		||||
      }
 | 
			
		||||
      return APKDetails(
 | 
			
		||||
          version, ['https://mullvad.net/download/app/apk/latest']);
 | 
			
		||||
    } else {
 | 
			
		||||
      throw 'Unable to fetch release info';
 | 
			
		||||
      throw couldNotFindReleases;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -319,8 +340,71 @@ class Mullvad implements AppSource {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class IzzyOnDroid implements AppSource {
 | 
			
		||||
  @override
 | 
			
		||||
  late String host = 'android.izzysoft.de';
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String standardizeURL(String url) {
 | 
			
		||||
    RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+');
 | 
			
		||||
    RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
 | 
			
		||||
    if (match == null) {
 | 
			
		||||
      throw notValidURL;
 | 
			
		||||
    }
 | 
			
		||||
    return url.substring(0, match.end);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
 | 
			
		||||
    Response res = await get(Uri.parse(standardUrl));
 | 
			
		||||
    if (res.statusCode == 200) {
 | 
			
		||||
      var parsedHtml = parse(res.body);
 | 
			
		||||
      var multipleVersionApkUrls = parsedHtml
 | 
			
		||||
          .querySelectorAll('a')
 | 
			
		||||
          .where((element) =>
 | 
			
		||||
              element.attributes['href']?.toLowerCase().endsWith('.apk') ??
 | 
			
		||||
              false)
 | 
			
		||||
          .map((e) => 'https://$host${e.attributes['href'] ?? ''}')
 | 
			
		||||
          .toList();
 | 
			
		||||
      if (multipleVersionApkUrls.isEmpty) {
 | 
			
		||||
        throw noAPKFound;
 | 
			
		||||
      }
 | 
			
		||||
      var version = parsedHtml
 | 
			
		||||
          .querySelector('#keydata')
 | 
			
		||||
          ?.querySelectorAll('b')
 | 
			
		||||
          .where(
 | 
			
		||||
              (element) => element.innerHtml.toLowerCase().contains('version'))
 | 
			
		||||
          .toList()[0]
 | 
			
		||||
          .parentNode
 | 
			
		||||
          ?.parentNode
 | 
			
		||||
          ?.children[1]
 | 
			
		||||
          .innerHtml;
 | 
			
		||||
      if (version == null) {
 | 
			
		||||
        throw couldNotFindLatestVersion;
 | 
			
		||||
      }
 | 
			
		||||
      return APKDetails(version, [multipleVersionApkUrls[0]]);
 | 
			
		||||
    } else {
 | 
			
		||||
      throw couldNotFindReleases;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  AppNames getAppNames(String standardUrl) {
 | 
			
		||||
    return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class SourceProvider {
 | 
			
		||||
  List<AppSource> sources = [GitHub(), GitLab(), FDroid(), Mullvad(), Signal()];
 | 
			
		||||
  List<AppSource> sources = [
 | 
			
		||||
    GitHub(),
 | 
			
		||||
    GitLab(),
 | 
			
		||||
    FDroid(),
 | 
			
		||||
    IzzyOnDroid(),
 | 
			
		||||
    Mullvad(),
 | 
			
		||||
    Signal()
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  List<MassAppSource> massSources = [GitHubStars()];
 | 
			
		||||
 | 
			
		||||
  // Add more source classes here so they are available via the service
 | 
			
		||||
  AppSource getSource(String url) {
 | 
			
		||||
@@ -360,5 +444,54 @@ class SourceProvider {
 | 
			
		||||
        apk.apkUrls.length - 1);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Returns a length 2 list, where the first element is a list of Apps and
 | 
			
		||||
  /// the second is a Map<String, dynamic> of URLs and errors
 | 
			
		||||
  Future<List<dynamic>> getApps(List<String> urls) async {
 | 
			
		||||
    List<App> apps = [];
 | 
			
		||||
    Map<String, dynamic> errors = {};
 | 
			
		||||
    for (var url in urls) {
 | 
			
		||||
      try {
 | 
			
		||||
        apps.add(await getApp(url));
 | 
			
		||||
      } catch (e) {
 | 
			
		||||
        errors.addAll(<String, dynamic>{url: e});
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return [apps, errors];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  List<String> getSourceHosts() => sources.map((e) => e.host).toList();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
abstract class MassAppSource {
 | 
			
		||||
  late String name;
 | 
			
		||||
  late List<String> requiredArgs;
 | 
			
		||||
  Future<List<String>> getUrls(List<String> args);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class GitHubStars implements MassAppSource {
 | 
			
		||||
  @override
 | 
			
		||||
  late String name = 'GitHub Starred Repos';
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  late List<String> requiredArgs = ['Username'];
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Future<List<String>> getUrls(List<String> args) async {
 | 
			
		||||
    if (args.length != requiredArgs.length) {
 | 
			
		||||
      throw 'Wrong number of arguments provided';
 | 
			
		||||
    }
 | 
			
		||||
    Response res =
 | 
			
		||||
        await get(Uri.parse('https://api.github.com/users/${args[0]}/starred'));
 | 
			
		||||
    if (res.statusCode == 200) {
 | 
			
		||||
      return (jsonDecode(res.body) as List<dynamic>)
 | 
			
		||||
          .map((e) => e['html_url'] as String)
 | 
			
		||||
          .toList();
 | 
			
		||||
    } else {
 | 
			
		||||
      if (res.headers['x-ratelimit-remaining'] == '0') {
 | 
			
		||||
        throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes';
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      throw 'Unable to find user\'s starred repos';
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										95
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										95
									
								
								pubspec.lock
									
									
									
									
									
								
							@@ -1,6 +1,13 @@
 | 
			
		||||
# Generated by pub
 | 
			
		||||
# See https://dart.dev/tools/pub/glossary#lockfile
 | 
			
		||||
packages:
 | 
			
		||||
  animations:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: animations
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.0.4"
 | 
			
		||||
  archive:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -92,13 +99,55 @@ packages:
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.7.8"
 | 
			
		||||
  device_info_plus:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: device_info_plus
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "4.1.2"
 | 
			
		||||
  device_info_plus_linux:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: device_info_plus_linux
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "3.0.0"
 | 
			
		||||
  device_info_plus_macos:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: device_info_plus_macos
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "3.0.0"
 | 
			
		||||
  device_info_plus_platform_interface:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: device_info_plus_platform_interface
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "3.0.0"
 | 
			
		||||
  device_info_plus_web:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: device_info_plus_web
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "3.0.0"
 | 
			
		||||
  device_info_plus_windows:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: device_info_plus_windows
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "4.1.0"
 | 
			
		||||
  dynamic_color:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: dynamic_color
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "1.5.3"
 | 
			
		||||
    version: "1.5.4"
 | 
			
		||||
  fake_async:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -120,6 +169,13 @@ packages:
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "6.1.4"
 | 
			
		||||
  file_picker:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: file_picker
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "5.1.0"
 | 
			
		||||
  flutter:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description: flutter
 | 
			
		||||
@@ -152,21 +208,28 @@ packages:
 | 
			
		||||
      name: flutter_local_notifications
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "9.8.0+1"
 | 
			
		||||
    version: "10.0.0"
 | 
			
		||||
  flutter_local_notifications_linux:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: flutter_local_notifications_linux
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.5.1"
 | 
			
		||||
    version: "1.0.0"
 | 
			
		||||
  flutter_local_notifications_platform_interface:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: flutter_local_notifications_platform_interface
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "5.0.0"
 | 
			
		||||
    version: "6.0.0"
 | 
			
		||||
  flutter_plugin_android_lifecycle:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: flutter_plugin_android_lifecycle
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.0.7"
 | 
			
		||||
  flutter_test:
 | 
			
		||||
    dependency: "direct dev"
 | 
			
		||||
    description: flutter
 | 
			
		||||
@@ -323,7 +386,7 @@ packages:
 | 
			
		||||
      name: path_provider_windows
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.1.2"
 | 
			
		||||
    version: "2.1.3"
 | 
			
		||||
  permission_handler:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
@@ -379,7 +442,7 @@ packages:
 | 
			
		||||
      name: plugin_platform_interface
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.1.2"
 | 
			
		||||
    version: "2.1.3"
 | 
			
		||||
  process:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -407,7 +470,7 @@ packages:
 | 
			
		||||
      name: shared_preferences_android
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.0.12"
 | 
			
		||||
    version: "2.0.13"
 | 
			
		||||
  shared_preferences_ios:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -435,7 +498,7 @@ packages:
 | 
			
		||||
      name: shared_preferences_platform_interface
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.0.0"
 | 
			
		||||
    version: "2.1.0"
 | 
			
		||||
  shared_preferences_web:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -496,7 +559,7 @@ packages:
 | 
			
		||||
      name: test_api
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.4.12"
 | 
			
		||||
    version: "0.4.14"
 | 
			
		||||
  timezone:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -524,7 +587,7 @@ packages:
 | 
			
		||||
      name: url_launcher_android
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "6.0.17"
 | 
			
		||||
    version: "6.0.19"
 | 
			
		||||
  url_launcher_ios:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
@@ -573,7 +636,7 @@ packages:
 | 
			
		||||
      name: vector_math
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.1.2"
 | 
			
		||||
    version: "2.1.3"
 | 
			
		||||
  webview_flutter:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
@@ -587,28 +650,28 @@ packages:
 | 
			
		||||
      name: webview_flutter_android
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.9.5"
 | 
			
		||||
    version: "2.10.1"
 | 
			
		||||
  webview_flutter_platform_interface:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: webview_flutter_platform_interface
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "1.9.2"
 | 
			
		||||
    version: "1.9.3"
 | 
			
		||||
  webview_flutter_wkwebview:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: webview_flutter_wkwebview
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.9.3"
 | 
			
		||||
    version: "2.9.4"
 | 
			
		||||
  win32:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: win32
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.7.0"
 | 
			
		||||
    version: "3.0.0"
 | 
			
		||||
  workmanager:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
@@ -639,4 +702,4 @@ packages:
 | 
			
		||||
    version: "3.1.1"
 | 
			
		||||
sdks:
 | 
			
		||||
  dart: ">=2.19.0-79.0.dev <3.0.0"
 | 
			
		||||
  flutter: ">=3.1.0-0.0.pre.1036"
 | 
			
		||||
  flutter: ">=3.3.0"
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										13
									
								
								pubspec.yaml
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								pubspec.yaml
									
									
									
									
									
								
							@@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
 | 
			
		||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
 | 
			
		||||
# In Windows, build-name is used as the major, minor, and patch parts
 | 
			
		||||
# of the product and file versions while build-number is used as the build suffix.
 | 
			
		||||
version: 0.1.7+8 # When changing this, update the tag in main() accordingly
 | 
			
		||||
version: 0.2.2+13 # When changing this, update the tag in main() accordingly
 | 
			
		||||
 | 
			
		||||
environment:
 | 
			
		||||
  sdk: '>=2.19.0-79.0.dev <3.0.0'
 | 
			
		||||
@@ -35,21 +35,24 @@ dependencies:
 | 
			
		||||
 | 
			
		||||
  # The following adds the Cupertino Icons font to your application.
 | 
			
		||||
  # Use with the CupertinoIcons class for iOS style icons.
 | 
			
		||||
  cupertino_icons: ^1.0.2
 | 
			
		||||
  cupertino_icons: ^1.0.5
 | 
			
		||||
  path_provider: ^2.0.11
 | 
			
		||||
  flutter_fgbg: ^0.2.0 # Try removing reliance on this
 | 
			
		||||
  flutter_local_notifications: ^9.8.0+1
 | 
			
		||||
  flutter_local_notifications: ^10.0.0
 | 
			
		||||
  provider: ^6.0.3
 | 
			
		||||
  http: ^0.13.5
 | 
			
		||||
  webview_flutter: ^3.0.4
 | 
			
		||||
  workmanager: ^0.5.0
 | 
			
		||||
  dynamic_color: ^1.5.3
 | 
			
		||||
  dynamic_color: ^1.5.4
 | 
			
		||||
  install_plugin_v2: ^1.0.0 # Try replacing this
 | 
			
		||||
  html: ^0.15.0
 | 
			
		||||
  shared_preferences: ^2.0.15
 | 
			
		||||
  url_launcher: ^6.1.5
 | 
			
		||||
  permission_handler: ^10.0.0
 | 
			
		||||
  fluttertoast: ^8.0.9
 | 
			
		||||
  device_info_plus: ^4.1.2
 | 
			
		||||
  file_picker: ^5.1.0
 | 
			
		||||
  animations: ^2.0.4
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
dev_dependencies:
 | 
			
		||||
@@ -62,7 +65,7 @@ dev_dependencies:
 | 
			
		||||
  # activated in the `analysis_options.yaml` file located at the root of your
 | 
			
		||||
  # package. See that file for information about deactivating specific lint
 | 
			
		||||
  # rules and activating additional ones.
 | 
			
		||||
  flutter_lints: ^2.0.0
 | 
			
		||||
  flutter_lints: ^2.0.1
 | 
			
		||||
 | 
			
		||||
flutter_icons:
 | 
			
		||||
  android: true
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user