mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-15 00:09:39 +08:00
[feat] support adaptive tablet layouts on Android and iPad
This commit is contained in:
@@ -13,7 +13,8 @@
|
|||||||
- 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。
|
- 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。
|
||||||
- 可拖动的悬浮控制球、三列控制菜单和浮动 ASCII / 电脑键盘、常用快捷键;通过剪贴板发送中文等文本,再手动执行远端粘贴。
|
- 可拖动的悬浮控制球、三列控制菜单和浮动 ASCII / 电脑键盘、常用快捷键;通过剪贴板发送中文等文本,再手动执行远端粘贴。
|
||||||
- 设备身份与选择保存的连接密码使用 Android Keystore / AES-GCM 加密;首次联网需同意,后台切换会结束会话。
|
- 设备身份与选择保存的连接密码使用 Android Keystore / AES-GCM 加密;首次联网需同意,后台切换会结束会话。
|
||||||
- 与 iOS 一致的首页卡片、顶部状态/公告/设置入口、密码底部弹层、两列最近连接及分组设置页;最近连接按服务器隔离,长按可删除记录及密码。
|
- 与 iOS 一致的首页卡片、顶部状态/公告/设置入口、密码弹层及分组设置页;最近连接按服务器隔离,长按可删除记录及密码。
|
||||||
|
- 平板首页支持横竖屏和分屏,按当前可用宽度自动适配:900 dp 起连接与最近连接并排,较窄窗口纵向排列;最近连接随宽度显示 1–4 列,手机常规宽度仍为两列。首页最大宽度为 1200 dp,设置、公告及阅读页最大宽度为 720 dp,宽窗口中的密码和隐私弹窗居中限宽。旋转或调整分屏直接重排现有页面,保留输入、设置编辑和当前会话;手机全屏首页保持竖屏。
|
||||||
- 页面转场沿用 iOS 的交互方式:设置、隐私、关于及公告页面进入时从右滑入,返回时反向滑出,背景页面轻微跟随;进入和退出远程会话使用 0.2 秒淡入淡出。遵循 Android 系统动画开关及速度设置。
|
- 页面转场沿用 iOS 的交互方式:设置、隐私、关于及公告页面进入时从右滑入,返回时反向滑出,背景页面轻微跟随;进入和退出远程会话使用 0.2 秒淡入淡出。遵循 Android 系统动画开关及速度设置。
|
||||||
- 设置页的鼠标控制、画面偏好,以及悬浮控制栏的画质、帧率和画面偏好使用 0.2 秒的选中底块滑动动画;连续点击从当前位置转向,电脑端拒绝或超时后平滑回退,系统关闭动画时直接切换。
|
- 设置页的鼠标控制、画面偏好,以及悬浮控制栏的画质、帧率和画面偏好使用 0.2 秒的选中底块滑动动画;连续点击从当前位置转向,电脑端拒绝或超时后平滑回退,系统关闭动画时直接切换。
|
||||||
- 通知公告与 iOS 共用信令协议,支持未读角标、分页、下拉刷新、更新推送、详情和网页链接;已读及删除状态按服务器、本机身份和公告版本保存在本机。
|
- 通知公告与 iOS 共用信令协议,支持未读角标、分页、下拉刷新、更新推送、详情和网页链接;已读及删除状态按服务器、本机身份和公告版本保存在本机。
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:supportsRtl="true" android:usesCleartextTraffic="false">
|
android:supportsRtl="true" android:usesCleartextTraffic="false">
|
||||||
<activity android:name=".MainActivity" android:exported="true"
|
<activity android:name=".MainActivity" android:exported="true"
|
||||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
|
||||||
|
android:resizeableActivity="true"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package cn.crossdesk.mobile;
|
||||||
|
|
||||||
|
import android.view.Gravity;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.ScrollView;
|
||||||
|
|
||||||
|
/** Reflows the existing panels, keeping text, focus and scroll state during window resizing. */
|
||||||
|
@android.annotation.SuppressLint("ViewConstructor")
|
||||||
|
final class HomeLayout extends ScrollView {
|
||||||
|
final LinearLayout panels;
|
||||||
|
private final MobileUi ui;
|
||||||
|
private int panelHeight;
|
||||||
|
|
||||||
|
HomeLayout(MobileUi ui){
|
||||||
|
super(ui.context);this.ui=ui;
|
||||||
|
setFillViewport(true);setClipChildren(false);setClipToPadding(false);
|
||||||
|
panels=new LinearLayout(ui.context){
|
||||||
|
@Override protected void onMeasure(int widthSpec,int heightSpec){
|
||||||
|
// ScrollView offers unbounded height even with an explicit LayoutParams height.
|
||||||
|
super.onMeasure(widthSpec,MeasureSpec.makeMeasureSpec(panelHeight,MeasureSpec.EXACTLY));
|
||||||
|
}
|
||||||
|
};panels.setGravity(Gravity.TOP);
|
||||||
|
panels.setPadding(ui.dp(20),ui.dp(24),ui.dp(20),ui.dp(12));
|
||||||
|
panels.setClipChildren(false);panels.setClipToPadding(false);
|
||||||
|
addView(panels,new LayoutParams(-1,-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override protected void onMeasure(int widthSpec,int heightSpec){
|
||||||
|
boolean wide=MeasureSpec.getSize(widthSpec)>=ui.dp(900);
|
||||||
|
panels.setOrientation(wide?LinearLayout.HORIZONTAL:LinearLayout.VERTICAL);
|
||||||
|
// A short split window or the IME can leave less room than the two panels need.
|
||||||
|
// Keep the recent list usable and let the outer viewport scroll in that case.
|
||||||
|
panelHeight=Math.max(ui.dp(wide?360:440),MeasureSpec.getSize(heightSpec));
|
||||||
|
if(panels.getChildCount()==2){
|
||||||
|
View connection=panels.getChildAt(0),recent=panels.getChildAt(1);
|
||||||
|
LinearLayout.LayoutParams first=(LinearLayout.LayoutParams)connection.getLayoutParams();
|
||||||
|
first.width=wide?ui.dp(360):-1;first.height=-2;first.weight=0;first.gravity=Gravity.TOP;
|
||||||
|
LinearLayout.LayoutParams second=(LinearLayout.LayoutParams)recent.getLayoutParams();
|
||||||
|
second.width=wide?0:-1;second.height=wide?-1:0;second.weight=1;
|
||||||
|
second.topMargin=wide?0:ui.dp(20);second.setMarginStart(wide?ui.dp(24):0);
|
||||||
|
second.resolveLayoutDirection(panels.getLayoutDirection());
|
||||||
|
}
|
||||||
|
super.onMeasure(widthSpec,heightSpec);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
|||||||
private RecentConnections history;
|
private RecentConnections history;
|
||||||
private final RecentConnectionPresence recentPresence=new RecentConnectionPresence();
|
private final RecentConnectionPresence recentPresence=new RecentConnectionPresence();
|
||||||
private LinearLayout recentGrid;
|
private LinearLayout recentGrid;
|
||||||
|
private int recentColumns=2;
|
||||||
private final Runnable presenceTick=new Runnable(){public void run(){
|
private final Runnable presenceTick=new Runnable(){public void run(){
|
||||||
if(!recentPresence.isConnected())return;
|
if(!recentPresence.isConnected())return;
|
||||||
recentPresence.maintain(android.os.SystemClock.elapsedRealtime());
|
recentPresence.maintain(android.os.SystemClock.elapsedRealtime());
|
||||||
@@ -114,8 +115,10 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
|||||||
settingsUpdateDot=null;aboutUpdateDot=null;settingsUpdateButton=null;aboutUpdateRow=null;aboutUpdateLabel=null;
|
settingsUpdateDot=null;aboutUpdateDot=null;settingsUpdateButton=null;aboutUpdateRow=null;aboutUpdateLabel=null;
|
||||||
previewSwitch=null;clearPreviewsButton=null;previewCleanupError=null;previewCards.clear();recentGrid=null;
|
previewSwitch=null;clearPreviewsButton=null;previewCleanupError=null;previewCards.clear();recentGrid=null;
|
||||||
canvas=new FrameLayout(this);canvas.setBackgroundColor(dark?Color.BLACK:BACKGROUND);
|
canvas=new FrameLayout(this);canvas.setBackgroundColor(dark?Color.BLACK:BACKGROUND);
|
||||||
root=column();canvas.addView(root,new FrameLayout.LayoutParams(-1,-1));
|
FrameLayout safeArea=new FrameLayout(this);canvas.addView(safeArea,new FrameLayout.LayoutParams(-1,-1));
|
||||||
root.setOnApplyWindowInsetsListener((view,insets)->{
|
root=ui.pageColumn(dark?0:nextPage.equals("home")?1200:720);
|
||||||
|
safeArea.addView(root,new FrameLayout.LayoutParams(-1,-1,Gravity.TOP|Gravity.CENTER_HORIZONTAL));
|
||||||
|
safeArea.setOnApplyWindowInsetsListener((view,insets)->{
|
||||||
view.setPadding(insets.getSystemWindowInsetLeft(),insets.getSystemWindowInsetTop(),insets.getSystemWindowInsetRight(),insets.getSystemWindowInsetBottom());return insets;
|
view.setPadding(insets.getSystemWindowInsetLeft(),insets.getSystemWindowInsetTop(),insets.getSystemWindowInsetRight(),insets.getSystemWindowInsetBottom());return insets;
|
||||||
});
|
});
|
||||||
root.setFocusableInTouchMode(true);pageHost.show(canvas,motion);
|
root.setFocusableInTouchMode(true);pageHost.show(canvas,motion);
|
||||||
@@ -155,9 +158,18 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
|||||||
TextView chevron=text(" ›",23,false);chevron.setTextColor(0xFFB7B7BD);item.addView(chevron);item.setContentDescription(title+(detail.isEmpty()?"":","+detail));item.setClickable(true);item.setFocusable(true);item.setOnClickListener(v->action.run());parent.addView(item);
|
TextView chevron=text(" ›",23,false);chevron.setTextColor(0xFFB7B7BD);item.addView(chevron);item.setContentDescription(title+(detail.isEmpty()?"":","+detail));item.setClickable(true);item.setFocusable(true);item.setOnClickListener(v->action.run());parent.addView(item);
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
@android.annotation.SuppressLint("SourceLockedOrientationActivity") // Match the iOS portrait home; sessions support both orientations.
|
@android.annotation.SuppressLint("SourceLockedOrientationActivity") // Phones keep the portrait home; tablets and multi-window follow user rotation settings.
|
||||||
|
private void updateHomeOrientation(){
|
||||||
|
boolean tablet=getResources().getConfiguration().smallestScreenWidthDp>=600;
|
||||||
|
if(android.os.Build.VERSION.SDK_INT>=30){
|
||||||
|
android.graphics.Rect bounds=getWindowManager().getMaximumWindowMetrics().getBounds();
|
||||||
|
tablet|=Math.min(bounds.width(),bounds.height())>=dp(600);
|
||||||
|
}
|
||||||
|
int orientation=tablet||isInMultiWindowMode()?ActivityInfo.SCREEN_ORIENTATION_FULL_USER:ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
|
||||||
|
if(getRequestedOrientation()!=orientation)setRequestedOrientation(orientation);
|
||||||
|
}
|
||||||
private void home(String message){
|
private void home(String message){
|
||||||
backAction=null;setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);installRoot("home",false);
|
backAction=null;updateHomeOrientation();installRoot("home",false);
|
||||||
LinearLayout toolbar=row();toolbar.setPadding(dp(20),0,dp(20),0);root.addView(toolbar,ui.size(-1,56));
|
LinearLayout toolbar=row();toolbar.setPadding(dp(20),0,dp(20),0);root.addView(toolbar,ui.size(-1,56));
|
||||||
signalBadge=text("",12,true);signalBadge.setGravity(Gravity.CENTER);signalBadge.setPadding(dp(11),0,dp(11),0);signalBadge.setSingleLine(true);
|
signalBadge=text("",12,true);signalBadge.setGravity(Gravity.CENTER);signalBadge.setPadding(dp(11),0,dp(11),0);signalBadge.setSingleLine(true);
|
||||||
signalBadge.setOnClickListener(v->{
|
signalBadge.setOnClickListener(v->{
|
||||||
@@ -173,7 +185,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
|||||||
TextView updateBadge=text("!",10,true);updateBadge.setTextColor(Color.WHITE);updateBadge.setGravity(Gravity.CENTER);updateBadge.setIncludeFontPadding(false);updateBadge.setBackground(ui.background(RED,8));updateBadge.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);settingsUpdateDot=updateBadge;
|
TextView updateBadge=text("!",10,true);updateBadge.setTextColor(Color.WHITE);updateBadge.setGravity(Gravity.CENTER);updateBadge.setIncludeFontPadding(false);updateBadge.setBackground(ui.background(RED,8));updateBadge.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);settingsUpdateDot=updateBadge;
|
||||||
FrameLayout.LayoutParams updatePosition=new FrameLayout.LayoutParams(dp(16),dp(16),Gravity.TOP|Gravity.END);updatePosition.topMargin=-dp(3);updatePosition.setMarginEnd(-dp(5));settingsButton.addView(settingsUpdateDot,updatePosition);
|
FrameLayout.LayoutParams updatePosition=new FrameLayout.LayoutParams(dp(16),dp(16),Gravity.TOP|Gravity.END);updatePosition.topMargin=-dp(3);updatePosition.setMarginEnd(-dp(5));settingsButton.addView(settingsUpdateDot,updatePosition);
|
||||||
toolbar.addView(settingsButton,ui.size(40,40));renderAppUpdates();
|
toolbar.addView(settingsButton,ui.size(40,40));renderAppUpdates();
|
||||||
LinearLayout content=column();content.setPadding(dp(20),dp(24),dp(20),dp(12));content.setClipChildren(false);content.setClipToPadding(false);root.addView(content,new LinearLayout.LayoutParams(-1,0,1));
|
HomeLayout layout=new HomeLayout(ui);root.addView(layout,new LinearLayout.LayoutParams(-1,0,1));LinearLayout content=layout.panels;
|
||||||
LinearLayout connection=column();connection.setPadding(dp(18),dp(18),dp(18),dp(18));content.addView(ui.shadowCard(connection,20));
|
LinearLayout connection=column();connection.setPadding(dp(18),dp(18),dp(18),dp(18));content.addView(ui.shadowCard(connection,20));
|
||||||
connection.addView(text("远程桌面",20,true));ui.gap(connection,16);
|
connection.addView(text("远程桌面",20,true));ui.gap(connection,16);
|
||||||
LinearLayout entry=row();connection.addView(entry,ui.size(-1,54));
|
LinearLayout entry=row();connection.addView(entry,ui.size(-1,54));
|
||||||
@@ -191,7 +203,12 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
|||||||
LinearLayout empty=column();empty.setGravity(Gravity.CENTER);recent.addView(empty,new LinearLayout.LayoutParams(-1,0,1));empty.addView(ui.icon("history",0xFFB6B6BB),ui.size(34,34));ui.gap(empty,12);
|
LinearLayout empty=column();empty.setGravity(Gravity.CENTER);recent.addView(empty,new LinearLayout.LayoutParams(-1,0,1));empty.addView(ui.icon("history",0xFFB6B6BB),ui.size(34,34));ui.gap(empty,12);
|
||||||
TextView emptyTitle=text("还没有连接记录",17,true);emptyTitle.setGravity(Gravity.CENTER);empty.addView(emptyTitle);ui.gap(empty,8);TextView hint=text("成功连接后,这里会显示最近的远程设备。",13,false);hint.setTextColor(SECONDARY);hint.setGravity(Gravity.CENTER);empty.addView(hint);
|
TextView emptyTitle=text("还没有连接记录",17,true);emptyTitle.setGravity(Gravity.CENTER);empty.addView(emptyTitle);ui.gap(empty,8);TextView hint=text("成功连接后,这里会显示最近的远程设备。",13,false);hint.setTextColor(SECONDARY);hint.setGravity(Gravity.CENTER);empty.addView(hint);
|
||||||
}else{
|
}else{
|
||||||
ui.gap(recent,14);ScrollView scroll=new ScrollView(this);recent.addView(scroll,new LinearLayout.LayoutParams(-1,0,1));recentGrid=column();scroll.addView(recentGrid);renderRecentConnections();
|
ui.gap(recent,14);ScrollView scroll=new ScrollView(this);recent.addView(scroll,new LinearLayout.LayoutParams(-1,0,1));recentGrid=column();scroll.addView(recentGrid);
|
||||||
|
recentGrid.addOnLayoutChangeListener((view,left,top,right,bottom,oldLeft,oldTop,oldRight,oldBottom)->{
|
||||||
|
int width=right-left;if(width<=0)return;
|
||||||
|
int columns=width<dp(300)?1:Math.max(2,Math.min(4,(width+dp(12))/dp(192)));
|
||||||
|
if(columns!=recentColumns){recentColumns=columns;view.post(()->{if(view==recentGrid)renderRecentConnections();});}
|
||||||
|
});renderRecentConnections();
|
||||||
}
|
}
|
||||||
ensureSignaling();
|
ensureSignaling();
|
||||||
if(!message.isEmpty())new AlertDialog.Builder(this).setTitle("连接提示").setMessage(message).setPositiveButton("确定",null).show();
|
if(!message.isEmpty())new AlertDialog.Builder(this).setTitle("连接提示").setMessage(message).setPositiveButton("确定",null).show();
|
||||||
@@ -202,7 +219,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
|||||||
if(recentGrid==null)return;
|
if(recentGrid==null)return;
|
||||||
JSONArray records=recentPresence.ordered(history.list(serverKey()),android.os.SystemClock.elapsedRealtime());
|
JSONArray records=recentPresence.ordered(history.list(serverKey()),android.os.SystemClock.elapsedRealtime());
|
||||||
recentGrid.removeAllViews();previewCards.clear();
|
recentGrid.removeAllViews();previewCards.clear();
|
||||||
for(int i=0;i<records.length();i+=2){LinearLayout line=row();line.setGravity(Gravity.TOP);recentGrid.addView(line);for(int j=i;j<i+2;j++){View card=j<records.length()?recentCard(records.optJSONObject(j)):new View(this);LinearLayout.LayoutParams cell=new LinearLayout.LayoutParams(0,-2,1);if(j>i)cell.leftMargin=dp(12);line.addView(card,cell);}ui.gap(recentGrid,12);}
|
for(int i=0;i<records.length();i+=recentColumns){LinearLayout line=row();line.setGravity(Gravity.TOP);recentGrid.addView(line);for(int j=i;j<i+recentColumns;j++){View card=j<records.length()?recentCard(records.optJSONObject(j)):new View(this);LinearLayout.LayoutParams cell=new LinearLayout.LayoutParams(0,-2,1);if(j>i)cell.setMarginStart(dp(12));line.addView(card,cell);}ui.gap(recentGrid,12);}
|
||||||
}
|
}
|
||||||
private View recentCard(JSONObject item){
|
private View recentCard(JSONObject item){
|
||||||
if(item==null)return new View(this);String id=item.optString("id"),platform=item.optString("platform");
|
if(item==null)return new View(this);String id=item.optString("id"),platform=item.optString("platform");
|
||||||
@@ -249,9 +266,16 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
|||||||
String secret=password.getText().toString();if(secret.isEmpty()||secret.length()>128||secret.contains("@")){password.setError("请输入有效访问密码");return;}
|
String secret=password.getText().toString();if(secret.isEmpty()||secret.length()>128||secret.contains("@")){password.setError("请输入有效访问密码");return;}
|
||||||
rememberPassword=remember.isChecked();password.setText("");dialog.dismiss();begin(id,secret);
|
rememberPassword=remember.isChecked();password.setText("");dialog.dismiss();begin(id,secret);
|
||||||
});connect.setContentDescription("确认连接");sheet.addView(connect,ui.size(-1,48));
|
});connect.setContentDescription("确认连接");sheet.addView(connect,ui.size(-1,48));
|
||||||
dialog.setContentView(sheet);Window window=dialog.getWindow();if(window!=null){window.setBackgroundDrawableResource(android.R.color.transparent);window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);window.setDimAmount(.22f);}dialog.show();if(window!=null){window.setLayout(-1,-2);window.setGravity(Gravity.BOTTOM);}password.requestFocus();if(!error.isEmpty())password.setError(error);
|
ScrollView scroll=new ScrollView(this);scroll.addView(sheet);
|
||||||
|
dialog.setContentView(scroll);Window window=dialog.getWindow();if(window!=null){window.setBackgroundDrawableResource(android.R.color.transparent);window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);window.setDimAmount(.22f);}dialog.show();sizePasswordDialog();password.requestFocus();if(!error.isEmpty())password.setError(error);
|
||||||
dialog.setOnDismissListener(d->{password.setText("");passwordDialog=null;});
|
dialog.setOnDismissListener(d->{password.setText("");passwordDialog=null;});
|
||||||
}
|
}
|
||||||
|
private void sizePasswordDialog(){
|
||||||
|
if(passwordDialog==null||passwordDialog.getWindow()==null)return;
|
||||||
|
int width=getResources().getConfiguration().screenWidthDp;
|
||||||
|
Window window=passwordDialog.getWindow();window.setLayout(width>=600?dp(Math.min(520,width-48)):-1,-2);
|
||||||
|
window.setGravity(width>=600?Gravity.CENTER:Gravity.BOTTOM);
|
||||||
|
}
|
||||||
private void begin(String id,String secret){
|
private void begin(String id,String secret){
|
||||||
resetRemoteVersionCheck();
|
resetRemoteVersionCheck();
|
||||||
resetVideoSettings();
|
resetVideoSettings();
|
||||||
@@ -538,7 +562,14 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
|||||||
private void back(){pageHost.finish();if(session!=null){if(ready)confirmDisconnect();else end("");}else if(backAction!=null)navigateBack();else finish();}
|
private void back(){pageHost.finish();if(session!=null){if(ready)confirmDisconnect();else end("");}else if(backAction!=null)navigateBack();else finish();}
|
||||||
@Override public void onWindowFocusChanged(boolean focus){super.onWindowFocusChanged(focus);if(!focus)releaseKeys();else applySystemBars(ready);}
|
@Override public void onWindowFocusChanged(boolean focus){super.onWindowFocusChanged(focus);if(!focus)releaseKeys();else applySystemBars(ready);}
|
||||||
@android.annotation.SuppressLint("GestureBackNavigation") @Override public void onBackPressed(){back();}
|
@android.annotation.SuppressLint("GestureBackNavigation") @Override public void onBackPressed(){back();}
|
||||||
@Override public void onConfigurationChanged(Configuration config){super.onConfigurationChanged(config);if(controls!=null)controls.post(controls::constrainPanels);}
|
@Override public void onConfigurationChanged(Configuration config){
|
||||||
|
super.onConfigurationChanged(config);
|
||||||
|
releaseKeys();
|
||||||
|
if(!page.equals("session"))updateHomeOrientation();
|
||||||
|
sizePasswordDialog();
|
||||||
|
if(privacyDialog instanceof PrivacyConsentDialog)((PrivacyConsentDialog)privacyDialog).updateSize();
|
||||||
|
if(controls!=null)controls.post(controls::constrainPanels);
|
||||||
|
}
|
||||||
// onStart marks a new visit; focus/resume callbacks must not reopen a refused notice.
|
// onStart marks a new visit; focus/resume callbacks must not reopen a refused notice.
|
||||||
@Override protected void onStart(){super.onStart();foreground=true;appUpdates.setEnabled(preferences.getBoolean("networkConsent",false));if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();}
|
@Override protected void onStart(){super.onStart();foreground=true;appUpdates.setEnabled(preferences.getBoolean("networkConsent",false));if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();}
|
||||||
@Override protected void onStop(){foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();if(session!=null)end(isFinishing()?"":"应用已进入后台,会话已结束");stopSignaling();super.onStop();}
|
@Override protected void onStop(){foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();if(session!=null)end(isFinishing()?"":"应用已进入后台,会话已结束");stopSignaling();super.onStop();}
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ final class MobileUi {
|
|||||||
return wrapper;
|
return wrapper;
|
||||||
}
|
}
|
||||||
LinearLayout column() { LinearLayout v=new LinearLayout(context);v.setOrientation(LinearLayout.VERTICAL);return v; }
|
LinearLayout column() { LinearLayout v=new LinearLayout(context);v.setOrientation(LinearLayout.VERTICAL);return v; }
|
||||||
|
LinearLayout pageColumn(int maxWidth) {
|
||||||
|
LinearLayout v=new LinearLayout(context){
|
||||||
|
@Override protected void onMeasure(int widthSpec,int heightSpec){
|
||||||
|
if(maxWidth>0&&MeasureSpec.getSize(widthSpec)>dp(maxWidth))
|
||||||
|
widthSpec=MeasureSpec.makeMeasureSpec(dp(maxWidth),MeasureSpec.EXACTLY);
|
||||||
|
super.onMeasure(widthSpec,heightSpec);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
v.setOrientation(LinearLayout.VERTICAL);return v;
|
||||||
|
}
|
||||||
LinearLayout row() { LinearLayout v=new LinearLayout(context);v.setGravity(Gravity.CENTER_VERTICAL);return v; }
|
LinearLayout row() { LinearLayout v=new LinearLayout(context);v.setGravity(Gravity.CENTER_VERTICAL);return v; }
|
||||||
TextView text(String title,int size,boolean bold) {
|
TextView text(String title,int size,boolean bold) {
|
||||||
TextView v=new TextView(context);v.setText(title);v.setTextSize(size);v.setTextColor(INK);v.setIncludeFontPadding(false);
|
TextView v=new TextView(context);v.setText(title);v.setTextSize(size);v.setTextColor(INK);v.setIncludeFontPadding(false);
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import static cn.crossdesk.mobile.MobileUi.*;
|
|||||||
|
|
||||||
/** Explicit agreement is required; refusal dismisses only the current visit. */
|
/** Explicit agreement is required; refusal dismisses only the current visit. */
|
||||||
final class PrivacyConsentDialog extends Dialog {
|
final class PrivacyConsentDialog extends Dialog {
|
||||||
|
private final Activity activity;
|
||||||
|
private final MobileUi ui;
|
||||||
PrivacyConsentDialog(Activity activity,MobileUi ui,Runnable accepted){
|
PrivacyConsentDialog(Activity activity,MobileUi ui,Runnable accepted){
|
||||||
this(activity,ui,new PrivacyPolicyDocument(activity),accepted);
|
this(activity,ui,new PrivacyPolicyDocument(activity),accepted);
|
||||||
}
|
}
|
||||||
PrivacyConsentDialog(Activity activity,MobileUi ui,PrivacyPolicyDocument policy,Runnable accepted){
|
PrivacyConsentDialog(Activity activity,MobileUi ui,PrivacyPolicyDocument policy,Runnable accepted){
|
||||||
super(activity);requestWindowFeature(Window.FEATURE_NO_TITLE);setCancelable(false);setCanceledOnTouchOutside(false);
|
super(activity);this.activity=activity;this.ui=ui;requestWindowFeature(Window.FEATURE_NO_TITLE);setCancelable(false);setCanceledOnTouchOutside(false);
|
||||||
LinearLayout sheet=ui.column();sheet.setBackground(ui.background(android.graphics.Color.WHITE,20));sheet.setClipToOutline(true);
|
LinearLayout sheet=ui.column();sheet.setBackground(ui.background(android.graphics.Color.WHITE,20));sheet.setClipToOutline(true);
|
||||||
TextView title=ui.text("隐私政策",17,true);title.setGravity(Gravity.CENTER);sheet.addView(title,ui.size(-1,56));ui.divider(sheet);
|
TextView title=ui.text("隐私政策",17,true);title.setGravity(Gravity.CENTER);sheet.addView(title,ui.size(-1,56));ui.divider(sheet);
|
||||||
sheet.addView(policy.view(ui),new LinearLayout.LayoutParams(-1,0,1));ui.divider(sheet);
|
sheet.addView(policy.view(ui),new LinearLayout.LayoutParams(-1,0,1));ui.divider(sheet);
|
||||||
@@ -31,6 +33,15 @@ final class PrivacyConsentDialog extends Dialog {
|
|||||||
setContentView(sheet);Window window=getWindow();if(window!=null){window.setBackgroundDrawableResource(android.R.color.transparent);window.setDimAmount(.22f);}
|
setContentView(sheet);Window window=getWindow();if(window!=null){window.setBackgroundDrawableResource(android.R.color.transparent);window.setDimAmount(.22f);}
|
||||||
}
|
}
|
||||||
@Override public void show(){
|
@Override public void show(){
|
||||||
super.show();Window window=getWindow();if(window!=null){window.setLayout(-1,Math.round(getContext().getResources().getDisplayMetrics().heightPixels*.9f));window.setGravity(Gravity.BOTTOM);}
|
super.show();updateSize();
|
||||||
|
}
|
||||||
|
void updateSize(){
|
||||||
|
android.content.res.Configuration configuration=activity.getResources().getConfiguration();
|
||||||
|
boolean wide=configuration.screenWidthDp>=600;
|
||||||
|
Window window=getWindow();if(window!=null){
|
||||||
|
window.setLayout(wide?ui.dp(Math.min(640,configuration.screenWidthDp-48)):-1,
|
||||||
|
Math.min(ui.dp(760),Math.round(ui.dp(configuration.screenHeightDp)*.9f)));
|
||||||
|
window.setGravity(wide?Gravity.CENTER:Gravity.BOTTOM);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ final class SessionControls extends FrameLayout {
|
|||||||
private final LinearLayout waiting;
|
private final LinearLayout waiting;
|
||||||
private View shield;
|
private View shield;
|
||||||
private LinearLayout menu,keyboard;
|
private LinearLayout menu,keyboard;
|
||||||
|
private ScrollView keyboardKeys;
|
||||||
|
private int keyboardPreferredHeight;
|
||||||
private TextView networkLabel,videoFeedback;
|
private TextView networkLabel,videoFeedback;
|
||||||
private String menuPage="controls";
|
private String menuPage="controls";
|
||||||
private RemoteNetworkStatistics.Snapshot network=new RemoteNetworkStatistics().snapshot(0);
|
private RemoteNetworkStatistics.Snapshot network=new RemoteNetworkStatistics().snapshot(0);
|
||||||
@@ -136,14 +138,21 @@ final class SessionControls extends FrameLayout {
|
|||||||
private int panelWidth(int width){return Math.max(1,Math.min(width-ui.dp(16),Math.max(ui.dp(280),Math.min(ui.dp(340),width-ui.dp(160)))));}
|
private int panelWidth(int width){return Math.max(1,Math.min(width-ui.dp(16),Math.max(ui.dp(280),Math.min(ui.dp(340),width-ui.dp(160)))));}
|
||||||
private int panelHeight(int height){return Math.min(ui.dp(310),Math.max(1,height-ui.dp(16)));}
|
private int panelHeight(int height){return Math.min(ui.dp(310),Math.max(1,height-ui.dp(16)));}
|
||||||
private void positionMenu(){if(menu==null)return;float ox=orb.getX()+orb.getWidth()/2f;float x=ox>getWidth()/2f?orb.getX()-ui.dp(12)-menu.getWidth():orb.getX()+orb.getWidth()+ui.dp(12);menu.setX(bound(x,ui.dp(8),getWidth()-menu.getWidth()-ui.dp(8)));menu.setY(bound(orb.getY(),ui.dp(8),getHeight()-menu.getHeight()-ui.dp(8)));}
|
private void positionMenu(){if(menu==null)return;float ox=orb.getX()+orb.getWidth()/2f;float x=ox>getWidth()/2f?orb.getX()-ui.dp(12)-menu.getWidth():orb.getX()+orb.getWidth()+ui.dp(12);menu.setX(bound(x,ui.dp(8),getWidth()-menu.getWidth()-ui.dp(8)));menu.setY(bound(orb.getY(),ui.dp(8),getHeight()-menu.getHeight()-ui.dp(8)));}
|
||||||
private void closeKeyboard(){if(keyboard!=null){removeView(keyboard);keyboard=null;}}
|
private int keyboardWidth(int width){return Math.min(ui.dp(computerKeyboard?520:440),Math.max(1,width-ui.dp(16)));}
|
||||||
|
private int keyboardHeight(int height){return Math.min(keyboardPreferredHeight,Math.max(1,height-ui.dp(16)));}
|
||||||
|
private void positionKeyboard(){if(keyboard!=null){keyboard.setX(bound(keyboard.getX(),ui.dp(8),getWidth()-keyboard.getWidth()-ui.dp(8)));keyboard.setY(bound(keyboard.getY(),ui.dp(8),getHeight()-keyboard.getHeight()-ui.dp(8)));}}
|
||||||
|
private void closeKeyboard(){if(keyboard!=null){removeView(keyboard);keyboard=null;keyboardKeys=null;}}
|
||||||
private void toggleKeyboard(){if(keyboard!=null){closeKeyboard();modifiers.clear();return;}modifiers.clear();showKeyboard();}
|
private void toggleKeyboard(){if(keyboard!=null){closeKeyboard();modifiers.clear();return;}modifiers.clear();showKeyboard();}
|
||||||
private void showKeyboard(){
|
private void showKeyboard(){
|
||||||
float oldX=keyboard==null?-1:keyboard.getX(),oldY=keyboard==null?-1:keyboard.getY();closeKeyboard();keyboard=ui.column();keyboard.setPadding(ui.dp(5),ui.dp(5),ui.dp(5),ui.dp(5));keyboard.setBackground(ui.background(0xFFD1D6DE,12));keyboard.setElevation(ui.dp(10));
|
float oldX=keyboard==null?-1:keyboard.getX(),oldY=keyboard==null?-1:keyboard.getY();int oldScroll=keyboardKeys==null?0:keyboardKeys.getScrollY();closeKeyboard();keyboard=ui.column();keyboard.setPadding(ui.dp(5),ui.dp(5),ui.dp(5),ui.dp(5));keyboard.setBackground(ui.background(0xFFD1D6DE,12));keyboard.setElevation(ui.dp(10));
|
||||||
LinearLayout top=ui.row();TextView handle=ui.text("━",19,true);handle.setGravity(Gravity.CENTER);handle.setTextColor(SECONDARY);handle.setContentDescription("拖动键盘");top.addView(handle,new LinearLayout.LayoutParams(0,ui.dp(28),1));top.addView(ui.iconButton("close","收起键盘",this::closeKeyboard),ui.size(30,28));keyboard.addView(top);drag(handle,keyboard,()->{});
|
LinearLayout top=ui.row();TextView handle=ui.text("━",19,true);handle.setGravity(Gravity.CENTER);handle.setTextColor(SECONDARY);handle.setContentDescription("拖动键盘");top.addView(handle,new LinearLayout.LayoutParams(0,ui.dp(28),1));top.addView(ui.iconButton("close","收起键盘",this::closeKeyboard),ui.size(30,28));keyboard.addView(top);drag(handle,keyboard,()->{});
|
||||||
|
// Keep the drag/close row fixed while short split windows can scroll every key into view.
|
||||||
|
keyboardKeys=new ScrollView(getContext());keyboard.addView(keyboardKeys,new LinearLayout.LayoutParams(-1,0,1));LinearLayout keys=ui.column();keyboardKeys.addView(keys);
|
||||||
String[][] rows=computerKeyboard?new String[][]{{"Esc","F1","F2","F3","F4","F5","F6"},{"Tab","Ctrl","Alt","Win","↑","⌫"},{"复制","粘贴","全选","←","↓","→"},{"ABC","空格","Enter"}}:new String[][]{{"1","2","3","4","5","6","7","8","9","0"},{"q","w","e","r","t","y","u","i","o","p"},{"a","s","d","f","g","h","j","k","l"},{"⇧","z","x","c","v","b","n","m","⌫"},{"电脑","剪贴板","空格",".","Enter"}};
|
String[][] rows=computerKeyboard?new String[][]{{"Esc","F1","F2","F3","F4","F5","F6"},{"Tab","Ctrl","Alt","Win","↑","⌫"},{"复制","粘贴","全选","←","↓","→"},{"ABC","空格","Enter"}}:new String[][]{{"1","2","3","4","5","6","7","8","9","0"},{"q","w","e","r","t","y","u","i","o","p"},{"a","s","d","f","g","h","j","k","l"},{"⇧","z","x","c","v","b","n","m","⌫"},{"电脑","剪贴板","空格",".","Enter"}};
|
||||||
for(String[] values:rows){LinearLayout line=ui.row();for(String value:values){String shown=shift&&value.length()==1?value.toUpperCase(java.util.Locale.ROOT):value;TextView key=ui.action(shown,INK,()->keyboardKey(value));key.setTextSize(value.length()>2?11:14);key.setMinHeight(ui.dp(31));int modifier=value.equals("Ctrl")?17:value.equals("Alt")?18:value.equals("Win")?91:0;key.setBackground(ui.background(modifiers.contains(modifier)?0xFF9BC7F7:value.length()>1||value.equals("⇧")?0xFFAFB8C5:Color.WHITE,5));LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(31),value.equals("空格")?3:1);p.setMargins(ui.dp(1.5f),0,ui.dp(1.5f),0);line.addView(key,p);}keyboard.addView(line);ui.gap(keyboard,3);}
|
for(String[] values:rows){LinearLayout line=ui.row();for(String value:values){String shown=shift&&value.length()==1?value.toUpperCase(java.util.Locale.ROOT):value;TextView key=ui.action(shown,INK,()->keyboardKey(value));key.setTextSize(value.length()>2?11:14);key.setMinHeight(ui.dp(31));int modifier=value.equals("Ctrl")?17:value.equals("Alt")?18:value.equals("Win")?91:0;key.setBackground(ui.background(modifiers.contains(modifier)?0xFF9BC7F7:value.length()>1||value.equals("⇧")?0xFFAFB8C5:Color.WHITE,5));LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(31),value.equals("空格")?3:1);p.setMargins(ui.dp(1.5f),0,ui.dp(1.5f),0);line.addView(key,p);}keys.addView(line);ui.gap(keys,3);}
|
||||||
int width=Math.min(ui.dp(computerKeyboard?520:440),Math.max(1,getWidth()-ui.dp(16)));addView(keyboard,new LayoutParams(width,-2));keyboard.post(()->{if(keyboard!=null){keyboard.setX(oldX<0?(getWidth()-keyboard.getWidth())/2f:oldX);keyboard.setY(oldY<0?getHeight()-keyboard.getHeight()-ui.dp(20):oldY);constrainPanels();}});
|
keyboardPreferredHeight=ui.dp(5)*2+ui.dp(28)+rows.length*(ui.dp(31)+ui.dp(3));
|
||||||
|
addView(keyboard,new LayoutParams(keyboardWidth(getWidth()),keyboardHeight(getHeight())));LinearLayout shownKeyboard=keyboard;ScrollView shownKeys=keyboardKeys;
|
||||||
|
keyboard.post(()->{if(keyboard==shownKeyboard){keyboard.setX(oldX<0?(getWidth()-keyboard.getWidth())/2f:oldX);keyboard.setY(oldY<0?getHeight()-keyboard.getHeight()-ui.dp(20):oldY);shownKeys.scrollTo(0,oldScroll);constrainPanels();}});
|
||||||
}
|
}
|
||||||
private void keyboardKey(String value){
|
private void keyboardKey(String value){
|
||||||
switch(value){
|
switch(value){
|
||||||
@@ -177,16 +186,22 @@ final class SessionControls extends FrameLayout {
|
|||||||
}
|
}
|
||||||
void constrainPanels(){
|
void constrainPanels(){
|
||||||
orb.setX(bound(orb.getX(),ui.dp(8),getWidth()-orb.getWidth()-ui.dp(8)));orb.setY(bound(orb.getY(),ui.dp(8),getHeight()-orb.getHeight()-ui.dp(8)));
|
orb.setX(bound(orb.getX(),ui.dp(8),getWidth()-orb.getWidth()-ui.dp(8)));orb.setY(bound(orb.getY(),ui.dp(8),getHeight()-orb.getHeight()-ui.dp(8)));
|
||||||
if(menu!=null)requestLayout();
|
if(menu!=null||keyboard!=null)requestLayout();
|
||||||
if(keyboard!=null){ViewGroup.LayoutParams p=keyboard.getLayoutParams();p.width=Math.min(ui.dp(computerKeyboard?520:440),Math.max(1,getWidth()-ui.dp(16)));keyboard.setLayoutParams(p);keyboard.setX(bound(keyboard.getX(),ui.dp(8),getWidth()-p.width-ui.dp(8)));keyboard.setY(bound(keyboard.getY(),ui.dp(8),getHeight()-keyboard.getHeight()-ui.dp(8)));}
|
positionKeyboard();
|
||||||
}
|
}
|
||||||
@Override protected void onMeasure(int widthSpec,int heightSpec){
|
@Override protected void onMeasure(int widthSpec,int heightSpec){
|
||||||
super.onMeasure(widthSpec,heightSpec);
|
super.onMeasure(widthSpec,heightSpec);
|
||||||
|
boolean resized=false;
|
||||||
if(menu!=null){
|
if(menu!=null){
|
||||||
// Use this traversal's viewport, including rotation/inset changes, before drawing.
|
// Use this traversal's viewport, including rotation/inset changes, before drawing.
|
||||||
ViewGroup.LayoutParams p=menu.getLayoutParams();int width=panelWidth(getMeasuredWidth()),height=panelHeight(getMeasuredHeight());
|
ViewGroup.LayoutParams p=menu.getLayoutParams();int width=panelWidth(getMeasuredWidth()),height=panelHeight(getMeasuredHeight());
|
||||||
if(p.width!=width||p.height!=height){p.width=width;p.height=height;super.onMeasure(widthSpec,heightSpec);}
|
if(p.width!=width||p.height!=height){p.width=width;p.height=height;resized=true;}
|
||||||
}
|
}
|
||||||
|
if(keyboard!=null){
|
||||||
|
ViewGroup.LayoutParams p=keyboard.getLayoutParams();int width=keyboardWidth(getMeasuredWidth()),height=keyboardHeight(getMeasuredHeight());
|
||||||
|
if(p.width!=width||p.height!=height){p.width=width;p.height=height;resized=true;}
|
||||||
|
}
|
||||||
|
if(resized)super.onMeasure(widthSpec,heightSpec);
|
||||||
}
|
}
|
||||||
@Override protected void onLayout(boolean changed,int left,int top,int right,int bottom){
|
@Override protected void onLayout(boolean changed,int left,int top,int right,int bottom){
|
||||||
super.onLayout(changed,left,top,right,bottom);
|
super.onLayout(changed,left,top,right,bottom);
|
||||||
@@ -194,6 +209,7 @@ final class SessionControls extends FrameLayout {
|
|||||||
orb.setX(bound(orb.getX(),ui.dp(8),getWidth()-orb.getWidth()-ui.dp(8)));orb.setY(bound(orb.getY(),ui.dp(8),getHeight()-orb.getHeight()-ui.dp(8)));
|
orb.setX(bound(orb.getX(),ui.dp(8),getWidth()-orb.getWidth()-ui.dp(8)));orb.setY(bound(orb.getY(),ui.dp(8),getHeight()-orb.getHeight()-ui.dp(8)));
|
||||||
// New/rebuilt menus have real dimensions now; position them in the same frame.
|
// New/rebuilt menus have real dimensions now; position them in the same frame.
|
||||||
positionMenu();
|
positionMenu();
|
||||||
|
positionKeyboard();
|
||||||
}
|
}
|
||||||
@Override protected void onSizeChanged(int width,int height,int oldWidth,int oldHeight){super.onSizeChanged(width,height,oldWidth,oldHeight);post(this::constrainPanels);}
|
@Override protected void onSizeChanged(int width,int height,int oldWidth,int oldHeight){super.onSizeChanged(width,height,oldWidth,oldHeight);post(this::constrainPanels);}
|
||||||
private static float bound(float value,float min,float max){return Math.max(min,Math.min(value,Math.max(min,max)));}
|
private static float bound(float value,float min,float max){return Math.max(min,Math.min(value,Math.max(min,max)));}
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ struct AnnouncementInboxView: View {
|
|||||||
Text("共 \(inbox.state.total) 条 · \(inbox.state.unread) 条未读")
|
Text("共 \(inbox.state.total) 条 · \(inbox.state.unread) 条未读")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.readableContentWidth()
|
||||||
|
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
||||||
.navigationTitle("通知公告")
|
.navigationTitle("通知公告")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -174,10 +176,12 @@ struct AnnouncementDetailView: View {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
|
.readableContentWidth()
|
||||||
} else {
|
} else {
|
||||||
Text("这条公告已更新或删除,请返回公告列表查看。")
|
Text("这条公告已更新或删除,请返回公告列表查看。")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.padding(20)
|
.padding(20)
|
||||||
|
.readableContentWidth()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("公告详情")
|
.navigationTitle("公告详情")
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
enum MobileLayout {
|
||||||
|
static let twoColumnBreakpoint: CGFloat = 900
|
||||||
|
static let homeMaximumWidth: CGFloat = 1200
|
||||||
|
static let readableMaximumWidth: CGFloat = 720
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func readableContentWidth() -> some View {
|
||||||
|
frame(maxWidth: MobileLayout.readableMaximumWidth)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum HomeDestination: Hashable {
|
enum HomeDestination: Hashable {
|
||||||
case settings
|
case settings
|
||||||
case announcements
|
case announcements
|
||||||
@@ -207,8 +220,11 @@ private struct ConnectionHomeView: View {
|
|||||||
return "arrow.triangle.2.circlepath"
|
return "arrow.triangle.2.circlepath"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var recentConnectionColumns: [GridItem] {
|
private func recentConnectionColumns(windowWidth: CGFloat) -> [GridItem] {
|
||||||
[
|
if windowWidth >= 600 {
|
||||||
|
return [GridItem(.adaptive(minimum: 180), spacing: 12, alignment: .top)]
|
||||||
|
}
|
||||||
|
return [
|
||||||
GridItem(.flexible(), spacing: 12, alignment: .top),
|
GridItem(.flexible(), spacing: 12, alignment: .top),
|
||||||
GridItem(.flexible(), spacing: 12, alignment: .top)
|
GridItem(.flexible(), spacing: 12, alignment: .top)
|
||||||
]
|
]
|
||||||
@@ -229,15 +245,33 @@ private struct ConnectionHomeView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
Color(.systemGroupedBackground).ignoresSafeArea()
|
Color(.systemGroupedBackground).ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
GeometryReader { proxy in
|
||||||
remoteConnectionPanel
|
let isWide = proxy.size.width >= MobileLayout.twoColumnBreakpoint
|
||||||
|
// AnyLayout keeps the fields, focus and scroll view alive when
|
||||||
|
// rotation or iPad multitasking crosses the layout breakpoint.
|
||||||
|
let layout = isWide
|
||||||
|
? AnyLayout(HStackLayout(alignment: .top, spacing: 24))
|
||||||
|
: AnyLayout(VStackLayout(spacing: 20))
|
||||||
|
|
||||||
|
ScrollView(.vertical) {
|
||||||
|
layout {
|
||||||
|
remoteConnectionPanel
|
||||||
|
.frame(width: isWide ? 360 : nil)
|
||||||
|
recentConnectionsPanel(windowWidth: proxy.size.width)
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: MobileLayout.homeMaximumWidth)
|
||||||
|
// Short multitasking windows and the onscreen keyboard
|
||||||
|
// must not squeeze the recent list below a usable height.
|
||||||
|
.frame(height: max(isWide ? 260 : 420, proxy.size.height - 36),
|
||||||
|
alignment: .top)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
.padding(.top, 24)
|
.padding(.top, 24)
|
||||||
recentConnectionsPanel
|
.padding(.bottom, 12)
|
||||||
.padding(.top, 20)
|
}
|
||||||
.frame(maxHeight: .infinity)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.padding(.bottom, 12)
|
|
||||||
|
|
||||||
if session.isConnecting {
|
if session.isConnecting {
|
||||||
connectionProgressOverlay
|
connectionProgressOverlay
|
||||||
@@ -430,7 +464,7 @@ private struct ConnectionHomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var recentConnectionsPanel: some View {
|
private func recentConnectionsPanel(windowWidth: CGFloat) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
HStack(alignment: .firstTextBaseline) {
|
HStack(alignment: .firstTextBaseline) {
|
||||||
Text("最近连接")
|
Text("最近连接")
|
||||||
@@ -458,7 +492,7 @@ private struct ConnectionHomeView: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else {
|
} else {
|
||||||
ScrollView(.vertical) {
|
ScrollView(.vertical) {
|
||||||
LazyVGrid(columns: recentConnectionColumns, spacing: 12) {
|
LazyVGrid(columns: recentConnectionColumns(windowWidth: windowWidth), spacing: 12) {
|
||||||
ForEach(orderedRecentConnections) { connection in
|
ForEach(orderedRecentConnections) { connection in
|
||||||
RecentConnectionCard(
|
RecentConnectionCard(
|
||||||
connection: connection,
|
connection: connection,
|
||||||
@@ -872,6 +906,8 @@ private struct ServerSettingsView: View {
|
|||||||
.accessibilityLabel(appUpdates.updateAvailable ? "关于,有新版本" : "关于")
|
.accessibilityLabel(appUpdates.updateAvailable ? "关于,有新版本" : "关于")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.readableContentWidth()
|
||||||
|
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
||||||
.navigationTitle("设置")
|
.navigationTitle("设置")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import SwiftUI
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
final class CrossDeskAppDelegate: NSObject, UIApplicationDelegate {
|
final class CrossDeskAppDelegate: NSObject, UIApplicationDelegate {
|
||||||
static var supportedOrientations: UIInterfaceOrientationMask = .portrait
|
static var supportedOrientations: UIInterfaceOrientationMask =
|
||||||
|
UIDevice.current.userInterfaceIdiom == .pad ? .all : .portrait
|
||||||
|
|
||||||
func application(_ application: UIApplication,
|
func application(_ application: UIApplication,
|
||||||
supportedInterfaceOrientationsFor window: UIWindow?)
|
supportedInterfaceOrientationsFor window: UIWindow?)
|
||||||
@@ -14,6 +15,10 @@ final class CrossDeskAppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
enum AppOrientation {
|
enum AppOrientation {
|
||||||
static func update(to orientations: UIInterfaceOrientationMask) {
|
static func update(to orientations: UIInterfaceOrientationMask) {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
// iPad windows can rotate and resize independently of the session.
|
||||||
|
// Keep all orientations available and let the window manager choose
|
||||||
|
// the geometry; the phone still locks the home screen to portrait.
|
||||||
|
guard UIDevice.current.userInterfaceIdiom != .pad else { return }
|
||||||
CrossDeskAppDelegate.supportedOrientations = orientations
|
CrossDeskAppDelegate.supportedOrientations = orientations
|
||||||
|
|
||||||
for case let scene as UIWindowScene in UIApplication.shared.connectedScenes {
|
for case let scene as UIWindowScene in UIApplication.shared.connectedScenes {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ struct PrivacyConsentView: View {
|
|||||||
PrivacyPolicyDocument()
|
PrivacyPolicyDocument()
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.vertical, 20)
|
.padding(.vertical, 20)
|
||||||
|
.readableContentWidth()
|
||||||
}
|
}
|
||||||
.background(Color(.systemBackground))
|
.background(Color(.systemBackground))
|
||||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
@@ -57,6 +58,7 @@ struct PrivacyConsentView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
|
.readableContentWidth()
|
||||||
}
|
}
|
||||||
.background(Color(.systemBackground))
|
.background(Color(.systemBackground))
|
||||||
}
|
}
|
||||||
@@ -161,6 +163,8 @@ private struct PrivacyAuthorizationView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.readableContentWidth()
|
||||||
|
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
||||||
.navigationTitle("隐私与授权")
|
.navigationTitle("隐私与授权")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.alert("是否撤回隐私授权?", isPresented: $confirmWithdrawal) {
|
.alert("是否撤回隐私授权?", isPresented: $confirmWithdrawal) {
|
||||||
@@ -220,7 +224,7 @@ private struct PrivacyPolicyDocument: View {
|
|||||||
struct PrivacyPolicyView: View {
|
struct PrivacyPolicyView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
PrivacyPolicyDocument().padding()
|
PrivacyPolicyDocument().padding().readableContentWidth()
|
||||||
}
|
}
|
||||||
.navigationTitle("隐私政策")
|
.navigationTitle("隐私政策")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ struct ThirdPartyLicensesView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.readableContentWidth()
|
||||||
|
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
||||||
.navigationTitle("关于 CrossDesk")
|
.navigationTitle("关于 CrossDesk")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.onChange(of: session.hasNetworkConsent) { allowed in
|
.onChange(of: session.hasNetworkConsent) { allowed in
|
||||||
@@ -189,6 +191,8 @@ private struct OpenSourceComponentsView: View {
|
|||||||
Text("各组件遵循所列开源许可证,其授予的权利不受本应用普通使用条款限制。查看源码需要网络连接。")
|
Text("各组件遵循所列开源许可证,其授予的权利不受本应用普通使用条款限制。查看源码需要网络连接。")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.readableContentWidth()
|
||||||
|
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
||||||
.navigationTitle("开源组件")
|
.navigationTitle("开源组件")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
}
|
}
|
||||||
@@ -217,6 +221,8 @@ private struct SourceCodeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.readableContentWidth()
|
||||||
|
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
||||||
.navigationTitle("源码与构建说明")
|
.navigationTitle("源码与构建说明")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
}
|
}
|
||||||
@@ -240,6 +246,7 @@ private struct ComponentLicenseView: View {
|
|||||||
ForEach(component.documents) { document in
|
ForEach(component.documents) { document in
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
LicenseTextView(text: document.text)
|
LicenseTextView(text: document.text)
|
||||||
|
.readableContentWidth()
|
||||||
.navigationTitle(documentTitle(document.title))
|
.navigationTitle(documentTitle(document.title))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -248,6 +255,8 @@ private struct ComponentLicenseView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.readableContentWidth()
|
||||||
|
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
||||||
.navigationTitle(component.name)
|
.navigationTitle(component.name)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,17 @@ No Apple signing secrets are needed. Sign the app yourself before installing
|
|||||||
it on a device. Push any referenced MiniRTC commit before the parent repository
|
it on a device. Push any referenced MiniRTC commit before the parent repository
|
||||||
so the runner can check out all corresponding sources.
|
so the runner can check out all corresponding sources.
|
||||||
|
|
||||||
|
## iPad and adaptive layout
|
||||||
|
|
||||||
|
iPad supports portrait, upside-down portrait, landscape and resizable multitasking
|
||||||
|
windows on both the home screen and an active remote session. The home screen
|
||||||
|
uses two columns at a window width of 900 points or more, with the connection
|
||||||
|
form beside recent connections; smaller windows use a single column. Content is
|
||||||
|
centered at a maximum width of 1200 points, and recent-connection cards adapt to
|
||||||
|
the available space. Settings and reading pages are limited to 720 points.
|
||||||
|
Resizing preserves the connection fields and active session. iPhone retains its
|
||||||
|
portrait home screen and two-column recent-connection grid.
|
||||||
|
|
||||||
## App updates
|
## App updates
|
||||||
|
|
||||||
After network consent, the app checks the public version manifest once on each
|
After network consent, the app checks the public version manifest once on each
|
||||||
|
|||||||
Reference in New Issue
Block a user