Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 57 additions & 56 deletions lib/features/action_mode/action_mode.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:math' as math;

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
Expand Down Expand Up @@ -33,47 +35,42 @@ class ActionModeTab extends HookConsumerWidget {
return null;
}, [visible]);

// The globe fills whatever the cards leave over; the page only scrolls
// when the cards alone are taller than the screen.
return SafeArea(
child: CustomScrollView(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@atavism One reason I choose Slivers is their memory efficiency: for unbounded lists, we must redraw every second, but Slivers offer more flexibility and use less memory.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jigar-f Great point! I opened a PR here to restore the use of Silvers: #9063

slivers: [
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(defaultSize),
child: Column(
children: [
InfoRow(
text: 'smc_intro'.i18n,
onPressed: () => showActionModeWelcomeDialog(context, ref),
),
const Expanded(
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(child: ActionModeGlobe()),
Positioned(
left: 0,
right: 0,
bottom: 8,
child: Center(child: PeerStatusPill()),
),
],
child: LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
padding: const EdgeInsets.all(defaultSize),
child: Column(
children: [
InfoRow(
text: 'smc_intro'.i18n,
onPressed: () => showActionModeWelcomeDialog(context, ref),
),
ConstrainedBox(
constraints: BoxConstraints(
minHeight: math.max(180, constraints.maxHeight - 336),
),
child: const Stack(
alignment: Alignment.bottomCenter,
clipBehavior: Clip.none,
children: [
Positioned.fill(child: ActionModeGlobe()),
Padding(
padding: EdgeInsets.only(bottom: 8),
child: Center(child: PeerStatusPill()),
),
),
_StatusCard(
state: state,
onToggle: () =>
ref.read(shareProvider.notifier).toggle(context, ref),
),
const SizedBox(height: 8),
const ActionModeAutoEnable(),
],
],
),
),
_StatusCard(
state: state,
onToggle: () =>
ref.read(shareProvider.notifier).toggle(context, ref),
),
),
const SizedBox(height: 8),
const ActionModeAutoEnable(),
],
),
],
),
),
);
}
Expand All @@ -87,28 +84,32 @@ class _StatusCard extends StatelessWidget {

@override
Widget build(BuildContext context) {
final hasError =
state.phase == SharePhase.error ||
(state.errorMessage?.isNotEmpty ?? false);
final error = state.errorMessage != null
? 'smc_status_error_with_message'.i18n.fill([state.errorMessage!])
: 'smc_status_error_generic'.i18n;

// Three states per spec: Off, Enabled, and Configuring while a start or
// probe is in flight. An error while off is terminal and shown as-is.
final status = switch (state.mode) {
ShareMode.off => switch (state.phase) {
SharePhase.error => error,
_ =>
state.probing ? 'smc_status_configuring'.i18n : 'smc_status_off'.i18n,
},
ShareMode.unbounded =>
state.unboundedRunning
? 'enabled'.i18n
: 'unbounded_status_waiting'.i18n,
ShareMode.smc => switch (state.phase) {
SharePhase.serving => 'enabled'.i18n,
SharePhase.error => error,
_ => 'smc_status_configuring'.i18n,
},
};
// Show Off when disabled, Enabled when ready, and Configuring during startup.
// Unbounded shows a waiting status until ready. Errors take precedence,
// including failed stops while sharing is still active.
final status = hasError
? error
: switch (state.mode) {
ShareMode.off =>
state.probing
? 'smc_status_configuring'.i18n
: 'smc_status_off'.i18n,
ShareMode.unbounded =>
state.unboundedRunning
? 'enabled'.i18n
: 'unbounded_status_waiting'.i18n,
ShareMode.smc => switch (state.phase) {
SharePhase.serving => 'enabled'.i18n,
_ => 'smc_status_configuring'.i18n,
},
};

return ActionModeStatusCard(
status: status,
Expand All @@ -117,7 +118,7 @@ class _StatusCard extends StatelessWidget {
(state.mode == ShareMode.unbounded && state.unboundedRunning) ||
(state.mode == ShareMode.smc && state.phase == SharePhase.serving),
busy: state.probing,
hasError: state.phase == SharePhase.error,
hasError: hasError,
activeCount: state.activeCount,
totalCount: state.totalCount,
onToggle: onToggle,
Expand Down
14 changes: 8 additions & 6 deletions lib/features/action_mode/peer_status_pill.dart
Original file line number Diff line number Diff line change
Expand Up @@ -142,12 +142,14 @@ class _Pill extends StatelessWidget {
),
const SizedBox(width: 14),
],
Text(
text,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: heart ? null : theme.hintColor,
Flexible(
child: Text(
text,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: heart ? null : theme.hintColor,
),
),
),
],
Expand Down
2 changes: 1 addition & 1 deletion lib/features/action_mode/provider/share_notifier.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion lib/features/home/provider/app_event_notifier.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion lib/features/home/provider/data_cap_info_provider.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

82 changes: 74 additions & 8 deletions test/features/action_mode/action_mode_widgets_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,22 @@ import 'package:http/testing.dart';
import 'package:lantern/core/common/common.dart';
import 'package:lantern/core/models/app_setting.dart';
import 'package:lantern/core/models/share_state.dart';
import 'package:lantern/core/widgets/info_row.dart';
import 'package:lantern/features/action_mode/action_mode_globe.dart';
import 'package:lantern/features/action_mode/peer_status_pill.dart';
import 'package:lantern/features/home/provider/app_setting_notifier.dart';
import 'package:lantern/features/action_mode/action_mode_widgets.dart';
import 'package:lantern/features/action_mode/auto_enable_mode.dart';
import 'package:lantern/features/action_mode/provider/share_notifier.dart';
import 'package:lantern/features/action_mode/action_mode.dart';

class _Share extends ShareNotifier {
_Share(this.initialState);

final ShareState initialState;

@override
ShareState build() => const ShareState(
active: true,
mode: ShareMode.unbounded,
activeCount: 9,
totalCount: 219,
);
ShareState build() => initialState;
@override
void replayCurrentPeers() {}
@override
Expand Down Expand Up @@ -70,6 +72,12 @@ void main() {
double scale = 1,
Brightness brightness = Brightness.light,
bool animated = false,
ShareState shareState = const ShareState(
active: true,
mode: ShareMode.unbounded,
activeCount: 9,
totalCount: 219,
),
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
Expand All @@ -82,7 +90,7 @@ void main() {
ProviderScope(
overrides: [
appSettingProvider.overrideWith(_Settings.new),
shareProvider.overrideWith(_Share.new),
shareProvider.overrideWith(() => _Share(shareState)),
],
child: ScreenUtilInit(
designSize: const Size(393, 852),
Expand Down Expand Up @@ -117,6 +125,64 @@ void main() {
}
}

for (final mode in [ShareMode.smc, ShareMode.unbounded]) {
testWidgets('failed $mode stop is visible and can be retried', (
tester,
) async {
await mount(
tester,
const ActionModeTab(),
shareState: ShareState(
active: true,
mode: mode,
phase: mode == ShareMode.smc ? SharePhase.serving : SharePhase.idle,
unboundedRunning: mode == ShareMode.unbounded,
activeCount: 1,
errorMessage: 'backend busy',
),
animated: true,
);
final card = tester.widget<ActionModeStatusCard>(
find.byType(ActionModeStatusCard),
);
expect(card.status, contains('backend busy'));
expect(card.hasError, isTrue);
expect(card.enabled, isTrue);
final toggle = find.byKey(const Key('action-mode.toggle'));
await tester.tapAt(tester.getCenter(toggle) - const Offset(20, 0));
await tester.pump(const Duration(milliseconds: 300));
expect(_Share.toggles, 1);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
});
}

for (final scale in [1.0, 2.0]) {
testWidgets('waiting pill stays below the intro at 320x568, scale $scale', (
tester,
) async {
await mount(
tester,
const ActionModeTab(),
size: const Size(320, 568),
scale: scale,
shareState: const ShareState(active: true, mode: ShareMode.unbounded),
animated: true,
);
expect(tester.takeException(), isNull);
final pill = tester.getRect(find.byType(PeerStatusPill));
final intro = tester.getRect(find.byType(InfoRow));
final status = tester.getRect(find.byType(ActionModeStatusCard));
expect(pill.top, greaterThanOrEqualTo(intro.bottom));
expect(pill.bottom, lessThanOrEqualTo(status.top));
expect(
tester.getSize(find.byType(ActionModeGlobe)).height,
greaterThan(0),
);
await tester.pumpWidget(const SizedBox());
});
}

for (final delta in [const Offset(-8, 0), const Offset(0, -8)]) {
testWidgets(
'globe owns touch drag $delta inside scroll view and pager',
Expand All @@ -138,7 +204,7 @@ void main() {
final scroll = tester.state<ScrollableState>(
find
.descendant(
of: find.byType(CustomScrollView),
of: find.byType(SingleChildScrollView),
matching: find.byType(Scrollable),
)
.first,
Expand Down
Loading