diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7968d249..05bb8158 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -78,7 +78,6 @@ @@ -162,4 +161,4 @@ android:exported="true" tools:ignore="ExportedContentProvider" /> - \ No newline at end of file + diff --git a/lang/en.json b/lang/en.json index f78b8cf3..6048007f 100644 --- a/lang/en.json +++ b/lang/en.json @@ -243,5 +243,10 @@ "get_apps": "Get watch apps", "incompatible_faces": "Incompatible Watch Faces", "incompatible_apps": "Incompatible Watch Apps" + }, + "store_page": { + "faces": "Watchfaces", + "apps": "Apps", + "search_bar": "Search for something..." } } diff --git a/lib/background/actions/calendar_action_handler.dart b/lib/background/actions/calendar_action_handler.dart index f4f17618..e7e1473d 100644 --- a/lib/background/actions/calendar_action_handler.dart +++ b/lib/background/actions/calendar_action_handler.dart @@ -80,7 +80,7 @@ class CalendarActionHandler implements ActionHandler { final calendarList = await (_calendarList.streamWithExistingValue.firstSuccessOrError() as FutureOr>>); - final calendars = calendarList.data?.value; + final calendars = calendarList.value; if (calendars == null) { return TimelineActionResponse(false); } diff --git a/lib/background/main_background.dart b/lib/background/main_background.dart index e1a96254..565932fb 100644 --- a/lib/background/main_background.dart +++ b/lib/background/main_background.dart @@ -65,7 +65,7 @@ class BackgroundReceiver implements TimelineCallbacks { final asyncValue = await container.readUntilFirstSuccessOrError(preferencesProvider); - return asyncValue.data!.value; + return asyncValue.value!; }); TimelineCallbacks.setup(this); diff --git a/lib/domain/api/appstore/appstore.dart b/lib/domain/api/appstore/appstore.dart index 5bcbc0a4..16c37841 100644 --- a/lib/domain/api/appstore/appstore.dart +++ b/lib/domain/api/appstore/appstore.dart @@ -8,11 +8,11 @@ import 'package:cobble/infrastructure/datasources/web_services/appstore.dart'; final appstoreServiceProvider = FutureProvider((ref) async { final boot = await (await ref.watch(bootServiceProvider.future)).config; - final token = await (await ref.watch(tokenProvider.last)); + final token = await (await ref.watch(tokenProvider.future)); final oauth = await ref.watch(oauthClientProvider.future); final prefs = await ref.watch(preferencesProvider.future); if (token == null) { throw NoTokenException("Service requires a token but none was found in storage"); } return AppstoreService(boot.appstore.base, prefs, oauth, token); -}); \ No newline at end of file +}); diff --git a/lib/domain/api/auth/auth.dart b/lib/domain/api/auth/auth.dart index f3cdc23a..b23a9c49 100644 --- a/lib/domain/api/auth/auth.dart +++ b/lib/domain/api/auth/auth.dart @@ -9,7 +9,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; final authServiceProvider = FutureProvider((ref) async { final boot = await (await ref.watch(bootServiceProvider.future)).config; - final token = await (await ref.watch(tokenProvider.last)); + final token = await (await ref.watch(tokenProvider.future)); final oauth = await ref.watch(oauthClientProvider.future); final prefs = await ref.watch(preferencesProvider.future); if (token == null) { diff --git a/lib/domain/api/boot/boot.dart b/lib/domain/api/boot/boot.dart index d7907c2a..c5cf453f 100644 --- a/lib/domain/api/boot/boot.dart +++ b/lib/domain/api/boot/boot.dart @@ -3,5 +3,5 @@ import 'package:cobble/infrastructure/datasources/web_services/boot.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; final bootServiceProvider = FutureProvider( - (ref) async => BootService(await ref.watch(bootUrlProvider.last) ?? ""), -); \ No newline at end of file + (ref) async => BootService(await ref.watch(bootUrlProvider.future) ?? ""), +); diff --git a/lib/domain/calendar/calendar_list.dart b/lib/domain/calendar/calendar_list.dart index 12245faa..c51c8f1b 100644 --- a/lib/domain/calendar/calendar_list.dart +++ b/lib/domain/calendar/calendar_list.dart @@ -32,7 +32,7 @@ class CalendarList extends StateNotifier>> { await _permissionCheck.hasCalendarPermission(); if (hasCalendarPermission.value == false) { - return AsyncValue.error([ResultError(0, "No permission")]); + return AsyncValue.error([ResultError(0, "No permission")], StackTrace.current); } final preferences = await _preferencesFuture; @@ -43,7 +43,7 @@ class CalendarList extends StateNotifier>> { final calendars = await _deviceCalendarPlugin.retrieveCalendars(); if (!calendars.isSuccess) { - return AsyncValue.error(calendars.errors); + return AsyncValue.error(calendars.errors, StackTrace.current); } else { return AsyncValue.data(calendars.data ?.map((c) => SelectableCalendar( diff --git a/lib/domain/calendar/calendar_syncer.db.dart b/lib/domain/calendar/calendar_syncer.db.dart index a4ab02a7..9447bdcc 100644 --- a/lib/domain/calendar/calendar_syncer.db.dart +++ b/lib/domain/calendar/calendar_syncer.db.dart @@ -33,7 +33,7 @@ class CalendarSyncer { return false; } - final allCalendars = allCalendarsResult.data!.value; + final allCalendars = allCalendarsResult.value!; final now = _dateTimeProvider(); // 1 day is added since we need to get the start of the next day diff --git a/lib/domain/timeline/watch_timeline_syncer.dart b/lib/domain/timeline/watch_timeline_syncer.dart index e1ab11d0..900a0efe 100644 --- a/lib/domain/timeline/watch_timeline_syncer.dart +++ b/lib/domain/timeline/watch_timeline_syncer.dart @@ -134,7 +134,7 @@ class WatchTimelineSyncer { return; } - final plugin = pluginValue.data!.value; + final plugin = pluginValue.value!; const AndroidNotificationDetails androidPlatformChannelSpecifics = AndroidNotificationDetails("WARNINGS", "Warnings", diff --git a/lib/infrastructure/backgroundcomm/BackgroundRpc.dart b/lib/infrastructure/backgroundcomm/BackgroundRpc.dart index 3dbb53c9..9cc53c69 100644 --- a/lib/infrastructure/backgroundcomm/BackgroundRpc.dart +++ b/lib/infrastructure/backgroundcomm/BackgroundRpc.dart @@ -71,10 +71,10 @@ class BackgroundRpc { } else if (receivedMessage.errorResult != null) { result = AsyncValue.error( receivedMessage.errorResult!, - stackTrace: receivedMessage.errorStacktrace, + receivedMessage.errorStacktrace ?? StackTrace.current, ); } else { - result = AsyncValue.error("Received result without any data."); + result = AsyncValue.error("Received result without any data.", StackTrace.current); } waitingCompleter.complete(result); diff --git a/lib/infrastructure/datasources/web_services/auth.dart b/lib/infrastructure/datasources/web_services/auth.dart index b52ada24..c22b1528 100644 --- a/lib/infrastructure/datasources/web_services/auth.dart +++ b/lib/infrastructure/datasources/web_services/auth.dart @@ -37,6 +37,10 @@ class AuthService extends Service { } } + OAuthToken get token { + return _token; + } + Future signOut() async { await _oauth.signOut(); } diff --git a/lib/infrastructure/datasources/web_services/rest_client.dart b/lib/infrastructure/datasources/web_services/rest_client.dart index d2bbdf98..23be997d 100644 --- a/lib/infrastructure/datasources/web_services/rest_client.dart +++ b/lib/infrastructure/datasources/web_services/rest_client.dart @@ -28,7 +28,7 @@ class RESTClient { ..addAll(params ?? {}), ); - HttpClientRequest req = await _client.getUrl(requestUri); + HttpClientRequest req = await _client.openUrl(method, requestUri); if (token != null) { req.headers.add("Authorization", "Bearer $token"); } @@ -58,4 +58,4 @@ class RESTClient { } return _completer.future; } -} \ No newline at end of file +} diff --git a/lib/infrastructure/datasources/workarounds.dart b/lib/infrastructure/datasources/workarounds.dart index 44bb75ea..f0059828 100644 --- a/lib/infrastructure/datasources/workarounds.dart +++ b/lib/infrastructure/datasources/workarounds.dart @@ -17,7 +17,7 @@ final neededWorkaroundsProvider = StreamProvider>((ref) { return Stream>.empty(); } - final preferences = preferencesData.data!.value; + final preferences = preferencesData.value!; fetchControls() async { final workaroundControl = WorkaroundsControl(); diff --git a/lib/localization/model/model_generator.model.dart b/lib/localization/model/model_generator.model.dart index 4681a73e..4b01aecd 100644 --- a/lib/localization/model/model_generator.model.dart +++ b/lib/localization/model/model_generator.model.dart @@ -166,6 +166,13 @@ class Language { ) final LanguageLockerPage lockerPage; + @JsonKey( + name: 'store_page', + required: true, + disallowNullValue: true, + ) + final LanguageStorePage storePage; + Language( this.common, this.firstRun, @@ -187,6 +194,7 @@ class Language { this.systemApps, this.calendar, this.lockerPage, + this.storePage, ); factory Language.fromJson(Map json) => @@ -1716,6 +1724,38 @@ class LanguageSplashPage { _$LanguageSplashPageFromJson(json); } +@JsonSerializable( + createToJson: false, + disallowUnrecognizedKeys: true, +) +class LanguageStorePage { + @JsonKey( + name: 'faces', + required: true, + disallowNullValue: true, + ) + final String faces; + + @JsonKey( + name: 'apps', + required: true, + disallowNullValue: true, + ) + final String apps; + + @JsonKey( + name: 'search_bar', + required: true, + disallowNullValue: true, + ) + final String searchBar; + + LanguageStorePage(this.faces, this.apps, this.searchBar); + + factory LanguageStorePage.fromJson(Map json) => + _$LanguageStorePageFromJson(json); +} + @JsonSerializable( createToJson: false, disallowUnrecognizedKeys: true, diff --git a/lib/localization/model/model_generator.model.g.dart b/lib/localization/model/model_generator.model.g.dart index 2ce3a42f..73c9a6ff 100644 --- a/lib/localization/model/model_generator.model.g.dart +++ b/lib/localization/model/model_generator.model.g.dart @@ -29,7 +29,8 @@ Language _$LanguageFromJson(Map json) { 'settings', 'system_apps', 'calendar', - 'locker_page' + 'locker_page', + 'store_page' ], requiredKeys: const [ 'common', @@ -51,7 +52,8 @@ Language _$LanguageFromJson(Map json) { 'settings', 'system_apps', 'calendar', - 'locker_page' + 'locker_page', + 'store_page' ], disallowNullValues: const [ 'common', @@ -73,7 +75,8 @@ Language _$LanguageFromJson(Map json) { 'settings', 'system_apps', 'calendar', - 'locker_page' + 'locker_page', + 'store_page' ], ); return Language( @@ -103,6 +106,7 @@ Language _$LanguageFromJson(Map json) { LanguageSystemApps.fromJson(json['system_apps'] as Map), LanguageCalendar.fromJson(json['calendar'] as Map), LanguageLockerPage.fromJson(json['locker_page'] as Map), + LanguageStorePage.fromJson(json['store_page'] as Map), ); } @@ -860,6 +864,20 @@ LanguageSplashPage _$LanguageSplashPageFromJson(Map json) { ); } +LanguageStorePage _$LanguageStorePageFromJson(Map json) { + $checkKeys( + json, + allowedKeys: const ['faces', 'apps', 'search_bar'], + requiredKeys: const ['faces', 'apps', 'search_bar'], + disallowNullValues: const ['faces', 'apps', 'search_bar'], + ); + return LanguageStorePage( + json['faces'] as String, + json['apps'] as String, + json['search_bar'] as String, + ); +} + LanguageSystemApps _$LanguageSystemAppsFromJson(Map json) { $checkKeys( json, diff --git a/lib/main.dart b/lib/main.dart index d05ae4c9..ee710292 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'package:cobble/infrastructure/datasources/preferences.dart'; import 'package:cobble/localization/localization.dart'; import 'package:cobble/localization/localization_delegate.dart'; import 'package:cobble/localization/model/model_generator.model.dart'; +import 'package:cobble/ui/home/tabs/store_tab.dart'; import 'package:cobble/ui/splash/splash_page.dart'; import 'package:cobble/ui/theme/cobble_scheme.dart'; import 'package:cobble/ui/theme/cobble_theme.dart'; @@ -88,6 +89,9 @@ class MyApp extends HookConsumerWidget { child: MaterialApp( onGenerateTitle: (context) => tr.common.title, theme: CobbleTheme.appTheme(brightness), + routes: { + '/appstore': (context) => StoreTab(), + }, home: SplashPage(), // List all of the app's supported locales here supportedLocales: supportedLocales, diff --git a/lib/ui/devoptions/debug_options_page.dart b/lib/ui/devoptions/debug_options_page.dart index e8d75a0a..1f4aa5f0 100644 --- a/lib/ui/devoptions/debug_options_page.dart +++ b/lib/ui/devoptions/debug_options_page.dart @@ -11,11 +11,11 @@ class DebugOptionsPage extends HookConsumerWidget implements CobbleScreen { @override Widget build(BuildContext context, WidgetRef ref) { final preferences = ref.watch(preferencesProvider); - final bootUrl = ref.watch(bootUrlProvider).data?.value ?? ""; + final bootUrl = ref.watch(bootUrlProvider).value ?? ""; final shouldOverrideBoot = - ref.watch(shouldOverrideBootProvider).data?.value ?? false; + ref.watch(shouldOverrideBootProvider).value ?? false; final overrideBootUrl = - ref.watch(overrideBootValueProvider).data?.value ?? ""; + ref.watch(overrideBootValueProvider).value ?? ""; final bootUrlController = useTextEditingController(); final bootOverrideUrlController = useTextEditingController(); diff --git a/lib/ui/home/tabs/store_tab.dart b/lib/ui/home/tabs/store_tab.dart index 87c81167..c3909ddb 100644 --- a/lib/ui/home/tabs/store_tab.dart +++ b/lib/ui/home/tabs/store_tab.dart @@ -1,22 +1,337 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'dart:io'; +import 'package:cobble/domain/api/appstore/locker_sync.dart'; +import 'package:cobble/domain/api/auth/auth.dart'; +import 'package:cobble/domain/api/auth/oauth_token.dart'; +import 'package:cobble/domain/connection/connection_state_provider.dart'; +import 'package:cobble/domain/entities/hardware_platform.dart'; +import 'package:cobble/infrastructure/datasources/web_services/auth.dart'; +import 'package:cobble/localization/localization.dart'; +import 'package:cobble/ui/common/components/cobble_button.dart'; +import 'package:cobble/ui/common/icons/fonts/rebble_icons.dart'; +import 'package:cobble/ui/common/icons/watch_icon.dart'; import 'package:cobble/ui/router/cobble_scaffold.dart'; import 'package:cobble/ui/router/cobble_screen.dart'; +import 'package:cobble/ui/theme/with_cobble_theme.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'package:webview_flutter/webview_flutter.dart'; +import 'package:cobble/infrastructure/pigeons/pigeons.g.dart'; +import 'package:path/path.dart' as path; +import 'package:uuid_type/uuid_type.dart'; -class StoreTab extends StatefulWidget implements CobbleScreen { - @override - State createState() => new _StoreTabState(); +class _TabConfig { + final String label; + final Uri url; + + _TabConfig(this.label, this.url); } -class _StoreTabState extends State { +class StoreTab extends HookConsumerWidget implements CobbleScreen { + final _config = [ + _TabConfig(tr.storePage.faces, Uri.parse("https://store-beta.rebble.io/faces")), + _TabConfig(tr.storePage.apps, Uri.parse("https://store-beta.rebble.io/apps")), + ]; + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + // Those are args from outside that tell us what application to display + final args = ModalRoute.of(context)!.settings.arguments as AppstoreArguments; + final appUrl = "https://store-beta.rebble.io/app/"; + + final indexTab = useState(0); + final pageTitle = useState("Loading"); + final backButton = useState(false); + final searchBar = useState(false); + final attrs = useState>(HashMap()); + final baseUrl = useState(_config[0].url); + + final Completer _controller = + useMemoized(() => Completer()); + final searchController = useTextEditingController(); + + final locker_sync = ref.watch(lockerSyncProvider.notifier); + + final connectionState = ref.watch(connectionStateProvider); + + final _auth = ref.watch(authServiceProvider.future); + + void handleRequest(String methodName, Map data) async { + WebViewController controller = await _controller.future; + data['methodName'] = methodName; + controller.runJavascript("PebbleBridge.handleRequest(${json.encode(data)})"); + } + + void onEachPageLoad() async { + WebViewController controller = await _controller.future; + String current = await controller.currentUrl() ?? baseUrl.value.toString(); + bool canGoBack = await controller.canGoBack(); + backButton.value = canGoBack && baseUrl.value != Uri.parse(current); + } + + void handleResponse(Map data, int? callback) async { + WebViewController controller = await _controller.future; + Map response = HashMap(); + response['data'] = data; + response['callbackId'] = callback; + controller.runJavascript("PebbleBridge.handleResponse(${json.encode(response)})"); + } + + void installApp(Uuid uuid, int? callback) async { + Map data = HashMap(); + data['added_to_locker'] = false; + + try { + await locker_sync.addToLocker(uuid); + data['added_to_locker'] = true; + handleResponse(data, callback); + } on Exception { + handleResponse(data, callback); + } + } + + // TODO: When we use up to date hooks riverpod, use callback like so: + // void Function(String, Map) _handleMethod = useCallback((method, data) async { + void handleMethod(String method, Map data, int? callback) async { + switch (method) { + case "setNavBarTitle": + // the title is set once per page load, and at the start of every page load, so we attach a hook for that here + onEachPageLoad(); + pageTitle.value = data["title"]; + break; + case "openURL": + launchURL(data["url"]); + break; + case "loadAppToDeviceAndLocker": + installApp(Uuid.parse(data["uuid"]), callback); + break; + case "setVisibleApp": + // In the original app, this was used for displaying sharing button on the app view + break; + } + } + + void _goBack() async { + WebViewController controller = await _controller.future; + String current = await controller.currentUrl() ?? baseUrl.value.toString(); + if (baseUrl.value != Uri.parse(current)) controller.goBack(); + } + + Future _setBaseAttrs() async { + attrs.value.addAll({ 'native': 'true', 'inApp': 'true', 'jsv': '0', 'platform': Platform.operatingSystem }); + PackageInfo packageInfo = await PackageInfo.fromPlatform(); + attrs.value.addAll({ 'app_version': 'packageInfo.version', 'release_id': packageInfo.buildNumber }); + final currentWatch = connectionState.currentConnectedWatch; + if (currentWatch != null) { + attrs.value['pebble_color'] = currentWatch.model.index.toString(); + WatchType watchType = + currentWatch.runningFirmware.hardwarePlatform.getWatchType(); + attrs.value['hardware'] = watchType.toString().split('.').last; + if (currentWatch.serial != null) + attrs.value['pid'] = currentWatch.serial!; + } + } + + Future _setAuthCookie() async { + AuthService auth = await _auth; + OAuthToken token = auth.token; + if (auth != null) { + WebViewCookie accessTokenCookie = new WebViewCookie(name: 'access_token', value: token.accessToken, domain: 'store-beta.rebble.io'); + CookieManager cookieManager = new CookieManager(); + cookieManager.setCookie(accessTokenCookie); + } + } + + void _setWebviewUrl(Uri url) async { + WebViewController controller = await _controller.future; + controller.loadUrl(url.toString()); + } + + void _performSearch() { + Map searchAttrs = HashMap(); + searchAttrs['query'] = searchController.text; + searchAttrs['section'] = indexTab.value == 0 ? 'watchfaces' : 'watchapps'; + handleRequest('search', searchAttrs); + searchBar.value = false; + searchController.clear(); + } + + void _setIndexTab(int newValue) { + indexTab.value = newValue; + baseUrl.value = _config[newValue].url.replace(queryParameters: attrs.value); + _setWebviewUrl(baseUrl.value); + // This would be changed anyway, but it looked ugly when it jumped from the previous title + } + + Future initialSetup() async { + await _setBaseAttrs(); + await _setAuthCookie(); + return true; + } + + useEffect(() { + initialSetup() + .whenComplete(() { + if (args == null) { + _setIndexTab(indexTab.value); + } else { + Uri appUri = Uri.parse(appUrl + args.id).replace(queryParameters: attrs.value); + _setWebviewUrl(appUri); + } + }); + }, + [connectionState], + ); + return CobbleScaffold.tab( + leading: backButton.value + ? IconButton( + onPressed: () => _goBack(), + icon: Icon(RebbleIcons.caret_left), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + ) + : null, + actions: args == null + ? [ + IconButton( + icon: Icon(RebbleIcons.search), + onPressed: () { + searchBar.value = true; + }, + tooltip: MaterialLocalizations.of(context).searchFieldLabel, + ), + ] : [], + bottomAppBar: searchBar.value + ? PreferredSize( + preferredSize: Size.fromHeight(57), + child: Row( + children: [ + IconButton( + icon: Icon(RebbleIcons.x_close), + onPressed: () { + searchBar.value = false; + }, + tooltip: + MaterialLocalizations.of(context).closeButtonTooltip, + ), + Expanded( + child: TextField( + controller: searchController, + decoration: InputDecoration( + hintText: tr.storePage.searchBar, + border: InputBorder.none, + contentPadding: EdgeInsets.all(19), + ), + textInputAction: TextInputAction.search, + autofocus: true, + maxLines: 1, + expands: false, + onSubmitted: (String value) => _performSearch(), + ), + ), + IconButton( + icon: Icon(RebbleIcons.caret_right), + onPressed: () => _performSearch(), + tooltip: MaterialLocalizations.of(context).searchFieldLabel, + ) + ], + ), + ) + : null, + titleWidget: args == null + ? DropdownButton( + value: indexTab.value, + icon: Icon(RebbleIcons.dropdown), + iconSize: 25, + underline: Container( + height: 0, + ), + onChanged: (int? newValue) => _setIndexTab(newValue!), + selectedItemBuilder: (BuildContext context) { + return [0, 1].map((int value) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(width: 25.0), //Offset to appear centered + Container( + width: 125, + height: 57, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + pageTitle.value, + overflow: TextOverflow.ellipsis, + ), + if (pageTitle.value != _config[value].label) ...[ + SizedBox(height: 4), + Text( + _config[value].label, + style: context.theme.appBarTheme.textTheme!.headline6! + .copyWith( + fontSize: 14, + color: context.scheme!.muted, + ), + ), + ], + ], + ), + ), + ], + ); + }).toList(); + }, + items: [0, 1].map>((int value) { + return DropdownMenuItem( + value: value, + child: Text(_config[value].label), + ); + }).toList(), + ) + : Text( + pageTitle.value, + overflow: TextOverflow.ellipsis, + ), child: WebView( - initialUrl: - "https://store-beta.rebble.io/?native=true&platform=android", + initialUrl: baseUrl.value.toString(), javascriptMode: JavascriptMode.unrestricted, + onWebViewCreated: (WebViewController webViewController) { + _controller.complete(webViewController); + }, + navigationDelegate: (NavigationRequest request) { + // TODO: Most likely needs different handling on iOS device, I don't have one though so I can't test this + String url = request.url; + if (url[30] != "?") + url = url.substring(0, 29) + "?" + url.substring(30, url.length); + Uri uri = Uri.parse(url); + if (uri.isScheme("pebble-method-call-js-frame")) { + Map args = json.decode(uri.queryParameters["args"]!); + handleMethod(args["methodName"], args["data"], args["callbackId"]); + } + // We don't actually want to open any other website + return NavigationDecision.prevent; + }, ), ); } + + launchURL(String url) async { + if (await canLaunch(url)) { + await launch(url); + } else { + throw 'Could not launch $url'; + } + } +} + +class AppstoreArguments { + final String id; + + AppstoreArguments(this.id); } diff --git a/lib/ui/home/tabs/test_tab.dart b/lib/ui/home/tabs/test_tab.dart index 9e25005e..25608ab9 100644 --- a/lib/ui/home/tabs/test_tab.dart +++ b/lib/ui/home/tabs/test_tab.dart @@ -121,8 +121,8 @@ class TestTab extends HookConsumerWidget implements CobbleScreen { Switch( value: workaround.disabled, onChanged: (value) async { - await preferences.data?.value - .setWorkaroundDisabled(workaround.name, value); + await preferences.value + ?.setWorkaroundDisabled(workaround.name, value); }, ), Text(workaround.name) diff --git a/lib/ui/router/cobble_scaffold.dart b/lib/ui/router/cobble_scaffold.dart index 7a1db249..e1ae5dbc 100644 --- a/lib/ui/router/cobble_scaffold.dart +++ b/lib/ui/router/cobble_scaffold.dart @@ -7,26 +7,30 @@ import '../common/icons/fonts/rebble_icons.dart'; class CobbleScaffold extends StatelessWidget { final Widget child; final String? title; + final Widget? titleWidget; final String? subtitle; final List actions; final FloatingActionButton? floatingActionButton; final FloatingActionButtonLocation? floatingActionButtonLocation; final Widget? bottomNavigationBar; final PreferredSizeWidget? bottomAppBar; + final Widget? leading; final bool? expandedAppBar; const CobbleScaffold._({ Key? key, required this.child, this.title, + this.titleWidget, this.subtitle, + this.leading, this.actions = const [], this.floatingActionButton, this.floatingActionButtonLocation, this.bottomNavigationBar, this.bottomAppBar, this.expandedAppBar, - }) : assert(title == null || title.length > 0), + }) : assert(title == null || title.length > 0 || titleWidget != null), assert(subtitle == null || (subtitle.length > 0 && title != null && title.length > 0)), super(key: key); @@ -38,14 +42,16 @@ class CobbleScaffold extends StatelessWidget { navBarTitle = _withSubtitle(context); } else if (title != null) { navBarTitle = _titleOnly(context); + } else if (titleWidget != null) { + navBarTitle = titleWidget; } - Widget? leading; + Widget? leadingWidget = leading; final route = ModalRoute.of(context); final bool canPop = route?.canPop ?? false; final bool useCloseButton = route is PageRoute && route.fullscreenDialog; if (canPop) - leading = useCloseButton + leadingWidget = useCloseButton ? IconButton( icon: Icon(RebbleIcons.x_close), onPressed: () => Navigator.maybePop(context), @@ -66,7 +72,7 @@ class CobbleScaffold extends StatelessWidget { : PreferredSize( preferredSize: Size.fromHeight(height), child: AppBar( - leading: leading, + leading: leadingWidget, title: navBarTitle, actions: actions, bottom: bottomAppBar, @@ -114,6 +120,7 @@ class CobbleScaffold extends StatelessWidget { Key? key, required Widget child, String? title, + Widget? titleWidget, String? subtitle, List actions = const [], FloatingActionButton? floatingActionButton, @@ -124,6 +131,7 @@ class CobbleScaffold extends StatelessWidget { key: key, child: child, title: title, + titleWidget: titleWidget, subtitle: subtitle, floatingActionButton: floatingActionButton, floatingActionButtonLocation: floatingActionButtonLocation, @@ -145,7 +153,9 @@ class CobbleScaffold extends StatelessWidget { Key? key, required Widget child, String? title, + Widget? titleWidget, String? subtitle, + Widget? leading, List actions = const [], FloatingActionButton? floatingActionButton, FloatingActionButtonLocation? floatingActionButtonLocation, @@ -157,6 +167,8 @@ class CobbleScaffold extends StatelessWidget { key: key, child: child, title: title, + leading: leading, + titleWidget: titleWidget, subtitle: subtitle, floatingActionButton: floatingActionButton, floatingActionButtonLocation: floatingActionButtonLocation, diff --git a/lib/ui/router/uri_navigator.dart b/lib/ui/router/uri_navigator.dart index 3ed844cb..a9ba58a8 100644 --- a/lib/ui/router/uri_navigator.dart +++ b/lib/ui/router/uri_navigator.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:cobble/ui/home/tabs/store_tab.dart'; import 'package:cobble/ui/router/cobble_navigator.dart'; import 'package:cobble/ui/screens/install_prompt.dart'; import 'package:flutter/cupertino.dart'; @@ -25,9 +26,16 @@ class UriNavigator implements IntentCallbacks { @override void openUri(StringWrapper arg) async { - String uri = arg.value!; + Uri uri = Uri.parse(arg.value!); + if (uri.isScheme("pebble") && uri.host == 'appstore') { + String id = uri.pathSegments[0]; + // TODO: We currently set up a minified StoreTab() for this, but it would be + // better if we just used the StoreTab() that already exists and navigated + // directly to the app from it (ie. handleRequest('navigate', { 'url': '/application/$id' })) + Navigator.of(_context).pushNamed('/appstore', arguments: AppstoreArguments(id)); + } - if (Platform.isAndroid && !uri.startsWith("content://")) { + if (Platform.isAndroid && !uri.isScheme("content")) { // Only content URIs are supported return; } @@ -35,10 +43,10 @@ class UriNavigator implements IntentCallbacks { AppInstallControl control = AppInstallControl(); final uriWrapper = StringWrapper(); - uriWrapper.value = uri; + uriWrapper.value = uri.toString(); final pbwResult = await control.getAppInfo(uriWrapper); - _context.push(InstallPrompt(uri, pbwResult)); + _context.push(InstallPrompt(uri.toString(), pbwResult)); } } diff --git a/lib/ui/screens/alerting_app_details.dart b/lib/ui/screens/alerting_app_details.dart index 0f5cf428..60fec355 100644 --- a/lib/ui/screens/alerting_app_details.dart +++ b/lib/ui/screens/alerting_app_details.dart @@ -51,7 +51,7 @@ class AlertingAppDetails extends HookConsumerWidget implements CobbleScreen { child: Switch( value: app.enabled, onChanged: (value) async { - var mutedPkgList = mutedPackages.data?.value ?? []; + var mutedPkgList = mutedPackages.value ?? []; if (value) { mutedPkgList.removeWhere((element) => element == app.packageId); }else { @@ -59,8 +59,8 @@ class AlertingAppDetails extends HookConsumerWidget implements CobbleScreen { mutedPkgList.add(app.packageId); } app = AlertingApp(app.name, value, app.packageId); - await preferences.data?.value - .setNotificationsMutedPackages(mutedPkgList); + await preferences.value + ?.setNotificationsMutedPackages(mutedPkgList); }, ), ), @@ -101,4 +101,4 @@ class AlertingAppDetails extends HookConsumerWidget implements CobbleScreen { ); } -} \ No newline at end of file +} diff --git a/lib/ui/screens/alerting_apps.dart b/lib/ui/screens/alerting_apps.dart index 4edeae0f..3dcde248 100644 --- a/lib/ui/screens/alerting_apps.dart +++ b/lib/ui/screens/alerting_apps.dart @@ -79,7 +79,7 @@ class AlertingApps extends HookConsumerWidget implements CobbleScreen { if (snapshot.hasData && snapshot.data != null) { List apps = []; for (int i = 0; i < snapshot.data!.packageId!.length; i++) { - final enabled = (mutedPackages.data?.value ?? []).firstWhere( + final enabled = (mutedPackages.value ?? []).firstWhere( (element) => element == snapshot.data!.packageId![i], orElse: () => null) == null; diff --git a/lib/ui/screens/calendar.dart b/lib/ui/screens/calendar.dart index 2185ccc7..570e79cf 100644 --- a/lib/ui/screens/calendar.dart +++ b/lib/ui/screens/calendar.dart @@ -46,9 +46,9 @@ class Calendar extends HookConsumerWidget implements CobbleScreen { title: tr.calendar.toggleTitle, subtitle: tr.calendar.toggleSubtitle, child: Switch( - value: calendarSyncEnabled.data?.value ?? false, + value: calendarSyncEnabled.value ?? false, onChanged: (value) async { - await preferences.data?.value.setCalendarSyncEnabled(value); + await preferences.value?.setCalendarSyncEnabled(value); if (!value) { backgroundRpc.triggerMethod(DeleteAllCalendarPinsRequest()); @@ -57,11 +57,11 @@ class Calendar extends HookConsumerWidget implements CobbleScreen { ), ), CobbleDivider(), - if (calendarSyncEnabled.data?.value ?? false) ...[ + if (calendarSyncEnabled.value ?? false) ...[ CobbleTile.title( title: tr.calendar.choose, ), - ...calendars.data?.value.map((e) { + ...calendars.value?.map((e) { return CobbleTile.setting( leading: BoxDecoration( color: Color(e.color).withOpacity(1), diff --git a/lib/ui/screens/notifications.dart b/lib/ui/screens/notifications.dart index 2ba6c32a..0f8107ae 100644 --- a/lib/ui/screens/notifications.dart +++ b/lib/ui/screens/notifications.dart @@ -28,9 +28,9 @@ class Notifications extends HookConsumerWidget implements CobbleScreen { leading: RebbleIcons.notification, title: tr.notifications.enabled, child: Switch( - value: notifcationsEnabled.data?.value ?? true, + value: notifcationsEnabled.value ?? true, onChanged: (bool value) async { - await preferences.data?.value.setNotificationsEnabled(value); + await preferences.value?.setNotificationsEnabled(value); }, ), ), @@ -55,9 +55,9 @@ class Notifications extends HookConsumerWidget implements CobbleScreen { leading: CobbleTile.reservedIconSpace, title: tr.notifications.silence.notifications, child: Switch( - value: phoneNotificationsMuteEnabled.data?.value ?? false, + value: phoneNotificationsMuteEnabled.value ?? false, onChanged: (bool value) async { - await preferences.data?.value.setPhoneNotificationMute(value); + await preferences.value?.setPhoneNotificationMute(value); }, ), ), @@ -65,9 +65,9 @@ class Notifications extends HookConsumerWidget implements CobbleScreen { leading: CobbleTile.reservedIconSpace, title: tr.notifications.silence.calls, child: Switch( - value: phoneCallsMuteEnabled.data?.value ?? false, + value: phoneCallsMuteEnabled.value ?? false, onChanged: (bool value) async { - await preferences.data?.value.setPhoneCallsMute(value); + await preferences.value?.setPhoneCallsMute(value); }, ), ), diff --git a/lib/ui/setup/boot/rebble_setup_fail.dart b/lib/ui/setup/boot/rebble_setup_fail.dart index fccc4902..cf7747f3 100644 --- a/lib/ui/setup/boot/rebble_setup_fail.dart +++ b/lib/ui/setup/boot/rebble_setup_fail.dart @@ -26,7 +26,7 @@ class RebbleSetupFail extends HookConsumerWidget implements CobbleScreen { ), floatingActionButton: FloatingActionButton.extended( onPressed: () async { - await preferences.data?.value.setWasSetupSuccessful(false); + await preferences.value?.setWasSetupSuccessful(false); context.pushAndRemoveAllBelow(HomePage()); }, label: Text(tr.setup.failure.fab)), diff --git a/lib/ui/setup/pair_page.dart b/lib/ui/setup/pair_page.dart index 823b0068..f25858af 100644 --- a/lib/ui/setup/pair_page.dart +++ b/lib/ui/setup/pair_page.dart @@ -51,7 +51,7 @@ class PairPage extends HookConsumerWidget implements CobbleScreen { Widget build(BuildContext context, WidgetRef ref) { final pairedStorage = ref.watch(pairedStorageProvider.notifier); final scan = ref.watch(scanProvider); - final pair = ref.watch(pairProvider).data?.value; + final pair = ref.watch(pairProvider).value; final preferences = ref.watch(preferencesProvider); useEffect(() { @@ -99,7 +99,7 @@ class PairPage extends HookConsumerWidget implements CobbleScreen { StringWrapper addressWrapper = StringWrapper(); addressWrapper.value = dev.address; uiConnectionControl.connectToWatch(addressWrapper); - preferences.data?.value.setHasBeenConnected(); + preferences.value?.setHasBeenConnected(); }; final title = tr.pairPage.title; diff --git a/lib/ui/splash/splash_page.dart b/lib/ui/splash/splash_page.dart index 5b71fee2..a50ca64f 100644 --- a/lib/ui/splash/splash_page.dart +++ b/lib/ui/splash/splash_page.dart @@ -24,12 +24,12 @@ class SplashPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final hasBeenConnected = ref.watch(hasBeenConnectedProvider).data; + final hasBeenConnected = ref.watch(hasBeenConnectedProvider).value; // Let's not do a timed splash screen here, it's a waste of // the user's time and there are better platform ways to do it useEffect(() { if (hasBeenConnected != null) { - Future.microtask(_openHome(hasBeenConnected.value, context: context)); + Future.microtask(_openHome(hasBeenConnected, context: context)); } }, [hasBeenConnected]); return CobbleScaffold.page( diff --git a/pubspec.lock b/pubspec.lock index 30549676..a1310ea6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -359,10 +359,10 @@ packages: dependency: transitive description: name: flutter_riverpod - sha256: d84e180f039a6b963e610d2e4435641fdfe8f12437e8770e963632e05af16d80 + sha256: b6cb0041c6c11cefb2dcb97ef436eba43c6d41287ac6d8ca93e02a497f53a4f3 url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "2.3.7" flutter_secure_storage: dependency: "direct main" description: @@ -473,10 +473,10 @@ packages: dependency: "direct main" description: name: hooks_riverpod - sha256: c2264035396e5fc238e98ef053b07b9cab298450e39c6a8704634c8452c61bbe + sha256: "2bb8ae6a729e1334f71f1ef68dd5f0400dca8f01de8cbdcde062584a68017b18" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "2.3.8" http: dependency: transitive description: @@ -817,10 +817,10 @@ packages: dependency: transitive description: name: riverpod - sha256: e7f097159b9512f5953ff544164c19057f45ce28fd0cb971fc4cad1f7b28217d + sha256: b0657b5b30c81a3184bdaab353045f0a403ebd60bb381591a8b7ad77dcade793 url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "2.3.7" rxdart: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index f377fa9f..3302d63a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,7 +35,7 @@ dependencies: sqflite: ^2.2.0 package_info_plus: ^3.0.0 state_notifier: ^0.7.0 - hooks_riverpod: ^1.0.1 + hooks_riverpod: ^2.0.0 flutter_hooks: ^0.18.0 device_calendar: ^4.3.0 uuid_type: ^2.0.0