diff --git a/.cspell/nextcloud.txt b/.cspell/nextcloud.txt index 5cd8c84cb53..fd1d2c685aa 100644 --- a/.cspell/nextcloud.txt +++ b/.cspell/nextcloud.txt @@ -3,18 +3,23 @@ apppassword bigfilechunking bools bulkupload +clearsky datetime dialin dialout displayname etag fediverse +heavyrain +heavyrainshowers iscustomavatar itemsperpage keepalive keypair lastmod licence +lightrain +lightrainshowers logoheader matterbridge mimetypes @@ -23,7 +28,9 @@ navigations nextcloud nextcloud's organisation +partlycloudy productname +rainshowers replyable resharing rgdnvw diff --git a/packages/neon/neon_dashboard/lib/l10n/en.arb b/packages/neon/neon_dashboard/lib/l10n/en.arb index b67c006d837..d738d7d66c9 100644 --- a/packages/neon/neon_dashboard/lib/l10n/en.arb +++ b/packages/neon/neon_dashboard/lib/l10n/en.arb @@ -1,5 +1,13 @@ { "@@locale": "en", "noEntries": "No entries", - "setUserStatus": "Set status" + "setUserStatus": "Set status", + "weather": "{code, select, clearsky{clear sky} cloudy{cloudy} fair{fair weather} partlycloudy{partly cloudy} fog{foggy} rain{rainfall} lightrain{light rainfall} heavyrain{heavy rainfall} rainshowers{rainfall showers} lightrainshowers{light rainfall showers} heavyrainshowers{heavy rainfall showers} other{}}", + "@weather": { + "placeholders": { + "code": {} + } + }, + "locationSet": "Set location for weather", + "address": "Address" } diff --git a/packages/neon/neon_dashboard/lib/l10n/localizations.dart b/packages/neon/neon_dashboard/lib/l10n/localizations.dart index 576fddf3d64..201ebe5b29d 100644 --- a/packages/neon/neon_dashboard/lib/l10n/localizations.dart +++ b/packages/neon/neon_dashboard/lib/l10n/localizations.dart @@ -100,6 +100,24 @@ abstract class DashboardLocalizations { /// In en, this message translates to: /// **'Set status'** String get setUserStatus; + + /// No description provided for @weather. + /// + /// In en, this message translates to: + /// **'{code, select, clearsky{clear sky} cloudy{cloudy} fair{fair weather} partlycloudy{partly cloudy} fog{foggy} rain{rainfall} lightrain{light rainfall} heavyrain{heavy rainfall} rainshowers{rainfall showers} lightrainshowers{light rainfall showers} heavyrainshowers{heavy rainfall showers} other{}}'** + String weather(String code); + + /// No description provided for @locationSet. + /// + /// In en, this message translates to: + /// **'Set location for weather'** + String get locationSet; + + /// No description provided for @address. + /// + /// In en, this message translates to: + /// **'Address'** + String get address; } class _DashboardLocalizationsDelegate extends LocalizationsDelegate { diff --git a/packages/neon/neon_dashboard/lib/l10n/localizations_en.dart b/packages/neon/neon_dashboard/lib/l10n/localizations_en.dart index 1ceaafeed41..1d29db41372 100644 --- a/packages/neon/neon_dashboard/lib/l10n/localizations_en.dart +++ b/packages/neon/neon_dashboard/lib/l10n/localizations_en.dart @@ -1,3 +1,5 @@ +import 'package:intl/intl.dart' as intl; + import 'localizations.dart'; /// The translations for English (`en`). @@ -9,4 +11,32 @@ class DashboardLocalizationsEn extends DashboardLocalizations { @override String get setUserStatus => 'Set status'; + + @override + String weather(String code) { + String _temp0 = intl.Intl.selectLogic( + code, + { + 'clearsky': 'clear sky', + 'cloudy': 'cloudy', + 'fair': 'fair weather', + 'partlycloudy': 'partly cloudy', + 'fog': 'foggy', + 'rain': 'rainfall', + 'lightrain': 'light rainfall', + 'heavyrain': 'heavy rainfall', + 'rainshowers': 'rainfall showers', + 'lightrainshowers': 'light rainfall showers', + 'heavyrainshowers': 'heavy rainfall showers', + 'other': '', + }, + ); + return '$_temp0'; + } + + @override + String get locationSet => 'Set location for weather'; + + @override + String get address => 'Address'; } diff --git a/packages/neon/neon_dashboard/lib/src/pages/main.dart b/packages/neon/neon_dashboard/lib/src/pages/main.dart index b8a2eaed1c7..8a4ee02ce2c 100644 --- a/packages/neon/neon_dashboard/lib/src/pages/main.dart +++ b/packages/neon/neon_dashboard/lib/src/pages/main.dart @@ -5,6 +5,7 @@ import 'package:intersperse/intersperse.dart'; import 'package:neon_dashboard/l10n/localizations.dart'; import 'package:neon_dashboard/src/blocs/dashboard.dart'; import 'package:neon_dashboard/src/widgets/dry_intrinsic_height.dart'; +import 'package:neon_dashboard/src/widgets/set_weather_location_dialog.dart'; import 'package:neon_dashboard/src/widgets/widget.dart'; import 'package:neon_dashboard/src/widgets/widget_button.dart'; import 'package:neon_dashboard/src/widgets/widget_item.dart'; @@ -28,6 +29,7 @@ class DashboardMainPage extends StatelessWidget { final bloc = NeonProvider.of(context); final accountsBloc = NeonProvider.of(context); final userStatusBloc = accountsBloc.activeUserStatusBloc; + final weatherStatusBloc = accountsBloc.activeWeatherStatusBloc; return NeonCustomBackground( child: ResultBuilder.behaviorSubject( @@ -37,6 +39,7 @@ class DashboardMainPage extends StatelessWidget { _buildStatuses( account: accountsBloc.activeAccount.value!, userStatusBloc: userStatusBloc, + weatherStatusBloc: weatherStatusBloc, ), ]; @@ -110,6 +113,7 @@ class DashboardMainPage extends StatelessWidget { Widget _buildStatuses({ required Account account, required UserStatusBloc userStatusBloc, + required WeatherStatusBloc weatherStatusBloc, }) => Row( mainAxisAlignment: MainAxisAlignment.center, @@ -156,6 +160,76 @@ class DashboardMainPage extends StatelessWidget { return const SizedBox.shrink(); }, ), + StreamBuilder( + stream: weatherStatusBloc.isSupported, + builder: (context, weatherStatusSupportedSnapshot) => ResultBuilder.behaviorSubject( + subject: weatherStatusBloc.forecasts, + builder: (context, forecastsResult) { + if (!(weatherStatusSupportedSnapshot.data ?? false)) { + return const SizedBox.shrink(); + } + + Future onWeatherStatusPressed() async { + final location = await showDialog( + context: context, + builder: (context) => DashboardSetWeatherLocationDialog( + currentAddress: weatherStatusBloc.location.valueOrNull?.data?.address, + ), + ); + if (location != null) { + weatherStatusBloc.setLocation(location); + } + } + + if (forecastsResult.hasData) { + final weatherCode = forecastsResult.requireData.first.data.next1Hours.summary.symbolCode; + final temperature = forecastsResult.requireData.first.data.instant.details.airTemperature; + final description = DashboardLocalizations.of(context).weather( + weatherCode.replaceAll(RegExp(r'_(day|night)$'), ''), + ); + + final icon = switch (weatherCode) { + 'clearsky_day' => 'sun', + 'clearsky_night' => 'moon', + 'cloudy' => 'cloud-cloud', + 'fair_day' => 'sun-small-cloud', + 'fair_night' => 'moon-small-cloud', + 'partlycloudy_day' => 'sun-cloud', + 'partlycloudy_night' => 'moon-cloud', + 'fog' => 'fog', + 'rain' => 'rain', + 'lightrain' => 'light-rain', + 'heavyrain' => 'heavy-rain', + 'rainshowers_day' => 'sun-cloud-rain', + 'rainshowers_night' => 'moon-cloud-rain', + 'lightrainshowers_day' => 'sun-cloud-light-rain', + 'lightrainshowers_night' => 'moon-cloud-light-rain', + 'heavyrainshowers_day' => 'sun-cloud-heavy-rain', + 'heavyrainshowers_night' => 'moon-cloud-heavy-rain', + _ => throw UnimplementedError('Unknown icon: $weatherCode'), + }; + + return _buildStatus( + context: context, + icon: NeonServerIcon( + icon: icon, + ), + label: Text('$temperature °C $description'), + onPressed: onWeatherStatusPressed, + ); + } else { + return _buildStatus( + context: context, + icon: const NeonServerIcon( + icon: 'sun-small-cloud', + ), + label: Text(DashboardLocalizations.of(context).locationSet), + onPressed: onWeatherStatusPressed, + ); + } + }, + ), + ), ] .intersperse( const SizedBox( diff --git a/packages/neon/neon_dashboard/lib/src/widgets/set_weather_location_dialog.dart b/packages/neon/neon_dashboard/lib/src/widgets/set_weather_location_dialog.dart new file mode 100644 index 00000000000..62fd3cefa12 --- /dev/null +++ b/packages/neon/neon_dashboard/lib/src/widgets/set_weather_location_dialog.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:neon_dashboard/l10n/localizations.dart'; +import 'package:neon_framework/l10n/localizations.dart'; +import 'package:neon_framework/widgets.dart'; + +/// Dialog for setting the weather location. +class DashboardSetWeatherLocationDialog extends StatefulWidget { + /// Create a new dialog for setting the weather location. + const DashboardSetWeatherLocationDialog({ + required this.currentAddress, + super.key, + }); + + /// The current weather address. + final String? currentAddress; + + @override + State createState() => _DashboardSetWeatherLocationDialogState(); +} + +class _DashboardSetWeatherLocationDialogState extends State { + final controller = TextEditingController(); + + @override + void initState() { + super.initState(); + + controller.text = widget.currentAddress ?? ''; + } + + @override + void dispose() { + controller.dispose(); + + super.dispose(); + } + + void submit() { + Navigator.pop(context, controller.text.isNotEmpty ? controller.text : null); + } + + @override + Widget build(BuildContext context) => NeonDialog( + title: Text(DashboardLocalizations.of(context).locationSet), + content: TextField( + controller: controller, + keyboardType: TextInputType.streetAddress, + decoration: InputDecoration( + hintText: DashboardLocalizations.of(context).address, + ), + onSubmitted: (_) { + submit(); + }, + ), + actions: [ + NeonDialogAction( + isDefaultAction: true, + onPressed: submit, + child: Text(NeonLocalizations.of(context).actionDone), + ), + ], + ); +} diff --git a/packages/neon/neon_dashboard/test/set_weather_location_dialog_test.dart b/packages/neon/neon_dashboard/test/set_weather_location_dialog_test.dart new file mode 100644 index 00000000000..8f4fe8ecd8b --- /dev/null +++ b/packages/neon/neon_dashboard/test/set_weather_location_dialog_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:neon_dashboard/l10n/localizations.dart'; +import 'package:neon_dashboard/src/widgets/set_weather_location_dialog.dart'; +import 'package:neon_framework/testing.dart'; + +void main() { + testWidgets('Set weather location dialog', (tester) async { + await tester.pumpWidget( + const TestApp( + localizationsDelegates: DashboardLocalizations.localizationsDelegates, + supportedLocales: DashboardLocalizations.supportedLocales, + child: SizedBox.shrink(), + ), + ); + + final BuildContext context = tester.element(find.byType(SizedBox)); + + var future = showDialog( + context: context, + builder: (context) => const DashboardSetWeatherLocationDialog( + currentAddress: 'Berlin', + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Berlin'), findsOne); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + expect(await future, 'Berlin'); + + future = showDialog( + context: context, + builder: (context) => const DashboardSetWeatherLocationDialog( + currentAddress: null, + ), + ); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'Berlin'); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + expect(await future, 'Berlin'); + }); +} diff --git a/packages/neon_framework/lib/blocs.dart b/packages/neon_framework/lib/blocs.dart index c9734df5464..f21adcce356 100644 --- a/packages/neon_framework/lib/blocs.dart +++ b/packages/neon_framework/lib/blocs.dart @@ -4,3 +4,4 @@ export 'package:neon_framework/src/bloc/result.dart'; export 'package:neon_framework/src/blocs/accounts.dart' show AccountsBloc; export 'package:neon_framework/src/blocs/timer.dart'; export 'package:neon_framework/src/blocs/user_status.dart'; +export 'package:neon_framework/src/blocs/weather_status.dart'; diff --git a/packages/neon_framework/lib/l10n/en.arb b/packages/neon_framework/lib/l10n/en.arb index 98b187aaf0d..a7db74f0ea4 100644 --- a/packages/neon_framework/lib/l10n/en.arb +++ b/packages/neon_framework/lib/l10n/en.arb @@ -86,6 +86,7 @@ "actionExit": "Exit", "actionContinue": "Continue", "actionCancel": "Cancel", + "actionDone": "Done", "firstLaunchGoToSettingsToEnablePushNotifications": "Go to the settings to enable push notifications", "nextPushSupported": "NextPush is supported!", "nextPushSupportedText": "NextPush is a FOSS way of receiving push notifications using the UnifiedPush protocol via a Nextcloud instance.\nYou can install NextPush from the F-Droid app store.", diff --git a/packages/neon_framework/lib/l10n/localizations.dart b/packages/neon_framework/lib/l10n/localizations.dart index 1e25ba77cd6..d2fd21809c9 100644 --- a/packages/neon_framework/lib/l10n/localizations.dart +++ b/packages/neon_framework/lib/l10n/localizations.dart @@ -323,6 +323,12 @@ abstract class NeonLocalizations { /// **'Cancel'** String get actionCancel; + /// No description provided for @actionDone. + /// + /// In en, this message translates to: + /// **'Done'** + String get actionDone; + /// No description provided for @firstLaunchGoToSettingsToEnablePushNotifications. /// /// In en, this message translates to: diff --git a/packages/neon_framework/lib/l10n/localizations_en.dart b/packages/neon_framework/lib/l10n/localizations_en.dart index 365b11cba40..68ccb089549 100644 --- a/packages/neon_framework/lib/l10n/localizations_en.dart +++ b/packages/neon_framework/lib/l10n/localizations_en.dart @@ -153,6 +153,9 @@ class NeonLocalizationsEn extends NeonLocalizations { @override String get actionCancel => 'Cancel'; + @override + String get actionDone => 'Done'; + @override String get firstLaunchGoToSettingsToEnablePushNotifications => 'Go to the settings to enable push notifications'; diff --git a/packages/neon_framework/lib/src/blocs/accounts.dart b/packages/neon_framework/lib/src/blocs/accounts.dart index 437d64538e4..2fafde5ddad 100644 --- a/packages/neon_framework/lib/src/blocs/accounts.dart +++ b/packages/neon_framework/lib/src/blocs/accounts.dart @@ -10,6 +10,7 @@ import 'package:neon_framework/src/blocs/capabilities.dart'; import 'package:neon_framework/src/blocs/unified_search.dart'; import 'package:neon_framework/src/blocs/user_details.dart'; import 'package:neon_framework/src/blocs/user_status.dart'; +import 'package:neon_framework/src/blocs/weather_status.dart'; import 'package:neon_framework/src/models/account.dart'; import 'package:neon_framework/src/models/account_cache.dart'; import 'package:neon_framework/src/models/app_implementation.dart'; @@ -130,6 +131,16 @@ abstract interface class AccountsBloc implements Disposable { /// /// Use [activeUnifiedSearchBloc] to get them for the [activeAccount]. UnifiedSearchBloc getUnifiedSearchBlocFor(Account account); + + /// The WeatherStatusBloc for the [activeAccount]. + /// + /// Convenience method for [getWeatherStatusBlocFor] with the currently active account. + WeatherStatusBloc get activeWeatherStatusBloc; + + /// The WeatherStatusBloc for the specified [account]. + /// + /// Use [activeWeatherStatusBloc] to get them for the [activeAccount]. + WeatherStatusBloc getWeatherStatusBlocFor(Account account); } /// Implementation of [AccountsBloc]. @@ -201,6 +212,7 @@ class _AccountsBloc extends Bloc implements AccountsBloc { final userDetailsBlocs = AccountCache(); final userStatusBlocs = AccountCache(); final unifiedSearchBlocs = AccountCache(); + final weatherStatusBlocs = AccountCache(); @override void dispose() { @@ -211,6 +223,7 @@ class _AccountsBloc extends Bloc implements AccountsBloc { userDetailsBlocs.dispose(); userStatusBlocs.dispose(); unifiedSearchBlocs.dispose(); + weatherStatusBlocs.dispose(); accountsOptions.dispose(); } @@ -341,6 +354,15 @@ class _AccountsBloc extends Bloc implements AccountsBloc { getAppsBlocFor(account), account, ); + + @override + WeatherStatusBloc get activeWeatherStatusBloc => getWeatherStatusBlocFor(aa); + + @override + WeatherStatusBloc getWeatherStatusBlocFor(Account account) => weatherStatusBlocs[account] ??= WeatherStatusBloc( + getCapabilitiesBlocFor(account).capabilities, + account, + ); } /// Gets a list of logged in accounts from storage. diff --git a/packages/neon_framework/lib/src/blocs/weather_status.dart b/packages/neon_framework/lib/src/blocs/weather_status.dart new file mode 100644 index 00000000000..7372f351c81 --- /dev/null +++ b/packages/neon_framework/lib/src/blocs/weather_status.dart @@ -0,0 +1,121 @@ +import 'dart:async'; + +import 'package:built_collection/built_collection.dart'; +import 'package:meta/meta.dart'; +import 'package:neon_framework/blocs.dart'; +import 'package:neon_framework/models.dart'; +import 'package:neon_framework/utils.dart'; +import 'package:nextcloud/core.dart' as core; +import 'package:nextcloud/weather_status.dart' as weather_status; +import 'package:rxdart/rxdart.dart'; + +/// Bloc for managing the weather status. +@sealed +abstract class WeatherStatusBloc implements InteractiveBloc { + /// Create a new weather status bloc. + factory WeatherStatusBloc( + Stream> capabilities, + Account account, + ) => + _WeatherStatusBloc( + account, + capabilities, + ); + + /// Set the location to use. + void setLocation(String address); + + /// Whether weather status is supported by the server. + BehaviorSubject get isSupported; + + /// The current location. + BehaviorSubject> get location; + + /// Contains the forecasts. + BehaviorSubject>> get forecasts; +} + +class _WeatherStatusBloc extends InteractiveBloc implements WeatherStatusBloc { + _WeatherStatusBloc( + this.account, + Stream> capabilities, + ) { + capabilitiesSubscription = capabilities.listen((result) { + final oldSupport = isSupported.valueOrNull ?? false; + final newSupport = result.data?.capabilities.weatherStatusCapabilities?.weatherStatus.enabled ?? false; + isSupported.add(newSupport); + if (!oldSupport && newSupport) { + unawaited(refresh()); + } + }); + + timer = TimerBloc().registerTimer(const Duration(minutes: 5), refresh); + } + + final Account account; + late final StreamSubscription> + capabilitiesSubscription; + late final NeonTimer timer; + + @override + void dispose() { + timer.cancel(); + unawaited(capabilitiesSubscription.cancel()); + unawaited(isSupported.close()); + unawaited(location.close()); + unawaited(forecasts.close()); + super.dispose(); + } + + @override + final isSupported = BehaviorSubject(); + + @override + final location = BehaviorSubject>(); + + @override + final forecasts = BehaviorSubject>>(); + + @override + Future refresh() async { + if (!(isSupported.valueOrNull ?? false)) { + return; + } + + await Future.wait([ + RequestManager.instance.wrapNextcloud( + account: account, + cacheKey: 'weather_status-location', + subject: location, + rawResponse: account.client.weatherStatus.weatherStatus.getLocationRaw(), + unwrap: (response) => response.body.ocs.data, + ), + refreshForecast(), + ]); + } + + Future refreshForecast() async { + await RequestManager.instance.wrapNextcloud( + account: account, + cacheKey: 'weather_status-forecast', + subject: forecasts, + rawResponse: account.client.weatherStatus.weatherStatus.getForecastRaw(), + unwrap: (response) => response.body.ocs.data.builtListForecast ?? BuiltList(), + ); + } + + @override + void setLocation(String address) { + wrapAction( + () async { + final response = await account.client.weatherStatus.weatherStatus.setLocation( + address: address, + ); + location.add(Result.success(response.body.ocs.data)); + }, + refresh: () async { + await refreshForecast(); + }, + ); + } +} diff --git a/packages/neon_framework/lib/src/testing/utils.dart b/packages/neon_framework/lib/src/testing/utils.dart index cc17fb2cec0..3413dfe5fcd 100644 --- a/packages/neon_framework/lib/src/testing/utils.dart +++ b/packages/neon_framework/lib/src/testing/utils.dart @@ -9,6 +9,8 @@ class TestApp extends StatelessWidget { const TestApp({ this.child, this.platform = TargetPlatform.android, + this.localizationsDelegates, + this.supportedLocales, this.locale = const Locale('en'), this.wrapMaterial = true, super.key, @@ -32,6 +34,16 @@ class TestApp extends StatelessWidget { /// Defaults to `true`. final bool wrapMaterial; + /// Additional [LocalizationsDelegate]s. + /// + /// [NeonLocalizations.localizationsDelegates] are always added. + final List>? localizationsDelegates; + + /// Additional supported [Locale]s. + /// + /// [NeonLocalizations.supportedLocales] are always added. + final List? supportedLocales; + /// {@macro flutter.widgets.widgetsApp.locale} final Locale locale; @@ -46,8 +58,14 @@ class TestApp extends StatelessWidget { return MaterialApp( theme: theme.lightTheme, - localizationsDelegates: NeonLocalizations.localizationsDelegates, - supportedLocales: NeonLocalizations.supportedLocales, + localizationsDelegates: [ + ...NeonLocalizations.localizationsDelegates, + ...?localizationsDelegates, + ], + supportedLocales: [ + ...NeonLocalizations.supportedLocales, + ...?supportedLocales, + ], locale: locale, home: child, ); diff --git a/packages/neon_framework/test/weather_status_bloc_test.dart b/packages/neon_framework/test/weather_status_bloc_test.dart new file mode 100644 index 00000000000..c2b06afbe6f --- /dev/null +++ b/packages/neon_framework/test/weather_status_bloc_test.dart @@ -0,0 +1,207 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart'; +import 'package:http/testing.dart'; +import 'package:neon_framework/blocs.dart'; +import 'package:neon_framework/src/models/account.dart'; +import 'package:nextcloud/core.dart' as core; +import 'package:rxdart/rxdart.dart'; + +Account mockWeatherStatusAccount() { + String? lat; + String? lon; + String? address = 'Berlin'; + + Response locationResponse() => Response( + json.encode({ + 'ocs': { + 'meta': {'status': '', 'statuscode': 0}, + 'data': { + 'success': true, + 'mode': 2, + if (lat != null) 'lat': lat, + if (lon != null) 'lon': lon, + if (address != null) 'address': address, + }, + }, + }), + 200, + ); + + final requests = queryParameters)>>{ + RegExp(r'/ocs/v2\.php/apps/weather_status/api/v1/location'): { + 'get': (match, queryParameters) => locationResponse(), + 'put': (match, queryParameters) { + lat = queryParameters['lat']; + lon = queryParameters['lon']; + address = queryParameters['address']; + return locationResponse(); + }, + }, + RegExp(r'/ocs/v2\.php/apps/weather_status/api/v1/forecast'): { + 'get': (match, queryParameters) => Response( + json.encode({ + 'ocs': { + 'meta': {'status': '', 'statuscode': 0}, + 'data': [ + { + 'time': '', + 'data': { + 'instant': { + 'details': { + 'air_pressure_at_sea_level': 0, + 'air_temperature': 0, + 'cloud_area_fraction': 0, + 'relative_humidity': 0, + 'wind_from_direction': 0, + 'wind_speed': 0, + }, + }, + 'next_12_hours': { + 'summary': { + 'symbol_code': '', + }, + 'details': { + 'precipitation_amount': 0, + }, + }, + 'next_1_hours': { + 'summary': { + 'symbol_code': '', + }, + 'details': { + 'precipitation_amount': 0, + }, + }, + 'next_6_hours': { + 'summary': { + 'symbol_code': '', + }, + 'details': { + 'precipitation_amount': 0, + }, + }, + }, + } + ], + }, + }), + 200, + ), + }, + }; + + return Account( + serverURL: Uri.parse('https://example.com'), + username: 'test', + password: 'test', + httpClient: MockClient((request) async { + for (final entry in requests.entries) { + final match = entry.key.firstMatch(request.url.path); + if (match != null) { + final call = entry.value[request.method]; + if (call != null) { + return call(match, request.url.queryParameters); + } + } + } + + throw Exception(request); + }), + ); +} + +core.OcsGetCapabilitiesResponseApplicationJson_Ocs_Data buildCapabilities({required bool enabled}) => + core.OcsGetCapabilitiesResponseApplicationJson_Ocs_Data( + (b) => b + ..version = core.OcsGetCapabilitiesResponseApplicationJson_Ocs_Data_Version( + (b) => b + ..major = 0 + ..minor = 0 + ..micro = 0 + ..string = '' + ..edition = '' + ..extendedSupport = false, + ).toBuilder() + ..capabilities = ( + commentsCapabilities: null, + davCapabilities: null, + filesCapabilities: null, + filesSharingCapabilities: null, + filesTrashbinCapabilities: null, + filesVersionsCapabilities: null, + notesCapabilities: null, + notificationsCapabilities: null, + provisioningApiCapabilities: null, + sharebymailCapabilities: null, + spreedPublicCapabilities: null, + themingPublicCapabilities: null, + userStatusCapabilities: null, + weatherStatusCapabilities: core.WeatherStatusCapabilities( + (b) => b + ..weatherStatus = core.WeatherStatusCapabilities_WeatherStatus( + (b) => b..enabled = enabled, + ).toBuilder(), + ), + ), + ); + +void main() { + late Account account; + late BehaviorSubject> capabilities; + late WeatherStatusBloc bloc; + + setUp(() { + account = mockWeatherStatusAccount(); + capabilities = BehaviorSubject>(); + bloc = WeatherStatusBloc(capabilities, account); + }); + + tearDown(() async { + await capabilities.close(); + }); + + test('isSupported', () async { + expect( + bloc.isSupported, + emitsInOrder([ + false, + true, + false, + ]), + ); + capabilities + ..add(Result.success(buildCapabilities(enabled: false))) + ..add(Result.success(buildCapabilities(enabled: true))) + ..add(Result.success(buildCapabilities(enabled: false))); + }); + + test('refresh', () async { + capabilities.add(Result.success(buildCapabilities(enabled: true))); + expect( + bloc.forecasts.transformResult((e) => e.isNotEmpty), + emitsInOrder([ + Result.loading(), + Result.success(true), + Result.success(true).asLoading(), + Result.success(true), + ]), + ); + await Future.delayed(const Duration(milliseconds: 1)); + await bloc.refresh(); + }); + + test('setLocation', () async { + expect( + bloc.location.transformResult((e) => e.address), + emitsInOrder([ + Result.success('Hamburg'), + Result.success('Berlin'), + ]), + ); + bloc + ..setLocation('Hamburg') + ..setLocation('Berlin'); + }); +} diff --git a/packages/nextcloud/test/fixtures/weather_status/get_favorites.regexp b/packages/nextcloud/test/fixtures/weather_status/get_favorites.regexp new file mode 100644 index 00000000000..1bb1b12acf2 --- /dev/null +++ b/packages/nextcloud/test/fixtures/weather_status/get_favorites.regexp @@ -0,0 +1,8 @@ +PUT http://localhost/ocs/v2\.php/apps/weather_status/api/v1/favorites\?favorites%5B%5D=a&favorites%5B%5D=b +accept: application/json +authorization: Bearer mock +ocs-apirequest: true +GET http://localhost/ocs/v2\.php/apps/weather_status/api/v1/favorites +accept: application/json +authorization: Bearer mock +ocs-apirequest: true \ No newline at end of file diff --git a/packages/nextcloud/test/fixtures/weather_status/get_forecast.regexp b/packages/nextcloud/test/fixtures/weather_status/get_forecast.regexp new file mode 100644 index 00000000000..74536cbb8dd --- /dev/null +++ b/packages/nextcloud/test/fixtures/weather_status/get_forecast.regexp @@ -0,0 +1,8 @@ +PUT http://localhost/ocs/v2\.php/apps/weather_status/api/v1/location\?address=Berlin +accept: application/json +authorization: Bearer mock +ocs-apirequest: true +GET http://localhost/ocs/v2\.php/apps/weather_status/api/v1/forecast +accept: application/json +authorization: Bearer mock +ocs-apirequest: true \ No newline at end of file diff --git a/packages/nextcloud/test/fixtures/weather_status/get_location.regexp b/packages/nextcloud/test/fixtures/weather_status/get_location.regexp new file mode 100644 index 00000000000..6fc734919a7 --- /dev/null +++ b/packages/nextcloud/test/fixtures/weather_status/get_location.regexp @@ -0,0 +1,8 @@ +PUT http://localhost/ocs/v2\.php/apps/weather_status/api/v1/location\?address=Berlin +accept: application/json +authorization: Bearer mock +ocs-apirequest: true +GET http://localhost/ocs/v2\.php/apps/weather_status/api/v1/location +accept: application/json +authorization: Bearer mock +ocs-apirequest: true \ No newline at end of file diff --git a/packages/nextcloud/test/fixtures/weather_status/set_favorites.regexp b/packages/nextcloud/test/fixtures/weather_status/set_favorites.regexp new file mode 100644 index 00000000000..bdc023bb977 --- /dev/null +++ b/packages/nextcloud/test/fixtures/weather_status/set_favorites.regexp @@ -0,0 +1,4 @@ +PUT http://localhost/ocs/v2\.php/apps/weather_status/api/v1/favorites\?favorites%5B%5D=a&favorites%5B%5D=b +accept: application/json +authorization: Bearer mock +ocs-apirequest: true \ No newline at end of file diff --git a/packages/nextcloud/test/fixtures/weather_status/set_location.regexp b/packages/nextcloud/test/fixtures/weather_status/set_location.regexp new file mode 100644 index 00000000000..f9119dbf4ca --- /dev/null +++ b/packages/nextcloud/test/fixtures/weather_status/set_location.regexp @@ -0,0 +1,8 @@ +PUT http://localhost/ocs/v2\.php/apps/weather_status/api/v1/location\?address=Berlin +accept: application/json +authorization: Bearer mock +ocs-apirequest: true +PUT http://localhost/ocs/v2\.php/apps/weather_status/api/v1/location\?lat=52\.5170365&lon=13\.3888599 +accept: application/json +authorization: Bearer mock +ocs-apirequest: true \ No newline at end of file diff --git a/packages/nextcloud/test/fixtures/weather_status/set_mode.regexp b/packages/nextcloud/test/fixtures/weather_status/set_mode.regexp new file mode 100644 index 00000000000..5dfb33952b3 --- /dev/null +++ b/packages/nextcloud/test/fixtures/weather_status/set_mode.regexp @@ -0,0 +1,4 @@ +PUT http://localhost/ocs/v2\.php/apps/weather_status/api/v1/mode\?mode=1 +accept: application/json +authorization: Bearer mock +ocs-apirequest: true \ No newline at end of file diff --git a/packages/nextcloud/test/fixtures/weather_status/use_personal_address.regexp b/packages/nextcloud/test/fixtures/weather_status/use_personal_address.regexp new file mode 100644 index 00000000000..517396bd233 --- /dev/null +++ b/packages/nextcloud/test/fixtures/weather_status/use_personal_address.regexp @@ -0,0 +1,4 @@ +PUT http://localhost/ocs/v2\.php/apps/weather_status/api/v1/use-personal +accept: application/json +authorization: Bearer mock +ocs-apirequest: true \ No newline at end of file diff --git a/packages/nextcloud/test/weather_status_test.dart b/packages/nextcloud/test/weather_status_test.dart new file mode 100644 index 00000000000..b87c1eebdd9 --- /dev/null +++ b/packages/nextcloud/test/weather_status_test.dart @@ -0,0 +1,96 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:nextcloud/weather_status.dart'; +import 'package:nextcloud_test/nextcloud_test.dart'; +import 'package:test/test.dart'; +import 'package:test_api/src/backend/invoker.dart'; + +void main() { + presets( + 'server', + 'weather_status', + (preset) { + late DockerContainer container; + late NextcloudClient client; + setUpAll(() async { + container = await DockerContainer.create(preset); + client = await TestNextcloudClient.create(container); + }); + tearDownAll(() async { + if (Invoker.current!.liveTest.errors.isNotEmpty) { + print(await container.allLogs()); + } + container.destroy(); + }); + + test('Set mode', () async { + final response = await client.weatherStatus.weatherStatus.setMode( + mode: 1, + ); + expect(response.statusCode, 200); + expect(response.body.ocs.data.success, true); + }); + + test('Get location', () async { + await client.weatherStatus.weatherStatus.setLocation( + address: 'Berlin', + ); + + final response = await client.weatherStatus.weatherStatus.getLocation(); + expect(response.statusCode, 200); + expect(response.body.ocs.data.mode, 2); + expect(response.body.ocs.data.address, 'Berlin, Deutschland'); + expect(response.body.ocs.data.lat, '52.5170365'); + expect(response.body.ocs.data.lon, '13.3888599'); + }); + + test('Set location', () async { + var response = await client.weatherStatus.weatherStatus.setLocation( + address: 'Berlin', + ); + expect(response.statusCode, 200); + expect(response.body.ocs.data.success, true); + expect(response.body.ocs.data.address, 'Berlin, Deutschland'); + expect(response.body.ocs.data.lat, '52.5170365'); + expect(response.body.ocs.data.lon, '13.3888599'); + + response = await client.weatherStatus.weatherStatus.setLocation( + lat: 52.5170365, + lon: 13.3888599, + ); + expect(response.statusCode, 200); + expect(response.body.ocs.data.success, true); + expect(response.body.ocs.data.address, 'Berlin, 10117, Deutschland'); + expect(response.body.ocs.data.lat, null); + expect(response.body.ocs.data.lon, null); + }); + + test('Get forecast', () async { + await client.weatherStatus.weatherStatus.setLocation( + address: 'Berlin', + ); + + final response = await client.weatherStatus.weatherStatus.getForecast(); + expect(response.statusCode, 200); + expect(response.body.ocs.data.builtListForecast, isNotNull); + expect(response.body.ocs.data.builtListForecast, isNotEmpty); + }); + + test('Get favorites', () async { + await client.weatherStatus.weatherStatus.setFavorites(favorites: BuiltList(['a', 'b'])); + + final response = await client.weatherStatus.weatherStatus.getFavorites(); + expect(response.statusCode, 200); + expect(response.body.ocs.data, equals(['a', 'b'])); + }); + + test('Set favorites', () async { + final response = await client.weatherStatus.weatherStatus.setFavorites(favorites: BuiltList(['a', 'b'])); + expect(response.statusCode, 200); + expect(response.body.ocs.data.success, true); + }); + }, + retry: retryCount, + timeout: timeout, + ); +}